154 lines
6.3 KiB
Python
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}")
|