From aa992c36c6442efee5ea4a336a6ab5ca28d77d6a Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 14 Jul 2026 07:53:20 -0400 Subject: [PATCH 1/3] Add admin trigger for earned Discord roles --- backend/CLAUDE.md | 1 + backend/social_connections/admin.py | 61 ++++++++++++++- backend/social_connections/discord_oauth.py | 29 ++++--- .../change_list.html | 12 +++ .../run_assignment.html | 27 +++++++ .../social_connections/tests/test_admin.py | 75 +++++++++++++++++++ 6 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/change_list.html create mode 100644 backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/run_assignment.html create mode 100644 backend/social_connections/tests/test_admin.py diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 5231a82c..dc654a3b 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -351,6 +351,7 @@ backend/ - **Audit**: `DiscordEarnedRoleAssignment` stores each successful automatic grant with the Discord member, role, CP/POAP snapshot, and creation time; records are read-only in admin. - **Service**: `DiscordRoleSyncService.add_member_role(discord_user_id, role_id)` PUTs to Discord; 404 (member left) returns False. - **Trigger**: `POST /api/v1/users/discord/assign-earned-roles/` (cron-protected, background thread + `DiscordRoleSyncLock` row `discord_earned_role_assign`), called daily at 00:30 UTC by `.github/workflows/assign-discord-roles.yml` (after the 00:00 MEE6 XP sync). Manual/backfill: `python manage.py assign_earned_discord_roles [--dry-run]`; dry run prints the would-assign list for review. +- **Admin**: superusers can start the same locked background assignment from the `DiscordEarnedRoleAssignment` changelist after confirming the operation. - **Ops**: the bot needs Manage Roles and its role above Synapse/Brain in the guild hierarchy. ### Database & Migrations diff --git a/backend/social_connections/admin.py b/backend/social_connections/admin.py index 04c815d8..591d5500 100644 --- a/backend/social_connections/admin.py +++ b/backend/social_connections/admin.py @@ -1,4 +1,9 @@ -from django.contrib import admin +from django.contrib import admin, messages +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect, render +from django.urls import path, reverse + +from .discord_oauth import start_earned_role_assignment from .models import ( DiscordConnection, @@ -97,6 +102,7 @@ class DiscordRoleSyncLockAdmin(admin.ModelAdmin): @admin.register(DiscordEarnedRoleAssignment) class DiscordEarnedRoleAssignmentAdmin(admin.ModelAdmin): + change_list_template = 'admin/social_connections/discordearnedroleassignment/change_list.html' list_select_related = ('connection__user',) list_display = ('created_at', 'role_name', 'discord_username', 'connection', 'total_points', 'poap_count') list_filter = ('role_name',) @@ -113,6 +119,59 @@ class DiscordEarnedRoleAssignmentAdmin(admin.ModelAdmin): 'created_at', ) + def get_urls(self): + return [ + path( + 'run-assignment/', + self.admin_site.admin_view(self.run_assignment_view), + name='social_connections_discordearnedroleassignment_run_assignment', + ), + ] + super().get_urls() + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context['can_run_assignment'] = request.user.is_superuser + return super().changelist_view(request, extra_context=extra_context) + + def run_assignment_view(self, request): + if not request.user.is_superuser: + raise PermissionDenied + + changelist_url = reverse( + 'admin:social_connections_discordearnedroleassignment_changelist' + ) + if request.method == 'POST': + started, elapsed_seconds = start_earned_role_assignment() + if started: + self.message_user( + request, + 'Earned Discord role assignment started. Successful grants will appear in this table.', + level=messages.SUCCESS, + ) + else: + elapsed = ( + f' Last heartbeat was {elapsed_seconds:.0f} seconds ago.' + if elapsed_seconds is not None else '' + ) + self.message_user( + request, + f'Earned Discord role assignment is already running.{elapsed}', + level=messages.WARNING, + ) + return redirect(changelist_url) + + context = { + **self.admin_site.each_context(request), + 'opts': self.model._meta, + 'title': 'Run earned Discord role assignment', + 'changelist_url': changelist_url, + } + return render( + request, + 'admin/social_connections/discordearnedroleassignment/run_assignment.html', + context, + ) + def has_add_permission(self, request): return False diff --git a/backend/social_connections/discord_oauth.py b/backend/social_connections/discord_oauth.py index 2248397d..bf378b05 100644 --- a/backend/social_connections/discord_oauth.py +++ b/backend/social_connections/discord_oauth.py @@ -396,20 +396,13 @@ def _run_sync(): }, status=status.HTTP_202_ACCEPTED) -@api_view(['POST']) -@authentication_classes([]) -@permission_classes([IsCronToken]) -def assign_earned_discord_roles(request): - """Trigger a background assignment of earned community roles (Synapse/Brain).""" +def start_earned_role_assignment(): + """Start earned role assignment in the background if no run is active.""" from .earned_roles import assign_earned_community_roles lock_token, elapsed_seconds = _acquire_role_sync_lock(DISCORD_EARNED_ROLE_LOCK_NAME) if lock_token is None: - elapsed = f' ({elapsed_seconds:.0f}s since last heartbeat)' if elapsed_seconds is not None else '' - return Response({ - 'success': False, - 'message': f'Earned role assignment already in progress{elapsed}', - }, status=status.HTTP_409_CONFLICT) + return False, elapsed_seconds heartbeat_stop = threading.Event() @@ -453,6 +446,22 @@ def _run_assignment(): _release_role_sync_lock(lock_token, DISCORD_EARNED_ROLE_LOCK_NAME) raise + return True, None + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([IsCronToken]) +def assign_earned_discord_roles(request): + """Trigger a background assignment of earned community roles (Synapse/Brain).""" + started, elapsed_seconds = start_earned_role_assignment() + if not started: + elapsed = f' ({elapsed_seconds:.0f}s since last heartbeat)' if elapsed_seconds is not None else '' + return Response({ + 'success': False, + 'message': f'Earned role assignment already in progress{elapsed}', + }, status=status.HTTP_409_CONFLICT) + return Response({ 'success': True, 'message': 'Earned role assignment started in background', diff --git a/backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/change_list.html b/backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/change_list.html new file mode 100644 index 00000000..7c247430 --- /dev/null +++ b/backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/change_list.html @@ -0,0 +1,12 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools-items %} + {% if can_run_assignment %} +
  • + + Run earned role assignment + +
  • + {% endif %} + {{ block.super }} +{% endblock %} diff --git a/backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/run_assignment.html b/backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/run_assignment.html new file mode 100644 index 00000000..af778021 --- /dev/null +++ b/backend/social_connections/templates/admin/social_connections/discordearnedroleassignment/run_assignment.html @@ -0,0 +1,27 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +

    + This checks all currently qualified Discord connections and assigns any missing + Synapse or Brain roles. It never removes roles. Successful grants will be added + to the audit table. +

    + +
    + {% csrf_token %} + +
    +{% endblock %} diff --git a/backend/social_connections/tests/test_admin.py b/backend/social_connections/tests/test_admin.py new file mode 100644 index 00000000..370cabbf --- /dev/null +++ b/backend/social_connections/tests/test_admin.py @@ -0,0 +1,75 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import TestCase +from django.urls import reverse + + +class DiscordEarnedRoleAssignmentAdminTest(TestCase): + def setUp(self): + self.superuser = get_user_model().objects.create_superuser( + email='admin@test.com', + password='password', + ) + self.client.force_login(self.superuser) + self.changelist_url = reverse( + 'admin:social_connections_discordearnedroleassignment_changelist' + ) + self.run_url = reverse( + 'admin:social_connections_discordearnedroleassignment_run_assignment' + ) + + def test_changelist_shows_run_button(self): + response = self.client.get(self.changelist_url) + + self.assertContains(response, self.run_url) + self.assertContains(response, 'Run earned role assignment') + + @patch('social_connections.admin.start_earned_role_assignment') + def test_confirmation_does_not_start_assignment(self, mock_start): + response = self.client.get(self.run_url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'It never removes roles.') + mock_start.assert_not_called() + + @patch( + 'social_connections.admin.start_earned_role_assignment', + return_value=(True, None), + ) + def test_post_starts_assignment(self, mock_start): + response = self.client.post(self.run_url, follow=True) + + self.assertRedirects(response, self.changelist_url) + self.assertContains(response, 'Earned Discord role assignment started.') + mock_start.assert_called_once_with() + + @patch( + 'social_connections.admin.start_earned_role_assignment', + return_value=(False, 12), + ) + def test_post_reports_existing_run(self, mock_start): + response = self.client.post(self.run_url, follow=True) + + self.assertRedirects(response, self.changelist_url) + self.assertContains(response, 'already running') + self.assertContains(response, '12 seconds ago') + mock_start.assert_called_once_with() + + def test_non_superuser_cannot_run_assignment(self): + staff = get_user_model().objects.create_user( + email='staff@test.com', + password='password', + is_staff=True, + ) + staff.user_permissions.add( + Permission.objects.get(codename='view_discordearnedroleassignment') + ) + self.client.force_login(staff) + + changelist_response = self.client.get(self.changelist_url) + run_response = self.client.post(self.run_url) + + self.assertNotContains(changelist_response, 'Run earned role assignment') + self.assertEqual(run_response.status_code, 403) From 3f62b0e8a78f6afcb634ed9cca1ec9e141f05241 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 14 Jul 2026 08:06:03 -0400 Subject: [PATCH 2/3] Add more-info submission filters --- backend/CLAUDE.md | 2 +- .../contributions/ai_review/serializers.py | 42 ++++++- backend/contributions/ai_review/views.py | 18 ++- backend/contributions/lifecycle_filters.py | 50 ++++++++ .../tests/test_calibration_data.py | 87 ++++++++++++++ .../tests/test_steward_submission_search.py | 109 +++++++++++++++++- backend/contributions/views.py | 9 +- backend/submissions_review.md | 13 ++- .../src/components/StewardSearchBar.svelte | 35 ++++-- frontend/src/lib/searchParser.js | 8 +- frontend/src/lib/searchToParams.js | 25 ++++ frontend/src/tests/StewardSearchBar.test.js | 51 ++++++++ frontend/src/tests/searchParser.test.js | 28 +++++ 13 files changed, 453 insertions(+), 24 deletions(-) create mode 100644 backend/contributions/lifecycle_filters.py create mode 100644 frontend/src/tests/StewardSearchBar.test.js diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 5231a82c..2aed4ce4 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -95,7 +95,7 @@ backend/ - ProjectMetric - Admin-managed title/value/detail metric rows for project pages - ProjectPageRevision - Owner-submitted ordered page blocks rendered through whitelisted portal components - **AI Review**: `contributions/ai_review/views.py` - - `/api/v1/ai-review/` - List pending unproposed submissions for the external AI review agent; proposal filters such as `has_proposal=true` opt into active proposals + - `/api/v1/ai-review/` - List pending unproposed submissions for the external AI review agent; `has_more_info_request=true` selects submissions with recorded request blocks, while `is_more_info_resubmitted=true` selects audited resubmissions - `/api/v1/ai-review/{id}/` - Retrieve a pending submission with evidence and user history - `/api/v1/ai-review/{id}/propose/` - Submit an AI proposal for human approval - `/api/v1/ai-review/proposed/` - List pending submissions with active proposals awaiting steward review; use `proposed_by=ai` for AI-created proposals only diff --git a/backend/contributions/ai_review/serializers.py b/backend/contributions/ai_review/serializers.py index 0bda2392..25f22260 100644 --- a/backend/contributions/ai_review/serializers.py +++ b/backend/contributions/ai_review/serializers.py @@ -4,6 +4,7 @@ Evidence, ProjectMilestoneReview, SubmissionNote, + SubmissionStateTransition, SubmittedContribution, ) from contributions.rubric_review import ( @@ -36,7 +37,35 @@ def get_is_active(self, obj): return obj.is_active() -class AIReviewProposalFieldsMixin(serializers.Serializer): +class AIReviewLifecycleFieldsMixin(serializers.Serializer): + """Recorded request history and durable lifecycle facts for DAI.""" + + has_more_info_request = serializers.SerializerMethodField() + is_more_info_resubmitted = serializers.SerializerMethodField() + + def get_has_more_info_request(self, obj): + annotated = getattr(obj, 'has_more_info_request_flag', None) + if annotated is not None: + return bool(annotated) + return SubmissionNote.objects.filter( + submitted_contribution=obj, + is_proposal=False, + data__action='more_info', + ).exists() + + def get_is_more_info_resubmitted(self, obj): + annotated = getattr(obj, 'more_info_resubmitted_flag', None) + if annotated is not None: + return bool(annotated) + return SubmissionStateTransition.objects.filter( + submitted_contribution=obj, + event=SubmissionStateTransition.EVENT_EDITED, + from_state='more_info_needed', + to_state='pending', + ).exists() + + +class AIReviewProposalFieldsMixin(AIReviewLifecycleFieldsMixin): """Shared active-proposal fields for list and detail AI review payloads.""" proposed_by_name = serializers.SerializerMethodField() @@ -112,6 +141,8 @@ class Meta: 'gate_reviewed', 'has_appeal', 'appeal_reason', + 'has_more_info_request', + 'is_more_info_resubmitted', 'has_proposal', 'assigned_to', 'assigned_to_name', @@ -224,6 +255,8 @@ class Meta: 'gate_reviewed', 'has_appeal', 'appeal_reason', + 'has_more_info_request', + 'is_more_info_resubmitted', 'evidence_items', 'internal_notes', 'user_history', @@ -277,7 +310,10 @@ def get_has_proposal(self, obj): return obj.proposed_action is not None -class AIReviewReviewedSubmissionSerializer(serializers.ModelSerializer): +class AIReviewReviewedSubmissionSerializer( + AIReviewLifecycleFieldsMixin, + serializers.ModelSerializer, +): """Serializer for reviewed submissions — includes review outcome and notes.""" contribution_type_name = serializers.CharField( @@ -311,6 +347,8 @@ class Meta: 'gate_reviewed', 'has_appeal', 'appeal_reason', + 'has_more_info_request', + 'is_more_info_resubmitted', 'reviewed_at', 'evidence_items', 'internal_notes', diff --git a/backend/contributions/ai_review/views.py b/backend/contributions/ai_review/views.py index fe2415db..b23bd9c5 100644 --- a/backend/contributions/ai_review/views.py +++ b/backend/contributions/ai_review/views.py @@ -26,6 +26,10 @@ SubmissionNote, SubmittedContribution, ) +from contributions.lifecycle_filters import ( + SubmissionLifecycleFilterMixin, + annotate_more_info_flags, +) from contributions.proposal_filters import ProposalReviewStatusFilterMixin from contributions.rubric_review import rubric_summary_text, uses_project_rubric from service_accounts.authentication import ServiceAccountAuthentication @@ -51,7 +55,11 @@ class AIReviewPagination(PageNumberPagination): # ─── FilterSet ──────────────────────────────────────────────────────────────── -class AIReviewFilterSet(ProposalReviewStatusFilterMixin, FilterSet): +class AIReviewFilterSet( + SubmissionLifecycleFilterMixin, + ProposalReviewStatusFilterMixin, + FilterSet, +): """Filterset for AI review agent submission queries.""" contribution_type = NumberFilter(field_name='contribution_type_id') @@ -82,6 +90,8 @@ class AIReviewFilterSet(ProposalReviewStatusFilterMixin, FilterSet): search = CharFilter(method='filter_search') mission = CharFilter(method='filter_mission') exclude_mission = CharFilter(method='filter_exclude_mission') + has_more_info_request = BooleanFilter(method='filter_has_more_info_request') + is_more_info_resubmitted = BooleanFilter(method='filter_is_more_info_resubmitted') class Meta: model = SubmittedContribution @@ -441,7 +451,7 @@ def get_queryset(self): if self.action != 'list': prefetches.append('internal_notes') - return ( + return annotate_more_info_flags( qs.select_related( 'contribution_type', 'contribution_type__category', @@ -653,7 +663,7 @@ def proposed(self, request): Use GET /ai-review/{id}/ to retrieve full proposal details for any submission returned here. """ - queryset = ( + queryset = annotate_more_info_flags( SubmittedContribution.objects.filter( state='pending', proposed_action__isnull=False, @@ -706,7 +716,7 @@ def reviewed(self, request): user__email=AI_STEWARD_EMAIL, ) - queryset = ( + queryset = annotate_more_info_flags( SubmittedContribution.objects .filter( state__in=['accepted', 'rejected', 'more_info_needed'], diff --git a/backend/contributions/lifecycle_filters.py b/backend/contributions/lifecycle_filters.py new file mode 100644 index 00000000..fe045ed9 --- /dev/null +++ b/backend/contributions/lifecycle_filters.py @@ -0,0 +1,50 @@ +from django.db.models import Exists, OuterRef + +from .models import SubmissionNote, SubmissionStateTransition + + +def more_info_request_notes(): + """Recorded requests rendered by the submission card's history blocks.""" + return SubmissionNote.objects.filter( + submitted_contribution_id=OuterRef('pk'), + is_proposal=False, + data__action='more_info', + ) + + +def more_info_resubmission_transitions(): + """Transitions that prove a submitter reopened a more-info request.""" + return SubmissionStateTransition.objects.filter( + submitted_contribution_id=OuterRef('pk'), + event=SubmissionStateTransition.EVENT_EDITED, + from_state='more_info_needed', + to_state='pending', + ) + + +def annotate_more_info_flags(queryset): + """Expose request-history and durable transition flags without N+1 queries.""" + return queryset.annotate( + has_more_info_request_flag=Exists(more_info_request_notes()), + more_info_resubmitted_flag=Exists(more_info_resubmission_transitions()), + ) + + +class SubmissionLifecycleFilterMixin: + """Shared history filters for steward and DAI submission queues.""" + + def filter_has_more_info_request(self, queryset, name, value): + request_exists = Exists(more_info_request_notes()) + if value is True: + return queryset.filter(request_exists) + if value is False: + return queryset.exclude(request_exists) + return queryset + + def filter_is_more_info_resubmitted(self, queryset, name, value): + transition_exists = Exists(more_info_resubmission_transitions()) + if value is True: + return queryset.filter(transition_exists) + if value is False: + return queryset.exclude(transition_exists) + return queryset diff --git a/backend/contributions/tests/test_calibration_data.py b/backend/contributions/tests/test_calibration_data.py index 4ce223c4..5d309c62 100644 --- a/backend/contributions/tests/test_calibration_data.py +++ b/backend/contributions/tests/test_calibration_data.py @@ -16,6 +16,7 @@ ContributionType, Evidence, SubmissionNote, + SubmissionStateTransition, SubmittedContribution, ) from leaderboard.models import GlobalLeaderboardMultiplier @@ -443,6 +444,92 @@ def test_ai_review_list_supports_compound_assignment_filters(self): self.assertNotIn(str(assigned_to_steward.id), exclude_ids) self.assertIn(str(assigned_to_ai.id), exclude_ids) + def test_ai_review_can_filter_and_identify_more_info_resubmissions(self): + resubmitted = self.fixtures['submission'] + SubmissionStateTransition.record( + resubmitted, + SubmissionStateTransition.EVENT_EDITED, + from_state='more_info_needed', + actor=self.fixtures['submitter'], + ) + ordinary_pending = SubmittedContribution.objects.create( + user=self.fixtures['submitter'], + contribution_type=self.fixtures['ct'], + contribution_date=timezone.now(), + notes='Ordinary pending submission.', + state='pending', + ) + + response = self.client.get( + '/api/v1/ai-review/', + data={ + 'assigned_to': 'unassigned', + 'is_more_info_resubmitted': 'true', + }, + **self.ai_auth, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(str(response.data['results'][0]['id']), str(resubmitted.id)) + self.assertTrue(response.data['results'][0]['is_more_info_resubmitted']) + + response = self.client.get( + '/api/v1/ai-review/', + data={'is_more_info_resubmitted': 'false'}, + **self.ai_auth, + ) + result_by_id = {str(item['id']): item for item in response.data['results']} + self.assertIn(str(ordinary_pending.id), result_by_id) + self.assertFalse(result_by_id[str(ordinary_pending.id)]['is_more_info_resubmitted']) + self.assertNotIn(str(resubmitted.id), result_by_id) + + def test_ai_review_can_filter_pending_submissions_with_more_info_request_blocks(self): + requested = self.fixtures['submission'] + SubmissionNote.objects.create( + submitted_contribution=requested, + user=self.fixtures['steward_user'], + message='Reviewed: more_info', + is_proposal=False, + data={ + 'action': 'more_info', + 'staff_reply': 'Please add the missing evidence.', + }, + ) + ordinary_pending = SubmittedContribution.objects.create( + user=self.fixtures['submitter'], + contribution_type=self.fixtures['ct'], + contribution_date=timezone.now(), + notes='Ordinary pending submission.', + state='pending', + ) + + response = self.client.get( + '/api/v1/ai-review/', + data={ + 'assigned_to': 'unassigned', + 'has_more_info_request': 'true', + }, + **self.ai_auth, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data['results']), 1) + payload = response.data['results'][0] + self.assertEqual(str(payload['id']), str(requested.id)) + self.assertTrue(payload['has_more_info_request']) + self.assertFalse(payload['is_more_info_resubmitted']) + + response = self.client.get( + '/api/v1/ai-review/', + data={'has_more_info_request': 'false'}, + **self.ai_auth, + ) + result_by_id = {str(item['id']): item for item in response.data['results']} + self.assertIn(str(ordinary_pending.id), result_by_id) + self.assertFalse(result_by_id[str(ordinary_pending.id)]['has_more_info_request']) + self.assertNotIn(str(requested.id), result_by_id) + def test_ai_review_invalid_steward_id_filters_do_not_error(self): submission = self.fixtures['submission'] submission.assigned_to = self.fixtures['steward_user'] diff --git a/backend/contributions/tests/test_steward_submission_search.py b/backend/contributions/tests/test_steward_submission_search.py index 8f612f63..eb69c8f3 100644 --- a/backend/contributions/tests/test_steward_submission_search.py +++ b/backend/contributions/tests/test_steward_submission_search.py @@ -4,7 +4,15 @@ from rest_framework import status from rest_framework.test import APIClient -from contributions.models import Category, Contribution, ContributionType, Evidence, SubmittedContribution +from contributions.models import ( + Category, + Contribution, + ContributionType, + Evidence, + SubmissionNote, + SubmissionStateTransition, + SubmittedContribution, +) from leaderboard.models import GlobalLeaderboardMultiplier from stewards.models import Steward, StewardPermission @@ -245,6 +253,105 @@ def test_can_include_multiple_assignment_values(self): self.assertIn(str(assigned_to_other.id), result_ids) self.assertNotIn(str(assigned_to_current.id), result_ids) + def test_can_filter_unassigned_more_info_resubmissions_from_transition_history(self): + resubmitted = SubmittedContribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + notes='User supplied the requested follow-up.', + state='pending', + ) + SubmissionStateTransition.record( + resubmitted, + SubmissionStateTransition.EVENT_EDITED, + from_state='more_info_needed', + actor=self.regular_user, + ) + ordinary_pending = SubmittedContribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + notes='Ordinary pending submission.', + state='pending', + ) + assigned_resubmission = SubmittedContribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + notes='Resubmitted but already assigned.', + state='pending', + assigned_to=self.other_steward_user, + ) + SubmissionStateTransition.record( + assigned_resubmission, + SubmissionStateTransition.EVENT_EDITED, + from_state='more_info_needed', + actor=self.regular_user, + ) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'pending', + 'assigned_to': 'unassigned', + 'is_more_info_resubmitted': 'true', + }) + + 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(resubmitted.id)}) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'pending', + 'is_more_info_resubmitted': 'false', + }) + result_ids = {str(item['id']) for item in response.data['results']} + self.assertIn(str(ordinary_pending.id), result_ids) + self.assertNotIn(str(resubmitted.id), result_ids) + self.assertNotIn(str(assigned_resubmission.id), result_ids) + + def test_can_filter_pending_unassigned_submissions_with_more_info_request_blocks(self): + requested = SubmittedContribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + notes='Currently pending after a request.', + state='pending', + ) + SubmissionNote.objects.create( + submitted_contribution=requested, + user=self.steward_user, + message='Reviewed: more_info\n\n> Please provide a clearer link.', + is_proposal=False, + data={ + 'action': 'more_info', + 'staff_reply': 'Please provide a clearer link.', + }, + ) + ordinary_pending = SubmittedContribution.objects.create( + user=self.regular_user, + contribution_type=self.contribution_type, + contribution_date=timezone.now(), + notes='No more-information request.', + state='pending', + ) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'pending', + 'assigned_to': 'unassigned', + 'has_more_info_request': 'true', + }) + + 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(requested.id)}) + + response = self.client.get('/api/v1/steward-submissions/', { + 'state': 'pending', + 'has_more_info_request': 'false', + }) + result_ids = {str(item['id']) for item in response.data['results']} + self.assertIn(str(ordinary_pending.id), result_ids) + self.assertNotIn(str(requested.id), result_ids) + def test_invalid_steward_id_filters_do_not_error(self): pending = SubmittedContribution.objects.create( user=self.regular_user, diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 5dac1f30..04fb2bf6 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -53,6 +53,7 @@ FeaturedContentSerializer, AlertSerializer, ContributionDiscordXPStateSerializer) from .permissions import IsSteward, steward_has_permission, steward_permitted_type_ids +from .lifecycle_filters import SubmissionLifecycleFilterMixin from .proposal_filters import ProposalReviewStatusFilterMixin from .project_milestones import ( accepted_project_contributions_for_user, @@ -1269,7 +1270,11 @@ def add_evidence(self, request, pk=None): ) -class StewardSubmissionFilterSet(ProposalReviewStatusFilterMixin, FilterSet): +class StewardSubmissionFilterSet( + SubmissionLifecycleFilterMixin, + ProposalReviewStatusFilterMixin, + FilterSet, +): """Custom filterset for steward submission filtering.""" username_search = CharFilter(method='filter_username') exclude_username = CharFilter(method='filter_exclude_username') @@ -1299,6 +1304,8 @@ class StewardSubmissionFilterSet(ProposalReviewStatusFilterMixin, FilterSet): exclude_mission = CharFilter(method='filter_exclude_mission') has_appeal = BooleanFilter(field_name='has_appeal') has_ai_analysis = BooleanFilter(method='filter_has_ai_analysis') + has_more_info_request = BooleanFilter(method='filter_has_more_info_request') + is_more_info_resubmitted = BooleanFilter(method='filter_is_more_info_resubmitted') def _split_filter_values(self, value): return [ diff --git a/backend/submissions_review.md b/backend/submissions_review.md index 4d0d2040..2a92ca42 100644 --- a/backend/submissions_review.md +++ b/backend/submissions_review.md @@ -84,11 +84,22 @@ The external AI agent accesses submissions via the `/api/v1/ai-review/` API endp | Endpoint | Method | Purpose | |----------|--------|---------| -| `/api/v1/ai-review/` | GET | List pending submissions (paginated, filterable) | +| `/api/v1/ai-review/` | GET | List pending submissions (paginated, filterable); use `has_more_info_request=true` for submissions with recorded request blocks or `is_more_info_resubmitted=true` for audited resubmissions | | `/api/v1/ai-review/{id}/` | GET | Submission detail with evidence and user history | | `/api/v1/ai-review/{id}/propose/` | POST | Submit a review proposal | | `/api/v1/ai-review/templates/` | GET | List review templates | +DAI can combine lifecycle and assignment filters directly: + +```text +GET /api/v1/ai-review/?assigned_to=unassigned&has_more_info_request=true +``` + +List and detail payloads distinguish `has_more_info_request`, backed by the +same structured notes rendered in submission cards, from +`is_more_info_resubmitted`, backed only by an append-only transition from +`more_info_needed` to `pending`. + ### Project Rubric Proposals Builder Project contribution types can opt into a structured rubric flow with diff --git a/frontend/src/components/StewardSearchBar.svelte b/frontend/src/components/StewardSearchBar.svelte index df9c90cc..afec4c63 100644 --- a/frontend/src/components/StewardSearchBar.svelte +++ b/frontend/src/components/StewardSearchBar.svelte @@ -49,10 +49,10 @@ { name: 'proposed-by', description: 'Filter by proposal creator', values: () => ['ai', 'me', 'none', ...stewardSearchValues()] }, { name: 'exclude', description: 'Exclude submissions containing text', values: () => ['medium.com'] }, { name: 'include', description: 'Only show submissions containing text', values: () => [] }, - { name: 'has', description: 'Filter by presence', values: () => ['url', 'evidence', 'proposal', 'appeal'] }, - { name: 'no', description: 'Filter by absence', values: () => ['url', 'evidence', 'proposal', 'appeal'] }, - { name: 'is', description: 'Filter by internal flag', values: () => ['interesting', 'appealed', 'ai-reviewed'] }, - { name: 'not', description: 'Exclude by internal flag', values: () => ['interesting', 'appealed', 'ai-reviewed'] }, + { name: 'has', description: 'Filter by presence', values: () => ['more-info-request', 'url', 'evidence', 'proposal', 'appeal'] }, + { name: 'no', description: 'Filter by absence', values: () => ['more-info-request', 'url', 'evidence', 'proposal', 'appeal'] }, + { name: 'is', description: 'Filter by lifecycle or internal flag', values: () => ['more-info-resubmitted', 'interesting', 'appealed', 'ai-reviewed'] }, + { name: 'not', description: 'Exclude by lifecycle or internal flag', values: () => ['more-info-resubmitted', 'interesting', 'appealed', 'ai-reviewed'] }, { name: 'proposal', description: 'Filter by proposed action', values: () => ['accept', 'reject', 'more-info'] }, { name: 'proposal-status', description: 'Filter by proposal review status', values: () => ['pending', 'questioned'] }, { name: 'confidence', description: 'Filter by proposal confidence', values: () => ['high', 'medium', 'low'] }, @@ -62,7 +62,7 @@ { name: 'sort', description: 'Sort order', values: () => ['created', '-created', 'date', '-date', 'reviewed', '-reviewed', 'points', '-points'] } ]; - const TAGS = variant === 'xp' ? XP_TAGS : SUBMISSION_TAGS; + let TAGS = $derived(variant === 'xp' ? XP_TAGS : SUBMISSION_TAGS); function getCurrentWord() { if (!inputRef) return { word: '', start: 0, end: 0 }; @@ -129,14 +129,16 @@ const needsSpace = !suggestion.endsWith(':'); const spacer = needsSpace ? ' ' : ''; value = before + suggestion + (after.startsWith(' ') || !needsSpace ? after.trimStart() : spacer + after.trimStart()); - showAutocomplete = false; - // Focus input and move cursor if (inputRef) { - inputRef.focus(); + const selectedInput = inputRef; + selectedInput.focus(); const newPos = before.length + suggestion.length + (needsSpace ? 1 : 0); - setTimeout(() => inputRef.setSelectionRange(newPos, newPos), 0); + setTimeout(() => selectedInput?.setSelectionRange(newPos, newPos), 0); } + // Focusing the input invokes handleFocus, so close the menu afterwards. + showAutocomplete = false; + selectedIndex = -1; } function handleKeydown(event) { @@ -225,6 +227,11 @@ onblur={handleBlur} {placeholder} class="search-input" + role="combobox" + aria-autocomplete="list" + aria-expanded={showAutocomplete && suggestions.length > 0} + aria-controls="submission-search-suggestions" + aria-activedescendant={selectedIndex >= 0 ? `submission-search-suggestion-${selectedIndex}` : undefined} />