feat: Implement core support ticket system with WebSocket notifications
This commit is contained in:
@@ -75,6 +75,7 @@ THIRD_PARTY_APPS = [
|
|||||||
"rest_framework.authtoken",
|
"rest_framework.authtoken",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_spectacular",
|
"drf_spectacular",
|
||||||
|
"channels", # New: Django Channels for WebSockets
|
||||||
]
|
]
|
||||||
|
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
@@ -83,6 +84,7 @@ LOCAL_APPS = [
|
|||||||
"schedule",
|
"schedule",
|
||||||
"payments",
|
"payments",
|
||||||
"platform_admin.apps.PlatformAdminConfig",
|
"platform_admin.apps.PlatformAdminConfig",
|
||||||
|
"notifications", # New: Generic notification app
|
||||||
# Your stuff: custom apps go here
|
# Your stuff: custom apps go here
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
@@ -316,5 +318,18 @@ SPECTACULAR_SETTINGS = {
|
|||||||
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
|
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
|
||||||
"SCHEMA_PATH_PREFIX": "/api/",
|
"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...
|
# Your stuff...
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ SHARED_APPS = [
|
|||||||
'crispy_forms',
|
'crispy_forms',
|
||||||
'crispy_bootstrap5',
|
'crispy_bootstrap5',
|
||||||
'csp',
|
'csp',
|
||||||
|
'tickets', # New: Core ticket system
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
# Tenant-specific apps - Each tenant gets isolated data in their own schema
|
||||||
@@ -51,13 +52,14 @@ TENANT_APPS = [
|
|||||||
'schedule', # Resource scheduling with configurable concurrency
|
'schedule', # Resource scheduling with configurable concurrency
|
||||||
'payments', # Stripe Connect payments bridge
|
'payments', # Stripe Connect payments bridge
|
||||||
'communication', # Twilio masked communications
|
'communication', # Twilio masked communications
|
||||||
|
'notifications', # New: Generic notification app
|
||||||
# Add your tenant-scoped business logic apps here:
|
# Add your tenant-scoped business logic apps here:
|
||||||
# 'appointments',
|
# 'appointments',
|
||||||
# 'customers',
|
# 'customers',
|
||||||
# 'analytics',
|
# 'analytics',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Override INSTALLED_APPS to include all unique apps
|
# Override INSTALLED_APPS to include all unique apps
|
||||||
INSTALLED_APPS = list(dict.fromkeys(SHARED_APPS + TENANT_APPS))
|
INSTALLED_APPS = list(dict.fromkeys(SHARED_APPS + TENANT_APPS))
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ urlpatterns += [
|
|||||||
path("api/", include("schedule.urls")),
|
path("api/", include("schedule.urls")),
|
||||||
# Payments API
|
# Payments API
|
||||||
path("api/payments/", include("payments.urls")),
|
path("api/payments/", include("payments.urls")),
|
||||||
|
# Tickets API
|
||||||
|
path("api/tickets/", include("tickets.urls")),
|
||||||
# Platform API
|
# Platform API
|
||||||
path("api/platform/", include("platform_admin.urls", namespace="platform")),
|
path("api/platform/", include("platform_admin.urls", namespace="platform")),
|
||||||
# Auth API
|
# Auth API
|
||||||
|
|||||||
0
smoothschedule/notifications/__init__.py
Normal file
0
smoothschedule/notifications/__init__.py
Normal file
3
smoothschedule/notifications/admin.py
Normal file
3
smoothschedule/notifications/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
smoothschedule/notifications/apps.py
Normal file
6
smoothschedule/notifications/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'notifications'
|
||||||
39
smoothschedule/notifications/migrations/0001_initial.py
Normal file
39
smoothschedule/notifications/migrations/0001_initial.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
smoothschedule/notifications/migrations/__init__.py
Normal file
0
smoothschedule/notifications/migrations/__init__.py
Normal file
67
smoothschedule/notifications/models.py
Normal file
67
smoothschedule/notifications/models.py
Normal 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')}"
|
||||||
3
smoothschedule/notifications/tests.py
Normal file
3
smoothschedule/notifications/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
smoothschedule/notifications/views.py
Normal file
3
smoothschedule/notifications/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
@@ -168,6 +168,8 @@ requires-python = "==3.13.*"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2-cffi==25.1.0",
|
"argon2-cffi==25.1.0",
|
||||||
"celery==5.5.3",
|
"celery==5.5.3",
|
||||||
|
"channels==4.0.0",
|
||||||
|
"channels-redis==4.1.0",
|
||||||
"crispy-bootstrap5==2025.6",
|
"crispy-bootstrap5==2025.6",
|
||||||
"django==5.2.8",
|
"django==5.2.8",
|
||||||
"django-allauth[mfa]==65.13.1",
|
"django-allauth[mfa]==65.13.1",
|
||||||
|
|||||||
0
smoothschedule/tickets/__init__.py
Normal file
0
smoothschedule/tickets/__init__.py
Normal file
3
smoothschedule/tickets/admin.py
Normal file
3
smoothschedule/tickets/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
9
smoothschedule/tickets/apps.py
Normal file
9
smoothschedule/tickets/apps.py
Normal 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
|
||||||
69
smoothschedule/tickets/consumers.py
Normal file
69
smoothschedule/tickets/consumers.py
Normal 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)
|
||||||
65
smoothschedule/tickets/migrations/0001_initial.py
Normal file
65
smoothschedule/tickets/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
smoothschedule/tickets/migrations/__init__.py
Normal file
0
smoothschedule/tickets/migrations/__init__.py
Normal file
127
smoothschedule/tickets/models.py
Normal file
127
smoothschedule/tickets/models.py
Normal 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')}"
|
||||||
8
smoothschedule/tickets/routing.py
Normal file
8
smoothschedule/tickets/routing.py
Normal 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()),
|
||||||
|
]
|
||||||
55
smoothschedule/tickets/serializers.py
Normal file
55
smoothschedule/tickets/serializers.py
Normal 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)
|
||||||
117
smoothschedule/tickets/signals.py
Normal file
117
smoothschedule/tickets/signals.py
Normal 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}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
3
smoothschedule/tickets/tests.py
Normal file
3
smoothschedule/tickets/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
19
smoothschedule/tickets/urls.py
Normal file
19
smoothschedule/tickets/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
129
smoothschedule/tickets/views.py
Normal file
129
smoothschedule/tickets/views.py
Normal 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)
|
||||||
Reference in New Issue
Block a user