Skip to content
Merged
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
4 changes: 2 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Django Settings
SECRET_KEY=your-secret-key-here
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
ALLOWED_HOSTS=localhost,127.0.0.1,testserver

# Database
# Leave DATABASE_URL empty for SQLite (development)
Expand Down Expand Up @@ -30,7 +30,7 @@ CORS_ALLOWED_ORIGINS=https://your-frontend-domain.com,https://another-domain.com
CSRF_TRUSTED_ORIGINS=https://your-frontend-domain.com,https://another-domain.com

# Ethereum Authentication
SIWE_DOMAIN=localhost
SIWE_DOMAIN=localhost:5173

# Blockchain Settings - Shared RPC (both networks on same chain)
VALIDATOR_RPC_URL=https://rpc.testnet-chain.genlayer.com
Expand Down
Binary file removed backend/2025-05-27.sqlite3
Binary file not shown.
41 changes: 39 additions & 2 deletions backend/builders/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,40 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework import status
from rest_framework.test import APITestCase

# Create your tests here.
from .models import Builder


User = get_user_model()


class BuilderAPITestCase(APITestCase):
def setUp(self):
self.user = User.objects.create_user(
email='builder-user@example.com',
password='testpass123',
)
self.client.force_authenticate(user=self.user)

def test_patch_builder_me_does_not_create_profile(self):
response = self.client.patch('/api/v1/builders/me/', {})

self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
self.assertFalse(Builder.objects.filter(user=self.user).exists())

def test_regular_user_cannot_create_builder_profile(self):
response = self.client.post('/api/v1/builders/', {})

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertFalse(Builder.objects.filter(user=self.user).exists())

def test_regular_user_cannot_mutate_arbitrary_builder_profile(self):
other_user = User.objects.create_user(
email='builder-other@example.com',
password='testpass123',
)
builder = Builder.objects.create(user=other_user)

response = self.client.patch(f'/api/v1/builders/{builder.id}/', {})

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
49 changes: 47 additions & 2 deletions backend/builders/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,45 @@ class BuilderViewSet(viewsets.ModelViewSet):
serializer_class = BuilderSerializer
permission_classes = [IsAuthenticated]

def _is_staff_mutation(self, request):
return bool(
request.user
and request.user.is_authenticated
and (request.user.is_staff or request.user.is_superuser)
)

def _deny_non_staff_mutation(self, request):
if self._is_staff_mutation(request):
return None
return Response(
{'detail': 'Only staff users can mutate builder profiles.'},
status=status.HTTP_403_FORBIDDEN,
)

def create(self, request, *args, **kwargs):
denied = self._deny_non_staff_mutation(request)
if denied is not None:
return denied
return super().create(request, *args, **kwargs)

def update(self, request, *args, **kwargs):
denied = self._deny_non_staff_mutation(request)
if denied is not None:
return denied
return super().update(request, *args, **kwargs)

def partial_update(self, request, *args, **kwargs):
denied = self._deny_non_staff_mutation(request)
if denied is not None:
return denied
return super().partial_update(request, *args, **kwargs)

def destroy(self, request, *args, **kwargs):
denied = self._deny_non_staff_mutation(request)
if denied is not None:
return denied
return super().destroy(request, *args, **kwargs)

def get_permissions(self):
"""
Allow read-only access without authentication for public endpoints.
Expand All @@ -39,7 +78,13 @@ def my_profile(self, request):
)

elif request.method == 'PATCH':
builder, created = Builder.objects.get_or_create(user=request.user)
try:
builder = Builder.objects.get(user=request.user)
except Builder.DoesNotExist:
return Response(
{'detail': 'Builder profile not found for current user.'},
status=status.HTTP_404_NOT_FOUND
)
serializer = self.get_serializer(builder, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
Expand Down Expand Up @@ -74,4 +119,4 @@ def newest_builders(self, request):
'created_at': builder.created_at
})

return Response(result)
return Response(result)
61 changes: 45 additions & 16 deletions backend/contributions/management/commands/review_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ def rule_duplicate_evidence_url(submission, evidence_items,
before comparison to prevent cosmetic variants from bypassing the check.

Args:
skip_pending: If True, only reject when an older submitted duplicate
already exists. This keeps `--submission-id` runs deterministic.
Pending submission duplicates only count when the duplicate is older,
unless skip_pending is set for a targeted command run.
Accepted contribution duplicates always count.
"""
urls_with_evidence = [(e, _normalize_url(e.url))
for e in evidence_items if e.url]
Expand Down Expand Up @@ -183,18 +184,32 @@ def _check_single_url_duplicate(submission, evidence, normalized,
)
# Check pending/accepted submitted contributions (exclude self)
others = (url_to_sub_ids.get(normalized) or set()) - {submission.id}
if skip_pending and others:
pending_states = {'pending', 'more_info_needed'}
others = set(
SubmittedContribution.objects
.filter(id__in=others)
.exclude(state__in=pending_states)
.values_list('id', flat=True)
)
if not others:
return None

