feat: Enhance ticketing system with categories, templates, SLA tracking, and fix frontend integration

- Add ticket categories (billing, technical, feature_request, etc.) with type-specific options
- Add TicketTemplate and CannedResponse models for quick ticket creation
- Implement SLA tracking with due_at and first_response_at fields
- Add is_platform_admin and is_customer helper functions to fix permission checks
- Register models in Django admin with filters and fieldsets
- Enhance signals with error handling for WebSocket notifications
- Fix frontend API URLs for templates and canned responses
- Update PlatformSupport page to use real ticketing API
- Add comprehensive i18n translations for all ticket fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 05:32:36 -05:00
parent 512d95ca2d
commit 200a6b3dd4
22 changed files with 1782 additions and 425 deletions

View File

@@ -1,3 +1,93 @@
from django.contrib import admin
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
# Register your models here.
@admin.register(Ticket)
class TicketAdmin(admin.ModelAdmin):
list_display = ('id', 'subject', 'ticket_type', 'status', 'priority', 'creator', 'assignee', 'tenant', 'created_at')
list_filter = ('status', 'priority', 'ticket_type', 'tenant', 'created_at')
search_fields = ('subject', 'description', 'creator__email', 'assignee__email')
readonly_fields = ('created_at', 'updated_at')
raw_id_fields = ('creator', 'assignee', 'tenant')
ordering = ('-created_at',)
fieldsets = (
(None, {
'fields': ('subject', 'description', 'category')
}),
('Classification', {
'fields': ('ticket_type', 'status', 'priority')
}),
('Assignment', {
'fields': ('tenant', 'creator', 'assignee')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at', 'resolved_at'),
'classes': ('collapse',)
}),
)
@admin.register(TicketComment)
class TicketCommentAdmin(admin.ModelAdmin):
list_display = ('id', 'ticket', 'author', 'is_internal', 'created_at')
list_filter = ('is_internal', 'created_at')
search_fields = ('comment_text', 'author__email', 'ticket__subject')
readonly_fields = ('created_at',)
raw_id_fields = ('ticket', 'author')
ordering = ('-created_at',)
@admin.register(TicketTemplate)
class TicketTemplateAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'ticket_type', 'category', 'default_priority', 'tenant', 'is_active', 'created_at')
list_filter = ('ticket_type', 'category', 'default_priority', 'is_active', 'tenant', 'created_at')
search_fields = ('name', 'description', 'subject_template', 'description_template')
readonly_fields = ('created_at',)
raw_id_fields = ('tenant',)
ordering = ('ticket_type', 'name')
fieldsets = (
(None, {
'fields': ('name', 'description', 'is_active')
}),
('Template Details', {
'fields': ('ticket_type', 'category', 'default_priority')
}),
('Content Templates', {
'fields': ('subject_template', 'description_template')
}),
('Scope', {
'fields': ('tenant',)
}),
('Timestamps', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)
@admin.register(CannedResponse)
class CannedResponseAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'category', 'tenant', 'is_active', 'use_count', 'created_by', 'created_at')
list_filter = ('category', 'is_active', 'tenant', 'created_at')
search_fields = ('title', 'content', 'created_by__email')
readonly_fields = ('use_count', 'created_at')
raw_id_fields = ('tenant', 'created_by')
ordering = ('-use_count', 'title')
fieldsets = (
(None, {
'fields': ('title', 'content', 'is_active')
}),
('Categorization', {
'fields': ('category',)
}),
('Scope', {
'fields': ('tenant',)
}),
('Metadata', {
'fields': ('use_count', 'created_by', 'created_at'),
'classes': ('collapse',)
}),
)

View File

