from django.conf import settings from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone from apps.core.choices.fields import RichChoiceField from apps.core.history import TrackedModel from apps.core.state_machine import RichFSMField, StateMachineMixin # Import choices to ensure registration on app load from . import choices # noqa: F401 class Ticket(StateMachineMixin, TrackedModel): """ Support ticket model with FSM-managed status transitions. Status workflow: open -> in_progress -> closed -> open (reopen) """ state_field_name = "status" user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="tickets", help_text="User who submitted the ticket (optional)", ) # Submitter info (for anonymous/guest tickets) name = models.CharField( max_length=255, blank=True, help_text="Name of the submitter (for anonymous tickets)", ) category = RichChoiceField( choice_group="ticket_categories", domain="support", max_length=20, default="general", db_index=True, help_text="Category of the ticket", ) subject = models.CharField(max_length=255) message = models.TextField() email = models.EmailField(help_text="Contact email", blank=True) # FSM-managed status field status = RichFSMField( choice_group="ticket_statuses", domain="support", max_length=20, default="open", db_index=True, ) # Human-readable ticket number (e.g., TKT-00001) ticket_number = models.CharField( max_length=20, unique=True, blank=True, help_text="Human-readable ticket number", ) # Admin management fields admin_notes = models.TextField( blank=True, help_text="Internal notes for administrators", ) assigned_to = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tickets", help_text="Staff member assigned to this ticket", ) # Resolution tracking resolved_at = models.DateTimeField( null=True, blank=True, help_text="When the ticket was resolved", ) resolved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_tickets", help_text="Staff member who resolved this ticket", ) # Archive functionality archived_at = models.DateTimeField( null=True, blank=True, help_text="When the ticket was archived", ) archived_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="archived_tickets", help_text="Staff member who archived this ticket", ) class Meta(TrackedModel.Meta): verbose_name = "Ticket" verbose_name_plural = "Tickets" ordering = ["-created_at"] indexes = [ models.Index(fields=["status", "created_at"]), models.Index(fields=["ticket_number"]), models.Index(fields=["archived_at"]), ] def __str__(self): return f"[{self.ticket_number}] {self.subject}" def save(self, *args, **kwargs): # If user is set but email is empty, autofill from user if self.user and not self.email: self.email = self.user.email # Generate ticket number if not set if not self.ticket_number: self.ticket_number = self._generate_ticket_number() super().save(*args, **kwargs) def _generate_ticket_number(self): """Generate a unique ticket number like TKT-00001.""" import uuid # Use last 8 chars of a UUID for uniqueness suffix = uuid.uuid4().hex[:8].upper() return f"TKT-{suffix}" # ========================================================================= # FSM Transition Methods # ========================================================================= def start_progress(self, user=None) -> None: """ Start working on this ticket. Transition: open -> in_progress Args: user: The staff member starting work on the ticket """ if self.status != "open": raise ValidationError( f"Cannot start progress: current status is {self.status}, expected open" ) self.status = "in_progress" if user and user.is_staff: self.assigned_to = user self.save() def close(self, user=None, notes: str = "") -> None: """ Close/resolve this ticket. Transition: in_progress -> closed Args: user: The staff member closing the ticket notes: Optional resolution notes """ if self.status not in ("open", "in_progress"): raise ValidationError( f"Cannot close: current status is {self.status}, expected open or in_progress" ) self.status = "closed" self.resolved_at = timezone.now() if user: self.resolved_by = user if notes: self.admin_notes = f"{self.admin_notes}\n\n[CLOSED] {notes}".strip() self.save() def reopen(self, user=None, reason: str = "") -> None: """ Reopen a closed ticket. Transition: closed -> open Args: user: The staff member reopening the ticket reason: Reason for reopening """ if self.status != "closed": raise ValidationError( f"Cannot reopen: current status is {self.status}, expected closed" ) self.status = "open" self.resolved_at = None self.resolved_by = None if reason: self.admin_notes = f"{self.admin_notes}\n\n[REOPENED] {reason}".strip() self.save() def archive(self, user=None, reason: str = "") -> None: """ Archive this ticket. Can be called from any status. Args: user: The staff member archiving the ticket reason: Reason for archiving """ if self.archived_at: raise ValidationError("Ticket is already archived") self.archived_at = timezone.now() if user: self.archived_by = user if reason: self.admin_notes = f"{self.admin_notes}\n\n[ARCHIVED] {reason}".strip() self.save() def unarchive(self, user=None) -> None: """ Restore an archived ticket. Args: user: The staff member unarchiving the ticket """ if not self.archived_at: raise ValidationError("Ticket is not archived") self.archived_at = None self.archived_by = None self.admin_notes = f"{self.admin_notes}\n\n[UNARCHIVED] by {user.username if user else 'system'}".strip() self.save() # ========================================================================= # Computed Properties # ========================================================================= @property def thread_id(self): """Return the ID for email thread association.""" return str(self.id) @property def response_count(self): """Return number of email thread responses.""" return self.email_threads.count() @property def last_admin_response_at(self): """Return timestamp of last admin response.""" last_outbound = self.email_threads.filter(direction="outbound").order_by("-created_at").first() return last_outbound.created_at if last_outbound else None class EmailThread(TrackedModel): """Email thread entries for ticket conversations.""" ticket = models.ForeignKey( Ticket, on_delete=models.CASCADE, related_name="email_threads", help_text="Associated support ticket", ) message_id = models.CharField( max_length=255, blank=True, help_text="Email message ID for threading", ) from_email = models.EmailField(help_text="Sender email address") to_email = models.EmailField(help_text="Recipient email address") subject = models.CharField(max_length=255) body_text = models.TextField(help_text="Plain text email body") direction = RichChoiceField( choice_group="email_directions", domain="support", max_length=10, help_text="Whether email is inbound or outbound", ) sent_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="sent_email_threads", help_text="Staff member who sent this email (for outbound)", ) class Meta(TrackedModel.Meta): verbose_name = "Email Thread" verbose_name_plural = "Email Threads" ordering = ["created_at"] def __str__(self): return f"[{self.direction}] {self.subject}" class Report(TrackedModel): """ User-submitted reports about content issues. Reports allow users to flag problems with specific entities (parks, rides, reviews, etc.) for moderator review. """ # Reporter (optional for anonymous reports) reporter = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="submitted_reports", help_text="User who submitted the report", ) # Target entity using GenericForeignKey content_type = models.ForeignKey( "contenttypes.ContentType", on_delete=models.CASCADE, help_text="Type of entity being reported", ) object_id = models.CharField( max_length=50, help_text="ID of the entity being reported", ) # Note: GenericForeignKey doesn't create a database column # It's a convenience for accessing the related object # content_object = GenericForeignKey("content_type", "object_id") # Report details - now using RichChoiceField report_type = RichChoiceField( choice_group="report_types", domain="support", max_length=20, db_index=True, help_text="Type of issue being reported", ) reason = models.TextField( help_text="Detailed description of the issue", ) status = RichChoiceField( choice_group="report_statuses", domain="support", max_length=20, default="pending", db_index=True, help_text="Current status of the report", ) # Resolution resolved_at = models.DateTimeField( null=True, blank=True, help_text="When the report was resolved", ) resolved_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="resolved_reports", help_text="Moderator who resolved the report", ) resolution_notes = models.TextField( blank=True, help_text="Notes about how the report was resolved", ) class Meta(TrackedModel.Meta): verbose_name = "Report" verbose_name_plural = "Reports" ordering = ["-created_at"] indexes = [ models.Index(fields=["status", "created_at"]), models.Index(fields=["content_type", "object_id"]), models.Index(fields=["report_type", "created_at"]), ] def __str__(self): return f"[{self.get_report_type_display()}] {self.content_type} #{self.object_id}" @property def is_resolved(self) -> bool: return self.status in ("resolved", "dismissed")