if not skip_pending:
return (
'Reject: Duplicate Submission',
f'Tier 1 auto-reject: Evidence URL already exists in another '
f'submission: {evidence.url[:100]}',
)

submission_key = (submission.created_at, str(submission.id))
created_at_lookup = submitted_created_at or {}
missing_created_at_ids = [
other_id for other_id in others
if other_id not in created_at_lookup
]
if missing_created_at_ids:
created_at_lookup = {
**created_at_lookup,
**dict(
SubmittedContribution.objects
.filter(id__in=missing_created_at_ids)
.values_list('id', 'created_at')
),
}
if any(
(
created_at_lookup.get(other_id, submission.created_at),
Expand Down Expand Up @@ -367,6 +382,8 @@ def _build_url_lookup(self):
# URLs from pending/accepted submitted contributions.
# Evidence whose url_type allows duplicates is excluded so those
# URLs never participate in duplicate detection.
from contributions.url_utils import detect_url_type

submitted = (
Evidence.objects
.filter(
Expand All @@ -375,27 +392,39 @@ def _build_url_lookup(self):
],
url__gt='',
)
.exclude(url_type__allow_duplicate=True)
.values_list(
'url',
'submitted_contribution_id',
'submitted_contribution__created_at',
'url_type__allow_duplicate',
)
)
url_to_sub_ids = defaultdict(set)
submitted_created_at = {}
for url, sub_id, created_at in submitted:
for url, sub_id, created_at, allow_duplicate in submitted:
if allow_duplicate:
continue
if allow_duplicate is None:
detected = detect_url_type(url)
if detected and detected.allow_duplicate:
continue
url_to_sub_ids[_normalize_url(url)].add(sub_id)
submitted_created_at.setdefault(sub_id, created_at)

# URLs from converted/accepted contributions
accepted_urls = set(
_normalize_url(url) for url in
accepted_urls = set()
for url, allow_duplicate in (
Evidence.objects
.filter(contribution__isnull=False, url__gt='')
.exclude(url_type__allow_duplicate=True)
.values_list('url', flat=True)
)
.values_list('url', 'url_type__allow_duplicate')
):
if allow_duplicate:
continue
if allow_duplicate is None:
detected = detect_url_type(url)
if detected and detected.allow_duplicate:
continue
accepted_urls.add(_normalize_url(url))

return url_to_sub_ids, accepted_urls, submitted_created_at

Expand Down
4 changes: 3 additions & 1 deletion backend/contributions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,9 @@ class Evidence(BaseModel):

def save(self, *args, **kwargs):
if self.url:
from .url_utils import normalize_url
from .url_utils import detect_url_type, normalize_url
if self.url_type_id is None:
self.url_type = detect_url_type(self.url)
self.normalized_url = normalize_url(self.url)
else:
self.normalized_url = ''
Expand Down
21 changes: 19 additions & 2 deletions backend/contributions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,8 @@ def update(self, instance, validated_data):
evidence_items_data = self.initial_data.get('evidence_items', None)
evidence_items_validated = None
if evidence_items_data is not None:
user = self.context['request'].user
request = self.context.get('request')
user = request.user if request else instance.user
contribution_type = (
validated_data.get('contribution_type')
or instance.contribution_type
Expand Down Expand Up @@ -893,21 +894,37 @@ class StewardSubmissionReviewSerializer(serializers.Serializer):
def validate(self, data):
"""Validate the review action and required fields."""
action = data.get('action')
submission = self.context.get('submission')
request = self.context.get('request')

if action == 'accept':
if not data.get('points'):
if 'points' not in data or data.get('points') is None:
raise serializers.ValidationError({
'points': 'Points are required when accepting a submission.'
})

# Validate points are within contribution type limits
contribution_type = data.get('contribution_type')
if not contribution_type and submission:
contribution_type = submission.contribution_type
if contribution_type:
points = data.get('points')
if points < contribution_type.min_points or points > contribution_type.max_points:
raise serializers.ValidationError({
'points': f'Points must be between {contribution_type.min_points} and {contribution_type.max_points} for {contribution_type.name}.'
})

if submission and data.get('user') and data['user'] != submission.user:
reviewer_is_staff = bool(
request
and request.user
and request.user.is_authenticated
and (request.user.is_staff or request.user.is_superuser)
)
if not reviewer_is_staff:
raise serializers.ValidationError({
'user': 'Only staff users can reassign accepted contributions.'
})

# Validate highlight fields if creating highlight
if data.get('create_highlight'):
Expand Down
Loading