From d9d1155111cc674294d4119c00b8ca9349dc7cb7 Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Thu, 26 Feb 2026 11:11:34 +0530 Subject: [PATCH 1/3] feat: add Notification Center with filtering, mark-read, and delete --- tests/test_trackers.py | 99 +++++++- web/context_processors.py | 9 + web/settings.py | 1 + .../account/notification_center.html | 220 ++++++++++++++++++ web/templates/base.html | 15 +- web/urls.py | 12 + web/views.py | 61 ++++- 7 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 web/templates/account/notification_center.html diff --git a/tests/test_trackers.py b/tests/test_trackers.py index f9d80fec0..726d016b4 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -8,7 +8,9 @@ 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, @@ -55,3 +57,98 @@ def test_embed_tracker(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "Test Tracker") self.assertContains(response, "25%") + + +from web.models import Notification + + +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_context_processor(self): + response = self.client.get(reverse("notification_center")) + self.assertEqual(response.context["unread_count"], 1) diff --git a/web/context_processors.py b/web/context_processors.py index 045963981..b95165527 100644 --- a/web/context_processors.py +++ b/web/context_processors.py @@ -21,3 +21,12 @@ 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): + 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..bcabc67ff --- /dev/null +++ b/web/templates/account/notification_center.html @@ -0,0 +1,220 @@ +{% extends "base.html" %} + +{% block title %} + Notifications - Alpha One Labs +{% endblock title %} + +{% block content %} +
+ +
+
+

Notifications

+

+ {% if unread_count %} + You have {{ unread_count }} unread notification{{ unread_count|pluralize }} + {% else %} + You're all caught up + {% endif %} +

