Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{% macro pending_notice(ticket) %}
{% if ticket.zd_status == ticket.ZD_STATUS_PENDING %}
{% if ticket.zd_status == ticket.ZD_STATUS_PENDING and ticket.is_syncable %}
<div class="thread-detail--notice thread-detail--notice--info">
{{ _('Our support team has replied and is waiting for your response. You can reply below or respond directly via email.') }}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ <h2 class="sumo-card-heading">
<p class="thread-comments--empty">{{ _("No replies yet.") }}</p>
{% endif %}

{% if ticket.zd_status == ticket.ZD_STATUS_CLOSED %}
{% if not ticket.is_syncable %}
<div class="thread-detail--notice thread-detail--notice--info">
{{ _('This ticket can no longer receive replies. Please open a new ticket if you still need help.') }}
</div>
{% elif ticket.zd_status == ticket.ZD_STATUS_CLOSED %}
<div class="thread-detail--notice thread-detail--notice--info">
{{ _('This ticket is closed and can no longer receive replies. Please open a new ticket if you still need help.') }}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 5.2.14 on 2026-06-04 23:20

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("customercare", "0011_delete_supportticketpendingchange"),
]

operations = [
migrations.AddField(
model_name="supportticket",
name="zd_deleted_at",
field=models.DateTimeField(
blank=True,
help_text="Set when the ticket is deleted in Zendesk (soft or permanent).",
null=True,
),
),
]
139 changes: 125 additions & 14 deletions kitsune/customercare/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from dataclasses import dataclass

from django.contrib.auth.models import User
from django.db import models
from django.db.models import Q
Expand All @@ -9,7 +11,43 @@
from kitsune.sumo.models import ModelBase


class SupportTicketManager(models.Manager):
@dataclass(frozen=True)
class StatusMeta:
"""Display metadata for a single Zendesk ticket status.

group -- the "active" or "solved" bucket the status belongs to; drives the
SupportTicketQuerySet.active()/solved() filters.
variant -- the status-label--<variant> modifier for the badge; the colors for
each variant live in SCSS (base/_status-label.scss).
tip -- the tooltip copy shown on the badge.
"""

group: str
variant: str
tip: str


class SupportTicketQuerySet(models.QuerySet):
def syncable(self):
# Mirror SupportTicket.is_syncable: a usable Zendesk id and not deleted.
return self.filter(zd_deleted_at__isnull=True).exclude(
Q(zendesk_ticket_id__isnull=True) | Q(zendesk_ticket_id="")
)

def active(self):
# Display semantics: status alone, regardless of whether the ticket has
# synced a Zendesk id yet. Chain .syncable() when sync-eligibility matters.
return self.filter(
zd_status__in=SupportTicket.statuses_in_group(SupportTicket.ZD_GROUP_ACTIVE),
zd_deleted_at__isnull=True,
)

def solved(self):
return self.filter(
zd_status__in=SupportTicket.statuses_in_group(SupportTicket.ZD_GROUP_SOLVED),
zd_deleted_at__isnull=True,
)

def accessible_to(self, user):
if not (user and user.is_authenticated):
return self.none()
Expand Down Expand Up @@ -64,6 +102,46 @@ class SupportTicket(ModelBase):
(ZD_STATUS_CLOSED, _lazy("Closed")),
)

ZD_GROUP_ACTIVE = "active"
ZD_GROUP_SOLVED = "solved"

# Per-status metadata. The group is the active/solved bucket for the status,
# and defines the single source of truth for the split (no second hardcoded
# list in the querysets). The variant selects the appropriate CSS variant,
# and the tip is the visual tool tip for the status.
ZD_STATUS_META = {
ZD_STATUS_NEW: StatusMeta(
group=ZD_GROUP_ACTIVE,
variant="new",
tip=_lazy("This ticket has been received and is awaiting review"),
),
ZD_STATUS_OPEN: StatusMeta(
group=ZD_GROUP_ACTIVE,
variant="open",
tip=_lazy("This ticket is open and being reviewed by our team"),
),
ZD_STATUS_PENDING: StatusMeta(
group=ZD_GROUP_ACTIVE,
variant="pending",
tip=_lazy("Our team has responded and is awaiting a reply"),
),
ZD_STATUS_HOLD: StatusMeta(
group=ZD_GROUP_ACTIVE,
variant="hold",
tip=_lazy("Our team is investigating with a third party"),
),
ZD_STATUS_SOLVED: StatusMeta(
group=ZD_GROUP_SOLVED,
variant="solved",
tip=_lazy("This ticket has been resolved"),
),
ZD_STATUS_CLOSED: StatusMeta(
group=ZD_GROUP_SOLVED,
variant="closed",
tip=_lazy("This ticket has been closed"),
),
}

