feat: Implement core support ticket system with WebSocket notifications

This commit is contained in:
poduck
2025-11-28 04:46:29 -05:00
parent 640961904e
commit 3761480d68
25 changed files with 747 additions and 1 deletions

View File

@@ -75,6 +75,7 @@ THIRD_PARTY_APPS = [
"rest_framework.authtoken",
"corsheaders",
"drf_spectacular",
"channels", # New: Django Channels for WebSockets
]
LOCAL_APPS = [
@@ -83,6 +84,7 @@ LOCAL_APPS = [
"schedule",
"payments",
"platform_admin.apps.PlatformAdminConfig",
"notifications", # New: Generic notification app
# Your stuff: custom apps go here
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@@ -316,5 +318,18 @@ SPECTACULAR_SETTINGS = {
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
"SCHEMA_PATH_PREFIX": "/api/",
}
# Django Channels
# ------------------------------------------------------------------------------
ASGI_APPLICATION = "config.asgi.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
"CONFIG": {
"hosts": [REDIS_URL],
},
},
}
# Your stuff...
# ------------------------------------------------------------------------------

View File

@@ -43,6 +43,7 @@ SHARED_APPS = [
'crispy_forms',
'crispy_bootstrap5',
'csp',
'tickets', # New: Core ticket system
]
# Tenant-specific apps - Each tenant gets isolated data in their own schema
@@ -51,13 +52,14 @@ TENANT_APPS = [
'schedule', # Resource scheduling with configurable concurrency
'payments', # Stripe Connect payments bridge
'communication', # Twilio masked communications
'notifications', # New: Generic notification app
# Add your tenant-scoped business logic apps here:
# 'appointments',
# 'customers',
# 'analytics',
]
# Override INSTALLED_APPS to include all unique apps
INSTALLED_APPS = list(dict.fromkeys(SHARED_APPS + TENANT_APPS))

View File

@@ -41,6 +41,8 @@ urlpatterns += [
path("api/", include("schedule.urls")),
# Payments API
path("api/payments/", include("payments.urls")),
# Tickets API
path("api/tickets/", include("tickets.urls")),
# Platform API
path("api/platform/", include("platform_admin.urls", namespace="platform")),
# Auth API

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'notifications'

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.8 on 2025-11-28 09:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_object_id', models.CharField(blank=True, max_length=255, null=True)),
('verb', models.CharField(help_text="A short phrase describing the action (e.g., 'assigned you to a ticket', 'commented on your ticket').", max_length=255)),
('action_object_object_id', models.CharField(blank=True, max_length=255, null=True)),
('target_object_id', models.CharField(blank=True, max_length=255, null=True)),
('read', models.BooleanField(default=False, help_text='Whether the recipient has read this notification.')),
('timestamp', models.DateTimeField(auto_now_add=True)),
('data', models.JSONField(blank=True, default=dict, help_text='Optional JSON data for extra context, e.g., links, previous values.')),
('action_object_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications_as_action_object', to='contenttypes.contenttype')),
('actor_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications_as_actor', to='contenttypes.contenttype')),
('recipient', models.ForeignKey(help_text='The user who should receive this notification.', on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
('target_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications_as_target', to='contenttypes.contenttype')),
],
options={
'ordering': ['-timestamp'],
'indexes': [models.Index(fields=['recipient', 'read', 'timestamp'], name='notificatio_recipie_02bf7f_idx'), models.Index(fields=['recipient', 'timestamp'], name='notificatio_recipie_236852_idx')],
},
),
]

View File

