# apps/chat/admin.py """ Django Admin for the Chat domain. Models registered: - Conversation : Buyer ↔ Vendor conversation threads - Message : Individual messages (append-only audit view) - MessageMedia : Media attachments (read-only) - ChatOffer : Price/offer negotiations inside a conversation - ModerationFlag : User-reported content (moderation queue) - ChatEscalation : Escalated conversations needing admin attention Production rules: - Message records are effectively immutable (soft-delete only via is_deleted) - Offer amounts are read-only (financial record) - ModerationFlag review action gated to staff - Escalation assignment action available 2026 features: - Unread count badges in Conversation list - Message body preview truncated to 80 chars - Offer status colour badge - Moderation quick-review bulk action - date_hierarchy on all time-series admins - show_full_result_count = False (chat tables grow fast) """ 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 apps.chat.models import ( Conversation, Message, MessageMedia, ChatOffer, ModerationFlag, ChatEscalation, ) logger = logging.getLogger(__name__) # ── Inlines ─────────────────────────────────────────────────────────────────── class MessageInline(admin.TabularInline): """Read-only inline showing the last N messages in a conversation.""" model = Message fields = [ "author", "message_type", "body_preview_inline", "is_read_by_buyer", "is_read_by_vendor", "is_deleted", "created_at", ] readonly_fields = [ "author", "message_type", "body_preview_inline", "is_read_by_buyer", "is_read_by_vendor", "is_deleted", "created_at", ] extra = 0 can_delete = False max_num = 20 show_change_link = True ordering = ["-created_at"] def has_add_permission(self, request, obj=None): return False def has_change_permission(self, request, obj=None): return False @admin.display(description="Body") def body_preview_inline(self, obj): return (obj.body or "")[:80] + ("…" if len(obj.body or "") > 80 else "") class ChatOfferInline(admin.TabularInline): """Read-only offer summary inline inside a conversation.""" model = ChatOffer fields = [ "product_title_snapshot", "offered_price", "currency", "status", "responded_at", ] readonly_fields = [ "product_title_snapshot", "offered_price", "currency", "status", "responded_at", ] extra = 0 can_delete = False def has_add_permission(self, request, obj=None): return False def has_change_permission(self, request, obj=None): return False # ── Conversation Admin ──────────────────────────────────────────────────────── @admin.register(Conversation) class ConversationAdmin(admin.ModelAdmin): list_display = [ "id", "buyer", "vendor", "status_badge", "product_title_snapshot", "unread_buyer_count", "unread_vendor_count", "last_message_at", ] list_filter = ["status"] search_fields = [ "buyer__email", "vendor__email", "product_title_snapshot", ] ordering = ["-last_message_at"] date_hierarchy = "last_message_at" list_select_related = ["buyer", "vendor"] raw_id_fields = ["buyer", "vendor"] list_per_page = 25 list_max_show_all = 200 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = ["id", "last_message_at", "created_at", "updated_at"] fieldsets = ( (_("Parties"), { "fields": ("buyer", "vendor"), }), (_("Context"), { "fields": ("product_title_snapshot", "product_snapshot_id", "status"), }), (_("Read Counts"), { "fields": ("unread_buyer_count", "unread_vendor_count"), }), (_("Timestamps"), { "fields": ("last_message_at", "created_at", "updated_at"), "classes": ("collapse",), }), ) inlines = [MessageInline, ChatOfferInline] @admin.display(description="Status") def status_badge(self, obj): colours = { "active": ("#10b981", "#fff"), "closed": ("#6b7280", "#fff"), "blocked": ("#ef4444", "#fff"), "archived": ("#94a3b8", "#fff"), } bg, fg = colours.get(obj.status, ("#6b7280", "#fff")) return format_html( '{}', bg, fg, obj.status.upper(), ) # ── Message Admin ───────────────────────────────────────────────────────────── @admin.register(Message) class MessageAdmin(admin.ModelAdmin): list_display = [ "id", "conversation", "author", "message_type_badge", "body_preview", "is_deleted", "created_at", ] list_filter = ["message_type", "is_deleted"] search_fields = ["author__email", "body"] ordering = ["-created_at"] date_hierarchy = "created_at" list_select_related = ["conversation", "author"] raw_id_fields = ["conversation", "author"] 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"] fieldsets = ( (_("Message"), { "fields": ( "conversation", "author", "message_type", "body", "is_deleted", ), }), (_("Read Status"), { "fields": ("is_read_by_buyer", "is_read_by_vendor"), }), (_("Timestamps"), { "fields": ("created_at", "updated_at"), "classes": ("collapse",), }), ) @admin.display(description="Body") def body_preview(self, obj): return (obj.body or "")[:80] + ("…" if len(obj.body or "") > 80 else "") @admin.display(description="Type") def message_type_badge(self, obj): colours = { "text": ("#6366f1", "#fff"), "image": ("#10b981", "#fff"), "offer": ("#f59e0b", "#fff"), "file": ("#3b82f6", "#fff"), "system": ("#94a3b8", "#fff"), } bg, fg = colours.get(obj.message_type, ("#6b7280", "#fff")) return format_html( '{}', bg, fg, obj.message_type.upper(), ) # ── Message Media Admin ─────────────────────────────────────────────────────── @admin.register(MessageMedia) class MessageMediaAdmin(admin.ModelAdmin): list_display = ["id", "message", "media_type", "thumbnail_preview", "created_at"] list_filter = ["media_type"] search_fields = ["message__id", "cloudinary_url"] ordering = ["-created_at"] date_hierarchy = "created_at" list_select_related = ["message"] list_per_page = 25 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = [ f.name for f in MessageMedia._meta.get_fields() if hasattr(f, "name") ] def has_add_permission(self, request): return False def has_change_permission(self, request, obj=None): return False @admin.display(description="Preview") def thumbnail_preview(self, obj): url = getattr(obj, "cloudinary_url", None) if url and obj.media_type in ("image",): return format_html( '', url, ) return "—" # ── Chat Offer Admin ────────────────────────────────────────────────────────── @admin.register(ChatOffer) class ChatOfferAdmin(admin.ModelAdmin): list_display = [ "id", "conversation", "product_title_snapshot", "offered_price_display", "currency", "status_badge", "created_at", ] list_filter = ["status", "currency"] search_fields = [ "conversation__buyer__email", "conversation__vendor__email", "product_title_snapshot", ] ordering = ["-created_at"] date_hierarchy = "created_at" list_select_related = ["conversation"] list_per_page = 25 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = [ "id", "offered_price", "currency", "created_at", "updated_at", "responded_at", ] fieldsets = ( (_("Offer"), { "fields": ( "conversation", "product_title_snapshot", "offered_price", "currency", "status", ), }), (_("Product Snapshot"), { "fields": ("product_snapshot_id",), "classes": ("collapse",), }), (_("Timeline"), { "fields": ("responded_at", "created_at", "updated_at"), "classes": ("collapse",), }), ) @admin.display(description="Status") def status_badge(self, obj): colours = { "pending": ("#f59e0b", "#fff"), "accepted": ("#10b981", "#fff"), "rejected": ("#ef4444", "#fff"), "expired": ("#94a3b8", "#fff"), } bg, fg = colours.get(obj.status, ("#6b7280", "#fff")) return format_html( '{}', bg, fg, obj.status.upper(), ) @admin.display(description="Amount") def offered_price_display(self, obj): return format_html( '₦{:,.2f}', obj.offered_price ) # ── Moderation Flag Admin ───────────────────────────────────────────────────── @admin.register(ModerationFlag) class ModerationFlagAdmin(admin.ModelAdmin): list_display = [ "id", "conversation", "reported_by", "reason", "reviewed_badge", "created_at", ] list_filter = ["reason", "is_reviewed"] search_fields = [ "conversation__buyer__email", "reported_by__email", "notes", ] ordering = ["is_reviewed", "-created_at"] date_hierarchy = "created_at" list_select_related = ["conversation", "reported_by", "reviewed_by"] raw_id_fields = ["conversation", "reported_by"] list_per_page = 25 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = ["id", "created_at", "updated_at", "reviewed_at"] fieldsets = ( (_("Flag"), { "fields": ( "conversation", "reported_by", "reason", "notes", ), }), (_("Review"), { "fields": ( "is_reviewed", "reviewed_by", "review_action", "reviewed_at", ), }), (_("Timestamps"), { "fields": ("created_at", "updated_at"), "classes": ("collapse",), }), ) actions = ["action_mark_reviewed"] @admin.display(description="Reviewed", boolean=True) def reviewed_badge(self, obj): return obj.is_reviewed @admin.action(description="✅ Mark selected flags as reviewed") def action_mark_reviewed(self, request, queryset): from django.utils import timezone updated = queryset.filter(is_reviewed=False).update( is_reviewed=True, reviewed_by=request.user, reviewed_at=timezone.now(), ) self.message_user( request, f"✅ {updated} flag(s) marked as reviewed.", level=messages.SUCCESS, ) # ── Chat Escalation Admin ───────────────────────────────────────────────────── @admin.register(ChatEscalation) class ChatEscalationAdmin(admin.ModelAdmin): list_display = [ "id", "conversation", "assigned_admin", "status_badge", "resolved_at", "created_at", ] list_filter = ["status"] search_fields = [ "conversation__buyer__email", "assigned_admin__email", "reason", ] ordering = ["status", "-created_at"] date_hierarchy = "created_at" list_select_related = ["conversation", "assigned_admin"] raw_id_fields = ["conversation", "assigned_admin"] list_per_page = 25 show_full_result_count = False empty_value_display = "-N/A-" readonly_fields = ["id", "created_at", "updated_at", "resolved_at"] fieldsets = ( (_("Escalation"), { "fields": ( "conversation", "reason", "assigned_admin", "status", ), }), (_("Resolution"), { "fields": ("resolution_notes", "resolved_at"), }), (_("Timestamps"), { "fields": ("created_at", "updated_at"), "classes": ("collapse",), }), ) actions = ["action_resolve"] @admin.display(description="Status") def status_badge(self, obj): colours = { "open": ("#f59e0b", "#fff"), "resolved": ("#10b981", "#fff"), "closed": ("#6b7280", "#fff"), } bg, fg = colours.get(obj.status, ("#6b7280", "#fff")) return format_html( '{}', bg, fg, obj.status.upper(), ) @admin.action(description="✅ Mark selected escalations as Resolved") def action_resolve(self, request, queryset): if not request.user.is_staff: self.message_user(request, "Staff only.", level=messages.ERROR) return from django.utils import timezone updated = queryset.exclude(status="resolved").update( status="resolved", resolved_at=timezone.now(), ) self.message_user( request, f"✅ {updated} escalation(s) resolved.", level=messages.SUCCESS, )