subject = models.CharField(max_length=255)
description = models.TextField()
category = models.CharField(max_length=255)
Expand Down Expand Up @@ -104,11 +182,16 @@ class SupportTicket(ModelBase):
zd_status = models.CharField(max_length=20, choices=ZD_STATUS_CHOICES, null=True, blank=True)
zd_updated_at = models.DateTimeField(null=True, blank=True)
last_synced_at = models.DateTimeField(null=True, blank=True)
zd_deleted_at = models.DateTimeField(
null=True,
blank=True,
help_text="Set when the ticket is deleted in Zendesk (soft or permanent).",
)
comments = models.JSONField(default=list, blank=True)
internal_zd_tags = models.JSONField(default=list, blank=True)
created = models.DateTimeField(auto_now_add=True, db_index=True)

objects = SupportTicketManager()
objects = SupportTicketQuerySet.as_manager()

class Meta:
ordering = ["-created"]
Expand All @@ -128,6 +211,11 @@ def content(self):
def channel(self):
return "direct_support"

@property
def is_syncable(self):
"""True if the ticket can be synced with Zendesk."""
return bool(self.zendesk_ticket_id and not self.zd_deleted_at)

def get_absolute_url(self):
if not self.user:
return None
Expand All @@ -151,16 +239,39 @@ def public_comments(self):
first_reply_index = 0 if self.last_synced_at is None else 1
return [c for c in self.comments[first_reply_index:] if c.get("public", False)]

@classmethod
def statuses_in_group(cls, group):
"""The zd_status values belonging to an active/solved group."""
return tuple(status for status, meta in cls.ZD_STATUS_META.items() if meta.group == group)

@property
def user_status(self):
"""Derive a user-facing status from submission_status and zd_status."""
if self.submission_status == self.STATUS_REJECTED:
return None
if self.submission_status in (
self.STATUS_PENDING,
self.STATUS_FLAGGED,
self.STATUS_PROCESSING_FAILED,
):
return _lazy("processing")
# STATUS_SENT — delegate to ZD status if available
return self.zd_status or _lazy("submitted")
def status_meta(self):
"""Display metadata for the current zd_status, or None for a status not in
the registry (e.g. a not-yet-synced ticket whose zd_status is null)."""
return self.ZD_STATUS_META.get(self.zd_status)

@property
def status_label(self):
"""Derive a user-facing status."""
if self.zd_deleted_at:
return _lazy("Inactive")
return self.get_zd_status_display() or _lazy("Submitted")

@property
def status_variant(self):
"""The status-label--<variant> modifier for this ticket's badge. Deleted
and not-yet-synced tickets fall back to the "neutral" variant."""
if self.zd_deleted_at:
return "neutral"
meta = self.status_meta
return meta.variant if meta else "neutral"

@property
def status_tip(self):
"""Tooltip copy describing the ticket's current status."""
if self.zd_deleted_at:
return _lazy("This ticket is no longer active and can no longer receive replies")
meta = self.status_meta
if meta:
return meta.tip
return _lazy("This ticket has been submitted and is being processed")
75 changes: 42 additions & 33 deletions kitsune/customercare/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils import timezone
from django.utils.dateparse import parse_datetime

from kitsune.customercare.models import SupportTicket
from kitsune.customercare.utils import (
apply_zendesk_ticket_data,
fetch_zendesk_ticket_data,
process_zendesk_classification_result,
sync_ticket_from_zendesk,
)
Expand All @@ -25,12 +24,22 @@
acks_late=True, autoretry_for=(Exception,), retry_backoff=2, retry_kwargs={"max_retries": 3}
)

