""" Admin commands (run on the server, next to the app): python manage.py add-user --name "Display Name" # prompts for password python manage.py set-password # prompts for password python manage.py list-users python manage.py deactivate-user python manage.py dedupe-files [--apply] # fix double-counted uploads There is deliberately no self-signup: the 5-or-so finance users are created here. `dedupe-files` is the one-time cleanup for the historical upload bug where re-uploading a file created a second session_files row pointing at the same stored file — which made the pipeline parse and sum that file twice. Run it once after deploying the fix; affected closings are flagged needs_reprocess so the corrected totals are one click away. """ from __future__ import annotations import argparse import getpass import sys from app.db.database import SessionLocal, init_db from app.db import models def _prompt_password() -> str: pw = getpass.getpass("Password: ") if len(pw) < 8: sys.exit("Password must be at least 8 characters.") if pw != getpass.getpass("Repeat password: "): sys.exit("Passwords do not match.") return pw def cmd_add_user(args) -> int: from app.api.auth import hash_password db = SessionLocal() try: username = args.username.strip().lower() if db.query(models.User).filter(models.User.username == username).first(): print(f"User '{username}' already exists — use set-password to change it.") return 1 pw = _prompt_password() db.add(models.User(username=username, display_name=(args.name or username).strip(), password_hash=hash_password(pw), is_active=True)) db.commit() print(f"Created user '{username}' ({args.name or username}). " f"Login is now required (AR_AUTH=auto turns on with the first user).") return 0 finally: db.close() def cmd_set_password(args) -> int: from app.api.auth import hash_password db = SessionLocal() try: user = db.query(models.User).filter( models.User.username == args.username.strip().lower()).first() if user is None: print(f"No user '{args.username}'.") return 1 user.password_hash = hash_password(_prompt_password()) user.is_active = True db.commit() print(f"Password updated for '{user.username}'.") return 0 finally: db.close() def cmd_list_users(_args) -> int: db = SessionLocal() try: rows = db.query(models.User).order_by(models.User.username).all() if not rows: print("No users yet — the API is open until the first one is created " "(AR_AUTH=auto).") return 0 for u in rows: flag = "" if u.is_active else " [DEACTIVATED]" print(f" {u.username:<20} {u.display_name}{flag}") return 0 finally: db.close() def cmd_deactivate_user(args) -> int: db = SessionLocal() try: user = db.query(models.User).filter( models.User.username == args.username.strip().lower()).first() if user is None: print(f"No user '{args.username}'.") return 1 user.is_active = False db.commit() print(f"Deactivated '{user.username}' — existing tokens stop working within " f"their normal expiry; new logins are refused immediately.") return 0 finally: db.close() def cmd_dedupe_files(args) -> int: """Collapse session_files rows that point at the same stored file (or share a name) within one closing. Keeps the NEWEST row (it matches the bytes on disk — uploads overwrote the file), deletes the rest, flags the closing for re-processing.""" db = SessionLocal() try: rows = db.query(models.SessionFile).order_by( models.SessionFile.session_id, models.SessionFile.id).all() by_key: dict[tuple, list[models.SessionFile]] = {} for f in rows: by_key.setdefault((f.session_id, f.stored_path or f.filename), []).append(f) dupes = {k: v for k, v in by_key.items() if len(v) > 1} if not dupes: print("No duplicated file rows found — nothing to do.") return 0 affected_sessions: set[int] = set() removed = 0 for (session_id, path), group in sorted(dupes.items()): keep = group[-1] # newest row matches the bytes on disk print(f"session {session_id}: '{keep.filename}' has {len(group)} rows " f"-> keeping id {keep.id}, " f"removing {[g.id for g in group if g.id != keep.id]}") for g in group: if g.id == keep.id: continue if args.apply: db.delete(g) removed += 1 affected_sessions.add(session_id) if not args.apply: print(f"\nDRY RUN: would remove {removed} duplicate row(s) across " f"{len(affected_sessions)} closing(s). Re-run with --apply to fix.") return 0 for sid in affected_sessions: s = db.get(models.Session, sid) if s is not None and s.status in ("processed", "blocked", "completed"): s.needs_reprocess = True db.commit() print(f"\nRemoved {removed} duplicate row(s). {len(affected_sessions)} closing(s) " f"flagged needs_reprocess — re-process them so the corrected totals persist.") return 0 finally: db.close() def main(argv: list[str]) -> int: ap = argparse.ArgumentParser(description="AR Aging admin commands") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("add-user", help="create a user (prompts for password)") p.add_argument("username") p.add_argument("--name", default="", help="display name (lands in sign-off fields)") p.set_defaults(fn=cmd_add_user) p = sub.add_parser("set-password", help="reset a user's password") p.add_argument("username") p.set_defaults(fn=cmd_set_password) p = sub.add_parser("list-users", help="list users") p.set_defaults(fn=cmd_list_users) p = sub.add_parser("deactivate-user", help="disable a user's login") p.add_argument("username") p.set_defaults(fn=cmd_deactivate_user) p = sub.add_parser("dedupe-files", help="fix double-counted duplicate upload rows") p.add_argument("--apply", action="store_true", help="actually delete (default: dry run)") p.set_defaults(fn=cmd_dedupe_files) args = ap.parse_args(argv) init_db() return args.fn(args) if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))