@@ -0,0 +1,67 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from smoothschedule.users.models import User
class Notification(models.Model):
"""
Generic notification model for in-app user notifications.
Uses Django's GenericForeignKey to link to any object that triggers the notification.
"""
recipient = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='notifications',
help_text="The user who should receive this notification."
)
actor_content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
related_name='notifications_as_actor',
null=True, blank=True
)
actor_object_id = models.CharField(max_length=255, null=True, blank=True)
actor = GenericForeignKey('actor_content_type', 'actor_object_id')
verb = models.CharField(
max_length=255,
help_text="A short phrase describing the action (e.g., 'assigned you to a ticket', 'commented on your ticket')."
)
action_object_content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
related_name='notifications_as_action_object',
null=True, blank=True
)
action_object_object_id = models.CharField(max_length=255, null=True, blank=True)
action_object = GenericForeignKey('action_object_content_type', 'action_object_object_id')
target_content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
related_name='notifications_as_target',
null=True, blank=True
)
target_object_id = models.CharField(max_length=255, null=True, blank=True)
target = GenericForeignKey('target_content_type', 'target_object_id')
read = models.BooleanField(default=False, help_text="Whether the recipient has read this notification.")
timestamp = models.DateTimeField(auto_now_add=True)
data = models.JSONField(
default=dict,
blank=True,
help_text="Optional JSON data for extra context, e.g., links, previous values."
)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['recipient', 'read', 'timestamp']),
models.Index(fields=['recipient', 'timestamp']),
]
def __str__(self):
return f"{self.recipient.email} - {self.verb} - {self.timestamp.strftime('%Y-%m-%d %H:%M')}"

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

