Finance-Accounts/ar-aging-app/backend/manage.py

185 lines
6.8 KiB
Python

"""
Admin commands (run on the server, next to the app):
python manage.py add-user <username> --name "Display Name" # prompts for password
python manage.py set-password <username> # prompts for password
python manage.py list-users
python manage.py deactivate-user <username>
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:]))