Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/api/urls.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = [
Expand Down
37 changes: 37 additions & 0 deletions backend/contributions/migrations/0028_notification.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
Original file line number Diff line number Diff line change
@@ -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'),
),
]
Original file line number Diff line number Diff line change
@@ -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
),
]
Original file line number Diff line number Diff line change
@@ -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',
),
]
2 changes: 1 addition & 1 deletion backend/contributions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,5 +475,5 @@ def get_active_highlights(cls, contribution_type=None, user=None, limit=5):
'contribution__user',
'contribution__contribution_type'
)

return queryset[:limit]
48 changes: 48 additions & 0 deletions backend/contributions/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Loading