+
+
+ {% if unread_count > 0 %} +
+ {% csrf_token %} + +
+ {% endif %} + + + Settings + +
+
+ + + + + +
+ {% for notification in notifications %} +
+ +
+ {% if notification.notification_type == "success" %} +
+ +
+ {% elif notification.notification_type == "warning" %} +
+ +
+ {% elif notification.notification_type == "error" %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ + +
+
+

+ {{ notification.title }} +

+ {% if not notification.read %} + + {% endif %} +
+

{{ notification.message }}

+
+ + + {{ notification.created_at|timesince }} ago + +
+ {% if not notification.read %} + + {% endif %} + +
+
+
+
+ {% empty %} + +
+
+ +
+

+ {% if filter_type == "unread" %} + No unread notifications + {% elif filter_type == "read" %} + No read notifications + {% else %} + No notifications yet + {% endif %} +

+

+ {% 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 %} +

+
+ {% endfor %} +
+
+ + +{% endblock content %} diff --git a/web/templates/base.html b/web/templates/base.html index d869af45a..1d3532616 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -376,10 +376,16 @@ {% endif %} - + - {% if pending_invites_count %} + {% if unread_notifications_count %} + + {{ unread_notifications_count }} + + {% elif pending_invites_count %} {{ pending_invites_count }} @@ -488,10 +494,9 @@ class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"> Messages - - Notification Preferences + Notifications diff --git a/web/urls.py b/web/urls.py index 3fb4e298a..5427f1802 100644 --- a/web/urls.py +++ b/web/urls.py @@ -91,6 +91,18 @@ path("accounts/signup/", views.signup_view, name="account_signup"), # Our custom signup view path("accounts/", include("allauth.urls")), path("account/notification-preferences/", notification_preferences, name="notification_preferences"), + path("account/notifications/", views.notification_center, name="notification_center"), + path( + "account/notifications//read/", + views.mark_notification_read, + name="mark_notification_read", + ), + path("account/notifications/mark-all-read/", views.mark_all_notifications_read, name="mark_all_notifications_read"), + path( + "account/notifications//delete/", + views.delete_notification, + name="delete_notification", + ), path("profile/", views.profile, name="profile"), path("accounts/profile/", views.profile, name="accounts_profile"), path("accounts/delete/", views.delete_account, name="delete_account"), diff --git a/web/views.py b/web/views.py index 8dd972d98..4c8999bbe 100644 --- a/web/views.py +++ b/web/views.py @@ -20,7 +20,9 @@ import stripe import tweepy from allauth.account.models import EmailAddress -from allauth.account.utils import send_email_confirmation +from allauth.account.internal.flows.email_verification import ( + send_verification_email_for_user as send_email_confirmation, +) from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import NestedObjects @@ -1332,7 +1334,7 @@ def teach(request): ) else: # Email not verified, resend verification email - send_email_confirmation(request, user, signup=False) + send_email_confirmation(request, user) messages.info( request, "An account with this email exists. Please verify your email to continue.", @@ -1362,7 +1364,7 @@ def teach(request): EmailAddress.objects.create(user=user, email=email, primary=True, verified=False) # Send verification email via allauth - send_email_confirmation(request, user, signup=True) + send_email_confirmation(request, user) # Send welcome email with username, email, and temp password try: send_welcome_teach_course_email(request, user, temp_password) @@ -6975,6 +6977,59 @@ def notification_preferences(request): return render(request, "account/notification_preferences.html", {"form": form}) +@login_required +def notification_center(request): + """Display all notifications for the logged-in user with filtering.""" + filter_type = request.GET.get("filter", "all") + notifications = Notification.objects.filter(user=request.user) + + if filter_type == "unread": + notifications = notifications.filter(read=False) + elif filter_type == "read": + notifications = notifications.filter(read=True) + + notifications = notifications.order_by("-created_at") + + unread_count = Notification.objects.filter(user=request.user, read=False).count() + total_count = Notification.objects.filter(user=request.user).count() + + context = { + "notifications": notifications, + "filter_type": filter_type, + "unread_count": unread_count, + "total_count": total_count, + } + return render(request, "account/notification_center.html", context) + + +@login_required +@require_POST +def mark_notification_read(request, notification_id): + """Mark a single notification as read.""" + notification = get_object_or_404(Notification, id=notification_id, user=request.user) + notification.read = True + notification.save() + return JsonResponse({"success": True}) + + +@login_required +@require_POST +def mark_all_notifications_read(request): + """Mark all notifications as read for the logged-in user.""" + Notification.objects.filter(user=request.user, read=False).update(read=True) + messages.success(request, "All notifications marked as read.") + return redirect("notification_center") + + +@login_required +@require_POST +def delete_notification(request, notification_id): + """Delete a single notification.""" + notification = get_object_or_404(Notification, id=notification_id, user=request.user) + notification.delete() + return JsonResponse({"success": True}) + + @login_required def invite_to_study_group(request, group_id): """Invite a user to a study group.""" From 3e4735cda68e8a49945e9a62e1e106075ad16048 Mon Sep 17 00:00:00 2001 From: Prem Kumar BODDU Date: Thu, 26 Feb 2026 21:39:13 +0530 Subject: [PATCH 2/3] fix: address PR review feedback for notification center --- tests/test_trackers.py | 24 +++++------ web/context_processors.py | 1 + .../account/notification_center.html | 42 ++++++++++++++++++- web/templates/base.html | 6 ++- web/views.py | 18 +++++--- 5 files changed, 69 insertions(+), 22 deletions(-) diff --git a/tests/test_trackers.py b/tests/test_trackers.py index 726d016b4..6d43c6b4e 100644 --- a/tests/test_trackers.py +++ b/tests/test_trackers.py @@ -2,7 +2,7 @@ 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): @@ -28,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) @@ -59,9 +60,6 @@ def test_embed_tracker(self): self.assertContains(response, "25%") -from web.models import Notification - - class NotificationCenterTests(TestCase): def setUp(self): self.client = Client() @@ -149,6 +147,6 @@ def test_unauthenticated_redirect(self): response = self.client.get(reverse("notification_center")) self.assertEqual(response.status_code, 302) - def test_unread_count_context_processor(self): + def test_unread_count_in_view_context(self): response = self.client.get(reverse("notification_center")) self.assertEqual(response.context["unread_count"], 1) diff --git a/web/context_processors.py b/web/context_processors.py index b95165527..bac59f4ab 100644 --- a/web/context_processors.py +++ b/web/context_processors.py @@ -24,6 +24,7 @@ def invitation_notifications(request): def unread_notifications(request): + """Add unread notification count to the global template context.""" if request.user.is_authenticated: from web.models import Notification diff --git a/web/templates/account/notification_center.html b/web/templates/account/notification_center.html index bcabc67ff..33bf7994e 100644 --- a/web/templates/account/notification_center.html +++ b/web/templates/account/notification_center.html @@ -145,6 +145,44 @@

{% endfor %} + + + {% if is_paginated %} + + {% endif %} diff --git a/web/templates/base.html b/web/templates/base.html index 5cfa9dd42..181affc24 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -777,6 +777,18 @@ {% endif %} + +
+ + Notifications +
+ {% if unread_notifications_count %} + + {{ unread_notifications_count }} + + {% endif %} +
{% if user.is_authenticated %}
diff --git a/web/views.py b/web/views.py index c63eac937..2b677c921 100644 --- a/web/views.py +++ b/web/views.py @@ -7004,6 +7004,7 @@ def notification_center(request: HttpRequest) -> HttpResponse: "total_count": total_count, "is_paginated": paginator.num_pages > 1, "page_obj": page_obj, + "elided_page_range": paginator.get_elided_page_range(page_obj.number, on_each_side=1, on_ends=1), } return render(request, "account/notification_center.html", context)