feat(notification): added notification model and admin
This commit is contained in:
1 parent
adfd2bf63f
commit
466d4b868a
13 files changed
+419
-1
No files matched your search
@@ -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/
|
||||
@@ -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).
|
||||
Executable
+7
@@ -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
|
||||
@@ -7,4 +7,5 @@ from src.admin import ( # noqa: F401
|
||||
productivity,
|
||||
projects,
|
||||
services,
|
||||
notifications,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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."))
|
||||
@@ -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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
"""generated with djinit"""
|
||||
|
||||
# Create your models here.
|
||||
from .notifications import Notification
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
Whitespace-only changes.
@@ -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)
|
||||
Reference in new issue
Block a user