@@ -168,6 +168,8 @@ requires-python = "==3.13.*"
dependencies = [
"argon2-cffi==25.1.0",
"celery==5.5.3",
"channels==4.0.0",
"channels-redis==4.1.0",
"crispy-bootstrap5==2025.6",
"django==5.2.8",
"django-allauth[mfa]==65.13.1",

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,9 @@
from django.apps import AppConfig
class TicketsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tickets'
def ready(self):
import smoothschedule.tickets.signals # noqa

View File

@@ -0,0 +1,69 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async
from smoothschedule.users.models import User
from .models import Ticket, TicketComment
from .serializers import TicketSerializer, TicketCommentSerializer # Import your serializers
class BaseConsumer(AsyncWebsocketConsumer):
async def connect(self):
if self.scope["user"].is_authenticated:
# Add user to a group for their tenant
if hasattr(self.scope["user"], 'tenant') and self.scope["user"].tenant:
self.tenant_group_name = f'tenant_{self.scope["user"].tenant.schema_name}'
await self.channel_layer.group_add(
self.tenant_group_name,
self.channel_name
)
# Add user to their personal group
self.user_group_name = f'user_{self.scope["user"].id}'
await self.channel_layer.group_add(
self.user_group_name,
self.channel_name
)
await self.accept()
else:
await self.close()
async def disconnect(self, close_code):
if hasattr(self, 'tenant_group_name'):
await self.channel_layer.group_discard(
self.tenant_group_name,
self.channel_name
)
if hasattr(self, 'user_group_name'):
await self.channel_layer.group_discard(
self.user_group_name,
self.channel_name
)
async def receive(self, text_data):
# Consumers are read-only for now, no client-to-server messages expected
pass
async def send_json(self, event):
"""Helper to send JSON message to client"""
await self.send(text_data=json.dumps(event['message']))
class TicketConsumer(BaseConsumer):
async def connect(self):
await super().connect()
if self.scope["user"].is_authenticated:
# Add user to groups for tickets they are involved with (creator or assignee)
# This is more complex, might be better handled by signals pushing to user/tenant groups
pass
async def disconnect(self, close_code):
await super().disconnect(close_code)
# Clean up any ticket-specific groups if necessary
async def ticket_message(self, event):
"""Receive message from ticket group and send to WebSocket"""
await self.send_json(event)
class NotificationConsumer(BaseConsumer):
async def notification_message(self, event):
"""Receive message from notification group and send to WebSocket"""
await self.send_json(event)

View File

@@ -0,0 +1,65 @@
# Generated by Django 5.2.8 on 2025-11-28 09:16
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('core', '0007_add_tenant_permissions'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Ticket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ticket_type', models.CharField(choices=[('PLATFORM', 'Platform Support'), ('CUSTOMER', 'Customer Inquiry'), ('STAFF_REQUEST', 'Staff Request')], default='CUSTOMER', help_text='Distinguishes between platform support tickets and customer/staff tickets.', max_length=20)),
('status', models.CharField(choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('RESOLVED', 'Resolved'), ('CLOSED', 'Closed')], default='OPEN', help_text='Current status of the ticket.', max_length=20)),
('priority', models.CharField(choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High'), ('URGENT', 'Urgent')], default='MEDIUM', help_text='Priority level of the ticket.', max_length=20)),
('subject', models.CharField(help_text='Subject line of the ticket.', max_length=255)),
('description', models.TextField(help_text='Detailed description of the issue or request.')),
('category', models.CharField(blank=True, help_text='Category of the ticket (e.g., Billing, Technical, Feature Request).', max_length=50)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('assignee', models.ForeignKey(blank=True, help_text='The user (e.g., support agent, staff) currently assigned to this ticket.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)),
('creator', models.ForeignKey(help_text='The user who created this ticket.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tickets', to=settings.AUTH_USER_MODEL)),
('tenant', models.ForeignKey(blank=True, help_text='The tenant (business) this ticket belongs to. Null for platform-level tickets.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to='core.tenant')),
],
options={
'ordering': ['-priority', '-created_at'],
},
),
migrations.CreateModel(
name='TicketComment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('comment_text', models.TextField(help_text='The content of the comment.')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_internal', models.BooleanField(default=False, help_text='If true, this comment is only visible to internal staff/platform admins.')),
('author', models.ForeignKey(help_text='The user who wrote this comment.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ticket_comments', to=settings.AUTH_USER_MODEL)),
('ticket', models.ForeignKey(help_text='The ticket this comment belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='tickets.ticket')),
],
options={
'ordering': ['created_at'],
},
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['tenant', 'status'], name='tickets_tic_tenant__c58352_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['assignee', 'status'], name='tickets_tic_assigne_294cb9_idx'),
),
migrations.AddIndex(
model_name='ticket',
index=models.Index(fields=['ticket_type', 'status'], name='tickets_tic_ticket__73a594_idx'),
),
]

View File

@@ -0,0 +1,127 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from core.models import Tenant
from smoothschedule.users.models import User # Assuming smoothschedule.users is the app for User model
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).
"""
class TicketType(models.TextChoices):
PLATFORM = 'PLATFORM', _('Platform Support')
CUSTOMER = 'CUSTOMER', _('Customer Inquiry')
STAFF_REQUEST = 'STAFF_REQUEST', _('Staff Request')
class Status(models.TextChoices):
OPEN = 'OPEN', _('Open')
IN_PROGRESS = 'IN_PROGRESS', _('In Progress')
RESOLVED = 'RESOLVED', _('Resolved')
CLOSED = 'CLOSED', _('Closed')
class Priority(models.TextChoices):
LOW = 'LOW', _('Low')
MEDIUM = 'MEDIUM', _('Medium')
HIGH = 'HIGH', _('High')
URGENT = 'URGENT', _('Urgent')
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
related_name='tickets',
null=True, # For platform-level tickets created by platform admins, tenant might be null
blank=True, # For platform-level tickets created by platform admins, tenant might be null
help_text="The tenant (business) this ticket belongs to. Null for platform-level tickets."
)
creator = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name='created_tickets',
null=True,
help_text="The user who created this ticket."
)
assignee = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name='assigned_tickets',
null=True,
blank=True,
help_text="The user (e.g., support agent, staff) currently assigned to this ticket."
)
ticket_type = models.CharField(
max_length=20,
choices=TicketType.choices,
default=TicketType.CUSTOMER,
help_text="Distinguishes between platform support tickets and customer/staff tickets."
)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.OPEN,
help_text="Current status of the ticket."
)
priority = models.CharField(
max_length=20,
choices=Priority.choices,
default=Priority.MEDIUM,
help_text="Priority level of the ticket."
)
subject = models.CharField(max_length=255, help_text="Subject line of the ticket.")
description = models.TextField(help_text="Detailed description of the issue or request.")
category = models.CharField(
max_length=50,
blank=True,
help_text="Category of the ticket (e.g., Billing, Technical, Feature Request)."
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
resolved_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-priority', '-created_at']
indexes = [
models.Index(fields=['tenant', 'status']),
models.Index(fields=['assignee', 'status']),
models.Index(fields=['ticket_type', 'status']),
]
def __str__(self):
return f"Ticket #{self.id}: {self.subject} ({self.get_status_display()})"
class TicketComment(models.Model):
"""
Represents a comment or update on a support ticket.
"""
ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name='comments',
help_text="The ticket this comment belongs to."
)
author = models.ForeignKey(
User,
on_delete=models.SET_NULL,
related_name='ticket_comments',
null=True,
help_text="The user who wrote this comment."
)
comment_text = models.TextField(help_text="The content of the comment.")
created_at = models.DateTimeField(auto_now_add=True)
is_internal = models.BooleanField(
default=False,
help_text="If true, this comment is only visible to internal staff/platform admins."
)
class Meta:
ordering = ['created_at']
def __str__(self):
return f"Comment on Ticket #{self.ticket.id} by {self.author.email} at {self.created_at.strftime('%Y-%m-%d %H:%M')}"

View File

@@ -0,0 +1,8 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"ws/tickets/$", consumers.TicketConsumer.as_asgi()),
re_path(r"ws/notifications/$", consumers.NotificationConsumer.as_asgi()),
]

View File

@@ -0,0 +1,55 @@
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 core.models import Tenant
class TicketCommentSerializer(serializers.ModelSerializer):
author_email = serializers.ReadOnlyField(source='author.email')
author_full_name = serializers.ReadOnlyField(source='author.full_name')
class Meta:
model = TicketComment
fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'comment_text', 'created_at', 'is_internal']
read_only_fields = ['id', 'ticket', 'author', 'author_email', 'author_full_name', 'created_at']
class TicketSerializer(serializers.ModelSerializer):
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')
comments = TicketCommentSerializer(many=True, read_only=True) # Nested serializer for comments
class Meta:
model = Ticket
fields = [
'id', 'tenant', 'creator', 'creator_email', 'creator_full_name',
'assignee', 'assignee_email', 'assignee_full_name',
'ticket_type', 'status', 'priority', 'subject', 'description', 'category',
'created_at', 'updated_at', 'resolved_at', 'comments'
]
read_only_fields = ['id', 'creator', 'creator_email', 'creator_full_name', '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)
if 'creator' not in validated_data and self.context['request'].user.is_authenticated:
validated_data['creator'] = self.context['request'].user
# Ensure tenant is set for non-platform tickets
if validated_data.get('ticket_type') != Ticket.TicketType.PLATFORM:
# For tenant-specific tickets, ensure the requesting user's tenant is set
if hasattr(self.context['request'].user, 'tenant') and self.context['request'].user.tenant:
validated_data['tenant'] = self.context['request'].user.tenant
else:
raise serializers.ValidationError({"tenant": "Tenant must be provided for non-platform tickets."})
elif validated_data.get('ticket_type') == Ticket.TicketType.PLATFORM and not validated_data.get('tenant'):
# If platform ticket, but a platform admin wants to associate it with a tenant
# This means the tenant should be provided explicitly in the request
pass # Let it be null or provided by platform admin
return super().create(validated_data)
def update(self, instance, validated_data):
# Prevent changing tenant or creator after creation
validated_data.pop('tenant', None)
validated_data.pop('creator', None)
return super().update(instance, validated_data)

View File

@@ -0,0 +1,117 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
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
@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.
# 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}"
}
}
)
# General notification for tenant/platform admins (if needed)
# This might be too broad, usually target specific groups/users
pass
@receiver(post_save, sender=TicketComment)
def comment_notification_handler(sender, instance, created, **kwargs):
if created:
channel_layer = get_channel_layer()
ticket = instance.ticket
# 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(
recipient=ticket.creator,
actor=instance.author,
verb=f"New comment on your ticket #{ticket.id} '{ticket.subject}'.",
action_object=instance,
target=ticket,
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)(
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}."
}
}
)
# 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(
recipient=ticket.assignee,
actor=instance.author,
verb=f"New comment on ticket #{ticket.id} '{ticket.subject}' you are assigned to.",
action_object=instance,
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)(
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}."
}
}
)

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,19 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TicketViewSet, TicketCommentViewSet
app_name = 'tickets'
router = DefaultRouter()
router.register(r'tickets', TicketViewSet, basename='ticket')
# Nested comments route
router.register(
r'tickets/(?P<ticket_pk>[^/.]+)/comments',
TicketCommentViewSet,
basename='ticket-comment'
)
urlpatterns = [
path('', include(router.urls)),
]

View File

@@ -0,0 +1,129 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
from core.models import Tenant
from smoothschedule.users.models import User
from .models import Ticket, TicketComment
from .serializers import TicketSerializer, TicketCommentSerializer
class IsTenantUser(IsAuthenticated):
"""
Custom permission to only allow users of the current tenant to access.
Platform admins can access all.
"""
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
# Platform admins can do anything
if request.user.is_platform_admin:
return True
# Tenant users can only access their own tenant's data
return hasattr(request.user, 'tenant') and request.user.tenant is not None
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:
return True
if request.user == obj.creator or request.user == obj.assignee:
return True
# Tenant owner/manager/staff can manage tickets for their tenant
if hasattr(request.user, 'tenant') and obj.tenant == request.user.tenant:
# Additional checks can be added here, e.g., request.user.role in [User.Role.TENANT_OWNER, User.Role.MANAGER]
return True
return False
class TicketViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows tickets to be viewed or edited.
"""
queryset = Ticket.objects.all().select_related('tenant', 'creator', 'assignee')
serializer_class = TicketSerializer
permission_classes = [IsTicketOwnerOrAssigneeOrPlatformAdmin]
def get_queryset(self):
"""
Filter tickets based on user role and ticket type.
- Platform Admins see all tickets (platform, customer, staff_request)
- Tenant Owners/Managers/Staff see customer/staff_request tickets for their tenant
and platform tickets they created
- Customers see customer tickets they created
"""
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:
# Tenant-level users
q_filter = Q(tenant=user.tenant) | Q(creator=user, ticket_type=Ticket.TicketType.PLATFORM)
return 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)
def perform_create(self, serializer):
# Creator is automatically set by the serializer
# Tenant is automatically set by the serializer for non-platform tickets
serializer.save()
def perform_update(self, serializer):
# Prevent changing creator or tenant through update
serializer.validated_data.pop('creator', None)
serializer.validated_data.pop('tenant', None)
serializer.save()
class TicketCommentViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows ticket comments to be viewed or edited.
"""
queryset = TicketComment.objects.all().select_related('ticket', 'author')
serializer_class = TicketCommentSerializer
permission_classes = [IsTicketOwnerOrAssigneeOrPlatformAdmin] # Reusing for now
def get_queryset(self):
"""Filter comments based on the associated ticket and user permissions."""
queryset = super().get_queryset()
ticket_pk = self.kwargs.get('ticket_pk') # Assuming nested URL like /tickets/{ticket_pk}/comments/
if ticket_pk:
queryset = queryset.filter(ticket__pk=ticket_pk)
user = self.request.user
if user.is_platform_admin:
return queryset # Platform admins see all comments
# For tenant-level users, ensure they can only see comments for tickets they can access
# This implicitly filters by the Ticket's permissions
if hasattr(user, 'tenant') and user.tenant:
q_filter = Q(ticket__tenant=user.tenant) | Q(ticket__creator=user, ticket__ticket_type=Ticket.TicketType.PLATFORM)
queryset = queryset.filter(q_filter).distinct()
else:
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)
return queryset
def perform_create(self, serializer):
ticket_pk = self.kwargs.get('ticket_pk')
try:
ticket = Ticket.objects.get(pk=ticket_pk)
except Ticket.DoesNotExist:
raise status.HTTP_404_NOT_FOUND
# Author is automatically set to the requesting user
serializer.save(ticket=ticket, author=self.request.user)