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,126 changes: 1,126 additions & 0 deletions backend/contributions/management/commands/review_submissions.py

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions backend/contributions/migrations/0033_submissionnote_data_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 6.0.2 on 2026-02-26 17:16

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('contributions', '0032_submittedcontribution_proposal_fields_submissionnote'),
]

operations = [
migrations.AlterModelOptions(
name='submissionnote',
options={'ordering': ['-created_at']},
),
migrations.AddField(
model_name='submissionnote',
name='data',
field=models.JSONField(blank=True, default=dict, help_text='Structured data: action, points, staff_reply, template_id, flags, confidence'),
),
]
5 changes: 5 additions & 0 deletions backend/contributions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,11 @@ class SubmissionNote(BaseModel):
default=False,
help_text="True for auto-generated proposal notes"
)
data = models.JSONField(
default=dict,
blank=True,
help_text="Structured data: action, points, staff_reply, template_id, flags, confidence"
)

class Meta:
ordering = ['-created_at']
Expand Down
9 changes: 7 additions & 2 deletions backend/contributions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ class StewardSubmissionReviewSerializer(serializers.Serializer):

# Staff reply (required for reject/more_info)
staff_reply = serializers.CharField(required=False, allow_blank=True)

# Template tracking for calibration
template_id = serializers.IntegerField(required=False, allow_null=True)

def validate(self, data):
"""Validate the review action and required fields."""
Expand Down Expand Up @@ -514,8 +517,8 @@ class SubmissionNoteSerializer(serializers.ModelSerializer):

class Meta:
model = SubmissionNote
fields = ['id', 'user', 'user_name', 'message', 'is_proposal', 'created_at']
read_only_fields = ['id', 'user', 'user_name', 'is_proposal', 'created_at']
fields = ['id', 'user', 'user_name', 'message', 'is_proposal', 'data', 'created_at']
read_only_fields = ['id', 'user', 'user_name', 'is_proposal', 'data', 'created_at']

def get_user_name(self, obj):
return obj.user.name or obj.user.address[:10] + '...'
Expand All @@ -536,6 +539,8 @@ class SubmissionProposeSerializer(serializers.Serializer):
required=False,
)
proposed_staff_reply = serializers.CharField(required=False, allow_blank=True, default='')
# Template tracking for calibration
template_id = serializers.IntegerField(required=False, allow_null=True)
proposed_create_highlight = serializers.BooleanField(default=False, required=False)
proposed_highlight_title = serializers.CharField(max_length=200, required=False, allow_blank=True, default='')
proposed_highlight_description = serializers.CharField(required=False, allow_blank=True, default='')
Expand Down
98 changes: 98 additions & 0 deletions backend/contributions/tests/test_ban_submission_block.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework import status

from contributions.models import SubmittedContribution, ContributionType, Category

User = get_user_model()


class BannedUserSubmissionBlockTest(TestCase):
"""Test that banned users cannot create new submissions."""

def setUp(self):
self.category = Category.objects.create(
name='Test Category', slug='test', description='Test',
)
self.contribution_type = ContributionType.objects.create(
name='Test Type', slug='test-type',
description='Test contribution type',
category=self.category, min_points=1, max_points=100,
)
self.user = User.objects.create_user(
email='user@test.com',
address='0x1234567890123456789012345678901234567890',
password='testpass123',
)
self.client = APIClient()

def test_normal_user_can_submit(self):
"""Non-banned user can create a submission."""
self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/submissions/', {
'contribution_type': self.contribution_type.id,
'contribution_date': timezone.now().date().isoformat(),
'notes': 'My great contribution',
'recaptcha': 'test-token',
}, format='json')
# Should not be 403 (may be 201 or 400 depending on recaptcha config)
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_banned_user_cannot_submit(self):
"""Banned user gets 403 when trying to submit."""
self.user.is_banned = True
self.user.ban_reason = 'Repeated spam'
self.user.save()

self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/submissions/', {
'contribution_type': self.contribution_type.id,
'contribution_date': timezone.now().date().isoformat(),
'notes': 'Trying to submit while banned',
'recaptcha': 'test-token',
}, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertIn('suspended', response.data['error'])

def test_banned_user_error_message_mentions_appeal(self):
"""The 403 error message tells the user about the appeal option."""
self.user.is_banned = True
self.user.save()

self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/submissions/', {
'contribution_type': self.contribution_type.id,
'contribution_date': timezone.now().date().isoformat(),
'notes': 'Test',
'recaptcha': 'test-token',
}, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertIn('appeal', response.data['error'])

def test_unbanned_user_can_submit_again(self):
"""After being unbanned, a user can submit again."""
self.user.is_banned = True
self.user.save()

self.client.force_authenticate(user=self.user)
response = self.client.post('/api/v1/submissions/', {
'contribution_type': self.contribution_type.id,
'contribution_date': timezone.now().date().isoformat(),
'notes': 'Test',
'recaptcha': 'test-token',
}, format='json')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

# Unban the user
self.user.is_banned = False
self.user.save()

response = self.client.post('/api/v1/submissions/', {
'contribution_type': self.contribution_type.id,
'contribution_date': timezone.now().date().isoformat(),
'notes': 'I am back with quality content',
'recaptcha': 'test-token',
}, format='json')
self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN)
Loading