@@ -0,0 +1,103 @@
# Generated by Django 5.2.8 on 2025-11-28 10:22
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_add_tenant_permissions'),
('tickets', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CannedResponse',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(help_text='Short title for easy selection.', max_length=100)),
('content', models.TextField(help_text='The response content. Can include placeholders.')),
('category', models.CharField(blank=True, choices=[('BILLING', 'Billing & Payments'), ('TECHNICAL', 'Technical Issue'), ('FEATURE_REQUEST', 'Feature Request'), ('ACCOUNT', 'Account & Settings'), ('APPOINTMENT', 'Appointment Issue'), ('REFUND', 'Refund Request'), ('COMPLAINT', 'Complaint'), ('GENERAL_INQUIRY', 'General Inquiry'), ('TIME_OFF', 'Time Off Request'), ('SCHEDULE_CHANGE', 'Schedule Change'), ('EQUIPMENT', 'Equipment Issue'), ('OTHER', 'Other')], help_text='Category this response is most relevant to.', max_length=50)),
('is_active', models.BooleanField(default=True)),
('use_count', models.PositiveIntegerField(default=0, help_text='Number of times this response was used.')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['-use_count', 'title'],
},
),
migrations.CreateModel(
name='TicketTemplate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Template name.', max_length=100)),
('description', models.TextField(blank=True, help_text='Description of when to use this template.')),
('ticket_type', models.CharField(choices=[('PLATFORM', 'Platform Support'), ('CUSTOMER', 'Customer Inquiry'), ('STAFF_REQUEST', 'Staff Request'), ('INTERNAL', 'Internal Business Ticket')], help_text='Type of ticket this template creates.', max_length=20)),
('category', models.CharField(choices=[('BILLING', 'Billing & Payments'), ('TECHNICAL', 'Technical Issue'), ('FEATURE_REQUEST', 'Feature Request'), ('ACCOUNT', 'Account & Settings'), ('APPOINTMENT', 'Appointment Issue'), ('REFUND', 'Refund Request'), ('COMPLAINT', 'Complaint'), ('GENERAL_INQUIRY', 'General Inquiry'), ('TIME_OFF', 'Time Off Request'), ('SCHEDULE_CHANGE', 'Schedule Change'), ('EQUIPMENT', 'Equipment Issue'), ('OTHER', 'Other')], default='OTHER', max_length=50)),
('default_priority', models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', max_length=20)),
('subject_template', models.CharField(help_text='Default subject line. Can include placeholders like {customer_name}.', max_length=255)),
('description_template', models.TextField(help_text='Default description. Can include placeholders.')),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['ticket_type', 'name'],
},
),
migrations.AddField(
model_name='ticket',
name='due_at',
field=models.DateTimeField(blank=True, help_text='SLA deadline based on priority.', null=True),
),
migrations.AddField(
model_name='ticket',
name='first_response_at',
field=models.DateTimeField(blank=True, help_text='When the first response was made.', null=True),
),
migrations.AddField(
model_name='ticket',
name='related_appointment_id',
field=models.CharField(blank=True, help_text='ID of the related appointment for customer inquiry tickets.', max_length=50, null=True),
),
migrations.AlterField(
model_name='ticket',
name='category',
field=models.CharField(choices=[('BILLING', 'Billing & Payments'), ('TECHNICAL', 'Technical Issue'), ('FEATURE_REQUEST', 'Feature Request'), ('ACCOUNT', 'Account & Settings'), ('APPOINTMENT', 'Appointment Issue'), ('REFUND', 'Refund Request'), ('COMPLAINT', 'Complaint'), ('GENERAL_INQUIRY', 'General Inquiry'), ('TIME_OFF', 'Time Off Request'), ('SCHEDULE_CHANGE', 'Schedule Change'), ('EQUIPMENT', 'Equipment Issue'), ('OTHER', 'Other')], default='OTHER', help_text='Category of the ticket.', max_length=50),
),
migrations.AlterField(
model_name='ticket',
name='status',
field=models.CharField(choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('AWAITING_RESPONSE', 'Awaiting Response'), ('RESOLVED', 'Resolved'), ('CLOSED', 'Closed')], default='OPEN', help_text='Current status of the ticket.', max_length=20),
),
migrations.AlterField(
model_name='ticket',
name='ticket_type',
field=models.CharField(choices=[('PLATFORM', 'Platform Support'), ('CUSTOMER', 'Customer Inquiry'), ('STAFF_REQUEST', 'Staff Request'), ('INTERNAL', 'Internal Business Ticket')], default='CUSTOMER', help_text='Distinguishes between platform support tickets and customer/staff tickets.', max_length=20),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['creator', 'status'], name='tickets_tic_creator_cc6043_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['category'], name='tickets_tic_categor_fc7dd1_idx'),
),
migrations.AddField(
model_name='cannedresponse',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_canned_responses', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='cannedresponse',
name='tenant',
field=models.ForeignKey(blank=True, help_text='Tenant this response belongs to. Null for platform-wide responses.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='canned_responses', to='core.tenant'),
),
migrations.AddField(
model_name='tickettemplate',
name='tenant',
field=models.ForeignKey(blank=True, help_text='Tenant this template belongs to. Null for platform-wide templates.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticket_templates', to='core.tenant'),
),
]

View File

