HH/apps/complaints/management/commands/close_stale_complaints.py
2026-07-11 19:24:28 +03:00

154 lines
6.3 KiB
Python

"""
Bulk-close stale complaints.
Closes complaints older than a cutoff that are still in an active (non-terminal)
status, without triggering patient-facing side effects (no resolution-survey
emails/SMS, no per-instance notifications) — the canonical ComplaintService
change_status() path is deliberately bypassed for this administrative cleanup.
An audit trail is still written: one ComplaintUpdate per complaint + one
batch AuditEvent.
Usage:
python manage.py close_stale_complaints --dry-run
python manage.py close_stale_complaints
python manage.py close_stale_complaints --before 2026-01-01 --user ismail@tenhal.sa
"""
from datetime import datetime
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from django.utils import timezone
from apps.complaints.models import Complaint, ComplaintStatus, ComplaintUpdate
from apps.core.services import AuditService
User = get_user_model()
DEFAULT_BEFORE = "2026-01-01"
DEFAULT_USER_EMAIL = "ismail@tenhal.sa"
ACTIVE_STATUSES = [ComplaintStatus.OPEN, ComplaintStatus.IN_PROGRESS]
class Command(BaseCommand):
help = "Bulk-close stale complaints older than a cutoff (default: pre-2026, open/in_progress)."
def add_arguments(self, parser):
parser.add_argument("--before", default=DEFAULT_BEFORE,
help="Cutoff date (YYYY-MM-DD). Complaints created before this are eligible.")
parser.add_argument("--user", default=DEFAULT_USER_EMAIL,
help="Email/username to attribute the close to (default: %(default)s).")
parser.add_argument("--note", default="Bulk close: stale complaint (administrative cleanup)",
help="Audit note attached to each closed complaint.")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be closed without changing anything.")
def _resolve_user(self, ref):
for field in ("email", "username"):
try:
return User.objects.get(**{field: ref})
except User.DoesNotExist:
continue
try:
return User.objects.get(pk=ref)
except (User.DoesNotExist, ValueError):
pass
user = User.objects.filter(is_superuser=True).order_by("id").first()
if user:
self.stdout.write(self.style.WARNING(
f"User '{ref}' not found; falling back to superuser {user.email or user.username}."))
return user
raise CommandError(f"Could not resolve a user for '{ref}' and no superuser exists.")
def handle(self, *args, **options):
before_str = options["before"]
try:
naive = datetime.strptime(before_str + " 00:00:00", "%Y-%m-%d %H:%M:%S")
except ValueError as exc:
raise CommandError(f"--before must be YYYY-MM-DD: {exc}") from exc
before = timezone.make_aware(naive, timezone.get_current_timezone())
before_label = before_str
user = self._resolve_user(options["user"])
note = options["note"]
dry_run = options["dry_run"]
qs = Complaint.objects.filter(created_at__lt=before, status__in=ACTIVE_STATUSES)
total = qs.count()
self.stdout.write(self.style.MIGRATE_HEADING(
f"close_stale_complaints (before={before_label}, user={user.email or user.username}, "
f"dry_run={dry_run})"))
self.stdout.write(f"Eligible complaints: {total}")
if total == 0:
self.stdout.write(self.style.SUCCESS("Nothing to do."))
return
# Breakdown
self.stdout.write("\nBy current status:")
for row in qs.values("status").annotate(n=Count("id")).order_by("-n"):
self.stdout.write(f" {row['status']:<20} {row['n']}")
self.stdout.write("\nBy year (created_at):")
for row in qs.extra(select={"y": "date_part('year', created_at)::int"}
).values("y").annotate(n=Count("id")).order_by("y"):
self.stdout.write(f" {int(row['y'])} {row['n']}")
self.stdout.write("\nSample (5 oldest):")
for c in qs.order_by("created_at")[:5]:
self.stdout.write(f" id={c.id} status={c.status} created={c.created_at.date()}")
if dry_run:
self.stdout.write(self.style.WARNING("\nDRY-RUN: no changes made. Re-run without --dry-run to apply."))
return
now = timezone.now()
with transaction.atomic():
# Capture targets + old status for audit before the UPDATE
targets = list(qs.values_list("id", "status"))
ids = [t[0] for t in targets]
updated = qs.update(
status=ComplaintStatus.CLOSED,
closed_at=now,
closed_by=user,
updated_at=now,
)
# Audit per complaint (bulk_create bypasses signals → no notifications)
ComplaintUpdate.objects.bulk_create([
ComplaintUpdate(
complaint_id=cid,
update_type="status_change",
message=note,
old_status=old,
new_status=ComplaintStatus.CLOSED,
created_by=user,
metadata={"bulk_close": True, "source": "close_stale_complaints"},
)
for cid, old in targets
])
# Batch audit event — kept OUTSIDE atomic so a logging failure can never
# roll back the data change above. Uses a valid AuditEvent.EVENT_TYPES value.
AuditService.log_event(
event_type="complaint_closed",
description=(
f"Bulk closed {updated} stale complaints (created before {before_label}) "
f"by {user.email or user.username}"
),
user=user,
metadata={
"count": updated,
"before": before_label,
"complaint_ids": ids,
"note": note,
},
)
self.stdout.write(self.style.SUCCESS(f"\nClosed {updated} complaint(s)."))
remaining = Complaint.objects.filter(created_at__lt=before, status__in=ACTIVE_STATUSES).count()
self.stdout.write(f"Remaining eligible (open/in_progress before {before_label}): {remaining}")