Files
gaokao-volunteer-system/data/orders/retention_cleanup.py
Hermes Agent 7fe4ddcbc4
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat: harden T12 backup and portal workflows
2026-06-14 23:12:18 +08:00

133 lines
3.8 KiB
Python

from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from data.orders.dao import OrdersDAO
from data.orders.deletion_service import OrderDeletionService
@dataclass
class RetentionCleanupResult:
cutoff_iso: str
dry_run: bool
scanned: int = 0
candidates: int = 0
anonymized: int = 0
_TERMINAL_STATUSES = {"completed", "refunded"}
def _parse_iso(ts: str | None) -> datetime | None:
if not ts:
return None
return datetime.fromisoformat(ts)
def _iter_candidates(db_path: str, cutoff_iso: str) -> tuple[int, list[str]]:
cutoff = datetime.fromisoformat(cutoff_iso)
scanned = 0
candidates: list[str] = []
offset = 0
page_size = 1000
while True:
with OrdersDAO.connect(db_path) as dao:
batch = dao.list(limit=page_size, offset=offset)
if not batch:
break
scanned += len(batch)
for order in batch:
if order.status not in _TERMINAL_STATUSES:
continue
anchor = _parse_iso(
order.completed_at or order.status_updated_at or order.created_at
)
if anchor is None:
continue
if anchor <= cutoff:
candidates.append(order.id)
if len(batch) < page_size:
break
offset += page_size
return scanned, candidates
def resolve_cutoff_iso(
*, cutoff_iso: str | None = None, retention_days: int | None = None
) -> str:
if cutoff_iso is not None:
datetime.fromisoformat(cutoff_iso)
return cutoff_iso
if retention_days is None or retention_days <= 0:
raise ValueError("retention_days must be > 0 when cutoff is not provided")
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
return cutoff.replace(microsecond=0).isoformat()
def run_cleanup(
db_path: str, *, cutoff_iso: str, apply: bool
) -> RetentionCleanupResult:
scanned, candidate_ids = _iter_candidates(db_path, cutoff_iso)
result = RetentionCleanupResult(
cutoff_iso=cutoff_iso,
dry_run=not apply,
scanned=scanned,
candidates=len(candidate_ids),
)
if not apply:
return result
service = OrderDeletionService.for_db(db_path)
try:
for order_id in candidate_ids:
service.anonymize_order(
order_id, actor="retention_cleanup", reason="retention_expired"
)
result.anonymized += 1
finally:
service.close()
return result
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run retention cleanup for old completed/refunded orders"
)
cutoff_group = parser.add_mutually_exclusive_group(required=True)
cutoff_group.add_argument(
"--cutoff",
help="ISO8601 cutoff; orders on or before this anchor are candidates",
)
cutoff_group.add_argument(
"--retention-days",
type=int,
help="Retention window in days; cutoff is computed as now - retention_days",
)
parser.add_argument("--dry-run", action="store_true")
return parser
def main(argv: list[str] | None = None, *, db_path: str | None = None) -> int:
if db_path is None:
from admin.config import load_settings
db_path = load_settings().orders_db_path
parser = build_parser()
args = parser.parse_args(argv)
try:
cutoff_iso = resolve_cutoff_iso(
cutoff_iso=args.cutoff,
retention_days=args.retention_days,
)
except ValueError as exc:
parser.error(str(exc))
result = run_cleanup(db_path, cutoff_iso=cutoff_iso, apply=not args.dry_run)
print(json.dumps(result.__dict__, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())