HANDLED_EVENT_TYPES = {
# Zendesk webhook events that trigger a re-sync of the ticket from the Zendesk REST API.
RESYNC_EVENT_TYPES = {
"zen:event-type:ticket.status_changed",
"zen:event-type:ticket.subject_changed",
"zen:event-type:ticket.description_changed",
"zen:event-type:ticket.comment_added",
}
# Zendesk webhook events that only update the local deletion state of the support ticket.
UNDELETE_EVENT_TYPE = "zen:event-type:ticket.undeleted"
DELETION_EVENT_TYPES = {
"zen:event-type:ticket.soft_deleted",
"zen:event-type:ticket.permanently_deleted",
UNDELETE_EVENT_TYPE,
}

HANDLED_EVENT_TYPES = RESYNC_EVENT_TYPES | DELETION_EVENT_TYPES


@shared_task_with_retry
Expand Down Expand Up @@ -141,9 +150,12 @@ def process_failed_zendesk_tickets() -> None:
@skip_if_read_only_mode
def process_zendesk_update(payload: dict) -> None:
"""
Process an incoming Zendesk ticket-event webhook payload by re-syncing
the affected ticket from Zendesk via the REST API. The webhook is a
notification trigger only; the REST API is the source of truth.
Process an incoming Zendesk ticket-event webhook payload.

Most events re-sync the affected ticket from Zendesk via the REST API. The
webhook is only a notification trigger. The REST API is the source of truth.
Deletion events are the exception. A deleted ticket no longer exists in Zendesk,
so they only update the "zd_deleted_at" value and never touch the API.
"""
if not (detail := payload.get("detail")) or not (ticket_id := detail.get("id")):
raise ValueError("Zendesk webhook payload missing detail.id.")
Expand All @@ -154,17 +166,31 @@ def process_zendesk_update(payload: dict) -> None:
return

qs = SupportTicket.objects.filter(zendesk_ticket_id=str(ticket_id))
if not qs.exists():

if event_type in DELETION_EVENT_TYPES:
zd_deleted_at = (
None
if event_type == UNDELETE_EVENT_TYPE
else parse_datetime(payload.get("time") or "") or timezone.now()
)

with transaction.atomic():
try:
ticket = qs.select_for_update().get()
except SupportTicket.DoesNotExist:
return

ticket.zd_deleted_at = zd_deleted_at
ticket.save(update_fields=["zd_deleted_at"])

return

zd_ticket, zd_comments = fetch_zendesk_ticket_data(str(ticket_id))
try:
ticket = qs.syncable().get()
except SupportTicket.DoesNotExist:
return

with transaction.atomic():
try:
ticket = qs.select_for_update().get()
except SupportTicket.DoesNotExist:
return
apply_zendesk_ticket_data(ticket, zd_ticket, zd_comments)
sync_ticket_from_zendesk(ticket)


@shared_task_with_retry
Expand All @@ -175,11 +201,7 @@ def sync_support_ticket(ticket_id: int) -> None:
failing ticket doesn't stall the rest of the batch and gets isolated retries.
"""
try:
ticket = (
SupportTicket.objects.filter(zendesk_ticket_id__isnull=False)
.exclude(zendesk_ticket_id="")
.get(id=ticket_id)
)
ticket = SupportTicket.objects.syncable().get(id=ticket_id)
except SupportTicket.DoesNotExist:
return

Expand All @@ -195,20 +217,7 @@ def sync_active_support_tickets() -> None:
Zenpy handles Zendesk API rate limiting, so sub-tasks run as fast as the
worker pool allows.
"""
active_statuses = [
SupportTicket.ZD_STATUS_NEW,
SupportTicket.ZD_STATUS_OPEN,
SupportTicket.ZD_STATUS_PENDING,
SupportTicket.ZD_STATUS_HOLD,
]
ticket_ids = (
SupportTicket.objects.filter(
zd_status__in=active_statuses,
zendesk_ticket_id__isnull=False,
)
.exclude(zendesk_ticket_id="")
.values_list("id", flat=True)
)
ticket_ids = SupportTicket.objects.active().syncable().values_list("id", flat=True)

result = group(sync_support_ticket.s(ticket_id) for ticket_id in ticket_ids).delay()

Expand Down
Loading