From 3761480d68c6a938ed4b8228106a3a2993109a45 Mon Sep 17 00:00:00 2001 From: poduck Date: Fri, 28 Nov 2025 04:46:29 -0500 Subject: [PATCH] feat: Implement core support ticket system with WebSocket notifications --- smoothschedule/config/settings/base.py | 15 ++ .../config/settings/multitenancy.py | 4 +- smoothschedule/config/urls.py | 2 + smoothschedule/notifications/__init__.py | 0 smoothschedule/notifications/admin.py | 3 + smoothschedule/notifications/apps.py | 6 + .../notifications/migrations/0001_initial.py | 39 ++++++ .../notifications/migrations/__init__.py | 0 smoothschedule/notifications/models.py | 67 +++++++++ smoothschedule/notifications/tests.py | 3 + smoothschedule/notifications/views.py | 3 + smoothschedule/pyproject.toml | 2 + smoothschedule/tickets/__init__.py | 0 smoothschedule/tickets/admin.py | 3 + smoothschedule/tickets/apps.py | 9 ++ smoothschedule/tickets/consumers.py | 69 ++++++++++ .../tickets/migrations/0001_initial.py | 65 +++++++++ smoothschedule/tickets/migrations/__init__.py | 0 smoothschedule/tickets/models.py | 127 +++++++++++++++++ smoothschedule/tickets/routing.py | 8 ++ smoothschedule/tickets/serializers.py | 55 ++++++++ smoothschedule/tickets/signals.py | 117 ++++++++++++++++ smoothschedule/tickets/tests.py | 3 + smoothschedule/tickets/urls.py | 19 +++ smoothschedule/tickets/views.py | 129 ++++++++++++++++++ 25 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 smoothschedule/notifications/__init__.py create mode 100644 smoothschedule/notifications/admin.py create mode 100644 smoothschedule/notifications/apps.py create mode 100644 smoothschedule/notifications/migrations/0001_initial.py create mode 100644 smoothschedule/notifications/migrations/__init__.py create mode 100644 smoothschedule/notifications/models.py create mode 100644 smoothschedule/notifications/tests.py create mode 100644 smoothschedule/notifications/views.py create mode 100644 smoothschedule/tickets/__init__.py create mode 100644 smoothschedule/tickets/admin.py create mode 100644 smoothschedule/tickets/apps.py create mode 100644 smoothschedule/tickets/consumers.py create mode 100644 smoothschedule/tickets/migrations/0001_initial.py create mode 100644 smoothschedule/tickets/migrations/__init__.py create mode 100644 smoothschedule/tickets/models.py create mode 100644 smoothschedule/tickets/routing.py create mode 100644 smoothschedule/tickets/serializers.py create mode 100644 smoothschedule/tickets/signals.py create mode 100644 smoothschedule/tickets/tests.py create mode 100644 smoothschedule/tickets/urls.py create mode 100644 smoothschedule/tickets/views.py diff --git a/smoothschedule/config/settings/base.py b/smoothschedule/config/settings/base.py index 39ea1dd..6910f46 100644 --- a/smoothschedule/config/settings/base.py +++ b/smoothschedule/config/settings/base.py @@ -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... # ------------------------------------------------------------------------------ diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py index a6afb5d..b70322b 100644 --- a/smoothschedule/config/settings/multitenancy.py +++ b/smoothschedule/config/settings/multitenancy.py @@ -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)) diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index 127a2ee..059d732 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -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 diff --git a/smoothschedule/notifications/__init__.py b/smoothschedule/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/notifications/admin.py b/smoothschedule/notifications/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/smoothschedule/notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/smoothschedule/notifications/apps.py b/smoothschedule/notifications/apps.py new file mode 100644 index 0000000..001b4f9 --- /dev/null +++ b/smoothschedule/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notifications' diff --git a/smoothschedule/notifications/migrations/0001_initial.py b/smoothschedule/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..6bb1bfb --- /dev/null +++ b/smoothschedule/notifications/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/smoothschedule/notifications/migrations/__init__.py b/smoothschedule/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/notifications/models.py b/smoothschedule/notifications/models.py new file mode 100644 index 0000000..ee2df2d --- /dev/null +++ b/smoothschedule/notifications/models.py @@ -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')}" \ No newline at end of file diff --git a/smoothschedule/notifications/tests.py b/smoothschedule/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/smoothschedule/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/smoothschedule/notifications/views.py b/smoothschedule/notifications/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/smoothschedule/notifications/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/smoothschedule/pyproject.toml b/smoothschedule/pyproject.toml index 52c566f..3aa3d47 100644 --- a/smoothschedule/pyproject.toml +++ b/smoothschedule/pyproject.toml @@ -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", diff --git a/smoothschedule/tickets/__init__.py b/smoothschedule/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/tickets/admin.py b/smoothschedule/tickets/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/smoothschedule/tickets/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/smoothschedule/tickets/apps.py b/smoothschedule/tickets/apps.py new file mode 100644 index 0000000..e20efd4 --- /dev/null +++ b/smoothschedule/tickets/apps.py @@ -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 diff --git a/smoothschedule/tickets/consumers.py b/smoothschedule/tickets/consumers.py new file mode 100644 index 0000000..e695ad5 --- /dev/null +++ b/smoothschedule/tickets/consumers.py @@ -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) diff --git a/smoothschedule/tickets/migrations/0001_initial.py b/smoothschedule/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..566c1a0 --- /dev/null +++ b/smoothschedule/tickets/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/smoothschedule/tickets/migrations/__init__.py b/smoothschedule/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/smoothschedule/tickets/models.py b/smoothschedule/tickets/models.py new file mode 100644 index 0000000..564c092 --- /dev/null +++ b/smoothschedule/tickets/models.py @@ -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')}" \ No newline at end of file diff --git a/smoothschedule/tickets/routing.py b/smoothschedule/tickets/routing.py new file mode 100644 index 0000000..5d8d6b6 --- /dev/null +++ b/smoothschedule/tickets/routing.py @@ -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()), +] \ No newline at end of file diff --git a/smoothschedule/tickets/serializers.py b/smoothschedule/tickets/serializers.py new file mode 100644 index 0000000..036287d --- /dev/null +++ b/smoothschedule/tickets/serializers.py @@ -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) diff --git a/smoothschedule/tickets/signals.py b/smoothschedule/tickets/signals.py new file mode 100644 index 0000000..f006801 --- /dev/null +++ b/smoothschedule/tickets/signals.py @@ -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}." + } + } + ) diff --git a/smoothschedule/tickets/tests.py b/smoothschedule/tickets/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/smoothschedule/tickets/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/smoothschedule/tickets/urls.py b/smoothschedule/tickets/urls.py new file mode 100644 index 0000000..834687f --- /dev/null +++ b/smoothschedule/tickets/urls.py @@ -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[^/.]+)/comments', + TicketCommentViewSet, + basename='ticket-comment' +) + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/smoothschedule/tickets/views.py b/smoothschedule/tickets/views.py new file mode 100644 index 0000000..fa21750 --- /dev/null +++ b/smoothschedule/tickets/views.py @@ -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) \ No newline at end of file