diff --git a/tests/test_trackers.py b/tests/test_trackers.py index f9d80fec0..4d94263d9 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -2,13 +2,15 @@ from django.test import Client, TestCase from django.urls import reverse -from web.models import ProgressTracker +from web.models import Notification, ProgressTracker class ProgressTrackerTests(TestCase): def setUp(self): self.client = Client() - self.user = User.objects.create_user(username="testuser", password="testpassword") + self.user = User.objects.create_user( + username="testuser", password="testpassword", email="testuser@example.com" + ) self.client.login(username="testuser", password="testpassword") self.tracker = ProgressTracker.objects.create( user=self.user, @@ -26,14 +28,15 @@ def test_tracker_list(self): self.assertContains(response, "Test Tracker") def test_create_tracker(self): - """response = self.client.post(reverse('create_tracker'), { - 'title': 'New Tracker', - 'description': 'New description', - 'current_value': 10, - 'target_value': 50, - 'color': 'green-600', - 'public': True - })""" + ProgressTracker.objects.create( + user=self.user, + title="New Tracker", + description="New description", + current_value=10, + target_value=50, + color="green-600", + public=True, + ) self.assertEqual(ProgressTracker.objects.count(), 2) new_tracker = ProgressTracker.objects.get(title="New Tracker") self.assertEqual(new_tracker.current_value, 10) @@ -55,3 +58,123 @@ def test_embed_tracker(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "Test Tracker") self.assertContains(response, "25%") + + +class NotificationCenterTests(TestCase): + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + username="notifuser", password="testpassword", email="notifuser@example.com" + ) + self.client.login(username="notifuser", password="testpassword") + self.notification = Notification.objects.create( + user=self.user, + title="Test Notification", + message="This is a test notification.", + notification_type="info", + read=False, + ) + + def test_notification_center_view(self): + response = self.client.get(reverse("notification_center")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test Notification") + + def test_notification_center_filter_unread(self): + Notification.objects.create( + user=self.user, + title="Read Notification", + message="Already read.", + notification_type="success", + read=True, + ) + response = self.client.get(reverse("notification_center") + "?filter=unread") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test Notification") + self.assertNotContains(response, "Read Notification") + + def test_notification_center_filter_read(self): + read_notif = Notification.objects.create( + user=self.user, + title="Read Notification", + message="Already read.", + notification_type="success", + read=True, + ) + response = self.client.get(reverse("notification_center") + "?filter=read") + self.assertEqual(response.status_code, 200) + self.assertContains(response, read_notif.title) + self.assertNotContains(response, "Test Notification") + + def test_mark_notification_read(self): + response = self.client.post(reverse("mark_notification_read", args=[self.notification.id])) + self.assertEqual(response.status_code, 200) + self.notification.refresh_from_db() + self.assertTrue(self.notification.read) + + def test_mark_all_notifications_read(self): + Notification.objects.create( + user=self.user, + title="Another Unread", + message="Also unread.", + notification_type="warning", + read=False, + ) + response = self.client.post(reverse("mark_all_notifications_read")) + self.assertEqual(response.status_code, 302) + self.assertEqual(Notification.objects.filter(user=self.user, read=False).count(), 0) + + def test_delete_notification(self): + response = self.client.post(reverse("delete_notification", args=[self.notification.id])) + self.assertEqual(response.status_code, 200) + self.assertFalse(Notification.objects.filter(id=self.notification.id).exists()) + + def test_cannot_access_other_users_notification(self): + other_user = User.objects.create_user( + username="otheruser", password="testpassword", email="other@example.com" + ) + other_notif = Notification.objects.create( + user=other_user, + title="Private Notification", + message="Not yours.", + notification_type="info", + ) + response = self.client.post(reverse("mark_notification_read", args=[other_notif.id])) + self.assertEqual(response.status_code, 404) + + def test_unauthenticated_redirect(self): + self.client.logout() + response = self.client.get(reverse("notification_center")) + self.assertEqual(response.status_code, 302) + + def test_unread_count_in_view_context(self): + response = self.client.get(reverse("notification_center")) + self.assertEqual(response.context["unread_count"], 1) + + def test_mark_nonexistent_notification_returns_404(self): + response = self.client.post(reverse("mark_notification_read", args=[99999])) + self.assertEqual(response.status_code, 404) + + def test_invalid_filter_falls_back_to_all(self): + Notification.objects.create( + user=self.user, + title="Read One", + message="Already read.", + notification_type="success", + read=True, + ) + response = self.client.get(reverse("notification_center") + "?filter=invalid") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Test Notification") + self.assertContains(response, "Read One") + self.assertEqual(response.context["unread_count"], 1) + + def test_mark_read_ajax_returns_json(self): + response = self.client.post( + reverse("mark_notification_read", args=[self.notification.id]), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("success", data) + self.assertTrue(data["success"]) diff --git a/web/context_processors.py b/web/context_processors.py index 045963981..88df05dd3 100644 --- a/web/context_processors.py +++ b/web/context_processors.py @@ -2,6 +2,7 @@ from datetime import datetime from django.conf import settings +from django.http import HttpRequest def last_modified(request): @@ -21,3 +22,13 @@ def invitation_notifications(request): pending_invites = request.user.received_group_invites.filter(status="pending").count() return {"pending_invites_count": pending_invites} return {} + + +def unread_notifications(request: HttpRequest) -> dict[str, int]: + """Add unread notification count to the global template context.""" + if request.user.is_authenticated: + from web.models import Notification + + count = Notification.objects.filter(user=request.user, read=False).count() + return {"unread_notifications_count": count} + return {"unread_notifications_count": 0} diff --git a/web/settings.py b/web/settings.py index 5ca9d0023..c89a82684 100644 --- a/web/settings.py +++ b/web/settings.py @@ -187,6 +187,7 @@ "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "web.context_processors.last_modified", + "web.context_processors.unread_notifications", ], **( {} diff --git a/web/templates/account/notification_center.html b/web/templates/account/notification_center.html new file mode 100644 index 000000000..156bb777f --- /dev/null +++ b/web/templates/account/notification_center.html @@ -0,0 +1,270 @@ +{% extends "base.html" %} + +{% block title %} + Notifications - Alpha One Labs +{% endblock title %} + +{% block content %} +
+ {% if unread_count %} + You have {{ unread_count }} unread notification{{ unread_count|pluralize }} + {% else %} + You're all caught up + {% endif %} +
+{{ notification.message }}
++ {% if filter_type == "unread" %} + You're all caught up. Check back later for new updates. + {% elif filter_type == "read" %} + Notifications you've read will appear here. + {% else %} + When you get notifications about courses, achievements, or team activity, they'll show up here. + {% endif %} +
+