feat: Implement core support ticket system with WebSocket notifications
This commit is contained in:
@@ -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...
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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 = [
|
||||
"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",
|
||||
|
||||
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