185 lines
6.8 KiB
Python
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:]))
|