From 9a63abaf1266b5a2160f24e125d58670f5d87982 Mon Sep 17 00:00:00 2001
From: Albert Martinez <58224660+albert-mr@users.noreply.github.com>
Date: Wed, 22 Oct 2025 22:33:18 +0700
Subject: [PATCH 1/2] Add notification system for submission reviews
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Custom Notification model (temporary until django-notifications-hq supports Django 5.2)
- Bell icon with unread count and 30-second polling
- Notifications page with read/unread filtering
- Mark as read functionality with always-visible eye icon
- Currently triggers on submission accept/reject/more_info
- Clean UI matching project design (similar to SubmissionCard)
- Navigation: accepted/rejected → /my-submissions, more_info → /contributions/{id}
Note: Using custom implementation due to django-notifications-hq incompatibility with Django 5.2 (index_together deprecation issue).
Tracking issue: https://github.com/django-notifications/django-notifications/pull/396
Closes #208
---
backend/CLAUDE.md | 1 +
backend/api/urls.py | 3 +-
.../migrations/0028_notification.py | 37 +++
backend/contributions/models.py | 91 +++++-
backend/contributions/serializers.py | 39 ++-
backend/contributions/views.py | 87 +++++-
backend/tally/settings.py | 2 +-
frontend/src/App.svelte | 2 +
frontend/src/components/Navbar.svelte | 11 +-
.../src/components/NotificationBell.svelte | 48 +++
frontend/src/lib/api.js | 8 +
frontend/src/routes/Notifications.svelte | 276 ++++++++++++++++++
12 files changed, 594 insertions(+), 11 deletions(-)
create mode 100644 backend/contributions/migrations/0028_notification.py
create mode 100644 frontend/src/components/NotificationBell.svelte
create mode 100644 frontend/src/routes/Notifications.svelte
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index aa2e1521..30fa752a 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -66,6 +66,7 @@ backend/
- ContributionType - Categories with slug field (Node Running, Blog Posts, etc.)
- ContributionTypeMultiplier - Dynamic point multipliers
- Evidence - Evidence items for contributions (text descriptions and URLs only - file uploads are disabled)
+ - Notification - Temporary custom model (will migrate to django-notifications-hq)
- **reCAPTCHA**: `contributions/recaptcha_field.py`
- Custom DRF serializer field for Google reCAPTCHA v2 validation
- Validates tokens from frontend reCAPTCHA widget
diff --git a/backend/api/urls.py b/backend/api/urls.py
index 9d72d499..81e1762b 100644
--- a/backend/api/urls.py
+++ b/backend/api/urls.py
@@ -1,7 +1,7 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from users.views import UserViewSet
-from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet
+from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, NotificationViewSet
from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView
@@ -17,6 +17,7 @@
router.register(r'submissions', SubmittedContributionViewSet, basename='submission')
router.register(r'steward-submissions', StewardSubmissionViewSet, basename='steward-submission')
router.register(r'missions', MissionViewSet, basename='mission')
+router.register(r'notifications', NotificationViewSet, basename='notification')
# The API URLs are now determined automatically by the router
urlpatterns = [
diff --git a/backend/contributions/migrations/0028_notification.py b/backend/contributions/migrations/0028_notification.py
new file mode 100644
index 00000000..553406f9
--- /dev/null
+++ b/backend/contributions/migrations/0028_notification.py
@@ -0,0 +1,37 @@
+# Generated by Django 5.2.6 on 2025-10-22 12:13
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contributions', '0027_alter_evidence_file'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ 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)),
+ ('notification_type', models.CharField(choices=[('accepted', 'Submission Accepted'), ('rejected', 'Submission Rejected'), ('more_info', 'More Information Requested')], help_text='Type of notification', max_length=20)),
+ ('message', models.TextField(help_text='Notification message')),
+ ('data', models.JSONField(blank=True, default=dict, help_text='Additional data about the notification')),
+ ('unread', models.BooleanField(default=True, help_text='Whether the notification has been read')),
+ ('actor', models.ForeignKey(blank=True, help_text='User who triggered this notification', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notifications_sent', to=settings.AUTH_USER_MODEL)),
+ ('recipient', models.ForeignKey(help_text='User who receives this notification', on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
+ ('submission', models.ForeignKey(help_text='The submission this notification relates to', on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='contributions.submittedcontribution')),
+ ],
+ options={
+ 'verbose_name': 'Notification',
+ 'verbose_name_plural': 'Notifications',
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['recipient', 'unread', '-created_at'], name='contributio_recipie_5037ef_idx'), models.Index(fields=['recipient', '-created_at'], name='contributio_recipie_f71f63_idx')],
+ },
+ ),
+ ]
diff --git a/backend/contributions/models.py b/backend/contributions/models.py
index dc8ff0d7..7ba86de0 100644
--- a/backend/contributions/models.py
+++ b/backend/contributions/models.py
@@ -475,5 +475,94 @@ def get_active_highlights(cls, contribution_type=None, user=None, limit=5):
'contribution__user',
'contribution__contribution_type'
)
-
+
return queryset[:limit]
+
+
+class Notification(BaseModel):
+ """
+ Temporary custom notification system for submission reviews.
+ Will migrate to django-notifications-hq when Django 5.2 support is released.
+ """
+ # Recipient of the notification
+ recipient = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name='notifications',
+ help_text="User who receives this notification"
+ )
+
+ # Actor (steward) who performed the action
+ actor = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='notifications_sent',
+ help_text="User who triggered this notification"
+ )
+
+ # Submission this notification is about
+ submission = models.ForeignKey(
+ 'SubmittedContribution',
+ on_delete=models.CASCADE,
+ related_name='notifications',
+ help_text="The submission this notification relates to"
+ )
+
+ # Notification details
+ NOTIFICATION_TYPES = [
+ ('accepted', 'Submission Accepted'),
+ ('rejected', 'Submission Rejected'),
+ ('more_info', 'More Information Requested'),
+ ]
+ notification_type = models.CharField(
+ max_length=20,
+ choices=NOTIFICATION_TYPES,
+ help_text="Type of notification"
+ )
+
+ message = models.TextField(
+ help_text="Notification message"
+ )
+
+ # Additional data (points awarded, staff reply, etc.)
+ data = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="Additional data about the notification"
+ )
+
+ # Read status
+ unread = models.BooleanField(
+ default=True,
+ help_text="Whether the notification has been read"
+ )
+
+ class Meta:
+ ordering = ['-created_at']
+ verbose_name = "Notification"
+ verbose_name_plural = "Notifications"
+ indexes = [
+ models.Index(fields=['recipient', 'unread', '-created_at']),
+ models.Index(fields=['recipient', '-created_at']),
+ ]
+
+ def __str__(self):
+ return f"{self.get_notification_type_display()} for {self.recipient.email}"
+
+ def mark_as_read(self):
+ """Mark this notification as read."""
+ if self.unread:
+ self.unread = False
+ self.save(update_fields=['unread'])
+
+ @classmethod
+ def mark_all_as_read(cls, user):
+ """Mark all notifications for a user as read."""
+ cls.objects.filter(recipient=user, unread=True).update(unread=False)
+
+ @classmethod
+ def get_unread_count(cls, user):
+ """Get count of unread notifications for a user."""
+ return cls.objects.filter(recipient=user, unread=True).count()
diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py
index 2e679e7b..06068c28 100644
--- a/backend/contributions/serializers.py
+++ b/backend/contributions/serializers.py
@@ -1,5 +1,5 @@
from rest_framework import serializers
-from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission
+from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, Notification
from users.serializers import UserSerializer, LightUserSerializer
from users.models import User
from .recaptcha_field import ReCaptchaField
@@ -558,3 +558,40 @@ class Meta:
def get_is_active(self, obj):
return obj.is_active()
+
+
+class NotificationSerializer(serializers.ModelSerializer):
+ """Serializer for notification objects."""
+ actor_name = serializers.SerializerMethodField()
+ submission_id = serializers.SerializerMethodField()
+ time_ago = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Notification
+ fields = [
+ 'id',
+ 'notification_type',
+ 'message',
+ 'unread',
+ 'created_at',
+ 'actor_name',
+ 'submission_id',
+ 'data',
+ 'time_ago'
+ ]
+ read_only_fields = ['id', 'created_at']
+
+ def get_actor_name(self, obj):
+ """Get the name of the steward who took action."""
+ if obj.actor:
+ return obj.actor.name or obj.actor.address[:10]
+ return 'System'
+
+ def get_submission_id(self, obj):
+ """Get submission ID."""
+ return str(obj.submission.id) if obj.submission else None
+
+ def get_time_ago(self, obj):
+ """Human-readable time since notification."""
+ from django.utils.timesince import timesince
+ return timesince(obj.created_at)
diff --git a/backend/contributions/views.py b/backend/contributions/views.py
index 75b2e668..4a2ac1e5 100644
--- a/backend/contributions/views.py
+++ b/backend/contributions/views.py
@@ -10,12 +10,12 @@
from django.contrib import messages
from django.views.generic import ListView
from django.utils.decorators import method_decorator
-from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission
+from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, Notification
from .serializers import (ContributionTypeSerializer, ContributionSerializer,
EvidenceSerializer, SubmittedContributionSerializer,
SubmittedEvidenceSerializer, ContributionHighlightSerializer,
StewardSubmissionSerializer, StewardSubmissionReviewSerializer,
- MissionSerializer)
+ MissionSerializer, NotificationSerializer)
from .forms import SubmissionReviewForm
from .permissions import IsSteward
from leaderboard.models import GlobalLeaderboardMultiplier
@@ -820,15 +820,55 @@ def review(self, request, pk=None):
submission.state = 'accepted'
submission.converted_contribution = contribution
submission.staff_reply = serializer.validated_data.get('staff_reply', '')
-
+
+ # Create notification for user
+ # TODO: Migrate to django-notifications-hq
+ Notification.objects.create(
+ recipient=submission.user,
+ actor=request.user,
+ submission=submission,
+ notification_type='accepted',
+ message=f'Your submission was accepted and awarded {serializer.validated_data["points"]} points!',
+ data={
+ 'points': serializer.validated_data['points'],
+ 'contribution_type': contribution_type.name
+ }
+ )
+
elif action == 'reject':
submission.state = 'rejected'
submission.staff_reply = serializer.validated_data['staff_reply']
-
+
+ # Create notification for user
+ # TODO: Migrate to django-notifications-hq
+ Notification.objects.create(
+ recipient=submission.user,
+ actor=request.user,
+ submission=submission,
+ notification_type='rejected',
+ message=f'Your submission was rejected.',
+ data={
+ 'staff_reply': serializer.validated_data['staff_reply']
+ }
+ )
+
elif action == 'more_info':
submission.state = 'more_info_needed'
submission.staff_reply = serializer.validated_data['staff_reply']
-
+
+ # Create notification for user
+ # TODO: Migrate to django-notifications-hq
+ Notification.objects.create(
+ recipient=submission.user,
+ actor=request.user,
+ submission=submission,
+ notification_type='more_info',
+ message=f'More information requested on your submission.',
+ data={
+ 'staff_reply': serializer.validated_data['staff_reply']
+ }
+ )
+
submission.save()
return Response(
@@ -949,3 +989,40 @@ def get_queryset(self):
queryset = queryset.filter(contribution_type__category__slug=category)
return queryset
+
+
+class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
+ """
+ API endpoint for user notifications.
+ Users can only see their own notifications.
+ """
+ serializer_class = NotificationSerializer
+ authentication_classes = [EthereumAuthentication]
+ permission_classes = [permissions.IsAuthenticated]
+
+ def get_queryset(self):
+ """Only return notifications for the authenticated user."""
+ return self.request.user.notifications.all().select_related(
+ 'actor',
+ 'submission',
+ 'submission__contribution_type'
+ )
+
+ @action(detail=False, methods=['get'])
+ def unread_count(self, request):
+ """Get count of unread notifications."""
+ count = Notification.get_unread_count(request.user)
+ return Response({'count': count})
+
+ @action(detail=True, methods=['post'])
+ def mark_read(self, request, pk=None):
+ """Mark a single notification as read."""
+ notification = self.get_object()
+ notification.mark_as_read()
+ return Response({'status': 'marked as read'})
+
+ @action(detail=False, methods=['post'])
+ def mark_all_read(self, request):
+ """Mark all notifications as read."""
+ Notification.mark_all_as_read(request.user)
+ return Response({'status': 'all marked as read'})
diff --git a/backend/tally/settings.py b/backend/tally/settings.py
index c66abbd5..345633d2 100644
--- a/backend/tally/settings.py
+++ b/backend/tally/settings.py
@@ -62,7 +62,7 @@ def get_required_env(key):
'django_filters',
'django_recaptcha',
'allow_cidr',
-
+
# Local apps
'api',
'utils',
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index fbd6a669..7a7dc441 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -32,6 +32,7 @@
import Metrics from './routes/Metrics.svelte';
import ProfileEdit from './routes/ProfileEdit.svelte';
import Highlights from './routes/Highlights.svelte';
+ import Notifications from './routes/Notifications.svelte';
import NotFound from './routes/NotFound.svelte';
import LoaderShowcase from './routes/LoaderShowcase.svelte';
import StewardDashboard from './routes/StewardDashboard.svelte';
@@ -66,6 +67,7 @@
'/participants': Validators,
'/referrals': Referrals,
'/supporters': Supporters,
+ '/notifications': Notifications,
// Builders routes
'/builders': Dashboard,
diff --git a/frontend/src/components/Navbar.svelte b/frontend/src/components/Navbar.svelte
index 4fc28d7d..3174d2bf 100644
--- a/frontend/src/components/Navbar.svelte
+++ b/frontend/src/components/Navbar.svelte
@@ -1,6 +1,7 @@
+
+
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index d3a4f328..578c26a4 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -190,6 +190,14 @@ export const imageAPI = {
getCloudinaryConfig: (type) => api.get('/users/cloudinary_config/', { params: { type } })
};
+// Notifications API
+export const notificationsAPI = {
+ getAll: (params) => api.get('/notifications/', { params }),
+ getUnreadCount: () => api.get('/notifications/unread_count/'),
+ markRead: (id) => api.post(`/notifications/${id}/mark_read/`),
+ markAllRead: () => api.post('/notifications/mark_all_read/')
+};
+
// Convenience exports for profile functions
export const getCurrentUser = async () => {
const response = await usersAPI.getCurrentUser();
diff --git a/frontend/src/routes/Notifications.svelte b/frontend/src/routes/Notifications.svelte
new file mode 100644
index 00000000..6941c886
--- /dev/null
+++ b/frontend/src/routes/Notifications.svelte
@@ -0,0 +1,276 @@
+
+
+
+
+
+
Notifications
+
+ {#if unreadCount > 0}
+ {unreadCount} unread notification{unreadCount > 1 ? 's' : ''}
+ {:else}
+ You're all caught up!
+ {/if}
+
+
+ {#if unreadCount > 0}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if loading}
+
+ {:else if filteredNotifications.length === 0}
+
+
+
No {filter === 'unread' ? 'unread ' : ''}notifications
+
+ {filter === 'unread' ? 'You\'re all caught up!' : 'Notifications will appear here when stewards review your submissions.'}
+
+
+ {:else}
+
+ {#each filteredNotifications as notification}
+
handleNotificationClick(notification)}
+ >
+
+
+
+
+ {#if notification.unread}
+
+ {/if}
+
+
+ {notification.message}
+
+
+ {formatDate(notification.created_at)}
+
+
+
+
+
+ {notification.notification_type === 'accepted' ? 'Accepted' :
+ notification.notification_type === 'rejected' ? 'Rejected' :
+ notification.notification_type === 'more_info' ? 'More Info' : 'Notification'}
+
+
+
+ {#if notification.unread}
+
+ {/if}
+
+
+
+
+
+ {#if notification.data?.staff_reply || notification.data?.points || notification.data?.contribution_type}
+
+ {#if notification.data?.points}
+
+
Points Awarded
+
{notification.data.points} points
+
+ {/if}
+
+ {#if notification.data?.contribution_type}
+
+
Contribution Type
+
{notification.data.contribution_type}
+
+ {/if}
+
+ {#if notification.data?.staff_reply}
+
+
Steward Message
+
"{notification.data.staff_reply}"
+
+ {/if}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
From 204eb23311e306d34bcee7ac867da795ae65b076 Mon Sep 17 00:00:00 2001
From: Albert Martinez <58224660+albert-mr@users.noreply.github.com>
Date: Thu, 23 Oct 2025 15:21:58 +0700
Subject: [PATCH 2/2] Migrate to django-notifications-hq package
- Replace custom Notification model with django-notifications-hq
- Install from GitHub commit for Django 5.2 support
- Migrate 27 existing notifications with data preservation
- Update views to use notify.send() signal instead of direct model creation
- Add automated test suite in tests/ directory
- Update serializers for GenericForeignKey compatibility
---
backend/CLAUDE.md | 2 +-
.../0029_rename_notification_related_names.py | 26 ++
...otifications_to_django_notifications_hq.py | 122 +++++++
.../0031_delete_old_notification_model.py | 16 +
backend/contributions/models.py | 89 -----
backend/contributions/serializers.py | 23 +-
backend/contributions/views.py | 58 +--
backend/requirements.txt | 7 +-
backend/tally/settings.py | 1 +
backend/tests/__init__.py | 0
backend/tests/test_notifications.py | 339 ++++++++++++++++++
11 files changed, 559 insertions(+), 124 deletions(-)
create mode 100644 backend/contributions/migrations/0029_rename_notification_related_names.py
create mode 100644 backend/contributions/migrations/0030_migrate_notifications_to_django_notifications_hq.py
create mode 100644 backend/contributions/migrations/0031_delete_old_notification_model.py
create mode 100644 backend/tests/__init__.py
create mode 100644 backend/tests/test_notifications.py
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 30fa752a..d403eeb6 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -66,7 +66,7 @@ backend/
- ContributionType - Categories with slug field (Node Running, Blog Posts, etc.)
- ContributionTypeMultiplier - Dynamic point multipliers
- Evidence - Evidence items for contributions (text descriptions and URLs only - file uploads are disabled)
- - Notification - Temporary custom model (will migrate to django-notifications-hq)
+- **Notifications**: Uses `django-notifications-hq` (GitHub commit 4c44bcea for Django 5.2 support). Create with `notify.send()` signal.
- **reCAPTCHA**: `contributions/recaptcha_field.py`
- Custom DRF serializer field for Google reCAPTCHA v2 validation
- Validates tokens from frontend reCAPTCHA widget
diff --git a/backend/contributions/migrations/0029_rename_notification_related_names.py b/backend/contributions/migrations/0029_rename_notification_related_names.py
new file mode 100644
index 00000000..1b54d542
--- /dev/null
+++ b/backend/contributions/migrations/0029_rename_notification_related_names.py
@@ -0,0 +1,26 @@
+# Generated by Django 5.2.6 on 2025-10-23 07:26
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contributions', '0028_notification'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='notification',
+ name='recipient',
+ field=models.ForeignKey(help_text='User who receives this notification', on_delete=django.db.models.deletion.CASCADE, related_name='old_notifications', to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AlterField(
+ model_name='notification',
+ name='submission',
+ field=models.ForeignKey(help_text='The submission this notification relates to', on_delete=django.db.models.deletion.CASCADE, related_name='old_notifications', to='contributions.submittedcontribution'),
+ ),
+ ]
diff --git a/backend/contributions/migrations/0030_migrate_notifications_to_django_notifications_hq.py b/backend/contributions/migrations/0030_migrate_notifications_to_django_notifications_hq.py
new file mode 100644
index 00000000..df1048a2
--- /dev/null
+++ b/backend/contributions/migrations/0030_migrate_notifications_to_django_notifications_hq.py
@@ -0,0 +1,122 @@
+# Generated by Django 5.2.6 on 2025-10-23 07:26
+
+from django.db import migrations
+
+
+def migrate_notifications_forward(apps, schema_editor):
+ """
+ Migrate notifications from custom Notification model to django-notifications-hq.
+
+ Field mapping:
+ - recipient → recipient (same)
+ - actor → actor (same)
+ - submission → target (GenericForeignKey to SubmittedContribution)
+ - notification_type → verb
+ - message → description
+ - data → data (same)
+ - unread → unread (same)
+ - created_at → timestamp
+ """
+ # Get models
+ OldNotification = apps.get_model('contributions', 'Notification')
+ NewNotification = apps.get_model('notifications', 'Notification')
+ SubmittedContribution = apps.get_model('contributions', 'SubmittedContribution')
+ User = apps.get_model('users', 'User')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+
+ # Get ContentTypes
+ submission_ct = ContentType.objects.get(
+ app_label='contributions',
+ model='submittedcontribution'
+ )
+ user_ct = ContentType.objects.get(
+ app_label='users',
+ model='user'
+ )
+
+ # Get or create a system user for notifications without an actor
+ # Use the first superuser, or create a placeholder
+ system_user = User.objects.filter(is_staff=True).first()
+ if not system_user:
+ print("Warning: No staff user found to use as system actor for notifications")
+ return # Skip migration if no suitable user exists
+
+ # Counter for tracking
+ migrated_count = 0
+
+ # Iterate through all old notifications
+ for old_notif in OldNotification.objects.all():
+ # For notifications without an actor, use the system user
+ actor_to_use = old_notif.actor if old_notif.actor else system_user
+
+ # Create new notification with mapped fields
+ # Actor is a GenericForeignKey, so we need actor_content_type and actor_object_id
+ NewNotification.objects.create(
+ recipient=old_notif.recipient,
+ actor_content_type=user_ct,
+ actor_object_id=str(actor_to_use.id),
+ verb=old_notif.notification_type, # 'accepted', 'rejected', 'more_info'
+ description=old_notif.message,
+ target_content_type=submission_ct,
+ target_object_id=str(old_notif.submission.id), # UUID as string
+ data=old_notif.data,
+ unread=old_notif.unread,
+ timestamp=old_notif.created_at,
+ )
+ migrated_count += 1
+
+ if migrated_count > 0:
+ print(f"Successfully migrated {migrated_count} notifications to django-notifications-hq")
+
+
+def migrate_notifications_reverse(apps, schema_editor):
+ """
+ Reverse migration: copy notifications back from django-notifications-hq to custom model.
+ This is for rollback purposes.
+ """
+ OldNotification = apps.get_model('contributions', 'Notification')
+ NewNotification = apps.get_model('notifications', 'Notification')
+ SubmittedContribution = apps.get_model('contributions', 'SubmittedContribution')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+
+ # Get ContentType for SubmittedContribution
+ submission_ct = ContentType.objects.get(
+ app_label='contributions',
+ model='submittedcontribution'
+ )
+
+ # Find all notifications that have SubmittedContribution as target
+ for new_notif in NewNotification.objects.filter(target_content_type=submission_ct):
+ # Get the submission by UUID
+ try:
+ submission = SubmittedContribution.objects.get(id=new_notif.target_object_id)
+ except SubmittedContribution.DoesNotExist:
+ print(f"Warning: Submission {new_notif.target_object_id} not found, skipping notification {new_notif.id}")
+ continue
+
+ # Create old notification
+ OldNotification.objects.create(
+ recipient=new_notif.recipient,
+ actor=new_notif.actor,
+ submission=submission,
+ notification_type=new_notif.verb,
+ message=new_notif.description,
+ data=new_notif.data,
+ unread=new_notif.unread,
+ created_at=new_notif.timestamp,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contributions', '0029_rename_notification_related_names'),
+ ('notifications', '0010_rename_notification_recipient_unread_notificatio_recipie_8bedf2_idx'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ migrate_notifications_forward,
+ reverse_code=migrate_notifications_reverse
+ ),
+ ]
diff --git a/backend/contributions/migrations/0031_delete_old_notification_model.py b/backend/contributions/migrations/0031_delete_old_notification_model.py
new file mode 100644
index 00000000..f823e2a0
--- /dev/null
+++ b/backend/contributions/migrations/0031_delete_old_notification_model.py
@@ -0,0 +1,16 @@
+# Generated by Django 5.2.6 on 2025-10-23 07:40
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contributions', '0030_migrate_notifications_to_django_notifications_hq'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='Notification',
+ ),
+ ]
diff --git a/backend/contributions/models.py b/backend/contributions/models.py
index 7ba86de0..c0a62d50 100644
--- a/backend/contributions/models.py
+++ b/backend/contributions/models.py
@@ -477,92 +477,3 @@ def get_active_highlights(cls, contribution_type=None, user=None, limit=5):
)
return queryset[:limit]
-
-
-class Notification(BaseModel):
- """
- Temporary custom notification system for submission reviews.
- Will migrate to django-notifications-hq when Django 5.2 support is released.
- """
- # Recipient of the notification
- recipient = models.ForeignKey(
- settings.AUTH_USER_MODEL,
- on_delete=models.CASCADE,
- related_name='notifications',
- help_text="User who receives this notification"
- )
-
- # Actor (steward) who performed the action
- actor = models.ForeignKey(
- settings.AUTH_USER_MODEL,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='notifications_sent',
- help_text="User who triggered this notification"
- )
-
- # Submission this notification is about
- submission = models.ForeignKey(
- 'SubmittedContribution',
- on_delete=models.CASCADE,
- related_name='notifications',
- help_text="The submission this notification relates to"
- )
-
- # Notification details
- NOTIFICATION_TYPES = [
- ('accepted', 'Submission Accepted'),
- ('rejected', 'Submission Rejected'),
- ('more_info', 'More Information Requested'),
- ]
- notification_type = models.CharField(
- max_length=20,
- choices=NOTIFICATION_TYPES,
- help_text="Type of notification"
- )
-
- message = models.TextField(
- help_text="Notification message"
- )
-
- # Additional data (points awarded, staff reply, etc.)
- data = models.JSONField(
- default=dict,
- blank=True,
- help_text="Additional data about the notification"
- )
-
- # Read status
- unread = models.BooleanField(
- default=True,
- help_text="Whether the notification has been read"
- )
-
- class Meta:
- ordering = ['-created_at']
- verbose_name = "Notification"
- verbose_name_plural = "Notifications"
- indexes = [
- models.Index(fields=['recipient', 'unread', '-created_at']),
- models.Index(fields=['recipient', '-created_at']),
- ]
-
- def __str__(self):
- return f"{self.get_notification_type_display()} for {self.recipient.email}"
-
- def mark_as_read(self):
- """Mark this notification as read."""
- if self.unread:
- self.unread = False
- self.save(update_fields=['unread'])
-
- @classmethod
- def mark_all_as_read(cls, user):
- """Mark all notifications for a user as read."""
- cls.objects.filter(recipient=user, unread=True).update(unread=False)
-
- @classmethod
- def get_unread_count(cls, user):
- """Get count of unread notifications for a user."""
- return cls.objects.filter(recipient=user, unread=True).count()
diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py
index 06068c28..ee16ff8a 100644
--- a/backend/contributions/serializers.py
+++ b/backend/contributions/serializers.py
@@ -1,5 +1,6 @@
from rest_framework import serializers
-from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, Notification
+from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission
+from notifications.models import Notification
from users.serializers import UserSerializer, LightUserSerializer
from users.models import User
from .recaptcha_field import ReCaptchaField
@@ -561,10 +562,13 @@ def get_is_active(self, obj):
class NotificationSerializer(serializers.ModelSerializer):
- """Serializer for notification objects."""
+ """Serializer for django-notifications-hq Notification model."""
actor_name = serializers.SerializerMethodField()
submission_id = serializers.SerializerMethodField()
time_ago = serializers.SerializerMethodField()
+ notification_type = serializers.CharField(source='verb', read_only=True)
+ message = serializers.CharField(source='description', read_only=True)
+ created_at = serializers.DateTimeField(source='timestamp', read_only=True)
class Meta:
model = Notification
@@ -584,14 +588,21 @@ class Meta:
def get_actor_name(self, obj):
"""Get the name of the steward who took action."""
if obj.actor:
- return obj.actor.name or obj.actor.address[:10]
+ # Actor is a GenericForeignKey, should be a User
+ if hasattr(obj.actor, 'name') and obj.actor.name:
+ return obj.actor.name
+ elif hasattr(obj.actor, 'address'):
+ return obj.actor.address[:10]
return 'System'
def get_submission_id(self, obj):
- """Get submission ID."""
- return str(obj.submission.id) if obj.submission else None
+ """Get submission ID from target GenericForeignKey."""
+ # Target is a GenericForeignKey to SubmittedContribution
+ if obj.target and hasattr(obj.target, 'id'):
+ return str(obj.target.id)
+ return None
def get_time_ago(self, obj):
"""Human-readable time since notification."""
from django.utils.timesince import timesince
- return timesince(obj.created_at)
+ return timesince(obj.timestamp)
diff --git a/backend/contributions/views.py b/backend/contributions/views.py
index 4a2ac1e5..dedf6fde 100644
--- a/backend/contributions/views.py
+++ b/backend/contributions/views.py
@@ -10,7 +10,9 @@
from django.contrib import messages
from django.views.generic import ListView
from django.utils.decorators import method_decorator
-from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, Notification
+from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission
+from notifications.models import Notification
+from notifications.signals import notify
from .serializers import (ContributionTypeSerializer, ContributionSerializer,
EvidenceSerializer, SubmittedContributionSerializer,
SubmittedEvidenceSerializer, ContributionHighlightSerializer,
@@ -821,14 +823,13 @@ def review(self, request, pk=None):
submission.converted_contribution = contribution
submission.staff_reply = serializer.validated_data.get('staff_reply', '')
- # Create notification for user
- # TODO: Migrate to django-notifications-hq
- Notification.objects.create(
+ # Create notification for user using django-notifications-hq
+ notify.send(
+ sender=request.user,
recipient=submission.user,
- actor=request.user,
- submission=submission,
- notification_type='accepted',
- message=f'Your submission was accepted and awarded {serializer.validated_data["points"]} points!',
+ verb='accepted',
+ description=f'Your submission was accepted and awarded {serializer.validated_data["points"]} points!',
+ target=submission,
data={
'points': serializer.validated_data['points'],
'contribution_type': contribution_type.name
@@ -839,14 +840,13 @@ def review(self, request, pk=None):
submission.state = 'rejected'
submission.staff_reply = serializer.validated_data['staff_reply']
- # Create notification for user
- # TODO: Migrate to django-notifications-hq
- Notification.objects.create(
+ # Create notification for user using django-notifications-hq
+ notify.send(
+ sender=request.user,
recipient=submission.user,
- actor=request.user,
- submission=submission,
- notification_type='rejected',
- message=f'Your submission was rejected.',
+ verb='rejected',
+ description=f'Your submission was rejected.',
+ target=submission,
data={
'staff_reply': serializer.validated_data['staff_reply']
}
@@ -856,14 +856,13 @@ def review(self, request, pk=None):
submission.state = 'more_info_needed'
submission.staff_reply = serializer.validated_data['staff_reply']
- # Create notification for user
- # TODO: Migrate to django-notifications-hq
- Notification.objects.create(
+ # Create notification for user using django-notifications-hq
+ notify.send(
+ sender=request.user,
recipient=submission.user,
- actor=request.user,
- submission=submission,
- notification_type='more_info',
- message=f'More information requested on your submission.',
+ verb='more_info',
+ description=f'More information requested on your submission.',
+ target=submission,
data={
'staff_reply': serializer.validated_data['staff_reply']
}
@@ -1002,27 +1001,32 @@ class NotificationViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
"""Only return notifications for the authenticated user."""
+ # Note: actor and target are GenericForeignKeys, so we use prefetch_related
+ # We can select_related the content types to reduce queries
return self.request.user.notifications.all().select_related(
+ 'actor_content_type',
+ 'target_content_type'
+ ).prefetch_related(
'actor',
- 'submission',
- 'submission__contribution_type'
+ 'target'
)
@action(detail=False, methods=['get'])
def unread_count(self, request):
"""Get count of unread notifications."""
- count = Notification.get_unread_count(request.user)
+ count = request.user.notifications.unread().count()
return Response({'count': count})
@action(detail=True, methods=['post'])
def mark_read(self, request, pk=None):
"""Mark a single notification as read."""
notification = self.get_object()
- notification.mark_as_read()
+ notification.unread = False
+ notification.save(update_fields=['unread'])
return Response({'status': 'marked as read'})
@action(detail=False, methods=['post'])
def mark_all_read(self, request):
"""Mark all notifications as read."""
- Notification.mark_all_as_read(request.user)
+ request.user.notifications.mark_all_as_read()
return Response({'status': 'all marked as read'})
diff --git a/backend/requirements.txt b/backend/requirements.txt
index fe179886..1ce57b70 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -23,4 +23,9 @@ requests>=2.31.0
cryptography>=41.0.0
email-validator>=2.1.0
disposable-email-domains>=0.0.97
-django-recaptcha>=4.0.0
\ No newline at end of file
+django-recaptcha>=4.0.0
+
+# django-notifications-hq from GitHub master (1.9.0) - Django 5.2 compatibility
+# Using specific commit until 1.9.0 is officially released to PyPI
+# See: https://github.com/django-notifications/django-notifications/pull/405
+git+https://github.com/django-notifications/django-notifications.git@4c44bcea2504d9a3fcd26366b232a13732a08f2a#egg=django-notifications-hq
\ No newline at end of file
diff --git a/backend/tally/settings.py b/backend/tally/settings.py
index 345633d2..a883254a 100644
--- a/backend/tally/settings.py
+++ b/backend/tally/settings.py
@@ -61,6 +61,7 @@ def get_required_env(key):
'drf_yasg',
'django_filters',
'django_recaptcha',
+ 'notifications', # django-notifications-hq for notification system
'allow_cidr',
# Local apps
diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py
new file mode 100644
index 00000000..3e385fff
--- /dev/null
+++ b/backend/tests/test_notifications.py
@@ -0,0 +1,339 @@
+#!/usr/bin/env python
+"""
+Notification System Verification Script
+
+This script tests all notification triggers and verifies they work correctly.
+Run with: python tests/test_notifications.py
+"""
+
+import os
+import sys
+import django
+
+# Setup Django
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tally.settings')
+# Get the backend root directory (parent of tests/)
+backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, backend_dir)
+django.setup()
+
+from django.test import TestCase
+from django.contrib.auth import get_user_model
+from notifications.models import Notification
+from notifications.signals import notify
+from contributions.models import SubmittedContribution, ContributionType
+from users.models import User
+
+class NotificationVerification:
+ """Automated verification of notification system"""
+
+ def __init__(self):
+ self.passed = 0
+ self.failed = 0
+ self.results = []
+
+ def log(self, test_name, passed, message=""):
+ """Log test result"""
+ status = "✅ PASS" if passed else "❌ FAIL"
+ self.results.append(f"{status} - {test_name}: {message}")
+ if passed:
+ self.passed += 1
+ else:
+ self.failed += 1
+
+ def test_notification_creation(self):
+ """Test 1: Verify notify.send() creates notifications correctly"""
+ print("\n🔍 Test 1: Notification Creation")
+ print("=" * 60)
+
+ try:
+ # Get test users
+ users = list(User.objects.all()[:2])
+ if len(users) < 2:
+ self.log("Test 1.1: Users exist", False, "Not enough users in database")
+ return
+
+ sender = users[0]
+ recipient = users[1]
+ self.log("Test 1.1: Users exist", True, f"Found {len(users)} users")
+
+ # Get a submission
+ submission = SubmittedContribution.objects.first()
+ if not submission:
+ self.log("Test 1.2: Submission exists", False, "No submissions found")
+ return
+ self.log("Test 1.2: Submission exists", True)
+
+ # Count before
+ count_before = Notification.objects.filter(recipient=recipient).count()
+
+ # Create notification
+ notify.send(
+ sender=sender,
+ recipient=recipient,
+ verb='test_accepted',
+ description='Test notification: You earned 100 points!',
+ target=submission,
+ data={'points': 100, 'test': True}
+ )
+
+ # Count after
+ count_after = Notification.objects.filter(recipient=recipient).count()
+ created = count_after > count_before
+ self.log("Test 1.3: Notification created", created,
+ f"Before: {count_before}, After: {count_after}")
+
+ if created:
+ # Verify the notification
+ notif = Notification.objects.filter(recipient=recipient, verb='test_accepted').first()
+
+ # Check fields
+ self.log("Test 1.4: Recipient correct", notif.recipient == recipient)
+ self.log("Test 1.5: Actor correct", notif.actor == sender)
+ self.log("Test 1.6: Verb correct", notif.verb == 'test_accepted')
+ self.log("Test 1.7: Description correct",
+ 'Test notification' in notif.description)
+ self.log("Test 1.8: Target correct", notif.target == submission)
+ self.log("Test 1.9: Data correct",
+ notif.data.get('points') == 100 and notif.data.get('test') == True)
+ self.log("Test 1.10: Unread by default", notif.unread == True)
+
+ # Cleanup
+ notif.delete()
+ self.log("Test 1.11: Cleanup", True, "Test notification deleted")
+
+ except Exception as e:
+ self.log("Test 1: Exception", False, str(e))
+
+ def test_api_serialization(self):
+ """Test 2: Verify serialization works correctly"""
+ print("\n🔍 Test 2: API Serialization")
+ print("=" * 60)
+
+ try:
+ from contributions.serializers import NotificationSerializer
+
+ # Get a notification
+ notif = Notification.objects.first()
+ if not notif:
+ self.log("Test 2.1: Notification exists", False, "No notifications to serialize")
+ return
+ self.log("Test 2.1: Notification exists", True)
+
+ # Serialize
+ serializer = NotificationSerializer(notif)
+ data = serializer.data
+
+ # Check required fields
+ required_fields = ['id', 'notification_type', 'message', 'unread',
+ 'created_at', 'actor_name', 'submission_id', 'data', 'time_ago']
+
+ for field in required_fields:
+ exists = field in data
+ self.log(f"Test 2.{required_fields.index(field)+2}: Field '{field}' exists",
+ exists, f"Value: {data.get(field, 'MISSING')}")
+
+ # Check field mappings
+ self.log("Test 2.11: verb->notification_type mapping",
+ data['notification_type'] == notif.verb)
+ self.log("Test 2.12: description->message mapping",
+ data['message'] == notif.description)
+ self.log("Test 2.13: timestamp->created_at mapping",
+ 'created_at' in data)
+
+ except Exception as e:
+ self.log("Test 2: Exception", False, str(e))
+
+ def test_query_optimization(self):
+ """Test 3: Verify no N+1 queries"""
+ print("\n🔍 Test 3: Query Optimization")
+ print("=" * 60)
+
+ try:
+ from django.db import connection
+ from django.test.utils import override_settings
+
+ # Get a user with notifications
+ user = User.objects.filter(notifications__isnull=False).first()
+ if not user:
+ self.log("Test 3.1: User with notifications", False, "No users with notifications")
+ return
+ self.log("Test 3.1: User with notifications", True)
+
+ # Reset queries
+ connection.queries_log.clear()
+
+ # Query with optimization
+ notifications = user.notifications.all().select_related(
+ 'actor_content_type',
+ 'target_content_type'
+ ).prefetch_related(
+ 'actor',
+ 'target'
+ )[:10]
+
+ # Force evaluation
+ list(notifications)
+
+ query_count = len(connection.queries)
+ self.log("Test 3.2: Query count reasonable",
+ query_count < 10,
+ f"Executed {query_count} queries for 10 notifications")
+
+ except Exception as e:
+ self.log("Test 3: Exception", False, str(e))
+
+ def test_notification_methods(self):
+ """Test 4: Verify notification methods work"""
+ print("\n🔍 Test 4: Notification Methods")
+ print("=" * 60)
+
+ try:
+ # Get user with notifications
+ user = User.objects.filter(notifications__isnull=False).first()
+ if not user:
+ self.log("Test 4.1: User exists", False)
+ return
+ self.log("Test 4.1: User exists", True)
+
+ # Test unread query
+ unread = user.notifications.unread()
+ self.log("Test 4.2: Unread query works",
+ unread is not None,
+ f"Found {unread.count()} unread")
+
+ # Test mark_all_as_read
+ before_count = user.notifications.unread().count()
+ user.notifications.mark_all_as_read()
+ after_count = user.notifications.unread().count()
+
+ self.log("Test 4.3: mark_all_as_read works",
+ after_count == 0,
+ f"Before: {before_count}, After: {after_count}")
+
+ # Test mark single as read
+ if user.notifications.exists():
+ notif = user.notifications.first()
+ notif.unread = True
+ notif.save()
+
+ notif.unread = False
+ notif.save(update_fields=['unread'])
+ notif.refresh_from_db()
+
+ self.log("Test 4.4: Mark single as read",
+ notif.unread == False)
+
+ except Exception as e:
+ self.log("Test 4: Exception", False, str(e))
+
+ def test_edge_cases(self):
+ """Test 5: Edge cases and error handling"""
+ print("\n🔍 Test 5: Edge Cases")
+ print("=" * 60)
+
+ try:
+ # Test with deleted target
+ user = User.objects.first()
+
+ # Create notification with target
+ notif = Notification.objects.filter(recipient=user).first()
+ if notif:
+ # Check target can be accessed
+ try:
+ target = notif.target
+ self.log("Test 5.1: Target access", True,
+ f"Target: {target}")
+ except Exception as e:
+ self.log("Test 5.1: Target access", False, str(e))
+
+ # Test with None actor (system notification)
+ try:
+ notify.send(
+ sender=user,
+ recipient=user,
+ verb='system_test',
+ description='System notification without actor',
+ target=None,
+ data={}
+ )
+ notif = Notification.objects.filter(verb='system_test').first()
+ if notif:
+ self.log("Test 5.2: Notification without target", True)
+ notif.delete()
+ except Exception as e:
+ self.log("Test 5.2: Notification without target", False, str(e))
+
+ # Test very long text
+ try:
+ long_text = "X" * 5000
+ notify.send(
+ sender=user,
+ recipient=user,
+ verb='long_test',
+ description=long_text,
+ target=None,
+ data={}
+ )
+ notif = Notification.objects.filter(verb='long_test').first()
+ if notif:
+ self.log("Test 5.3: Very long description",
+ len(notif.description) == 5000)
+ notif.delete()
+ except Exception as e:
+ self.log("Test 5.3: Very long description", False, str(e))
+
+ # Test special characters
+ try:
+ special_text = "Test 🎉 emoji and 中文 unicode! "
+ notify.send(
+ sender=user,
+ recipient=user,
+ verb='special_test',
+ description=special_text,
+ target=None,
+ data={'emoji': '🚀', 'unicode': '中文'}
+ )
+ notif = Notification.objects.filter(verb='special_test').first()
+ if notif:
+ self.log("Test 5.4: Special characters",
+ '🎉' in notif.description and '中文' in notif.description)
+ notif.delete()
+ except Exception as e:
+ self.log("Test 5.4: Special characters", False, str(e))
+
+ except Exception as e:
+ self.log("Test 5: Exception", False, str(e))
+
+ def run_all_tests(self):
+ """Run all verification tests"""
+ print("\n" + "="*60)
+ print("🧪 NOTIFICATION SYSTEM VERIFICATION")
+ print("="*60)
+
+ self.test_notification_creation()
+ self.test_api_serialization()
+ self.test_query_optimization()
+ self.test_notification_methods()
+ self.test_edge_cases()
+
+ # Print results
+ print("\n" + "="*60)
+ print("📊 TEST RESULTS")
+ print("="*60)
+ for result in self.results:
+ print(result)
+
+ print("\n" + "="*60)
+ print(f"Total: {self.passed + self.failed} tests")
+ print(f"✅ Passed: {self.passed}")
+ print(f"❌ Failed: {self.failed}")
+ print(f"Success Rate: {(self.passed/(self.passed+self.failed)*100):.1f}%")
+ print("="*60)
+
+ return self.failed == 0
+
+if __name__ == '__main__':
+ verifier = NotificationVerification()
+ success = verifier.run_all_tests()
+ sys.exit(0 if success else 1)