@@ -1,23 +1,31 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from core.models import Tenant
from smoothschedule.users.models import User # Assuming smoothschedule.users is the app for User model
from smoothschedule.users.models import User
class Ticket(models.Model):
"""
Represents a support ticket in the system.
Can be a platform-level ticket (Business Owner -> Platform)
or a customer-level ticket (Customer -> Business).
Ticket Types:
- PLATFORM: Business Owner -> Platform Support (billing, technical issues with platform)
- CUSTOMER: Customer -> Business (appointment issues, service inquiries)
- STAFF_REQUEST: Staff -> Business Owner/Manager (time off, schedule changes)
- INTERNAL: Business internal tickets (equipment issues, facilities)
"""
class TicketType(models.TextChoices):
PLATFORM = 'PLATFORM', _('Platform Support')
CUSTOMER = 'CUSTOMER', _('Customer Inquiry')
STAFF_REQUEST = 'STAFF_REQUEST', _('Staff Request')
INTERNAL = 'INTERNAL', _('Internal Business Ticket')
class Status(models.TextChoices):
OPEN = 'OPEN', _('Open')
IN_PROGRESS = 'IN_PROGRESS', _('In Progress')
AWAITING_RESPONSE = 'AWAITING_RESPONSE', _('Awaiting Response')
RESOLVED = 'RESOLVED', _('Resolved')
CLOSED = 'CLOSED', _('Closed')
@@ -27,6 +35,24 @@ class Ticket(models.Model):
HIGH = 'HIGH', _('High')
URGENT = 'URGENT', _('Urgent')
class Category(models.TextChoices):
# Platform ticket categories
BILLING = 'BILLING', _('Billing & Payments')
TECHNICAL = 'TECHNICAL', _('Technical Issue')
FEATURE_REQUEST = 'FEATURE_REQUEST', _('Feature Request')
ACCOUNT = 'ACCOUNT', _('Account & Settings')
# Customer/Business ticket categories
APPOINTMENT = 'APPOINTMENT', _('Appointment Issue')
REFUND = 'REFUND', _('Refund Request')
COMPLAINT = 'COMPLAINT', _('Complaint')
GENERAL_INQUIRY = 'GENERAL_INQUIRY', _('General Inquiry')
# Staff request categories
TIME_OFF = 'TIME_OFF', _('Time Off Request')
SCHEDULE_CHANGE = 'SCHEDULE_CHANGE', _('Schedule Change')
EQUIPMENT = 'EQUIPMENT', _('Equipment Issue')
# General
OTHER = 'OTHER', _('Other')
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
@@ -76,9 +102,30 @@ class Ticket(models.Model):
description = models.TextField(help_text="Detailed description of the issue or request.")
category = models.CharField(
max_length=50,
choices=Category.choices,
default=Category.OTHER,
help_text="Category of the ticket."
)
# Related appointment for customer tickets (stored as ID string since Event is tenant-scoped)
related_appointment_id = models.CharField(
max_length=50,
blank=True,
help_text="Category of the ticket (e.g., Billing, Technical, Feature Request)."
null=True,
help_text="ID of the related appointment for customer inquiry tickets."
)
# SLA tracking
due_at = models.DateTimeField(
null=True,
blank=True,
help_text="SLA deadline based on priority."
)
first_response_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the first response was made."
)
created_at = models.DateTimeField(auto_now_add=True)
@@ -91,11 +138,132 @@ class Ticket(models.Model):
models.Index(fields=['tenant', 'status']),
models.Index(fields=['assignee', 'status']),
models.Index(fields=['ticket_type', 'status']),
models.Index(fields=['creator', 'status']),
models.Index(fields=['category']),
]
def __str__(self):
return f"Ticket #{self.id}: {self.subject} ({self.get_status_display()})"
def save(self, *args, **kwargs):
# Set SLA due date based on priority if not already set
if not self.due_at and not self.pk:
self.due_at = self._calculate_sla_due_date()
# Auto-set resolved_at when status changes to RESOLVED or CLOSED
if self.status in [self.Status.RESOLVED, self.Status.CLOSED] and not self.resolved_at:
self.resolved_at = timezone.now()
elif self.status not in [self.Status.RESOLVED, self.Status.CLOSED]:
self.resolved_at = None
super().save(*args, **kwargs)
def _calculate_sla_due_date(self):
"""Calculate SLA due date based on priority."""
from datetime import timedelta
now = timezone.now()
sla_hours = {
self.Priority.URGENT: 1,
self.Priority.HIGH: 4,
self.Priority.MEDIUM: 24,
self.Priority.LOW: 72,
}
hours = sla_hours.get(self.priority, 24)
return now + timedelta(hours=hours)
@property
def is_overdue(self):
"""Check if ticket is past SLA deadline."""
if not self.due_at:
return False
if self.status in [self.Status.RESOLVED, self.Status.CLOSED]:
return False
return timezone.now() > self.due_at
class TicketTemplate(models.Model):
"""
Predefined templates for common ticket types.
Can be platform-wide or tenant-specific.
"""
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='ticket_templates',
null=True,
blank=True,
help_text="Tenant this template belongs to. Null for platform-wide templates."
)
name = models.CharField(max_length=100, help_text="Template name.")
description = models.TextField(blank=True, help_text="Description of when to use this template.")
ticket_type = models.CharField(
max_length=20,
choices=Ticket.TicketType.choices,
help_text="Type of ticket this template creates."
)
category = models.CharField(
max_length=50,
choices=Ticket.Category.choices,
default=Ticket.Category.OTHER,
)
default_priority = models.CharField(
max_length=20,
choices=Ticket.Priority.choices,
default=Ticket.Priority.MEDIUM,
)
subject_template = models.CharField(
max_length=255,
help_text="Default subject line. Can include placeholders like {customer_name}."
)
description_template = models.TextField(
help_text="Default description. Can include placeholders."
)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['ticket_type', 'name']
def __str__(self):
scope = self.tenant.name if self.tenant else "Platform"
return f"{self.name} ({scope})"
class CannedResponse(models.Model):
"""
Predefined responses for support staff to quickly reply to common issues.
"""
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='canned_responses',
null=True,
blank=True,
help_text="Tenant this response belongs to. Null for platform-wide responses."
)
title = models.CharField(max_length=100, help_text="Short title for easy selection.")
content = models.TextField(help_text="The response content. Can include placeholders.")
category = models.CharField(
max_length=50,
choices=Ticket.Category.choices,
blank=True,
help_text="Category this response is most relevant to."
)
is_active = models.BooleanField(default=True)
use_count = models.PositiveIntegerField(default=0, help_text="Number of times this response was used.")
created_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='created_canned_responses',
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-use_count', 'title']
def __str__(self):
scope = self.tenant.name if self.tenant else "Platform"
return f"{self.title} ({scope})"
class TicketComment(models.Model):
"""
Represents a comment or update on a support ticket.

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers
from .models import Ticket, TicketComment
from smoothschedule.users.models import User # Assuming smoothschedule.users is the app for User model
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
from smoothschedule.users.models import User
from core.models import Tenant
class TicketCommentSerializer(serializers.ModelSerializer):
@@ -17,7 +17,8 @@ class TicketSerializer(serializers.ModelSerializer):
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
assignee_email = serializers.ReadOnlyField(source='assignee.email')
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
comments = TicketCommentSerializer(many=True, read_only=True) # Nested serializer for comments
is_overdue = serializers.ReadOnlyField()
comments = TicketCommentSerializer(many=True, read_only=True)
class Meta:
model = Ticket
@@ -25,9 +26,11 @@ class TicketSerializer(serializers.ModelSerializer):
'id', 'tenant', 'creator', 'creator_email', 'creator_full_name',
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at', 'comments'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name', 'created_at', 'updated_at', 'resolved_at', 'comments']
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at', 'comments']
def create(self, validated_data):
# Automatically set creator to the requesting user if not provided (e.g., for platform admin creating for tenant)
@@ -53,3 +56,78 @@ class TicketSerializer(serializers.ModelSerializer):
validated_data.pop('tenant', None)
validated_data.pop('creator', None)
return super().update(instance, validated_data)
class TicketListSerializer(serializers.ModelSerializer):
"""Lighter version of TicketSerializer for list views without comments."""
creator_email = serializers.ReadOnlyField(source='creator.email')
creator_full_name = serializers.ReadOnlyField(source='creator.full_name')
assignee_email = serializers.ReadOnlyField(source='assignee.email')
assignee_full_name = serializers.ReadOnlyField(source='assignee.full_name')
is_overdue = serializers.ReadOnlyField()
class Meta:
model = Ticket
fields = [
'id', 'tenant', 'creator', 'creator_email', 'creator_full_name',
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'category',
'related_appointment_id', 'due_at', 'first_response_at', 'is_overdue',
'created_at', 'updated_at', 'resolved_at'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name',
'is_overdue', 'created_at', 'updated_at', 'resolved_at']
class TicketTemplateSerializer(serializers.ModelSerializer):
"""Serializer for TicketTemplate model."""
class Meta:
model = TicketTemplate
fields = [
'id', 'tenant', 'name', 'description', 'ticket_type', 'category',
'default_priority', 'subject_template', 'description_template',
'is_active', 'created_at'
]
read_only_fields = ['id', 'created_at']
def create(self, validated_data):
# Set tenant based on request context
user = self.context['request'].user
# If tenant is not provided and user has a tenant, use it
if 'tenant' not in validated_data or validated_data['tenant'] is None:
if hasattr(user, 'tenant') and user.tenant:
validated_data['tenant'] = user.tenant
# Platform admins can create platform-wide templates (tenant=null)
return super().create(validated_data)
class CannedResponseSerializer(serializers.ModelSerializer):
"""Serializer for CannedResponse model."""
created_by_email = serializers.ReadOnlyField(source='created_by.email')
created_by_full_name = serializers.ReadOnlyField(source='created_by.full_name')
class Meta:
model = CannedResponse
fields = [
'id', 'tenant', 'title', 'content', 'category', 'is_active',
'use_count', 'created_by', 'created_by_email', 'created_by_full_name',
'created_at'
]
read_only_fields = ['id', 'use_count', 'created_by', 'created_by_email',
'created_by_full_name', 'created_at']
def create(self, validated_data):
# Set created_by to requesting user
user = self.context['request'].user
validated_data['created_by'] = user
# Set tenant based on request context
if 'tenant' not in validated_data or validated_data['tenant'] is None:
if hasattr(user, 'tenant') and user.tenant:
validated_data['tenant'] = user.tenant
# Platform admins can create platform-wide responses (tenant=null)
return super().create(validated_data)

View File

@@ -1,70 +1,223 @@
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from django.contrib.contenttypes.models import ContentType
from .models import Ticket, TicketComment
from notifications.models import Notification # Assuming notifications app is installed
from smoothschedule.users.models import User
logger = logging.getLogger(__name__)
def send_websocket_notification(group_name, message_data):
"""Safely send a WebSocket notification, handling errors gracefully."""
try:
channel_layer = get_channel_layer()
if channel_layer is None:
logger.warning("Channel layer not configured, skipping WebSocket notification")
return
async_to_sync(channel_layer.group_send)(
group_name,
{
"type": "notification_message",
"message": message_data
}
)
except Exception as e:
logger.error(f"Failed to send WebSocket notification to {group_name}: {e}")
def create_notification(recipient, actor, verb, action_object, target, data):
"""Safely create a notification, handling import and creation errors."""
try:
from notifications.models import Notification
Notification.objects.create(
recipient=recipient,
actor=actor,
verb=verb,
action_object=action_object,
target=target,
data=data
)
except ImportError:
logger.warning("notifications app not installed, skipping notification creation")
except Exception as e:
logger.error(f"Failed to create notification for {recipient}: {e}")
def get_platform_support_team():
"""Get all platform support team members."""
try:
return User.objects.filter(
role__in=[User.Role.PLATFORM_SUPPORT, User.Role.PLATFORM_MANAGER, User.Role.SUPERUSER],
is_active=True
)
except Exception as e:
logger.error(f"Failed to fetch platform support team: {e}")
return User.objects.none()
def get_tenant_managers(tenant):
"""Get all owners and managers for a tenant."""
try:
if not tenant:
return User.objects.none()
return User.objects.filter(
tenant=tenant,
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
is_active=True
)
except Exception as e:
logger.error(f"Failed to fetch tenant managers for {tenant}: {e}")
return User.objects.none()
@receiver(post_save, sender=Ticket)
def ticket_notification_handler(sender, instance, created, **kwargs):
channel_layer = get_channel_layer()
# Logic for Ticket assignment and status change
if not created: # Only on update
# Get old instance (this is tricky with post_save, usually requires pre_save)
# For simplicity, we'll assume the instance passed is the new state,
# and compare against previous state if we had it.
# For now, let's just trigger on any save after creation, and focus on assignee.
"""Handle ticket save events and send notifications."""
try:
if created:
# Handle ticket creation notifications
_handle_ticket_creation(instance)
else:
# Handle ticket update notifications
_handle_ticket_update(instance)
except Exception as e:
logger.error(f"Error in ticket_notification_handler for ticket {instance.id}: {e}")
# Notify assignee if changed
if instance.assignee:
# Check if assignee actually changed (requires pre_save or a custom field for old value)
# For this iteration, let's just notify the assignee on any update to the ticket they are assigned to
# Or if it's a new assignment.
# Create Notification object for the assignee
Notification.objects.create(
recipient=instance.assignee,
actor=instance.creator, # The one who created the ticket (can be updated later)
verb=f"Ticket #{instance.id} '{instance.subject}' was updated.",
action_object=instance,
target=instance,
data={'ticket_id': instance.id, 'subject': instance.subject, 'status': instance.status}
)
# Send WebSocket message to assignee's personal channel
async_to_sync(channel_layer.group_send)(
f"user_{instance.assignee.id}",
{
"type": "notification_message",
"message": {
"type": "ticket_update",
"ticket_id": instance.id,
"subject": instance.subject,
"status": instance.status,
"assignee_id": str(instance.assignee.id),
"message": f"Ticket #{instance.id} '{instance.subject}' updated. Status: {instance.status}"
def _handle_ticket_creation(ticket):
"""Send notifications when a ticket is created."""
try:
creator_name = ticket.creator.full_name if ticket.creator else "Someone"
if ticket.ticket_type == Ticket.TicketType.PLATFORM:
# PLATFORM tickets: Notify platform support team
platform_team = get_platform_support_team()
for member in platform_team:
create_notification(
recipient=member,
actor=ticket.creator,
verb=f"New platform support ticket #{ticket.id}: '{ticket.subject}'",
action_object=ticket,
target=ticket,
data={
'ticket_id': ticket.id,
'subject': ticket.subject,
'priority': ticket.priority,
'category': ticket.category
}
}
)
)
send_websocket_notification(
f"user_{member.id}",
{
"type": "new_ticket",
"ticket_id": ticket.id,
"subject": ticket.subject,
"ticket_type": ticket.ticket_type,
"priority": ticket.priority,
"creator_name": creator_name,
"message": f"New platform support ticket from {creator_name}: {ticket.subject}"
}
)
# General notification for tenant/platform admins (if needed)
# This might be too broad, usually target specific groups/users
pass
elif ticket.ticket_type in [
Ticket.TicketType.CUSTOMER,
Ticket.TicketType.STAFF_REQUEST,
Ticket.TicketType.INTERNAL
]:
# CUSTOMER, STAFF_REQUEST, INTERNAL tickets: Notify tenant owner/managers
tenant_managers = get_tenant_managers(ticket.tenant)
ticket_type_display = ticket.get_ticket_type_display()
for manager in tenant_managers:
create_notification(
recipient=manager,
actor=ticket.creator,
verb=f"New {ticket_type_display.lower()} ticket #{ticket.id}: '{ticket.subject}'",
action_object=ticket,
target=ticket,
data={
'ticket_id': ticket.id,
'subject': ticket.subject,
'priority': ticket.priority,
'category': ticket.category,
'ticket_type': ticket.ticket_type
}
)
send_websocket_notification(
f"user_{manager.id}",
{
"type": "new_ticket",
"ticket_id": ticket.id,
"subject": ticket.subject,
"ticket_type": ticket.ticket_type,
"priority": ticket.priority,
"creator_name": creator_name,
"message": f"New {ticket_type_display.lower()} from {creator_name}: {ticket.subject}"
}
)
except Exception as e:
logger.error(f"Error handling ticket creation for ticket {ticket.id}: {e}")
def _handle_ticket_update(ticket):
"""Send notifications when a ticket is updated."""
try:
# Notify assignee if one exists
if not ticket.assignee:
return
# Create Notification object for the assignee
create_notification(
recipient=ticket.assignee,
actor=ticket.creator,
verb=f"Ticket #{ticket.id} '{ticket.subject}' was updated.",
action_object=ticket,
target=ticket,
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'status': ticket.status}
)
# Send WebSocket message to assignee's personal channel
send_websocket_notification(
f"user_{ticket.assignee.id}",
{
"type": "ticket_update",
"ticket_id": ticket.id,
"subject": ticket.subject,
"status": ticket.status,
"assignee_id": str(ticket.assignee.id),
"message": f"Ticket #{ticket.id} '{ticket.subject}' updated. Status: {ticket.status}"
}
)
except Exception as e:
logger.error(f"Error handling ticket update for ticket {ticket.id}: {e}")
@receiver(post_save, sender=TicketComment)
def comment_notification_handler(sender, instance, created, **kwargs):
if created:
channel_layer = get_channel_layer()
"""Handle comment creation and send notifications to relevant parties."""
if not created:
return
try:
ticket = instance.ticket
author_name = instance.author.full_name if instance.author else "Someone"
# Track first_response_at: when a comment is added by someone other than the ticket creator
if not ticket.first_response_at and instance.author and instance.author != ticket.creator:
try:
ticket.first_response_at = timezone.now()
ticket.save(update_fields=['first_response_at'])
logger.info(f"Set first_response_at for ticket {ticket.id}")
except Exception as e:
logger.error(f"Failed to set first_response_at for ticket {ticket.id}: {e}")
# Notify creator of the ticket (if not the commenter)
if ticket.creator and ticket.creator != instance.author:
# Create Notification object for the ticket creator
Notification.objects.create(
create_notification(
recipient=ticket.creator,
actor=instance.author,
verb=f"New comment on your ticket #{ticket.id} '{ticket.subject}'.",
@@ -73,26 +226,21 @@ def comment_notification_handler(sender, instance, created, **kwargs):
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'comment_id': instance.id}
)
# Send WebSocket message to creator's personal channel
async_to_sync(channel_layer.group_send)(
send_websocket_notification(
f"user_{ticket.creator.id}",
{
"type": "notification_message",
"message": {
"type": "new_comment",
"ticket_id": ticket.id,
"subject": ticket.subject,
"comment_id": instance.id,
"author_name": instance.author.full_name,
"message": f"New comment on your ticket #{ticket.id} from {instance.author.full_name}."
}
"type": "new_comment",
"ticket_id": ticket.id,
"subject": ticket.subject,
"comment_id": instance.id,
"author_name": author_name,
"message": f"New comment on your ticket #{ticket.id} from {author_name}."
}
)
# Notify assignee of the ticket (if not the commenter and not the creator)
if ticket.assignee and ticket.assignee != instance.author and ticket.assignee != ticket.creator:
# Create Notification object for the ticket assignee
Notification.objects.create(
create_notification(
recipient=ticket.assignee,
actor=instance.author,
verb=f"New comment on ticket #{ticket.id} '{ticket.subject}' you are assigned to.",
@@ -100,18 +248,17 @@ def comment_notification_handler(sender, instance, created, **kwargs):
target=ticket,
data={'ticket_id': ticket.id, 'subject': ticket.subject, 'comment_id': instance.id}
)
# Send WebSocket message to assignee's personal channel
async_to_sync(channel_layer.group_send)(
send_websocket_notification(
f"user_{ticket.assignee.id}",
{
"type": "notification_message",
"message": {
"type": "new_comment",
"ticket_id": ticket.id,
"subject": ticket.subject,
"comment_id": instance.id,
"author_name": instance.author.full_name,
"message": f"New comment on ticket #{ticket.id} you are assigned to from {instance.author.full_name}."
}
"type": "new_comment",
"ticket_id": ticket.id,
"subject": ticket.subject,
"comment_id": instance.id,
"author_name": author_name,
"message": f"New comment on ticket #{ticket.id} you are assigned to from {author_name}."
}
)
except Exception as e:
logger.error(f"Error in comment_notification_handler for comment {instance.id}: {e}")

View File

@@ -1,19 +1,32 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TicketViewSet, TicketCommentViewSet
from .views import (
TicketViewSet, TicketCommentViewSet,
TicketTemplateViewSet, CannedResponseViewSet
)
app_name = 'tickets'
router = DefaultRouter()
router.register(r'tickets', TicketViewSet, basename='ticket')
# Main tickets endpoint - will be at /api/tickets/
router.register(r'', TicketViewSet, basename='ticket')
# Nested comments route
# Nested comments route - will be at /api/tickets/{ticket_pk}/comments/
router.register(
r'tickets/(?P<ticket_pk>[^/.]+)/comments',
r'(?P<ticket_pk>[^/.]+)/comments',
TicketCommentViewSet,
basename='ticket-comment'
)
# Separate router for templates and canned responses
templates_router = DefaultRouter()
templates_router.register(r'', TicketTemplateViewSet, basename='ticket-template')
canned_router = DefaultRouter()
canned_router.register(r'', CannedResponseViewSet, basename='canned-response')
urlpatterns = [
path('', include(router.urls)),
path('templates/', include(templates_router.urls)),
path('canned-responses/', include(canned_router.urls)),
]

View File

@@ -1,12 +1,31 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from rest_framework.filters import OrderingFilter, SearchFilter
from core.models import Tenant
from smoothschedule.users.models import User
from .models import Ticket, TicketComment
from .serializers import TicketSerializer, TicketCommentSerializer
from .models import Ticket, TicketComment, TicketTemplate, CannedResponse
from .serializers import (
TicketSerializer, TicketListSerializer, TicketCommentSerializer,
TicketTemplateSerializer, CannedResponseSerializer
)
def is_platform_admin(user):
"""Check if user is a platform-level administrator."""
return user.role in [
User.Role.SUPERUSER,
User.Role.PLATFORM_MANAGER,
User.Role.PLATFORM_SUPPORT,
]
def is_customer(user):
"""Check if user is a customer."""
return user.role == User.Role.CUSTOMER
class IsTenantUser(IsAuthenticated):
@@ -18,7 +37,7 @@ class IsTenantUser(IsAuthenticated):
if not super().has_permission(request, view):
return False
# Platform admins can do anything
if request.user.is_platform_admin:
if is_platform_admin(request.user):
return True
# Tenant users can only access their own tenant's data
return hasattr(request.user, 'tenant') and request.user.tenant is not None
@@ -29,7 +48,7 @@ class IsTicketOwnerOrAssigneeOrPlatformAdmin(IsTenantUser):
Custom permission to only allow owners, assignees, or platform admins to view/edit tickets.
"""
def has_object_permission(self, request, view, obj):
if request.user.is_platform_admin:
if is_platform_admin(request.user):
return True
if request.user == obj.creator or request.user == obj.assignee:
return True
@@ -43,10 +62,21 @@ class IsTicketOwnerOrAssigneeOrPlatformAdmin(IsTenantUser):
class TicketViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows tickets to be viewed or edited.
Includes filtering by status, priority, category, ticket_type, and assignee.
"""
queryset = Ticket.objects.all().select_related('tenant', 'creator', 'assignee')
serializer_class = TicketSerializer
permission_classes = [IsTicketOwnerOrAssigneeOrPlatformAdmin]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['created_at', 'updated_at', 'priority', 'status', 'due_at']
ordering = ['-created_at']
search_fields = ['subject', 'description']
def get_serializer_class(self):
"""Use TicketListSerializer for list view, TicketSerializer for detail view."""
if self.action == 'list':
return TicketListSerializer
return TicketSerializer
def get_queryset(self):
"""
@@ -59,17 +89,36 @@ class TicketViewSet(viewsets.ModelViewSet):
user = self.request.user
queryset = super().get_queryset()
if user.is_platform_admin:
return queryset # Platform admins see everything
if hasattr(user, 'tenant') and user.tenant:
if is_platform_admin(user):
queryset = queryset # Platform admins see everything
elif hasattr(user, 'tenant') and user.tenant:
# Tenant-level users
q_filter = Q(tenant=user.tenant) | Q(creator=user, ticket_type=Ticket.TicketType.PLATFORM)
return queryset.filter(q_filter).distinct()
queryset = queryset.filter(q_filter).distinct()
else:
# Regular users (e.g., customers without an associated tenant, if that's a case)
# They should only see tickets they created
return queryset.filter(creator=user)
queryset = queryset.filter(creator=user)
# Apply query parameter filters
status_filter = self.request.query_params.get('status')
priority_filter = self.request.query_params.get('priority')
category_filter = self.request.query_params.get('category')
ticket_type_filter = self.request.query_params.get('ticket_type')
assignee_filter = self.request.query_params.get('assignee')
if status_filter:
queryset = queryset.filter(status=status_filter)
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
if category_filter:
queryset = queryset.filter(category=category_filter)
if ticket_type_filter:
queryset = queryset.filter(ticket_type=ticket_type_filter)
if assignee_filter:
queryset = queryset.filter(assignee_id=assignee_filter)
return queryset
def perform_create(self, serializer):
# Creator is automatically set by the serializer
@@ -82,6 +131,65 @@ class TicketViewSet(viewsets.ModelViewSet):
serializer.validated_data.pop('tenant', None)
serializer.save()
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def my_tickets(self, request):
"""
Get all tickets created by or assigned to the requesting user.
URL: /api/tickets/my-tickets/
"""
user = request.user
queryset = self.get_queryset().filter(
Q(creator=user) | Q(assignee=user)
).distinct()
# Apply filters
queryset = self.filter_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def tenant_tickets(self, request):
"""
Get all tickets for the business owner's tenant.
Only accessible by tenant owners, managers, and staff.
URL: /api/tickets/tenant-tickets/
"""
user = request.user
# Check if user has tenant access
if not hasattr(user, 'tenant') or not user.tenant:
return Response(
{'error': 'You do not have access to tenant tickets.'},
status=status.HTTP_403_FORBIDDEN
)
# Customers should use my-tickets instead
if is_customer(user):
return Response(
{'error': 'Customers should use the my-tickets endpoint.'},
status=status.HTTP_403_FORBIDDEN
)
# Get all tickets for the user's tenant
queryset = self.get_queryset().filter(tenant=user.tenant)
# Apply filters
queryset = self.filter_queryset(queryset)
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
class TicketCommentViewSet(viewsets.ModelViewSet):
"""
@@ -100,7 +208,7 @@ class TicketCommentViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(ticket__pk=ticket_pk)
user = self.request.user
if user.is_platform_admin:
if is_platform_admin(user):
return queryset # Platform admins see all comments
# For tenant-level users, ensure they can only see comments for tickets they can access
@@ -112,8 +220,8 @@ class TicketCommentViewSet(viewsets.ModelViewSet):
queryset = queryset.filter(ticket__creator=user)
# Hide internal comments from customers
if user.is_customer: # Assuming there's an `is_customer` property or role check
queryset = queryset.filter(is_internal=False)
if is_customer(user):
queryset = queryset.filter(is_internal=False)
return queryset
@@ -126,4 +234,90 @@ class TicketCommentViewSet(viewsets.ModelViewSet):
raise status.HTTP_404_NOT_FOUND
# Author is automatically set to the requesting user
serializer.save(ticket=ticket, author=self.request.user)
serializer.save(ticket=ticket, author=self.request.user)
class TicketTemplateViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing ticket templates.
Platform admins can see all templates. Tenant users see their own + platform-wide templates.
"""
queryset = TicketTemplate.objects.all()
serializer_class = TicketTemplateSerializer
permission_classes = [IsAuthenticated]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['created_at', 'name']
ordering = ['ticket_type', 'name']
search_fields = ['name', 'description']
def get_queryset(self):
"""
Filter templates based on user role.
- Platform admins see all templates
- Tenant users see their own templates + platform-wide templates (tenant=null)
"""
user = self.request.user
queryset = super().get_queryset()
if is_platform_admin(user):
return queryset
if hasattr(user, 'tenant') and user.tenant:
# Tenant users see their own templates + platform-wide templates
return queryset.filter(Q(tenant=user.tenant) | Q(tenant__isnull=True))
else:
# Users without a tenant can only see platform-wide templates
return queryset.filter(tenant__isnull=True)
def perform_create(self, serializer):
# Tenant is automatically set by the serializer
serializer.save()
class CannedResponseViewSet(viewsets.ModelViewSet):
"""
API endpoint for managing canned responses.
Platform admins can see all responses. Tenant users see their own + platform-wide responses.
"""
queryset = CannedResponse.objects.all().select_related('tenant', 'created_by')
serializer_class = CannedResponseSerializer
permission_classes = [IsAuthenticated]
filter_backends = [OrderingFilter, SearchFilter]
ordering_fields = ['created_at', 'use_count', 'title']
ordering = ['-use_count', 'title']
search_fields = ['title', 'content']
def get_queryset(self):
"""
Filter canned responses based on user role.
- Platform admins see all responses
- Tenant users see their own responses + platform-wide responses (tenant=null)
"""
user = self.request.user
queryset = super().get_queryset()
if is_platform_admin(user):
return queryset
if hasattr(user, 'tenant') and user.tenant:
# Tenant users see their own responses + platform-wide responses
return queryset.filter(Q(tenant=user.tenant) | Q(tenant__isnull=True))
else:
# Users without a tenant can only see platform-wide responses
return queryset.filter(tenant__isnull=True)
def perform_create(self, serializer):
# Tenant and created_by are automatically set by the serializer
serializer.save()
@action(detail=True, methods=['post'], permission_classes=[IsAuthenticated])
def use(self, request, pk=None):
"""
Increment the use_count for a canned response.
URL: /api/canned-responses/{id}/use/
"""
canned_response = self.get_object()
canned_response.use_count += 1
canned_response.save(update_fields=['use_count'])
serializer = self.get_serializer(canned_response)
return Response(serializer.data)