# apps/support/admin.py """ Django Admin for the Support domain. Models registered: - SupportTicket : Customer support tickets with priority/status management - TicketMessage : Conversation thread within a ticket (mostly read-only) - TicketEscalation : Escalations to senior admin/compliance Production rules: - Tickets are append-only audit-style records; status transitions via actions - Messages cannot be edited after creation (is_deleted flag for soft-purge) - Escalation assignment is superuser-only action - Bulk actions: Close Tickets, Escalate Tickets, Resolve Selected 2026 features: - Priority badge (Critical → Low) with colour coding - Status badge with full colour range - Date hierarchy for temporal analysis - show_full_result_count = False - Response time indicator (if resolved_at exists) - Staff assignment dropdown via raw_id_fields """ import logging from django.contrib import admin from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django.contrib import messages from django.utils import timezone from apps.support.models import SupportTicket, TicketMessage, TicketEscalation logger = logging.getLogger(__name__) # ── Priority colours ────────────────────────────────────────────────────────── _PRIORITY_COLOURS = { "critical": ("#dc2626", "#fff"), "high": ("#ef4444", "#fff"), "medium": ("#f59e0b", "#fff"), "low": ("#10b981", "#fff"), } # ── Status colours ──────────────────────────────────────────────────────────── _STATUS_COLOURS = { "open": ("#3b82f6", "#fff"), "in_progress": ("#8b5cf6", "#fff"), "resolved": ("#10b981", "#fff"), "closed": ("#6b7280", "#fff"), "escalated": ("#dc2626", "#fff"), "pending": ("#f59e0b", "#fff"), } # ── Inlines ─────────────────────────────────────────────────────────────────── class TicketMessageInline(admin.TabularInline): """Chronological message thread inside a ticket (read-only).""" model = TicketMessage fields = ["author", "body_preview", "is_staff_reply", "created_at"] readonly_fields = ["author", "body_preview", "is_staff_reply", "created_at"] extra = 0 can_delete = False ordering = ["created_at"] max_num = 50 def has_add_permission(self, request, obj=None): # Staff can reply via the normal API; not from admin to avoid confusion return False def has_change_permission(self, request, obj=None): return False @admin.display(description="Message") def body_preview(self, obj): return (obj.body or "")[:100] + ("…" if len(obj.body or "") > 100 else "") # ── Support Ticket Admin ────────────────────────────────────────────────────── @admin.register(SupportTicket) class SupportTicketAdmin(admin.ModelAdmin): list_display = [ "id", "submitter", "priority_badge", "status_badge", "category", "title_preview", "assigned_to", "response_time", "created_at", ] list_filter = [ "status", "priority", "category", ] search_fields = [ "title", "description", "submitter__email", "id", ] ordering = ["-created_at"] date_hierarchy = "created_at" list_select_related = ["submitter", "assigned_to"] raw_id_fields = ["submitter", "assigned_to"] list_per_page = 25 list_max_show_all = 200 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = [ "id", "created_at", "updated_at", "resolved_at", "closed_at", ] fieldsets = ( (_("Ticket"), { "fields": ( "id", "submitter", "category", "priority", "status", "title", ), }), (_("Description"), { "fields": ("description",), }), (_("Assignment"), { "fields": ("assigned_to", "internal_notes"), }), (_("Resolution"), { "fields": ("resolution_summary", "resolved_at", "closed_at"), }), (_("Timestamps"), { "fields": ("created_at", "updated_at"), "classes": ("collapse",), }), ) inlines = [TicketMessageInline] actions = [ "action_close_tickets", "action_resolve_tickets", "action_escalate_tickets", "action_assign_to_me", ] # ── List display helpers ───────────────────────────────────────────────── @admin.display(description="Priority") def priority_badge(self, obj): bg, fg = _PRIORITY_COLOURS.get(obj.priority, ("#6b7280", "#fff")) return format_html( '{}', bg, fg, obj.priority.upper(), ) @admin.display(description="Status") def status_badge(self, obj): bg, fg = _STATUS_COLOURS.get(obj.status, ("#6b7280", "#fff")) label = obj.get_status_display() if hasattr(obj, "get_status_display") else obj.status return format_html( '{}', bg, fg, label, ) @admin.display(description="Title") def title_preview(self, obj): return (obj.title or "")[:60] @admin.display(description="Response Time") def response_time(self, obj): if obj.resolved_at and obj.created_at: delta = obj.resolved_at - obj.created_at hours = int(delta.total_seconds() // 3600) if hours < 1: mins = int(delta.total_seconds() // 60) return format_html( '{}m', mins ) elif hours < 24: return format_html( '{}h', hours ) else: days = delta.days return format_html( '{}d', days ) return "—" # ── Admin actions ──────────────────────────────────────────────────────── @admin.action(description="✅ Mark selected tickets as Resolved") def action_resolve_tickets(self, request, queryset): now = timezone.now() updated = queryset.exclude( status__in=["resolved", "closed"] ).update(status="resolved", resolved_at=now) self.message_user( request, f"✅ {updated} ticket(s) marked as resolved.", level=messages.SUCCESS, ) @admin.action(description="🔒 Close selected tickets") def action_close_tickets(self, request, queryset): now = timezone.now() updated = queryset.exclude(status="closed").update( status="closed", closed_at=now ) self.message_user( request, f"🔒 {updated} ticket(s) closed.", level=messages.SUCCESS, ) @admin.action(description="🚨 Escalate selected tickets (superuser only)") def action_escalate_tickets(self, request, queryset): if not request.user.is_superuser: self.message_user(request, "Superuser only.", level=messages.ERROR) return updated = queryset.exclude(status="escalated").update(status="escalated") self.message_user( request, f"🚨 {updated} ticket(s) escalated.", level=messages.WARNING, ) @admin.action(description="👤 Assign selected tickets to me") def action_assign_to_me(self, request, queryset): updated = queryset.filter(assigned_to__isnull=True).update( assigned_to=request.user ) self.message_user( request, f"👤 {updated} ticket(s) assigned to you.", level=messages.SUCCESS, ) # ── Ticket Message Admin ────────────────────────────────────────────────────── @admin.register(TicketMessage) class TicketMessageAdmin(admin.ModelAdmin): list_display = [ "id", "ticket", "author", "staff_badge", "body_preview", "created_at", ] list_filter = ["is_staff_reply"] search_fields = ["body", "author__email", "ticket__id"] ordering = ["-created_at"] date_hierarchy = "created_at" list_select_related = ["ticket", "author"] raw_id_fields = ["ticket", "author"] list_per_page = 25 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = ["id", "created_at"] def has_add_permission(self, request): return False # Replies go through the API, not the admin def has_change_permission(self, request, obj=None): return False @admin.display(description="Staff Reply", boolean=True) def staff_badge(self, obj): return obj.is_staff_reply @admin.display(description="Message") def body_preview(self, obj): return (obj.body or "")[:80] + ("…" if len(obj.body or "") > 80 else "") # ── Ticket Escalation Admin ─────────────────────────────────────────────────── @admin.register(TicketEscalation) class TicketEscalationAdmin(admin.ModelAdmin): list_display = [ "id", "ticket", "status_badge", "escalated_by", "assigned_admin", "created_at", ] list_filter = ["status"] search_fields = [ "reason", "resolution_notes", "escalated_by__email", "assigned_admin__email", ] ordering = ["-created_at"] date_hierarchy = "created_at" list_select_related = ["ticket", "escalated_by", "assigned_admin"] raw_id_fields = ["ticket", "escalated_by", "assigned_admin"] list_per_page = 25 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = ["id", "created_at", "updated_at"] fieldsets = ( (_("Escalation"), { "fields": ( "ticket", "escalated_by", "reason", "assigned_admin", "status", ), }), (_("Resolution"), { "fields": ("resolution_notes",), }), (_("Timestamps"), { "fields": ("created_at", "updated_at"), "classes": ("collapse",), }), ) actions = ["action_resolve_escalations"] @admin.display(description="Status") def status_badge(self, obj): bg, fg = _STATUS_COLOURS.get(obj.status, ("#6b7280", "#fff")) label = obj.get_status_display() if hasattr(obj, "get_status_display") else obj.status return format_html( '{}', bg, fg, label, ) @admin.action(description="✅ Resolve selected escalations (superuser only)") def action_resolve_escalations(self, request, queryset): if not request.user.is_superuser: self.message_user(request, "Superuser only.", level=messages.ERROR) return updated = queryset.exclude(status="resolved").update(status="resolved") self.message_user( request, f"✅ {updated} escalation(s) resolved.", level=messages.SUCCESS, )