From 466d4b868ae389c9cea47a3b7c87fc2308cafca6 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Thu, 11 Dec 2025 21:02:16 +0545 Subject: [PATCH] feat(notification): added notification model and admin --- README.md | 7 +- notifi.md | 48 ++++ scripts/run_notifications.sh | 7 + src/admin/__init__.py | 1 + src/admin/base.py | 5 + src/admin/notifications.py | 11 + src/management/commands/send_notifications.py | 10 + src/migrations/0003_notification.py | 60 +++++ src/models/__init__.py | 2 + src/models/notifications.py | 210 ++++++++++++++++++ src/settings/base.py | 3 + tests/__init__.py | 0 tests/test_notifications.py | 56 +++++ 13 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 notifi.md create mode 100755 scripts/run_notifications.sh create mode 100644 src/management/commands/send_notifications.py create mode 100644 src/migrations/0003_notification.py create mode 100644 tests/__init__.py create mode 100644 tests/test_notifications.py diff --git a/README.md b/README.md index 2752c7a..adc1522 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # . -A Django project created with [djinitx/djinit/dj](https://github.com/S4NKALP/djinit). +A Django project folder structure generated with [djinitx/djinit/dj](https://github.com/S4NKALP/djinit). ## Features @@ -16,6 +16,7 @@ A Django project created with [djinitx/djinit/dj](https://github.com/S4NKALP/dji 1. Set environment variables in `.env` file 2. Run setup (installs dependencies, runs migrations, creates superuser): + ```bash just setup ``` @@ -28,6 +29,7 @@ A Django project created with [djinitx/djinit/dj](https://github.com/S4NKALP/dji ### Traditional Method 1. Install dependencies: + ```bash pip install -r requirements.txt ``` @@ -35,11 +37,13 @@ A Django project created with [djinitx/djinit/dj](https://github.com/S4NKALP/dji 2. Set environment variables in `.env` file 3. Run migrations: + ```bash python manage.py migrate ``` 4. Create superuser: + ```bash python manage.py createsuperuser ``` @@ -102,5 +106,6 @@ See the README files in `api/` and `models/` directories for more details. ## API Documentation When running in development mode, API documentation is available at: + - Swagger UI: http://localhost:8000/docs/ - Schema: http://localhost:8000/schema/ diff --git a/notifi.md b/notifi.md new file mode 100644 index 0000000..4fb32ec --- /dev/null +++ b/notifi.md @@ -0,0 +1,48 @@ +# Notification System Workflow + +This document outlines how the notification system works in DevSuite. + +## Overview +The system automatically checks for upcoming deadlines, due dates, and other time-sensitive events and sends email notifications to Clients and Admins. + +## Triggers & Recipients + +The system checks for the following events daily: + +| Module | Event | Condition | Recipient | +| :--- | :--- | :--- | :--- | +| **Projects** | Project Deadline | Deadline is **tomorrow** | Client & Admin | +| **Projects** | Milestone Due | Due date is **tomorrow** | Client & Admin | +| **Projects** | Task Due | Due date is **tomorrow** | Admin | +| **Services** | Service Expiry | Expiry date is **tomorrow** | Client | +| **Finance** | Invoice Due | Due date is **tomorrow** | Client | +| **Productivity** | Time Entry Logged | Completed in last **24 hours** | Admin | + +## How It Works + +1. **Command**: The logic is encapsulated in a Django management command: + ```bash + python manage.py send_notifications + ``` +2. **Logic**: + - It queries the database for objects matching the conditions above. + - It generates an email with relevant details. + - It records the notification in the `Notification` model to prevent duplicates (for the same day). + - It sends the email using the configured `EMAIL_BACKEND`. + +## Automation + +To ensure notifications are sent daily, a cron job is used. + +**Script**: `scripts/run_notifications.sh` + +**Setup**: +Add this to your crontab (`crontab -e`) to run daily at 9:00 AM: +```bash +0 9 * * * /path/to/devsuite/scripts/run_notifications.sh >> /path/to/devsuite/logs/cron.log 2>&1 +``` + +## Configuration + +- **Admin Emails**: Configured in `src/settings/base.py` under `ADMINS`. +- **Email Backend**: Configured in `src/settings/base.py` (default is console for dev, SMTP for prod). diff --git a/scripts/run_notifications.sh b/scripts/run_notifications.sh new file mode 100755 index 0000000..99a08ed --- /dev/null +++ b/scripts/run_notifications.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Script to run daily notifications +# Add this to crontab: 0 9 * * * /path/to/devsuite/scripts/run_notifications.sh + +cd "$(dirname "$0")/.." || exit +source .venv/bin/activate +python manage.py send_notifications diff --git a/src/admin/__init__.py b/src/admin/__init__.py index 134b769..d211469 100644 --- a/src/admin/__init__.py +++ b/src/admin/__init__.py @@ -7,4 +7,5 @@ from src.admin import ( # noqa: F401 productivity, projects, services, + notifications, ) diff --git a/src/admin/base.py b/src/admin/base.py index 1d66ce0..0a78323 100644 --- a/src/admin/base.py +++ b/src/admin/base.py @@ -86,6 +86,11 @@ class DevSuiteAdminSite(AdminSite): "display_name": "Groups", "order": 2, }, + "Notification": { + "group": "System & Users", + "display_name": "Notifications", + "order": 3, + }, } # Group models according to the model_map diff --git a/src/admin/notifications.py b/src/admin/notifications.py index e69de29..6af549f 100644 --- a/src/admin/notifications.py +++ b/src/admin/notifications.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from src.admin.base import admin_site +from src.models.notifications import Notification + +@admin.register(Notification, site=admin_site) +class NotificationAdmin(admin.ModelAdmin): + list_display = ("recipient", "subject", "status", "sent_at", "created_at") + list_filter = ("status", "created_at", "sent_at") + search_fields = ("recipient", "subject", "message") + readonly_fields = ("created_at", "updated_at", "sent_at") + date_hierarchy = "created_at" diff --git a/src/management/commands/send_notifications.py b/src/management/commands/send_notifications.py new file mode 100644 index 0000000..35e1419 --- /dev/null +++ b/src/management/commands/send_notifications.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand +from src.models.notifications import check_and_send_notifications + +class Command(BaseCommand): + help = 'Checks for upcoming deadlines and sends notifications.' + + def handle(self, *args, **options): + self.stdout.write("Checking for notifications...") + check_and_send_notifications() + self.stdout.write(self.style.SUCCESS("Successfully sent notifications.")) diff --git a/src/migrations/0003_notification.py b/src/migrations/0003_notification.py new file mode 100644 index 0000000..3b428fc --- /dev/null +++ b/src/migrations/0003_notification.py @@ -0,0 +1,60 @@ +# Generated by Django 6.0 on 2025-12-11 14:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("src", "0002_alter_client_phone"), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("recipient", models.EmailField(max_length=254)), + ("subject", models.CharField(max_length=255)), + ("message", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "Pending"), + ("SENT", "Sent"), + ("FAILED", "Failed"), + ], + default="PENDING", + max_length=20, + ), + ), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("object_id", models.PositiveIntegerField(blank=True, null=True)), + ( + "content_type", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/src/models/__init__.py b/src/models/__init__.py index 6cb4fd6..d4565b2 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,3 +1,5 @@ """generated with djinit""" # Create your models here. +from .notifications import Notification + diff --git a/src/models/notifications.py b/src/models/notifications.py index e69de29..adc07b9 100644 --- a/src/models/notifications.py +++ b/src/models/notifications.py @@ -0,0 +1,210 @@ +from django.db import models +from django.core.mail import send_mail +from django.conf import settings +from django.utils import timezone +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.contrib.auth import get_user_model + +from src.models.base import TimeStampedModel +from src.models.projects import Project, Milestone, Task +from src.models.services import Service +from src.models.finance import Invoice +from src.models.productivity import TimeEntry + +User = get_user_model() + +class Notification(TimeStampedModel): + STATUS_CHOICES = [ + ("PENDING", "Pending"), + ("SENT", "Sent"), + ("FAILED", "Failed"), + ] + + recipient = models.EmailField() + subject = models.CharField(max_length=255) + message = models.TextField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING") + sent_at = models.DateTimeField(null=True, blank=True) + + # Generic relation to link to the source object (Project, Invoice, etc.) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, null=True, blank=True) + object_id = models.PositiveIntegerField(null=True, blank=True) + content_object = GenericForeignKey('content_type', 'object_id') + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"Notification to {self.recipient}: {self.subject}" + + def send(self): + try: + send_mail( + self.subject, + self.message, + settings.DEFAULT_FROM_EMAIL, + [self.recipient], + fail_silently=False, + ) + self.status = "SENT" + self.sent_at = timezone.now() + except Exception as e: + self.status = "FAILED" + # In a real app, we might want to log the error 'e' + print(f"Failed to send email: {e}") + self.save() + + +def get_admin_emails(): + """Return a list of admin emails.""" + # First try settings.ADMINS + admins = getattr(settings, "ADMINS", []) + if admins: + return [email for name, email in admins] + + # Fallback to superusers + return list(User.objects.filter(is_superuser=True).values_list('email', flat=True)) + + +def create_and_send(recipient, subject, message, obj=None): + """Helper to create and send a notification.""" + # Avoid duplicate notifications for the same event on the same day + if obj: + ct = ContentType.objects.get_for_model(obj) + already_sent = Notification.objects.filter( + recipient=recipient, + subject=subject, + content_type=ct, + object_id=obj.id, + created_at__date=timezone.localdate() + ).exists() + if already_sent: + return + + notification = Notification( + recipient=recipient, + subject=subject, + message=message, + content_type=ContentType.objects.get_for_model(obj) if obj else None, + object_id=obj.id if obj else None, + ) + notification.save() + notification.send() + + +def check_and_send_notifications(): + """ + Checks for upcoming deadlines/due dates and sends notifications. + Should be called periodically (e.g., via a cron job or Celery beat). + """ + today = timezone.localdate() + tomorrow = today + timezone.timedelta(days=1) + + admin_emails = get_admin_emails() + if not admin_emails: + print("Warning: No admin emails found.") + + # --- Projects (Deadline) --- + # Notify Client & Admin if deadline is tomorrow + projects_due = Project.objects.filter(deadline=tomorrow) + for project in projects_due: + # Notify Client + if project.client.email: + create_and_send( + recipient=project.client.email, + subject=f"Project Deadline Reminder: {project.name}", + message=f"Dear {project.client.name},\n\nThe project '{project.name}' is due on {project.deadline}.\n\nRegards,\nDevSuite", + obj=project + ) + + # Notify Admin + for email in admin_emails: + create_and_send( + recipient=email, + subject=f"Admin Alert: Project Deadline - {project.name}", + message=f"Project '{project.name}' for client {project.client.name} is due on {project.deadline}.", + obj=project + ) + + # --- Milestones (Due Date) --- + # Notify Client & Admin if due_date is tomorrow + milestones_due = Milestone.objects.filter(due_date=tomorrow, is_completed=False) + for milestone in milestones_due: + project = milestone.project + # Notify Client + if project.client.email: + create_and_send( + recipient=project.client.email, + subject=f"Milestone Due: {milestone.title}", + message=f"Dear {project.client.name},\n\nThe milestone '{milestone.title}' for project '{project.name}' is due on {milestone.due_date}.\n\nRegards,\nDevSuite", + obj=milestone + ) + + # Notify Admin + for email in admin_emails: + create_and_send( + recipient=email, + subject=f"Admin Alert: Milestone Due - {milestone.title}", + message=f"Milestone '{milestone.title}' (Project: {project.name}) is due on {milestone.due_date}.", + obj=milestone + ) + + # --- Tasks (Due Date) --- + # Notify Admin only + tasks_due = Task.objects.filter(due_date=tomorrow).exclude(status="DONE") + for task in tasks_due: + for email in admin_emails: + create_and_send( + recipient=email, + subject=f"Task Due: {task.title}", + message=f"Task '{task.title}' (Project: {task.project.name}) is due on {task.due_date}.", + obj=task + ) + + # --- Services (Expiry Date) --- + # Notify Client + services_expiring = Service.objects.filter(expiry_date=tomorrow) + for service in services_expiring: + if service.client.email: + create_and_send( + recipient=service.client.email, + subject=f"Service Expiry Warning: {service.name}", + message=f"Dear {service.client.name},\n\nYour service '{service.name}' ({service.get_service_type_display()}) expires on {service.expiry_date}.\nPlease renew it soon.\n\nRegards,\nDevSuite", + obj=service + ) + + # --- Invoices (Due Date) --- + # Notify Client + invoices_due = Invoice.objects.filter(due_date=tomorrow, status__in=["SENT", "OVERDUE"]) + for invoice in invoices_due: + if invoice.client.email: + create_and_send( + recipient=invoice.client.email, + subject=f"Invoice Due: {invoice.invoice_number}", + message=f"Dear {invoice.client.name},\n\nInvoice {invoice.invoice_number} for {invoice.amount} is due on {invoice.due_date}.\nPlease make payment.\n\nRegards,\nDevSuite", + obj=invoice + ) + + # --- Productivity / TimeEntries --- + # Notify Admin for recently completed time entries (last 24h) + recent_entries = TimeEntry.objects.filter( + end_time__gte=timezone.now() - timezone.timedelta(hours=24), + end_time__lte=timezone.now() + ) + + for entry in recent_entries: + # Check if we already sent a notification for this entry + if Notification.objects.filter( + content_type=ContentType.objects.get_for_model(entry), + object_id=entry.id + ).exists(): + continue + + for email in admin_emails: + create_and_send( + recipient=email, + subject=f"Time Entry Logged: {entry.description}", + message=f"User logged time for '{entry.description}'.\nDuration: {entry.duration}\nEnd Time: {entry.end_time}", + obj=entry + ) diff --git a/src/settings/base.py b/src/settings/base.py index 0036662..426d7fd 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -18,6 +18,9 @@ BASE_DIR = Path(__file__).resolve().parent.parent.parent # Application definition +ADMINS = [("Admin", env("ADMIN_EMAIL", default="admin@example.com"))] +MANAGERS = ADMINS + THIRD_PARTY_APPS = [ "django_components", "django_htmx", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..0074b9e --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,56 @@ +from django.test import TestCase +from django.utils import timezone +from django.core import mail +from django.contrib.auth import get_user_model +from src.models.projects import Project +from src.models.clients import Client +from src.models.notifications import check_and_send_notifications, Notification + +User = get_user_model() + +class NotificationTests(TestCase): + def test_send_deadline_notifications(self): + # Setup + admin = User.objects.create_superuser('admin', 'admin@example.com', 'password') + client = Client.objects.create(name="Test Client", email="client@example.com") + + # Project due tomorrow + tomorrow = timezone.localdate() + timezone.timedelta(days=1) + project = Project.objects.create( + name="Test Project", + client=client, + deadline=tomorrow + ) + + # Run notification check + check_and_send_notifications() + + # Verify emails sent + # Expecting 2 emails: 1 to client, 1 to admin + self.assertEqual(len(mail.outbox), 2) + + # Verify Notification objects created + self.assertEqual(Notification.objects.count(), 2) + + client_notif = Notification.objects.get(recipient="client@example.com") + self.assertIn("Project Deadline Reminder", client_notif.subject) + + admin_notif = Notification.objects.get(recipient="admin@example.com") + self.assertIn("Admin Alert", admin_notif.subject) + + def test_no_notifications_if_not_due(self): + # Setup + client = Client.objects.create(name="Test Client", email="client@example.com") + + # Project due day after tomorrow + day_after = timezone.localdate() + timezone.timedelta(days=2) + Project.objects.create( + name="Future Project", + client=client, + deadline=day_after + ) + + check_and_send_notifications() + + self.assertEqual(len(mail.outbox), 0) + self.assertEqual(Notification.objects.count(), 0)