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

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 + +
+
+ +
+ + All ({{ total_count }}) + + + Unread ({{ unread_count }}) + + + Read + +
+ +
+ {% 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 %} +
+ + {% if is_paginated %} + + {% endif %} +
+ + +{% endblock content %} diff --git a/web/templates/base.html b/web/templates/base.html index d869af45a..181affc24 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,13 @@ 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 - + + Notifications + - Notification Preferences + Notification Preferences @@ -768,6 +777,18 @@ {% endif %} + +
+ + Notifications +
+ {% if unread_notifications_count %} + + {{ unread_notifications_count }} + + {% endif %} +
{% if user.is_authenticated %}
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..2b677c921 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,66 @@ def notification_preferences(request): return render(request, "account/notification_preferences.html", {"form": form}) +@login_required +def notification_center(request: HttpRequest) -> HttpResponse: + """Display all notifications for the logged-in user with filtering and pagination.""" + 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") + + paginator = Paginator(notifications, 20) + page_number = request.GET.get("page") + page_obj = paginator.get_page(page_number) + + unread_count = Notification.objects.filter(user=request.user, read=False).count() + total_count = Notification.objects.filter(user=request.user).count() + + context = { + "notifications": page_obj, + "filter_type": filter_type, + "unread_count": unread_count, + "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) + + +@login_required +@require_POST +def mark_notification_read(request: HttpRequest, notification_id: int) -> JsonResponse: + """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: HttpRequest) -> HttpResponse: + """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: HttpRequest, notification_id: int) -> JsonResponse: + """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."""