diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 13aa53dc..a63cc8d7 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -727,9 +727,10 @@ def __str__(self): class ProjectMilestoneReview(BaseModel): """ - Latest structured Builder Project rubric proposal for a submitted - contribution. The existing SubmittedContribution proposed_* fields remain - the queue summary; this record is the detailed rubric source of truth. + Latest structured Builder Project rubric review for a submitted + contribution. This can come from a proposal or a final steward decision. + The existing SubmittedContribution proposed_* fields remain the queue + summary; this record is the detailed rubric source of truth. """ ACTION_CHOICES = [ ('accept', 'Accept'), diff --git a/backend/contributions/rubric_review.py b/backend/contributions/rubric_review.py index 8b432f90..e5a6ed79 100644 --- a/backend/contributions/rubric_review.py +++ b/backend/contributions/rubric_review.py @@ -102,10 +102,15 @@ def _normalize_score(value, section_key): return score -def normalize_rubric_review_payload(payload, proposed_action): +def normalize_rubric_review_payload( + payload, + proposed_action, + require_overall_reason=True, + action_field='proposed_action', +): if payload is None: raise serializers.ValidationError({ - 'rubric_review': 'Rubric review is required for Builder Project proposals.' + 'rubric_review': 'Rubric review is required for Builder Project reviews.' }) if not isinstance(payload, dict): raise serializers.ValidationError({ @@ -123,7 +128,7 @@ def normalize_rubric_review_payload(payload, proposed_action): 'extras', ) overall_reason = str(payload.get('overall_reason') or '').strip() - if not overall_reason: + if require_overall_reason and not overall_reason: raise serializers.ValidationError({ 'overall_reason': 'Overall reason is required.' }) @@ -131,7 +136,7 @@ def normalize_rubric_review_payload(payload, proposed_action): if gate_failures: if proposed_action != 'reject': raise serializers.ValidationError({ - 'proposed_action': 'Gate failures must be submitted as reject proposals.' + action_field: 'Gate failures must be submitted as reject reviews.' }) return { 'gate_failures': gate_failures, diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index 178a8859..50c602b0 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -895,12 +895,36 @@ class StewardSubmissionReviewSerializer(serializers.Serializer): template_id = serializers.PrimaryKeyRelatedField( queryset=ReviewTemplate.objects.all(), required=False, allow_null=True, ) + rubric_review = serializers.JSONField(required=False) 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') + current_contribution_type = submission.contribution_type if submission else None + effective_contribution_type = ( + data.get('contribution_type') + if action == 'accept' + else current_contribution_type + ) or current_contribution_type + current_requires_rubric = uses_project_rubric(current_contribution_type) + requires_rubric = uses_project_rubric(effective_contribution_type) + requires_direct_rubric = requires_rubric and action in ['accept', 'reject'] + if requires_direct_rubric or 'rubric_review' in data: + if requires_rubric: + data['rubric_review'] = normalize_rubric_review_payload( + data.get('rubric_review'), + action, + require_overall_reason=False, + action_field='action', + ) + elif current_requires_rubric: + data.pop('rubric_review', None) + else: + raise serializers.ValidationError({ + 'rubric_review': 'Rubric review is only accepted for Builder Project reviews.' + }) if action == 'accept': if 'points' not in data or data.get('points') is None: diff --git a/backend/contributions/tests/test_project_milestone_rubric.py b/backend/contributions/tests/test_project_milestone_rubric.py index d2437976..9f367021 100644 --- a/backend/contributions/tests/test_project_milestone_rubric.py +++ b/backend/contributions/tests/test_project_milestone_rubric.py @@ -212,6 +212,213 @@ def test_project_accept_proposal_allows_no_points(self): self.assertIsNotNone(note) self.assertNotIn('points**', note.message) + def test_direct_project_accept_and_reject_require_rubric_review(self): + for action in ['accept', 'reject']: + submission = self.create_submission() + data = {'action': action} + if action == 'accept': + data.update({ + 'points': 75, + 'contribution_type': self.project_type.id, + 'user': self.submitter.id, + }) + else: + data['staff_reply'] = 'Not enough project evidence.' + + with self.subTest(action=action): + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data=data, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.assertIn('rubric_review', response.data) + self.assertFalse(ProjectMilestoneReview.objects.filter(submitted_contribution=submission).exists()) + + def test_direct_standard_accept_does_not_require_rubric_review(self): + submission = self.create_submission(self.standard_type) + + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'accept', + 'points': 5, + 'contribution_type': self.standard_type.id, + 'user': self.submitter.id, + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.state, 'accepted') + self.assertFalse(ProjectMilestoneReview.objects.filter(submitted_contribution=submission).exists()) + + def test_direct_standard_submission_reclassified_to_project_requires_and_stores_rubric(self): + submission = self.create_submission(self.standard_type) + + missing_rubric_response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'accept', + 'points': 75, + 'contribution_type': self.project_type.id, + 'user': self.submitter.id, + }, + content_type='application/json', + ) + + self.assertEqual(missing_rubric_response.status_code, 400) + self.assertIn('rubric_review', missing_rubric_response.data) + + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'accept', + 'points': 75, + 'contribution_type': self.project_type.id, + 'user': self.submitter.id, + 'rubric_review': rubric_payload(overall_reason=''), + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.state, 'accepted') + self.assertEqual(submission.contribution_type, self.project_type) + review = ProjectMilestoneReview.objects.get(submitted_contribution=submission) + self.assertEqual(review.action, 'accept') + self.assertEqual(review.review_flow, ContributionType.REVIEW_FLOW_BUILDER_PROJECT) + self.assertEqual(response.data['rubric_review']['sections'], review.sections) + + def test_direct_project_more_info_clears_stale_proposal_rubric_review(self): + submission = self.create_submission() + self.client.post( + f'/api/v1/steward-submissions/{submission.id}/propose/', + data={ + 'proposed_action': 'accept', + 'proposed_contribution_type': self.project_type.id, + 'proposed_user': self.submitter.id, + 'rubric_review': rubric_payload(), + }, + content_type='application/json', + ) + self.assertTrue(ProjectMilestoneReview.objects.filter(submitted_contribution=submission).exists()) + + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'more_info', + 'staff_reply': 'Please add clearer milestone evidence.', + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.state, 'more_info_needed') + self.assertIsNone(submission.proposed_action) + self.assertFalse(ProjectMilestoneReview.objects.filter(submitted_contribution=submission).exists()) + self.assertIsNone(response.data['rubric_review']) + + def test_direct_project_accept_stores_rubric_review(self): + submission = self.create_submission() + payload = rubric_payload( + sections={ + 'genlayer_fit': {'score': 4}, + 'contract_quality': {'score': 3, 'reason': ''}, + 'engineering': {'score': 3, 'reason': ' '}, + 'frontend_ux': {'score': 2}, + }, + extras=['live_deployment'], + overall_reason='', + ) + + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'accept', + 'points': 75, + 'contribution_type': self.project_type.id, + 'user': self.submitter.id, + 'rubric_review': payload, + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.state, 'accepted') + review = ProjectMilestoneReview.objects.get(submitted_contribution=submission) + self.assertEqual(review.action, 'accept') + self.assertIsNone(review.confidence) + self.assertEqual(review.gate_failures, []) + self.assertEqual(review.extras, ['live_deployment']) + self.assertEqual(review.overall_reason, '') + self.assertEqual(review.sections['genlayer_fit']['score'], 4) + self.assertEqual(review.sections['genlayer_fit']['reason'], '') + self.assertEqual(review.sections['engineering']['reason'], '') + self.assertEqual(response.data['rubric_review']['sections'], review.sections) + + note = SubmissionNote.objects.filter( + submitted_contribution=submission, + is_proposal=False, + ).first() + self.assertIsNotNone(note) + self.assertEqual(note.data['rubric_review_id'], review.id) + self.assertIn('Rubric scores', note.message) + + def test_direct_project_reject_stores_gate_failure_rubric_review(self): + submission = self.create_submission() + + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'reject', + 'staff_reply': 'The repository does not build.', + 'rubric_review': gate_payload(), + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + submission.refresh_from_db() + self.assertEqual(submission.state, 'rejected') + review = ProjectMilestoneReview.objects.get(submitted_contribution=submission) + self.assertEqual(review.action, 'reject') + self.assertEqual(review.gate_failures, ['repo_does_not_build']) + self.assertEqual(review.sections, {}) + self.assertEqual(response.data['rubric_review']['gate_failures'], ['repo_does_not_build']) + + note = SubmissionNote.objects.filter( + submitted_contribution=submission, + is_proposal=False, + ).first() + self.assertIsNotNone(note) + self.assertEqual(note.data['rubric_review_id'], review.id) + self.assertIn('Gate failed', note.message) + + def test_direct_project_accept_rejects_gate_failure_rubric_review(self): + submission = self.create_submission() + + response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + data={ + 'action': 'accept', + 'points': 75, + 'contribution_type': self.project_type.id, + 'user': self.submitter.id, + 'rubric_review': gate_payload(), + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.assertIn('action', response.data) + self.assertFalse(ProjectMilestoneReview.objects.filter(submitted_contribution=submission).exists()) + def test_project_reject_proposal_stores_scores_reasons_and_extras_when_gate_passes(self): submission = self.create_submission() payload = rubric_payload( diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index 88de01f6..866725f7 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -235,6 +235,52 @@ def test_steward_can_review_submissions(self): self.assertIsNotNone(self.submission.converted_contribution) self.assertEqual(self.submission.converted_contribution.points, 50) + def test_second_steward_cannot_accept_already_accepted_submission(self): + """A stale second accept must not create another contribution.""" + second_steward_user = User.objects.create_user( + email='second-steward@test.com', + address='0x3333333333333333333333333333333333333333', + password='testpass123', + ) + second_steward = Steward.objects.create(user=second_steward_user) + StewardPermission.objects.create( + steward=second_steward, + contribution_type=self.contribution_type, + action='accept', + ) + + self.client.force_authenticate(user=self.steward_user) + first_response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id + }, + format='json' + ) + self.assertEqual(first_response.status_code, status.HTTP_200_OK) + self.submission.refresh_from_db() + first_contribution_id = self.submission.converted_contribution_id + + second_client = APIClient() + second_client.force_authenticate(user=second_steward_user) + second_response = second_client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 75, + 'contribution_type': self.contribution_type.id + }, + format='json' + ) + + self.assertEqual(second_response.status_code, status.HTTP_400_BAD_REQUEST) + self.submission.refresh_from_db() + self.assertEqual(self.submission.reviewed_by, self.steward_user) + self.assertEqual(self.submission.converted_contribution_id, first_contribution_id) + self.assertEqual(Contribution.objects.count(), 1) + def test_accept_checks_permission_against_final_contribution_type(self): self.client.force_authenticate(user=self.steward_user) @@ -329,6 +375,32 @@ def test_steward_can_request_more_info(self): self.submission.refresh_from_db() self.assertEqual(self.submission.state, 'more_info_needed') self.assertEqual(self.submission.staff_reply, 'Please provide URL evidence') + + def test_steward_can_accept_more_info_needed_submission(self): + """A submission waiting for more info remains reviewable after follow-up.""" + self.submission.state = 'more_info_needed' + self.submission.reviewed_by = self.steward_user + self.submission.reviewed_at = timezone.now() + self.submission.staff_reply = 'Please provide URL evidence' + self.submission.last_edited_at = timezone.now() + timezone.timedelta(minutes=1) + self.submission.save() + + self.client.force_authenticate(user=self.steward_user) + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.submission.refresh_from_db() + self.assertEqual(self.submission.state, 'accepted') + self.assertIsNotNone(self.submission.converted_contribution) + self.assertEqual(self.submission.converted_contribution.points, 50) def test_steward_can_create_highlight(self): """Test that stewards can create highlights when accepting.""" diff --git a/backend/contributions/tests/test_steward_submission_search.py b/backend/contributions/tests/test_steward_submission_search.py new file mode 100644 index 00000000..14aaf05b --- /dev/null +++ b/backend/contributions/tests/test_steward_submission_search.py @@ -0,0 +1,167 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from contributions.models import Category, Contribution, ContributionType, Evidence, SubmittedContribution +from leaderboard.models import GlobalLeaderboardMultiplier +from stewards.models import Steward, StewardPermission + + +User = get_user_model() + + +class StewardSubmissionSearchTest(TestCase): + """Test steward submission search and ordering behavior.""" + + def setUp(self): + self.category = Category.objects.create( + name='Test Category', + slug='test', + description='Test category', + ) + self.contribution_type = ContributionType.objects.create( + name='Test Type', + slug='test-type', + description='Test contribution type', + category=self.category, + min_points=0, + max_points=100, + ) + GlobalLeaderboardMultiplier.objects.create( + contribution_type=self.contribution_type, + multiplier_value=1, + valid_from=timezone.now() - timezone.timedelta(days=1), + ) + self.regular_user = User.objects.create_user( + email='regular@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + self.steward_user = User.objects.create_user( + email='steward@test.com', + address='0x0987654321098765432109876543210987654321', + password='testpass123', + ) + self.steward = Steward.objects.create(user=self.steward_user) + StewardPermission.objects.create( + steward=self.steward, + contribution_type=self.contribution_type, + action='accept', + ) + self.client = APIClient() + self.client.force_authenticate(user=self.steward_user) + + def _create_accepted_submission( + self, + *, + points=50, + reviewed_at=None, + submitted_url=None, + converted_url=None, + title='Accepted submission', + ) -> SubmittedContribution: + contribution = Contribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + points=points, + title=title, + notes=f'{title} contribution notes', + ) + if converted_url: + Evidence.objects.create( + contribution=contribution, + url=converted_url, + description=f'{title} converted evidence', + ) + + submission = SubmittedContribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + notes=f'{title} submission notes', + title=title, + state='accepted', + reviewed_by=self.steward_user, + reviewed_at=reviewed_at or timezone.now(), + converted_contribution=contribution, + ) + if submitted_url: + Evidence.objects.create( + submitted_contribution=submission, + url=submitted_url, + description=f'{title} submitted evidence', + ) + return submission + + def test_accepted_submission_search_matches_submitted_and_converted_evidence(self): + """Accepted search covers original submitted evidence and copied contribution evidence.""" + submitted_match = self._create_accepted_submission( + title='Submitted evidence match', + submitted_url='https://x.com/FIREDRAGON10101/status/2060708443153928646', + ) + converted_match = self._create_accepted_submission( + title='Converted evidence match', + converted_url='https://github.com/GenLayerLabs/search-demo', + ) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'accepted', + 'include_content': 'https://x.com/FIREDRAGON10101/status/2060708443153928646', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = {str(item['id']) for item in response.data['results']} + self.assertEqual(result_ids, {str(submitted_match.id)}) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'accepted', + 'include_content': 'https://github.com/GenLayerLabs/search-demo', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = {str(item['id']) for item in response.data['results']} + self.assertEqual(result_ids, {str(converted_match.id)}) + + def test_accepted_submission_search_matches_normalized_url(self): + """Tracking params should not prevent URL searches from finding accepted evidence.""" + submission = self._create_accepted_submission( + submitted_url='https://x.com/FIREDRAGON10101/status/2060708443153928646', + ) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'accepted', + 'include_content': 'https://x.com/FIREDRAGON10101/status/2060708443153928646?s=20', + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = {str(item['id']) for item in response.data['results']} + self.assertEqual(result_ids, {str(submission.id)}) + + def test_accepted_submissions_can_order_by_points_and_reviewed_time(self): + older_low = self._create_accepted_submission( + points=20, + reviewed_at=timezone.now() - timezone.timedelta(days=2), + title='Older low points', + ) + newer_high = self._create_accepted_submission( + points=90, + reviewed_at=timezone.now() - timezone.timedelta(days=1), + title='Newer high points', + ) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'accepted', + 'ordering': '-converted_contribution__frozen_global_points', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = [str(item['id']) for item in response.data['results']] + self.assertEqual(result_ids[:2], [str(newer_high.id), str(older_low.id)]) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'accepted', + 'ordering': '-reviewed_at', + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = [str(item['id']) for item in response.data['results']] + self.assertEqual(result_ids[:2], [str(newer_high.id), str(older_low.id)]) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index d7d8a9d1..c4881d1e 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -1100,19 +1100,44 @@ class StewardSubmissionFilterSet(FilterSet): has_appeal = BooleanFilter(field_name='has_appeal') resubmitted_more_info = BooleanFilter(method='filter_resubmitted_more_info') + def _normalized_url_query(self, term): + if '://' not in term and not term.lower().startswith('www.'): + return Q() + normalized = normalize_url(term) + if not normalized: + return Q() + return Q(normalized_url=normalized) + + def _evidence_content_query(self, term): + return ( + Q(url__icontains=term) | + Q(description__icontains=term) | + self._normalized_url_query(term) + ) + + def _content_query(self, term): + submitted_evidence = Evidence.objects.filter( + submitted_contribution=OuterRef('pk') + ).filter(self._evidence_content_query(term)) + converted_evidence = Evidence.objects.filter( + contribution_id=OuterRef('converted_contribution_id') + ).filter(self._evidence_content_query(term)) + return ( + Q(title__icontains=term) | + Q(notes__icontains=term) | + Q(converted_contribution__title__icontains=term) | + Q(converted_contribution__notes__icontains=term) | + Exists(submitted_evidence) | + Exists(converted_evidence) + ) + def filter_search(self, queryset, name, value): - """General search across user name/address, notes, and evidence URLs.""" + """General search across submitter, title, notes, and evidence.""" if value: - has_matching_evidence = Evidence.objects.filter( - submitted_contribution=OuterRef('pk') - ).filter( - Q(url__icontains=value) | Q(description__icontains=value) - ) return queryset.filter( Q(user__name__icontains=value) | Q(user__address__icontains=value) | - Q(notes__icontains=value) | - Exists(has_matching_evidence) + self._content_query(value) ) return queryset @@ -1206,37 +1231,21 @@ def filter_exclude_state(self, queryset, name, value): return queryset def filter_exclude_content(self, queryset, name, value): - """Exclude submissions containing text in notes or evidence. Supports comma-separated values.""" + """Exclude submissions containing text in title, notes, or evidence.""" if value: for term in value.split(','): term = term.strip() if term: - has_matching_evidence = Evidence.objects.filter( - submitted_contribution=OuterRef('pk') - ).filter( - Q(url__icontains=term) | Q(description__icontains=term) - ) - queryset = queryset.exclude( - Exists(has_matching_evidence) | - Q(notes__icontains=term) - ) + queryset = queryset.exclude(self._content_query(term)) return queryset def filter_include_content(self, queryset, name, value): - """Include ONLY submissions containing text in notes or evidence. Supports comma-separated values.""" + """Include ONLY submissions containing text in title, notes, or evidence.""" if value: for term in value.split(','): term = term.strip() if term: - has_matching_evidence = Evidence.objects.filter( - submitted_contribution=OuterRef('pk') - ).filter( - Q(url__icontains=term) | Q(description__icontains=term) - ) - queryset = queryset.filter( - Exists(has_matching_evidence) | - Q(notes__icontains=term) - ) + queryset = queryset.filter(self._content_query(term)) return queryset def filter_exclude_empty_evidence(self, queryset, name, value): @@ -1711,7 +1720,12 @@ class StewardSubmissionViewSet(viewsets.ModelViewSet): permission_classes = [IsSteward] filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_class = StewardSubmissionFilterSet - ordering_fields = ['created_at', 'contribution_date'] + ordering_fields = [ + 'created_at', + 'contribution_date', + 'reviewed_at', + 'converted_contribution__frozen_global_points', + ] ordering = ['-created_at'] def get_permissions(self): @@ -1812,9 +1826,27 @@ def get_serializer_context(self): return context @action(detail=True, methods=['post'], url_path='review') + @transaction.atomic def review(self, request, pk=None): """Review and take action on a submission.""" - submission = self.get_object() + submission = get_object_or_404( + self._visible_submission_queryset( + SubmittedContribution.objects.select_for_update(of=('self',)) + ).select_related( + 'user', + 'contribution_type', + 'contribution_type__category', + 'mission', + ).prefetch_related('evidence_items'), + pk=pk, + ) + self.check_object_permissions(request, submission) + + if submission.state not in ['pending', 'more_info_needed']: + return Response( + {'detail': 'Only pending submissions or submissions awaiting more information can be reviewed.'}, + status=status.HTTP_400_BAD_REQUEST + ) serializer = StewardSubmissionReviewSerializer( data=request.data, @@ -1932,22 +1964,50 @@ def review(self, request, pk=None): submission.save() + requires_project_rubric = uses_project_rubric( + final_contribution_type if action_name == 'accept' else submission.contribution_type + ) + rubric_review = serializer.validated_data.get('rubric_review') + rubric_record = None + if rubric_review and requires_project_rubric: + rubric_record, _ = ProjectMilestoneReview.objects.update_or_create( + submitted_contribution=submission, + defaults={ + 'proposer': request.user, + 'review_flow': ( + final_contribution_type + if action_name == 'accept' + else submission.contribution_type + ).review_flow, + 'action': action_name, + 'confidence': None, + 'gate_failures': rubric_review['gate_failures'], + 'sections': rubric_review['sections'], + 'extras': rubric_review['extras'], + 'overall_reason': rubric_review['overall_reason'], + }, + ) + else: + ProjectMilestoneReview.objects.filter(submitted_contribution=submission).delete() + # Create CRM note recording the final decision from .models import SubmissionNote reviewer_name = request.user.name or request.user.address[:10] + '...' pts_str = f" with **{serializer.validated_data.get('points', '')} points**" if action_name == 'accept' else '' reply_text = serializer.validated_data.get('staff_reply', '') or '' reply_str = f"\n\n> {reply_text}" if reply_text and action_name in ('reject', 'more_info') else '' + rubric_str = f"\n\n{rubric_summary_text(rubric_review)}" if rubric_review else '' SubmissionNote.objects.create( submitted_contribution=submission, user=request.user, - message=f"Reviewed: **{action_name}**{pts_str} by {reviewer_name}{reply_str}", + message=f"Reviewed: **{action_name}**{pts_str} by {reviewer_name}{reply_str}{rubric_str}", is_proposal=False, data={ 'action': action_name, 'points': serializer.validated_data.get('points'), 'staff_reply': reply_text, 'template_id': serializer.validated_data['template_id'].id if serializer.validated_data.get('template_id') else None, + 'rubric_review_id': rubric_record.id if rubric_record else None, }, ) diff --git a/backend/stewards/migrations/0010_builder_project_gate_templates.py b/backend/stewards/migrations/0010_builder_project_gate_templates.py new file mode 100644 index 00000000..68450d2b --- /dev/null +++ b/backend/stewards/migrations/0010_builder_project_gate_templates.py @@ -0,0 +1,82 @@ +""" +Create/update reject templates for Builder Project gate failures. +""" + +from django.db import migrations + + +TEMPLATES = [ + { + 'label': 'Reject: Project Has No Real GenLayer Contract', + 'action': 'reject', + 'text': ( + "Thanks for your submission. This project does not qualify as a " + "Builder Project because we could not verify a real GenLayer " + "contract as part of the work. Builder Project submissions need " + "to include a working GenLayer contract or intelligent contract " + "implementation with evidence that it is used by the project." + ), + }, + { + 'label': 'Reject: GenLayer Is Branding Only', + 'action': 'reject', + 'text': ( + "Thanks for your submission. This project does not qualify as a " + "Builder Project because GenLayer appears to be used only as " + "branding or description, without the project actually calling or " + "using a GenLayer contract. Please resubmit if you can provide " + "evidence of real GenLayer contract integration." + ), + }, + { + 'label': 'Reject: Project Does Not Build', + 'action': 'reject', + 'text': ( + "Thanks for your submission. We could not accept this Builder " + "Project because the repository does not build or the project " + "does not work from the submitted evidence. Please resubmit with " + "a working repository, setup instructions, and any deployment or " + "demo evidence needed to verify it." + ), + }, + { + 'label': 'Reject: Empty Fork or Boilerplate', + 'action': 'reject', + 'text': ( + "Thanks for your submission. This project does not qualify as a " + "Builder Project because it appears to be an empty fork, a plain " + "boilerplate project, or a renamed example without enough original " + "implementation. Please resubmit once the project includes " + "substantial original work." + ), + }, +] + + +def create_templates(apps, schema_editor): + ReviewTemplate = apps.get_model('stewards', 'ReviewTemplate') + for template in TEMPLATES: + ReviewTemplate.objects.update_or_create( + label=template['label'], + defaults={ + 'text': template['text'], + 'action': template['action'], + }, + ) + + +def remove_templates(apps, schema_editor): + ReviewTemplate = apps.get_model('stewards', 'ReviewTemplate') + for template in TEMPLATES: + ReviewTemplate.objects.filter(label=template['label']).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('stewards', '0009_tier1_review_templates'), + ] + + operations = [ + migrations.RunPython(create_templates, remove_templates), + ] diff --git a/frontend/src/components/StewardSearchBar.svelte b/frontend/src/components/StewardSearchBar.svelte index 6c12cfe9..9efdb4c5 100644 --- a/frontend/src/components/StewardSearchBar.svelte +++ b/frontend/src/components/StewardSearchBar.svelte @@ -7,7 +7,7 @@ stewardsList = [], templates = [], missions = [], - placeholder = 'type:blog-post reviewed:me mission:name...', + placeholder = 'Search URL or text, or type sort:-reviewed...', onSearch = () => {} } = $props(); @@ -42,7 +42,7 @@ { name: 'template', description: 'Filter by review template', values: () => templates.map(t => t.label.toLowerCase().replace(/\s+/g, '-')) }, { name: 'mission', description: 'Filter by mission', values: () => ['none', ...missions.map(m => m.name.toLowerCase().replace(/\s+/g, '-'))] }, { name: 'min-contributions', description: 'Min accepted contributions', values: () => ['1', '2', '3', '4', '5'] }, - { name: 'sort', description: 'Sort order', values: () => ['created', '-created', 'date', '-date'] } + { name: 'sort', description: 'Sort order', values: () => ['created', '-created', 'date', '-date', 'reviewed', '-reviewed', 'points', '-points'] } ]; function getCurrentWord() { @@ -229,31 +229,42 @@ {#if showHelp}
-
Search Syntax
+
Submission Search
-
type:blog-postFilter by contribution type
-
status:canceledFilter by review status
-
category:builderFilter by category (builder, validator)
-
from:usernameSearch by user name or address
-
assigned:meFilter by assignment (me, unassigned, name)
-
reviewed:meFilter by steward who accepted/rejected it
-
proposed-by:aiOnly active proposals from the AI reviewer
-
exclude:medium.comExclude submissions containing text
-
include:genlayerOnly show submissions containing text
-
has:urlOnly submissions with URLs
-
has:proposalOnly submissions with a proposal
-
has:appealOnly submissions appealed by submitters
-
no:urlOnly submissions without URLs
-
is:interestingOnly submissions flagged as interesting
+
Common
+
https://x.com/...Search submitter, title, notes, and evidence
+
include:genlayerRequire matching title, notes, or evidence
+
exclude:medium.comHide matching title, notes, or evidence
+
type:blog-postContribution type
+
category:builderCategory slug
+
from:aliceSubmitter name or address
+
assigned:meAssigned steward (me, unassigned, name)
+
reviewed:meSteward who took the final review action
+
mission:nameMission name, ID, or none
+
has:urlOnly submissions with URL evidence
+
no:urlOnly submissions without URL evidence
+
is:interestingFlagged as interesting
is:resubmittedMore-info requests resubmitted for review
-
not:interestingExclude submissions flagged as interesting
-
proposal:rejectFilter by proposed action (accept, reject, more-info)
-
confidence:highFilter by proposal confidence (high, medium, low)
-
mission:nameFilter by mission (or "none")
-
template:spamFilter by review template used
-
min-contributions:3Users with N+ accepted contributions
-
sort:-createdSort order (created, -created, date, -date)
+
+
+
Status and Sorting
+
Status dropdownPrimary control for Pending, Accepted, Rejected, Canceled
+
status:acceptedTyped status filter for combined queries
+
sort:-reviewedLatest reviewed or accepted first
+
sort:-pointsHighest accepted points first
+
sort:-createdNewest submission first
+
sort:-dateNewest submitted contribution date first
+
+
+
Advanced
+
has:proposalOnly submissions with an active proposal
+
proposed-by:aiOnly active proposals from the AI reviewer
+
proposal:rejectProposed action (accept, reject, more-info)
+
confidence:highProposal confidence
+
template:spamProposal template
+
has:appealAppealed by submitter
+
min-contributions:3Submitters with N+ accepted submissions
Negation
@@ -264,12 +275,12 @@
Examples
assigned:me exclude:medium.com has:url
-
has:proposal proposed-by:ai confidence:low
-
status:accepted reviewed:alice
-
from:alice -type:referral min-contributions:2
+
status:accepted sort:-points
+
status:accepted reviewed:alice sort:-reviewed
+
https://x.com/user/status/123
type:bug-report -mission:wallet-login is:resubmitted
-
All terms must use tags. Unrecognized text is ignored.
+
Untagged text and URLs search submitter, title, notes, and evidence. Use quotes for values with spaces.
{/if} diff --git a/frontend/src/components/SubmissionCard.svelte b/frontend/src/components/SubmissionCard.svelte index 4825418f..6aee84d6 100644 --- a/frontend/src/components/SubmissionCard.svelte +++ b/frontend/src/components/SubmissionCard.svelte @@ -69,16 +69,6 @@ let canCopyReviewContext = $derived( showReviewForm && !isOwnSubmission && submission.state === 'pending' ); - let isProjectReview = $derived( - enableRubricReview && isProjectReviewFlow(submission.contribution_type_details?.review_flow) - ); - let rubricState = $derived({ - gateFailures: rubricGateFailures, - sections: rubricSections, - extras: rubricExtras, - overallReason: rubricOverallReason - }); - let hasRubricGateFailures = $derived(rubricGateFailures.length > 0); let textOnlyEvidence = $derived( (submission.evidence_items || []).filter(evidence => !evidence?.url && evidence?.description) ); @@ -340,13 +330,43 @@ selectedUserDetails?.address || (selectedUser ? `User #${selectedUser}` : 'Current submitter') ); - let selectedTypeDetails = $derived(contributionTypes.find(t => t.id === selectedType)); + let selectedTypeDetails = $derived( + contributionTypes.find(t => String(t.id) === String(selectedType)) || + (String(selectedType) === String(submission.contribution_type) ? submission.contribution_type_details : null) + ); + let isProjectReview = $derived( + enableRubricReview && isProjectReviewFlow( + reviewAction === 'accept' || (reviewAction === 'propose' && proposedAction === 'accept') + ? selectedTypeDetails?.review_flow + : submission.contribution_type_details?.review_flow + ) + ); + let rubricState = $derived({ + gateFailures: rubricGateFailures, + sections: rubricSections, + extras: rubricExtras, + overallReason: rubricOverallReason + }); + let hasRubricGateFailures = $derived(rubricGateFailures.length > 0); + let activeRubricAction = $derived( + reviewAction === 'propose' + ? proposedAction + : normalizeAction(reviewAction) || normalizeProposedAction(submission.proposed_action) + ); + let isProposalRubric = $derived(reviewAction === 'propose'); + let showProjectRubric = $derived( + isProjectReview && (reviewAction === 'accept' || reviewAction === 'reject' || reviewAction === 'propose') + ); + let proposalNoticeTone = $derived(getRubricTone(normalizeProposedAction(submission.proposed_action), true)); + let rubricTone = $derived(getRubricTone(activeRubricAction, isProposalRubric)); let points = $state(reviewData?.points || submission.proposed_points || submission.contribution_type_details?.min_points || 0); let staffReply = $state(reviewData?.staff_reply || ''); let createHighlight = $state(reviewData?.create_highlight || false); let highlightTitle = $state(reviewData?.highlight_title || ''); let highlightDescription = $state(reviewData?.highlight_description || ''); let selectedTemplateId = $state(null); + let autoSelectedGateKey = $state(null); + let autoSelectedGateTemplateText = $state(''); // For ContributionSelection component let selectedCategory = $state(submission.contribution_type_details?.category || 'validator'); @@ -369,6 +389,78 @@ return normalized === 'propose' ? null : normalized; } + function getRubricTone(action, isProposal = true) { + const noun = isProposal ? 'proposal' : 'evaluation'; + switch (action) { + case 'accept': + return { + label: `Accept ${noun}`, + shortLabel: 'Accept', + description: isProposal + ? 'This rubric supports accepting the project contribution.' + : 'Score the project before accepting. Criterion reasons are not needed for a final decision.', + border: 'border-emerald-300', + container: 'bg-emerald-50', + header: 'border-emerald-200 bg-emerald-100/70', + title: 'text-emerald-950', + body: 'text-emerald-800', + pill: 'bg-emerald-600 text-white', + focus: 'focus:border-emerald-500 focus:ring-emerald-400', + button: 'bg-emerald-600 hover:bg-emerald-700' + }; + case 'reject': + return { + label: `Reject ${noun}`, + shortLabel: 'Reject', + description: hasRubricGateFailures + ? `One or more gate failures require this to stay a reject ${noun}.` + : isProposal + ? 'This rubric supports rejecting the project contribution.' + : 'Score the project before rejecting, or select a gate failure to use its rejection template.', + border: 'border-red-300', + container: 'bg-red-50', + header: 'border-red-200 bg-red-100/70', + title: 'text-red-950', + body: 'text-red-800', + pill: 'bg-red-600 text-white', + focus: 'focus:border-red-500 focus:ring-red-400', + button: 'bg-red-600 hover:bg-red-700' + }; + case 'more_info': + return { + label: isProposal ? 'Request-info proposal' : 'Request-info evaluation', + shortLabel: 'Request info', + description: isProposal + ? 'This rubric supports asking the contributor for more evidence or clarification.' + : 'Use this when the project cannot be evaluated from the current evidence.', + border: 'border-blue-300', + container: 'bg-blue-50', + header: 'border-blue-200 bg-blue-100/70', + title: 'text-blue-950', + body: 'text-blue-800', + pill: 'bg-blue-600 text-white', + focus: 'focus:border-blue-500 focus:ring-blue-400', + button: 'bg-blue-600 hover:bg-blue-700' + }; + default: + return { + label: isProposal ? 'Proposal' : 'Evaluation', + shortLabel: isProposal ? 'Proposal' : 'Evaluation', + description: isProposal + ? 'Choose the proposed action before submitting the rubric.' + : 'Choose the final decision before using the rubric.', + border: 'border-amber-300', + container: 'bg-amber-50', + header: 'border-amber-200 bg-amber-100/70', + title: 'text-amber-950', + body: 'text-amber-800', + pill: 'bg-amber-600 text-white', + focus: 'focus:border-amber-500 focus:ring-amber-400', + button: 'bg-amber-600 hover:bg-amber-700' + }; + } + } + function canUseReviewAction(action) { const normalized = normalizeAction(action); if (normalized === 'accept') return canAccept; @@ -434,6 +526,12 @@ } }); + $effect(() => { + if (hasRubricGateFailures && proposedAction !== 'reject') { + proposedAction = 'reject'; + } + }); + // Sync selected contribution type with the ContributionSelection component $effect(() => { if (selectedContributionTypeObj && selectedContributionTypeObj.id !== selectedType) { @@ -525,16 +623,49 @@ return type?.name || 'Contribution'; } + function getGateFailure(key) { + return RUBRIC_GATE_FAILURES.find(gate => gate.key === key) || null; + } + + function getGateFailureTemplate(key) { + const gate = getGateFailure(key); + if (!gate?.templateLabel) return null; + return rejectTemplates.find(template => template.label === gate.templateLabel) || null; + } + + function applyTemplate(template) { + staffReply = template.text; + selectedTemplateId = template.id; + } + + function applyGateFailureTemplate(key) { + const template = getGateFailureTemplate(key); + if (!template) return; + applyTemplate(template); + autoSelectedGateKey = key; + autoSelectedGateTemplateText = template.text; + } + + function clearAutoGateTemplateIfUnchanged() { + if (autoSelectedGateTemplateText && staffReply === autoSelectedGateTemplateText) { + staffReply = ''; + selectedTemplateId = null; + } + autoSelectedGateKey = null; + autoSelectedGateTemplateText = ''; + } + function handleTemplateSelect(event) { const templateId = event.target.value; + autoSelectedGateKey = null; + autoSelectedGateTemplateText = ''; if (!templateId) { selectedTemplateId = null; return; } const template = templates.find(t => String(t.id) === templateId); if (template) { - staffReply = template.text; - selectedTemplateId = template.id; + applyTemplate(template); } } @@ -548,11 +679,24 @@ function toggleRubricGate(key) { if (rubricGateFailures.includes(key)) { - rubricGateFailures = rubricGateFailures.filter(item => item !== key); + const remainingGateFailures = rubricGateFailures.filter(item => item !== key); + rubricGateFailures = remainingGateFailures; + if (autoSelectedGateKey === key) { + if (remainingGateFailures.length > 0 && staffReply === autoSelectedGateTemplateText) { + applyGateFailureTemplate(remainingGateFailures[0]); + } else { + clearAutoGateTemplateIfUnchanged(); + } + } return; } rubricGateFailures = [...rubricGateFailures, key]; - proposedAction = 'reject'; + applyGateFailureTemplate(key); + if (reviewAction === 'propose') { + proposedAction = 'reject'; + } else if (canReject) { + reviewAction = 'reject'; + } } function toggleRubricExtra(key) { @@ -592,6 +736,11 @@ function handleReview() { if (onReview) { + if (isProjectReview && reviewAction === 'accept' && hasRubricGateFailures) { + showError('Clear all gate failures before accepting this project.'); + return; + } + const data = { action: reviewAction, user: selectedUser, @@ -603,6 +752,9 @@ highlight_description: highlightDescription, template_id: selectedTemplateId }; + if (isProjectReview && (reviewAction === 'accept' || reviewAction === 'reject')) { + data.rubric_review = buildRubricReviewPayload(rubricState); + } onReview(submission.id, data); } } @@ -715,6 +867,122 @@ {/if} {/snippet} +{#snippet projectRubric()} +
+
+
+
+

Project rubric

+

+ {rubricTone.description} +

+
+ + {rubricTone.label} + +
+
+ +
+ {#if hasRubricGateFailures} +
+ {isProposalRubric + ? 'Gate failures force a reject proposal. Clear all gate failures to propose accept or request info.' + : 'Gate failure selected. The action is set to reject and the matching rejection template is selected.'} +
+ {/if} + +
+

Gate failures

+
+ {#each RUBRIC_GATE_FAILURES as gate} + + {/each} +
+
+ + {#if !hasRubricGateFailures} +
+ {#each RUBRIC_SECTIONS as section} +
+
+
+ + {#if !isProposalRubric} +

{section.help}

+ {/if} +
+ +
+ {#if isProposalRubric} + + {/if} +
+ {/each} +
+ {/if} + +
+

Verified extras

+
+ {#each RUBRIC_EXTRAS as extra} + + {/each} +
+
+ + {#if isProposalRubric} +
+ + +
+ {/if} +
+
+{/snippet} +
@@ -939,18 +1207,28 @@ {:else if showReviewForm && (submission.state === 'pending' || submission.state === 'more_info_needed')} {#if submission.has_proposal} -
-
- - - - - Proposed by {submission.proposed_by_details?.name || 'a steward'} - {#if submission.proposed_at} - on {formatDate(submission.proposed_at)} - {/if} +
+
+
+ + + + + Proposed by {submission.proposed_by_details?.name || 'a steward'} + {#if submission.proposed_at} + on {formatDate(submission.proposed_at)} + {/if} + +
+ + {proposalNoticeTone.label}
+ {#if isProjectReview && submission.rubric_review} +

+ Builder project rubric saved for this {proposalNoticeTone.shortLabel.toLowerCase()} proposal. +

+ {/if}
{/if} @@ -961,8 +1239,10 @@ {#if canAccept} @@ -1021,95 +1301,8 @@
{/if} - {#if reviewAction === 'propose' && isProjectReview} -
-
-

Project rubric

-
- -
-
-

Gate failures

-
- {#each RUBRIC_GATE_FAILURES as gate} - - {/each} -
-
- - {#if !hasRubricGateFailures} -
- {#each RUBRIC_SECTIONS as section} -
-
-
- -
- -
- -
- {/each} -
- {/if} - -
-

Verified extras

-
- {#each RUBRIC_EXTRAS as extra} - - {/each} -
-
- -
- - -
-
-
+ {#if showProjectRubric} + {@render projectRubric()} {/if} {#if reviewAction === 'accept' || (proposedAction === 'accept' && !isProjectReview)}
@@ -1160,7 +1353,7 @@ bind:selectedCategory bind:selectedContributionType={selectedContributionTypeObj} bind:selectedMission - defaultContributionType={submission.contribution_type} + defaultContributionType={selectedType} defaultMission={submission.mission?.id} onlySubmittable={false} stewardMode={true} @@ -1312,9 +1505,9 @@ {/if}
@@ -1322,6 +1515,10 @@ {:else if reviewAction === 'reject'}
+ {#if showProjectRubric} + {@render projectRubric()} + {/if} +
diff --git a/frontend/src/tests/SubmissionCard.test.js b/frontend/src/tests/SubmissionCard.test.js index e32246a2..9b06a086 100644 --- a/frontend/src/tests/SubmissionCard.test.js +++ b/frontend/src/tests/SubmissionCard.test.js @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/svelte/svelte5'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte/svelte5'; import { describe, expect, it, vi } from 'vitest'; import SubmissionCard from '../components/SubmissionCard.svelte'; @@ -92,12 +92,276 @@ describe('SubmissionCard', () => { await waitFor(() => { expect(screen.getByText('Project rubric')).toBeTruthy(); - expect(screen.getByRole('button', { name: 'Submit Proposal' })).toBeTruthy(); + expect(screen.getByText('Accept proposal')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Submit Accept Proposal' })).toBeTruthy(); }); expect(screen.queryByRole('button', { name: 'Accept & Create Contribution' })).toBeNull(); }); + it('shows the builder project rubric in reject proposal state', async () => { + render(SubmissionCard, { + props: { + submission: makeSubmission({ + has_proposal: true, + proposed_action: 'reject', + proposed_staff_reply: 'The repository does not build.', + proposed_by_details: { name: 'AI Steward' }, + rubric_review: { + action: 'reject', + confidence: 'medium', + gate_failures: ['repo_does_not_build'], + sections: {}, + extras: [], + overall_reason: 'The repository does not build.' + } + }), + showReviewForm: true, + onReview: vi.fn(), + onPropose: vi.fn(), + reviewData: { + action: 'accept', + user: 9, + contribution_type: 7, + points: 0, + staff_reply: '' + }, + permissions: { + 7: ['propose'] + }, + contributionTypes: [ + { + id: 7, + name: 'Builder Project', + category: 'builder', + min_points: 0, + max_points: 100, + review_flow: 'builder_project' + } + ], + multipliers: { 7: 1 }, + templates: [], + notes: [], + enableRubricReview: true + } + }); + + await waitFor(() => { + expect(screen.getAllByText('Reject proposal').length).toBeGreaterThan(0); + expect(screen.getByText('Gate failures force a reject proposal. Clear all gate failures to propose accept or request info.')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Submit Reject Proposal' })).toBeTruthy(); + }); + }); + + it('shows the builder project rubric as a direct accept evaluation without criterion reasons', async () => { + render(SubmissionCard, { + props: { + submission: makeSubmission(), + showReviewForm: true, + onReview: vi.fn(), + onPropose: vi.fn(), + reviewData: { + action: 'accept', + user: 9, + contribution_type: 7, + points: 10, + staff_reply: '' + }, + permissions: { + 7: ['accept', 'reject'] + }, + contributionTypes: [ + { + id: 7, + name: 'Builder Project', + category: 'builder', + min_points: 0, + max_points: 100, + review_flow: 'builder_project' + } + ], + multipliers: { 7: 1 }, + templates: [], + notes: [], + enableRubricReview: true + } + }); + + await waitFor(() => { + expect(screen.getByText('Project rubric')).toBeTruthy(); + expect(screen.getByText('Accept evaluation')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Accept & Create Contribution' })).toBeTruthy(); + }); + + expect(screen.queryByLabelText('GenLayer fit reason optional')).toBeNull(); + expect(screen.queryByText('Overall reason')).toBeNull(); + }); + + it('submits the builder project rubric payload for direct reviews', async () => { + const onReview = vi.fn(); + render(SubmissionCard, { + props: { + submission: makeSubmission(), + showReviewForm: true, + onReview, + onPropose: vi.fn(), + reviewData: { + action: 'accept', + user: 9, + contribution_type: 7, + points: 10, + staff_reply: '' + }, + permissions: { + 7: ['accept', 'reject'] + }, + contributionTypes: [ + { + id: 7, + name: 'Builder Project', + category: 'builder', + min_points: 0, + max_points: 100, + review_flow: 'builder_project' + } + ], + multipliers: { 7: 1 }, + templates: [], + notes: [], + enableRubricReview: true + } + }); + + await fireEvent.change(screen.getByLabelText('GenLayer fit'), { target: { value: '4' } }); + await fireEvent.click(screen.getByLabelText('Live deployment')); + await fireEvent.click(screen.getByRole('button', { name: 'Accept & Create Contribution' })); + + await waitFor(() => { + expect(onReview).toHaveBeenCalledTimes(1); + }); + const payload = onReview.mock.calls[0][1].rubric_review; + expect(payload).toEqual(expect.objectContaining({ + gate_failures: [], + extras: ['live_deployment'], + overall_reason: '' + })); + expect(payload.sections.genlayer_fit).toEqual({ score: 4, reason: '' }); + }); + + it('shows and submits the rubric when a direct accept selected type is builder project', async () => { + const onReview = vi.fn(); + const standardType = { + id: 8, + name: 'Standard Builder', + category: 'builder', + min_points: 0, + max_points: 10, + review_flow: null + }; + const projectType = { + id: 7, + name: 'Builder Project', + category: 'builder', + min_points: 0, + max_points: 100, + review_flow: 'builder_project' + }; + + render(SubmissionCard, { + props: { + submission: makeSubmission({ + contribution_type: 8, + contribution_type_details: standardType + }), + showReviewForm: true, + onReview, + onPropose: vi.fn(), + reviewData: { + action: 'accept', + user: 9, + contribution_type: 7, + points: 75, + staff_reply: '' + }, + permissions: { + 8: ['accept', 'reject'], + 7: ['accept', 'reject'] + }, + contributionTypes: [standardType, projectType], + multipliers: { 7: 1, 8: 1 }, + templates: [], + notes: [], + enableRubricReview: true + } + }); + + await waitFor(() => { + expect(screen.getByText('Project rubric')).toBeTruthy(); + }); + await fireEvent.click(screen.getByRole('button', { name: 'Accept & Create Contribution' })); + + await waitFor(() => { + expect(onReview).toHaveBeenCalledTimes(1); + }); + expect(onReview.mock.calls[0][1]).toEqual(expect.objectContaining({ + contribution_type: 7, + rubric_review: expect.objectContaining({ + gate_failures: [], + overall_reason: '' + }) + })); + }); + + it('selects the gate rejection template when a direct reviewer chooses a gate failure', async () => { + render(SubmissionCard, { + props: { + submission: makeSubmission(), + showReviewForm: true, + onReview: vi.fn(), + onPropose: vi.fn(), + reviewData: { + action: 'accept', + user: 9, + contribution_type: 7, + points: 10, + staff_reply: '' + }, + permissions: { + 7: ['accept', 'reject'] + }, + contributionTypes: [ + { + id: 7, + name: 'Builder Project', + category: 'builder', + min_points: 0, + max_points: 100, + review_flow: 'builder_project' + } + ], + multipliers: { 7: 1 }, + templates: [ + { + id: 99, + label: 'Reject: Project Does Not Build', + text: 'The project does not build from the submitted repository.', + action: 'reject' + } + ], + notes: [], + enableRubricReview: true + } + }); + + await fireEvent.click(screen.getByLabelText('Repo does not build or work')); + + await waitFor(() => { + expect(screen.getByText('Reject evaluation')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Reject Submission' })).toBeTruthy(); + expect(screen.getByDisplayValue('The project does not build from the submitted repository.')).toBeTruthy(); + }); + }); + it('normalizes request_more_info proposal actions before choosing a review view', async () => { render(SubmissionCard, { props: { diff --git a/frontend/src/tests/searchParser.test.js b/frontend/src/tests/searchParser.test.js index 91afce7a..a4a6a6f7 100644 --- a/frontend/src/tests/searchParser.test.js +++ b/frontend/src/tests/searchParser.test.js @@ -7,6 +7,31 @@ function paramsFor(query) { } describe("steward search negation", () => { + it("maps URL searches without dropping colon-like tokens", () => { + expect(paramsFor("include:https://x.com/user/status/123?s=20")).toEqual({ + include_content: "https://x.com/user/status/123?s=20", + }); + expect(paramsFor("include: https://x.com/user/status/123?s=20")).toEqual({ + include_content: "https://x.com/user/status/123?s=20", + }); + expect(paramsFor("https://x.com/user/status/123?s=20")).toEqual({ + search: "https://x.com/user/status/123?s=20", + }); + }); + + it("maps reviewed and points sort aliases", () => { + expect(paramsFor("sort:-reviewed")).toEqual({ ordering: "-reviewed_at" }); + expect(paramsFor("sort:-points")).toEqual({ + ordering: "-converted_contribution__frozen_global_points", + }); + }); + + it("does not merge a dangling tag with the next real filter", () => { + expect(paramsFor("include: sort:-reviewed")).toEqual({ + ordering: "-reviewed_at", + }); + }); + it("normalizes negated presence filters to absence filters", () => { const { filters } = parseSearch("-has:proposal -has:url -has:appeal"); diff --git a/package-lock.json b/package-lock.json index 0223de3e..21c15998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "little-rock-v1", + "name": "osaka-v1", "lockfileVersion": 3, "requires": true, "packages": {