Skip to content

Instantly share code, notes, and snippets.

@jvalente
Created February 27, 2026 13:09
Show Gist options
  • Select an option

  • Save jvalente/65e3bc34fcf11b0ca202afb737e7f054 to your computer and use it in GitHub Desktop.

Select an option

Save jvalente/65e3bc34fcf11b0ca202afb737e7f054 to your computer and use it in GitHub Desktop.
#!/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