From 298501b97360dbee7c6df731ad75e8a25cb0a9d8 Mon Sep 17 00:00:00 2001 From: Keannu Bernasol Date: Tue, 21 Jan 2025 13:57:31 +0800 Subject: [PATCH] Implement notifications --- docmanager_backend/api/urls.py | 1 + .../authorization_requests/serializers.py | 23 +++++++ .../management/commands/start_watcher.py | 16 +++++ docmanager_backend/config/settings.py | 3 +- .../document_requests/serializers.py | 16 +++++ docmanager_backend/notifications/__init__.py | 0 docmanager_backend/notifications/admin.py | 16 +++++ docmanager_backend/notifications/apps.py | 11 ++++ .../notifications/migrations/0001_initial.py | 60 +++++++++++++++++++ .../migrations/0002_notification_type.py | 22 +++++++ .../notifications/migrations/__init__.py | 0 docmanager_backend/notifications/models.py | 27 +++++++++ .../notifications/serializers.py | 23 +++++++ docmanager_backend/notifications/signals.py | 14 +++++ docmanager_backend/notifications/urls.py | 10 ++++ docmanager_backend/notifications/views.py | 52 ++++++++++++++++ 16 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 docmanager_backend/notifications/__init__.py create mode 100644 docmanager_backend/notifications/admin.py create mode 100644 docmanager_backend/notifications/apps.py create mode 100644 docmanager_backend/notifications/migrations/0001_initial.py create mode 100644 docmanager_backend/notifications/migrations/0002_notification_type.py create mode 100644 docmanager_backend/notifications/migrations/__init__.py create mode 100644 docmanager_backend/notifications/models.py create mode 100644 docmanager_backend/notifications/serializers.py create mode 100644 docmanager_backend/notifications/signals.py create mode 100644 docmanager_backend/notifications/urls.py create mode 100644 docmanager_backend/notifications/views.py diff --git a/docmanager_backend/api/urls.py b/docmanager_backend/api/urls.py index 9f68925..7795e1f 100644 --- a/docmanager_backend/api/urls.py +++ b/docmanager_backend/api/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path("requests/", include("document_requests.urls")), path("authorization_requests/", include("authorization_requests.urls")), path("questionnaires/", include("questionnaires.urls")), + path("notifications/", include("notifications.urls")), path("admin/", admin.site.urls), path("schema/", SpectacularAPIView.as_view(), name="schema"), path( diff --git a/docmanager_backend/authorization_requests/serializers.py b/docmanager_backend/authorization_requests/serializers.py index c9eb909..839592d 100644 --- a/docmanager_backend/authorization_requests/serializers.py +++ b/docmanager_backend/authorization_requests/serializers.py @@ -2,6 +2,7 @@ from rest_framework import serializers from accounts.models import CustomUser from emails.templates import RequestUpdateEmail from .models import AuthorizationRequest, AuthorizationRequestUnit +from notifications.models import Notification class AuthorizationRequestUnitCreationSerializer(serializers.ModelSerializer): @@ -61,6 +62,11 @@ class AuthorizationRequestCreationSerializer(serializers.ModelSerializer): AUTHORIZATION_REQUEST.documents.set(AUTHORIZATION_REQUEST_UNITS) AUTHORIZATION_REQUEST.save() + Notification.objects.create( + type="info", + audience="head", + content=f"A new authorization request ID:{AUTHORIZATION_REQUEST.id} requires your attention") + return AUTHORIZATION_REQUEST @@ -148,7 +154,14 @@ class AuthorizationRequestUnitUpdateSerializer(serializers.ModelSerializer): # And send an email notification email = RequestUpdateEmail() email.context = {"request_status": "approved"} + email.context = {"remarks": "N/A"} email.send(to=[instance.authorization_request.requester.email]) + + Notification.objects.create( + client=instance.authorization_request.requester, + type="info", + audience="client", + content=f"Your authorization request ID:{instance.authorization_request.id} has been approved") return representation @@ -199,9 +212,19 @@ class AuthorizationRequestUpdateSerializer(serializers.ModelSerializer): if validated_data["status"] == "denied": email.context = {"request_status": "denied"} email.context = {"remarks": validated_data["remarks"]} + Notification.objects.create( + client=instance.requester, + type="info", + audience="client", + content=f"Your authorization request ID:{instance.id} has been denied") else: email.context = {"request_status": "approved"} email.context = {"remarks": "N/A"} + Notification.objects.create( + client=instance.requester, + type="info", + audience="client", + content=f"Your authorization request ID:{instance.id} has been approved") email.send(to=[instance.requester.email]) except Exception as e: # Silence out errors if email sending fails diff --git a/docmanager_backend/config/management/commands/start_watcher.py b/docmanager_backend/config/management/commands/start_watcher.py index 914b7ff..9fcfda4 100644 --- a/docmanager_backend/config/management/commands/start_watcher.py +++ b/docmanager_backend/config/management/commands/start_watcher.py @@ -20,6 +20,7 @@ from ollama import Client from pydantic import BaseModel from datetime import date from typing import Optional +from notifications.models import Notification class PDFHandler(FileSystemEventHandler): @@ -223,6 +224,7 @@ class PDFHandler(FileSystemEventHandler): # If that fails, just use regular OCR read the title as a dirty fix/fallback except Exception as e: + document_subject = "placeholder_document_name" document_type = "other" sent_from = "N/A" document_month = "no_month" @@ -232,6 +234,11 @@ class PDFHandler(FileSystemEventHandler): self.logger.warning( "Ollama OCR offload failed. Using defaults for missing values") + Notification.objects.create( + type="warning", + audience="staff", + content=f"Ollama OCR failed for document {document_subject}. Please check if the Ollama API is reachable.") + metadata += text # Open the file for instance creation @@ -257,9 +264,18 @@ class PDFHandler(FileSystemEventHandler): document_type}'. sent_from: {sent_from}, document_month: {document_month}, document_year: {document_year}" ) + Notification.objects.create( + type="info", + audience="staff", + content=f"New Document Scanned: {document_subject}.") + else: self.logger.info( f"Document '{document_subject}' already exists.") + Notification.objects.create( + type="info", + audience="staff", + content=f"Skipping Scanned Document {document_subject}: Already exists.") os.remove(file_path) except Exception as e: diff --git a/docmanager_backend/config/settings.py b/docmanager_backend/config/settings.py index 62b59d6..724d593 100644 --- a/docmanager_backend/config/settings.py +++ b/docmanager_backend/config/settings.py @@ -98,6 +98,7 @@ INSTALLED_APPS = [ "document_requests", "authorization_requests", "questionnaires", + "notifications", "django_cleanup.apps.CleanupConfig", ] @@ -267,7 +268,7 @@ AUTH_USER_MODEL = "accounts.CustomUser" DATA_UPLOAD_MAX_NUMBER_FIELDS = 20480 GRAPH_MODELS = { - "app_labels": ["accounts", "documents", "document_requests", "questionnaires", "authorization_requests"] + "app_labels": ["accounts", "documents", "document_requests", "questionnaires", "authorization_requests", "notifications"] } CORS_ORIGIN_ALLOW_ALL = True diff --git a/docmanager_backend/document_requests/serializers.py b/docmanager_backend/document_requests/serializers.py index 18f4e70..35208f7 100644 --- a/docmanager_backend/document_requests/serializers.py +++ b/docmanager_backend/document_requests/serializers.py @@ -5,6 +5,7 @@ from questionnaires.models import Questionnaire from accounts.models import CustomUser from emails.templates import RequestUpdateEmail from .models import DocumentRequest, DocumentRequestUnit +from notifications.models import Notification class DocumentRequestUnitCreationSerializer(serializers.ModelSerializer): @@ -60,6 +61,11 @@ class DocumentRequestCreationSerializer(serializers.ModelSerializer): DOCUMENT_REQUEST.documents.set(DOCUMENT_REQUEST_UNITS) DOCUMENT_REQUEST.save() + Notification.objects.create( + type="info", + audience="head", + content=f"A new document request ID:{DOCUMENT_REQUEST.id} requires your attention") + return DOCUMENT_REQUEST @@ -218,9 +224,19 @@ class DocumentRequestUpdateSerializer(serializers.ModelSerializer): if validated_data["status"] == "denied": email.context = {"request_status": "denied"} email.context = {"remarks": validated_data["remarks"]} + Notification.objects.create( + client=instance.requester, + type="info", + audience="client", + content=f"Your authorization request ID:{instance.id} has been denied") else: email.context = {"request_status": "approved"} email.context = {"remarks": "N/A"} + Notification.objects.create( + client=instance.requester, + type="info", + audience="client", + content=f"Your authorization request ID:{instance.id} has been approved") email.send(to=[instance.requester.email]) except: # Silence out errors if email sending fails diff --git a/docmanager_backend/notifications/__init__.py b/docmanager_backend/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docmanager_backend/notifications/admin.py b/docmanager_backend/notifications/admin.py new file mode 100644 index 0000000..756a348 --- /dev/null +++ b/docmanager_backend/notifications/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin +from .models import Notification +from unfold.contrib.filters.admin import RangeDateFilter + +# Register your models here. + + +@admin.register(Notification) +class NotificationAdmin(ModelAdmin): + search_fields = ["id"] + list_display = ["id", "content", "type", "timestamp", "audience", "client"] + + list_filter = [ + ("timestamp", RangeDateFilter), + ] diff --git a/docmanager_backend/notifications/apps.py b/docmanager_backend/notifications/apps.py new file mode 100644 index 0000000..a8e8db1 --- /dev/null +++ b/docmanager_backend/notifications/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" + + def ready(self) -> None: + import notifications.signals + + return super().ready() diff --git a/docmanager_backend/notifications/migrations/0001_initial.py b/docmanager_backend/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..9e91e67 --- /dev/null +++ b/docmanager_backend/notifications/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.3 on 2025-01-21 04:14 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "timestamp", + models.DateTimeField( + default=django.utils.timezone.now, editable=False + ), + ), + ("content", models.TextField(blank=True, max_length=512, null=True)), + ( + "audience", + models.CharField( + choices=[ + ("client", "Client"), + ("staff", "Staff"), + ("head", "Head"), + ], + default="staff", + max_length=16, + ), + ), + ( + "client", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/docmanager_backend/notifications/migrations/0002_notification_type.py b/docmanager_backend/notifications/migrations/0002_notification_type.py new file mode 100644 index 0000000..3594e68 --- /dev/null +++ b/docmanager_backend/notifications/migrations/0002_notification_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.3 on 2025-01-21 04:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="notification", + name="type", + field=models.CharField( + choices=[("info", "Info"), ("warning", "Warning")], + default="info", + max_length=16, + ), + ), + ] diff --git a/docmanager_backend/notifications/migrations/__init__.py b/docmanager_backend/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docmanager_backend/notifications/models.py b/docmanager_backend/notifications/models.py new file mode 100644 index 0000000..06fdc4d --- /dev/null +++ b/docmanager_backend/notifications/models.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.timezone import now + + +class Notification(models.Model): + client = models.ForeignKey( + "accounts.CustomUser", on_delete=models.CASCADE, null=True, blank=True) + timestamp = models.DateTimeField(default=now, editable=False) + + content = models.TextField(max_length=512, blank=True, null=True) + + AUDIENCE_CHOICES = ( + ("client", "Client"), + ("staff", "Staff"), + ("head", "Head") + ) + + TYPE_CHOICES = ( + ("info", "Info"), + ("warning", "Warning"), + ) + + type = models.CharField( + max_length=16, choices=TYPE_CHOICES, default="info") + + audience = models.CharField( + max_length=16, choices=AUDIENCE_CHOICES, default="staff") diff --git a/docmanager_backend/notifications/serializers.py b/docmanager_backend/notifications/serializers.py new file mode 100644 index 0000000..70a734c --- /dev/null +++ b/docmanager_backend/notifications/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers +from accounts.models import CustomUser +from .models import Notification + + +class NotificationSerializer(serializers.ModelSerializer): + client = serializers.SlugRelatedField( + many=False, slug_field="id", queryset=CustomUser.objects.all(), required=False + ) + timestamp = serializers.DateTimeField( + format="%m-%d-%Y %I:%M %p", read_only=True + ) + + class Meta: + model = Notification + fields = [ + "id", + "client", + "timestamp", + "content", + "type", + "audience", + ] diff --git a/docmanager_backend/notifications/signals.py b/docmanager_backend/notifications/signals.py new file mode 100644 index 0000000..dd62f52 --- /dev/null +++ b/docmanager_backend/notifications/signals.py @@ -0,0 +1,14 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from datetime import timedelta +from notifications.models import Notification +from django.utils import timezone + + +@receiver(post_save, sender=Notification) +def notification_post_save(sender, instance, **kwargs): + # Calculate the time threshold (15 minutes ago) + threshold = timezone.now() - timedelta(minutes=15) + + # Find and delete all notifications older than 15 minutes + Notification.objects.filter(timestamp__lt=threshold).delete() diff --git a/docmanager_backend/notifications/urls.py b/docmanager_backend/notifications/urls.py new file mode 100644 index 0000000..fa990be --- /dev/null +++ b/docmanager_backend/notifications/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import ( + NotificationListView, + NotificationDeleteView +) + +urlpatterns = [ + path("list/", NotificationListView.as_view()), + path("delete//", NotificationDeleteView.as_view()), +] diff --git a/docmanager_backend/notifications/views.py b/docmanager_backend/notifications/views.py new file mode 100644 index 0000000..f1fc1a8 --- /dev/null +++ b/docmanager_backend/notifications/views.py @@ -0,0 +1,52 @@ +from rest_framework import generics +from django.db.models import Q +from .serializers import NotificationSerializer +from .models import Notification +from rest_framework.permissions import IsAuthenticated + + +class NotificationListView(generics.ListAPIView): + """ + Used by all users to view notifications. Returns user-specific notifications for clients. + + Returns role-wide notifications for staff and head roles + """ + + http_method_names = ["get"] + serializer_class = NotificationSerializer + queryset = Notification.objects.all().order_by("-timestamp") + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if user.role == "client": + queryset = Notification.objects.filter(client=user) + elif user.role == "staff": + queryset = Notification.objects.filter(audience="staff") + elif user.role in ["head", "admin"]: + queryset = Notification.objects.filter( + Q(audience="staff") | Q(audience="head")) + return queryset + + +class NotificationDeleteView(generics.DestroyAPIView): + """ + Used by all users to dismiss or delete notifications. + """ + + http_method_names = ["delete"] + serializer_class = NotificationSerializer + queryset = Notification.objects.all().order_by("-timestamp") + permission_classes = [IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if user.role == "client": + return Notification.objects.filter(client=user) + elif user.role == "staff": + return Notification.objects.filter(audience="staff") + else: + # For head or admin roles + return Notification.objects.filter( + Q(audience="head") | Q(audience="staff") + )