mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 04:05:25 -05:00
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.
This commit is contained in:
@@ -145,6 +145,179 @@ TICKET_CATEGORIES = [
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Report Type Choices (for user-submitted reports about content issues)
|
||||
# ============================================================================
|
||||
REPORT_TYPES = [
|
||||
RichChoice(
|
||||
value="inaccurate",
|
||||
label="Inaccurate Information",
|
||||
description="Information is factually incorrect",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "alert-circle",
|
||||
"css_class": "bg-orange-100 text-orange-800 border-orange-200",
|
||||
"sort_order": 1,
|
||||
"requires_evidence": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="inappropriate",
|
||||
label="Inappropriate Content",
|
||||
description="Content is offensive or inappropriate",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "flag",
|
||||
"css_class": "bg-red-100 text-red-800 border-red-200",
|
||||
"sort_order": 2,
|
||||
"requires_evidence": False,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="spam",
|
||||
label="Spam",
|
||||
description="Content is spam or promotional",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "mail-x",
|
||||
"css_class": "bg-purple-100 text-purple-800 border-purple-200",
|
||||
"sort_order": 3,
|
||||
"requires_evidence": False,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="copyright",
|
||||
label="Copyright Violation",
|
||||
description="Content violates copyright",
|
||||
metadata={
|
||||
"color": "indigo",
|
||||
"icon": "shield-alert",
|
||||
"css_class": "bg-indigo-100 text-indigo-800 border-indigo-200",
|
||||
"sort_order": 4,
|
||||
"requires_evidence": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="duplicate",
|
||||
label="Duplicate Content",
|
||||
description="Content duplicates existing entry",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "copy",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"sort_order": 5,
|
||||
"requires_evidence": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="other",
|
||||
label="Other",
|
||||
description="Other issue not covered above",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "help-circle",
|
||||
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
"sort_order": 6,
|
||||
"requires_evidence": False,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Report Status Choices
|
||||
# ============================================================================
|
||||
REPORT_STATUSES = [
|
||||
RichChoice(
|
||||
value="pending",
|
||||
label="Pending",
|
||||
description="Report is awaiting review",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "clock",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"sort_order": 1,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="investigating",
|
||||
label="Investigating",
|
||||
description="Report is being investigated",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "search",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"sort_order": 2,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="resolved",
|
||||
label="Resolved",
|
||||
description="Report has been resolved with action taken",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800 border-green-200",
|
||||
"sort_order": 3,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="dismissed",
|
||||
label="Dismissed",
|
||||
description="Report was dismissed as invalid or duplicate",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
"sort_order": 4,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Email Direction Choices
|
||||
# ============================================================================
|
||||
EMAIL_DIRECTIONS = [
|
||||
RichChoice(
|
||||
value="inbound",
|
||||
label="Inbound",
|
||||
description="Email received from user",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "arrow-down-left",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="outbound",
|
||||
label="Outbound",
|
||||
description="Email sent to user",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "arrow-up-right",
|
||||
"css_class": "bg-green-100 text-green-800 border-green-200",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_support_choices() -> None:
|
||||
"""Register all support domain choices with the global registry."""
|
||||
@@ -160,7 +333,26 @@ def register_support_choices() -> None:
|
||||
domain="support",
|
||||
description="Category options for support tickets",
|
||||
)
|
||||
register_choices(
|
||||
"report_types",
|
||||
REPORT_TYPES,
|
||||
domain="support",
|
||||
description="Type options for user-submitted reports",
|
||||
)
|
||||
register_choices(
|
||||
"report_statuses",
|
||||
REPORT_STATUSES,
|
||||
domain="support",
|
||||
description="Status options for user-submitted reports",
|
||||
)
|
||||
register_choices(
|
||||
"email_directions",
|
||||
EMAIL_DIRECTIONS,
|
||||
domain="support",
|
||||
description="Direction options for email threads",
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_support_choices()
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 22:01
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0004_alter_ticket_category_alter_ticket_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="report",
|
||||
name="report_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="report_types",
|
||||
choices=[
|
||||
("inaccurate", "Inaccurate Information"),
|
||||
("inappropriate", "Inappropriate Content"),
|
||||
("spam", "Spam"),
|
||||
("copyright", "Copyright Violation"),
|
||||
("duplicate", "Duplicate Content"),
|
||||
("other", "Other"),
|
||||
],
|
||||
db_index=True,
|
||||
domain="support",
|
||||
help_text="Type of issue being reported",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="report",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="report_statuses",
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("investigating", "Investigating"),
|
||||
("resolved", "Resolved"),
|
||||
("dismissed", "Dismissed"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
domain="support",
|
||||
help_text="Current status of the report",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 20:42
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0005_alter_report_report_type_alter_report_status"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailThread",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"message_id",
|
||||
models.CharField(blank=True, help_text="Email message ID for threading", max_length=255),
|
||||
),
|
||||
("from_email", models.EmailField(help_text="Sender email address", max_length=254)),
|
||||
("to_email", models.EmailField(help_text="Recipient email address", max_length=254)),
|
||||
("subject", models.CharField(max_length=255)),
|
||||
("body_text", models.TextField(help_text="Plain text email body")),
|
||||
(
|
||||
"direction",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="email_directions",
|
||||
choices=[("inbound", "Inbound"), ("outbound", "Outbound")],
|
||||
domain="support",
|
||||
help_text="Whether email is inbound or outbound",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Email Thread",
|
||||
"verbose_name_plural": "Email Threads",
|
||||
"ordering": ["created_at"],
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="admin_notes",
|
||||
field=models.TextField(blank=True, help_text="Internal notes for administrators"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="archived_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When the ticket was archived", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="archived_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who archived this ticket",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="archived_tickets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="assigned_to",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member assigned to this ticket",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="assigned_tickets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="name",
|
||||
field=models.CharField(
|
||||
blank=True, help_text="Name of the submitter (for anonymous tickets)", max_length=255
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="resolved_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When the ticket was resolved", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="resolved_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who resolved this ticket",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="resolved_tickets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="ticket_number",
|
||||
field=models.CharField(blank=True, help_text="Human-readable ticket number", max_length=20, unique=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticket",
|
||||
index=models.Index(fields=["status", "created_at"], name="support_tic_status_d0b46e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticket",
|
||||
index=models.Index(fields=["ticket_number"], name="support_tic_ticket__d87f40_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticket",
|
||||
index=models.Index(fields=["archived_at"], name="support_tic_archive_8fe8c5_idx"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailthread",
|
||||
name="sent_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who sent this email (for outbound)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sent_email_threads",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailthread",
|
||||
name="ticket",
|
||||
field=models.ForeignKey(
|
||||
help_text="Associated support ticket",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="email_threads",
|
||||
to="support.ticket",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 21:19
|
||||
|
||||
import apps.core.state_machine.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0006_add_ticket_admin_fields_and_email_threads"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ticket",
|
||||
name="status",
|
||||
field=apps.core.state_machine.fields.RichFSMField(
|
||||
allow_deprecated=False,
|
||||
choice_group="ticket_statuses",
|
||||
choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")],
|
||||
db_index=True,
|
||||
default="open",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,27 @@
|
||||
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(TrackedModel):
|
||||
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,
|
||||
@@ -18,6 +31,13 @@ class Ticket(TrackedModel):
|
||||
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",
|
||||
@@ -30,7 +50,8 @@ class Ticket(TrackedModel):
|
||||
message = models.TextField()
|
||||
email = models.EmailField(help_text="Contact email", blank=True)
|
||||
|
||||
status = RichChoiceField(
|
||||
# FSM-managed status field
|
||||
status = RichFSMField(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
@@ -38,20 +59,250 @@ class Ticket(TrackedModel):
|
||||
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.get_category_display()}] {self.subject}"
|
||||
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):
|
||||
"""
|
||||
@@ -61,20 +312,6 @@ class Report(TrackedModel):
|
||||
(parks, rides, reviews, etc.) for moderator review.
|
||||
"""
|
||||
|
||||
class ReportType(models.TextChoices):
|
||||
INACCURATE = "inaccurate", "Inaccurate Information"
|
||||
INAPPROPRIATE = "inappropriate", "Inappropriate Content"
|
||||
SPAM = "spam", "Spam"
|
||||
COPYRIGHT = "copyright", "Copyright Violation"
|
||||
DUPLICATE = "duplicate", "Duplicate Content"
|
||||
OTHER = "other", "Other"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
INVESTIGATING = "investigating", "Investigating"
|
||||
RESOLVED = "resolved", "Resolved"
|
||||
DISMISSED = "dismissed", "Dismissed"
|
||||
|
||||
# Reporter (optional for anonymous reports)
|
||||
reporter = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -99,20 +336,22 @@ class Report(TrackedModel):
|
||||
# It's a convenience for accessing the related object
|
||||
# content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Report details
|
||||
report_type = models.CharField(
|
||||
# Report details - now using RichChoiceField
|
||||
report_type = RichChoiceField(
|
||||
choice_group="report_types",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
choices=ReportType.choices,
|
||||
db_index=True,
|
||||
help_text="Type of issue being reported",
|
||||
)
|
||||
reason = models.TextField(
|
||||
help_text="Detailed description of the issue",
|
||||
)
|
||||
status = models.CharField(
|
||||
status = RichChoiceField(
|
||||
choice_group="report_statuses",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
default="pending",
|
||||
db_index=True,
|
||||
help_text="Current status of the report",
|
||||
)
|
||||
@@ -151,5 +390,6 @@ class Report(TrackedModel):
|
||||
|
||||
@property
|
||||
def is_resolved(self) -> bool:
|
||||
return self.status in (self.Status.RESOLVED, self.Status.DISMISSED)
|
||||
return self.status in ("resolved", "dismissed")
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,126 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.serializers import UserSerializer
|
||||
from apps.core.choices.serializers import RichChoiceSerializerField
|
||||
|
||||
from .models import Ticket
|
||||
from .models import EmailThread, Ticket
|
||||
|
||||
|
||||
class SubmitterProfileSerializer(serializers.Serializer):
|
||||
"""Nested serializer for submitter profile data."""
|
||||
|
||||
display_name = serializers.CharField(source="profile.display_name", allow_null=True)
|
||||
created_at = serializers.DateTimeField(source="date_joined", allow_null=True)
|
||||
coaster_count = serializers.IntegerField(source="profile.coaster_credit_count", allow_null=True, default=0)
|
||||
ride_count = serializers.IntegerField(source="profile.ride_credit_count", allow_null=True, default=0)
|
||||
park_count = serializers.IntegerField(source="profile.park_credit_count", allow_null=True, default=0)
|
||||
review_count = serializers.IntegerField(source="profile.review_count", allow_null=True, default=0)
|
||||
avatar_url = serializers.CharField(source="profile.avatar_url", allow_null=True)
|
||||
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Ticket model with full frontend compatibility."""
|
||||
|
||||
# User fields
|
||||
user = UserSerializer(read_only=True)
|
||||
user_id = serializers.UUIDField(source="user.id", read_only=True, allow_null=True)
|
||||
submitter_username = serializers.CharField(source="user.username", read_only=True, allow_null=True)
|
||||
submitter_reputation = serializers.SerializerMethodField()
|
||||
submitter_profile = serializers.SerializerMethodField()
|
||||
|
||||
# Choice display fields
|
||||
category_display = serializers.CharField(source="get_category_display", read_only=True)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
# Computed fields
|
||||
thread_id = serializers.CharField(read_only=True)
|
||||
response_count = serializers.IntegerField(read_only=True)
|
||||
last_admin_response_at = serializers.DateTimeField(read_only=True, allow_null=True)
|
||||
|
||||
# Resolution tracking (alias resolved_by username)
|
||||
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
|
||||
|
||||
# Admin fields
|
||||
assigned_to_username = serializers.CharField(source="assigned_to.username", read_only=True, allow_null=True)
|
||||
archived_by_username = serializers.CharField(source="archived_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = [
|
||||
# Core fields
|
||||
"id",
|
||||
"user",
|
||||
"category",
|
||||
"category_display",
|
||||
"subject",
|
||||
"message",
|
||||
"email",
|
||||
"status",
|
||||
"status_display",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
# User/submitter fields
|
||||
"user",
|
||||
"user_id",
|
||||
"submitter_username",
|
||||
"submitter_reputation",
|
||||
"submitter_profile",
|
||||
"name",
|
||||
"email",
|
||||
# Ticket content
|
||||
"subject",
|
||||
"message",
|
||||
"category",
|
||||
"category_display",
|
||||
# Status
|
||||
"status",
|
||||
"status_display",
|
||||
# Ticket number
|
||||
"ticket_number",
|
||||
# Admin management
|
||||
"admin_notes",
|
||||
"assigned_to",
|
||||
"assigned_to_username",
|
||||
# Resolution
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
# Thread info
|
||||
"thread_id",
|
||||
"last_admin_response_at",
|
||||
"response_count",
|
||||
# Archive
|
||||
"archived_at",
|
||||
"archived_by",
|
||||
"archived_by_username",
|
||||
]
|
||||
read_only_fields = ["id", "status", "created_at", "updated_at", "user"]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"user_id",
|
||||
"submitter_username",
|
||||
"submitter_reputation",
|
||||
"submitter_profile",
|
||||
"ticket_number",
|
||||
"thread_id",
|
||||
"response_count",
|
||||
"last_admin_response_at",
|
||||
]
|
||||
|
||||
def get_submitter_reputation(self, obj):
|
||||
"""Get the submitter's reputation score."""
|
||||
if obj.user and hasattr(obj.user, "profile"):
|
||||
return getattr(obj.user.profile, "reputation", 0)
|
||||
return None
|
||||
|
||||
def get_submitter_profile(self, obj):
|
||||
"""Get a subset of profile data for display."""
|
||||
if not obj.user or not hasattr(obj.user, "profile"):
|
||||
return None
|
||||
profile = obj.user.profile
|
||||
return {
|
||||
"display_name": getattr(profile, "display_name", None),
|
||||
"created_at": obj.user.date_joined.isoformat() if obj.user.date_joined else None,
|
||||
"coaster_count": getattr(profile, "coaster_credit_count", 0),
|
||||
"ride_count": getattr(profile, "ride_credit_count", 0),
|
||||
"park_count": getattr(profile, "park_credit_count", 0),
|
||||
"review_count": getattr(profile, "review_count", 0),
|
||||
"avatar_url": getattr(profile, "avatar_url", None),
|
||||
}
|
||||
|
||||
def validate(self, data):
|
||||
# Ensure email is provided if user is anonymous
|
||||
@@ -35,6 +130,31 @@ class TicketSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class EmailThreadSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for EmailThread model."""
|
||||
|
||||
# NOTE: Frontend uses submission_id, we provide ticket as that field
|
||||
submission_id = serializers.UUIDField(source="ticket.id", read_only=True)
|
||||
sent_by_username = serializers.CharField(source="sent_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = EmailThread
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"submission_id",
|
||||
"message_id",
|
||||
"from_email",
|
||||
"to_email",
|
||||
"subject",
|
||||
"body_text",
|
||||
"direction",
|
||||
"sent_by",
|
||||
"sent_by_username",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "submission_id"]
|
||||
|
||||
|
||||
class ReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Report model."""
|
||||
|
||||
@@ -134,14 +254,141 @@ class ReportCreateSerializer(serializers.ModelSerializer):
|
||||
class ReportResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving reports."""
|
||||
|
||||
from .models import Report
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=[
|
||||
(Report.Status.RESOLVED, "Resolved"),
|
||||
(Report.Status.DISMISSED, "Dismissed"),
|
||||
],
|
||||
default=Report.Status.RESOLVED,
|
||||
# Use RichChoiceSerializerField with only resolution statuses
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="report_statuses",
|
||||
domain="support",
|
||||
default="resolved",
|
||||
)
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Support Ticket Action Serializers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SendReplySerializer(serializers.Serializer):
|
||||
"""
|
||||
Input serializer for send_reply action.
|
||||
|
||||
Validates the request body for sending an email reply to a ticket.
|
||||
Supports both snake_case and camelCase field names for frontend compatibility.
|
||||
"""
|
||||
|
||||
# Primary fields (required=False because we validate manually to support camelCase)
|
||||
reply_body = serializers.CharField(
|
||||
required=False,
|
||||
min_length=1,
|
||||
max_length=50000,
|
||||
help_text="The body text of the email reply",
|
||||
)
|
||||
new_status = RichChoiceSerializerField(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Optionally update the ticket status after sending reply",
|
||||
)
|
||||
|
||||
# camelCase aliases for frontend compatibility
|
||||
replyBody = serializers.CharField(required=False, write_only=True)
|
||||
newStatus = serializers.CharField(required=False, write_only=True, allow_null=True)
|
||||
|
||||
def validate(self, data: dict) -> dict:
|
||||
"""Normalize camelCase to snake_case and validate required fields."""
|
||||
# Normalize camelCase to snake_case
|
||||
if "replyBody" in data and data["replyBody"]:
|
||||
data["reply_body"] = data.pop("replyBody")
|
||||
elif "replyBody" in data:
|
||||
data.pop("replyBody")
|
||||
|
||||
if "newStatus" in data and data["newStatus"]:
|
||||
data["new_status"] = data.pop("newStatus")
|
||||
elif "newStatus" in data:
|
||||
data.pop("newStatus")
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("reply_body"):
|
||||
raise serializers.ValidationError({"reply_body": "This field is required."})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SendReplyResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for send_reply action."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
thread_id = serializers.UUIDField()
|
||||
ticket_number = serializers.CharField()
|
||||
|
||||
|
||||
class MergeTicketsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Input serializer for merge tickets action.
|
||||
|
||||
Validates the request body for merging multiple tickets into one.
|
||||
Supports both snake_case and camelCase field names for frontend compatibility.
|
||||
"""
|
||||
|
||||
# Primary fields (required=False because we validate manually to support camelCase)
|
||||
primary_ticket_id = serializers.UUIDField(
|
||||
required=False,
|
||||
help_text="UUID of the primary ticket that will absorb others",
|
||||
)
|
||||
merge_ticket_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
min_length=1,
|
||||
help_text="List of ticket UUIDs to merge into the primary",
|
||||
)
|
||||
merge_reason = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
max_length=500,
|
||||
help_text="Optional reason for the merge",
|
||||
)
|
||||
|
||||
# camelCase aliases for frontend compatibility
|
||||
primaryTicketId = serializers.UUIDField(required=False, write_only=True)
|
||||
mergeTicketIds = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
mergeReason = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
||||
|
||||
def validate(self, data: dict) -> dict:
|
||||
"""Normalize camelCase to snake_case and validate required fields."""
|
||||
# Normalize camelCase to snake_case
|
||||
if "primaryTicketId" in data and data["primaryTicketId"]:
|
||||
data["primary_ticket_id"] = data.pop("primaryTicketId")
|
||||
elif "primaryTicketId" in data:
|
||||
data.pop("primaryTicketId")
|
||||
|
||||
if "mergeTicketIds" in data and data["mergeTicketIds"]:
|
||||
data["merge_ticket_ids"] = data.pop("mergeTicketIds")
|
||||
elif "mergeTicketIds" in data:
|
||||
data.pop("mergeTicketIds")
|
||||
|
||||
if "mergeReason" in data and data["mergeReason"]:
|
||||
data["merge_reason"] = data.pop("mergeReason")
|
||||
elif "mergeReason" in data:
|
||||
data.pop("mergeReason")
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("primary_ticket_id"):
|
||||
raise serializers.ValidationError({"primary_ticket_id": "This field is required."})
|
||||
if not data.get("merge_ticket_ids"):
|
||||
raise serializers.ValidationError({"merge_ticket_ids": "This field is required."})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class MergeTicketsResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for merge tickets action."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
primaryTicketNumber = serializers.CharField()
|
||||
mergedCount = serializers.IntegerField()
|
||||
threadsConsolidated = serializers.IntegerField()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ReportViewSet, TicketViewSet
|
||||
from .views import EmailThreadViewSet, ReportViewSet, TicketViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"tickets", TicketViewSet, basename="ticket")
|
||||
router.register(r"reports", ReportViewSet, basename="report")
|
||||
router.register(r"threads", EmailThreadViewSet, basename="email-thread")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ from rest_framework import filters, permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Report, Ticket
|
||||
from .models import EmailThread, Report, Ticket
|
||||
from .serializers import (
|
||||
EmailThreadSerializer,
|
||||
ReportCreateSerializer,
|
||||
ReportResolveSerializer,
|
||||
ReportSerializer,
|
||||
@@ -23,17 +24,26 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
queryset = Ticket.objects.all()
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ["status", "category"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
|
||||
filterset_fields = ["status", "category", "archived_at"]
|
||||
search_fields = ["name", "email", "subject", "ticket_number"]
|
||||
ordering_fields = ["created_at", "status", "ticket_number"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Ticket.objects.select_related(
|
||||
"user",
|
||||
"user__profile",
|
||||
"assigned_to",
|
||||
"resolved_by",
|
||||
"archived_by",
|
||||
).prefetch_related("email_threads")
|
||||
|
||||
if user.is_staff:
|
||||
return Ticket.objects.all()
|
||||
return qs
|
||||
if user.is_authenticated:
|
||||
return Ticket.objects.filter(user=user)
|
||||
return qs.filter(user=user)
|
||||
return Ticket.objects.none() # Guests can't list tickets
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -42,6 +52,337 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
serializer.save()
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def send_reply(self, request, pk=None):
|
||||
"""
|
||||
Send an email reply to the ticket submitter.
|
||||
|
||||
Creates an EmailThread record and sends the email via ForwardEmail.
|
||||
Optionally updates the ticket status.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
from .serializers import SendReplySerializer
|
||||
|
||||
# Validate input
|
||||
serializer = SendReplySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
validated_data: dict[str, Any] = serializer.validated_data
|
||||
|
||||
ticket = self.get_object()
|
||||
|
||||
if not ticket.email:
|
||||
return Response(
|
||||
{"detail": "Ticket has no email address to reply to"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
reply_body: str = validated_data["reply_body"]
|
||||
new_status: str | None = validated_data.get("new_status")
|
||||
|
||||
# Build email subject with ticket number for threading
|
||||
subject = f"Re: [{ticket.ticket_number}] {ticket.subject}"
|
||||
|
||||
# Get the support from email with proper formatting
|
||||
from_email: str = getattr(settings, "DEFAULT_FROM_EMAIL", "ThrillWiki Support <support@thrillwiki.com>")
|
||||
|
||||
try:
|
||||
# Get the current site for ForwardEmail configuration
|
||||
# ForwardEmail requires a Site object, not RequestSite
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
except Site.DoesNotExist:
|
||||
site = Site.objects.first()
|
||||
if site is None:
|
||||
return Response(
|
||||
{"detail": "No site configured for email sending"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Send email via ForwardEmail service
|
||||
EmailService.send_email(
|
||||
to=ticket.email,
|
||||
subject=subject,
|
||||
text=reply_body,
|
||||
from_email=from_email,
|
||||
reply_to=from_email, # Ensure replies come back to support
|
||||
site=site,
|
||||
)
|
||||
|
||||
# Create EmailThread record for audit trail
|
||||
email_thread = EmailThread.objects.create(
|
||||
ticket=ticket,
|
||||
from_email=from_email,
|
||||
to_email=ticket.email,
|
||||
subject=subject,
|
||||
body_text=reply_body,
|
||||
direction="outbound",
|
||||
sent_by=request.user,
|
||||
)
|
||||
|
||||
# Update ticket status if provided
|
||||
if new_status and new_status != ticket.status:
|
||||
ticket.status = new_status
|
||||
if new_status in ("resolved", "closed"):
|
||||
ticket.resolved_at = timezone.now()
|
||||
ticket.resolved_by = request.user
|
||||
ticket.save()
|
||||
|
||||
return Response({
|
||||
"detail": "Reply sent successfully",
|
||||
"thread_id": str(email_thread.id),
|
||||
"ticket_number": ticket.ticket_number,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Log the error for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.exception("Failed to send ticket reply email")
|
||||
|
||||
return Response(
|
||||
{"detail": f"Failed to send email: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def merge(self, request):
|
||||
"""
|
||||
Merge multiple tickets into a primary ticket.
|
||||
|
||||
Moves all EmailThread records to the primary ticket and archives merged tickets.
|
||||
"""
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from .serializers import MergeTicketsSerializer
|
||||
|
||||
# Validate input
|
||||
serializer = MergeTicketsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
validated_data: dict[str, Any] = serializer.validated_data
|
||||
|
||||
primary_id: UUID = validated_data["primary_ticket_id"]
|
||||
merge_ids: list[UUID] = validated_data["merge_ticket_ids"]
|
||||
reason: str = validated_data.get("merge_reason", "")
|
||||
|
||||
# Get primary ticket
|
||||
try:
|
||||
primary_ticket = Ticket.objects.get(pk=primary_id)
|
||||
except Ticket.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": f"Primary ticket {primary_id} not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get tickets to merge (exclud primary if accidentally included)
|
||||
tickets_to_merge = Ticket.objects.filter(pk__in=merge_ids).exclude(pk=primary_id)
|
||||
if tickets_to_merge.count() == 0:
|
||||
return Response(
|
||||
{"detail": "No valid tickets found to merge"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
merged_count = 0
|
||||
threads_consolidated = 0
|
||||
merged_ticket_numbers: list[str] = []
|
||||
|
||||
for ticket in tickets_to_merge:
|
||||
# Move all email threads to primary ticket
|
||||
thread_count = EmailThread.objects.filter(ticket=ticket).update(ticket=primary_ticket)
|
||||
threads_consolidated += thread_count
|
||||
|
||||
# Record the merged ticket number
|
||||
merged_ticket_numbers.append(ticket.ticket_number)
|
||||
|
||||
# Archive the merged ticket with merge history
|
||||
ticket.archived_at = timezone.now()
|
||||
ticket.archived_by = request.user
|
||||
existing_notes = ticket.admin_notes or ""
|
||||
ticket.admin_notes = (
|
||||
f"{existing_notes}\n\n"
|
||||
f"[MERGED] Merged into {primary_ticket.ticket_number} by {request.user.username} "
|
||||
f"on {timezone.now().isoformat()}. Reason: {reason or 'Not specified'}"
|
||||
).strip()
|
||||
ticket.save()
|
||||
|
||||
merged_count += 1
|
||||
|
||||
# Update primary ticket with merge history
|
||||
existing_merged = primary_ticket.admin_notes or ""
|
||||
merge_note = (
|
||||
f"\n\n[MERGE HISTORY] Absorbed tickets: {', '.join(merged_ticket_numbers)} "
|
||||
f"({threads_consolidated} threads consolidated) by {request.user.username}"
|
||||
)
|
||||
primary_ticket.admin_notes = existing_merged + merge_note
|
||||
primary_ticket.save()
|
||||
|
||||
return Response({
|
||||
"detail": "Tickets merged successfully",
|
||||
"primaryTicketNumber": primary_ticket.ticket_number,
|
||||
"mergedCount": merged_count,
|
||||
"threadsConsolidated": threads_consolidated,
|
||||
})
|
||||
|
||||
# =========================================================================
|
||||
# FSM Transition Endpoints
|
||||
# =========================================================================
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def start_progress(self, request, pk=None):
|
||||
"""
|
||||
Start working on a ticket.
|
||||
Transition: open -> in_progress
|
||||
"""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
try:
|
||||
ticket.start_progress(user=request.user)
|
||||
return Response({
|
||||
"detail": "Ticket marked as in progress",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"status": ticket.status,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser], url_path="close")
|
||||
def close_ticket(self, request, pk=None):
|
||||
"""
|
||||
Close/resolve a ticket.
|
||||
Transition: open|in_progress -> closed
|
||||
"""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
notes = request.data.get("notes", "")
|
||||
|
||||
try:
|
||||
ticket.close(user=request.user, notes=notes)
|
||||
return Response({
|
||||
"detail": "Ticket closed successfully",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"status": ticket.status,
|
||||
"resolvedAt": ticket.resolved_at.isoformat() if ticket.resolved_at else None,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def reopen(self, request, pk=None):
|
||||
"""
|
||||
Reopen a closed ticket.
|
||||
Transition: closed -> open
|
||||
"""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
try:
|
||||
ticket.reopen(user=request.user, reason=reason)
|
||||
return Response({
|
||||
"detail": "Ticket reopened",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"status": ticket.status,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def archive(self, request, pk=None):
|
||||
"""Archive a ticket."""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
try:
|
||||
ticket.archive(user=request.user, reason=reason)
|
||||
return Response({
|
||||
"detail": "Ticket archived",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"archivedAt": ticket.archived_at.isoformat() if ticket.archived_at else None,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def unarchive(self, request, pk=None):
|
||||
"""Restore an archived ticket."""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
|
||||
try:
|
||||
ticket.unarchive(user=request.user)
|
||||
return Response({
|
||||
"detail": "Ticket unarchived",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[permissions.IsAdminUser])
|
||||
def available_transitions(self, request, pk=None):
|
||||
"""
|
||||
Get available transitions for a ticket.
|
||||
Uses StateMachineMixin to return FSM-aware transition metadata.
|
||||
"""
|
||||
ticket = self.get_object()
|
||||
transitions = ticket.get_available_user_transitions(request.user)
|
||||
|
||||
return Response({
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"currentStatus": ticket.status,
|
||||
"currentStatusDisplay": ticket.get_status_display(),
|
||||
"availableTransitions": transitions,
|
||||
})
|
||||
|
||||
|
||||
class EmailThreadViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for email thread entries.
|
||||
Staff only for full access.
|
||||
"""
|
||||
|
||||
queryset = EmailThread.objects.select_related("ticket", "sent_by").all()
|
||||
serializer_class = EmailThreadSerializer
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ["ticket", "direction"]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
# Support filtering by submission_id (which is ticket_id in our model)
|
||||
qs = super().get_queryset()
|
||||
submission_id = self.request.query_params.get("submission_id")
|
||||
if submission_id:
|
||||
qs = qs.filter(ticket_id=submission_id)
|
||||
return qs
|
||||
|
||||
|
||||
class ReportViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user