Created
February 27, 2026 13:09
-
-
Save jvalente/65e3bc34fcf11b0ca202afb737e7f054 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python | |
| """ | |
| Fix users in the "not_found" state: user.inbox_project_id is set but no project | |
| with is_user_inbox=True exists for that user on any shard. | |
| For each user: | |
| 1. Look up the project referenced by user.inbox_project_id on its shard. | |
| 2. If found: mark it as the user's inbox (set is_user_inbox=True on the model). | |
| 3. If not found: clear stale refs on the user, then create a new inbox project. | |
| Usage: | |
| $ DD_TRACE_ENABLED=0 python -m scripts.fix_inbox_not_found [OPTIONS] | |
| Options: | |
| --input-file PATH File with one user ID per line | |
| --user-id ID Process a single user ID | |
| --dry-run/--no-dry-run Default: dry-run (verify only, no changes) | |
| """ | |
| from __future__ import annotations | |
| import os | |
| os.environ.setdefault("DD_TRACE_ENABLED", "0") | |
| from pathlib import Path | |
| import click | |
| from sqlalchemy.orm import close_all_sessions | |
| from todoist.init import init_todoist | |
| if __name__ == "__main__": | |
| init_todoist(expose_modules=[]) | |
| # isort: split | |
| from todoist.alchemy.contextmanagers import begin_if_necessary | |
| from todoist.sharing_v2.apps.projects._core.models import ProjectModel | |
| from todoist.util.logging import getLogger | |
| from todoist.models import project_inbox, users | |
| from todoist.apps.sessions import change_uid_ctx | |
| logger = getLogger(__name__) | |
| def _fix_user(user_id: int, dry_run: bool) -> tuple[str, str]: | |
| """Fix a single user. Returns (action, detail).""" | |
| user = users.get_by_id(user_id, get_dummy=True) | |
| if user is None: | |
| return "skipped", "User not found" | |
| inbox_project_id = user.inbox_project_id | |
| if inbox_project_id is None: | |
| return "skipped", "No inbox_project_id set" | |
| # Try to find the referenced project on its shard | |
| session = ProjectModel.sessions.by_project_id(inbox_project_id)() | |
| project_model = session.get(ProjectModel, inbox_project_id) | |
| if project_model is not None: | |
| # Project exists — mark it as the inbox | |
| if project_model.is_user_inbox: | |
| return "skipped", "Project already has is_user_inbox=True" | |
| if dry_run: | |
| return ( | |
| "would_restore_flag", | |
| f"Would set is_user_inbox=True on {inbox_project_id}", | |
| ) | |
| with begin_if_necessary(session): | |
| project_model.is_user_inbox = True | |
| return "flag_restored", f"Set is_user_inbox=True on {inbox_project_id}" | |
| # Project doesn't exist — create a new inbox | |
| if dry_run: | |
| return ( | |
| "would_create_new", | |
| f"Project {inbox_project_id} not found; would create new inbox", | |
| ) | |
| # Clear stale refs so add_user_inbox() won't raise FORBIDDEN | |
| users.update( | |
| user, | |
| auth_needed=False, | |
| inbox_project=None, | |
| inbox_project_id=None, | |
| ) | |
| # Reload user after clearing refs | |
| user = users.get_by_id(user_id, get_dummy=True) | |
| if user is None: | |
| return "error", "User disappeared after clearing refs" | |
| with change_uid_ctx(user.id): | |
| project_inbox.ensure_valid_inbox(user) | |
| new_inbox_id = user.inbox_project_id | |
| return "new_inbox_created", f"Old: {inbox_project_id}; New: {new_inbox_id}" | |
| @click.command() | |
| @click.option( | |
| "--input-file", type=click.Path(exists=True), help="File with one user ID per line" | |
| ) | |
| @click.option("--user-id", type=int, help="Process a single user ID") | |
| @click.option("--dry-run/--no-dry-run", default=True, help="Dry run (default: true)") | |
| def main(input_file: str | None, user_id: int | None, dry_run: bool) -> None: | |
| """Fix not_found inbox project inconsistencies.""" | |
| if not input_file and user_id is None: | |
| raise click.UsageError("Provide --input-file or --user-id") | |
| user_ids: list[int] = [] | |
| if user_id is not None: | |
| user_ids = [user_id] | |
| elif input_file: | |
| with Path(input_file).open() as f: | |
| for raw_line in f: | |
| stripped = raw_line.strip() | |
| if stripped and not stripped.startswith("#"): | |
| user_ids.append(int(stripped)) | |
| mode_label = "DRY RUN" if dry_run else "LIVE" | |
| click.echo(f"Processing {len(user_ids)} users ({mode_label})") | |
| counts: dict[str, int] = {} | |
| for i, uid in enumerate(user_ids): | |
| if i > 0 and i % 100 == 0: | |
| close_all_sessions() | |
| click.echo(f"Progress: {i}/{len(user_ids)}") | |
| try: | |
| action, detail = _fix_user(uid, dry_run) | |
| except Exception as e: | |
| action = "error" | |
| detail = f"{type(e).__name__}: {e}" | |
| logger.exception("Error fixing user %d", uid) | |
| counts[action] = counts.get(action, 0) + 1 | |
| click.echo(f"user_id={uid} action={action} detail={detail}") | |
| click.echo(f"\nDone. {len(user_ids)} users processed.") | |
| for action, count in sorted(counts.items()): | |
| click.echo(f" {action}: {count}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment