diff --git a/src/admin/__init__.py b/src/admin/__init__.py index d211469..8edfcd6 100644 --- a/src/admin/__init__.py +++ b/src/admin/__init__.py @@ -4,8 +4,8 @@ from src.admin import ( # noqa: F401 clients, finance, + notifications, productivity, projects, services, - notifications, ) diff --git a/src/admin/notifications.py b/src/admin/notifications.py index 6af549f..a04f0c7 100644 --- a/src/admin/notifications.py +++ b/src/admin/notifications.py @@ -1,7 +1,9 @@ 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") diff --git a/src/management/commands/send_notifications.py b/src/management/commands/send_notifications.py index 35e1419..09b25ef 100644 --- a/src/management/commands/send_notifications.py +++ b/src/management/commands/send_notifications.py @@ -1,8 +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.' + help = "Checks for upcoming deadlines and sends notifications." def handle(self, *args, **options): self.stdout.write("Checking for notifications...") diff --git a/src/migrations/0003_notification.py b/src/migrations/0003_notification.py index 3b428fc..349e17c 100644 --- a/src/migrations/0003_notification.py +++ b/src/migrations/0003_notification.py @@ -5,7 +5,6 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ ("contenttypes", "0002_remove_content_type_name"), ("src", "0002_alter_client_phone"), diff --git a/src/models/__init__.py b/src/models/__init__.py index d4565b2..288cf0e 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,5 +1,4 @@ """generated with djinit""" # Create your models here. -from .notifications import Notification - +from .notifications import Notification as Notification diff --git a/src/models/notifications.py b/src/models/notifications.py index adc07b9..6c88ba9 100644 --- a/src/models/notifications.py +++ b/src/models/notifications.py @@ -1,19 +1,20 @@ -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.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.db import models +from django.utils import timezone 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 +from src.models.projects import Milestone, Project, Task +from src.models.services import Service User = get_user_model() + class Notification(TimeStampedModel): STATUS_CHOICES = [ ("PENDING", "Pending"), @@ -26,11 +27,13 @@ class Notification(TimeStampedModel): 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) + 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') + content_object = GenericForeignKey("content_type", "object_id") class Meta: ordering = ["-created_at"] @@ -62,9 +65,9 @@ def get_admin_emails(): 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)) + return list(User.objects.filter(is_superuser=True).values_list("email", flat=True)) def create_and_send(recipient, subject, message, obj=None): @@ -77,7 +80,7 @@ def create_and_send(recipient, subject, message, obj=None): subject=subject, content_type=ct, object_id=obj.id, - created_at__date=timezone.localdate() + created_at__date=timezone.localdate(), ).exists() if already_sent: return @@ -100,7 +103,7 @@ def check_and_send_notifications(): """ today = timezone.localdate() tomorrow = today + timezone.timedelta(days=1) - + admin_emails = get_admin_emails() if not admin_emails: print("Warning: No admin emails found.") @@ -115,16 +118,16 @@ def check_and_send_notifications(): 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 + 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 + obj=project, ) # --- Milestones (Due Date) --- @@ -138,16 +141,16 @@ def check_and_send_notifications(): 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 + 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 + obj=milestone, ) # --- Tasks (Due Date) --- @@ -159,7 +162,7 @@ def check_and_send_notifications(): 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 + obj=task, ) # --- Services (Expiry Date) --- @@ -171,33 +174,34 @@ def check_and_send_notifications(): 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 + obj=service, ) # --- Invoices (Due Date) --- # Notify Client - invoices_due = Invoice.objects.filter(due_date=tomorrow, status__in=["SENT", "OVERDUE"]) + 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 + 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() + 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 + content_type=ContentType.objects.get_for_model(entry), object_id=entry.id ).exists(): continue @@ -206,5 +210,5 @@ def check_and_send_notifications(): 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 + obj=entry, ) diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 0074b9e..efbfe88 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -1,56 +1,50 @@ +from django.contrib.auth import get_user_model +from django.core import mail 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 +from src.models.notifications import Notification, check_and_send_notifications +from src.models.projects import Project User = get_user_model() + class NotificationTests(TestCase): def test_send_deadline_notifications(self): # Setup - admin = User.objects.create_superuser('admin', 'admin@example.com', 'password') + 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 - ) - + 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 - ) - + 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)