diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index aa2e1521..d403eeb6 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) +- **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/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/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 dc8ff0d7..c0a62d50 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -475,5 +475,5 @@ def get_active_highlights(cls, contribution_type=None, user=None, limit=5): 'contribution__user', 'contribution__contribution_type' ) - + return queryset[:limit] diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index 2e679e7b..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 +from notifications.models import Notification from users.serializers import UserSerializer, LightUserSerializer from users.models import User from .recaptcha_field import ReCaptchaField @@ -558,3 +559,50 @@ class Meta: def get_is_active(self, obj): return obj.is_active() + + +class NotificationSerializer(serializers.ModelSerializer): + """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 + 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: + # 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 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.timestamp) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 75b2e668..dedf6fde 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -11,11 +11,13 @@ from django.views.generic import ListView from django.utils.decorators import method_decorator 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, StewardSubmissionSerializer, StewardSubmissionReviewSerializer, - MissionSerializer) + MissionSerializer, NotificationSerializer) from .forms import SubmissionReviewForm from .permissions import IsSteward from leaderboard.models import GlobalLeaderboardMultiplier @@ -820,15 +822,52 @@ 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 using django-notifications-hq + notify.send( + sender=request.user, + recipient=submission.user, + 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 + } + ) + elif action == 'reject': submission.state = 'rejected' submission.staff_reply = serializer.validated_data['staff_reply'] - + + # Create notification for user using django-notifications-hq + notify.send( + sender=request.user, + recipient=submission.user, + verb='rejected', + description=f'Your submission was rejected.', + target=submission, + 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 using django-notifications-hq + notify.send( + sender=request.user, + recipient=submission.user, + verb='more_info', + description=f'More information requested on your submission.', + target=submission, + data={ + 'staff_reply': serializer.validated_data['staff_reply'] + } + ) + submission.save() return Response( @@ -949,3 +988,45 @@ 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.""" + # 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', + 'target' + ) + + @action(detail=False, methods=['get']) + def unread_count(self, request): + """Get count of unread notifications.""" + 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.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.""" + 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 c66abbd5..a883254a 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -61,8 +61,9 @@ def get_required_env(key): 'drf_yasg', 'django_filters', 'django_recaptcha', + 'notifications', # django-notifications-hq for notification system 'allow_cidr', - + # Local apps 'api', 'utils', 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) 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} +