diff --git a/.claude/skills/ai-review.md b/.claude/skills/ai-review.md index 4fa2b7f5..392f48cd 100644 --- a/.claude/skills/ai-review.md +++ b/.claude/skills/ai-review.md @@ -121,6 +121,7 @@ List responses from `/api/v1/ai-review/` and `/api/v1/ai-review/proposed/` include compact submission fields plus the active proposal summary: - `assigned_to`, `assigned_to_name` +- `has_more_info_request`, `is_more_info_resubmitted` - `has_proposal` - `proposed_action`, `proposed_points`, `proposed_staff_reply` - `proposed_contribution_type` @@ -176,11 +177,16 @@ already makes the filter meaningless. Send backend parameter names exactly. | `proposed_template` | template ID | Active proposal template | | `has_appeal` | `true`, `false` | Submitter appeal flag | | `is_interesting` | `true`, `false` | Internal interesting flag | +| `has_more_info_request` | `true`, `false` | Whether a non-proposal structured note records a `more_info` request; this is the same history rendered as a more-information block on submission cards | +| `is_more_info_resubmitted` | `true`, `false` | Whether the append-only transition log records an edited transition from `more_info_needed` to `pending` | | `min_accepted_contributions` | positive integer | Submitter has at least this many accepted submissions | The old `resubmitted_more_info` filter was removed (re-open paths clear -`reviewed_at`, so it could never match current data). Do not send it; unknown -params are silently ignored and you would get the ENTIRE unfiltered queue. +`reviewed_at`, so it could never match current data). Use +`is_more_info_resubmitted` for a confirmed reopen transition or +`has_more_info_request` for recorded request history. Do not send the old name; +unknown params are silently ignored and you would get the ENTIRE unfiltered +queue. ## More-Info Filters @@ -189,14 +195,15 @@ Use the exact filter for the question being asked: | Need | Endpoint | Filter | |---|---|---| | Submissions currently waiting on submitter info | `/api/v1/ai-review/reviewed/` or steward search | `state=more_info_needed` | -| Pending submissions resubmitted after more info was requested | `/api/v1/ai-review/{id}/` | No query param exists; inspect `internal_notes` for a prior `more_info` decision note | +| Pending submissions with a recorded more-info request block | `/api/v1/ai-review/` | `has_more_info_request=true` | +| Pending submissions confirmed as resubmitted after a more-info request | `/api/v1/ai-review/` | `is_more_info_resubmitted=true` | | Active proposals recommending more info | `/api/v1/ai-review/proposed/` | `proposed_action=more_info` | | Reviewed submissions where final steward decision was more info | `/api/v1/ai-review/reviewed/` | `state=more_info_needed` | | Exclude current more-info submissions | Any list endpoint where state is not fixed | `exclude_state=more_info_needed` | -There is no direct query parameter for "ever had a more-info request but later -became accepted or rejected". For that, use `/api/v1/ai-review/reviewed/` and -inspect `internal_notes`. +To find reviewed submissions that have ever had a recorded more-info request, +use `/api/v1/ai-review/reviewed/?has_more_info_request=true`; the reviewed +endpoint's calibration-data scope still applies. ## Common Filter Examples @@ -205,6 +212,14 @@ inspect `internal_notes`. curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/ai-review/?category=builder&exclude_empty_evidence=true" +# Unassigned pending submissions with a recorded more-info request block. +curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/ai-review/?assigned_to=unassigned&has_more_info_request=true" + +# Pending submissions with an audited more-info-needed to pending transition. +curl -s -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/ai-review/?is_more_info_resubmitted=true" + # Active more-info proposals from AI or human stewards. curl -s -H "Authorization: Bearer $TOKEN" \ "$BASE_URL/api/v1/ai-review/proposed/?proposed_action=more_info" diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 5231a82c..c4e77a14 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 @@ -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/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/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 %} +
+ 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. +
+ + +{% 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) 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} />