""" 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}")