Files
thrillwiki_django_no_react/backend/apps/support/models.py
pacnpal d631f3183c Based on the git diff provided, here's a concise and descriptive commit message:
feat: add passkey authentication and enhance user preferences

- Add passkey login security event type with fingerprint icon
- Include request and site context in email confirmation for backend
- Add user_id exact match filter to prevent incorrect user lookups
- Enable PATCH method for updating user preferences via API
- Add moderation_preferences support to user settings
- Optimize ticket queries with select_related and prefetch_related

This commit introduces passkey authentication tracking, improves user
profile filtering accuracy, and extends the preferences API to support
updates. Query optimizations reduce database hits for ticket listings.
2026-01-12 19:13:05 -05:00

396 lines
12 KiB
Python

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