mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 10:25:18 -05:00
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.
396 lines
12 KiB
Python
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")
|
|
|
|
|