From 2f0f99301c0cfc461d68ef1d8704453b8c8b57ab Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 9 May 2026 12:41:46 +0530 Subject: [PATCH 1/2] =?UTF-8?q?G2:=20NAM=20module=20=E2=80=94=20final=20su?= =?UTF-8?q?bmission=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification & Announcement Module backend (final submission for IT3C03). Roll numbers: 23BCS131, 23BCS054, 23BCS005 New module at applications/notifications_extension/: - services.py — business logic + custom exceptions (chokepoint for BR-NT-01..09) - selectors.py — DB read queries only (no writes) - models.py — TextChoices + 4 models (NotificationPreference, NotificationEventType, Announcement with approval_id UUID, ArchivedNotification) - api/views.py — 52 thin DRF endpoints - api/serializers.py — field-level validation only - api/urls.py — URL routing (each route registered with + without trailing slash) - api/exception_handler.py — global JSON error envelope - migrations/0001..0009 — full schema including approval_id (BR-NT-08) - tests/test_module.py + test_use_cases.py + test_business_rules.py + test_workflows.py - tests/specs/ + tests/reports/ — 39 spec-driven tests + 62 unit/integration, all passing - Designated_Roles.md — role permission matrix New SDK at notification_client/: - client.py — public API for other Fusion modules to send notifications Removed legacy stubs: - notifications_extension/tests.py — replaced by tests/ folder - notifications_extension/urls.py — replaced by api/urls.py - notifications_extension/views.py — replaced by api/views.py Roles defined in this module: - NAM Admin (Authorized Sender) — institute admins; full CRUD on event types, broadcasts, triggers - External Fusion Module (System Actor) — other Fusion apps emit via notification_client SDK - Recipient (Student / Faculty / Staff) — sees notifications in navbar bell + dashboard tray No other modules touched. --- .../Designated_Roles.md | 84 ++ .../notifications_extension/admin.py | 28 +- .../notifications_extension/api/__init__.py | 0 .../api/exception_handler.py | 38 + .../notifications_extension/apps.py | 9 +- .../migrations/0001_initial.py | 32 + ...0002_notificationeventtype_announcement.py | 98 ++ ..._notificationeventtype_default_priority.py | 18 + .../migrations/0004_archivednotification.py | 29 + ...lsdesignation_globalsextrainfo_and_more.py | 51 + .../0006_alter_announcement_audience_type.py | 18 + .../migrations/0007_auto_20260425_2252.py | 44 + .../migrations/0008_auto_20260426_1221.py | 18 + .../0009_announcement_approval_id.py | 36 + .../notifications_extension/models.py | 365 ++++++- .../notifications_extension/selectors.py | 242 +++++ .../notifications_extension/services.py | 936 ++++++++++++++++++ .../notifications_extension/tests.py | 3 - .../notifications_extension/tests/__init__.py | 0 .../notifications_extension/tests/conftest.py | 78 ++ .../tests/reports/Artifact_Evaluation.csv | 17 + .../tests/reports/BR_Test_Design.csv | 51 + .../tests/reports/Defect_Log.csv | 1 + .../tests/reports/Module_Test_Summary.csv | 18 + .../tests/reports/Test_Execution_Log.csv | 40 + .../tests/reports/UC_Test_Design.csv | 19 + .../tests/reports/WF_Test_Design.csv | 28 + .../notifications_extension/tests/runner.py | 324 ++++++ .../tests/specs/business_rules.yaml | 175 ++++ .../tests/specs/use_cases.yaml | 126 +++ .../tests/specs/workflows.yaml | 79 ++ .../tests/test_business_rules.py | 677 +++++++++++++ .../tests/test_module.py | 644 ++++++++++++ .../tests/test_use_cases.py | 399 ++++++++ .../tests/test_workflows.py | 367 +++++++ .../notifications_extension/urls.py | 15 - .../notifications_extension/views.py | 34 - FusionIIIT/notification_client/__init__.py | 3 + FusionIIIT/notification_client/client.py | 303 ++++++ 39 files changed, 5391 insertions(+), 56 deletions(-) create mode 100644 FusionIIIT/applications/notifications_extension/Designated_Roles.md create mode 100644 FusionIIIT/applications/notifications_extension/api/__init__.py create mode 100644 FusionIIIT/applications/notifications_extension/api/exception_handler.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0001_initial.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0002_notificationeventtype_announcement.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0003_alter_notificationeventtype_default_priority.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0004_archivednotification.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0005_globalsdesignation_globalsextrainfo_and_more.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0006_alter_announcement_audience_type.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0007_auto_20260425_2252.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0008_auto_20260426_1221.py create mode 100644 FusionIIIT/applications/notifications_extension/migrations/0009_announcement_approval_id.py create mode 100644 FusionIIIT/applications/notifications_extension/selectors.py create mode 100644 FusionIIIT/applications/notifications_extension/services.py delete mode 100644 FusionIIIT/applications/notifications_extension/tests.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/__init__.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/conftest.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/Artifact_Evaluation.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/BR_Test_Design.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/Defect_Log.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/Module_Test_Summary.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/Test_Execution_Log.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/UC_Test_Design.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/reports/WF_Test_Design.csv create mode 100644 FusionIIIT/applications/notifications_extension/tests/runner.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/specs/business_rules.yaml create mode 100644 FusionIIIT/applications/notifications_extension/tests/specs/use_cases.yaml create mode 100644 FusionIIIT/applications/notifications_extension/tests/specs/workflows.yaml create mode 100644 FusionIIIT/applications/notifications_extension/tests/test_business_rules.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/test_module.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/test_use_cases.py create mode 100644 FusionIIIT/applications/notifications_extension/tests/test_workflows.py delete mode 100644 FusionIIIT/applications/notifications_extension/urls.py delete mode 100644 FusionIIIT/applications/notifications_extension/views.py create mode 100644 FusionIIIT/notification_client/__init__.py create mode 100644 FusionIIIT/notification_client/client.py diff --git a/FusionIIIT/applications/notifications_extension/Designated_Roles.md b/FusionIIIT/applications/notifications_extension/Designated_Roles.md new file mode 100644 index 000000000..b5ba2d587 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/Designated_Roles.md @@ -0,0 +1,84 @@ +# Module Name: Notification & Announcement Module (NAM) + +## Designated User Roles & Permissions + +The notification module exposes its functionality through a REST API guarded by Django REST Framework's `IsAdminUser` and `IsAuthenticated` permissions, plus an explicit `is_staff` / `is_superuser` check inside `services.broadcast_announcement` (BR-NT-03). + +--- + +### 1. Role Name: NAM Admin (Authorized Sender / Module Admin) + +* **Description:** Institute-level administrators or designated NAM operators (e.g., Director's office, Dean PnD, Registrar). They have full control over event-type registry, manual broadcasts, and audit reporting. + +* **Permissions:** + * **Full CRUD on Event Types** — `POST /api/notifications/event-types/register/`, list & detail endpoints. (UC-NT-01) + * **Broadcast Manual Announcements** — orange Broadcast button on the dashboard; `POST /api/notifications/announcements/broadcast/` with audience filters (department / batch / designation / specific users). (UC-NT-03) + * **Trigger System Notifications** — `POST /api/notifications/trigger/` and the 22 module-specific shortcuts. (UC-NT-02) + * **View All Announcements** including expired (audit) — `GET /api/notifications/announcements/all/`. + * **Read audit metadata** — every announcement carries `sender`, `created_at`, and unique `approval_id` UUID (BR-NT-08). + * **Set priority=Critical** — bypasses user DND + sends email (BR-NT-05). + * Inherits all NAM Recipient permissions (read own tray, mark read, archive, star). + +* **How identified:** `request.user.is_staff` OR `request.user.is_superuser` is `True` in `auth_user`. Also exposed to the React app as `state.user.isStaff` from `/api/auth/me`. + +--- + +### 2. Role Name: External Fusion Module (System Actor) + +* **Description:** Other Fusion apps (Leave, Mess, Complaints, Placement, Gymkhana, etc.) that emit notifications on internal events — e.g., a leave is approved, a complaint is filed, a placement drive opens. They are not human users; they invoke NAM through service helpers or the API. + +* **Permissions:** + * **Send module-typed notifications** via: + * Direct Python: `services._notif(sender, recipient, type=...)` — 22 helpers (`leave_module_notif`, `central_mess_notif`, `placement_cell_notif`, ...). + * REST API: `POST /api/notifications/notify//` — same 22 endpoints, gated by `IsAdminUser` (BR-NT-03). + * `notification_client.NotificationClient` — the public wrapper class. + * **Trigger pre-registered events** via `POST /api/notifications/trigger/` with an `event_id` (UUID). Subject to BR-NT-04 60-second dedup. + * **No direct DB writes** — every send must go through `services._send()` (BR-NT-01 chokepoint). + * **No read access to other users' notifications.** + +* **How identified:** Authenticated with a service-account token whose user has `is_staff=True`. In current setup we reuse the calling user's token; in a future iteration this should be a dedicated per-module service account. + +--- + +### 3. Role Name: NAM Recipient (Student / Faculty / Staff — End User) + +* **Description:** Any authenticated Fusion user who receives notifications and announcements through the global navbar bell and the dashboard tabs. + +* **Permissions:** + * **Read own tray** — `GET /api/notifications/`, `GET /api/notifications/unread/`, `GET /api/notifications/unread-count/`. (UC-NT-04) + * **Mark notifications read / unread** — `PATCH //mark-read/`, `PATCH //mark-unread/`, plus the bulk variants `mark-all-read/`, `mark-all-unread/`. + * **Star / unstar** — `PATCH //star/` toggles `data.starred`. + * **Archive (soft-delete)** — `DELETE //delete/` inserts an `ArchivedNotification` row; original notification preserved 180 days (BR-NT-09). + * **Open with deep-link** — `GET //open/` marks read and returns the source-module URL. + * **Manage preferences** — `GET /preferences/`, `POST /preferences/set/` to opt-out of specific Fusion modules (BR-NT-06). + * **Read active announcements** — `GET /api/notifications/announcements/` (only non-expired). + * **Cannot send notifications, broadcast, register events, or permanently delete.** + +* **How identified:** Any user with a valid token from `POST /api/auth/login/`. No `is_staff` requirement. + +--- + +## Permission Matrix + +| Capability | NAM Admin | External Module | Recipient | +|--------------------------------------|:---------:|:---------------:|:---------:| +| Register Event Type (UC-NT-01) | ✅ | ❌ | ❌ | +| Trigger via Event ID (UC-NT-02) | ✅ | ✅ | ❌ | +| Use module shortcut endpoints | ✅ | ✅ | ❌ | +| Broadcast Announcement (UC-NT-03) | ✅ | ❌ | ❌ | +| Set priority=Critical (BR-NT-05) | ✅ | ✅ | ❌ | +| View own tray (UC-NT-04) | ✅ | n/a | ✅ | +| Mark read / unread / star / archive | ✅ | n/a | ✅ | +| View ALL announcements (incl expired)| ✅ | ❌ | ❌ | +| View audit metadata (approval_id) | ✅ | ❌ | ❌ | +| Permanent deletion | ❌ | ❌ | ❌ | + +--- + +## Enforcement Layers (defense in depth) + +1. **HTTP layer** — `@permission_classes(IsAdminUser)` on every send/register/trigger endpoint (`api/views.py`). +2. **Service layer** — explicit `if not (sender.is_staff or sender.is_superuser)` check in `services.broadcast_announcement` (raises `UnauthorizedSender`). +3. **Resource layer** — every read query filters by `recipient=request.user`; users cannot read each other's notifications. +4. **UI layer** — Broadcast button on the dashboard renders only when `state.user.isStaff` is True. +5. **DB layer** — `ArchivedNotification` UNIQUE on `(user_id, notification_id)`; `Announcement.approval_id` UNIQUE UUID for audit traceability. diff --git a/FusionIIIT/applications/notifications_extension/admin.py b/FusionIIIT/applications/notifications_extension/admin.py index 8c38f3f3d..6f70a92e8 100644 --- a/FusionIIIT/applications/notifications_extension/admin.py +++ b/FusionIIIT/applications/notifications_extension/admin.py @@ -1,3 +1,29 @@ from django.contrib import admin -# Register your models here. +from .models import Announcement, NotificationEventType, NotificationPreference + + +@admin.register(NotificationPreference) +class NotificationPreferenceAdmin(admin.ModelAdmin): + list_display = ("user", "module", "is_enabled", "updated_at") + list_filter = ("module", "is_enabled") + search_fields = ("user__username",) + ordering = ("user", "module") + + +@admin.register(NotificationEventType) +class NotificationEventTypeAdmin(admin.ModelAdmin): + list_display = ("event_name", "module", "default_priority", "is_active", "registered_by", "created_at") + list_filter = ("module", "default_priority", "is_active") + search_fields = ("event_name", "module", "registered_by__username") + readonly_fields = ("event_id", "created_at") + ordering = ("module", "event_name") + + +@admin.register(Announcement) +class AnnouncementAdmin(admin.ModelAdmin): + list_display = ("title", "sender", "audience_type", "audience_value", "expiry_date", "created_at") + list_filter = ("audience_type",) + search_fields = ("title", "sender__username", "message") + readonly_fields = ("created_at",) + ordering = ("-created_at",) diff --git a/FusionIIIT/applications/notifications_extension/api/__init__.py b/FusionIIIT/applications/notifications_extension/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/notifications_extension/api/exception_handler.py b/FusionIIIT/applications/notifications_extension/api/exception_handler.py new file mode 100644 index 000000000..f12a507c3 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/api/exception_handler.py @@ -0,0 +1,38 @@ +""" +Global DRF exception handler — returns consistent {error, detail} envelope +for any unhandled exception and logs server errors. +""" + +import logging + +from rest_framework.views import exception_handler as drf_exception_handler +from rest_framework.response import Response +from rest_framework import status + +logger = logging.getLogger(__name__) + + +def custom_exception_handler(exc, context): + """ + Wrap DRF's default handler to: + - add a consistent 'error' field + - log server errors (5xx) at ERROR level + """ + response = drf_exception_handler(exc, context) + view = context.get("view").__class__.__name__ if context.get("view") else "Unknown" + + if response is not None: + if response.status_code >= 500: + logger.error("api.error view=%s status=%s exc=%s", + view, response.status_code, exc) + elif response.status_code >= 400: + logger.info("api.4xx view=%s status=%s", + view, response.status_code) + return response + + # Unhandled exception — return 500 with a user-friendly message + logger.exception("api.unhandled view=%s exc=%s", view, exc) + return Response( + {"error": "Internal server error. Please try again or contact support."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/FusionIIIT/applications/notifications_extension/apps.py b/FusionIIIT/applications/notifications_extension/apps.py index 118c0dabf..a20c2a69d 100644 --- a/FusionIIIT/applications/notifications_extension/apps.py +++ b/FusionIIIT/applications/notifications_extension/apps.py @@ -1,5 +1,10 @@ from django.apps import AppConfig -class NotificationsExtensionConfig(AppConfig): - name = 'applications.notifications_extension' +class NotificationConfig(AppConfig): + name = "applications.notifications_extension" + label = "notifications_extension" + verbose_name = "Notification" + + def ready(self): + pass diff --git a/FusionIIIT/applications/notifications_extension/migrations/0001_initial.py b/FusionIIIT/applications/notifications_extension/migrations/0001_initial.py new file mode 100644 index 000000000..eb7ba4d38 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.29 on 2026-03-15 16:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationPreference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('module', models.CharField(choices=[('Leave Module', 'Leave Module'), ('Placement Cell', 'Placement Cell'), ("Academic's Module", "Academic's Module"), ('Central Mess', 'Central Mess'), ("Visitor's Hostel", "Visitor's Hostel"), ('Healthcare Center', 'Healthcare Center'), ('File Tracking', 'File Tracking'), ('Scholarship Portal', 'Scholarship Portal'), ('Complaint System', 'Complaint System'), ('Office of Dean PnD Module', 'Office of Dean PnD Module'), ('Office Module', 'Office Module'), ('Gymkhana Module', 'Gymkhana Module'), ('Assistantship Request', 'Assistantship Request'), ('department', 'Department'), ('Research Procedures', 'Research Procedures')], max_length=60)), + ('is_enabled', models.BooleanField(default=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Notification Preference', + 'verbose_name_plural': 'Notification Preferences', + 'unique_together': {('user', 'module')}, + }, + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0002_notificationeventtype_announcement.py b/FusionIIIT/applications/notifications_extension/migrations/0002_notificationeventtype_announcement.py new file mode 100644 index 000000000..45f026b45 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0002_notificationeventtype_announcement.py @@ -0,0 +1,98 @@ +# Generated migration for UC-NT-01 and UC-NT-03 models + +import uuid +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications_extension", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="NotificationEventType", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("event_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ("event_name", models.CharField(max_length=100)), + ("module", models.CharField( + choices=[ + ("Leave Module", "Leave Module"), + ("Placement Cell", "Placement Cell"), + ("Academic's Module", "Academic's Module"), + ("Central Mess", "Central Mess"), + ("Visitor's Hostel", "Visitor's Hostel"), + ("Healthcare Center", "Healthcare Center"), + ("File Tracking", "File Tracking"), + ("Scholarship Portal", "Scholarship Portal"), + ("Complaint System", "Complaint System"), + ("Office of Dean PnD Module", "Office of Dean PnD Module"), + ("Office Module", "Office Module"), + ("Gymkhana Module", "Gymkhana Module"), + ("Assistantship Request", "Assistantship Request"), + ("department", "Department"), + ("Research Procedures", "Research Procedures"), + ], + max_length=60, + )), + ("default_priority", models.CharField( + choices=[("low", "Low"), ("medium", "Medium"), ("high", "High")], + default="medium", + max_length=10, + )), + ("description", models.TextField(blank=True, default="")), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("registered_by", models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="registered_event_types", + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + "verbose_name": "Notification Event Type", + "verbose_name_plural": "Notification Event Types", + "ordering": ["module", "event_name"], + "unique_together": {("event_name", "module")}, + }, + ), + migrations.CreateModel( + name="Announcement", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("title", models.CharField(max_length=200)), + ("message", models.TextField()), + ("audience_type", models.CharField( + choices=[ + ("all", "All Users"), + ("students", "All Students"), + ("faculty", "All Faculty"), + ("staff", "All Staff"), + ("group", "Specific Group"), + ], + default="all", + max_length=20, + )), + ("audience_value", models.CharField(blank=True, default="", max_length=100)), + ("expiry_date", models.DateField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("sender", models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sent_announcements", + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + "verbose_name": "Announcement", + "verbose_name_plural": "Announcements", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0003_alter_notificationeventtype_default_priority.py b/FusionIIIT/applications/notifications_extension/migrations/0003_alter_notificationeventtype_default_priority.py new file mode 100644 index 000000000..45d8bdd25 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0003_alter_notificationeventtype_default_priority.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-28 13:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications_extension', '0002_notificationeventtype_announcement'), + ] + + operations = [ + migrations.AlterField( + model_name='notificationeventtype', + name='default_priority', + field=models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='medium', max_length=10), + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0004_archivednotification.py b/FusionIIIT/applications/notifications_extension/migrations/0004_archivednotification.py new file mode 100644 index 000000000..29b84de76 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0004_archivednotification.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.29 on 2026-03-29 09:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications_extension', '0003_alter_notificationeventtype_default_priority'), + ] + + operations = [ + migrations.CreateModel( + name='ArchivedNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_id', models.IntegerField()), + ('archived_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='archived_notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Archived Notification', + 'unique_together': {('user', 'notification_id')}, + }, + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0005_globalsdesignation_globalsextrainfo_and_more.py b/FusionIIIT/applications/notifications_extension/migrations/0005_globalsdesignation_globalsextrainfo_and_more.py new file mode 100644 index 000000000..b92ccccd4 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0005_globalsdesignation_globalsextrainfo_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.29 on 2026-04-06 16:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications_extension', '0004_archivednotification'), + ] + + operations = [ + migrations.CreateModel( + name='GlobalsDesignation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('full_name', models.CharField(max_length=100)), + ('type', models.CharField(max_length=30)), + ('basic', models.BooleanField(default=False)), + ('category', models.CharField(blank=True, max_length=20, null=True)), + ], + options={ + 'db_table': 'globals_designation', + 'managed': False, + }, + ), + migrations.CreateModel( + name='GlobalsExtraInfo', + fields=[ + ('id', models.CharField(max_length=50, primary_key=True, serialize=False)), + ('user_type', models.CharField(max_length=20)), + ], + options={ + 'db_table': 'globals_extrainfo', + 'managed': False, + }, + ), + migrations.CreateModel( + name='GlobalsHoldsDesignation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('working_id', models.IntegerField()), + ('held_at', models.DateTimeField(null=True)), + ], + options={ + 'db_table': 'globals_holdsdesignation', + 'managed': False, + }, + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0006_alter_announcement_audience_type.py b/FusionIIIT/applications/notifications_extension/migrations/0006_alter_announcement_audience_type.py new file mode 100644 index 000000000..2157d7fab --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0006_alter_announcement_audience_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-04-18 07:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications_extension', '0005_globalsdesignation_globalsextrainfo_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='announcement', + name='audience_type', + field=models.CharField(choices=[('all', 'All Users'), ('students', 'All Students'), ('faculty', 'All Faculty'), ('staff', 'All Staff'), ('group', 'Specific Designation'), ('department', 'By Department'), ('batch', 'By Batch')], default='all', max_length=20), + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0007_auto_20260425_2252.py b/FusionIIIT/applications/notifications_extension/migrations/0007_auto_20260425_2252.py new file mode 100644 index 000000000..5c6e0d7e0 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0007_auto_20260425_2252.py @@ -0,0 +1,44 @@ +# Generated by Django 3.1.5 on 2026-04-25 22:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications_extension', '0006_alter_announcement_audience_type'), + ] + + operations = [ + migrations.CreateModel( + name='GlobalsDepartmentInfo', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ], + options={ + 'db_table': 'globals_departmentinfo', + 'managed': False, + }, + ), + migrations.AlterField( + model_name='announcement', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='archivednotification', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='notificationeventtype', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='notificationpreference', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0008_auto_20260426_1221.py b/FusionIIIT/applications/notifications_extension/migrations/0008_auto_20260426_1221.py new file mode 100644 index 000000000..bb68c8fc4 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0008_auto_20260426_1221.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-26 12:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications_extension', '0007_auto_20260425_2252'), + ] + + operations = [ + migrations.AlterField( + model_name='announcement', + name='audience_type', + field=models.CharField(choices=[('all', 'All Users'), ('students', 'All Students'), ('faculty', 'All Faculty'), ('staff', 'All Staff'), ('group', 'Specific Designation'), ('department', 'By Department'), ('batch', 'By Batch'), ('specific_user', 'Specific User')], default='all', max_length=20), + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/migrations/0009_announcement_approval_id.py b/FusionIIIT/applications/notifications_extension/migrations/0009_announcement_approval_id.py new file mode 100644 index 000000000..9d11e4dd7 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/migrations/0009_announcement_approval_id.py @@ -0,0 +1,36 @@ +# Generated by Django 3.1.5 on 2026-04-26 13:10 + +from django.db import migrations, models +import uuid + + +def backfill_approval_id(apps, schema_editor): + """Give every existing Announcement a unique UUID before the unique constraint is added.""" + Announcement = apps.get_model("notifications_extension", "Announcement") + for ann in Announcement.objects.all(): + ann.approval_id = uuid.uuid4() + ann.save(update_fields=["approval_id"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications_extension', '0008_auto_20260426_1221'), + ] + + operations = [ + # Step 1: add field WITHOUT unique constraint, allow temp duplicates + migrations.AddField( + model_name='announcement', + name='approval_id', + field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), + ), + # Step 2: backfill each row with a fresh UUID + migrations.RunPython(backfill_approval_id, migrations.RunPython.noop), + # Step 3: now apply the unique constraint + migrations.AlterField( + model_name='announcement', + name='approval_id', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/FusionIIIT/applications/notifications_extension/models.py b/FusionIIIT/applications/notifications_extension/models.py index 71a836239..e4fc60267 100644 --- a/FusionIIIT/applications/notifications_extension/models.py +++ b/FusionIIIT/applications/notifications_extension/models.py @@ -1,3 +1,366 @@ +""" +models.py + +All models and constants (TextChoices / IntegerChoices) for the notification module. + +Source analysis: + - notification/views.py : All module names and verb strings extracted → TextChoices + - notifications_extension/views.py : mark_as_read_and_redirect logic uses Notification model + - Both source models.py were empty → NotificationPreference is a new custom model added here +""" + +import uuid + +from django.contrib.auth import get_user_model from django.db import models +from django.utils import timezone + +User = get_user_model() + + +# ───────────────────────────────────────────── +# TextChoices — extracted from all hardcoded +# module/type strings in both source views.py +# ───────────────────────────────────────────── + +class ModuleName(models.TextChoices): + LEAVE_MODULE = "Leave Module", "Leave Module" + PLACEMENT_CELL = "Placement Cell", "Placement Cell" + ACADEMICS_MODULE = "Academic's Module", "Academic's Module" + CENTRAL_MESS = "Central Mess", "Central Mess" + VISITORS_HOSTEL = "Visitor's Hostel", "Visitor's Hostel" + HEALTHCARE_CENTER = "Healthcare Center", "Healthcare Center" + FILE_TRACKING = "File Tracking", "File Tracking" + SCHOLARSHIP_PORTAL = "Scholarship Portal", "Scholarship Portal" + COMPLAINT_SYSTEM = "Complaint System", "Complaint System" + OFFICE_DEAN_PND = "Office of Dean PnD Module", "Office of Dean PnD Module" + OFFICE_MODULE = "Office Module", "Office Module" + GYMKHANA_MODULE = "Gymkhana Module", "Gymkhana Module" + ASSISTANTSHIP_REQUEST = "Assistantship Request", "Assistantship Request" + DEPARTMENT = "department", "Department" + RESEARCH_PROCEDURES = "Research Procedures", "Research Procedures" + + +class LeaveNotifType(models.TextChoices): + LEAVE_APPLIED = "leave_applied", "Leave Applied" + REQUEST_ACCEPTED = "request_accepted", "Request Accepted" + REQUEST_DECLINED = "request_declined", "Request Declined" + LEAVE_ACCEPTED = "leave_accepted", "Leave Accepted" + LEAVE_FORWARDED = "leave_forwarded", "Leave Forwarded" + LEAVE_REJECTED = "leave_rejected", "Leave Rejected" + OFFLINE_LEAVE = "offline_leave", "Offline Leave Updated" + REPLACEMENT_REQUEST = "replacement_request", "Replacement Request" + LEAVE_REQUEST = "leave_request", "Leave Request" + LEAVE_WITHDRAWN = "leave_withdrawn", "Leave Withdrawn" + REPLACEMENT_CANCEL = "replacement_cancel", "Replacement Cancelled" + + +class MessNotifType(models.TextChoices): + FEEDBACK_SUBMITTED = "feedback_submitted", "Feedback Submitted" + MENU_CHANGE_ACCEPTED = "menu_change_accepted", "Menu Change Accepted" + LEAVE_REQUEST = "leave_request", "Leave Request" + VACATION_REQUEST = "vacation_request", "Vacation Request" + MEETING_INVITATION = "meeting_invitation", "Meeting Invitation" + SPECIAL_REQUEST = "special_request", "Special Request" + ADDED_COMMITTEE = "added_committee", "Added to Committee" + + +class VisitorHostelNotifType(models.TextChoices): + BOOKING_CONFIRMATION = "booking_confirmation", "Booking Confirmed" + BOOKING_CANCELLATION_REQ_ACCEPTED = "booking_cancellation_request_accepted", "Cancellation Accepted" + BOOKING_REQUEST = "booking_request", "Booking Request" + CANCELLATION_REQUEST_PLACED = "cancellation_request_placed", "Cancellation Request Placed" + BOOKING_FORWARDED = "booking_forwarded", "Booking Forwarded" + BOOKING_REJECTED = "booking_rejected", "Booking Rejected" + + +class HealthcareNotifType(models.TextChoices): + APPOINTMENT_BOOKED = "appoint", "Appointment Booked" + AMBULANCE_REQUEST = "amb_request", "Ambulance Request Placed" + PRESCRIPTION = "Presc", "Prescription Issued" + APPOINTMENT_REQUEST = "appoint_req", "New Appointment Request" + AMBULANCE_REQ = "amb_req", "New Ambulance Request" + + +class ScholarshipNotifType(models.TextChoices): + ACCEPT_MCM = "Accept_MCM", "MCM Form Accepted" + REJECT_MCM = "Reject_MCM", "MCM Form Rejected" + ACCEPT_GOLD = "Accept_Gold", "Gold Medal Form Accepted" + REJECT_GOLD = "Reject_Gold", "Gold Medal Form Rejected" + ACCEPT_SILVER = "Accept_Silver", "Silver Medal Form Accepted" + REJECT_SILVER = "Reject_Silver", "Silver Medal Form Rejected" + ACCEPT_DM = "Accept_DM", "D&M Medal Form Accepted" + + +class OfficeDeanPnDNotifType(models.TextChoices): + REQUISITION_FILED = "requisition_filed", "Requisition Filed" + REQUEST_ACCEPTED = "request_accepted", "Request Accepted" + REQUEST_REJECTED = "request_rejected", "Request Rejected" + ASSIGNMENT_CREATED = "assignment_created", "Assignment Created" + ASSIGNMENT_RECEIVED = "assignment_received", "Assignment Received" + ASSIGNMENT_REVERTED = "assignment_reverted", "Assignment Reverted" + ASSIGNMENT_APPROVED = "assignment_approved", "Assignment Approved" + ASSIGNMENT_REJECTED = "assignment_rejected", "Assignment Rejected" + + +class OfficeDeanSNotifType(models.TextChoices): + HOSTEL_ALLOTED = "hostel_alloted", "Hostel Alloted" + INSUFFICIENT_FUNDS = "insufficient_funds", "Insufficient Funds" + MOM_SUBMITTED = "MOM_submitted", "MOM Submitted" + BUDGET_APPROVED = "budget_approved", "Budget Approved" + BUDGET_REJECTED = "budget_rejected", "Budget Rejected" + CLUB_APPROVED = "club_approved", "Club Approved" + CLUB_REJECTED = "club_rejected", "Club Rejected" + MEETING_BOOKED = "meeting_booked", "Meeting Booked" + SESSION_APPROVED = "session_approved", "Session Approved" + SESSION_REJECTED = "session_rejected", "Session Rejected" + BUDGET_ALLOTED = "budget_alloted", "Budget Alloted" + + +class OfficeDeanRSPCNotifType(models.TextChoices): + APPROVE = "Approve", "Approved" + DISAPPROVE = "Disapprove", "Disapproved" + PENDING = "Pending", "Pending" + + +class GymkhanaNotifType(models.TextChoices): + VOTING_OPEN = "voting_open", "Voting Open" + NEW_SESSION = "new_session", "New Session" + NEW_EVENT = "new_event", "New Event" + + +class ResearchProceduresNotifType(models.TextChoices): + APPROVED = "Approved", "Approved" + DISAPPROVED = "Disapproved", "Disapproved" + PENDING = "Pending", "Pending" + SUBMITTED = "submitted", "Submitted" + CREATED = "created", "Created" + + +# ───────────────────────────────────────────── +# Custom Model +# ───────────────────────────────────────────── + +class NotificationPreference(models.Model): + """ + Per-user, per-module notification preference. + Allows users to opt in/out of notifications from specific Fusion modules. + """ + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="notification_preferences", + ) + module = models.CharField( + max_length=60, + choices=ModuleName.choices, + ) + is_enabled = models.BooleanField(default=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ("user", "module") + verbose_name = "Notification Preference" + verbose_name_plural = "Notification Preferences" + + def __str__(self): + status = "ON" if self.is_enabled else "OFF" + return f"{self.user.username} — {self.module} [{status}]" + + +# ───────────────────────────────────────────── +# UC-NT-01: Notification Event Type Registry +# ───────────────────────────────────────────── + +class EventPriority(models.TextChoices): + LOW = "low", "Low" + MEDIUM = "medium", "Medium" + HIGH = "high", "High" + CRITICAL = "critical", "Critical" # BR-NT-05: bypasses user preferences + + +class NotificationEventType(models.Model): + """ + UC-NT-01: Registered notification event types. + External Fusion modules register their event types here before they can + trigger notifications via the NAM API. + """ + event_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + event_name = models.CharField(max_length=100) + module = models.CharField(max_length=60, choices=ModuleName.choices) + default_priority = models.CharField( + max_length=10, + choices=EventPriority.choices, + default=EventPriority.MEDIUM, + ) + description = models.TextField(blank=True, default="") + registered_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name="registered_event_types", + ) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("event_name", "module") + verbose_name = "Notification Event Type" + verbose_name_plural = "Notification Event Types" + ordering = ["module", "event_name"] + + def __str__(self): + return f"{self.module} / {self.event_name} [{self.event_id}]" + + +# ───────────────────────────────────────────── +# UC-NT-03: Announcements (Manual Broadcast) +# ───────────────────────────────────────────── + +class AudienceType(models.TextChoices): + ALL = "all", "All Users" + STUDENTS = "students", "All Students" + FACULTY = "faculty", "All Faculty" + STAFF = "staff", "All Staff" + GROUP = "group", "Specific Designation" + DEPARTMENT = "department", "By Department" + BATCH = "batch", "By Batch" + SPECIFIC_USER = "specific_user", "Specific User" + + +# ───────────────────────────────────────────── +# BR-NT-09: Archived Notifications (soft delete) +# ───────────────────────────────────────────── + +class ArchivedNotification(models.Model): + """ + BR-NT-09: Tracks which notifications have been archived by a user. + Notifications are NOT deleted from the DB — they are retained for 180 days. + The notification_id refers to the django-notifications-hq Notification pk. + """ + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="archived_notifications" + ) + notification_id = models.IntegerField() + archived_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "notification_id") + verbose_name = "Archived Notification" + + def __str__(self): + return f"{self.user.username} archived #{self.notification_id} at {self.archived_at}" + + +# ───────────────────────────────────────────── +# Fusionlab RBAC — unmanaged mirrors of the +# globals_* tables that already exist in the DB. +# managed = False means Django never creates/ +# drops these; it just lets us query them via ORM. +# ───────────────────────────────────────────── + +class GlobalsDesignation(models.Model): + """Mirror of public.globals_designation — roles like Director, HOD, Dean, etc.""" + name = models.CharField(max_length=50) + full_name = models.CharField(max_length=100) + type = models.CharField(max_length=30) # 'academic' | 'administrative' + basic = models.BooleanField(default=False) + category = models.CharField(max_length=20, null=True, blank=True) + + class Meta: + managed = False + db_table = "globals_designation" + + def __str__(self): + return self.name + + +class GlobalsDepartmentInfo(models.Model): + """Mirror of public.globals_departmentinfo — departments (CSE, ECE, ME, etc.).""" + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=100) + + class Meta: + managed = False + db_table = "globals_departmentinfo" + + def __str__(self): + return self.name + + +class GlobalsExtraInfo(models.Model): + """Mirror of public.globals_extrainfo — stores user_type and department.""" + id = models.CharField(max_length=50, primary_key=True) + user = models.OneToOneField( + User, on_delete=models.DO_NOTHING, db_column="user_id" + ) + user_type = models.CharField(max_length=20) # 'student' | 'staff' | 'faculty' + department = models.ForeignKey( + GlobalsDepartmentInfo, on_delete=models.DO_NOTHING, + db_column="department_id", null=True, blank=True, + ) + + class Meta: + managed = False + db_table = "globals_extrainfo" + + def __str__(self): + return f"{self.id} ({self.user_type})" + + +class GlobalsHoldsDesignation(models.Model): + """Mirror of public.globals_holdsdesignation — maps user → designation.""" + user = models.ForeignKey( + User, on_delete=models.DO_NOTHING, db_column="user_id" + ) + designation = models.ForeignKey( + GlobalsDesignation, on_delete=models.DO_NOTHING, db_column="designation_id" + ) + working_id = models.IntegerField() + held_at = models.DateTimeField(null=True) + + class Meta: + managed = False + db_table = "globals_holdsdesignation" + + def __str__(self): + return f"{self.user_id} → {self.designation_id}" + + +class Announcement(models.Model): + """ + UC-NT-03: Manual broadcast announcement created by an admin/authorized sender. + Displayed on all targeted users' dashboards until the expiry date. + """ + title = models.CharField(max_length=200) + message = models.TextField() + sender = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="sent_announcements", + ) + audience_type = models.CharField( + max_length=20, + choices=AudienceType.choices, + default=AudienceType.ALL, + ) + # For audience_type=GROUP: the Django auth group name (e.g. "4th_Year_BTech") + audience_value = models.CharField(max_length=100, blank=True, default="") + expiry_date = models.DateField() + created_at = models.DateTimeField(auto_now_add=True) + # BR-NT-08: Audit Trail Integrity — every announcement is stamped with a + # unique Approval_ID at creation time. Admins self-approve at submit, so + # we generate the UUID immediately rather than running a workflow. + approval_id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + + class Meta: + verbose_name = "Announcement" + verbose_name_plural = "Announcements" + ordering = ["-created_at"] + + @property + def is_active(self): + return self.expiry_date >= timezone.now().date() -# Create your models here. + def __str__(self): + return f"{self.title} (expires {self.expiry_date})" diff --git a/FusionIIIT/applications/notifications_extension/selectors.py b/FusionIIIT/applications/notifications_extension/selectors.py new file mode 100644 index 000000000..0607d1c5f --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/selectors.py @@ -0,0 +1,242 @@ +""" +selectors.py — All database read queries for the notification module. + +Source: + - notifications_extension/views.py used: + Notification.objects via get_object_or_404(Notification, recipient=..., id=...) + - notification/views.py had no direct DB queries (used notify.send signal only) + +Rules enforced: + - Every .objects. call in the entire module lives ONLY here. + - No business logic, no HTTP objects, no serialization. +""" + +from django.utils import timezone +from notifications.models import Notification +from notifications.utils import slug2id + +from .models import ( + Announcement, ArchivedNotification, NotificationEventType, NotificationPreference, + GlobalsExtraInfo, GlobalsHoldsDesignation, +) + + +def _archived_ids(user): + """Return set of notification IDs archived by this user (BR-NT-09).""" + return set( + ArchivedNotification.objects.filter(user=user).values_list("notification_id", flat=True) + ) + + +def _expired_announcement_ids(): + """ + BR-NT-06: announcements with expiry_date in the past must disappear from + active UI. Returns the list of expired Announcement IDs so notification + rows linked to them can be filtered out. + """ + today = timezone.now().date() + return list( + Announcement.objects.filter(expiry_date__lt=today).values_list("id", flat=True) + ) + + +def _expired_notification_ids(qs): + """For a given Notification queryset, return the set of row IDs whose + data.announcement_id points to an expired Announcement (BR-NT-06).""" + expired = set(_expired_announcement_ids()) + if not expired: + return set() + return { + n.id for n in qs.only("id", "data") + if isinstance(n.data, dict) and n.data.get("announcement_id") in expired + } + + +def _exclude_expired(qs): + """Exclude notification rows linked to an expired Announcement (BR-NT-06).""" + bad_ids = _expired_notification_ids(qs) + return qs.exclude(id__in=bad_ids) if bad_ids else qs + + +# ───────────────────────────────────────────── +# Notification queries (django-notifications-hq) +# ───────────────────────────────────────────── + +def get_notification_by_slug(slug, user): + """ + Convert slug → id and return the matching Notification for the user. + Returns None if not found. + (Source: notifications_extension/views.py — slug2id + get_object_or_404) + """ + notification_id = slug2id(slug) + return Notification.objects.filter( + recipient=user, + id=notification_id, + ).first() + + +def get_notification_by_id(notification_id, user): + """Return a single Notification owned by the user. Returns None if not found.""" + return Notification.objects.filter( + recipient=user, + id=notification_id, + ).first() + + +def get_all_notifications_for_user(user): + """All non-archived notifications, expired announcements excluded (BR-NT-06, BR-NT-09).""" + qs = Notification.objects.filter(recipient=user).exclude(id__in=_archived_ids(user)) + return _exclude_expired(qs).order_by("-timestamp") + + +def get_unread_notifications_for_user(user): + """Unread, non-archived, non-expired notifications.""" + qs = Notification.objects.filter(recipient=user, unread=True).exclude(id__in=_archived_ids(user)) + return _exclude_expired(qs).order_by("-timestamp") + + +def get_read_notifications_for_user(user): + """Read, non-archived, non-expired notifications.""" + qs = Notification.objects.filter(recipient=user, unread=False).exclude(id__in=_archived_ids(user)) + return _exclude_expired(qs).order_by("-timestamp") + + +def get_unread_count_for_user(user): + """Count of unread, non-archived, non-expired notifications.""" + qs = Notification.objects.filter(recipient=user, unread=True).exclude(id__in=_archived_ids(user)) + return _exclude_expired(qs).count() + + +def get_notifications_by_module(user, module): + """Module-filtered, non-archived, non-expired notifications.""" + archived = _archived_ids(user) + expired = set(_expired_announcement_ids()) + return [ + n for n in Notification.objects.filter(recipient=user).order_by("-timestamp") + if n.id not in archived + and isinstance(n.data, dict) + and n.data.get("module") == module + and n.data.get("announcement_id") not in expired + ] + + +# ───────────────────────────────────────────── +# NotificationPreference queries +# ───────────────────────────────────────────── + +def get_preference_for_user_and_module(user, module): + """Return NotificationPreference for user+module, or None.""" + return NotificationPreference.objects.filter(user=user, module=module).first() + + +def get_all_preferences_for_user(user): + """Return all NotificationPreference objects for a user.""" + return NotificationPreference.objects.filter(user=user).order_by("module") + + +def is_module_enabled_for_user(user, module): + """ + Return True if user has notifications enabled for the given module. + Defaults to True when no preference record exists. + """ + pref = get_preference_for_user_and_module(user, module) + return pref.is_enabled if pref is not None else True + + +# ───────────────────────────────────────────── +# UC-NT-01: NotificationEventType queries +# ───────────────────────────────────────────── + +def get_all_event_types(): + """Return all registered event types, active first.""" + return NotificationEventType.objects.all().order_by("-is_active", "module", "event_name") + + +def get_active_event_types(): + """Return only active registered event types.""" + return NotificationEventType.objects.filter(is_active=True).order_by("module", "event_name") + + +def get_event_type_by_event_id(event_id): + """Return a NotificationEventType by its UUID event_id. Returns None if not found.""" + return NotificationEventType.objects.filter(event_id=event_id, is_active=True).first() + + +def get_event_type_by_name_and_module(event_name, module): + """Return a NotificationEventType by event_name + module. Returns None if not found.""" + return NotificationEventType.objects.filter( + event_name=event_name, module=module + ).first() + + +# ───────────────────────────────────────────── +# Fusionlab RBAC queries +# (reads globals_extrainfo + globals_holdsdesignation) +# ───────────────────────────────────────────── + +def get_users_by_type(user_type: str): + """ + Return User queryset for a given user_type from globals_extrainfo. + user_type values in fusionlab: 'student', 'staff' + """ + from django.contrib.auth import get_user_model + User = get_user_model() + user_ids = GlobalsExtraInfo.objects.filter( + user_type__iexact=user_type + ).values_list("user_id", flat=True) + return User.objects.filter(id__in=user_ids) + + +def get_users_by_designation(designation_name: str): + """ + Return User queryset for all users who currently hold a designation + with the given name (e.g. 'Director', 'HOD (CSE)', 'Dean (P&D)'). + Matched case-insensitively against globals_designation.name. + """ + from django.contrib.auth import get_user_model + User = get_user_model() + user_ids = GlobalsHoldsDesignation.objects.filter( + designation__name__iexact=designation_name + ).values_list("user_id", flat=True) + return User.objects.filter(id__in=user_ids) + + +def get_user_type(user) -> str: + """ + Return the user_type string for a user from globals_extrainfo. + Returns empty string if no record found. + """ + info = GlobalsExtraInfo.objects.filter(user_id=user.id).first() + return info.user_type if info else "" + + +def get_user_designations(user): + """ + Return list of designation names held by the user. + """ + return list( + GlobalsHoldsDesignation.objects.filter(user_id=user.id) + .select_related("designation") + .values_list("designation__name", flat=True) + ) + + +# ───────────────────────────────────────────── +# UC-NT-03: Announcement queries +# ───────────────────────────────────────────── + +def get_active_announcements(): + """Return all announcements whose expiry_date >= today.""" + return Announcement.objects.filter( + expiry_date__gte=timezone.now().date() + ).select_related("sender") + + +def get_all_announcements(): + """Return all announcements (admin view), newest first.""" + return Announcement.objects.all().select_related("sender") + + +def get_announcement_by_id(announcement_id): + """Return a single Announcement by id. Returns None if not found.""" + return Announcement.objects.filter(id=announcement_id).first() diff --git a/FusionIIIT/applications/notifications_extension/services.py b/FusionIIIT/applications/notifications_extension/services.py new file mode 100644 index 000000000..0a84e9302 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/services.py @@ -0,0 +1,936 @@ +""" +services.py — All business logic for the notification module. + +Source mapping: + notification/views.py → all module-specific notif functions moved here + notifications_extension/views.py → mark_as_read_and_redirect logic moved here + +Rules enforced: + - All business logic lives here. + - Calls selectors for reads; uses ORM directly only for writes/updates. + - No Django HTTP objects (request/response) — those belong in api/views.py. + - Raises custom exceptions on failure. + - No hardcoded strings — all module names and types use TextChoices from models.py. +""" + +import logging + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from notifications.signals import notify + +from . import selectors + +logger = logging.getLogger(__name__) + + +def _get_content_type(obj): + """Cached ContentType lookup for a model instance (used by bulk_create).""" + return ContentType.objects.get_for_model(obj.__class__) +from .models import ( + Announcement, + AudienceType, + ModuleName, + NotificationEventType, + LeaveNotifType, + MessNotifType, + VisitorHostelNotifType, + HealthcareNotifType, + ScholarshipNotifType, + OfficeDeanPnDNotifType, + OfficeDeanSNotifType, + OfficeDeanRSPCNotifType, + GymkhanaNotifType, + ResearchProceduresNotifType, + NotificationPreference, +) + +User = get_user_model() + + +# ───────────────────────────────────────────── +# Custom Exceptions +# ───────────────────────────────────────────── + +class NotificationNotFound(Exception): + """Raised when a notification cannot be found for the given user.""" + + +class InvalidModuleName(Exception): + """Raised when an unrecognised module name is provided.""" + + +class InvalidNotificationType(Exception): + """Raised when an unrecognised notification type is provided for a module.""" + + +class UnauthorizedSender(Exception): + """BR-NT-03: Raised when a non-authorized user tries to perform admin actions.""" + + +class DuplicateNotification(Exception): + """BR-NT-04: Raised when a duplicate notification is sent within cooldown window.""" + + +# ───────────────────────────────────────────── +# Internal helper +# ───────────────────────────────────────────── + +def _send(*, sender, recipient, url: str, module: str, verb: str, + description: str = "", flag: str = "", priority: str = "medium"): + """ + Central wrapper around notify.send. + BR-NT-05: Critical priority bypasses user preferences + triggers email. + BR-NT-06: Per-module opt-out — disabled modules are silently skipped. + """ + if priority != "critical" and not selectors.is_module_enabled_for_user(recipient, module): + return # User opted out — silently skip. + + kwargs = dict( + sender=sender, + recipient=recipient, + url=url, + module=module, + verb=verb, + ) + if description: + kwargs["description"] = description + if flag: + kwargs["flag"] = flag + + notify.send(**kwargs) + logger.info("notification.sent user=%s module=%s priority=%s", + recipient.username, module, priority) + + # BR-NT-05: Critical notifications trigger immediate email (bypasses mute/DND) + if priority == "critical": + try: + from django.core.mail import send_mail + recipient_email = recipient.email or f"{recipient.username}@fusion.edu" + send_mail( + subject=f"[CRITICAL] {verb}", + message=( + f"This is a critical notification from Fusion ({module}).\n\n" + f"{verb}\n\n" + f"{description}\n\n" + f"-- Fusion Notification System (NAM)\n" + f" This message bypasses Do Not Disturb settings (BR-NT-05)." + ), + from_email=None, # uses DEFAULT_FROM_EMAIL + recipient_list=[recipient_email], + fail_silently=False, + ) + logger.info("critical.email.sent user=%s", recipient.username) + except Exception as exc: + logger.error("critical.email.failed user=%s err=%s", + recipient.username, exc) + # Do not propagate — notification is more important than email + + +# ───────────────────────────────────────────── +# Mark as read + return redirect URL +# (Source: notifications_extension/views.py) +# ───────────────────────────────────────────── + +def mark_as_read_and_get_redirect_url(*, slug, user): + """ + Mark a notification as read and return the redirect URL. + + Source: notifications_extension/views.py::mark_as_read_and_redirect + The original view used HttpResponseRedirect directly — redirect logic is + extracted here so the view stays thin. + + Returns: + dict with keys: + 'url_name' : str — Django URL name to reverse + 'kwargs' : dict — URL kwargs for reverse() + 'is_complaint': bool — True only for Complaint System module + + Raises: + NotificationNotFound: if notification not found for user/slug. + """ + notification = selectors.get_notification_by_slug(slug, user) + if notification is None: + raise NotificationNotFound( + f"Notification with slug '{slug}' not found for user '{user.username}'." + ) + + notification.mark_as_read() + + is_complaint = ( + notification.data.get("module") == ModuleName.COMPLAINT_SYSTEM + ) + + url_name = notification.data.get("url", "") + redirect_kwargs = {} + + if is_complaint: + complaint_id = notification.description + redirect_kwargs = {"detailcomp_id1": complaint_id} + + return { + "url_name": url_name, + "kwargs": redirect_kwargs, + "is_complaint": is_complaint, + } + + +# ───────────────────────────────────────────── +# Mark as read / unread (single) +# ───────────────────────────────────────────── + +def mark_notification_as_read(*, notification_id: int, user): + """ + Mark a single notification as read. + Raises NotificationNotFound if not found. + """ + notification = selectors.get_notification_by_id(notification_id, user) + if notification is None: + raise NotificationNotFound( + f"Notification {notification_id} not found for user '{user.username}'." + ) + if notification.unread: + notification.mark_as_read() + return notification + + +def mark_notification_as_unread(*, notification_id: int, user): + """ + Mark a single notification as unread. + Raises NotificationNotFound if not found. + """ + notification = selectors.get_notification_by_id(notification_id, user) + if notification is None: + raise NotificationNotFound( + f"Notification {notification_id} not found for user '{user.username}'." + ) + if not notification.unread: + notification.mark_as_unread() + return notification + + +def mark_all_notifications_as_read(*, user): + """Mark every unread notification for the user as read.""" + selectors.get_unread_notifications_for_user(user).mark_all_as_read() + + +def mark_all_notifications_as_unread(*, user): + """Mark every read notification for the user as unread.""" + from notifications.models import Notification + Notification.objects.filter(recipient=user, unread=False).update(unread=True) + + +# ───────────────────────────────────────────── +# Delete notifications +# ───────────────────────────────────────────── + +def delete_notification(*, notification_id: int, user): + """ + BR-NT-09: Soft-archive a notification (retained 180 days). + Creates an ArchivedNotification record instead of hard-deleting. + Raises NotificationNotFound if not found. + """ + from notifications.models import Notification as _Notification + from .models import ArchivedNotification + exists = _Notification.objects.filter(recipient=user, id=notification_id).exists() + if not exists: + raise NotificationNotFound( + f"Notification {notification_id} not found for user '{user.username}'." + ) + ArchivedNotification.objects.get_or_create(user=user, notification_id=notification_id) + + +def delete_all_notifications(*, user): + """BR-NT-09: Soft-archive all notifications for the user (retained 180 days).""" + from notifications.models import Notification as _Notification + from .models import ArchivedNotification + ids = _Notification.objects.filter(recipient=user).values_list("id", flat=True) + existing = set( + ArchivedNotification.objects.filter(user=user).values_list("notification_id", flat=True) + ) + ArchivedNotification.objects.bulk_create([ + ArchivedNotification(user=user, notification_id=nid) + for nid in ids if nid not in existing + ]) + + +# ───────────────────────────────────────────── +# Notification Preferences +# ───────────────────────────────────────────── + +def send_notification_from_module(*, sender, recipient, module: str, verb: str, + description: str = "", url: str = "#", + priority: str = "medium"): + """ + Generic entry-point called by other Fusion modules via the REST API. + Validates the module name, then delegates to _send() which also + checks the recipient's preference before dispatching. + BR-NT-05: priority="critical" bypasses preferences and sends email. + + Raises: + InvalidModuleName: if module is not a recognised ModuleName value. + """ + valid_modules = {choice[0] for choice in ModuleName.choices} + if module not in valid_modules: + raise InvalidModuleName(f"'{module}' is not a recognised Fusion module.") + + _send(sender=sender, recipient=recipient, url=url, module=module, + verb=verb, description=description, priority=priority) + + +def initialize_default_preferences(*, user): + """ + Create a default (enabled) NotificationPreference for every module + if none exist yet for this user. Called lazily on first GET /preferences/. + """ + if NotificationPreference.objects.filter(user=user).exists(): + return + NotificationPreference.objects.bulk_create([ + NotificationPreference(user=user, module=module, is_enabled=True) + for module, _ in ModuleName.choices + ]) + + +def set_notification_preference(*, user, module: str, is_enabled: bool): + """ + Create or update a NotificationPreference. + Raises InvalidModuleName if the module is not recognised. + """ + valid_modules = {choice[0] for choice in ModuleName.choices} + if module not in valid_modules: + raise InvalidModuleName(f"'{module}' is not a recognised Fusion module.") + + pref, _ = NotificationPreference.objects.update_or_create( + user=user, + module=module, + defaults={"is_enabled": is_enabled}, + ) + return pref + + +# ───────────────────────────────────────────── +# Module-specific notification senders +# Source: notification/views.py (all functions) +# ───────────────────────────────────────────── + +def leave_module_notif(*, sender, recipient, type: str, date: str = None): + """ + Source: notification/views.py::leave_module_notif + All verb strings moved from hardcoded to here; module uses ModuleName.LEAVE_MODULE. + """ + url = "leave:leave" + module = ModuleName.LEAVE_MODULE + verb = "" + + if type == LeaveNotifType.LEAVE_APPLIED: + verb = "Your leave has been successfully submitted." + elif type == LeaveNotifType.REQUEST_ACCEPTED: + verb = "Your responsibility has been accepted." + elif type == LeaveNotifType.REQUEST_DECLINED: + verb = "Your responsibility has been declined." + elif type == LeaveNotifType.LEAVE_ACCEPTED: + verb = "Your leave request has been accepted." + elif type == LeaveNotifType.LEAVE_FORWARDED: + verb = "Your leave request has been forwarded." + elif type == LeaveNotifType.LEAVE_REJECTED: + verb = "Your leave request has been rejected." + elif type == LeaveNotifType.OFFLINE_LEAVE: + verb = "Your offline leave has been updated." + elif type == LeaveNotifType.REPLACEMENT_REQUEST: + verb = "You have a replacement request." + elif type == LeaveNotifType.LEAVE_REQUEST: + verb = "You have a leave request." + elif type == LeaveNotifType.LEAVE_WITHDRAWN: + verb = f"The leave has been withdrawn for {date}." + elif type == LeaveNotifType.REPLACEMENT_CANCEL: + verb = f"Your replacement has been cancelled for {date}." + else: + raise InvalidNotificationType(f"Unknown leave notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def placement_cell_notif(*, sender, recipient, type: str): + """Source: notification/views.py::placement_cell_notif""" + url = "placement:placement" + module = ModuleName.PLACEMENT_CELL + verb = type # Placement cell verb == type directly (as in source) + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def academics_module_notif(*, sender, recipient, type: str): + """Source: notification/views.py::academics_module_notif""" + url = "" + module = ModuleName.ACADEMICS_MODULE + verb = type + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def office_module_notif(*, sender, recipient): + """Source: notification/views.py::office_module_notif""" + url = "office_module:officeOfRegistrar" + module = ModuleName.ACADEMICS_MODULE + verb = "New file received." + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def central_mess_notif(*, sender, recipient, type: str, message: str = None): + """Source: notification/views.py::central_mess_notif""" + url = "mess:mess" + module = ModuleName.CENTRAL_MESS + verb = "" + + if type == MessNotifType.FEEDBACK_SUBMITTED: + verb = "Your feedback has been successfully submitted." + elif type == MessNotifType.MENU_CHANGE_ACCEPTED: + verb = "Menu request has been approved." + elif type == MessNotifType.LEAVE_REQUEST: + verb = message + elif type == MessNotifType.VACATION_REQUEST: + verb = f"Your vacation request has been {message}." + elif type == MessNotifType.MEETING_INVITATION: + verb = message + elif type == MessNotifType.SPECIAL_REQUEST: + verb = f"Your special food request has been {message}." + elif type == MessNotifType.ADDED_COMMITTEE: + verb = "You have been added to the mess committee." + else: + raise InvalidNotificationType(f"Unknown mess notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def visitors_hostel_notif(*, sender, recipient, type: str): + """Source: notification/views.py::visitors_hostel_notif""" + url = "visitorhostel:visitorhostel" + module = ModuleName.VISITORS_HOSTEL + verb = "" + + if type == VisitorHostelNotifType.BOOKING_CONFIRMATION: + verb = "Your booking has been confirmed." + elif type == VisitorHostelNotifType.BOOKING_CANCELLATION_REQ_ACCEPTED: + verb = "Your Booking Cancellation Request has been accepted." + elif type == VisitorHostelNotifType.BOOKING_REQUEST: + verb = "New Booking Request." + elif type == VisitorHostelNotifType.CANCELLATION_REQUEST_PLACED: + verb = "New Booking Cancellation Request." + elif type == VisitorHostelNotifType.BOOKING_FORWARDED: + verb = "New Forwarded Booking Request." + elif type == VisitorHostelNotifType.BOOKING_REJECTED: + verb = "Your Booking Request has been rejected." + else: + raise InvalidNotificationType(f"Unknown visitor hostel notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def healthcare_center_notif(*, sender, recipient, type: str): + """Source: notification/views.py::healthcare_center_notif""" + url = "healthcenter:healthcenter" + module = ModuleName.HEALTHCARE_CENTER + verb = "" + + if type == HealthcareNotifType.APPOINTMENT_BOOKED: + verb = "Your Appointment has been booked." + elif type == HealthcareNotifType.AMBULANCE_REQUEST: + verb = "Your Ambulance request has been placed." + elif type == HealthcareNotifType.PRESCRIPTION: + verb = "You have been prescribed some medicine." + elif type == HealthcareNotifType.APPOINTMENT_REQUEST: + verb = "You have a new appointment request." + elif type == HealthcareNotifType.AMBULANCE_REQ: + verb = "You have a new ambulance request." + else: + raise InvalidNotificationType(f"Unknown healthcare notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def file_tracking_notif(*, sender, recipient, title: str): + """Source: notification/views.py::file_tracking_notif""" + url = "filetracking:inward" + module = ModuleName.FILE_TRACKING + verb = title + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def scholarship_portal_notif(*, sender, recipient, type: str): + """Source: notification/views.py::scholarship_portal_notif""" + url = "spacs:spacs" + module = ModuleName.SCHOLARSHIP_PORTAL + verb = "" + + if type.startswith("award"): + award_name = type.split("_", 1)[1] + verb = f"Invitation to apply for {award_name}." + elif type == ScholarshipNotifType.ACCEPT_MCM: + verb = "Your MCM form has been accepted." + elif type == ScholarshipNotifType.REJECT_MCM: + verb = "Your MCM form has been rejected as you have not fulfilled the required criteria." + elif type == ScholarshipNotifType.ACCEPT_GOLD: + verb = "Your Convocation form for Director's Gold Medal has been accepted." + elif type == ScholarshipNotifType.REJECT_GOLD: + verb = "Your Convocation form for Director's Gold Medal has been rejected." + elif type == ScholarshipNotifType.ACCEPT_SILVER: + verb = "Your Convocation form for Director's Silver Medal has been accepted." + elif type == ScholarshipNotifType.REJECT_SILVER: + verb = "Your Convocation form for Director's Silver Medal has been rejected." + elif type == ScholarshipNotifType.ACCEPT_DM: + verb = "Your Convocation form for D&M Proficiency Gold Medal has been accepted." + else: + raise InvalidNotificationType(f"Unknown scholarship notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def complaint_system_notif(*, sender, recipient, type: str, complaint_id, is_student: bool, message: str): + """ + Source: notification/views.py::complaint_system_notif + Original used student==0 integer flag; replaced with bool is_student. + """ + url = "complaint:detail2" if is_student else "complaint:detail" + module = ModuleName.COMPLAINT_SYSTEM + verb = message + description = str(complaint_id) + _send(sender=sender, recipient=recipient, url=url, module=module, + verb=verb, description=description) + + +def office_dean_pnd_notif(*, sender, recipient, type: str): + """Source: notification/views.py::office_dean_PnD_notif""" + url = "office_module:officeOfDeanPnD" + module = ModuleName.OFFICE_DEAN_PND + verb = "" + + if type == OfficeDeanPnDNotifType.REQUISITION_FILED: + verb = "Your requisition has been successfully submitted." + elif type == OfficeDeanPnDNotifType.REQUEST_ACCEPTED: + verb = "Your requisition has been accepted." + elif type == OfficeDeanPnDNotifType.REQUEST_REJECTED: + verb = "Your requisition has been rejected." + elif type == OfficeDeanPnDNotifType.ASSIGNMENT_CREATED: + verb = "Assignment has been created." + elif type == OfficeDeanPnDNotifType.ASSIGNMENT_RECEIVED: + verb = "You have received an assignment." + elif type == OfficeDeanPnDNotifType.ASSIGNMENT_REVERTED: + verb = "Assignment has been reverted." + elif type == OfficeDeanPnDNotifType.ASSIGNMENT_APPROVED: + verb = "Assignment has been approved." + elif type == OfficeDeanPnDNotifType.ASSIGNMENT_REJECTED: + verb = "Assignment has been rejected." + else: + raise InvalidNotificationType(f"Unknown Dean PnD notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def office_module_deans_notif(*, sender, recipient, type: str): + """Source: notification/views.py::office_module_DeanS_notif""" + url = "office_module:officeOfDeanStudents" + module = ModuleName.OFFICE_MODULE + verb = "" + + if type == OfficeDeanSNotifType.HOSTEL_ALLOTED: + verb = "Hostel has been allotted successfully." + elif type == OfficeDeanSNotifType.INSUFFICIENT_FUNDS: + verb = "Insufficient Funds! Please contact Junior Superintendent to allot funds." + elif type == OfficeDeanSNotifType.MOM_SUBMITTED: + verb = "MOM of the meeting has been submitted successfully." + elif type == OfficeDeanSNotifType.BUDGET_APPROVED: + verb = "Budget has been approved by Dean Students." + elif type == OfficeDeanSNotifType.BUDGET_REJECTED: + verb = "Budget has been rejected by Dean Students." + elif type == OfficeDeanSNotifType.CLUB_APPROVED: + verb = "New Club has been approved by Dean Students." + elif type == OfficeDeanSNotifType.CLUB_REJECTED: + verb = "New Club has been rejected by Dean Students." + elif type == OfficeDeanSNotifType.MEETING_BOOKED: + verb = "Meeting has been booked and members have been notified." + elif type == OfficeDeanSNotifType.SESSION_APPROVED: + verb = "Club session has been approved." + elif type == OfficeDeanSNotifType.SESSION_REJECTED: + verb = "Club session has been rejected." + elif type == OfficeDeanSNotifType.BUDGET_ALLOTED: + verb = "Budget has been allotted by Junior Superintendent." + else: + raise InvalidNotificationType(f"Unknown Dean Students notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def gymkhana_voting_notif(*, sender, recipient, type: str, title: str, desc: str): + """Source: notification/views.py::gymkhana_voting""" + url = "gymkhana:gymkhana" + module = ModuleName.GYMKHANA_MODULE + verb = "" + + if type == GymkhanaNotifType.VOTING_OPEN: + verb = f"Voting is open for {title}." + else: + raise InvalidNotificationType(f"Unknown gymkhana voting notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, + verb=verb, description=desc) + + +def gymkhana_session_notif(*, sender, recipient, type: str, club: str, desc: str, venue: str): + """Source: notification/views.py::gymkhana_session""" + url = "gymkhana:gymkhana" + module = ModuleName.GYMKHANA_MODULE + verb = "" + + if type == GymkhanaNotifType.NEW_SESSION: + verb = f"A session by {club} Club will be organised in {venue}." + else: + raise InvalidNotificationType(f"Unknown gymkhana session notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, + verb=verb, description=desc) + + +def gymkhana_event_notif(*, sender, recipient, type: str, club: str, + event_name: str, desc: str, venue: str): + """Source: notification/views.py::gymkhana_event""" + url = "gymkhana:gymkhana" + module = ModuleName.GYMKHANA_MODULE + verb = "" + + if type == GymkhanaNotifType.NEW_EVENT: + verb = f"{event_name} event by {club} Club will be organised in {venue}." + else: + raise InvalidNotificationType(f"Unknown gymkhana event notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, + verb=verb, description=desc) + + +def assistantship_claim_notify(*, sender, recipient, month: str, year: str): + """Source: notification/views.py::AssistantshipClaim_notify""" + url = "academic-procedures:academic_procedures" + module = ModuleName.ASSISTANTSHIP_REQUEST + verb = f"Your Assistantship claim of {month} month year {year} is approved." + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def assistantship_claim_faculty_notify(*, sender, recipient): + """Source: notification/views.py::AssistantshipClaim_faculty_notify""" + url = "academic-procedures:academic_procedures" + module = ModuleName.ASSISTANTSHIP_REQUEST + verb = "Assistantship claim is requested." + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def assistantship_claim_acad_notify(*, sender, recipient): + """Source: notification/views.py::AssistantshipClaim_acad_notify""" + url = "academic-procedures:academic_procedures" + module = ModuleName.ASSISTANTSHIP_REQUEST + verb = "Assistantship claim is requested." + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def assistantship_claim_account_notify(*, sender, stu, recipient): + """Source: notification/views.py::AssistantshipClaim_account_notify""" + url = "academic-procedures:academic_procedures" + module = ModuleName.ASSISTANTSHIP_REQUEST + verb = f"Assistantship claim of {stu} is forwarded." + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def department_notif(*, sender, recipient, type: str): + """Source: notification/views.py::department_notif""" + url = "dep:dep" + module = ModuleName.DEPARTMENT + verb = type + flag = "department" + _send(sender=sender, recipient=recipient, url=url, module=module, + verb=verb, flag=flag) + + +def office_module_dean_rspc_notif(*, sender, recipient, type: str): + """Source: notification/views.py::office_module_DeanRSPC_notif""" + url = "office:officeOfDeanRSPC" + module = ModuleName.OFFICE_MODULE + verb = "" + + if type == OfficeDeanRSPCNotifType.APPROVE: + verb = "Your Project request has been accepted." + elif type == OfficeDeanRSPCNotifType.DISAPPROVE: + verb = "Your project request got rejected." + elif type == OfficeDeanRSPCNotifType.PENDING: + verb = "Kindly wait for the response." + else: + raise InvalidNotificationType(f"Unknown Dean RSPC notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +def research_procedures_notif(*, sender, recipient, type: str): + """Source: notification/views.py::research_procedures_notif""" + url = "research_procedures:patent_registration" + module = ModuleName.RESEARCH_PROCEDURES + verb = "" + + if type == ResearchProceduresNotifType.APPROVED: + verb = "Your Patent has been Approved." + elif type == ResearchProceduresNotifType.DISAPPROVED: + verb = "Your Patent has been Rejected." + elif type == ResearchProceduresNotifType.PENDING: + verb = "Your Patent is Pending, wait for the response." + elif type == ResearchProceduresNotifType.SUBMITTED: + verb = "Your Patent has been Submitted, wait for the response." + elif type == ResearchProceduresNotifType.CREATED: + verb = "A new Patent has been Created." + else: + raise InvalidNotificationType(f"Unknown research procedures notification type: '{type}'") + + _send(sender=sender, recipient=recipient, url=url, module=module, verb=verb) + + +# ───────────────────────────────────────────── +# UC-NT-01: Register / manage event types +# ───────────────────────────────────────────── + +class EventTypeAlreadyExists(Exception): + """Raised when an event type with the same name+module already exists.""" + + +def register_event_type(*, registered_by, event_name: str, module: str, + default_priority: str = "medium", description: str = "") -> NotificationEventType: + """ + UC-NT-01 Main Flow step 1-3: + Validate the module, assign a unique Event_ID (UUID), map to notification template. + + Raises: + InvalidModuleName: if module is not a recognised ModuleName. + EventTypeAlreadyExists: if (event_name, module) already registered. + """ + valid_modules = {choice[0] for choice in ModuleName.choices} + if module not in valid_modules: + raise InvalidModuleName(f"'{module}' is not a recognised Fusion module.") + + existing = selectors.get_event_type_by_name_and_module(event_name, module) + if existing is not None: + raise EventTypeAlreadyExists( + f"Event '{event_name}' is already registered for module '{module}'." + ) + + event_type = NotificationEventType.objects.create( + event_name=event_name, + module=module, + default_priority=default_priority, + description=description, + registered_by=registered_by, + is_active=True, + ) + return event_type + + +def deactivate_event_type(*, event_id: str) -> NotificationEventType: + """Deactivate a registered event type so it can no longer trigger notifications.""" + event_type = selectors.get_event_type_by_event_id(event_id) + if event_type is None: + raise NotificationNotFound(f"Event type with event_id '{event_id}' not found.") + event_type.is_active = False + event_type.save(update_fields=["is_active"]) + return event_type + + +# ───────────────────────────────────────────── +# UC-NT-02: Trigger notification via Event ID +# ───────────────────────────────────────────── + +_recent_triggers = {} # BR-NT-04: in-memory dedup cache {(user_id, event_id): timestamp} + +def trigger_notification_by_event_id(*, event_id: str, sender, recipient, + message_content: str, deep_link: str = "#") -> None: + """ + UC-NT-02 Main Flow: + External module calls NAM with Event_ID, User_ID, Message_Content, Deep_Link. + NAM resolves recipient preferences, records the notification, pushes to tray. + + BR-NT-03: Only registered event types can trigger (event_id must exist). + BR-NT-04: Deduplication — suppress duplicate for same User_ID + Event_ID within 60s. + BR-NT-05: Critical priority bypasses user preferences. + + Raises: + NotificationNotFound: if event_id is not a registered active event type. + DuplicateNotification: if same user+event triggered within 60s. + """ + from django.utils import timezone + + event_type = selectors.get_event_type_by_event_id(event_id) + if event_type is None: + raise NotificationNotFound( + f"No active event type found with event_id '{event_id}'." + ) + + # BR-NT-04: Deduplication — 60-second cooldown per (recipient, event_id) + dedup_key = (recipient.id, event_id) + now = timezone.now() + if dedup_key in _recent_triggers: + elapsed = (now - _recent_triggers[dedup_key]).total_seconds() + if elapsed < 60: + raise DuplicateNotification( + f"Duplicate suppressed. Same notification sent {int(elapsed)}s ago (BR-NT-04: 60s cooldown)." + ) + _recent_triggers[dedup_key] = now + + # BR-NT-05: pass priority so critical bypasses preferences + _send( + sender=sender, + recipient=recipient, + url=deep_link, + module=event_type.module, + verb=message_content, + description=f"Triggered by event: {event_type.event_name}", + priority=event_type.default_priority, + ) + + +# ───────────────────────────────────────────── +# UC-NT-03: Broadcast Manual Announcement +# ───────────────────────────────────────────── + +def _resolve_audience(audience_type: str, audience_value: str): + """ + Return queryset of User objects for the given audience. + + audience_type values: + ALL → every auth_user in the database + STUDENTS → globals_extrainfo.user_type = 'student' + FACULTY → globals_extrainfo.user_type = 'faculty' + STAFF → globals_extrainfo.user_type = 'staff' + GROUP → audience_value = designation name + e.g. 'Director', 'HOD (CSE)', 'mess_warden' + DEPARTMENT → audience_value = department name + e.g. 'CSE', 'ECE', 'ME', 'Design', 'SM' + BATCH → audience_value = batch prefix + e.g. '23BCS', '22BEC', '24BME' + """ + from .models import GlobalsExtraInfo + + if audience_type == AudienceType.ALL: + return User.objects.filter(is_active=True) + elif audience_type == AudienceType.STUDENTS: + return selectors.get_users_by_type("student") + elif audience_type == AudienceType.FACULTY: + qs = selectors.get_users_by_type("faculty") + if not qs.exists(): + qs = selectors.get_users_by_type("staff") + return qs + elif audience_type == AudienceType.STAFF: + return selectors.get_users_by_type("staff") + elif audience_type == AudienceType.GROUP: + return selectors.get_users_by_designation(audience_value) + elif audience_type == AudienceType.DEPARTMENT: + dept_user_ids = GlobalsExtraInfo.objects.filter( + department__name=audience_value + ).values_list("user_id", flat=True) + return User.objects.filter(id__in=dept_user_ids, is_active=True) + elif audience_type == AudienceType.BATCH: + return User.objects.filter( + username__istartswith=audience_value, is_active=True + ) + elif audience_type == AudienceType.SPECIFIC_USER: + # audience_value may be a single username or a comma-separated list + usernames = [u.strip() for u in str(audience_value).split(",") if u.strip()] + return User.objects.filter(username__in=usernames, is_active=True) + return User.objects.none() + + +def broadcast_announcement(*, sender, title: str, message: str, + audience_type: str, audience_value: str = "", + expiry_date, priority: str = "medium") -> Announcement: + """ + UC-NT-03 Main Flow: + Admin drafts a message, selects audience, sets expiry date. + NAM fetches recipient list (BR-NT-07: RBAC) and publishes to all. + + BR-NT-03: Only authorized senders (staff/superuser) can broadcast. + + Returns the created Announcement instance. + """ + # BR-NT-03: Only admin/staff can broadcast announcements + if not (sender.is_staff or sender.is_superuser): + raise UnauthorizedSender("Only authorized senders (admin/staff) can broadcast announcements (BR-NT-03).") + + valid_audience = {choice[0] for choice in AudienceType.choices} + if audience_type not in valid_audience: + raise InvalidModuleName(f"'{audience_type}' is not a valid audience type.") + + # Wrap announcement row + fan-out in a single transaction. If any recipient + # insert fails the announcement is rolled back, preventing orphaned records. + with transaction.atomic(): + announcement = Announcement.objects.create( + title=title, + message=message, + sender=sender, + audience_type=audience_type, + audience_value=audience_value, + expiry_date=expiry_date, + ) + + recipients = _resolve_audience(audience_type, audience_value) + + # Single-query opt-out exclusion (BR-NT-06) — users who disabled the + # OFFICE_MODULE preference don't receive the announcement. + if priority != "critical": + from .models import NotificationPreference + opted_out = NotificationPreference.objects.filter( + module=ModuleName.OFFICE_MODULE, is_enabled=False + ).values_list("user_id", flat=True) + recipients = recipients.exclude(id__in=opted_out) + + # Single-statement fan-out: INSERT INTO notifications (...) SELECT user_id, ... + # FROM auth_user WHERE id IN (recipients). 3000+ rows in ~50ms instead of 15s. + from django.db import connection + from notifications.models import Notification + from django.utils import timezone + import json + + now = timezone.now() + sender_ct_id = _get_content_type(sender).pk + recipient_ids = list(recipients.values_list("id", flat=True)) + delivered = len(recipient_ids) + + if recipient_ids: + data_json = json.dumps({ + "module": ModuleName.OFFICE_MODULE, + "url": "/notifications/announcements/", + "flag": "announcement", + "announcement_id": announcement.id, + "priority": priority, + "expiry_date": announcement.expiry_date.isoformat(), + }) + table = Notification._meta.db_table + # 9 placeholders + 3 hardcoded booleans (unread/public TRUE, deleted/emailed FALSE) + placeholders = ",".join( + ["(%s, %s, %s, %s, %s, %s, TRUE, TRUE, FALSE, FALSE, %s, %s)"] * len(recipient_ids) + ) + sql = ( + f"INSERT INTO {table} " + "(recipient_id, actor_content_type_id, actor_object_id, " + "verb, description, level, " + "unread, public, deleted, emailed, " + "timestamp, data) " + f"VALUES {placeholders}" + ) + params = [] + for uid in recipient_ids: + params.extend([ + uid, + sender_ct_id, + str(sender.pk), + title, + message, + "info", + now, + data_json, + ]) + with connection.cursor() as cur: + cur.execute(sql, params) + + logger.info("announcement.broadcast id=%s audience=%s delivered=%s", + announcement.id, audience_type, delivered) + return announcement diff --git a/FusionIIIT/applications/notifications_extension/tests.py b/FusionIIIT/applications/notifications_extension/tests.py deleted file mode 100644 index 7ce503c2d..000000000 --- a/FusionIIIT/applications/notifications_extension/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/FusionIIIT/applications/notifications_extension/tests/__init__.py b/FusionIIIT/applications/notifications_extension/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/notifications_extension/tests/conftest.py b/FusionIIIT/applications/notifications_extension/tests/conftest.py new file mode 100644 index 000000000..09f33c6da --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/conftest.py @@ -0,0 +1,78 @@ +""" +conftest.py - Shared base test class for the Notification Module (NAM). + +Each test class inherits from BaseNAMTestCase which sets up: + - A student (is_staff=False) and a staff/admin user (is_staff=True) + - A DRF APIClient pre-wired for force_authenticate + - Helper methods to login as either role + - _record_result() to populate the report-metadata attributes the custom + test runner (runner.py) reads. +""" + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + + +User = get_user_model() + + +class BaseNAMTestCase(TestCase): + """Base class shared by UC, BR, and WF test classes.""" + + @classmethod + def setUpTestData(cls): + cls.student = User.objects.create_user( + username="nam_student", password="testpass123", + first_name="Test", last_name="Student", + email="nam_student@fusion.edu", is_staff=False, + ) + cls.staff = User.objects.create_user( + username="nam_staff", password="testpass123", + first_name="Test", last_name="Staff", + email="nam_staff@fusion.edu", is_staff=True, + ) + cls.admin = User.objects.create_user( + username="nam_admin", password="testpass123", + first_name="Test", last_name="Admin", + email="nam_admin@fusion.edu", is_staff=True, is_superuser=True, + ) + + def setUp(self): + self.client = APIClient() + + # ── Auth helpers ──────────────────────────────────────────── + def login_as_student(self): + self.client.force_authenticate(user=self.student) + + def login_as_staff(self): + self.client.force_authenticate(user=self.staff) + + def login_as_admin(self): + self.client.force_authenticate(user=self.admin) + + def logout(self): + self.client.force_authenticate(user=None) + + # ── Report-metadata recorder ──────────────────────────────── + def _record_result(self, *, test_id, source_id, category, scenario, + preconditions, input_action, expected_result, + actual_result, status="Pass", evidence=""): + """Attach report metadata to the test instance so runner.py picks it up.""" + self._test_id = test_id + self._test_category = category + self._scenario = scenario + self._preconditions = preconditions + self._input_action = input_action + self._expected_result = expected_result + self._actual_result = actual_result + self._status = status + self._evidence = evidence + + # source_id is either a UC ID, BR ID or WF ID. + if source_id.startswith("UC"): + self._uc_id = source_id + elif source_id.startswith("BR"): + self._br_id = source_id + elif source_id.startswith("WF"): + self._wf_id = source_id diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/Artifact_Evaluation.csv b/FusionIIIT/applications/notifications_extension/tests/reports/Artifact_Evaluation.csv new file mode 100644 index 000000000..6686b8c88 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/Artifact_Evaluation.csv @@ -0,0 +1,17 @@ +Artifact ID,Artifact Type,Tests,Pass,Partial,Fail,Final Status,Remarks +UC-NT-01,UC,3,3,0,0,Implemented Correctly,3/3 passed +UC-NT-02,UC,3,3,0,0,Implemented Correctly,3/3 passed +UC-NT-03,UC,3,3,0,0,Implemented Correctly,3/3 passed +UC-NT-04,UC,3,3,0,0,Implemented Correctly,3/3 passed +BR-NT-01,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-02,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-03,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-04,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-05,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-06,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-07,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-08,BR,2,2,0,0,Enforced Correctly,2/2 passed +BR-NT-09,BR,2,2,0,0,Enforced Correctly,2/2 passed +WF-NT-01,WF,3,3,0,0,Complete,3/3 passed +WF-NT-02,WF,3,3,0,0,Complete,3/3 passed +WF-NT-03,WF,3,3,0,0,Complete,3/3 passed diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/BR_Test_Design.csv b/FusionIIIT/applications/notifications_extension/tests/reports/BR_Test_Design.csv new file mode 100644 index 000000000..debda4fe6 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/BR_Test_Design.csv @@ -0,0 +1,51 @@ +Test ID,BR ID,Test Category,Input / Action,Expected Result +BR-NT-01-V-01,BR-NT-01,Valid,"Staff POSTs a notification with module=""Leave Module"" via /api/notifications/send/. NAM stores it in the single central table. +","201 Created; exactly one call to notify.send (central store); no per-module side-channel. +" +BR-NT-01-I-01,BR-NT-01,Invalid,"Staff POSTs with module=""RogueModule"" (not in ModuleName.choices). +","400 Bad Request; central store refuses unlisted modules, preventing any module from bypassing NAM. +" +BR-NT-02-V-01,BR-NT-02,Valid,"Structural check that NotificationBell component exists in the global header component tree. +","NotificationBell.jsx file exists and is imported by the header component, so it renders on every page. +" +BR-NT-02-I-01,BR-NT-02,Invalid,"Any module attempts to render its own alternate bell — should be impossible because a single shared component is imported from components/NotificationBell. +","Only one NotificationBell instance is present in the app (asserted by searching for duplicate declarations; only the shared one exists). +" +BR-NT-03-V-01,BR-NT-03,Valid,"Staff user (is_staff=True) POSTs /api/notifications/send/ with a valid token in Authorization header. +",201 Created; notification delivered. +BR-NT-03-I-01,BR-NT-03,Invalid,"Student user (is_staff=False) or unauthenticated caller POSTs /send/. +","403 Forbidden (students) or 401 Unauthorized (no token); no notification created. +" +BR-NT-04-V-01,BR-NT-04,Valid,"Staff triggers the same (user, event_id) twice with > 60 seconds between calls. +",Both calls return 201; two notifications stored. +BR-NT-04-I-01,BR-NT-04,Invalid,"Staff triggers the same (user, event_id) twice within 60 seconds. +","First call 201; second call is suppressed (DuplicateNotification mapped to 429/400). +" +BR-NT-05-V-01,BR-NT-05,Valid,"Recipient has muted ""Leave Module""; staff sends a critical-priority notification for that module. +","notify.send is still called; notification is recorded; a critical email is dispatched via send_mail. +" +BR-NT-05-I-01,BR-NT-05,Invalid,"Recipient has muted ""Leave Module""; staff sends a medium-priority notification for that module. +","_send() short-circuits; notify.send is NOT called; no notification stored. +" +BR-NT-06-V-01,BR-NT-06,Valid,"Create an announcement with a future expiry_date; call GET /api/notifications/announcements/. +","Announcement appears in the list (selectors filter by expiry_date__gte=today). +" +BR-NT-06-I-01,BR-NT-06,Invalid,"Create an announcement with a past expiry_date; call GET /api/notifications/announcements/. +","Expired announcement does NOT appear in the active list. +" +BR-NT-07-V-01,BR-NT-07,Valid,"Admin broadcasts with audience_type=group, audience_value=""HOD (CSE)"". _resolve_audience must query Fusion RBAC tables (globals_holdsdesignation) at delivery time. +","_resolve_audience is called with ('group', 'HOD (CSE)'); only users currently holding that designation receive the broadcast. +" +BR-NT-07-I-01,BR-NT-07,Invalid,"Admin broadcasts with audience_type=group and a non-existent designation value. +","Announcement is created but the resolved audience set is empty; zero notify.send invocations. +" +BR-NT-08-V-01,BR-NT-08,Valid,"Admin broadcasts an announcement and the Announcement row is inspected. +","Row has sender (Author_ID) and created_at (Timestamp) populated automatically. Approval_ID is captured by the enforcing caller (admin user). +" +BR-NT-08-I-01,BR-NT-08,Invalid,"Unauthenticated or non-admin caller attempts to broadcast. +","403/401; no Announcement row is created, so no audit trail row is generated for an unauthorized attempt. +" +BR-NT-09-V-01,BR-NT-09,Valid,"User DELETEs /api/notifications/{id}/delete/ for an existing notification. +","An ArchivedNotification row is created; the source notifications_notification row is NOT deleted (retained for audit). +" +BR-NT-09-I-01,BR-NT-09,Invalid,User attempts to DELETE a notification ID that does not exist.,404 Not Found; no ArchivedNotification row is created. diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/Defect_Log.csv b/FusionIIIT/applications/notifications_extension/tests/reports/Defect_Log.csv new file mode 100644 index 000000000..d2e7da2ee --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/Defect_Log.csv @@ -0,0 +1 @@ +Defect ID,Related Test ID,Related Artifact,Severity,Description,Suggested Fix diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/Module_Test_Summary.csv b/FusionIIIT/applications/notifications_extension/tests/reports/Module_Test_Summary.csv new file mode 100644 index 000000000..c2cce4fe3 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/Module_Test_Summary.csv @@ -0,0 +1,18 @@ +Metric,Value +Total Use Cases,4 +Total Business Rules,9 +Total Workflows,3 +Required UC Tests,12 +Designed UC Tests,12 +Required BR Tests,18 +Designed BR Tests,18 +Required WF Tests,6 +Designed WF Tests,9 +UC Adequacy %,100.0% +BR Adequacy %,100.0% +WF Adequacy %,150.0% +Total Tests Executed,39 +Total Pass,39 +Total Partial,0 +Total Fail,0 +Strict Pass Rate %,100.0% diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/Test_Execution_Log.csv b/FusionIIIT/applications/notifications_extension/tests/reports/Test_Execution_Log.csv new file mode 100644 index 000000000..f699dced7 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/Test_Execution_Log.csv @@ -0,0 +1,40 @@ +Test ID,Source Type,Source ID,Expected Result,Actual Result,Status,Evidence,Tester +UC-NT-01-AP-01,UC,UC-NT-01,201 Created; event stored with priority=critical,201 Created received; priority persisted correctly,Pass,default_priority=critical,Claude_Opus_4.6 +UC-NT-01-EX-01,UC,UC-NT-01,400 Bad Request with module error,"400 received; body={'error': {'module': [ErrorDetail(string='""NonExistentModule"" is not a valid choice.', code='invalid_choice')]}}",Pass,Validation at serializer level blocked bad module,Claude_Opus_4.6 +UC-NT-01-HP-01,UC,UC-NT-01,201 Created; event_id + is_active=true returned,201 Created received; event_type stored with UUID,Pass,event_id=e43bab82-9b76-4965-9a3a-3c1925b055c3,Claude_Opus_4.6 +UC-NT-02-AP-01,UC,UC-NT-02,201 Created; url kwarg in notify.send matches,201 received; url forwarded correctly,Pass,notify.send kwargs.url=/academic/timetable,Claude_Opus_4.6 +UC-NT-02-EX-01,UC,UC-NT-02,404 Not Found; no notification dispatched,"404 received; body={'error': ""No active event type found with event_id '00000000-0000-0000-0000-000000000000'.""}",Pass,Service raised NotificationNotFound,Claude_Opus_4.6 +UC-NT-02-HP-01,UC,UC-NT-02,201 Created; notify.send called once,201 received; service dispatched notification,Pass,notify.send invoked; recipient=nam_student,Claude_Opus_4.6 +UC-NT-03-AP-01,UC,UC-NT-03,201 Created; audience scoped to the designation,201 received; announcement stored,Pass,audience_type=group routed through _resolve_audience(),Claude_Opus_4.6 +UC-NT-03-EX-01,UC,UC-NT-03,400 Bad Request; error details,"400 received; body={'error': {'audience_type': [ErrorDetail(string='""mars"" is not a valid choice.', code='invalid_choice')]}}",Pass,Serializer rejected invalid AudienceType,Claude_Opus_4.6 +UC-NT-03-HP-01,UC,UC-NT-03,201 Created; announcement stored; fan-out performed,201 received; fan-out count=3,Pass,Announcement record + notify.send invocations,Claude_Opus_4.6 +UC-NT-04-AP-01,UC,UC-NT-04,200 OK; unread flag cleared in DB,200 OK; note.unread=False after refresh,Pass,notification_id=1,Claude_Opus_4.6 +UC-NT-04-EX-01,UC,UC-NT-04,401/403 denied,Got 403,Pass,DRF IsAuthenticated returned 401/403,Claude_Opus_4.6 +UC-NT-04-HP-01,UC,UC-NT-04,200 OK; notifications + pagination metadata,200 OK; pagination block returned,Pass,"pagination={'page': 1, 'page_size': 10, 'total': 0, 'total_pages': 0}",Claude_Opus_4.6 +BR-NT-01-I-01,BR,BR-NT-01,400 Bad Request,"400 received; body={'error': {'module': [ErrorDetail(string='""RogueModule"" is not a valid choice.', code='invalid_choice')]}}",Pass,Central store protected from unlisted modules,Claude_Opus_4.6 +BR-NT-01-V-01,BR,BR-NT-01,201; notify.send invoked once (central store),201 received; single notify.send call,Pass,No parallel module-owned store written,Claude_Opus_4.6 +BR-NT-02-I-01,BR,BR-NT-02,Exactly one NotificationBell.jsx file,Found 1 files => ['NotificationBell.jsx'],Pass,Single source of truth enforced by convention,Claude_Opus_4.6 +BR-NT-02-V-01,BR,BR-NT-02,Component file present and referenced in header,Found bell at NotificationBell.jsx; imported in ['header.jsx'],Pass,1 files reference NotificationBell,Claude_Opus_4.6 +BR-NT-03-I-01,BR,BR-NT-03,403 Forbidden,"403 received; body={'detail': ErrorDetail(string='You do not have permission to perform this action.', code='permission_denied')}",Pass,DRF IsAdminUser rejected non-staff,Claude_Opus_4.6 +BR-NT-03-V-01,BR,BR-NT-03,201 Created,201 received,Pass,DRF IsAdminUser permission passed,Claude_Opus_4.6 +BR-NT-04-I-01,BR,BR-NT-04,First 201; second 4xx with dedup message,"First=201, Second=429",Pass,DuplicateNotification raised by service,Claude_Opus_4.6 +BR-NT-04-V-01,BR,BR-NT-04,Both calls return 201,"First=201, Second=201",Pass,Cooldown window respected only when < 60s,Claude_Opus_4.6 +BR-NT-05-I-01,BR,BR-NT-05,notify.send is NOT called,notify.send assert_not_called passed,Pass,BR-NT-06 opt-out honored for non-critical,Claude_Opus_4.6 +BR-NT-05-V-01,BR,BR-NT-05,notify.send called despite opt-out,notify.send invoked exactly once,Pass,Service honored BR-NT-05 override,Claude_Opus_4.6 +BR-NT-06-I-01,BR,BR-NT-06,Active list does NOT contain the stale announcement,Returned titles: [],Pass,expiry_date__gte=today filter enforced,Claude_Opus_4.6 +BR-NT-06-V-01,BR,BR-NT-06,Active list contains the announcement,Returned titles: ['Active announcement'],Pass,selectors.get_active_announcements filter passes,Claude_Opus_4.6 +BR-NT-07-I-01,BR,BR-NT-07,201; zero notify.send calls,201 received; notify.send never invoked,Pass,Empty queryset => zero fan-out,Claude_Opus_4.6 +BR-NT-07-V-01,BR,BR-NT-07,"_resolve_audience called with ('group', value)",Called once with correct args,Pass,BR-NT-07 wiring confirmed,Claude_Opus_4.6 +BR-NT-08-I-01,BR,BR-NT-08,401/403; Announcement count unchanged,Got 403; row count still 0,Pass,No audit row created for unauthorized attempt,Claude_Opus_4.6 +BR-NT-08-V-01,BR,BR-NT-08,Announcement has sender + created_at populated,"sender_id=36, created_at=2026-04-20 10:50:49.532143+00:00",Pass,Database row audited with Author_ID and Timestamp,Claude_Opus_4.6 +BR-NT-09-I-01,BR,BR-NT-09,404 Not Found; no archive record created,404 received; archive count unchanged (0),Pass,NotificationNotFound mapped to 404,Claude_Opus_4.6 +BR-NT-09-V-01,BR,BR-NT-09,ArchivedNotification row created; DB row not deleted,Both assertions pass,Pass,Soft-delete semantics confirmed,Claude_Opus_4.6 +WF-NT-01-E2E-01,WF,WF-NT-01,Event stored; notify.send called; list returns 200,All three steps returned expected codes,Pass,event_id=83ac0ea9-1aef-4b06-8e14-fda55e3c2163,Claude_Opus_4.6 +WF-NT-01-EXIT-01,WF,WF-NT-01,404; workflow exits cleanly without side-effects,404 received,Pass,Workflow gracefully exits,Claude_Opus_4.6 +WF-NT-01-NEG-01,WF,WF-NT-01,403 Forbidden; event unchanged,403 received; event still exists,Pass,BR-NT-03 RBAC enforced mid-workflow,Claude_Opus_4.6 +WF-NT-02-E2E-01,WF,WF-NT-02,201; notify.send called for each recipient,201; fan-out count=1,Pass,WF-NT-02 E2E complete,Claude_Opus_4.6 +WF-NT-02-EXIT-01,WF,WF-NT-02,201; zero fan-out,201 received; no notify.send calls,Pass,Workflow exits without errors,Claude_Opus_4.6 +WF-NT-02-NEG-01,WF,WF-NT-02,403 Forbidden,403 received,Pass,RBAC blocks non-admins,Claude_Opus_4.6 +WF-NT-03-E2E-01,WF,WF-NT-03,medium skipped; critical delivered,First assert_not_called then assert_called both pass,Pass,BR-NT-05 + BR-NT-06 interaction verified,Claude_Opus_4.6 +WF-NT-03-EXIT-01,WF,WF-NT-03,notify.send called once after re-enable,assert_called_once passed,Pass,Preference lifecycle verified,Claude_Opus_4.6 +WF-NT-03-NEG-01,WF,WF-NT-03,400 Bad Request,"400 received; body={'error': {'priority': [ErrorDetail(string='""ultra_mega"" is not a valid choice.', code='invalid_choice')]}}",Pass,Serializer enum validation works,Claude_Opus_4.6 diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/UC_Test_Design.csv b/FusionIIIT/applications/notifications_extension/tests/reports/UC_Test_Design.csv new file mode 100644 index 000000000..5ed0db18f --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/UC_Test_Design.csv @@ -0,0 +1,19 @@ +Test ID,UC ID,Test Category,Scenario,Preconditions,Input / Action,Expected Result +UC-NT-01-HP-01,UC-NT-01,Happy Path,"Module registers event with Event Name and Default Priority; NAM validates and returns unique Event_ID. +",External module is a registered Fusion component,POST /api/notifications/event-types/register/ with valid payload,201 Created; response contains event_id (UUID) and is_active=true +UC-NT-01-AP-01,UC-NT-01,Alternate Path,"Module registers event with critical default priority (emergency alerts route to email template). +",External module is authorized,POST register/ with default_priority=critical,201 Created; event stored with default_priority=critical +UC-NT-01-EX-01,UC-NT-01,Exception,Attempt to register event for a module name not in ModuleName.choices,External module is authorized,"POST register/ with module=""NonExistentModule""",400 Bad Request; module field rejected +UC-NT-02-HP-01,UC-NT-02,Happy Path,"Module triggers valid Event_ID for existing User_ID; notification is recorded and delivered to the navbar tray. +",Event Type registered; recipient exists; no duplicate within 60s,POST /api/notifications/trigger/ with event_id + recipient_username,201 Created; notification stored and dispatched +UC-NT-02-AP-01,UC-NT-02,Alternate Path,Trigger with a Deep_Link so the recipient can follow the alert to the source module.,Event Type registered,"POST trigger/ with deep_link=""/leave/status/""",201 Created; notify.send is called with url=deep_link +UC-NT-02-EX-01,UC-NT-02,Exception,Trigger with an unregistered Event_ID,No such Event Type exists in the registry,POST trigger/ with random UUID,404 Not Found; no notification dispatched +UC-NT-03-HP-01,UC-NT-03,Happy Path,"Admin drafts announcement with future expiry date and broadcasts to all users; all resolved recipients see it on their dashboard. +",Authenticated admin; audience=all; valid expiry,POST /api/notifications/announcements/broadcast/,201 Created; Announcement stored; fan-out performed +UC-NT-03-AP-01,UC-NT-03,Alternate Path,"Admin targets a specific designation group (e.g., ""HOD (CSE)"") using Fusion RBAC to resolve recipients. +",Authenticated admin; audience_type=group,"POST broadcast/ with audience_value=""HOD (CSE)""",201 Created; only HOD (CSE) user receives the announcement +UC-NT-03-EX-01,UC-NT-03,Exception,Admin supplies an invalid audience type in the payload,Authenticated admin,"POST broadcast/ with audience_type=""mars""",400 Bad Request; error enumerates valid audience types +UC-NT-04-HP-01,UC-NT-04,Happy Path,"User clicks the bell icon; NAM returns the latest notifications (paginated) with the unread count. +",Authenticated user,GET /api/notifications/?page=1&page_size=10,200 OK; notifications list + pagination metadata +UC-NT-04-AP-01,UC-NT-04,Alternate Path,User clicks a single notification and marks it read,User has an unread notification,PATCH /api/notifications/{id}/mark-read/,200 OK; unread flag cleared in DB +UC-NT-04-EX-01,UC-NT-04,Exception,Unauthenticated visitor tries to view notifications,No auth token,GET /api/notifications/ without Authorization header,401/403 denied; no data leaked diff --git a/FusionIIIT/applications/notifications_extension/tests/reports/WF_Test_Design.csv b/FusionIIIT/applications/notifications_extension/tests/reports/WF_Test_Design.csv new file mode 100644 index 000000000..e039d1af6 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/reports/WF_Test_Design.csv @@ -0,0 +1,28 @@ +Test ID,WF ID,Test Category,Scenario,Expected Final State +WF-NT-01-E2E-01,WF-NT-01,End-to-End,"Staff registers ""Leave Approved"" event -> Staff triggers event for student -> Student sees the notification in their list. +","NotificationEventType exists, a Notification row exists for the student, and GET /api/notifications/ returns it. +" +WF-NT-01-NEG-01,WF-NT-01,Negative,"Staff registers event, then Student attempts to trigger it via the API (should be blocked). +","Event type still exists; no Notification row created; trigger returned 403. +" +WF-NT-01-EXIT-01,WF-NT-01,Exit,"Staff registers event; staff triggers with unknown event_id (typo). +","Original event still exists; trigger fails with 404; no new Notification row; workflow exits cleanly. +" +WF-NT-02-E2E-01,WF-NT-02,End-to-End,"Admin broadcasts announcement to audience=students -> all students receive fan-out notifications -> each student's list contains it. +","Announcement row stored; one Notification row per student recipient; GET /api/notifications/ returns the announcement for each student. +" +WF-NT-02-NEG-01,WF-NT-02,Negative,"Student attempts to POST /announcements/broadcast/ (not admin). +","403 Forbidden; no Announcement row created; no notifications sent. +" +WF-NT-02-EXIT-01,WF-NT-02,Exit,"Admin broadcasts with an empty audience (no one matches the designation). +","Announcement row created; zero Notification rows; workflow exits without errors. +" +WF-NT-03-E2E-01,WF-NT-03,End-to-End,"User disables Leave Module -> Staff sends medium notification -> User list unchanged -> Staff sends critical notification -> User list gains the critical notification. +","Preference saved with is_enabled=False; medium notification skipped; critical notification delivered; email dispatched. +" +WF-NT-03-NEG-01,WF-NT-03,Negative,"User disables module but staff sends with invalid priority string. +","Serializer rejects with 400; no notification delivered regardless of preference. +" +WF-NT-03-EXIT-01,WF-NT-03,Exit,"User toggles module off then toggles it back on; staff sends medium-priority notification. +","Preference is_enabled=True restored; notification delivered normally. +" diff --git a/FusionIIIT/applications/notifications_extension/tests/runner.py b/FusionIIIT/applications/notifications_extension/tests/runner.py new file mode 100644 index 000000000..b95c26b8a --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/runner.py @@ -0,0 +1,324 @@ +""" +runner.py - Custom Test Runner + Automated Report Generator + +When tests are run with: + python manage.py test applications.notifications_extension.tests --testrunner=applications.notifications_extension.tests.runner.ReportingTestRunner + +This runner: + 1. Runs all tests normally + 2. Collects results from every test method + 3. Reads YAML spec files for test design documentation + 4. Generates ALL 7 CSV report sheets automatically + +Output files (in tests/reports/): + - Module_Test_Summary.csv (Sheet 1) + - UC_Test_Design.csv (Sheet 2) + - BR_Test_Design.csv (Sheet 3) + - WF_Test_Design.csv (Sheet 4) + - Test_Execution_Log.csv (Sheet 5) + - Defect_Log.csv (Sheet 6) + - Artifact_Evaluation.csv (Sheet 7) +""" + +import csv +import os +import sys +import traceback +import unittest +from pathlib import Path +from datetime import datetime +from collections import defaultdict + +from django.test.runner import DiscoverRunner + + +TESTS_DIR = Path(__file__).parent +SPECS_DIR = TESTS_DIR / "specs" +REPORTS_DIR = TESTS_DIR / "reports" + + +class ReportCollectingResult(unittest.TextTestResult): + """Extended TestResult that captures metadata from test methods for reports.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_records = [] + self.defects = [] + + def addSuccess(self, test): + super().addSuccess(test) + self._record_test(test, "Pass") + + def addFailure(self, test, err): + super().addFailure(test, err) + self._record_test(test, "Fail", err) + + def addError(self, test, err): + super().addError(test, err) + self._record_test(test, "Fail", err) + + def _record_test(self, test, status, err=None): + record = { + "test_id": getattr(test, "_test_id", test.id().split(".")[-1]), + "source_type": self._detect_source_type(test), + "source_id": (getattr(test, "_uc_id", "") + or getattr(test, "_br_id", "") + or getattr(test, "_wf_id", "")), + "test_category": getattr(test, "_test_category", ""), + "scenario": getattr(test, "_scenario", test.shortDescription() or ""), + "preconditions": getattr(test, "_preconditions", ""), + "input_action": getattr(test, "_input_action", ""), + "expected_result": getattr(test, "_expected_result", ""), + "actual_result": getattr( + test, "_actual_result", + self._format_error(err) if err else "Test passed", + ), + "status": getattr(test, "_status", status) or status, + "evidence": getattr(test, "_evidence", ""), + "tester": os.environ.get("TESTER_NAME", "Automated"), + "test_class": test.__class__.__name__, + "test_method": test._testMethodName, + } + self.test_records.append(record) + + if record["status"] in ("Fail", "Partial"): + error_desc = self._format_error(err) if err else record["actual_result"] + self.defects.append({ + "defect_id": f"DEF-{len(self.defects) + 1:03d}", + "related_test_id": record["test_id"], + "related_artifact": record["source_id"], + "severity": "High" if record["status"] == "Fail" else "Medium", + "description": (error_desc or "")[:500], + "suggested_fix": "Investigate and fix the failing condition", + }) + + def _detect_source_type(self, test): + class_name = test.__class__.__name__ + module_name = test.__class__.__module__ + if "use_case" in module_name or class_name.startswith("TestUC"): + return "UC" + if "business_rule" in module_name or class_name.startswith("TestBR"): + return "BR" + if "workflow" in module_name or class_name.startswith("TestWF"): + return "WF" + return "Other" + + def _format_error(self, err): + if err: + return "".join(traceback.format_exception(*err))[:500] + return "" + + +class ReportingTestRunner(DiscoverRunner): + """Django test runner that generates the 7 CSV reports after the run.""" + + def get_resultclass(self): + return ReportCollectingResult + + def run_suite(self, suite, **kwargs): + result = super().run_suite(suite, **kwargs) + self._generate_reports(result) + return result + + def _generate_reports(self, result): + if not hasattr(result, "test_records"): + print("\nNo test records collected. Skipping report generation.") + return + + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + + print("\n" + "=" * 60) + print("GENERATING TEST REPORTS") + print("=" * 60) + + records = result.test_records + defects = result.defects + + uc_specs = self._load_yaml_safe("use_cases.yaml", "use_cases") + br_specs = self._load_yaml_safe("business_rules.yaml", "business_rules") + wf_specs = self._load_yaml_safe("workflows.yaml", "workflows") + + self._gen_sheet2_uc_design(uc_specs) + self._gen_sheet3_br_design(br_specs) + self._gen_sheet4_wf_design(wf_specs) + self._gen_sheet5_execution_log(records) + self._gen_sheet6_defect_log(defects) + self._gen_sheet7_artifact_eval(records, uc_specs, br_specs, wf_specs) + self._gen_sheet1_summary(records, uc_specs, br_specs, wf_specs) + + print("\n" + "=" * 60) + print(f"All 7 reports generated in: {REPORTS_DIR}") + print("=" * 60 + "\n") + + def _load_yaml_safe(self, filename, key): + try: + import yaml + filepath = SPECS_DIR / filename + if filepath.exists(): + with open(filepath, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get(key, []) if data else [] + except Exception as exc: + print(f"Warning: could not load {filename}: {exc}") + return [] + + def _write_csv(self, filename, headers, rows): + filepath = REPORTS_DIR / filename + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(headers) + for row in rows: + writer.writerow(row) + print(f" Wrote {filename} ({len(rows)} rows)") + + # Sheet 1: Module Test Summary + def _gen_sheet1_summary(self, records, uc_specs, br_specs, wf_specs): + num_ucs, num_brs, num_wfs = len(uc_specs), len(br_specs), len(wf_specs) + required_uc = 3 * num_ucs + required_br = 2 * num_brs + required_wf = 2 * num_wfs + + uc_records = [r for r in records if r["source_type"] == "UC"] + br_records = [r for r in records if r["source_type"] == "BR"] + wf_records = [r for r in records if r["source_type"] == "WF"] + + designed_uc = len(uc_records) + designed_br = len(br_records) + designed_wf = len(wf_records) + + total_executed = len(records) + total_pass = sum(1 for r in records if r["status"] == "Pass") + total_partial = sum(1 for r in records if r["status"] == "Partial") + total_fail = sum(1 for r in records if r["status"] == "Fail") + + def pct(n, d): + return f"{(n / d * 100):.1f}%" if d > 0 else "N/A" + + rows = [ + ["Total Use Cases", num_ucs], + ["Total Business Rules", num_brs], + ["Total Workflows", num_wfs], + ["Required UC Tests", required_uc], + ["Designed UC Tests", designed_uc], + ["Required BR Tests", required_br], + ["Designed BR Tests", designed_br], + ["Required WF Tests", required_wf], + ["Designed WF Tests", designed_wf], + ["UC Adequacy %", pct(designed_uc, required_uc)], + ["BR Adequacy %", pct(designed_br, required_br)], + ["WF Adequacy %", pct(designed_wf, required_wf)], + ["Total Tests Executed", total_executed], + ["Total Pass", total_pass], + ["Total Partial", total_partial], + ["Total Fail", total_fail], + ["Strict Pass Rate %", pct(total_pass, total_executed)], + ] + self._write_csv("Module_Test_Summary.csv", ["Metric", "Value"], rows) + + # Sheet 2: UC Test Design + def _gen_sheet2_uc_design(self, uc_specs): + headers = ["Test ID", "UC ID", "Test Category", "Scenario", + "Preconditions", "Input / Action", "Expected Result"] + rows = [] + for uc in uc_specs: + uc_id = uc["id"] + for i, hp in enumerate(uc.get("happy_paths", []), 1): + rows.append([f"{uc_id}-HP-{i:02d}", uc_id, "Happy Path", + hp.get("scenario", ""), hp.get("preconditions", ""), + hp.get("input_action", ""), hp.get("expected_result", "")]) + for i, ap in enumerate(uc.get("alternate_paths", []), 1): + rows.append([f"{uc_id}-AP-{i:02d}", uc_id, "Alternate Path", + ap.get("scenario", ""), ap.get("preconditions", ""), + ap.get("input_action", ""), ap.get("expected_result", "")]) + for i, ep in enumerate(uc.get("exception_paths", []), 1): + rows.append([f"{uc_id}-EX-{i:02d}", uc_id, "Exception", + ep.get("scenario", ""), ep.get("preconditions", ""), + ep.get("input_action", ""), ep.get("expected_result", "")]) + self._write_csv("UC_Test_Design.csv", headers, rows) + + # Sheet 3: BR Test Design + def _gen_sheet3_br_design(self, br_specs): + headers = ["Test ID", "BR ID", "Test Category", "Input / Action", "Expected Result"] + rows = [] + for br in br_specs: + br_id = br["id"] + for i, vt in enumerate(br.get("valid_tests", []), 1): + rows.append([f"{br_id}-V-{i:02d}", br_id, "Valid", + vt.get("input_action", ""), vt.get("expected_result", "")]) + for i, it in enumerate(br.get("invalid_tests", []), 1): + rows.append([f"{br_id}-I-{i:02d}", br_id, "Invalid", + it.get("input_action", ""), it.get("expected_result", "")]) + self._write_csv("BR_Test_Design.csv", headers, rows) + + # Sheet 4: WF Test Design + def _gen_sheet4_wf_design(self, wf_specs): + headers = ["Test ID", "WF ID", "Test Category", "Scenario", "Expected Final State"] + rows = [] + for wf in wf_specs: + wf_id = wf["id"] + for i, e2e in enumerate(wf.get("e2e_tests", []), 1): + rows.append([f"{wf_id}-E2E-{i:02d}", wf_id, "End-to-End", + e2e.get("scenario", ""), e2e.get("expected_final_state", "")]) + for i, neg in enumerate(wf.get("negative_tests", []), 1): + rows.append([f"{wf_id}-NEG-{i:02d}", wf_id, "Negative", + neg.get("scenario", ""), neg.get("expected_final_state", "")]) + for i, ext in enumerate(wf.get("exit_tests", []), 1): + rows.append([f"{wf_id}-EXIT-{i:02d}", wf_id, "Exit", + ext.get("scenario", ""), ext.get("expected_final_state", "")]) + self._write_csv("WF_Test_Design.csv", headers, rows) + + # Sheet 5: Execution Log + def _gen_sheet5_execution_log(self, records): + headers = ["Test ID", "Source Type", "Source ID", "Expected Result", + "Actual Result", "Status", "Evidence", "Tester"] + rows = [] + for r in records: + rows.append([r["test_id"], r["source_type"], r["source_id"], + r["expected_result"], r["actual_result"], + r["status"], r["evidence"], r["tester"]]) + self._write_csv("Test_Execution_Log.csv", headers, rows) + + # Sheet 6: Defect Log + def _gen_sheet6_defect_log(self, defects): + headers = ["Defect ID", "Related Test ID", "Related Artifact", + "Severity", "Description", "Suggested Fix"] + rows = [[d["defect_id"], d["related_test_id"], d["related_artifact"], + d["severity"], d["description"], d["suggested_fix"]] for d in defects] + self._write_csv("Defect_Log.csv", headers, rows) + + # Sheet 7: Artifact Evaluation + def _gen_sheet7_artifact_eval(self, records, uc_specs, br_specs, wf_specs): + headers = ["Artifact ID", "Artifact Type", "Tests", "Pass", + "Partial", "Fail", "Final Status", "Remarks"] + rows = [] + + def evaluate(spec_list, type_code, pass_label, partial_label, + fail_label, missing_label): + for spec in spec_list: + sid = spec["id"] + tests = [r for r in records if r["source_id"] == sid] + p = sum(1 for t in tests if t["status"] == "Pass") + pa = sum(1 for t in tests if t["status"] == "Partial") + f = sum(1 for t in tests if t["status"] == "Fail") + total = len(tests) + if total == 0: + status = missing_label + elif f == 0 and pa == 0: + status = pass_label + elif p > 0: + status = partial_label + else: + status = fail_label + remarks = f"{p}/{total} passed" if total else "No tests executed" + rows.append([sid, type_code, total, p, pa, f, status, remarks]) + + evaluate(uc_specs, "UC", + "Implemented Correctly", "Partially Implemented", + "Incorrectly Implemented", "Not Implemented") + evaluate(br_specs, "BR", + "Enforced Correctly", "Partially Enforced", + "Incorrectly Enforced", "Not Enforced") + evaluate(wf_specs, "WF", + "Complete", "Partial", "Incorrect", "Missing") + + self._write_csv("Artifact_Evaluation.csv", headers, rows) diff --git a/FusionIIIT/applications/notifications_extension/tests/specs/business_rules.yaml b/FusionIIIT/applications/notifications_extension/tests/specs/business_rules.yaml new file mode 100644 index 000000000..d616c9066 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/specs/business_rules.yaml @@ -0,0 +1,175 @@ +business_rules: + - id: BR-NT-01 + title: UI Centralization + description: > + Individual Fusion modules are prohibited from creating their own + notification trays, pop-ups, or alerts. All communication must pass + through NAM. + valid_tests: + - input_action: > + Staff POSTs a notification with module="Leave Module" via + /api/notifications/send/. NAM stores it in the single central table. + expected_result: > + 201 Created; exactly one call to notify.send (central store); + no per-module side-channel. + invalid_tests: + - input_action: > + Staff POSTs with module="RogueModule" (not in ModuleName.choices). + expected_result: > + 400 Bad Request; central store refuses unlisted modules, preventing + any module from bypassing NAM. + + - id: BR-NT-02 + title: Navbar Consistency + description: > + The Notification Bell Icon and Tray component must remain fixed on the + global Fusion header across all module transitions. + valid_tests: + - input_action: > + Structural check that NotificationBell component exists in the + global header component tree. + expected_result: > + NotificationBell.jsx file exists and is imported by the header + component, so it renders on every page. + invalid_tests: + - input_action: > + Any module attempts to render its own alternate bell — should be + impossible because a single shared component is imported from + components/NotificationBell. + expected_result: > + Only one NotificationBell instance is present in the app (asserted + by searching for duplicate declarations; only the shared one + exists). + + - id: BR-NT-03 + title: API Authorization + description: > + Only registered Fusion modules with valid credentials can trigger the + postNotification API. + valid_tests: + - input_action: > + Staff user (is_staff=True) POSTs /api/notifications/send/ with a + valid token in Authorization header. + expected_result: 201 Created; notification delivered. + invalid_tests: + - input_action: > + Student user (is_staff=False) or unauthenticated caller POSTs + /send/. + expected_result: > + 403 Forbidden (students) or 401 Unauthorized (no token); no + notification created. + + - id: BR-NT-04 + title: Idempotency (Deduplication) + description: > + NAM must suppress duplicate notification requests for the same + User_ID and Event_ID if they occur within a 60-second cool-down window. + valid_tests: + - input_action: > + Staff triggers the same (user, event_id) twice with > 60 seconds + between calls. + expected_result: Both calls return 201; two notifications stored. + invalid_tests: + - input_action: > + Staff triggers the same (user, event_id) twice within 60 seconds. + expected_result: > + First call 201; second call is suppressed (DuplicateNotification + mapped to 429/400). + + - id: BR-NT-05 + title: Priority Handling + description: > + Notifications marked as "Critical" must bypass any user-defined + "Do Not Disturb" or "Mute" settings and trigger an immediate email. + valid_tests: + - input_action: > + Recipient has muted "Leave Module"; staff sends a critical-priority + notification for that module. + expected_result: > + notify.send is still called; notification is recorded; a critical + email is dispatched via send_mail. + invalid_tests: + - input_action: > + Recipient has muted "Leave Module"; staff sends a medium-priority + notification for that module. + expected_result: > + _send() short-circuits; notify.send is NOT called; no notification + stored. + + - id: BR-NT-06 + title: Automatic Expiry + description: > + Manual announcements shall be automatically removed from active UI + visibility once the Expiry_Date timestamp is reached. + valid_tests: + - input_action: > + Create an announcement with a future expiry_date; call + GET /api/notifications/announcements/. + expected_result: > + Announcement appears in the list (selectors filter by + expiry_date__gte=today). + invalid_tests: + - input_action: > + Create an announcement with a past expiry_date; call + GET /api/notifications/announcements/. + expected_result: > + Expired announcement does NOT appear in the active list. + + - id: BR-NT-07 + title: RBAC Dependency + description: > + The NAM service must dynamically resolve announcement recipients using + the Fusion RBAC module at the time of delivery to ensure current role + accuracy. + valid_tests: + - input_action: > + Admin broadcasts with audience_type=group, audience_value="HOD (CSE)". + _resolve_audience must query Fusion RBAC tables + (globals_holdsdesignation) at delivery time. + expected_result: > + _resolve_audience is called with ('group', 'HOD (CSE)'); only + users currently holding that designation receive the broadcast. + invalid_tests: + - input_action: > + Admin broadcasts with audience_type=group and a non-existent + designation value. + expected_result: > + Announcement is created but the resolved audience set is empty; + zero notify.send invocations. + + - id: BR-NT-08 + title: Audit Trail Integrity + description: > + Every manual announcement must be logged with an Author_ID, Timestamp, + and Approval_ID for institutional audit compliance. + valid_tests: + - input_action: > + Admin broadcasts an announcement and the Announcement row is + inspected. + expected_result: > + Row has sender (Author_ID) and created_at (Timestamp) populated + automatically. Approval_ID is captured by the enforcing caller + (admin user). + invalid_tests: + - input_action: > + Unauthenticated or non-admin caller attempts to broadcast. + expected_result: > + 403/401; no Announcement row is created, so no audit trail row + is generated for an unauthorized attempt. + + - id: BR-NT-09 + title: Data Persistence + description: > + Recipients may "Archive" or "Read" notifications, but they cannot + permanently delete system-generated records from the database for + 180 days. + valid_tests: + - input_action: > + User DELETEs /api/notifications/{id}/delete/ for an existing + notification. + expected_result: > + An ArchivedNotification row is created; the source + notifications_notification row is NOT deleted (retained for audit). + invalid_tests: + - input_action: User attempts to DELETE a notification ID that does not exist. + expected_result: 404 Not Found; no ArchivedNotification row is created. diff --git a/FusionIIIT/applications/notifications_extension/tests/specs/use_cases.yaml b/FusionIIIT/applications/notifications_extension/tests/specs/use_cases.yaml new file mode 100644 index 000000000..4238feeab --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/specs/use_cases.yaml @@ -0,0 +1,126 @@ +use_cases: + - id: UC-NT-01 + name: Register Notification Event Type + actor: External Fusion Module (System Actor) + description: > + Other modules (MMS, Academics, etc.) must register their specific events + (e.g., "Bill_Generated") with NAM before they can send alerts. + preconditions: The external module is a registered component of the Fusion ERP. + main_flow: > + 1) External module sends event registration data (Event Name, Default + Priority). 2) NAM validates the event type and assigns a unique Event_ID. + 3) NAM maps the event to a standard notification template. + postconditions: The external module is now authorized to trigger this notification. + happy_paths: + - scenario: > + Module registers event with Event Name and Default Priority; NAM + validates and returns unique Event_ID. + preconditions: External module is a registered Fusion component + input_action: POST /api/notifications/event-types/register/ with valid payload + expected_result: 201 Created; response contains event_id (UUID) and is_active=true + alternate_paths: + - scenario: > + Module registers event with critical default priority (emergency + alerts route to email template). + preconditions: External module is authorized + input_action: POST register/ with default_priority=critical + expected_result: 201 Created; event stored with default_priority=critical + exception_paths: + - scenario: Attempt to register event for a module name not in ModuleName.choices + preconditions: External module is authorized + input_action: POST register/ with module="NonExistentModule" + expected_result: 400 Bad Request; module field rejected + + - id: UC-NT-02 + name: Publish System-Generated Notification (API Trigger) + actor: External Fusion Module (System Actor) + description: > + An external module triggers an automated alert to a specific user via + the NAM API. + preconditions: Event Type is registered; target User_ID exists in Fusion. + main_flow: > + 1) External module calls NAM API with Event_ID, User_ID, Message_Content, + Deep_Link. 2) NAM resolves recipient preferences (e.g., email or + dashboard). 3) NAM records the notification in the central database. + 4) NAM pushes the alert to the Global Navbar Tray in real-time. + postconditions: User receives a visual alert on the constant navbar bell icon. + happy_paths: + - scenario: > + Module triggers valid Event_ID for existing User_ID; notification is + recorded and delivered to the navbar tray. + preconditions: Event Type registered; recipient exists; no duplicate within 60s + input_action: POST /api/notifications/trigger/ with event_id + recipient_username + expected_result: 201 Created; notification stored and dispatched + alternate_paths: + - scenario: Trigger with a Deep_Link so the recipient can follow the alert to the source module. + preconditions: Event Type registered + input_action: POST trigger/ with deep_link="/leave/status/" + expected_result: 201 Created; notify.send is called with url=deep_link + exception_paths: + - scenario: Trigger with an unregistered Event_ID + preconditions: No such Event Type exists in the registry + input_action: POST trigger/ with random UUID + expected_result: 404 Not Found; no notification dispatched + + - id: UC-NT-03 + name: Create & Broadcast Manual Announcement + actor: Authorized Sender (Administrative Role) + description: > + An admin manually drafts and sends a broadcast to a specific group of + users. + preconditions: Admin is authenticated and authorized. + main_flow: > + 1) Admin logs into the NAM portal and drafts a message. 2) Admin selects + a target audience (e.g., "All 4th Year B.Tech"). 3) Admin sets an + Expiry Date for the announcement. 4) NAM fetches the recipient list from + the Fusion RBAC module. 5) NAM publishes the announcement to all + resolved users' dashboards. + postconditions: All targeted users see the announcement on their dashboard until the expiry date. + happy_paths: + - scenario: > + Admin drafts announcement with future expiry date and broadcasts to + all users; all resolved recipients see it on their dashboard. + preconditions: Authenticated admin; audience=all; valid expiry + input_action: POST /api/notifications/announcements/broadcast/ + expected_result: 201 Created; Announcement stored; fan-out performed + alternate_paths: + - scenario: > + Admin targets a specific designation group (e.g., "HOD (CSE)") using + Fusion RBAC to resolve recipients. + preconditions: Authenticated admin; audience_type=group + input_action: POST broadcast/ with audience_value="HOD (CSE)" + expected_result: 201 Created; only HOD (CSE) user receives the announcement + exception_paths: + - scenario: Admin supplies an invalid audience type in the payload + preconditions: Authenticated admin + input_action: POST broadcast/ with audience_type="mars" + expected_result: 400 Bad Request; error enumerates valid audience types + + - id: UC-NT-04 + name: Interact with Global Notification Tray + actor: Recipient (Student / Staff / Faculty) + description: > + The user views and manages notifications via the constant top navbar. + preconditions: User is authenticated in Fusion ERP. + main_flow: > + 1) User clicks the Bell Icon on the global Fusion navbar. 2) NAM fetches + the latest unread notifications. 3) User marks an alert as "Read" or + clicks the message to follow the Deep Link to the source module. + postconditions: The unread count is updated across the user's session. + happy_paths: + - scenario: > + User clicks the bell icon; NAM returns the latest notifications + (paginated) with the unread count. + preconditions: Authenticated user + input_action: GET /api/notifications/?page=1&page_size=10 + expected_result: 200 OK; notifications list + pagination metadata + alternate_paths: + - scenario: User clicks a single notification and marks it read + preconditions: User has an unread notification + input_action: PATCH /api/notifications/{id}/mark-read/ + expected_result: 200 OK; unread flag cleared in DB + exception_paths: + - scenario: Unauthenticated visitor tries to view notifications + preconditions: No auth token + input_action: GET /api/notifications/ without Authorization header + expected_result: 401/403 denied; no data leaked diff --git a/FusionIIIT/applications/notifications_extension/tests/specs/workflows.yaml b/FusionIIIT/applications/notifications_extension/tests/specs/workflows.yaml new file mode 100644 index 000000000..af352e8e9 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/specs/workflows.yaml @@ -0,0 +1,79 @@ +workflows: + - id: WF-NT-01 + name: Register Event Type Then Trigger Delivery + description: > + End-to-end flow: a module admin registers an event type, then another + staff user triggers a notification using that event_id, and the recipient + sees it in their notification list. + e2e_tests: + - scenario: > + Staff registers "Leave Approved" event -> Staff triggers event for + student -> Student sees the notification in their list. + expected_final_state: > + NotificationEventType exists, a Notification row exists for the + student, and GET /api/notifications/ returns it. + negative_tests: + - scenario: > + Staff registers event, then Student attempts to trigger it via the + API (should be blocked). + expected_final_state: > + Event type still exists; no Notification row created; trigger + returned 403. + exit_tests: + - scenario: > + Staff registers event; staff triggers with unknown event_id (typo). + expected_final_state: > + Original event still exists; trigger fails with 404; no new + Notification row; workflow exits cleanly. + + - id: WF-NT-02 + name: Admin Broadcast Announcement Fan-Out + description: > + End-to-end flow: admin composes an announcement for a target audience, + the service resolves the audience, fans out notifications, and all + recipients can retrieve them. + e2e_tests: + - scenario: > + Admin broadcasts announcement to audience=students -> all students + receive fan-out notifications -> each student's list contains it. + expected_final_state: > + Announcement row stored; one Notification row per student recipient; + GET /api/notifications/ returns the announcement for each student. + negative_tests: + - scenario: > + Student attempts to POST /announcements/broadcast/ (not admin). + expected_final_state: > + 403 Forbidden; no Announcement row created; no notifications sent. + exit_tests: + - scenario: > + Admin broadcasts with an empty audience (no one matches the designation). + expected_final_state: > + Announcement row created; zero Notification rows; workflow exits + without errors. + + - id: WF-NT-03 + name: Preference Toggle and Notification Filter + description: > + End-to-end flow: a user toggles a module off in preferences; when any + staff sends a non-critical notification for that module, it is silently + skipped; a critical notification still goes through (BR-NT-05). + e2e_tests: + - scenario: > + User disables Leave Module -> Staff sends medium notification -> + User list unchanged -> Staff sends critical notification -> User list + gains the critical notification. + expected_final_state: > + Preference saved with is_enabled=False; medium notification skipped; + critical notification delivered; email dispatched. + negative_tests: + - scenario: > + User disables module but staff sends with invalid priority string. + expected_final_state: > + Serializer rejects with 400; no notification delivered regardless of + preference. + exit_tests: + - scenario: > + User toggles module off then toggles it back on; staff sends + medium-priority notification. + expected_final_state: > + Preference is_enabled=True restored; notification delivered normally. diff --git a/FusionIIIT/applications/notifications_extension/tests/test_business_rules.py b/FusionIIIT/applications/notifications_extension/tests/test_business_rules.py new file mode 100644 index 000000000..f52d3e7a7 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/test_business_rules.py @@ -0,0 +1,677 @@ +""" +test_business_rules.py - Specification-driven BR tests for NAM. + +Coverage per BR: 1 Valid + 1 Invalid = 2 tests. +Total: 7 BRs x 2 = 14 tests. Adequacy = 14 / 14 = 100%. +""" + +from unittest.mock import patch + +from applications.notifications_extension.models import ( + ModuleName, NotificationEventType, NotificationPreference, +) +from applications.notifications_extension.tests.conftest import BaseNAMTestCase + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-01 - UI Centralization +# ──────────────────────────────────────────────────────────────── + +class TestBR01_UICentralization(BaseNAMTestCase): + """BR-NT-01: all notifications go to the single central store.""" + + def test_valid_send_creates_single_central_record(self): + """BR-NT-01-V-01: Send notification - stored in one central table.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send") as mock: + response = self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "Central store test", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + mock.assert_called_once() + + self._record_result( + test_id="BR-NT-01-V-01", source_id="BR-NT-01", + category="Valid", + scenario="Notification goes into central notifications table", + preconditions="Authenticated staff", + input_action="POST /api/notifications/send/", + expected_result="201; notify.send invoked once (central store)", + actual_result="201 received; single notify.send call", + status="Pass", + evidence="No parallel module-owned store written", + ) + + def test_invalid_rogue_module_rejected(self): + """BR-NT-01-I-01: Rogue module name blocked.""" + self.login_as_staff() + response = self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": "RogueModule", + "verb": "should be blocked", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 400) + + self._record_result( + test_id="BR-NT-01-I-01", source_id="BR-NT-01", + category="Invalid", + scenario="Rogue module name rejected by serializer", + preconditions="Authenticated staff", + input_action="POST /send/ with module='RogueModule'", + expected_result="400 Bad Request", + actual_result=f"400 received; body={response.data}", + status="Pass", + evidence="Central store protected from unlisted modules", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-03 - RBAC (Staff/Admin Only) +# ──────────────────────────────────────────────────────────────── + +class TestBR03_RBACStaffOnly(BaseNAMTestCase): + + def test_valid_staff_can_send(self): + """BR-NT-03-V-01: Staff sending returns 201.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send"): + response = self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "from staff", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + + self._record_result( + test_id="BR-NT-03-V-01", source_id="BR-NT-03", + category="Valid", scenario="Staff user allowed to send", + preconditions="Staff user authenticated", + input_action="POST /send/ while is_staff=True", + expected_result="201 Created", + actual_result="201 received", status="Pass", + evidence="DRF IsAdminUser permission passed", + ) + + def test_invalid_student_cannot_send(self): + """BR-NT-03-I-01: Student sending returns 403.""" + self.login_as_student() + response = self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "hack", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 403) + + self._record_result( + test_id="BR-NT-03-I-01", source_id="BR-NT-03", + category="Invalid", scenario="Student is forbidden from sending", + preconditions="Student user (is_staff=False) authenticated", + input_action="POST /send/ as student", + expected_result="403 Forbidden", + actual_result=f"403 received; body={response.data}", + status="Pass", + evidence="DRF IsAdminUser rejected non-staff", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-04 - Duplicate Suppression +# ──────────────────────────────────────────────────────────────── + +class TestBR04_DuplicateSuppression(BaseNAMTestCase): + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.event = NotificationEventType.objects.create( + event_name="Dup test event", module=ModuleName.LEAVE_MODULE, + default_priority="medium", registered_by=cls.staff, is_active=True, + ) + + def setUp(self): + super().setUp() + from applications.notifications_extension import services + services._recent_triggers.clear() + + def test_valid_two_triggers_outside_cooldown(self): + """BR-NT-04-V-01: Two triggers outside 60s both succeed.""" + from applications.notifications_extension import services + from datetime import timedelta + from django.utils import timezone + + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send"): + r1 = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(self.event.event_id), + "recipient_username": self.student.username, + "message_content": "first", + }, + format="json", + ) + self.assertEqual(r1.status_code, 201) + + # Fake that > 60s has passed by rewinding the cache timestamp + key = (self.student.id, str(self.event.event_id)) + services._recent_triggers[key] = ( + timezone.now() - timedelta(seconds=120) + ) + + r2 = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(self.event.event_id), + "recipient_username": self.student.username, + "message_content": "second", + }, + format="json", + ) + self.assertEqual(r2.status_code, 201) + + self._record_result( + test_id="BR-NT-04-V-01", source_id="BR-NT-04", + category="Valid", + scenario="Two triggers more than 60s apart both succeed", + preconditions="Same event + same recipient; 60s elapsed", + input_action="POST /trigger/ twice with 60s gap", + expected_result="Both calls return 201", + actual_result=f"First={r1.status_code}, Second={r2.status_code}", + status="Pass", + evidence="Cooldown window respected only when < 60s", + ) + + def test_invalid_duplicate_within_60s(self): + """BR-NT-04-I-01: Second trigger within 60s raises duplicate.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send"): + r1 = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(self.event.event_id), + "recipient_username": self.student.username, + "message_content": "first", + }, + format="json", + ) + r2 = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(self.event.event_id), + "recipient_username": self.student.username, + "message_content": "dup", + }, + format="json", + ) + + self.assertEqual(r1.status_code, 201) + # Either 429 Too Many or 400 depending on handler mapping - accept both + self.assertIn(r2.status_code, [400, 409, 429]) + + self._record_result( + test_id="BR-NT-04-I-01", source_id="BR-NT-04", + category="Invalid", + scenario="Second trigger within 60s is rejected", + preconditions="Same event + recipient within 60s", + input_action="POST /trigger/ twice in the same second", + expected_result="First 201; second 4xx with dedup message", + actual_result=f"First={r1.status_code}, Second={r2.status_code}", + status="Pass", + evidence="DuplicateNotification raised by service", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-05 - Critical Priority Bypass +# ──────────────────────────────────────────────────────────────── + +class TestBR05_CriticalBypass(BaseNAMTestCase): + + def test_valid_critical_bypasses_disabled_module(self): + """BR-NT-05-V-01: Critical priority bypasses opt-out.""" + NotificationPreference.objects.create( + user=self.student, module=ModuleName.LEAVE_MODULE, is_enabled=False, + ) + from applications.notifications_extension import services + + with patch("applications.notifications_extension.services.notify.send") as mock: + services._send( + sender=self.staff, recipient=self.student, + url="#", module=ModuleName.LEAVE_MODULE, + verb="CRITICAL", priority="critical", + ) + mock.assert_called_once() + + self._record_result( + test_id="BR-NT-05-V-01", source_id="BR-NT-05", + category="Valid", + scenario="Critical priority bypasses disabled module", + preconditions="User disabled Leave Module", + input_action="services._send() with priority='critical'", + expected_result="notify.send called despite opt-out", + actual_result="notify.send invoked exactly once", + status="Pass", + evidence="Service honored BR-NT-05 override", + ) + + def test_invalid_medium_respects_disabled_module(self): + """BR-NT-05-I-01: Non-critical respects opt-out.""" + NotificationPreference.objects.create( + user=self.student, module=ModuleName.LEAVE_MODULE, is_enabled=False, + ) + from applications.notifications_extension import services + + with patch("applications.notifications_extension.services.notify.send") as mock: + services._send( + sender=self.staff, recipient=self.student, + url="#", module=ModuleName.LEAVE_MODULE, + verb="normal", priority="medium", + ) + mock.assert_not_called() + + self._record_result( + test_id="BR-NT-05-I-01", source_id="BR-NT-05", + category="Invalid", + scenario="Medium priority respects disabled module", + preconditions="User disabled Leave Module", + input_action="services._send() with priority='medium'", + expected_result="notify.send is NOT called", + actual_result="notify.send assert_not_called passed", + status="Pass", + evidence="BR-NT-06 opt-out honored for non-critical", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-02 - Navbar Consistency (structural) +# ──────────────────────────────────────────────────────────────── + +class TestBR02_NavbarConsistency(BaseNAMTestCase): + """The NotificationBell component is the single source of truth in the + global header — modules may not render their own trays.""" + + FRONTEND_ROOT = None # lazy resolution + + @classmethod + def _frontend_root(cls): + if cls.FRONTEND_ROOT is None: + from pathlib import Path + # tests/ -> applications.notifications_extension/ -> backend/ -> project root + project_root = Path(__file__).resolve().parents[3] + cls.FRONTEND_ROOT = project_root / "Fusion-client" / "src" + return cls.FRONTEND_ROOT + + def test_valid_bell_component_exists_and_is_imported(self): + """BR-NT-02-V-01: NotificationBell exists + imported by a global header.""" + from pathlib import Path + import re + + root = self._frontend_root() + bell_path = root / "Modules" / "Notification" / "components" / "NotificationBell.jsx" + self.assertTrue(bell_path.exists(), f"Missing {bell_path}") + + # At least one file (typically header.jsx) must import the bell so it + # renders globally across module transitions. + importers = [] + pattern = re.compile(r"NotificationBell") + for jsx in root.rglob("*.jsx"): + if jsx.name == "NotificationBell.jsx": + continue + try: + text = jsx.read_text(encoding="utf-8", errors="ignore") + except Exception: + continue + if pattern.search(text): + importers.append(jsx.name) + self.assertGreaterEqual(len(importers), 1) + + self._record_result( + test_id="BR-NT-02-V-01", source_id="BR-NT-02", + category="Valid", + scenario="NotificationBell component exists and is imported globally", + preconditions="Frontend project structure unchanged", + input_action="File-system check for NotificationBell.jsx + imports", + expected_result="Component file present and referenced in header", + actual_result=f"Found bell at {bell_path.name}; imported in {importers[:3]}", + status="Pass", + evidence=f"{len(importers)} files reference NotificationBell", + ) + + def test_invalid_only_one_bell_definition(self): + """BR-NT-02-I-01: Only ONE NotificationBell definition in the repo.""" + root = self._frontend_root() + matches = list(root.rglob("NotificationBell.jsx")) + # Exclude node_modules + matches = [m for m in matches if "node_modules" not in str(m)] + self.assertEqual(len(matches), 1, + f"Expected exactly 1 NotificationBell.jsx, got {len(matches)}: {matches}") + + self._record_result( + test_id="BR-NT-02-I-01", source_id="BR-NT-02", + category="Invalid", + scenario="No rogue NotificationBell implementations", + preconditions="Frontend repo", + input_action="Glob for NotificationBell.jsx anywhere in src/", + expected_result="Exactly one NotificationBell.jsx file", + actual_result=f"Found {len(matches)} files => {[m.name for m in matches]}", + status="Pass", + evidence="Single source of truth enforced by convention", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-06 - Automatic Expiry +# ──────────────────────────────────────────────────────────────── + +class TestBR06_AutomaticExpiry(BaseNAMTestCase): + """Announcements past their expiry_date are hidden from the active list.""" + + def test_valid_future_expiry_appears_in_active_list(self): + """BR-NT-06-V-01: Future-expiry announcement appears in active list.""" + from datetime import date, timedelta + from applications.notifications_extension.models import Announcement + Announcement.objects.create( + title="Active announcement", message="Still valid", + sender=self.admin, audience_type="all", audience_value="", + expiry_date=date.today() + timedelta(days=30), + ) + self.login_as_student() + r = self.client.get("/api/notifications/announcements/") + self.assertEqual(r.status_code, 200) + titles = [a["title"] for a in r.data.get("announcements", [])] + self.assertIn("Active announcement", titles) + + self._record_result( + test_id="BR-NT-06-V-01", source_id="BR-NT-06", + category="Valid", + scenario="Announcement with future expiry_date is listed as active", + preconditions="Announcement exists with expiry_date in future", + input_action="GET /api/notifications/announcements/", + expected_result="Active list contains the announcement", + actual_result=f"Returned titles: {titles}", + status="Pass", + evidence="selectors.get_active_announcements filter passes", + ) + + def test_invalid_past_expiry_hidden_from_active_list(self): + """BR-NT-06-I-01: Past-expiry announcement hidden from active list.""" + from datetime import date, timedelta + from applications.notifications_extension.models import Announcement + Announcement.objects.create( + title="Stale announcement", message="Should be hidden", + sender=self.admin, audience_type="all", audience_value="", + expiry_date=date.today() - timedelta(days=1), + ) + self.login_as_student() + r = self.client.get("/api/notifications/announcements/") + self.assertEqual(r.status_code, 200) + titles = [a["title"] for a in r.data.get("announcements", [])] + self.assertNotIn("Stale announcement", titles) + + self._record_result( + test_id="BR-NT-06-I-01", source_id="BR-NT-06", + category="Invalid", + scenario="Announcement with past expiry_date hidden from active list", + preconditions="Announcement exists with expiry_date in the past", + input_action="GET /api/notifications/announcements/", + expected_result="Active list does NOT contain the stale announcement", + actual_result=f"Returned titles: {titles}", + status="Pass", + evidence="expiry_date__gte=today filter enforced", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-07 - Designation-Based Broadcast Audience +# ──────────────────────────────────────────────────────────────── + +class TestBR07_DesignationAudience(BaseNAMTestCase): + + def test_valid_designation_group_audience_routed(self): + """BR-NT-07-V-01: group audience uses globals_holdsdesignation.""" + # We patch _resolve_audience to return a controlled queryset so the + # test is independent of the shared Fusion globals tables. + self.login_as_admin() + from django.contrib.auth import get_user_model + User = get_user_model() + + with patch( + "applications.notifications_extension.services._resolve_audience", + return_value=User.objects.filter(pk=self.student.pk), + ) as mock_resolve, patch("applications.notifications_extension.services.notify.send"): + response = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Dept Meeting", + "message": "3 PM", + "audience_type": "group", + "audience_value": "HOD (CSE)", + "expiry_date": "2026-05-01", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + mock_resolve.assert_called_once() + args = mock_resolve.call_args.args + self.assertEqual(args[0], "group") + self.assertEqual(args[1], "HOD (CSE)") + + self._record_result( + test_id="BR-NT-07-V-01", source_id="BR-NT-07", + category="Valid", + scenario="group audience routed via _resolve_audience", + preconditions="Authenticated admin", + input_action="POST /broadcast/ audience_type=group", + expected_result="_resolve_audience called with ('group', value)", + actual_result="Called once with correct args", + status="Pass", evidence="BR-NT-07 wiring confirmed", + ) + + def test_invalid_designation_returns_empty_audience(self): + """BR-NT-07-I-01: Unknown designation yields empty fan-out.""" + self.login_as_admin() + from django.contrib.auth import get_user_model + User = get_user_model() + + with patch( + "applications.notifications_extension.services._resolve_audience", + return_value=User.objects.none(), + ), patch("applications.notifications_extension.services.notify.send") as mock: + response = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Ghost", + "message": "nobody", + "audience_type": "group", + "audience_value": "NonExistentRole", + "expiry_date": "2026-05-01", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + mock.assert_not_called() + + self._record_result( + test_id="BR-NT-07-I-01", source_id="BR-NT-07", + category="Invalid", + scenario="Unknown designation yields empty recipient set", + preconditions="Authenticated admin", + input_action="POST /broadcast/ with bad designation", + expected_result="201; zero notify.send calls", + actual_result="201 received; notify.send never invoked", + status="Pass", evidence="Empty queryset => zero fan-out", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-08 - Audit Trail Integrity +# ──────────────────────────────────────────────────────────────── + +class TestBR08_AuditTrail(BaseNAMTestCase): + """Every broadcast stores Author_ID + Timestamp; unauthorized calls create no audit record.""" + + def test_valid_broadcast_stamps_author_and_timestamp(self): + """BR-NT-08-V-01: Broadcast stores sender (Author_ID) + created_at (Timestamp).""" + from django.contrib.auth import get_user_model + from applications.notifications_extension.models import Announcement + User = get_user_model() + + self.login_as_admin() + before_count = Announcement.objects.count() + with patch( + "applications.notifications_extension.services._resolve_audience", + return_value=User.objects.filter(pk=self.student.pk), + ), patch("applications.notifications_extension.services.notify.send"): + r = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Audit trail test", + "message": "Check audit fields", + "audience_type": "all", + "expiry_date": "2026-12-31", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 201) + self.assertEqual(Announcement.objects.count(), before_count + 1) + + announcement = Announcement.objects.latest("created_at") + self.assertEqual(announcement.sender_id, self.admin.id) # Author_ID + self.assertIsNotNone(announcement.created_at) # Timestamp + self.assertEqual(announcement.title, "Audit trail test") + + self._record_result( + test_id="BR-NT-08-V-01", source_id="BR-NT-08", + category="Valid", + scenario="Admin broadcast persists Author_ID + Timestamp", + preconditions="Authenticated admin user", + input_action="POST /api/notifications/announcements/broadcast/", + expected_result="Announcement has sender + created_at populated", + actual_result=f"sender_id={announcement.sender_id}, created_at={announcement.created_at}", + status="Pass", + evidence="Database row audited with Author_ID and Timestamp", + ) + + def test_invalid_unauthorized_call_creates_no_audit_row(self): + """BR-NT-08-I-01: Unauthenticated call creates NO Announcement audit row.""" + from applications.notifications_extension.models import Announcement + before_count = Announcement.objects.count() + + self.logout() + r = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Rogue", + "message": "Should not be audited", + "audience_type": "all", + "expiry_date": "2026-12-31", + "priority": "medium", + }, + format="json", + ) + self.assertIn(r.status_code, [401, 403]) + self.assertEqual(Announcement.objects.count(), before_count) + + self._record_result( + test_id="BR-NT-08-I-01", source_id="BR-NT-08", + category="Invalid", + scenario="Unauthorized caller does not pollute the audit log", + preconditions="No authentication", + input_action="POST /announcements/broadcast/ without token", + expected_result="401/403; Announcement count unchanged", + actual_result=f"Got {r.status_code}; row count still {Announcement.objects.count()}", + status="Pass", + evidence="No audit row created for unauthorized attempt", + ) + + +# ──────────────────────────────────────────────────────────────── +# BR-NT-09 - Soft Archive with Retention +# ──────────────────────────────────────────────────────────────── + +class TestBR09_SoftArchive(BaseNAMTestCase): + + def test_valid_delete_creates_archive_record(self): + """BR-NT-09-V-01: DELETE creates ArchivedNotification row.""" + from notifications.signals import notify + from notifications.models import Notification + from applications.notifications_extension.models import ArchivedNotification + + notify.send( + self.staff, recipient=self.student, verb="to archive", + module=ModuleName.LEAVE_MODULE, url="#", + ) + note = Notification.objects.filter(recipient=self.student).first() + self.assertIsNotNone(note) + + self.login_as_student() + r = self.client.delete(f"/api/notifications/{note.id}/delete/") + self.assertEqual(r.status_code, 200) + + self.assertTrue( + ArchivedNotification.objects.filter( + user=self.student, notification_id=note.id, + ).exists() + ) + # Source notification row still present + self.assertTrue(Notification.objects.filter(id=note.id).exists()) + + self._record_result( + test_id="BR-NT-09-V-01", source_id="BR-NT-09", + category="Valid", + scenario="Delete creates archive row; source row retained", + preconditions="User has an active notification", + input_action="DELETE /api/notifications/{id}/delete/", + expected_result="ArchivedNotification row created; DB row not deleted", + actual_result="Both assertions pass", + status="Pass", + evidence="Soft-delete semantics confirmed", + ) + + def test_invalid_delete_nonexistent_returns_404(self): + """BR-NT-09-I-01: Delete non-existent notification -> 404.""" + from applications.notifications_extension.models import ArchivedNotification + before = ArchivedNotification.objects.count() + + self.login_as_student() + r = self.client.delete("/api/notifications/99999999/delete/") + self.assertEqual(r.status_code, 404) + after = ArchivedNotification.objects.count() + self.assertEqual(before, after) + + self._record_result( + test_id="BR-NT-09-I-01", source_id="BR-NT-09", + category="Invalid", + scenario="Delete non-existent notification id", + preconditions="No notification with that id exists", + input_action="DELETE /api/notifications/99999999/delete/", + expected_result="404 Not Found; no archive record created", + actual_result=f"404 received; archive count unchanged ({before})", + status="Pass", + evidence="NotificationNotFound mapped to 404", + ) diff --git a/FusionIIIT/applications/notifications_extension/tests/test_module.py b/FusionIIIT/applications/notifications_extension/tests/test_module.py new file mode 100644 index 000000000..0d0b6bbce --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/test_module.py @@ -0,0 +1,644 @@ +""" +tests/test_module.py — Unit tests for the notification module. + +Coverage: + - models : NotificationPreference creation, __str__, defaults + - selectors : all query helpers + - services : all business logic, all module-specific notif senders, exceptions + - api views : HTTP status codes and response structure +""" + +from unittest.mock import patch, call + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from applications.notifications_extension.models import ( + ModuleName, + LeaveNotifType, + MessNotifType, + VisitorHostelNotifType, + HealthcareNotifType, + ScholarshipNotifType, + OfficeDeanPnDNotifType, + OfficeDeanSNotifType, + OfficeDeanRSPCNotifType, + GymkhanaNotifType, + ResearchProceduresNotifType, + NotificationPreference, +) +from applications.notifications_extension import selectors, services +from applications.notifications_extension.services import ( + NotificationNotFound, + InvalidModuleName, + InvalidNotificationType, +) + +User = get_user_model() + + +# ───────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────── + +def make_user(username="testuser", password="pass"): + return User.objects.create_user(username=username, password=password) + + +# ───────────────────────────────────────────── +# Model tests +# ───────────────────────────────────────────── + +class NotificationPreferenceModelTest(TestCase): + + def setUp(self): + self.user = make_user() + + def test_preference_defaults_to_enabled(self): + pref = NotificationPreference.objects.create( + user=self.user, module=ModuleName.LEAVE_MODULE + ) + self.assertTrue(pref.is_enabled) + + def test_preference_str_shows_on(self): + pref = NotificationPreference.objects.create( + user=self.user, module=ModuleName.LEAVE_MODULE, is_enabled=True + ) + self.assertIn("ON", str(pref)) + + def test_preference_str_shows_off(self): + pref = NotificationPreference.objects.create( + user=self.user, module=ModuleName.CENTRAL_MESS, is_enabled=False + ) + self.assertIn("OFF", str(pref)) + + def test_unique_together_constraint(self): + from django.db import IntegrityError + NotificationPreference.objects.create( + user=self.user, module=ModuleName.LEAVE_MODULE + ) + with self.assertRaises(Exception): + NotificationPreference.objects.create( + user=self.user, module=ModuleName.LEAVE_MODULE + ) + + def test_module_name_choices_exist(self): + """All TextChoices values are valid.""" + choices = [c[0] for c in ModuleName.choices] + self.assertIn(ModuleName.COMPLAINT_SYSTEM, choices) + self.assertIn(ModuleName.RESEARCH_PROCEDURES, choices) + self.assertIn(ModuleName.GYMKHANA_MODULE, choices) + + +# ───────────────────────────────────────────── +# Selector tests +# ───────────────────────────────────────────── + +class SelectorTest(TestCase): + + def setUp(self): + self.user = make_user() + + def test_is_module_enabled_defaults_true_no_record(self): + self.assertTrue( + selectors.is_module_enabled_for_user(self.user, ModuleName.LEAVE_MODULE) + ) + + def test_is_module_enabled_false_when_disabled(self): + NotificationPreference.objects.create( + user=self.user, module=ModuleName.LEAVE_MODULE, is_enabled=False + ) + self.assertFalse( + selectors.is_module_enabled_for_user(self.user, ModuleName.LEAVE_MODULE) + ) + + def test_get_unread_count_zero_initially(self): + self.assertEqual(selectors.get_unread_count_for_user(self.user), 0) + + def test_get_all_preferences_empty_initially(self): + self.assertEqual(selectors.get_all_preferences_for_user(self.user).count(), 0) + + def test_get_notification_by_id_returns_none_when_missing(self): + result = selectors.get_notification_by_id(9999, self.user) + self.assertIsNone(result) + + def test_get_notification_by_slug_returns_none_when_missing(self): + # slug2id("1") → some id; we just check None is returned + result = selectors.get_notification_by_slug("1", self.user) + self.assertIsNone(result) + + +# ───────────────────────────────────────────── +# Service tests — preferences & mark/delete +# ───────────────────────────────────────────── + +class ServicePreferenceTest(TestCase): + + def setUp(self): + self.user = make_user() + + def test_set_preference_creates_record(self): + pref = services.set_notification_preference( + user=self.user, module=ModuleName.CENTRAL_MESS, is_enabled=False + ) + self.assertFalse(pref.is_enabled) + + def test_set_preference_updates_existing(self): + services.set_notification_preference( + user=self.user, module=ModuleName.LEAVE_MODULE, is_enabled=True + ) + pref = services.set_notification_preference( + user=self.user, module=ModuleName.LEAVE_MODULE, is_enabled=False + ) + self.assertFalse(pref.is_enabled) + self.assertEqual( + NotificationPreference.objects.filter( + user=self.user, module=ModuleName.LEAVE_MODULE + ).count(), 1 + ) + + def test_set_preference_invalid_module_raises(self): + with self.assertRaises(InvalidModuleName): + services.set_notification_preference( + user=self.user, module="fake_module", is_enabled=True + ) + + def test_mark_as_read_raises_when_not_found(self): + with self.assertRaises(NotificationNotFound): + services.mark_notification_as_read(notification_id=9999, user=self.user) + + def test_mark_as_unread_raises_when_not_found(self): + with self.assertRaises(NotificationNotFound): + services.mark_notification_as_unread(notification_id=9999, user=self.user) + + def test_delete_notification_raises_when_not_found(self): + with self.assertRaises(NotificationNotFound): + services.delete_notification(notification_id=9999, user=self.user) + + def test_mark_as_read_and_get_redirect_url_raises_when_not_found(self): + with self.assertRaises(NotificationNotFound): + services.mark_as_read_and_get_redirect_url(slug="9999", user=self.user) + + +# ───────────────────────────────────────────── +# Service tests — module-specific notif senders +# (all use mock to avoid real notify.send) +# ───────────────────────────────────────────── + +class ServiceNotifSendersTest(TestCase): + + def setUp(self): + self.sender = make_user("sender") + self.recipient = make_user("recipient") + + def _patch(self): + return patch("applications.notifications_extension.services.notify.send") + + # Leave + def test_leave_module_notif_leave_applied(self): + with self._patch() as mock_send: + services.leave_module_notif( + sender=self.sender, recipient=self.recipient, + type=LeaveNotifType.LEAVE_APPLIED + ) + mock_send.assert_called_once() + _, kwargs = mock_send.call_args + self.assertIn("successfully submitted", kwargs["verb"]) + + def test_leave_module_notif_withdrawn_with_date(self): + with self._patch() as mock_send: + services.leave_module_notif( + sender=self.sender, recipient=self.recipient, + type=LeaveNotifType.LEAVE_WITHDRAWN, date="2025-01-01" + ) + mock_send.assert_called_once() + _, kwargs = mock_send.call_args + self.assertIn("2025-01-01", kwargs["verb"]) + + def test_leave_module_notif_invalid_type_raises(self): + with self.assertRaises(InvalidNotificationType): + services.leave_module_notif( + sender=self.sender, recipient=self.recipient, type="bad_type" + ) + + # Mess + def test_central_mess_notif_feedback(self): + with self._patch() as mock_send: + services.central_mess_notif( + sender=self.sender, recipient=self.recipient, + type=MessNotifType.FEEDBACK_SUBMITTED + ) + mock_send.assert_called_once() + + def test_central_mess_notif_invalid_type_raises(self): + with self.assertRaises(InvalidNotificationType): + services.central_mess_notif( + sender=self.sender, recipient=self.recipient, type="bad" + ) + + # Visitor hostel + def test_visitors_hostel_notif_confirmed(self): + with self._patch() as mock_send: + services.visitors_hostel_notif( + sender=self.sender, recipient=self.recipient, + type=VisitorHostelNotifType.BOOKING_CONFIRMATION + ) + mock_send.assert_called_once() + + # Healthcare + def test_healthcare_center_notif_appointment(self): + with self._patch() as mock_send: + services.healthcare_center_notif( + sender=self.sender, recipient=self.recipient, + type=HealthcareNotifType.APPOINTMENT_BOOKED + ) + mock_send.assert_called_once() + + # File tracking + def test_file_tracking_notif(self): + with self._patch() as mock_send: + services.file_tracking_notif( + sender=self.sender, recipient=self.recipient, title="Budget File" + ) + _, kwargs = mock_send.call_args + self.assertEqual(kwargs["verb"], "Budget File") + + # Scholarship + def test_scholarship_portal_notif_accept_mcm(self): + with self._patch() as mock_send: + services.scholarship_portal_notif( + sender=self.sender, recipient=self.recipient, + type=ScholarshipNotifType.ACCEPT_MCM + ) + mock_send.assert_called_once() + + def test_scholarship_portal_notif_award(self): + with self._patch() as mock_send: + services.scholarship_portal_notif( + sender=self.sender, recipient=self.recipient, + type="award_Merit" + ) + _, kwargs = mock_send.call_args + self.assertIn("Merit", kwargs["verb"]) + + # Complaint system + def test_complaint_system_notif_student(self): + with self._patch() as mock_send: + services.complaint_system_notif( + sender=self.sender, recipient=self.recipient, + type="new_complaint", complaint_id=42, + is_student=True, message="New complaint raised" + ) + _, kwargs = mock_send.call_args + self.assertEqual(kwargs["url"], "complaint:detail2") + + def test_complaint_system_notif_non_student(self): + with self._patch() as mock_send: + services.complaint_system_notif( + sender=self.sender, recipient=self.recipient, + type="new_complaint", complaint_id=42, + is_student=False, message="New complaint raised" + ) + _, kwargs = mock_send.call_args + self.assertEqual(kwargs["url"], "complaint:detail") + + # Gymkhana + def test_gymkhana_voting_notif(self): + with self._patch() as mock_send: + services.gymkhana_voting_notif( + sender=self.sender, recipient=self.recipient, + type=GymkhanaNotifType.VOTING_OPEN, + title="President Election", desc="Vote now" + ) + _, kwargs = mock_send.call_args + self.assertIn("President Election", kwargs["verb"]) + + # Assistantship + def test_assistantship_claim_notify(self): + with self._patch() as mock_send: + services.assistantship_claim_notify( + sender=self.sender, recipient=self.recipient, + month="January", year="2025" + ) + _, kwargs = mock_send.call_args + self.assertIn("January", kwargs["verb"]) + self.assertIn("2025", kwargs["verb"]) + + # Research + def test_research_procedures_notif_approved(self): + with self._patch() as mock_send: + services.research_procedures_notif( + sender=self.sender, recipient=self.recipient, + type=ResearchProceduresNotifType.APPROVED + ) + mock_send.assert_called_once() + + def test_research_procedures_notif_invalid_raises(self): + with self.assertRaises(InvalidNotificationType): + services.research_procedures_notif( + sender=self.sender, recipient=self.recipient, type="bad_type" + ) + + # Opt-out respected + def test_send_respects_user_opt_out(self): + NotificationPreference.objects.create( + user=self.recipient, module=ModuleName.LEAVE_MODULE, is_enabled=False + ) + with self._patch() as mock_send: + services.leave_module_notif( + sender=self.sender, recipient=self.recipient, + type=LeaveNotifType.LEAVE_APPLIED + ) + mock_send.assert_not_called() + + +# ───────────────────────────────────────────── +# API endpoint tests +# ───────────────────────────────────────────── + +class NotificationAPITest(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = make_user("apiuser") + self.client.force_authenticate(user=self.user) + + def test_list_returns_200(self): + r = self.client.get("/api/notifications/") + self.assertEqual(r.status_code, 200) + self.assertIn("notifications", r.data) + + def test_unread_returns_200(self): + r = self.client.get("/api/notifications/unread/") + self.assertEqual(r.status_code, 200) + self.assertIn("unread_count", r.data) + + def test_unread_count_returns_200(self): + r = self.client.get("/api/notifications/unread-count/") + self.assertEqual(r.status_code, 200) + self.assertIn("unread_count", r.data) + + def test_mark_all_read_returns_200(self): + r = self.client.patch("/api/notifications/mark-all-read/") + self.assertEqual(r.status_code, 200) + + def test_delete_all_returns_200(self): + r = self.client.delete("/api/notifications/delete-all/") + self.assertEqual(r.status_code, 200) + + def test_mark_read_nonexistent_returns_404(self): + r = self.client.patch("/api/notifications/9999/mark-read/") + self.assertEqual(r.status_code, 404) + + def test_mark_unread_nonexistent_returns_404(self): + r = self.client.patch("/api/notifications/9999/mark-unread/") + self.assertEqual(r.status_code, 404) + + def test_delete_nonexistent_returns_404(self): + r = self.client.delete("/api/notifications/9999/delete/") + self.assertEqual(r.status_code, 404) + + def test_get_preferences_returns_200(self): + r = self.client.get("/api/notifications/preferences/") + self.assertEqual(r.status_code, 200) + self.assertIn("preferences", r.data) + + def test_set_preference_returns_200(self): + r = self.client.post( + "/api/notifications/preferences/set/", + {"module": ModuleName.LEAVE_MODULE, "is_enabled": False}, + format="json", + ) + self.assertEqual(r.status_code, 200) + self.assertFalse(r.data["preference"]["is_enabled"]) + + def test_set_preference_invalid_module_returns_400(self): + r = self.client.post( + "/api/notifications/preferences/set/", + {"module": "bad_module", "is_enabled": True}, + format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_mark_as_read_and_redirect_nonexistent_returns_404(self): + r = self.client.get("/api/notifications/mark-as-read-and-redirect/9999/") + self.assertEqual(r.status_code, 404) + + def test_unauthenticated_returns_401_or_403(self): + unauth = APIClient() + r = unauth.get("/api/notifications/") + self.assertIn(r.status_code, [401, 403]) + + # ───────────────────────────────────────────── + # API Performance — Pagination + # ───────────────────────────────────────────── + def test_pagination_default(self): + r = self.client.get("/api/notifications/") + self.assertEqual(r.status_code, 200) + self.assertIn("pagination", r.data) + self.assertEqual(r.data["pagination"]["page"], 1) + self.assertEqual(r.data["pagination"]["page_size"], 50) + + def test_pagination_custom(self): + r = self.client.get("/api/notifications/?page=1&page_size=10") + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["pagination"]["page_size"], 10) + + def test_pagination_invalid_returns_400(self): + r = self.client.get("/api/notifications/?page=abc") + self.assertEqual(r.status_code, 400) + + +# ───────────────────────────────────────────── +# BR-NT-03 — Staff-only enforcement (RBAC) +# ───────────────────────────────────────────── + +class StaffOnlyEndpointTest(TestCase): + def setUp(self): + User = get_user_model() + self.student = User.objects.create_user( + username="student", password="pw", is_staff=False + ) + self.staff = User.objects.create_user( + username="staff", password="pw", is_staff=True + ) + self.client = APIClient() + + def test_student_cannot_send(self): + self.client.force_authenticate(user=self.student) + r = self.client.post( + "/api/notifications/send/", + { + "recipient_username": "student", + "module": ModuleName.LEAVE_MODULE, + "verb": "test", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 403) + + def test_staff_can_send(self): + self.client.force_authenticate(user=self.staff) + r = self.client.post( + "/api/notifications/send/", + { + "recipient_username": "student", + "module": ModuleName.LEAVE_MODULE, + "verb": "Your leave approved", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 201) + + def test_student_cannot_register_event_type(self): + self.client.force_authenticate(user=self.student) + r = self.client.post( + "/api/notifications/event-types/register/", + { + "event_name": "Test", + "module": ModuleName.LEAVE_MODULE, + "default_priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 403) + + def test_student_cannot_trigger(self): + self.client.force_authenticate(user=self.student) + r = self.client.post( + "/api/notifications/trigger/", + { + "event_id": "00000000-0000-0000-0000-000000000000", + "recipient_username": "student", + "message_content": "test", + }, + format="json", + ) + self.assertEqual(r.status_code, 403) + + def test_student_cannot_send_group(self): + self.client.force_authenticate(user=self.student) + r = self.client.post( + "/api/notifications/send-group/", + {"audience": "students", "module": ModuleName.LEAVE_MODULE, "verb": "test"}, + format="json", + ) + self.assertEqual(r.status_code, 403) + + +# ───────────────────────────────────────────── +# BR-NT-05 — Critical Priority Bypass +# ───────────────────────────────────────────── + +class CriticalPriorityTest(TestCase): + def setUp(self): + User = get_user_model() + self.sender = User.objects.create_user(username="s", password="p", is_staff=True) + self.recipient = User.objects.create_user(username="r", password="p") + + def test_critical_bypasses_disabled_preference(self): + """BR-NT-05: critical notification should be sent even when module is disabled.""" + from applications.notifications_extension import services + NotificationPreference.objects.create( + user=self.recipient, module=ModuleName.LEAVE_MODULE, is_enabled=False + ) + with patch("applications.notifications_extension.services.notify.send") as mock_send: + services._send( + sender=self.sender, recipient=self.recipient, + url="#", module=ModuleName.LEAVE_MODULE, verb="Critical!", + priority="critical", + ) + mock_send.assert_called_once() + + def test_non_critical_respects_disabled_preference(self): + """BR-NT-06: normal notification should be skipped when module is disabled.""" + from applications.notifications_extension import services + NotificationPreference.objects.create( + user=self.recipient, module=ModuleName.LEAVE_MODULE, is_enabled=False + ) + with patch("applications.notifications_extension.services.notify.send") as mock_send: + services._send( + sender=self.sender, recipient=self.recipient, + url="#", module=ModuleName.LEAVE_MODULE, verb="Normal", + priority="medium", + ) + mock_send.assert_not_called() + + +# ───────────────────────────────────────────── +# BR-NT-04 — Duplicate Suppression +# ───────────────────────────────────────────── + +class DedupTest(TestCase): + def test_duplicate_trigger_raises(self): + """BR-NT-04: triggering the same event for the same user within 60s is blocked.""" + from applications.notifications_extension import services + from applications.notifications_extension.models import NotificationEventType + from applications.notifications_extension.services import DuplicateNotification, NotificationNotFound + + User = get_user_model() + staff = User.objects.create_user(username="staff", password="p", is_staff=True) + recipient = User.objects.create_user(username="recipient", password="p") + + event = NotificationEventType.objects.create( + event_name="Test Event", module=ModuleName.LEAVE_MODULE, + default_priority="medium", registered_by=staff, is_active=True, + ) + services._recent_triggers.clear() + + with patch("applications.notifications_extension.services.notify.send"): + services.trigger_notification_by_event_id( + event_id=str(event.event_id), sender=staff, recipient=recipient, + message_content="first", + ) + with self.assertRaises(DuplicateNotification): + services.trigger_notification_by_event_id( + event_id=str(event.event_id), sender=staff, recipient=recipient, + message_content="dup", + ) + + +# ───────────────────────────────────────────── +# Group Send — Audience Resolution +# ───────────────────────────────────────────── + +class GroupSendTest(TestCase): + def setUp(self): + User = get_user_model() + self.staff = User.objects.create_user(username="st", password="p", is_staff=True) + # Create some fake students + for roll in ["23BCS101", "23BCS102", "22BCS050"]: + User.objects.create_user(username=roll, password="p") + self.client = APIClient() + self.client.force_authenticate(user=self.staff) + + def test_batch_audience_matches_prefix(self): + with patch("applications.notifications_extension.services.notify.send"): + r = self.client.post( + "/api/notifications/send-group/", + {"audience": "batch", "audience_value": "23BCS", + "module": ModuleName.LEAVE_MODULE, "verb": "test"}, + format="json", + ) + self.assertEqual(r.status_code, 201) + # Should match 23BCS101 and 23BCS102 (not 22BCS050) + self.assertGreaterEqual(r.data["delivered"], 2) + + def test_group_send_rejects_unknown_audience(self): + r = self.client.post( + "/api/notifications/send-group/", + {"audience": "martians", "module": ModuleName.LEAVE_MODULE, "verb": "t"}, + format="json", + ) + self.assertEqual(r.status_code, 400) + + def test_group_send_rejects_bad_module(self): + r = self.client.post( + "/api/notifications/send-group/", + {"audience": "students", "module": "FakeModule", "verb": "t"}, + format="json", + ) + self.assertEqual(r.status_code, 400) diff --git a/FusionIIIT/applications/notifications_extension/tests/test_use_cases.py b/FusionIIIT/applications/notifications_extension/tests/test_use_cases.py new file mode 100644 index 000000000..3eb07fa7b --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/test_use_cases.py @@ -0,0 +1,399 @@ +""" +test_use_cases.py - Specification-driven tests for the 4 NAM Use Cases. + +Coverage per UC: 1 Happy Path + 1 Alternate Path + 1 Exception = 3 tests. +Total: 4 UCs x 3 = 12 tests. Adequacy = 12 / 12 = 100%. +""" + +from unittest.mock import patch + +from applications.notifications_extension.models import ModuleName, NotificationEventType +from applications.notifications_extension.tests.conftest import BaseNAMTestCase + + +# ──────────────────────────────────────────────────────────────── +# UC-NT-01 - Register Notification Event Type +# ──────────────────────────────────────────────────────────────── + +class TestUC01_RegisterEventType(BaseNAMTestCase): + """UC-NT-01: modules register event types with NAM.""" + + def test_happy_path_register_valid_event_type(self): + """UC-NT-01-HP-01: Register a valid event with default priority.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send"): + response = self.client.post( + "/api/notifications/event-types/register/", + { + "event_name": "Leave Approved", + "module": ModuleName.LEAVE_MODULE, + "default_priority": "medium", + "description": "Fired when HOD approves a leave", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.assertIn("event_type", response.data) + self.assertIn("event_id", response.data["event_type"]) + self.assertTrue(response.data["event_type"]["is_active"]) + + self._record_result( + test_id="UC-NT-01-HP-01", source_id="UC-NT-01", + category="Happy Path", + scenario="Staff registers valid event with default priority", + preconditions="Authenticated staff; module valid", + input_action="POST /api/notifications/event-types/register/", + expected_result="201 Created; event_id + is_active=true returned", + actual_result="201 Created received; event_type stored with UUID", + status="Pass", + evidence=f"event_id={response.data['event_type']['event_id']}", + ) + + def test_alternate_path_register_with_critical_priority(self): + """UC-NT-01-AP-01: Register event with critical priority.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send"): + response = self.client.post( + "/api/notifications/event-types/register/", + { + "event_name": "Emergency Evacuation", + "module": ModuleName.OFFICE_MODULE, + "default_priority": "critical", + "description": "Campus emergency", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["event_type"]["default_priority"], "critical") + + self._record_result( + test_id="UC-NT-01-AP-01", source_id="UC-NT-01", + category="Alternate Path", + scenario="Staff registers event with critical priority", + preconditions="Authenticated staff", + input_action="POST register/ with default_priority=critical", + expected_result="201 Created; event stored with priority=critical", + actual_result="201 Created received; priority persisted correctly", + status="Pass", + evidence="default_priority=critical", + ) + + def test_exception_path_invalid_module(self): + """UC-NT-01-EX-01: Register with invalid module name.""" + self.login_as_staff() + response = self.client.post( + "/api/notifications/event-types/register/", + { + "event_name": "Bogus", + "module": "NonExistentModule", + "default_priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 400) + + self._record_result( + test_id="UC-NT-01-EX-01", source_id="UC-NT-01", + category="Exception", + scenario="Staff registers event with invalid module", + preconditions="Authenticated staff", + input_action="POST register/ with module='NonExistentModule'", + expected_result="400 Bad Request with module error", + actual_result=f"400 received; body={response.data}", + status="Pass", + evidence="Validation at serializer level blocked bad module", + ) + + +# ──────────────────────────────────────────────────────────────── +# UC-NT-02 - Trigger Notification via Event ID +# ──────────────────────────────────────────────────────────────── + +class TestUC02_TriggerByEventId(BaseNAMTestCase): + """UC-NT-02: external modules trigger notifications by Event_ID.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.event_type = NotificationEventType.objects.create( + event_name="UC02 Test Event", + module=ModuleName.LEAVE_MODULE, + default_priority="medium", + registered_by=cls.staff, + is_active=True, + ) + + def setUp(self): + super().setUp() + # Clear dedup cache before each test so repeated runs don't collide + from applications.notifications_extension import services + services._recent_triggers.clear() + + def test_happy_path_trigger_by_valid_event(self): + """UC-NT-02-HP-01: Trigger valid event for existing recipient.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send") as mock: + response = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(self.event_type.event_id), + "recipient_username": self.student.username, + "message_content": "Your leave is approved", + "deep_link": "/leave/status/", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + mock.assert_called_once() + + self._record_result( + test_id="UC-NT-02-HP-01", source_id="UC-NT-02", + category="Happy Path", + scenario="Staff triggers valid event for existing student", + preconditions="Event type registered; recipient exists", + input_action="POST /api/notifications/trigger/", + expected_result="201 Created; notify.send called once", + actual_result="201 received; service dispatched notification", + status="Pass", + evidence="notify.send invoked; recipient=nam_student", + ) + + def test_alternate_path_trigger_with_deep_link(self): + """UC-NT-02-AP-01: Trigger with a custom deep_link.""" + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send") as mock: + response = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(self.event_type.event_id), + "recipient_username": self.student.username, + "message_content": "Check your schedule", + "deep_link": "/academic/timetable", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + # Verify the URL kwarg was forwarded + kwargs = mock.call_args.kwargs + self.assertEqual(kwargs.get("url"), "/academic/timetable") + + self._record_result( + test_id="UC-NT-02-AP-01", source_id="UC-NT-02", + category="Alternate Path", + scenario="Staff triggers with a custom deep_link", + preconditions="Event type registered", + input_action="POST trigger/ with deep_link='/academic/timetable'", + expected_result="201 Created; url kwarg in notify.send matches", + actual_result="201 received; url forwarded correctly", + status="Pass", + evidence="notify.send kwargs.url=/academic/timetable", + ) + + def test_exception_path_unknown_event_id(self): + """UC-NT-02-EX-01: Trigger with an unregistered event_id.""" + self.login_as_staff() + response = self.client.post( + "/api/notifications/trigger/", + { + "event_id": "00000000-0000-0000-0000-000000000000", + "recipient_username": self.student.username, + "message_content": "should fail", + }, + format="json", + ) + self.assertEqual(response.status_code, 404) + + self._record_result( + test_id="UC-NT-02-EX-01", source_id="UC-NT-02", + category="Exception", + scenario="Staff triggers with unknown event_id", + preconditions="No event type with that UUID", + input_action="POST trigger/ with random UUID", + expected_result="404 Not Found; no notification dispatched", + actual_result=f"404 received; body={response.data}", + status="Pass", + evidence="Service raised NotificationNotFound", + ) + + +# ──────────────────────────────────────────────────────────────── +# UC-NT-03 - Broadcast Manual Announcement +# ──────────────────────────────────────────────────────────────── + +class TestUC03_BroadcastAnnouncement(BaseNAMTestCase): + """UC-NT-03: admin broadcasts to a resolved audience.""" + + def test_happy_path_broadcast_to_all_users(self): + """UC-NT-03-HP-01: Admin broadcasts to audience=all.""" + self.login_as_admin() + with patch("applications.notifications_extension.services.notify.send") as mock: + response = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Convocation Notice", + "message": "Convocation ceremony on 15 May", + "audience_type": "all", + "audience_value": "", + "expiry_date": "2026-05-16", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + self.assertGreaterEqual(mock.call_count, 1) + + self._record_result( + test_id="UC-NT-03-HP-01", source_id="UC-NT-03", + category="Happy Path", + scenario="Admin broadcasts to all users with future expiry", + preconditions="Authenticated admin; expiry date in future", + input_action="POST /announcements/broadcast/", + expected_result="201 Created; announcement stored; fan-out performed", + actual_result=f"201 received; fan-out count={mock.call_count}", + status="Pass", + evidence="Announcement record + notify.send invocations", + ) + + def test_alternate_path_broadcast_to_specific_designation(self): + """UC-NT-03-AP-01: Admin targets a specific designation (group).""" + # Patch _resolve_audience: test DB has no globals_* tables + from django.contrib.auth import get_user_model + User = get_user_model() + self.login_as_admin() + with patch( + "applications.notifications_extension.services._resolve_audience", + return_value=User.objects.filter(pk=self.student.pk), + ), patch("applications.notifications_extension.services.notify.send"): + response = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "HOD Meeting", + "message": "Meeting at 3 PM", + "audience_type": "group", + "audience_value": "HOD (CSE)", + "expiry_date": "2026-05-01", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 201) + + self._record_result( + test_id="UC-NT-03-AP-01", source_id="UC-NT-03", + category="Alternate Path", + scenario="Admin broadcasts to a specific designation", + preconditions="Authenticated admin; audience=group", + input_action="POST broadcast/ with audience_value='HOD (CSE)'", + expected_result="201 Created; audience scoped to the designation", + actual_result="201 received; announcement stored", + status="Pass", + evidence="audience_type=group routed through _resolve_audience()", + ) + + def test_exception_path_invalid_audience_type(self): + """UC-NT-03-EX-01: Broadcast with invalid audience type.""" + self.login_as_admin() + response = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Nope", + "message": "Should fail", + "audience_type": "mars", + "expiry_date": "2026-05-01", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(response.status_code, 400) + + self._record_result( + test_id="UC-NT-03-EX-01", source_id="UC-NT-03", + category="Exception", + scenario="Admin broadcasts with invalid audience type", + preconditions="Authenticated admin", + input_action="POST broadcast/ with audience_type='mars'", + expected_result="400 Bad Request; error details", + actual_result=f"400 received; body={response.data}", + status="Pass", + evidence="Serializer rejected invalid AudienceType", + ) + + +# ──────────────────────────────────────────────────────────────── +# UC-NT-04 - Notification Tray (Navbar Bell) +# ──────────────────────────────────────────────────────────────── + +class TestUC04_NotificationTray(BaseNAMTestCase): + """UC-NT-04: user interacts with the navbar bell and list.""" + + def test_happy_path_fetch_paginated_list(self): + """UC-NT-04-HP-01: Fetch paginated notifications list.""" + self.login_as_student() + response = self.client.get("/api/notifications/?page=1&page_size=10") + self.assertEqual(response.status_code, 200) + self.assertIn("notifications", response.data) + self.assertIn("pagination", response.data) + self.assertEqual(response.data["pagination"]["page"], 1) + self.assertEqual(response.data["pagination"]["page_size"], 10) + + self._record_result( + test_id="UC-NT-04-HP-01", source_id="UC-NT-04", + category="Happy Path", + scenario="Student fetches paginated notifications", + preconditions="Authenticated student", + input_action="GET /api/notifications/?page=1&page_size=10", + expected_result="200 OK; notifications + pagination metadata", + actual_result="200 OK; pagination block returned", + status="Pass", + evidence=f"pagination={response.data['pagination']}", + ) + + def test_alternate_path_mark_single_as_read(self): + """UC-NT-04-AP-01: Mark a single notification as read.""" + # Create a real notification by calling notify.send directly + from notifications.signals import notify + notify.send( + self.staff, recipient=self.student, verb="Please read me", + module=ModuleName.LEAVE_MODULE, url="/x", + ) + from notifications.models import Notification + note = Notification.objects.filter(recipient=self.student).first() + self.assertIsNotNone(note) + self.assertTrue(note.unread) + + self.login_as_student() + response = self.client.patch(f"/api/notifications/{note.id}/mark-read/") + self.assertEqual(response.status_code, 200) + note.refresh_from_db() + self.assertFalse(note.unread) + + self._record_result( + test_id="UC-NT-04-AP-01", source_id="UC-NT-04", + category="Alternate Path", + scenario="Student marks a notification as read", + preconditions="Student has an unread notification", + input_action="PATCH /api/notifications/{id}/mark-read/", + expected_result="200 OK; unread flag cleared in DB", + actual_result="200 OK; note.unread=False after refresh", + status="Pass", + evidence=f"notification_id={note.id}", + ) + + def test_exception_path_unauthenticated_access(self): + """UC-NT-04-EX-01: Unauthenticated fetch is denied.""" + # No client.force_authenticate() + self.logout() + response = self.client.get("/api/notifications/") + self.assertIn(response.status_code, [401, 403]) + + self._record_result( + test_id="UC-NT-04-EX-01", source_id="UC-NT-04", + category="Exception", + scenario="Unauthenticated client tries to read list", + preconditions="No auth token", + input_action="GET /api/notifications/ without Authorization", + expected_result="401/403 denied", + actual_result=f"Got {response.status_code}", + status="Pass", + evidence="DRF IsAuthenticated returned 401/403", + ) diff --git a/FusionIIIT/applications/notifications_extension/tests/test_workflows.py b/FusionIIIT/applications/notifications_extension/tests/test_workflows.py new file mode 100644 index 000000000..2a6ff8814 --- /dev/null +++ b/FusionIIIT/applications/notifications_extension/tests/test_workflows.py @@ -0,0 +1,367 @@ +""" +test_workflows.py - End-to-end workflow tests for NAM. + +Coverage per WF: 1 End-to-End + 1 Negative + 1 Exit = 3 tests. +Total: 3 WFs x 3 = 9 tests. Required = 3 x 2 = 6. Adequacy = 9/6 = 150%. +""" + +from unittest.mock import patch + +from applications.notifications_extension.models import ( + ModuleName, NotificationEventType, NotificationPreference, +) +from applications.notifications_extension.tests.conftest import BaseNAMTestCase + + +# ──────────────────────────────────────────────────────────────── +# WF-NT-01 - Register Event -> Trigger -> Deliver +# ──────────────────────────────────────────────────────────────── + +class TestWF01_RegisterThenTrigger(BaseNAMTestCase): + + def setUp(self): + super().setUp() + from applications.notifications_extension import services + services._recent_triggers.clear() + + def test_e2e_register_trigger_receive(self): + """WF-NT-01-E2E-01: Full register -> trigger -> receive flow.""" + self.login_as_staff() + + # Step 1 - register event + with patch("applications.notifications_extension.services.notify.send") as mock_notify: + r_reg = self.client.post( + "/api/notifications/event-types/register/", + { + "event_name": "WF01 E2E", + "module": ModuleName.LEAVE_MODULE, + "default_priority": "medium", + "description": "E2E flow event", + }, + format="json", + ) + self.assertEqual(r_reg.status_code, 201) + event_id = r_reg.data["event_type"]["event_id"] + + # Step 2 - trigger for student + r_trig = self.client.post( + "/api/notifications/trigger/", + { + "event_id": event_id, + "recipient_username": self.student.username, + "message_content": "E2E message", + "deep_link": "/leave/status/", + }, + format="json", + ) + self.assertEqual(r_trig.status_code, 201) + mock_notify.assert_called() + + # Step 3 - student fetches list + self.login_as_student() + r_list = self.client.get("/api/notifications/") + self.assertEqual(r_list.status_code, 200) + + self._record_result( + test_id="WF-NT-01-E2E-01", source_id="WF-NT-01", + category="End-to-End", + scenario="Register -> Trigger -> Student fetches list", + preconditions="Clean dedup cache", + input_action="register -> trigger -> GET /api/notifications/", + expected_result="Event stored; notify.send called; list returns 200", + actual_result="All three steps returned expected codes", + status="Pass", evidence=f"event_id={event_id}", + ) + + def test_negative_student_cannot_trigger(self): + """WF-NT-01-NEG-01: Student attempting to trigger is blocked.""" + # Pre-seed an event (staff registers) + event = NotificationEventType.objects.create( + event_name="WF01 Neg event", + module=ModuleName.LEAVE_MODULE, + default_priority="medium", + registered_by=self.staff, is_active=True, + ) + + self.login_as_student() + r = self.client.post( + "/api/notifications/trigger/", + { + "event_id": str(event.event_id), + "recipient_username": self.student.username, + "message_content": "hack attempt", + }, + format="json", + ) + self.assertEqual(r.status_code, 403) + # Event still exists + self.assertTrue( + NotificationEventType.objects.filter(pk=event.pk).exists() + ) + + self._record_result( + test_id="WF-NT-01-NEG-01", source_id="WF-NT-01", + category="Negative", + scenario="Student attempts to trigger event", + preconditions="Event exists; logged in as student", + input_action="POST /trigger/ as student", + expected_result="403 Forbidden; event unchanged", + actual_result="403 received; event still exists", + status="Pass", evidence="BR-NT-03 RBAC enforced mid-workflow", + ) + + def test_exit_trigger_with_unknown_event(self): + """WF-NT-01-EXIT-01: Trigger with unknown event_id exits cleanly.""" + self.login_as_staff() + r = self.client.post( + "/api/notifications/trigger/", + { + "event_id": "00000000-0000-0000-0000-000000000000", + "recipient_username": self.student.username, + "message_content": "x", + }, + format="json", + ) + self.assertEqual(r.status_code, 404) + + self._record_result( + test_id="WF-NT-01-EXIT-01", source_id="WF-NT-01", + category="Exit", + scenario="Trigger with unknown event_id exits with 404", + preconditions="No event with that UUID", + input_action="POST /trigger/ with bogus event_id", + expected_result="404; workflow exits cleanly without side-effects", + actual_result="404 received", status="Pass", + evidence="Workflow gracefully exits", + ) + + +# ──────────────────────────────────────────────────────────────── +# WF-NT-02 - Broadcast Fan-Out +# ──────────────────────────────────────────────────────────────── + +class TestWF02_BroadcastFanout(BaseNAMTestCase): + + def test_e2e_admin_broadcast_to_students(self): + """WF-NT-02-E2E-01: Admin broadcasts to students; all receive.""" + from django.contrib.auth import get_user_model + User = get_user_model() + + self.login_as_admin() + with patch( + "applications.notifications_extension.services._resolve_audience", + return_value=User.objects.filter(pk=self.student.pk), + ), patch("applications.notifications_extension.services.notify.send") as mock_notify: + r = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "All Students", + "message": "Final exam schedule", + "audience_type": "students", + "expiry_date": "2026-06-30", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 201) + mock_notify.assert_called() + + self._record_result( + test_id="WF-NT-02-E2E-01", source_id="WF-NT-02", + category="End-to-End", + scenario="Admin broadcasts to students; fan-out happens", + preconditions="Authenticated admin; audience resolves to 1 student", + input_action="POST /announcements/broadcast/", + expected_result="201; notify.send called for each recipient", + actual_result=f"201; fan-out count={mock_notify.call_count}", + status="Pass", evidence="WF-NT-02 E2E complete", + ) + + def test_negative_student_cannot_broadcast(self): + """WF-NT-02-NEG-01: Student cannot call broadcast endpoint.""" + self.login_as_student() + r = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "rogue", + "message": "should fail", + "audience_type": "students", + "expiry_date": "2026-06-30", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 403) + + self._record_result( + test_id="WF-NT-02-NEG-01", source_id="WF-NT-02", + category="Negative", + scenario="Student attempts broadcast", + preconditions="Authenticated student", + input_action="POST /broadcast/ as student", + expected_result="403 Forbidden", + actual_result="403 received", + status="Pass", evidence="RBAC blocks non-admins", + ) + + def test_exit_empty_audience(self): + """WF-NT-02-EXIT-01: Broadcast with empty audience exits cleanly.""" + from django.contrib.auth import get_user_model + User = get_user_model() + + self.login_as_admin() + with patch( + "applications.notifications_extension.services._resolve_audience", + return_value=User.objects.none(), + ), patch("applications.notifications_extension.services.notify.send") as mock_notify: + r = self.client.post( + "/api/notifications/announcements/broadcast/", + { + "title": "Empty", + "message": "no audience", + "audience_type": "group", + "audience_value": "NobodyHere", + "expiry_date": "2026-06-30", + "priority": "medium", + }, + format="json", + ) + self.assertEqual(r.status_code, 201) + mock_notify.assert_not_called() + + self._record_result( + test_id="WF-NT-02-EXIT-01", source_id="WF-NT-02", + category="Exit", + scenario="Broadcast with empty audience exits cleanly", + preconditions="Audience resolves to zero users", + input_action="POST /broadcast/ with bad group", + expected_result="201; zero fan-out", + actual_result="201 received; no notify.send calls", + status="Pass", evidence="Workflow exits without errors", + ) + + +# ──────────────────────────────────────────────────────────────── +# WF-NT-03 - Preference Toggle + Filter +# ──────────────────────────────────────────────────────────────── + +class TestWF03_PreferenceFlow(BaseNAMTestCase): + + def test_e2e_toggle_off_filters_then_critical_delivers(self): + """WF-NT-03-E2E-01: Toggle off -> medium skipped -> critical delivered.""" + # Step 1 - student toggles module off + self.login_as_student() + r1 = self.client.post( + "/api/notifications/preferences/set/", + {"module": ModuleName.LEAVE_MODULE, "is_enabled": False}, + format="json", + ) + self.assertEqual(r1.status_code, 200) + + # Step 2 - staff sends medium (should be skipped) + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send") as mock_notify: + self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "medium", + "priority": "medium", + }, + format="json", + ) + mock_notify.assert_not_called() + + # Step 3 - staff sends critical (should be delivered) + self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "EMERGENCY", + "priority": "critical", + }, + format="json", + ) + mock_notify.assert_called() + + self._record_result( + test_id="WF-NT-03-E2E-01", source_id="WF-NT-03", + category="End-to-End", + scenario="Toggle off -> medium skipped -> critical delivered", + preconditions="Student authenticated; then staff sends twice", + input_action="preferences/set -> send(medium) -> send(critical)", + expected_result="medium skipped; critical delivered", + actual_result="First assert_not_called then assert_called both pass", + status="Pass", + evidence="BR-NT-05 + BR-NT-06 interaction verified", + ) + + def test_negative_invalid_priority_rejected(self): + """WF-NT-03-NEG-01: Invalid priority string rejected.""" + self.login_as_staff() + r = self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "bad prio", + "priority": "ultra_mega", + }, + format="json", + ) + self.assertEqual(r.status_code, 400) + + self._record_result( + test_id="WF-NT-03-NEG-01", source_id="WF-NT-03", + category="Negative", + scenario="Invalid priority string rejected", + preconditions="Authenticated staff", + input_action="POST /send/ with priority='ultra_mega'", + expected_result="400 Bad Request", + actual_result=f"400 received; body={r.data}", + status="Pass", evidence="Serializer enum validation works", + ) + + def test_exit_toggle_back_on_delivers(self): + """WF-NT-03-EXIT-01: Toggle off then back on, delivery resumes.""" + self.login_as_student() + self.client.post( + "/api/notifications/preferences/set/", + {"module": ModuleName.LEAVE_MODULE, "is_enabled": False}, + format="json", + ) + # Toggle back on + r = self.client.post( + "/api/notifications/preferences/set/", + {"module": ModuleName.LEAVE_MODULE, "is_enabled": True}, + format="json", + ) + self.assertEqual(r.status_code, 200) + self.assertTrue(r.data["preference"]["is_enabled"]) + + self.login_as_staff() + with patch("applications.notifications_extension.services.notify.send") as mock_notify: + self.client.post( + "/api/notifications/send/", + { + "recipient_username": self.student.username, + "module": ModuleName.LEAVE_MODULE, + "verb": "resumed", + "priority": "medium", + }, + format="json", + ) + mock_notify.assert_called_once() + + self._record_result( + test_id="WF-NT-03-EXIT-01", source_id="WF-NT-03", + category="Exit", + scenario="Re-enable module; delivery resumes", + preconditions="Module toggled off then on", + input_action="preferences/set twice -> send", + expected_result="notify.send called once after re-enable", + actual_result="assert_called_once passed", + status="Pass", evidence="Preference lifecycle verified", + ) diff --git a/FusionIIIT/applications/notifications_extension/urls.py b/FusionIIIT/applications/notifications_extension/urls.py deleted file mode 100644 index b5690994b..000000000 --- a/FusionIIIT/applications/notifications_extension/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -from notifications.urls import urlpatterns -from applications.notifications_extension.views import mark_as_read_and_redirect -from django.conf.urls import url as pattern -from django.conf.urls import include, url -from . import views - -# from .api import urls - -app_name = 'notifications' - -urlpatterns = [ - pattern(r'^mark-as-read-and-redirect/(?P\d+)/$', views.mark_as_read_and_redirect, name='mark_as_read_and_redirect'), - pattern(r'^delete/(?P\d+)/$', views.delete, name='delete'), - url(r'^api/',include('applications.notifications_extension.api.urls')), - ] + urlpatterns diff --git a/FusionIIIT/applications/notifications_extension/views.py b/FusionIIIT/applications/notifications_extension/views.py deleted file mode 100644 index a95e7a798..000000000 --- a/FusionIIIT/applications/notifications_extension/views.py +++ /dev/null @@ -1,34 +0,0 @@ -from django.http import HttpResponseRedirect -from django.urls import reverse -from notifications.utils import id2slug, slug2id -from django.shortcuts import get_object_or_404, redirect -from notifications.models import Notification -import Fusion.settings as FusionIIIT_settings -def delete(request, slug=None): - notification_id = slug2id(slug) - notification = get_object_or_404( - Notification, recipient=request.user, id=notification_id) - notification.delete() - previous_page = request.META.get('HTTP_REFERER', '/') - # Redirect to the previous page or the home page if the referrer is not available - return HttpResponseRedirect(previous_page) - # return HttpResponseRedirect('dashboard/') -def mark_as_read_and_redirect(request, slug=None): - notification_id = slug2id(slug) - notification = get_object_or_404( - Notification, recipient=request.user, id=notification_id) - notification.mark_as_read() - - # This conditional statement is True only in - # case of complaint_module. - # return redirect('notifications:all') - - if(notification.data['module'] == 'Complaint System'): - complaint_id=notification.description - return HttpResponseRedirect(reverse(notification.data['url'],kwargs={'detailcomp_id1':complaint_id})) - elif(notification.data['module'] == 'Course Management'): - course_code = notification.data['course_code'] - print(course_code) - return HttpResponseRedirect(reverse(notification.data['url'],kwargs={'course_code': course_code})) - else: - return HttpResponseRedirect(reverse(notification.data['url'])) diff --git a/FusionIIIT/notification_client/__init__.py b/FusionIIIT/notification_client/__init__.py new file mode 100644 index 000000000..d497bcba2 --- /dev/null +++ b/FusionIIIT/notification_client/__init__.py @@ -0,0 +1,3 @@ +# notification_client package +# Other Fusion modules import from here to send notifications. +# They must NOT call notify.send() or create Notification objects directly. diff --git a/FusionIIIT/notification_client/client.py b/FusionIIIT/notification_client/client.py new file mode 100644 index 000000000..0979cdbc9 --- /dev/null +++ b/FusionIIIT/notification_client/client.py @@ -0,0 +1,303 @@ +""" +notification_client/client.py + +The ONLY way other Fusion modules are allowed to send notifications. + +Rules: + - Other modules MUST import from this file. + - Other modules must NOT call notify.send() directly. + - Other modules must NOT import from applications.notifications_extension.services directly. + - This client calls the Notification Module REST API internally. + +Usage example (from any other Fusion module): + from notification_client.client import NotificationClient + client = NotificationClient.for_request(request) + client.leave(recipient_username="student1", type="leave_accepted") +""" + +import requests +from django.conf import settings + + +# Base URL for the notification module API. +# In production replace with the real service URL via settings. +_BASE_URL = getattr(settings, "NOTIFICATION_API_BASE_URL", "http://127.0.0.1:8000/api/notifications") + + +class NotificationError(Exception): + """Raised when the notification API returns an error.""" + + +class NotificationClient: + """ + Client for the Fusion Notification Module API. + + Instantiate with an auth token: + client = NotificationClient(token="abc123") + + Or from a DRF request (token is extracted automatically): + client = NotificationClient.for_request(request) + """ + + def __init__(self, token: str): + self._headers = { + "Authorization": f"Token {token}", + "Content-Type": "application/json", + } + + # ── Factory ────────────────────────────────────────────────────────────── + + @classmethod + def for_request(cls, request): + """ + Build a client from the current DRF request. + The calling module's authenticated token is forwarded. + """ + auth = request.META.get("HTTP_AUTHORIZATION", "") + if auth.startswith("Token "): + return cls(token=auth.split(" ", 1)[1]) + # Session-auth fallback: fetch or create a token for this user + from rest_framework.authtoken.models import Token + token, _ = Token.objects.get_or_create(user=request.user) + return cls(token=token.key) + + # ── Internal ───────────────────────────────────────────────────────────── + + def _post(self, endpoint: str, payload: dict): + url = f"{_BASE_URL}/{endpoint.lstrip('/')}" + response = requests.post(url, json=payload, headers=self._headers, timeout=5) + if not response.ok: + raise NotificationError( + f"Notification API error [{response.status_code}]: {response.text}" + ) + return response.json() + + # ───────────────────────────────────────────────────────────────────────── + # Public methods — one per Fusion module + # Each method maps 1-to-1 with a notification type. + # ───────────────────────────────────────────────────────────────────────── + + def leave(self, *, recipient_username: str, type: str, date: str = None): + """ + Send a Leave Module notification. + + type choices: + leave_applied | request_accepted | request_declined | + leave_accepted | leave_forwarded | leave_rejected | + offline_leave | replacement_request | leave_request | + leave_withdrawn | replacement_cancel + + date: required for leave_withdrawn and replacement_cancel + """ + payload = {"recipient_username": recipient_username, "type": type} + if date: + payload["date"] = date + return self._post("notify/leave/", payload) + + def mess(self, *, recipient_username: str, type: str, message: str = None): + """ + Send a Central Mess notification. + + type choices: + feedback_submitted | menu_change_accepted | leave_request | + vacation_request | meeting_invitation | special_request | added_committee + + message: required for leave_request, vacation_request, + meeting_invitation, special_request + """ + payload = {"recipient_username": recipient_username, "type": type} + if message: + payload["message"] = message + return self._post("notify/mess/", payload) + + def hostel(self, *, recipient_username: str, type: str): + """ + Send a Visitor's Hostel notification. + + type choices: + booking_confirmation | booking_cancellation_request_accepted | + booking_request | cancellation_request_placed | + booking_forwarded | booking_rejected + """ + return self._post("notify/hostel/", { + "recipient_username": recipient_username, + "type": type, + }) + + def healthcare(self, *, recipient_username: str, type: str): + """ + Send a Healthcare Center notification. + + type choices: + appoint | amb_request | Presc | appoint_req | amb_req + """ + return self._post("notify/healthcare/", { + "recipient_username": recipient_username, + "type": type, + }) + + def scholarship(self, *, recipient_username: str, type: str): + """ + Send a Scholarship Portal notification. + + type choices: + Accept_MCM | Reject_MCM | Accept_Gold | Reject_Gold | + Accept_Silver | Reject_Silver | Accept_DM + """ + return self._post("notify/scholarship/", { + "recipient_username": recipient_username, + "type": type, + }) + + def dean_pnd(self, *, recipient_username: str, type: str): + """ + Send an Office of Dean PnD notification. + + type choices: + requisition_filed | request_accepted | request_rejected | + assignment_created | assignment_received | + assignment_reverted | assignment_approved | assignment_rejected + """ + return self._post("notify/dean-pnd/", { + "recipient_username": recipient_username, + "type": type, + }) + + def dean_students(self, *, recipient_username: str, type: str): + """ + Send an Office Module (Dean Students) notification. + + type choices: + hostel_alloted | insufficient_funds | MOM_submitted | + budget_approved | budget_rejected | club_approved | club_rejected | + meeting_booked | session_approved | session_rejected | budget_alloted + """ + return self._post("notify/dean-students/", { + "recipient_username": recipient_username, + "type": type, + }) + + def dean_rspc(self, *, recipient_username: str, type: str): + """ + Send an Office Module (Dean RSPC) notification. + + type choices: + Approve | Disapprove | Pending + """ + return self._post("notify/dean-rspc/", { + "recipient_username": recipient_username, + "type": type, + }) + + def gymkhana_voting(self, *, recipient_username: str, title: str, desc: str): + """Send a Gymkhana voting-open notification.""" + return self._post("notify/gymkhana/voting/", { + "recipient_username": recipient_username, + "title": title, + "desc": desc, + }) + + def gymkhana_session(self, *, recipient_username: str, club: str, + desc: str, venue: str): + """Send a Gymkhana new-session notification.""" + return self._post("notify/gymkhana/session/", { + "recipient_username": recipient_username, + "club": club, + "desc": desc, + "venue": venue, + }) + + def gymkhana_event(self, *, recipient_username: str, club: str, + event_name: str, desc: str, venue: str): + """Send a Gymkhana new-event notification.""" + return self._post("notify/gymkhana/event/", { + "recipient_username": recipient_username, + "club": club, + "event_name": event_name, + "desc": desc, + "venue": venue, + }) + + def research(self, *, recipient_username: str, type: str): + """ + Send a Research Procedures notification. + + type choices: + Approved | Disapproved | Pending | submitted | created + """ + return self._post("notify/research/", { + "recipient_username": recipient_username, + "type": type, + }) + + def assistantship_approved(self, *, recipient_username: str, + month: str, year: str): + """Notify student that assistantship claim is approved.""" + return self._post("notify/assistantship/approved/", { + "recipient_username": recipient_username, + "month": month, + "year": year, + }) + + def assistantship_faculty(self, *, recipient_username: str): + """Notify faculty that an assistantship claim is requested.""" + return self._post("notify/assistantship/faculty/", { + "recipient_username": recipient_username, + }) + + def assistantship_acad(self, *, recipient_username: str): + """Notify academic section that an assistantship claim is requested.""" + return self._post("notify/assistantship/acad/", { + "recipient_username": recipient_username, + }) + + def assistantship_accounts(self, *, recipient_username: str, + student_username: str): + """Notify accounts section that a claim has been forwarded.""" + return self._post("notify/assistantship/accounts/", { + "recipient_username": recipient_username, + "student_username": student_username, + }) + + def complaint(self, *, recipient_username: str, complaint_id: int, + is_student: bool, message: str): + """ + Send a Complaint System notification. + + is_student=True → links to student complaint detail view + is_student=False → links to staff complaint detail view + """ + return self._post("notify/complaint/", { + "recipient_username": recipient_username, + "complaint_id": complaint_id, + "is_student": is_student, + "message": message, + }) + + def file_tracking(self, *, recipient_username: str, title: str): + """Send a File Tracking notification. title = the file/document name.""" + return self._post("notify/file-tracking/", { + "recipient_username": recipient_username, + "title": title, + }) + + def placement(self, *, recipient_username: str, message: str): + """Send a Placement Cell notification.""" + return self._post("notify/placement/", { + "recipient_username": recipient_username, + "message": message, + }) + + def academics(self, *, recipient_username: str, message: str): + """Send an Academics Module notification.""" + return self._post("notify/academics/", { + "recipient_username": recipient_username, + "message": message, + }) + + def department(self, *, recipient_username: str, message: str): + """Send a Department notification.""" + return self._post("notify/department/", { + "recipient_username": recipient_username, + "message": message, + }) From ef04e9219c003b66a8c3a57296a17be9dce83259 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Sat, 9 May 2026 14:24:38 +0530 Subject: [PATCH 2/2] =?UTF-8?q?G2:=20NAM=20module=20=E2=80=94=20replace=20?= =?UTF-8?q?api/=20stubs=20with=20full=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit accidentally left the api/ folder files as the legacy stubs because they existed on the announcements-v2 base branch. This commit replaces them with the team's full implementation: - api/serializers.py — DRF serializers with field-level validation (full version) - api/urls.py — URL routing for all 52 endpoints (each with + without trailing slash) - api/views.py — 52 thin DRF endpoints implementing UC-NT-01..04 and BR-NT-01..09 --- .../api/serializers.py | 352 +++- .../notifications_extension/api/urls.py | 143 +- .../notifications_extension/api/views.py | 1455 +++++++++++++---- 3 files changed, 1535 insertions(+), 415 deletions(-) diff --git a/FusionIIIT/applications/notifications_extension/api/serializers.py b/FusionIIIT/applications/notifications_extension/api/serializers.py index 899c50e57..71d009c1d 100644 --- a/FusionIIIT/applications/notifications_extension/api/serializers.py +++ b/FusionIIIT/applications/notifications_extension/api/serializers.py @@ -1,6 +1,352 @@ -from rest_framework import serializers +""" +api/serializers.py — DRF serializers for the notification module. + +Rules enforced: + - Field-level validation only. No business logic. + - No .objects. calls. No service calls. +""" + from notifications.models import Notification +from rest_framework import serializers + +from applications.notifications_extension.models import ( + Announcement, + AudienceType, + EventPriority, + ModuleName, + NotificationEventType, + NotificationPreference, + LeaveNotifType, + MessNotifType, + VisitorHostelNotifType, + HealthcareNotifType, + ScholarshipNotifType, + OfficeDeanPnDNotifType, + OfficeDeanSNotifType, + OfficeDeanRSPCNotifType, + GymkhanaNotifType, + ResearchProceduresNotifType, +) + + +# ───────────────────────────────────────────── +# Read serializers +# ───────────────────────────────────────────── + class NotificationSerializer(serializers.ModelSerializer): + """ + Read-only serializer for a django-notifications-hq Notification. + Exposes actor info, module, url, flag from the data JSON field. + """ + sender = serializers.SerializerMethodField() + module = serializers.SerializerMethodField() + url = serializers.SerializerMethodField() + flag = serializers.SerializerMethodField() + data = serializers.SerializerMethodField() + + class Meta: + model = Notification + fields = ( + "id", + "sender", + "verb", + "description", + "module", + "url", + "flag", + "data", + "unread", + "timestamp", + ) + read_only_fields = fields + + def get_sender(self, obj): + actor = obj.actor + if actor is None: + return None + return { + "id": actor.id, + "username": actor.username, + "full_name": actor.get_full_name(), + } + + def get_module(self, obj): + if obj.data and isinstance(obj.data, dict): + return obj.data.get("module", "") + return "" + + def get_url(self, obj): + if obj.data and isinstance(obj.data, dict): + return obj.data.get("url", "#") + return "#" + + def get_flag(self, obj): + if obj.data and isinstance(obj.data, dict): + return obj.data.get("flag", "") + return "" + + def get_data(self, obj): + if not isinstance(obj.data, dict): + return {} + out = dict(obj.data) + # Enrich announcement rows with expiry_date if missing (older rows + # didn't store it in the JSON blob — read it from the Announcement table) + if out.get("flag") == "announcement" and "expiry_date" not in out: + ann_id = out.get("announcement_id") + if ann_id is not None: + try: + ann = Announcement.objects.only("expiry_date").get(pk=ann_id) + out["expiry_date"] = ann.expiry_date.isoformat() + except Announcement.DoesNotExist: + pass + return out + + +class NotificationPreferenceSerializer(serializers.ModelSerializer): + """Read serializer for a user's module notification preference.""" + module_display = serializers.CharField(source="get_module_display", read_only=True) + + class Meta: + model = NotificationPreference + fields = ("id", "module", "module_display", "is_enabled", "updated_at") + read_only_fields = ("id", "module_display", "updated_at") + + +# ───────────────────────────────────────────── +# Input serializers +# ───────────────────────────────────────────── + +class SetPreferenceSerializer(serializers.Serializer): + """Input: set notification preference for a module.""" + module = serializers.ChoiceField(choices=ModuleName.choices) + is_enabled = serializers.BooleanField() + + +class MarkReadBySlugSerializer(serializers.Serializer): + """Input: mark a notification as read using its slug (from notifications_extension).""" + slug = serializers.CharField(max_length=64) + + def validate_slug(self, value): + if not value.isdigit(): + raise serializers.ValidationError("slug must be a numeric string.") + return value + + +class NotificationIdSerializer(serializers.Serializer): + """Input: single notification_id.""" + notification_id = serializers.IntegerField(min_value=1) + + +class SendNotificationSerializer(serializers.Serializer): + """ + Input: used by other modules to send a notification to a recipient. + The authenticated caller becomes the sender. + """ + recipient_username = serializers.CharField() + module = serializers.ChoiceField(choices=ModuleName.choices) + verb = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True, default="") + url = serializers.CharField(required=False, allow_blank=True, default="#") + priority = serializers.ChoiceField( + choices=EventPriority.choices, required=False, default="medium" + ) + + +# ───────────────────────────────────────────── +# Module-specific input serializers +# (used by other Fusion modules via REST API) +# ───────────────────────────────────────────── + +class _RecipientMixin(serializers.Serializer): + """Common recipient field for all module notification serializers.""" + recipient_username = serializers.CharField() + + +class LeaveNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/leave/""" + type = serializers.ChoiceField(choices=LeaveNotifType.choices) + date = serializers.CharField(required=False, allow_blank=True, default=None) + + +class MessNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/mess/""" + type = serializers.ChoiceField(choices=MessNotifType.choices) + message = serializers.CharField(required=False, allow_blank=True, default=None) + + +class HostelNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/hostel/""" + type = serializers.ChoiceField(choices=VisitorHostelNotifType.choices) + + +class HealthcareNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/healthcare/""" + type = serializers.ChoiceField(choices=HealthcareNotifType.choices) + + +class ScholarshipNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/scholarship/""" + type = serializers.ChoiceField(choices=ScholarshipNotifType.choices) + + +class DeanPnDNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/dean-pnd/""" + type = serializers.ChoiceField(choices=OfficeDeanPnDNotifType.choices) + + +class DeanStudentsNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/dean-students/""" + type = serializers.ChoiceField(choices=OfficeDeanSNotifType.choices) + + +class DeanRSPCNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/dean-rspc/""" + type = serializers.ChoiceField(choices=OfficeDeanRSPCNotifType.choices) + + +class GymkhanaVotingNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/gymkhana/voting/""" + title = serializers.CharField() + desc = serializers.CharField() + + +class GymkhanaSessionNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/gymkhana/session/""" + club = serializers.CharField() + desc = serializers.CharField() + venue = serializers.CharField() + + +class GymkhanaEventNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/gymkhana/event/""" + club = serializers.CharField() + event_name = serializers.CharField() + desc = serializers.CharField() + venue = serializers.CharField() + + +class ResearchNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/research/""" + type = serializers.ChoiceField(choices=ResearchProceduresNotifType.choices) + + +class AssistantshipClaimSerializer(_RecipientMixin): + """POST /api/notifications/notify/assistantship/claim-approved/""" + month = serializers.CharField() + year = serializers.CharField() + + +class AssistantshipForwardSerializer(_RecipientMixin): + """POST /api/notifications/notify/assistantship/forwarded/""" + student_username = serializers.CharField() + + +class ComplaintNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/complaint/""" + complaint_id = serializers.IntegerField() + is_student = serializers.BooleanField() + message = serializers.CharField() + + +class FileTrackingNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/file-tracking/""" + title = serializers.CharField() + + +class PlacementNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/placement/""" + message = serializers.CharField() + + +class AcademicsNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/academics/""" + message = serializers.CharField() + + +class DepartmentNotifSerializer(_RecipientMixin): + """POST /api/notifications/notify/department/""" + message = serializers.CharField() + + +# ───────────────────────────────────────────── +# UC-NT-01: Event Type Registry serializers +# ───────────────────────────────────────────── + +class NotificationEventTypeSerializer(serializers.ModelSerializer): + """Read serializer for a registered event type.""" + registered_by_username = serializers.SerializerMethodField() + + class Meta: + model = NotificationEventType + fields = ( + "id", "event_id", "event_name", "module", + "default_priority", "description", + "is_active", "registered_by_username", "created_at", + ) + read_only_fields = fields + + def get_registered_by_username(self, obj): + return obj.registered_by.username if obj.registered_by else None + + +class RegisterEventTypeSerializer(serializers.Serializer): + """Input: register a new notification event type (UC-NT-01).""" + event_name = serializers.CharField(max_length=100) + module = serializers.ChoiceField(choices=ModuleName.choices) + default_priority = serializers.ChoiceField( + choices=EventPriority.choices, default=EventPriority.MEDIUM + ) + description = serializers.CharField(required=False, allow_blank=True, default="") + + +class TriggerEventNotificationSerializer(serializers.Serializer): + """Input: trigger a notification via a registered event_id (UC-NT-02).""" + event_id = serializers.UUIDField() + recipient_username = serializers.CharField() + message_content = serializers.CharField() + deep_link = serializers.CharField(required=False, allow_blank=True, default="#") + + +# ───────────────────────────────────────────── +# UC-NT-03: Announcement serializers +# ───────────────────────────────────────────── + +class AnnouncementSerializer(serializers.ModelSerializer): + """Read serializer for an Announcement.""" + sender_username = serializers.SerializerMethodField() + is_active = serializers.SerializerMethodField() + class Meta: - model = Notification - fields = '__all__' \ No newline at end of file + model = Announcement + fields = ( + "id", "title", "message", + "sender_username", "audience_type", "audience_value", + "expiry_date", "created_at", "is_active", + "approval_id", + ) + read_only_fields = fields + + def get_sender_username(self, obj): + return obj.sender.username + + def get_is_active(self, obj): + return obj.is_active + + +class BroadcastAnnouncementSerializer(serializers.Serializer): + """Input: create and broadcast a manual announcement (UC-NT-03).""" + title = serializers.CharField(max_length=200) + message = serializers.CharField() + audience_type = serializers.ChoiceField(choices=AudienceType.choices) + audience_value = serializers.CharField(required=False, allow_blank=True, default="") + expiry_date = serializers.DateField() + priority = serializers.ChoiceField( + choices=EventPriority.choices, default=EventPriority.MEDIUM, required=False + ) + + def validate(self, attrs): + if attrs["audience_type"] == AudienceType.GROUP and not attrs.get("audience_value"): + raise serializers.ValidationError( + {"audience_value": "audience_value is required when audience_type is 'group'."} + ) + return attrs diff --git a/FusionIIIT/applications/notifications_extension/api/urls.py b/FusionIIIT/applications/notifications_extension/api/urls.py index 62238c007..52626c6a4 100644 --- a/FusionIIIT/applications/notifications_extension/api/urls.py +++ b/FusionIIIT/applications/notifications_extension/api/urls.py @@ -1,53 +1,98 @@ -# urls.py +""" +api/urls.py — All API URL routing for the notification module. +Each endpoint is registered both with and without trailing slash. +""" + from django.urls import path -from .views import ( - LeaveModuleNotificationAPIView, - PlacementCellNotificationAPIView, - AcademicsModuleNotificationAPIView, - OfficeModuleNotificationAPIView, - CentralMessNotificationAPIView, - VisitorsHostelNotificationAPIView, - HealthcareCenterNotificationAPIView, - FileTrackingNotificationAPIView, - ScholarshipPortalNotificationAPIView, - ComplaintSystemNotificationAPIView, - OfficeDeanPnDNotificationAPIView, - OfficeDeanSNotificationAPIView, - GymkhanaVotingNotificationAPIView, - GymkhanaSessionNotificationAPIView, - GymkhanaEventNotificationAPIView, - AssistantshipClaimNotificationAPIView, - DepartmentNotificationAPIView, - OfficeDeanRSPCNotificationAPIView, - ResearchProceduresNotificationAPIView, - HostelModuleNotificationAPIView, - MarkAsRead, - Delete, - NotificationsList, -) + +from . import views + +app_name = "notifications_api" + +def _both(route, view, name): + """Register a URL both with and without trailing slash.""" + return [ + path(route, view, name=name), + path(route.rstrip("/"), view, name=name + "-noslash"), + ] urlpatterns = [ - path('notifications/', NotificationsList.as_view(), name='notifications' ), - path('delete/', Delete.as_view(),name='delete'), - path('mark_as_read/', MarkAsRead.as_view(),name='mark_as_read'), - path('leave_module_notification/', LeaveModuleNotificationAPIView.as_view(), name='leave_module_notification'), - path('placement_cell_notification/', PlacementCellNotificationAPIView.as_view(), name='placement_cell_notification'), - path('academics_module_notification/', AcademicsModuleNotificationAPIView.as_view(), name='academics_module_notification'), - path('office_module_notification/', OfficeModuleNotificationAPIView.as_view(), name='office_module_notification'), - path('central_mess_notification/', CentralMessNotificationAPIView.as_view(), name='central_mess_notification'), - path('visitors_hostel_notification/', VisitorsHostelNotificationAPIView.as_view(), name='visitors_hostel_notification'), - path('healthcare_center_notification/', HealthcareCenterNotificationAPIView.as_view(), name='healthcare_center_notification'), - path('file_tracking_notification/', FileTrackingNotificationAPIView.as_view(), name='file_tracking_notification'), - path('scholarship_portal_notification/', ScholarshipPortalNotificationAPIView.as_view(), name='scholarship_portal_notification'), - path('complaint_system_notification/', ComplaintSystemNotificationAPIView.as_view(), name='complaint_system_notification'), - path('office_dean_PnD_notification/', OfficeDeanPnDNotificationAPIView.as_view(), name='office_dean_PnD_notification'), - path('office_dean_S_notification/', OfficeDeanSNotificationAPIView.as_view(), name='office_dean_S_notification'), - path('gymkhana_voting/', GymkhanaVotingNotificationAPIView.as_view(), name='gymkhana_voting'), - path('gymkhana_session/', GymkhanaSessionNotificationAPIView.as_view(), name='gymkhana_session'), - path('gymkhana_event/', GymkhanaEventNotificationAPIView.as_view(), name='gymkhana_event'), - path('assistantship_claim/', AssistantshipClaimNotificationAPIView.as_view(), name='assistantship_claim'), - path('department_notification/', DepartmentNotificationAPIView.as_view(), name='department_notification'), - path('office_dean_RSPC_notification/', OfficeDeanRSPCNotificationAPIView.as_view(), name='office_dean_RSPC_notification'), - path('research_procedures_notification/', ResearchProceduresNotificationAPIView.as_view(), name='research_procedures_notification'), - path('hostel_notifications/', HostelModuleNotificationAPIView.as_view(), name='hostel_notifications'), + + # ── Mark as read + redirect ─────────────────────────────────────────────── + path("mark-as-read-and-redirect//", views.mark_as_read_and_redirect, name="mark_as_read_and_redirect"), + path("mark-as-read-and-redirect/", views.mark_as_read_and_redirect, name="mark_as_read_and_redirect-noslash"), + + # ── List ────────────────────────────────────────────────────────────────── + path("", views.get_all_notifications, name="notification-list"), + *_both("unread/", views.get_unread_notifications, "notification-unread-list"), + *_both("unread-count/", views.get_unread_count, "notification-unread-count"), + + # ── Bulk actions ────────────────────────────────────────────────────────── + *_both("mark-all-read/", views.mark_all_as_read, "notification-mark-all-read"), + *_both("mark-all-unread/", views.mark_all_as_unread, "notification-mark-all-unread"), + *_both("delete-all/", views.delete_all_notifications, "notification-delete-all"), + + # ── Single-notification actions ─────────────────────────────────────────── + *_both("/mark-read/", views.mark_as_read, "notification-mark-read"), + *_both("/mark-unread/", views.mark_as_unread, "notification-mark-unread"), + *_both("/delete/", views.delete_notification, "notification-delete"), + *_both("/star/", views.toggle_star, "notification-star"), + + # ── Module-filtered ─────────────────────────────────────────────────────── + *_both("module//", views.get_notifications_by_module, "notification-by-module"), + + # ── Preferences (BR-NT-06: per-module opt-out) ─────────────────────────── + *_both("preferences/", views.get_preferences, "notification-preferences"), + *_both("preferences/set/", views.set_preference, "notification-preference-set"), + + # ── Generic send (from other modules) ──────────────────────────────────── + *_both("send/", views.receive_notification, "notification-send"), + *_both("send-group/", views.send_group_notification, "notification-send-group"), + + # ── UC-NT-01: Event Type Registry ──────────────────────────────────────── + *_both("event-types/register/", views.register_event_type, "event-type-register"), + *_both("event-types/", views.list_event_types, "event-type-list"), + path("event-types//", views.get_event_type, name="event-type-detail"), + path("event-types/", views.get_event_type, name="event-type-detail-noslash"), + + # ── UC-NT-02: Trigger via Event ID ─────────────────────────────────────── + *_both("trigger/", views.trigger_event_notification, "trigger-event"), + + # ── UC-NT-03: Announcements ─────────────────────────────────────────────── + *_both("announcements/", views.list_active_announcements, "announcement-list"), + *_both("announcements/all/", views.list_all_announcements, "announcement-list-all"), + *_both("announcements/broadcast/", views.broadcast_announcement, "announcement-broadcast"), + *_both("announcements/preview-audience/", views.preview_audience, "announcement-preview-audience"), + + # ── UC-NT-03: audience option lists for the dashboard broadcast modal ──── + *_both("audience/departments/", views.list_departments, "audience-departments"), + *_both("audience/designations/", views.list_designations, "audience-designations"), + *_both("audience/batches/", views.list_batches, "audience-batches"), + *_both("audience/users/", views.list_users, "audience-users"), + + # ── UC-NT-04: Deep link open (mark read + return URL) ──────────────────── + *_both("/open/", views.mark_read_return_url, "notification-open"), + + # ── Module-specific endpoints ───────────────────────────────────────────── + *_both("notify/leave/", views.notify_leave, "notify-leave"), + *_both("notify/mess/", views.notify_mess, "notify-mess"), + *_both("notify/hostel/", views.notify_hostel, "notify-hostel"), + *_both("notify/healthcare/", views.notify_healthcare, "notify-healthcare"), + *_both("notify/scholarship/", views.notify_scholarship, "notify-scholarship"), + *_both("notify/dean-pnd/", views.notify_dean_pnd, "notify-dean-pnd"), + *_both("notify/dean-students/", views.notify_dean_students, "notify-dean-students"), + *_both("notify/dean-rspc/", views.notify_dean_rspc, "notify-dean-rspc"), + *_both("notify/gymkhana/voting/", views.notify_gymkhana_voting, "notify-gymkhana-voting"), + *_both("notify/gymkhana/session/", views.notify_gymkhana_session, "notify-gymkhana-session"), + *_both("notify/gymkhana/event/", views.notify_gymkhana_event, "notify-gymkhana-event"), + *_both("notify/research/", views.notify_research, "notify-research"), + *_both("notify/assistantship/approved/", views.notify_assistantship_approved, "notify-assistantship-approved"), + *_both("notify/assistantship/faculty/", views.notify_assistantship_faculty, "notify-assistantship-faculty"), + *_both("notify/assistantship/acad/", views.notify_assistantship_acad, "notify-assistantship-acad"), + *_both("notify/assistantship/accounts/", views.notify_assistantship_accounts, "notify-assistantship-accounts"), + *_both("notify/complaint/", views.notify_complaint, "notify-complaint"), + *_both("notify/file-tracking/", views.notify_file_tracking, "notify-file-tracking"), + *_both("notify/placement/", views.notify_placement, "notify-placement"), + *_both("notify/academics/", views.notify_academics, "notify-academics"), + *_both("notify/department/", views.notify_department, "notify-department"), ] diff --git a/FusionIIIT/applications/notifications_extension/api/views.py b/FusionIIIT/applications/notifications_extension/api/views.py index c5017f79f..bc2ca27c3 100644 --- a/FusionIIIT/applications/notifications_extension/api/views.py +++ b/FusionIIIT/applications/notifications_extension/api/views.py @@ -1,371 +1,1100 @@ -# views.py -from rest_framework.views import APIView +""" +api/views.py — API endpoints for the notification module. + +Source mapping: + notifications_extension/views.py::mark_as_read_and_redirect → mark_as_read_and_redirect() + notification/views.py (all functions) → called via services.py + +Rules enforced: + - Thin views: validate input → call service/selector → return Response. + - NO .objects. calls. + - NO business logic. + - Every view has @api_view, @authentication_classes, @permission_classes. + - All responses use DRF Response. +""" + from django.contrib.auth import get_user_model -from rest_framework.response import Response -from notifications.utils import slug2id -from django.shortcuts import get_object_or_404 -from rest_framework.generics import ListAPIView -from notifications.models import Notification +from django.urls import reverse, NoReverseMatch +from django.http import HttpResponseRedirect from rest_framework import status -from .serializers import NotificationSerializer -from notification.views import (leave_module_notif, - placement_cell_notif, - academics_module_notif, - office_module_notif, - central_mess_notif, - visitors_hostel_notif, - healthcare_center_notif, - file_tracking_notif, - scholarship_portal_notif, - complaint_system_notif, - office_dean_PnD_notif, - office_module_DeanS_notif, - gymkhana_voting, - gymkhana_session, - gymkhana_event, - AssistantshipClaim_notify, - AssistantshipClaim_faculty_notify, - AssistantshipClaim_acad_notify, - AssistantshipClaim_account_notify, - department_notif, - office_module_DeanRSPC_notif, - research_procedures_notif, - hostel_notifications) - - - -class LeaveModuleNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - date = request.data.get('date') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - leave_module_notif(sender, recipient, type, date) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class PlacementCellNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - placement_cell_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class AcademicsModuleNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - academics_module_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class OfficeModuleNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - office_module_notif(sender, recipient) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) -class CentralMessNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - message = request.data.get('message') - - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - - # Trigger the notification function - central_mess_notif(sender, recipient, type, message) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class VisitorsHostelNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - visitors_hostel_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class HealthcareCenterNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - - # Trigger the notification function - healthcare_center_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class FileTrackingNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - title = request.data.get('title') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - file_tracking_notif(sender, recipient, title) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) -class ScholarshipPortalNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - scholarship_portal_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class ComplaintSystemNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - complaint_id = request.data.get('complaint_id') - student = request.data.get('student') - message = request.data.get('message') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - complaint_system_notif(sender, recipient, type, complaint_id, student, message) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class OfficeDeanPnDNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - office_dean_PnD_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class OfficeDeanSNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - office_module_DeanS_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class GymkhanaVotingNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - title = request.data.get('title') - desc = request.data.get('desc') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - gymkhana_voting(sender, recipient, type, title, desc) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class GymkhanaSessionNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - club = request.data.get('club') - desc = request.data.get('desc') - venue = request.data.get('venue') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - gymkhana_session(sender, recipient, type, club, desc, venue) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class GymkhanaEventNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - club = request.data.get('club') - event_name = request.data.get('event_name') - desc = request.data.get('desc') - venue = request.data.get('venue') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - gymkhana_event(sender, recipient, type, club, event_name, desc, venue) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class AssistantshipClaimNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - month = request.data.get('month') - year = request.data.get('year') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - AssistantshipClaim_notify(sender, recipient, month, year) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) -class AssistantshipClaimFacultyNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - AssistantshipClaim_faculty_notify(sender, recipient) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class AssistantshipClaimAcadNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - AssistantshipClaim_acad_notify(sender, recipient) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class AssistantshipClaimAccountNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - stu = request.data.get('stu') - recipient_id = request.data.get('recipient') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - AssistantshipClaim_account_notify(sender, stu, recipient) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class DepartmentNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - department_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) -class OfficeDeanRSPCNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - office_module_DeanRSPC_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class ResearchProceduresNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - research_procedures_notif(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class HostelModuleNotificationAPIView(APIView): - def post(self, request, *args, **kwargs): - # Extract data from the request, you can customize this based on your needs - sender = request.user - recipient_id = request.data.get('recipient') - type = request.data.get('type') - User = get_user_model() - recipient = User.objects.get(pk=recipient_id) - # Trigger the notification function - hostel_notifications(sender, recipient, type) - - return Response({'message': 'Notification sent successfully'}, status=status.HTTP_201_CREATED) - -class MarkAsRead(APIView): - - def put(self,request,**args): - notification_id = self.request.query_params.get('id') - notification = get_object_or_404( - Notification, recipient=request.user, id=notification_id) +from rest_framework.authentication import SessionAuthentication, TokenAuthentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAuthenticated, IsAdminUser +from rest_framework.response import Response + +from applications.notifications_extension import selectors, services +from applications.notifications_extension.services import ( + NotificationNotFound, + InvalidModuleName, + InvalidNotificationType, + EventTypeAlreadyExists, + UnauthorizedSender, + DuplicateNotification, +) + +from .serializers import ( + NotificationSerializer, + NotificationPreferenceSerializer, + SetPreferenceSerializer, + MarkReadBySlugSerializer, + NotificationIdSerializer, + SendNotificationSerializer, + LeaveNotifSerializer, + MessNotifSerializer, + HostelNotifSerializer, + HealthcareNotifSerializer, + ScholarshipNotifSerializer, + DeanPnDNotifSerializer, + DeanStudentsNotifSerializer, + DeanRSPCNotifSerializer, + GymkhanaVotingNotifSerializer, + GymkhanaSessionNotifSerializer, + GymkhanaEventNotifSerializer, + ResearchNotifSerializer, + AssistantshipClaimSerializer, + AssistantshipForwardSerializer, + ComplaintNotifSerializer, + FileTrackingNotifSerializer, + PlacementNotifSerializer, + AcademicsNotifSerializer, + DepartmentNotifSerializer, + # UC-NT-01 + NotificationEventTypeSerializer, + RegisterEventTypeSerializer, + TriggerEventNotificationSerializer, + # UC-NT-03 + AnnouncementSerializer, + BroadcastAnnouncementSerializer, +) + +_AUTH = [SessionAuthentication, TokenAuthentication] +_PERM = [IsAuthenticated] +# BR-NT-03: Only staff/admin can trigger module-specific notifications +_PERM_STAFF = [IsAuthenticated, IsAdminUser] + + +# ───────────────────────────────────────────── +# Helper +# ───────────────────────────────────────────── + +def _error(msg, http_status=status.HTTP_400_BAD_REQUEST): + return Response({"error": msg}, status=http_status) + + +# ───────────────────────────────────────────── +# 1. Mark as read and redirect +# Source: notifications_extension/views.py::mark_as_read_and_redirect +# ───────────────────────────────────────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def mark_as_read_and_redirect(request, slug): + """ + GET /api/notifications/mark-as-read-and-redirect// + + Marks a notification as read and redirects the user to the relevant page. + Source: notifications_extension/views.py::mark_as_read_and_redirect + """ + try: + redirect_info = services.mark_as_read_and_get_redirect_url( + slug=slug, user=request.user + ) + except NotificationNotFound as exc: + return _error(str(exc), status.HTTP_404_NOT_FOUND) + + url_name = redirect_info["url_name"] + kwargs = redirect_info["kwargs"] + + try: + redirect_url = reverse(url_name, kwargs=kwargs) if kwargs else reverse(url_name) + except NoReverseMatch: + return _error(f"Cannot resolve URL for '{url_name}'.", status.HTTP_400_BAD_REQUEST) + + return HttpResponseRedirect(redirect_url) + + +# ───────────────────────────────────────────── +# 2. List all notifications +# ───────────────────────────────────────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def get_all_notifications(request): + """ + GET /api/notifications/?page=1&page_size=50 + Paginated to avoid loading thousands of rows into memory at once. + """ + try: + page = max(int(request.query_params.get("page", 1)), 1) + page_size = min(max(int(request.query_params.get("page_size", 50)), 1), 200) + except (TypeError, ValueError): + return _error("page and page_size must be integers.") + + qs = selectors.get_all_notifications_for_user(request.user) + total = qs.count() + start = (page - 1) * page_size + end = start + page_size + serializer = NotificationSerializer(qs[start:end], many=True) + return Response({ + "notifications": serializer.data, + "pagination": { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": (total + page_size - 1) // page_size, + }, + }, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 3. List unread notifications +# ───────────────────────────────────────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def get_unread_notifications(request): + """GET /api/notifications/unread/""" + notifications = selectors.get_unread_notifications_for_user(request.user) + serializer = NotificationSerializer(notifications, many=True) + return Response( + {"unread_count": notifications.count(), "notifications": serializer.data}, + status=status.HTTP_200_OK, + ) + + +# ───────────────────────────────────────────── +# 4. Unread count +# ───────────────────────────────────────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def get_unread_count(request): + """GET /api/notifications/unread-count/""" + count = selectors.get_unread_count_for_user(request.user) + return Response({"unread_count": count}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 5. Filter by module +# ───────────────────────────────────────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def get_notifications_by_module(request, module): + """GET /api/notifications/module//""" + notifications = selectors.get_notifications_by_module(request.user, module) + serializer = NotificationSerializer(notifications, many=True) + return Response({"notifications": serializer.data}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 6. Mark single notification as read +# ───────────────────────────────────────────── + +@api_view(["PATCH"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def mark_as_read(request, notification_id): + """PATCH /api/notifications//mark-read/""" + try: + notification = services.mark_notification_as_read( + notification_id=notification_id, user=request.user + ) + except NotificationNotFound as exc: + return _error(str(exc), status.HTTP_404_NOT_FOUND) + + return Response( + {"message": "Notification marked as read.", "id": notification.id}, + status=status.HTTP_200_OK, + ) + + +# ───────────────────────────────────────────── +# 7. Mark single notification as unread +# ───────────────────────────────────────────── + +@api_view(["PATCH"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def mark_as_unread(request, notification_id): + """PATCH /api/notifications//mark-unread/""" + try: + notification = services.mark_notification_as_unread( + notification_id=notification_id, user=request.user + ) + except NotificationNotFound as exc: + return _error(str(exc), status.HTTP_404_NOT_FOUND) + + return Response( + {"message": "Notification marked as unread.", "id": notification.id}, + status=status.HTTP_200_OK, + ) + + +# ───────────────────────────────────────────── +# 8. Mark ALL as read +# ───────────────────────────────────────────── + +@api_view(["PATCH"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def mark_all_as_read(request): + """PATCH /api/notifications/mark-all-read/""" + services.mark_all_notifications_as_read(user=request.user) + return Response({"message": "All notifications marked as read."}, status=status.HTTP_200_OK) + + +@api_view(["PATCH"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def mark_all_as_unread(request): + """PATCH /api/notifications/mark-all-unread/""" + services.mark_all_notifications_as_unread(user=request.user) + return Response({"message": "All notifications marked as unread."}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 9. Delete single notification +# ───────────────────────────────────────────── + +@api_view(["PATCH"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def toggle_star(request, notification_id): + """ + PATCH /api/notifications//star/ + Flips the boolean `starred` flag inside the notification's data JSON. + Returns the new value so the UI can update without re-fetching the list. + """ + from notifications.models import Notification + try: + n = Notification.objects.get(pk=notification_id, recipient=request.user) + except Notification.DoesNotExist: + return _error("Notification not found.", status.HTTP_404_NOT_FOUND) + data = n.data if isinstance(n.data, dict) else {} + data["starred"] = not bool(data.get("starred")) + n.data = data + n.save(update_fields=["data"]) + return Response({"id": n.id, "starred": data["starred"]}) + + +@api_view(["DELETE"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def delete_notification(request, notification_id): + """DELETE /api/notifications//delete/""" + try: + services.delete_notification( + notification_id=notification_id, user=request.user + ) + except NotificationNotFound as exc: + return _error(str(exc), status.HTTP_404_NOT_FOUND) + + return Response({"message": "Notification deleted."}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 10. Delete ALL notifications +# ───────────────────────────────────────────── + +@api_view(["DELETE"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def delete_all_notifications(request): + """DELETE /api/notifications/delete-all/""" + services.delete_all_notifications(user=request.user) + return Response({"message": "All notifications deleted."}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 11. Get preferences +# ───────────────────────────────────────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def get_preferences(request): + """GET /api/notifications/preferences/""" + services.initialize_default_preferences(user=request.user) + preferences = selectors.get_all_preferences_for_user(request.user) + serializer = NotificationPreferenceSerializer(preferences, many=True) + return Response({"preferences": serializer.data}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 12. Set preference +# ───────────────────────────────────────────── + +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def set_preference(request): + """ + POST /api/notifications/preferences/set/ + Body: { "module": "", "is_enabled": true/false } + """ + serializer = SetPreferenceSerializer(data=request.data) + if not serializer.is_valid(): + return _error(serializer.errors) + + try: + pref = services.set_notification_preference( + user=request.user, + module=serializer.validated_data["module"], + is_enabled=serializer.validated_data["is_enabled"], + ) + except InvalidModuleName as exc: + return _error(str(exc)) + + out = NotificationPreferenceSerializer(pref) + return Response({"preference": out.data}, status=status.HTTP_200_OK) + + +# ───────────────────────────────────────────── +# 13. Receive a notification from another module +# ───────────────────────────────────────────── + +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def receive_notification(request): + """ + POST /api/notifications/send/ + + Called by other Fusion modules to push a notification to a user. + Only staff/admin users (module backends) can call this endpoint. + + Body: + { + "recipient_username": "", + "module": "", + "verb": "", + "description": "", + "url": "", + "priority": "low|medium|high|critical" + } + """ + serializer = SendNotificationSerializer(data=request.data) + if not serializer.is_valid(): + return _error(serializer.errors) + + data = serializer.validated_data + + User = get_user_model() + try: + recipient = User.objects.get(username=data["recipient_username"]) + except User.DoesNotExist: + return _error( + f"User '{data['recipient_username']}' not found.", + status.HTTP_404_NOT_FOUND, + ) + + try: + services.send_notification_from_module( + sender=request.user, + recipient=recipient, + module=data["module"], + verb=data["verb"], + description=data["description"], + url=data["url"], + priority=data.get("priority", "medium"), + ) + except InvalidModuleName as exc: + return _error(str(exc)) + + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def send_group_notification(request): + """ + POST /api/notifications/send-group/ + + Send notification to a group of users (staff/admin only). + + Body: + { + "audience": "all|students|faculty|staff|group|department|batch", + "audience_value": "", + "module": "", + "verb": "", + "description": "", + "priority": "low|medium|high|critical" + } + """ + import logging + logger = logging.getLogger(__name__) + + audience = request.data.get("audience", "").lower().strip() + audience_value = request.data.get("audience_value", "").strip() + module = request.data.get("module", "") + verb = request.data.get("verb", "") + description = request.data.get("description", "") + priority = request.data.get("priority", "medium") + + if not audience or not module or not verb: + return _error("audience, module, and verb are required.") + + from applications.notifications_extension.models import ModuleName, AudienceType + valid_modules = {choice[0] for choice in ModuleName.choices} + if module not in valid_modules: + return _error(f"'{module}' is not a valid module.") + + # Map legacy names to AudienceType values + audience_map = { + "all_students": AudienceType.STUDENTS, + "all_faculty": AudienceType.FACULTY, + "all_staff": AudienceType.STAFF, + } + audience = audience_map.get(audience, audience) + + valid_audiences = {choice[0] for choice in AudienceType.choices} + if audience not in valid_audiences: + return _error(f"Invalid audience '{audience}'. Must be one of: {sorted(valid_audiences)}") + + if audience in (AudienceType.GROUP, AudienceType.DEPARTMENT, AudienceType.BATCH) and not audience_value: + return _error(f"audience_value is required for '{audience}' audience.") + + from django.db import transaction + recipients = services._resolve_audience(audience, audience_value) + total = recipients.count() + + count = 0 + failed = 0 + # Use atomic block so partial failures roll back cleanly instead of leaving + # a half-sent group. Individual recipient errors are caught and counted. + with transaction.atomic(): + for recipient in recipients.iterator(chunk_size=500): + try: + services.send_notification_from_module( + sender=request.user, recipient=recipient, + module=module, verb=verb, description=description, + priority=priority, + ) + count += 1 + except Exception as exc: + failed += 1 + logger.warning("group.send.fail user=%s audience=%s err=%s", + recipient.username, audience, exc) + + logger.info("group.send.done audience=%s value=%s delivered=%s failed=%s total=%s", + audience, audience_value, count, failed, total) + return Response( + {"message": f"Notification sent to {count} of {total} users.", + "delivered": count, "failed": failed, "total": total}, + status=status.HTTP_201_CREATED, + ) + + +# ═════════════════════════════════════════════ +# MODULE-SPECIFIC NOTIFICATION ENDPOINTS +# Called by other Fusion modules via REST API +# ═════════════════════════════════════════════ + +User = get_user_model() + + +def _resolve_recipient(username): + """Return (user, error_response) — one of the two will be None.""" + try: + return User.objects.get(username=username), None + except User.DoesNotExist: + return None, _error(f"User '{username}' not found.", status.HTTP_404_NOT_FOUND) + + +def _module_view(serializer_cls, handler): + """ + Factory that builds a standard module-notification view: + - validates input with serializer_cls + - resolves recipient + - calls handler(sender, recipient, data) + BR-NT-03: Only staff/admin users can trigger module notifications. + """ + @api_view(["POST"]) + @authentication_classes(_AUTH) + @permission_classes(_PERM_STAFF) + def view(request): + ser = serializer_cls(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + try: + handler(request.user, recipient, data) + except Exception as exc: + return _error(str(exc)) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + return view + + +# ── Leave Module ────────────────────────────────────────────────────────────── +def _leave_handler(sender, recipient, data): + services.leave_module_notif( + sender=sender, recipient=recipient, + type=data["type"], date=data.get("date") + ) +notify_leave = _module_view(LeaveNotifSerializer, _leave_handler) + + +# ── Central Mess ────────────────────────────────────────────────────────────── +def _mess_handler(sender, recipient, data): + services.central_mess_notif( + sender=sender, recipient=recipient, + type=data["type"], message=data.get("message") + ) + +notify_mess = _module_view(MessNotifSerializer, _mess_handler) + + +# ── Visitor's Hostel ────────────────────────────────────────────────────────── +def _hostel_handler(sender, recipient, data): + services.visitors_hostel_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_hostel = _module_view(HostelNotifSerializer, _hostel_handler) + + +# ── Healthcare Center ───────────────────────────────────────────────────────── +def _healthcare_handler(sender, recipient, data): + services.healthcare_center_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_healthcare = _module_view(HealthcareNotifSerializer, _healthcare_handler) + + +# ── Scholarship Portal ──────────────────────────────────────────────────────── +def _scholarship_handler(sender, recipient, data): + services.scholarship_portal_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_scholarship = _module_view(ScholarshipNotifSerializer, _scholarship_handler) + + +# ── Office of Dean PnD ──────────────────────────────────────────────────────── +def _dean_pnd_handler(sender, recipient, data): + services.office_dean_pnd_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_dean_pnd = _module_view(DeanPnDNotifSerializer, _dean_pnd_handler) + + +# ── Office Module — Dean Students ───────────────────────────────────────────── +def _dean_students_handler(sender, recipient, data): + services.office_module_deans_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_dean_students = _module_view(DeanStudentsNotifSerializer, _dean_students_handler) + + +# ── Office Module — Dean RSPC ───────────────────────────────────────────────── +def _dean_rspc_handler(sender, recipient, data): + services.office_module_dean_rspc_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_dean_rspc = _module_view(DeanRSPCNotifSerializer, _dean_rspc_handler) + + +# ── Gymkhana — Voting ───────────────────────────────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_gymkhana_voting(request): + """POST /api/notifications/notify/gymkhana/voting/""" + ser = GymkhanaVotingNotifSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + services.gymkhana_voting_notif( + sender=request.user, recipient=recipient, + type="voting_open", title=data["title"], desc=data["desc"] + ) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Gymkhana — Session ──────────────────────────────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_gymkhana_session(request): + """POST /api/notifications/notify/gymkhana/session/""" + ser = GymkhanaSessionNotifSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + services.gymkhana_session_notif( + sender=request.user, recipient=recipient, + type="new_session", club=data["club"], + desc=data["desc"], venue=data["venue"] + ) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Gymkhana — Event ────────────────────────────────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_gymkhana_event(request): + """POST /api/notifications/notify/gymkhana/event/""" + ser = GymkhanaEventNotifSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + services.gymkhana_event_notif( + sender=request.user, recipient=recipient, + type="new_event", club=data["club"], + event_name=data["event_name"], desc=data["desc"], venue=data["venue"] + ) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Research Procedures ─────────────────────────────────────────────────────── +def _research_handler(sender, recipient, data): + services.research_procedures_notif( + sender=sender, recipient=recipient, type=data["type"] + ) + +notify_research = _module_view(ResearchNotifSerializer, _research_handler) + + +# ── Assistantship — Claim Approved (student) ────────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_assistantship_approved(request): + """POST /api/notifications/notify/assistantship/approved/""" + ser = AssistantshipClaimSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + services.assistantship_claim_notify( + sender=request.user, recipient=recipient, + month=data["month"], year=data["year"] + ) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Assistantship — Faculty notified ───────────────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_assistantship_faculty(request): + """POST /api/notifications/notify/assistantship/faculty/""" + from .serializers import _RecipientMixin + class S(_RecipientMixin): pass + ser = S(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + recipient, err = _resolve_recipient(ser.validated_data["recipient_username"]) + if err: + return err + services.assistantship_claim_faculty_notify(sender=request.user, recipient=recipient) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Assistantship — Academic section notified ───────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_assistantship_acad(request): + """POST /api/notifications/notify/assistantship/acad/""" + from .serializers import _RecipientMixin + class S(_RecipientMixin): pass + ser = S(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + recipient, err = _resolve_recipient(ser.validated_data["recipient_username"]) + if err: + return err + services.assistantship_claim_acad_notify(sender=request.user, recipient=recipient) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Assistantship — Accounts section notified ──────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_assistantship_accounts(request): + """POST /api/notifications/notify/assistantship/accounts/""" + ser = AssistantshipForwardSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + services.assistantship_claim_account_notify( + sender=request.user, recipient=recipient, + stu=data["student_username"] + ) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── Complaint System ────────────────────────────────────────────────────────── +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def notify_complaint(request): + """POST /api/notifications/notify/complaint/""" + ser = ComplaintNotifSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + recipient, err = _resolve_recipient(data["recipient_username"]) + if err: + return err + services.complaint_system_notif( + sender=request.user, recipient=recipient, + type="complaint", complaint_id=data["complaint_id"], + is_student=data["is_student"], message=data["message"] + ) + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +# ── File Tracking ───────────────────────────────────────────────────────────── +def _file_tracking_handler(sender, recipient, data): + services.file_tracking_notif( + sender=sender, recipient=recipient, title=data["title"] + ) + +notify_file_tracking = _module_view(FileTrackingNotifSerializer, _file_tracking_handler) + + +# ── Placement Cell ──────────────────────────────────────────────────────────── +def _placement_handler(sender, recipient, data): + services.placement_cell_notif( + sender=sender, recipient=recipient, type=data["message"] + ) + +notify_placement = _module_view(PlacementNotifSerializer, _placement_handler) + + +# ── Academics Module ────────────────────────────────────────────────────────── +def _academics_handler(sender, recipient, data): + services.academics_module_notif( + sender=sender, recipient=recipient, type=data["message"] + ) + +notify_academics = _module_view(AcademicsNotifSerializer, _academics_handler) + + +# ── Department ──────────────────────────────────────────────────────────────── +def _department_handler(sender, recipient, data): + services.department_notif( + sender=sender, recipient=recipient, type=data["message"] + ) + +notify_department = _module_view(DepartmentNotifSerializer, _department_handler) + + +# ═════════════════════════════════════════════ +# UC-NT-01: Event Type Registry +# ═════════════════════════════════════════════ + +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def register_event_type(request): + """ + POST /api/notifications/event-types/register/ + + UC-NT-01: External module registers a new notification event type with NAM. + Only staff/admin can register event types (BR-NT-03). + + Body: { "event_name": "...", "module": "...", "default_priority": "...", "description": "..." } + """ + ser = RegisterEventTypeSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + try: + event_type = services.register_event_type( + registered_by=request.user, + event_name=data["event_name"], + module=data["module"], + default_priority=data["default_priority"], + description=data["description"], + ) + except (InvalidModuleName, EventTypeAlreadyExists) as exc: + return _error(str(exc)) + out = NotificationEventTypeSerializer(event_type) + return Response({"event_type": out.data}, status=status.HTTP_201_CREATED) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_event_types(request): + """ + GET /api/notifications/event-types/ + Returns all registered event types (active only by default). + Pass ?all=true to include inactive ones. + """ + if request.query_params.get("all") == "true": + event_types = selectors.get_all_event_types() + else: + event_types = selectors.get_active_event_types() + ser = NotificationEventTypeSerializer(event_types, many=True) + return Response({"event_types": ser.data}, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def get_event_type(request, event_id): + """ + GET /api/notifications/event-types// + Returns a single registered event type by its UUID event_id. + """ + event_type = selectors.get_event_type_by_event_id(event_id) + if event_type is None: + return _error(f"Event type '{event_id}' not found.", status.HTTP_404_NOT_FOUND) + ser = NotificationEventTypeSerializer(event_type) + return Response({"event_type": ser.data}, status=status.HTTP_200_OK) + + +# ═════════════════════════════════════════════ +# UC-NT-02: Trigger notification via Event ID +# ═════════════════════════════════════════════ + +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM_STAFF) +def trigger_event_notification(request): + """ + POST /api/notifications/trigger/ + + UC-NT-02: External module calls NAM with Event_ID + User_ID + Message_Content + Deep_Link. + Only staff/admin (module backends) can trigger notifications (BR-NT-03). + NAM resolves preferences, records the notification, pushes to Navbar Tray. + + Body: { "event_id": "", "recipient_username": "...", "message_content": "...", "deep_link": "..." } + """ + ser = TriggerEventNotificationSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + + try: + recipient = User.objects.get(username=data["recipient_username"]) + except User.DoesNotExist: + return _error(f"User '{data['recipient_username']}' not found.", status.HTTP_404_NOT_FOUND) + + try: + services.trigger_notification_by_event_id( + event_id=str(data["event_id"]), + sender=request.user, + recipient=recipient, + message_content=data["message_content"], + deep_link=data["deep_link"], + ) + except NotificationNotFound as exc: + return _error(str(exc), status.HTTP_404_NOT_FOUND) + except DuplicateNotification as exc: + return _error(str(exc), status.HTTP_429_TOO_MANY_REQUESTS) + + return Response({"message": "Notification sent."}, status=status.HTTP_201_CREATED) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def mark_read_return_url(request, notification_id): + """ + GET /api/notifications//open/ + + UC-NT-04 deep-link support: marks the notification as read and returns + the stored deep_link URL as JSON so the SPA can navigate client-side. + + Returns: { "url": "/some/path" } + """ + from applications.notifications_extension.selectors import get_notification_by_id + notification = get_notification_by_id(notification_id, request.user) + if notification is None: + return _error("Notification not found.", status.HTTP_404_NOT_FOUND) + if notification.unread: notification.mark_as_read() + url = "#" + if notification.data and isinstance(notification.data, dict): + url = notification.data.get("url", "#") + return Response({"url": url}, status=status.HTTP_200_OK) + + +# ═════════════════════════════════════════════ +# UC-NT-03: Announcements (Manual Broadcast) +# ═════════════════════════════════════════════ + +@api_view(["POST"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def broadcast_announcement(request): + """ + POST /api/notifications/announcements/ + + UC-NT-03: Admin creates a broadcast announcement. + NAM resolves audience from Fusion RBAC groups and publishes to all dashboards. + + Body: { "title": "...", "message": "...", "audience_type": "...", + "audience_value": "...", "expiry_date": "YYYY-MM-DD" } + """ + ser = BroadcastAnnouncementSerializer(data=request.data) + if not ser.is_valid(): + return _error(ser.errors) + data = ser.validated_data + try: + announcement = services.broadcast_announcement( + sender=request.user, + title=data["title"], + message=data["message"], + audience_type=data["audience_type"], + audience_value=data["audience_value"], + expiry_date=data["expiry_date"], + priority=data.get("priority", "medium"), + ) + except UnauthorizedSender as exc: + return _error(str(exc), status.HTTP_403_FORBIDDEN) + except InvalidModuleName as exc: + return _error(str(exc)) + out = AnnouncementSerializer(announcement) + return Response({"announcement": out.data}, status=status.HTTP_201_CREATED) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def preview_audience(request): + """ + GET /api/notifications/announcements/preview-audience/ + ?audience_type=students&audience_value= + + Returns list of usernames that would receive a broadcast for the given audience. + """ + from applications.notifications_extension.services import _resolve_audience + audience_type = request.query_params.get("audience_type", "all") + audience_value = request.query_params.get("audience_value", "") + try: + users = _resolve_audience(audience_type, audience_value) + return Response({ + "count": users.count(), + "users": list(users.values_list("username", flat=True)), + }) + except Exception as exc: + return Response({"count": 0, "users": [], "error": str(exc)}) + + +# ── Audience option lists (powers UC-NT-03 dashboard modal dropdowns) ────────── + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_departments(request): + """GET /api/notifications/audience/departments/ — names of every department.""" + from applications.globals.models import DepartmentInfo + names = list(DepartmentInfo.objects.order_by("name").values_list("name", flat=True)) + return Response({"departments": names}) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_designations(request): + """GET /api/notifications/audience/designations/ — names of every designation.""" + from applications.globals.models import Designation + names = list(Designation.objects.order_by("name").values_list("name", flat=True)) + return Response({"designations": names}) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_users(request): + """ + GET /api/notifications/audience/users/ + Active users as [{value, label}] pairs the Mantine Select can consume directly. + """ + from django.contrib.auth import get_user_model + User = get_user_model() + users = User.objects.filter(is_active=True).only( + "username", "first_name", "last_name" + ).order_by("username") + out = [] + for u in users: + full = f"{u.first_name} {u.last_name}".strip() + out.append({"value": u.username, "label": f"{u.username} — {full}" if full else u.username}) + return Response({"users": out}) + + +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_batches(request): + """ + GET /api/notifications/audience/batches/ + Distinct batch prefixes derived from usernames matching ^\\d{2}[A-Z]{3}. + """ + import re + from django.contrib.auth import get_user_model + User = get_user_model() + seen = set() + for u in User.objects.values_list("username", flat=True): + if not u: + continue + m = re.match(r"^(\d{2}[A-Za-z]{3})", u) + if m: + seen.add(m.group(1).upper()) + return Response({"batches": sorted(seen)}) + - return Response({'message': "Successfully marked as read"}, status=status.HTTP_200_OK) - -class Delete(APIView): +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_active_announcements(request): + """ + GET /api/notifications/announcements/ - def delete(self,request, **args): - notification_id = self.request.query_params.get('id') - notification = get_object_or_404( - Notification, recipient=request.user, id=notification_id) + UC-NT-03 Post-condition: returns all announcements that haven't expired yet. + Shown on user dashboards until the expiry date. + """ + announcements = selectors.get_active_announcements() + ser = AnnouncementSerializer(announcements, many=True) + return Response({"announcements": ser.data}, status=status.HTTP_200_OK) - notification.delete() - return Response({'message': "Notification deleted succesfully"}, status=status.HTTP_200_OK) - -class NotificationsList(ListAPIView): - # queryset = Notification.objects.all(actor_object_id=) - serializer_class = NotificationSerializer - def get_queryset(self): - return Notification.objects.all().filter(recipient_id=self.request.user.id) \ No newline at end of file +@api_view(["GET"]) +@authentication_classes(_AUTH) +@permission_classes(_PERM) +def list_all_announcements(request): + """ + GET /api/notifications/announcements/all/ + Admin view — returns all announcements including expired ones. + """ + announcements = selectors.get_all_announcements() + ser = AnnouncementSerializer(announcements, many=True) + return Response({"announcements": ser.data}, status=status.HTTP_200_OK)