From 9e794a15aefa00f012f4eebfe0f2b8b842270f31 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 19 Jul 2026 18:04:46 +0200 Subject: [PATCH 1/2] Add appeal dates to steward submissions (#944) --- .../0081_submittedcontribution_appealed_at.py | 62 +++++++++++++++++++ backend/contributions/models.py | 5 ++ backend/contributions/serializers.py | 8 +-- backend/contributions/tests/test_appeal.py | 5 ++ .../tests/test_steward_permissions.py | 23 +++++++ backend/contributions/views.py | 3 +- .../components/StewardSubmissionCard.svelte | 7 ++- .../src/tests/StewardSubmissionCard.test.js | 15 +++++ 8 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 backend/contributions/migrations/0081_submittedcontribution_appealed_at.py diff --git a/backend/contributions/migrations/0081_submittedcontribution_appealed_at.py b/backend/contributions/migrations/0081_submittedcontribution_appealed_at.py new file mode 100644 index 00000000..84ad982f --- /dev/null +++ b/backend/contributions/migrations/0081_submittedcontribution_appealed_at.py @@ -0,0 +1,62 @@ +from django.db import migrations, models + + +def backfill_appealed_at(apps, schema_editor): + SubmittedContribution = apps.get_model('contributions', 'SubmittedContribution') + SubmissionNote = apps.get_model('contributions', 'SubmissionNote') + SubmissionStateTransition = apps.get_model( + 'contributions', + 'SubmissionStateTransition', + ) + database = schema_editor.connection.alias + + submissions = ( + SubmittedContribution.objects.using(database) + .filter(has_appeal=True, appealed_at__isnull=True) + .values_list('id', flat=True) + ) + for submission_id in submissions.iterator(): + appealed_at = ( + SubmissionStateTransition.objects.using(database) + .filter(submitted_contribution_id=submission_id, event='appeal') + .order_by('created_at') + .values_list('created_at', flat=True) + .first() + ) + if appealed_at is None: + appealed_at = ( + SubmissionNote.objects.using(database) + .filter( + submitted_contribution_id=submission_id, + data__kind='appeal', + ) + .order_by('created_at') + .values_list('created_at', flat=True) + .first() + ) + if appealed_at is not None: + ( + SubmittedContribution.objects.using(database) + .filter(id=submission_id) + .update(appealed_at=appealed_at) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0080_aireviewfeedback'), + ] + + operations = [ + migrations.AddField( + model_name='submittedcontribution', + name='appealed_at', + field=models.DateTimeField( + blank=True, + help_text='When the submitter appealed the rejection.', + null=True, + ), + ), + migrations.RunPython(backfill_appealed_at, migrations.RunPython.noop), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 1ab54104..8651ee13 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -902,6 +902,11 @@ class SubmittedContribution(BaseModel): blank=True, help_text="Reason provided by the submitter when appealing a rejection." ) + appealed_at = models.DateTimeField( + null=True, + blank=True, + help_text="When the submitter appealed the rejection." + ) # Edit tracking last_edited_at = models.DateTimeField(null=True, blank=True) diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index 57cdd740..21c171d8 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -581,13 +581,13 @@ class Meta: 'staff_reply', 'reviewed_by', 'reviewed_at', 'evidence_items', 'can_edit', 'proposed_points', 'converted_contribution', 'contribution', 'mission', 'project_contribution', 'milestone_version', - 'has_appeal', 'appeal_reason', 'more_info_requests', + 'has_appeal', 'appeal_reason', 'appealed_at', 'more_info_requests', 'created_at', 'updated_at', 'last_edited_at', 'recaptcha'] read_only_fields = ['id', 'user', 'state', 'staff_reply', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at', 'last_edited_at', 'proposed_points', 'converted_contribution', 'milestone_version', - 'has_appeal', 'appeal_reason'] + 'has_appeal', 'appeal_reason', 'appealed_at'] def get_user_details(self, obj): """ @@ -1494,7 +1494,7 @@ class Meta: 'proposal_questioned_at', 'rubric_review', 'ai_analysis', 'notes_count', 'is_interesting', 'gate_reviewed', - 'has_appeal', 'appeal_reason', 'more_info_requests', + 'has_appeal', 'appeal_reason', 'appealed_at', 'more_info_requests', 'created_at', 'updated_at', 'last_edited_at', 'converted_contribution', 'contribution', 'mission', 'project_contribution', 'milestone_version'] # Every model-backed field is read-only: this serializer only renders @@ -1510,7 +1510,7 @@ class Meta: 'proposal_review_status', 'proposal_review_feedback', 'proposal_questioned_by', 'proposal_questioned_at', 'created_at', 'updated_at', 'last_edited_at', 'proposed_points', - 'is_interesting', 'gate_reviewed', 'has_appeal', 'appeal_reason', + 'is_interesting', 'gate_reviewed', 'has_appeal', 'appeal_reason', 'appealed_at', 'converted_contribution', 'mission', 'milestone_version'] def get_user_details(self, obj): diff --git a/backend/contributions/tests/test_appeal.py b/backend/contributions/tests/test_appeal.py index 7a1b94e9..88cffcf5 100644 --- a/backend/contributions/tests/test_appeal.py +++ b/backend/contributions/tests/test_appeal.py @@ -72,6 +72,11 @@ def test_owner_can_appeal_rejected_submission(self): submission.refresh_from_db() self.assertTrue(submission.has_appeal) self.assertEqual(submission.appeal_reason, 'I think this was unfair') + self.assertIsNotNone(submission.appealed_at) + self.assertEqual( + response.data['appealed_at'], + submission.appealed_at.isoformat().replace('+00:00', 'Z'), + ) self.assertEqual(submission.state, 'pending') # Original rejection reason is preserved as audit context self.assertEqual(submission.staff_reply, 'Original rejection reason') diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index 44f15aa8..8271c5b8 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -162,6 +162,29 @@ def test_steward_can_access_steward_endpoints(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['pending_count'], 1) + def test_steward_submission_includes_appeal_timestamp(self): + appealed_at = timezone.now() + self.submission.has_appeal = True + self.submission.appeal_reason = 'Please reconsider the evidence.' + self.submission.appealed_at = appealed_at + self.submission.save(update_fields=[ + 'has_appeal', + 'appeal_reason', + 'appealed_at', + 'updated_at', + ]) + self.client.force_authenticate(user=self.steward_user) + + response = self.client.get('/api/v1/steward-submissions/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + submission = response.data['results'][0] + self.assertEqual(submission['appeal_reason'], 'Please reconsider the evidence.') + self.assertEqual( + submission['appealed_at'], + appealed_at.isoformat().replace('+00:00', 'Z'), + ) + def test_steward_superuser_has_all_permissions_without_permission_rows(self): """A steward marked as a superuser receives every effective permission.""" admin_steward = Steward.objects.create(user=self.admin_user) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 3c4d3326..917751b5 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -1182,12 +1182,13 @@ def appeal(self, request, pk=None): with transaction.atomic(): submission.has_appeal = True submission.appeal_reason = reason + submission.appealed_at = timezone.now() submission.state = 'pending' submission.reviewed_by = None submission.reviewed_at = None submission.gate_reviewed = False submission.save(update_fields=[ - 'has_appeal', 'appeal_reason', 'state', + 'has_appeal', 'appeal_reason', 'appealed_at', 'state', 'reviewed_by', 'reviewed_at', 'gate_reviewed', 'updated_at', ]) diff --git a/frontend/src/components/StewardSubmissionCard.svelte b/frontend/src/components/StewardSubmissionCard.svelte index 13f33497..1b4221b1 100644 --- a/frontend/src/components/StewardSubmissionCard.svelte +++ b/frontend/src/components/StewardSubmissionCard.svelte @@ -1542,7 +1542,12 @@ {#if submission.has_appeal && submission.appeal_reason}
-

Appeal reason

+
+

Appeal reason

+ {#if submission.appealed_at} +

Appealed on {formatDate(submission.appealed_at)}

+ {/if} +

{submission.appeal_reason}

{/if} diff --git a/frontend/src/tests/StewardSubmissionCard.test.js b/frontend/src/tests/StewardSubmissionCard.test.js index 8270a97f..808b52cf 100644 --- a/frontend/src/tests/StewardSubmissionCard.test.js +++ b/frontend/src/tests/StewardSubmissionCard.test.js @@ -72,6 +72,7 @@ function makeSubmission(overrides = {}) { is_interesting: false, has_appeal: false, appeal_reason: '', + appealed_at: null, more_info_requests: [], mission: null, contribution: null, @@ -180,6 +181,20 @@ describe('StewardSubmissionCard', () => { expect(screen.getByPlaceholderText('Add a note...')).toBeTruthy(); }); + it('shows the appeal date with the appeal reason', () => { + renderCard({ + submission: makeSubmission({ + has_appeal: true, + appeal_reason: 'The rejection overlooked the attached evidence.', + appealed_at: '2026-06-03T12:00:00Z' + }) + }); + + expect(screen.getByText('Appeal reason')).toBeTruthy(); + expect(screen.getByText(/Appealed on Jun 3, 2026/)).toBeTruthy(); + expect(screen.getByText('The rejection overlooked the attached evidence.')).toBeTruthy(); + }); + it('shows the compact AI proposal context without a competing human proposal', () => { const aiAnalysis = makeAIAnalysis(); renderCard({ From 0f18c30d1555561ef2da32e46c2b065a9486ed97 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 21 Jul 2026 03:36:21 +0200 Subject: [PATCH 2/2] Add submission limits, highlighted milestones, and community rankings (#945) --- backend/CLAUDE.md | 8 +- backend/contributions/admin.py | 3 +- ...butiontype_weekly_user_submission_limit.py | 31 +++ backend/contributions/models.py | 48 +++++ backend/contributions/project_milestones.py | 19 +- backend/contributions/serializers.py | 45 +++++ .../tests/test_projects_and_milestones.py | 94 ++++++++- .../tests/test_submission_limits.py | 179 +++++++++++++++++- backend/contributions/views.py | 168 +++++++++++++--- backend/leaderboard/tests/test_stats.py | 89 +++++++++ backend/leaderboard/views.py | 47 ++++- backend/utils/dates.py | 15 +- frontend/CLAUDE.md | 4 + frontend/src/components/Missions.svelte | 2 + .../components/StewardSubmissionCard.svelte | 10 +- .../SubmitContribution.svelte | 49 +++-- frontend/src/lib/api.js | 9 +- frontend/src/routes/Dashboard.svelte | 46 ++++- frontend/src/routes/EditSubmission.svelte | 18 +- frontend/src/routes/MissionDetail.svelte | 6 +- .../tests/communityDashboardRankings.test.js | 49 +++++ frontend/src/tests/setupTests.js | 1 + 22 files changed, 857 insertions(+), 83 deletions(-) create mode 100644 backend/contributions/migrations/0082_contributiontype_weekly_user_submission_limit.py create mode 100644 frontend/src/tests/communityDashboardRankings.test.js diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a9d10cb1..8a551317 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -69,11 +69,11 @@ backend/ ### Contributions - **Models**: `contributions/models.py` - Contribution - Individual contribution records. Has optional `project_contribution` self-FK and `milestone_version` used by the Projects/Milestones split. - - ContributionType - Categories with slug field, has M2M `accepted_evidence_url_types` + - ContributionType - Categories with slug field, M2M `accepted_evidence_url_types`, optional global lifetime `max_submissions`, and optional `max_submissions_per_user_per_week`. The weekly limit uses Monday-Sunday UTC `SubmittedContribution.created_at` bounds and counts every state; edits/appeals reuse the same row and do not consume another slot. If an editable submission changes contribution type, the target type's capacity is checked in the submission's original creation week. - AIReviewFeedback - Per-reviewer, per-AI-proposal benchmark feedback with an immutable `(proposal_source, proposal_source_id)` binding, timestamp metadata in `proposal_ref`, verdict, optional corrected decision/rubric ranges, typed anchored error claims, and a best-effort commit SHA pinned on first save. Records are unique by `(submitted_contribution, reviewer, proposal_source, proposal_source_id)` and never alter submission review state. - SubmissionStateTransition - Append-only lifecycle log for submissions (migration 0079). One row per event (`submitted`/`review`/`bulk_reject`/`gate_reject`/`edited`/`canceled`/`appeal`/`evidence_added`/`admin`) with from_state/to_state/actor. Written by every path that changes `state` or clears `reviewed_by`/`reviewed_at` (creation via post_save signal; the rest inline at each call site, incl. the Tier-1 gate command and admin `save_model`). Never mutate or delete rows; read-only in admin. Rationale: row state is overwritten in place and re-open paths destroy review fields, so this log is the only durable decision/lifecycle history. Bulk reject also writes a per-submission decision SubmissionNote (`data.action='reject'`, `data.bulk=true`) so bulk decisions appear in the CRM timeline and note-based metrics like single rejects. The dead `resubmitted_more_info` filter (relied on `reviewed_at` surviving edits, impossible since 2026-06-22) was removed from both filtersets and the steward search grammar. - **Projects/Milestones split**: `contributions/project_milestones.py` - - `projects` and `milestones` are separate contribution types (migration 0068). Projects require a GitHub repository evidence URL (`required_evidence_url_types` = github-repo). Milestones must be linked to one of the submitter's ACCEPTED Projects CONTRIBUTIONS (`/submissions/accepted-projects/`) via the `project_contribution` self-FK, require a written change description (evidence optional), and get an auto-assigned sequential `milestone_version` per project contribution. IMPORTANT: this is unrelated to the projects app's curated `projects.Project` showcase table, which contribution flows must never create or modify. + - `projects` and `milestones` are separate contribution types (migration 0068). Projects require a GitHub repository evidence URL (`required_evidence_url_types` = github-repo). New milestones must be linked to one of the submitter's HIGHLIGHTED Projects CONTRIBUTIONS (`/submissions/accepted-projects/`) via the `project_contribution` self-FK, require a written change description (evidence optional), and get an auto-assigned sequential `milestone_version` per project contribution. Existing pending/more-info milestone links are grandfathered if the project is not highlighted or its highlight is removed, so they remain editable and reviewable; new links still require a highlight. IMPORTANT: this is unrelated to the projects app's curated `projects.Project` showcase table and its `show_in_overview` field, which contribution flows must never create or modify. - FeaturedContent - Portal hero/community/validator-steward content managed through admin - ContributionTypeMultiplier - Dynamic point multipliers - Evidence - Evidence items with `url_type` FK for auto-detected URL type, `normalized_url` indexed field for fast duplicate detection (text descriptions and URLs only - file uploads are disabled) @@ -149,6 +149,7 @@ backend/ - **Views**: `leaderboard/views.py` - `/api/v1/leaderboard/` - Get rankings - `/api/v1/leaderboard/monthly/` - Top portal point totals for the current month by default, or for an explicit `start_date`/`end_date` range. Combines all category contributions (including onboarding/link awards) with social-task completions and returns `contribution_points`, `social_task_points`, and `total_points`. Non-community categories keep their normal leaderboard eligibility gate. Cumulative Discord chat XP is not included because it has no earning-event timestamp for monthly attribution. + - `/api/v1/leaderboard/community-podium/` - Community dashboard podium only. Returns at most three visible users ranked by `Contribution.frozen_global_points` from Contributions linked to accepted `SubmittedContribution.converted_contribution` rows. Discord/MEE6 XP, social-task completions, and direct/system/admin Contributions without an accepted source submission do not count. - `/api/v1/leaderboard/stats/` - Global statistics - `/api/v1/leaderboard/user_stats/by-address/{address}/` - User-specific stats - **Builder leaderboard eligibility is write-time**: a `type='builder'` LeaderboardEntry @@ -448,7 +449,7 @@ DELETE /api/v1/contributions/{id}/ (requires auth) # Submissions (submitter-side) GET /api/v1/submissions/my/ (requires auth, paginated user submissions) -GET /api/v1/submissions/accepted-projects/ (requires auth, the user's accepted Projects contributions milestones can link to, with next_milestone_version and github_url from evidence) +GET /api/v1/submissions/accepted-projects/ (requires auth, the user's highlighted Projects contributions milestones can link to; optional ?submission=UUID includes that pending milestone's grandfathered current link; includes next_milestone_version and github_url) POST /api/v1/submissions/{id}/appeal/ (requires auth, owner-only, one per submission) POST /api/v1/submissions/{id}/add-evidence/ (requires auth, owner-only) @@ -460,6 +461,7 @@ GET /api/v1/contribution-types/statistics/ (requires auth) # Leaderboard GET /api/v1/leaderboard/ (requires auth) GET /api/v1/leaderboard/monthly/ (requires auth, ?type=builder|community|validator, ?limit=10, optional ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD) +GET /api/v1/leaderboard/community-podium/ (public, top 3 Community users by accepted-submission points only) GET /api/v1/leaderboard/stats/ (requires auth) GET /api/v1/leaderboard/user_stats/by-address/{address}/ (requires auth) diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index 653b4ad2..3ab84034 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -153,7 +153,8 @@ class ContributionTypeAdmin(BroadcastNotificationAdminMixin, admin.ModelAdmin): broadcast_ineligible_reason = 'the contribution type is not submittable' list_display = ( 'name', 'category', 'review_flow', 'is_default', 'is_submittable', - 'get_submission_usage', 'show_in_contributions', + 'get_submission_usage', 'max_submissions_per_user_per_week', + 'show_in_contributions', 'get_current_multiplier', 'min_points', 'max_points', 'rubric_extra_points', 'description', 'created_at', ) diff --git a/backend/contributions/migrations/0082_contributiontype_weekly_user_submission_limit.py b/backend/contributions/migrations/0082_contributiontype_weekly_user_submission_limit.py new file mode 100644 index 00000000..6171a361 --- /dev/null +++ b/backend/contributions/migrations/0082_contributiontype_weekly_user_submission_limit.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0081_submittedcontribution_appealed_at'), + ] + + operations = [ + migrations.AddField( + model_name='contributiontype', + name='max_submissions_per_user_per_week', + field=models.PositiveIntegerField( + blank=True, + help_text=( + 'Maximum submissions each user may create for this ' + 'contribution type per Monday-Sunday UTC week. Every ' + 'submission state counts. Leave blank for unlimited.' + ), + null=True, + ), + ), + migrations.AddIndex( + model_name='submittedcontribution', + index=models.Index( + fields=['user', 'contribution_type', 'created_at'], + name='sub_user_type_week_idx', + ), + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 8651ee13..b50205ad 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from django.utils import timezone from utils.models import BaseModel +from utils.dates import utc_week_bounds import decimal import os import uuid @@ -117,6 +118,15 @@ class ContributionType(BaseModel): "for this contribution type. Leave blank for unlimited." ), ) + max_submissions_per_user_per_week = models.PositiveIntegerField( + null=True, + blank=True, + help_text=( + "Maximum submissions each user may create for this contribution " + "type per Monday-Sunday UTC week. Every submission state counts. " + "Leave blank for unlimited." + ), + ) show_in_contributions = models.BooleanField( default=False, help_text=( @@ -197,6 +207,40 @@ def is_full(self): self.max_submissions is not None and self.get_submission_count() >= self.max_submissions ) + + def get_user_weekly_submission_count(self, user, now=None): + """Count a user's submissions in the current Monday-Sunday UTC week. + + State is deliberately not filtered: pending, accepted, rejected, + canceled, and more-info submissions all consume the same weekly quota. + """ + if not user or not getattr(user, 'is_authenticated', False): + return None + annotated_count = getattr(self, 'user_weekly_submission_count', None) + if annotated_count is not None and now is None: + return annotated_count + week_start, week_end = utc_week_bounds(now) + return self.submitted_contributions.filter( + user=user, + created_at__gte=week_start, + created_at__lt=week_end, + ).count() + + def user_weekly_submissions_remaining(self, user, now=None): + if self.max_submissions_per_user_per_week is None: + return None + submission_count = self.get_user_weekly_submission_count(user, now=now) + if submission_count is None: + return None + return max(self.max_submissions_per_user_per_week - submission_count, 0) + + def is_weekly_full_for_user(self, user, now=None): + if self.max_submissions_per_user_per_week is None: + return False + submission_count = self.get_user_weekly_submission_count(user, now=now) + if submission_count is None: + return False + return submission_count >= self.max_submissions_per_user_per_week def clean(self): """Validate the contribution type data.""" @@ -922,6 +966,10 @@ class Meta: models.Index(fields=['created_at'], name='sub_created_idx'), models.Index(fields=['state', 'created_at'], name='sub_state_created_idx'), models.Index(fields=['state', 'reviewed_at'], name='sub_state_reviewed_idx'), + models.Index( + fields=['user', 'contribution_type', 'created_at'], + name='sub_user_type_week_idx', + ), ] diff --git a/backend/contributions/project_milestones.py b/backend/contributions/project_milestones.py index f5c2de0c..72cc8add 100644 --- a/backend/contributions/project_milestones.py +++ b/backend/contributions/project_milestones.py @@ -2,11 +2,11 @@ "Projects" here means the Projects contribution type (formerly "Projects & Milestones"), not the projects app's curated Project profiles. A Milestones -submission must be linked to one of the submitter's accepted Projects +submission must be linked to one of the submitter's highlighted Projects contributions and receives a sequential version number within that project contribution. """ -from django.db.models import Max +from django.db.models import Max, Q PROJECT_TYPE_SLUG = 'projects' @@ -17,14 +17,23 @@ def is_milestone_contribution_type(contribution_type): return getattr(contribution_type, 'slug', None) == MILESTONE_TYPE_SLUG -def accepted_project_contributions_for_user(user): - """Accepted Projects contributions the user can attach milestones to.""" +def highlighted_project_contributions_for_user(user, include_project_id=None): + """Highlighted Projects contributions the user can attach milestones to. + + ``include_project_id`` grandfathers an existing milestone link so a pending + submission remains editable/reviewable if its project's highlight is later + removed. It never bypasses project type or ownership checks. + """ from .models import Contribution + eligibility = Q(highlights__isnull=False) + if include_project_id: + eligibility |= Q(id=include_project_id) + return Contribution.objects.filter( user=user, contribution_type__slug=PROJECT_TYPE_SLUG, - ) + ).filter(eligibility).distinct() def project_contribution_display_title(contribution): diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index 21c171d8..ccff9b6d 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -67,6 +67,10 @@ class LightContributionTypeSerializer(serializers.Serializer): rubric_extra_points = serializers.IntegerField(read_only=True) current_multiplier = serializers.SerializerMethodField() max_submissions = serializers.IntegerField(read_only=True) + max_submissions_per_user_per_week = serializers.IntegerField(read_only=True) + user_weekly_submission_count = serializers.SerializerMethodField() + user_weekly_submissions_remaining = serializers.SerializerMethodField() + user_weekly_is_full = serializers.SerializerMethodField() review_flow = serializers.CharField(read_only=True) # Include category slug only, not the full category object category = serializers.SerializerMethodField() @@ -87,6 +91,21 @@ def get_current_multiplier(self, obj): except Exception: return 1.0 + def get_user_weekly_submission_count(self, obj): + return getattr(obj, 'user_weekly_submission_count', None) + + def get_user_weekly_submissions_remaining(self, obj): + limit = obj.max_submissions_per_user_per_week + count = self.get_user_weekly_submission_count(obj) + if limit is None or count is None: + return None + return max(limit - count, 0) + + def get_user_weekly_is_full(self, obj): + limit = obj.max_submissions_per_user_per_week + count = self.get_user_weekly_submission_count(obj) + return limit is not None and count is not None and count >= limit + class LightMissionSerializer(serializers.Serializer): """ @@ -203,6 +222,9 @@ class ContributionTypeSerializer(serializers.ModelSerializer): submission_count = serializers.SerializerMethodField() submissions_remaining = serializers.SerializerMethodField() is_full = serializers.SerializerMethodField() + user_weekly_submission_count = serializers.SerializerMethodField() + user_weekly_submissions_remaining = serializers.SerializerMethodField() + user_weekly_is_full = serializers.SerializerMethodField() class Meta: model = ContributionType @@ -210,6 +232,8 @@ class Meta: 'id', 'name', 'slug', 'description', 'category', 'min_points', 'max_points', 'rubric_extra_points', 'current_multiplier', 'is_submittable', 'review_flow', 'max_submissions', 'submission_count', 'submissions_remaining', 'is_full', + 'max_submissions_per_user_per_week', 'user_weekly_submission_count', + 'user_weekly_submissions_remaining', 'user_weekly_is_full', 'show_in_contributions', 'examples', 'required_social_accounts', 'required_discord_roles', 'accepted_evidence_url_types', 'required_evidence_url_types', @@ -288,6 +312,19 @@ def get_submissions_remaining(self, obj): def get_is_full(self, obj): return obj.is_full() + def _request_user(self): + request = self.context.get('request') + return getattr(request, 'user', None) + + def get_user_weekly_submission_count(self, obj): + return obj.get_user_weekly_submission_count(self._request_user()) + + def get_user_weekly_submissions_remaining(self, obj): + return obj.user_weekly_submissions_remaining(self._request_user()) + + def get_user_weekly_is_full(self, obj): + return obj.is_weekly_full_for_user(self._request_user()) + class ContributionSerializer(serializers.ModelSerializer): @@ -1757,6 +1794,14 @@ def get_contribution_type_details(self, obj): if annotated_multiplier is not None: contribution_type.current_multiplier_value = annotated_multiplier + weekly_user_count = getattr( + obj, + 'contribution_type_user_weekly_submission_count', + None, + ) + if weekly_user_count is not None: + contribution_type.user_weekly_submission_count = weekly_user_count + data = LightContributionTypeSerializer(contribution_type).data submission_count = getattr(obj, 'contribution_type_submission_count', None) max_submissions = contribution_type.max_submissions diff --git a/backend/contributions/tests/test_projects_and_milestones.py b/backend/contributions/tests/test_projects_and_milestones.py index 7bd1a690..7c1d1366 100644 --- a/backend/contributions/tests/test_projects_and_milestones.py +++ b/backend/contributions/tests/test_projects_and_milestones.py @@ -7,7 +7,14 @@ from rest_framework.test import APIClient from builders.models import Builder -from contributions.models import Category, Contribution, ContributionType, Evidence, SubmittedContribution +from contributions.models import ( + Category, + Contribution, + ContributionHighlight, + ContributionType, + Evidence, + SubmittedContribution, +) from leaderboard.models import GlobalLeaderboardMultiplier from projects.models import Project from stewards.models import Steward, StewardPermission @@ -84,7 +91,12 @@ def setUp(self): self.recaptcha_patcher.start() self.addCleanup(self.recaptcha_patcher.stop) - def _accepted_project_contribution(self, title='Cognocracy', user=None): + def _accepted_project_contribution( + self, + title='Cognocracy', + user=None, + highlighted=True, + ): contribution = Contribution.objects.create( user=user or self.user, contribution_type=self.project_type, @@ -97,6 +109,12 @@ def _accepted_project_contribution(self, title='Cognocracy', user=None): description='Repo', url=f'https://github.com/example/{title.lower().replace(" ", "-")}', ) + if highlighted: + ContributionHighlight.objects.create( + contribution=contribution, + title=title, + description='Highlighted project', + ) return contribution def _post_submission(self, contribution_type, **extra): @@ -159,7 +177,7 @@ def test_milestone_with_malformed_project_id_returns_client_error(self): response = self._post_submission(self.milestone_type, project_contribution='abc') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertIn('accepted projects', response.data['error']) + self.assertIn('highlighted projects', response.data['error']) def test_editing_milestone_to_other_project_assigns_next_version(self): project_a = self._accepted_project_contribution() @@ -275,6 +293,9 @@ def test_accepting_project_submission_does_not_touch_projects_table(self): 'contribution_type': self.project_type.id, 'user': self.user.id, 'points': 25, + 'create_highlight': True, + 'highlight_title': 'New Project', + 'highlight_description': 'Highlighted project', }, format='json', ) @@ -286,7 +307,7 @@ def test_accepting_project_submission_does_not_touch_projects_table(self): # The curated projects.Project showcase table is a separate feature # and must never gain rows from contribution acceptance. self.assertEqual(Project.objects.count(), projects_before) - # The accepted contribution becomes a milestone anchor immediately. + # The accepted and highlighted contribution becomes a milestone anchor. self.client.force_authenticate(user=self.user) eligible = self.client.get('/api/v1/submissions/accepted-projects/') self.assertIn(contribution.id, [item['id'] for item in eligible.data]) @@ -331,7 +352,7 @@ def test_accepting_milestone_reassigned_to_other_user_is_rejected(self): self.assertIn('owned by the selected user', response.data['detail']) def test_steward_accepted_projects_endpoint_lists_selected_users_projects(self): - """Return only the selected user's accepted Projects for steward review.""" + """Return only the selected user's highlighted Projects for steward review.""" project_contribution = self._accepted_project_contribution() other_user = User.objects.create_user( email='other-project-owner@test.com', @@ -447,3 +468,66 @@ def test_accepting_milestone_links_contribution_to_project_contribution(self): self.assertTrue( project_contribution.milestones.filter(id=contribution.id).exists() ) + + def test_unhighlighted_project_cannot_receive_new_milestone(self): + project_contribution = self._accepted_project_contribution( + highlighted=False, + ) + + self.client.force_authenticate(user=self.user) + picker_response = self.client.get('/api/v1/submissions/accepted-projects/') + response = self._post_submission( + self.milestone_type, + project_contribution=project_contribution.id, + ) + + self.assertEqual(picker_response.status_code, status.HTTP_200_OK) + self.assertEqual(picker_response.data, []) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertIn('highlighted projects', response.data['error']) + + def test_existing_pending_milestone_keeps_unhighlighted_project(self): + project_contribution = self._accepted_project_contribution( + highlighted=False, + ) + submission = SubmittedContribution.objects.create( + user=self.user, + contribution_type=self.milestone_type, + project_contribution=project_contribution, + milestone_version=1, + contribution_date=timezone.now(), + title='Existing milestone', + notes='Existing milestone details', + ) + + self.client.force_authenticate(user=self.user) + picker_response = self.client.get( + '/api/v1/submissions/accepted-projects/', + {'submission': submission.id}, + ) + edit_response = self.client.patch( + f'/api/v1/submissions/{submission.id}/', + {'notes': 'Updated existing milestone details'}, + format='json', + ) + + self.assertEqual(picker_response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item['id'] for item in picker_response.data], + [project_contribution.id], + ) + self.assertEqual(edit_response.status_code, status.HTTP_200_OK) + + self.client.force_authenticate(user=self.steward_user) + review_response = self.client.post( + f'/api/v1/steward-submissions/{submission.id}/review/', + { + 'action': 'accept', + 'contribution_type': self.milestone_type.id, + 'user': self.user.id, + 'points': 10, + }, + format='json', + ) + + self.assertEqual(review_response.status_code, status.HTTP_200_OK) diff --git a/backend/contributions/tests/test_submission_limits.py b/backend/contributions/tests/test_submission_limits.py index 84e179f6..ac78ed88 100644 --- a/backend/contributions/tests/test_submission_limits.py +++ b/backend/contributions/tests/test_submission_limits.py @@ -1,3 +1,4 @@ +from datetime import timedelta, timezone as datetime_timezone from unittest.mock import patch from django.contrib.auth import get_user_model @@ -9,6 +10,7 @@ from rest_framework.test import APIClient from contributions.models import Category, ContributionType, Mission, SubmittedContribution +from utils.dates import utc_week_bounds User = get_user_model() @@ -47,10 +49,16 @@ def setUp(self): self.recaptcha_patcher.start() self.addCleanup(self.recaptcha_patcher.stop) - def _create_submission(self, state='pending', mission=None, user=None): + def _create_submission( + self, + state='pending', + mission=None, + user=None, + contribution_type=None, + ): return SubmittedContribution.objects.create( user=user or self.other_user, - contribution_type=self.contribution_type, + contribution_type=contribution_type or self.contribution_type, mission=mission, contribution_date=timezone.now(), notes='Existing submission', @@ -277,3 +285,170 @@ def test_contribution_type_api_exposes_capacity_fields(self): self.assertEqual(response.data['submission_count'], 1) self.assertEqual(response.data['submissions_remaining'], 1) self.assertFalse(response.data['is_full']) + + def test_weekly_user_limit_counts_every_submission_state(self): + self.contribution_type.max_submissions_per_user_per_week = 1 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + + for state in [ + 'pending', 'accepted', 'rejected', 'canceled', 'more_info_needed', + ]: + with self.subTest(state=state): + SubmittedContribution.objects.all().delete() + self._create_submission(state=state, user=self.user) + + response = self._post_submission() + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('weekly submission limit', response.data['error']) + + def test_weekly_user_limit_is_independent_per_user(self): + self.contribution_type.max_submissions_per_user_per_week = 1 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + self._create_submission(state='rejected', user=self.other_user) + + response = self._post_submission() + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_previous_utc_week_does_not_consume_weekly_capacity(self): + self.contribution_type.max_submissions_per_user_per_week = 1 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + old_submission = self._create_submission( + state='rejected', + user=self.user, + ) + week_start, _ = utc_week_bounds() + SubmittedContribution.objects.filter(pk=old_submission.pk).update( + created_at=week_start - timedelta(microseconds=1), + ) + + response = self._post_submission() + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_weekly_limit_api_fields_are_user_specific(self): + self.contribution_type.max_submissions_per_user_per_week = 2 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + self._create_submission(state='rejected', user=self.user) + self._create_submission(state='canceled', user=self.user) + self._create_submission(state='pending', user=self.other_user) + + response = self.client.get( + f'/api/v1/contribution-types/{self.contribution_type.id}/' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['max_submissions_per_user_per_week'], 2) + self.assertEqual(response.data['user_weekly_submission_count'], 2) + self.assertEqual(response.data['user_weekly_submissions_remaining'], 0) + self.assertTrue(response.data['user_weekly_is_full']) + + def test_editing_existing_submission_does_not_consume_another_weekly_slot(self): + self.contribution_type.max_submissions_per_user_per_week = 1 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + submission = self._create_submission(state='pending', user=self.user) + + response = self.client.patch( + f'/api/v1/submissions/{submission.id}/', + {'notes': 'Updated existing submission'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_retyping_old_submission_checks_target_type_in_original_week(self): + self.contribution_type.max_submissions_per_user_per_week = 1 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + source_type = ContributionType.objects.create( + name='Unlimited Source Type', + slug='unlimited-source-type', + description='Test source type', + category=self.category, + min_points=1, + max_points=10, + ) + existing_target = self._create_submission(user=self.user) + source_submission = self._create_submission( + user=self.user, + contribution_type=source_type, + ) + week_start, _ = utc_week_bounds() + previous_week = week_start - timedelta(days=1) + SubmittedContribution.objects.filter( + pk__in=[existing_target.pk, source_submission.pk], + ).update(created_at=previous_week) + + response = self.client.patch( + f'/api/v1/submissions/{source_submission.id}/', + {'contribution_type': self.contribution_type.id}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('weekly submission limit', response.data['error']) + source_submission.refresh_from_db() + self.assertEqual(source_submission.contribution_type_id, source_type.id) + + def test_retyping_old_submission_does_not_use_current_week_capacity(self): + self.contribution_type.max_submissions_per_user_per_week = 1 + self.contribution_type.save( + update_fields=['max_submissions_per_user_per_week'] + ) + source_type = ContributionType.objects.create( + name='Historical Source Type', + slug='historical-source-type', + description='Test source type', + category=self.category, + min_points=1, + max_points=10, + ) + self._create_submission(user=self.user) + source_submission = self._create_submission( + user=self.user, + contribution_type=source_type, + ) + week_start, _ = utc_week_bounds() + SubmittedContribution.objects.filter(pk=source_submission.pk).update( + created_at=week_start - timedelta(days=1), + ) + + response = self.client.patch( + f'/api/v1/submissions/{source_submission.id}/', + {'contribution_type': self.contribution_type.id}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + source_submission.refresh_from_db() + self.assertEqual( + source_submission.contribution_type_id, + self.contribution_type.id, + ) + + def test_utc_week_runs_monday_through_sunday(self): + fixed_time = timezone.datetime( + 2026, + 7, + 22, + 18, + 30, + tzinfo=datetime_timezone.utc, + ) + + week_start, week_end = utc_week_bounds(fixed_time) + + self.assertEqual(week_start.isoformat(), '2026-07-20T00:00:00+00:00') + self.assertEqual(week_end.isoformat(), '2026-07-27T00:00:00+00:00') diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 917751b5..a37d0bde 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -61,7 +61,7 @@ from .lifecycle_filters import SubmissionLifecycleFilterMixin from .proposal_filters import ProposalReviewStatusFilterMixin from .project_milestones import ( - accepted_project_contributions_for_user, + highlighted_project_contributions_for_user, is_milestone_contribution_type, next_milestone_version, project_contribution_display_title, @@ -73,7 +73,7 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from ethereum_auth.authentication import EthereumAuthentication from community_xp.services import acquire_sync_lock, release_sync_lock -from utils.dates import day_start +from utils.dates import day_start, utc_week_bounds import requests COMMUNITY_CATEGORY_SLUGS = ('community', 'creator') @@ -104,17 +104,37 @@ def get_queryset(self): contribution_type_id=OuterRef('pk') ).order_by('-valid_from').values('multiplier_value')[:1] - queryset = ContributionType.objects.select_related('category').annotate( - submission_count=Coalesce( + annotations = { + 'submission_count': Coalesce( Subquery(submission_count, output_field=IntegerField()), Value(0), output_field=IntegerField(), ), - current_multiplier_value=Coalesce( + 'current_multiplier_value': Coalesce( Subquery(current_multiplier, output_field=multiplier_field), Value(1.0, output_field=multiplier_field), output_field=multiplier_field, ), + } + user = getattr(self.request, 'user', None) + if user and getattr(user, 'is_authenticated', False): + week_start, week_end = utc_week_bounds() + user_weekly_submission_count = SubmittedContribution.objects.filter( + contribution_type_id=OuterRef('pk'), + user_id=user.id, + created_at__gte=week_start, + created_at__lt=week_end, + ).values('contribution_type_id').annotate( + count=Count('pk') + ).values('count') + annotations['user_weekly_submission_count'] = Coalesce( + Subquery(user_weekly_submission_count, output_field=IntegerField()), + Value(0), + output_field=IntegerField(), + ) + + queryset = ContributionType.objects.select_related('category').annotate( + **annotations ) # Filter by category if provided @@ -662,6 +682,8 @@ def _validate_submission_contribution_type( contribution_type, mission=None, skip_capacity_check=False, + skip_weekly_capacity_check=False, + weekly_capacity_at=None, allow_existing_community_submission=False, skip_submittable_check=False, ): @@ -682,6 +704,17 @@ def _validate_submission_contribution_type( {'error': 'This contribution type has reached its submission limit.'}, status=status.HTTP_400_BAD_REQUEST, ) + if ( + not skip_weekly_capacity_check + and contribution_type.is_weekly_full_for_user( + user, + now=weekly_capacity_at, + ) + ): + return Response( + {'error': 'You have reached your weekly submission limit for this contribution type.'}, + status=status.HTTP_400_BAD_REQUEST, + ) if contribution_type.category: if contribution_type.category.slug == 'builder' and not hasattr(user, 'builder'): return Response( @@ -726,25 +759,32 @@ def _validate_submission_contribution_type( return None - def _project_link_error(self, user, contribution_type, project_contribution_id): + def _project_link_error( + self, + user, + contribution_type, + project_contribution_id, + existing_project_contribution_id=None, + ): """Validate project-contribution linking rules for Milestones submissions.""" if is_milestone_contribution_type(contribution_type): if not project_contribution_id: return None, Response( - {'error': 'Milestones must be linked to one of your accepted projects.'}, + {'error': 'Milestones must be linked to one of your highlighted projects.'}, status=status.HTTP_400_BAD_REQUEST, ) try: - project_contribution = accepted_project_contributions_for_user(user).get( - id=project_contribution_id, - ) + project_contribution = highlighted_project_contributions_for_user( + user, + include_project_id=existing_project_contribution_id, + ).get(id=project_contribution_id) except (Contribution.DoesNotExist, ValueError, TypeError): # ValueError/TypeError cover malformed ids (e.g. 'abc') that # would otherwise raise during pk conversion before DRF's # PrimaryKeyRelatedField validation runs. return None, Response( - {'error': 'Select one of your accepted projects before submitting a milestone.'}, + {'error': 'Select one of your highlighted projects before submitting a milestone.'}, status=status.HTTP_403_FORBIDDEN, ) return project_contribution, None @@ -865,6 +905,11 @@ def create(self, request, *args, **kwargs): {'error': 'This contribution type has reached its submission limit.'}, status=status.HTTP_400_BAD_REQUEST ) + if locked_type.is_weekly_full_for_user(request.user): + return Response( + {'error': 'You have reached your weekly submission limit for this contribution type.'}, + status=status.HTTP_400_BAD_REQUEST, + ) serializer.validated_data['contribution_type'] = locked_type if mission_id: @@ -887,11 +932,11 @@ def create(self, request, *args, **kwargs): locked_project_contribution = Contribution.objects.select_for_update().get( id=milestone_project_contribution.id ) - if not accepted_project_contributions_for_user(request.user).filter( + if not highlighted_project_contributions_for_user(request.user).filter( id=locked_project_contribution.id, ).exists(): return Response( - {'error': 'Select one of your accepted projects before submitting a milestone.'}, + {'error': 'Select one of your highlighted projects before submitting a milestone.'}, status=status.HTTP_403_FORBIDDEN, ) serializer.validated_data['project_contribution'] = locked_project_contribution @@ -966,6 +1011,8 @@ def update(self, request, *args, **kwargs): contribution_type, mission, skip_capacity_check=keeps_same_contribution_type, + skip_weekly_capacity_check=keeps_same_contribution_type, + weekly_capacity_at=instance.created_at, allow_existing_community_submission=keeps_same_contribution_type, # A type made non-submittable after the fact must not lock users # out of editing their existing pending/more-info submissions. @@ -983,6 +1030,7 @@ def update(self, request, *args, **kwargs): request.user, contribution_type, project_contribution_id, + existing_project_contribution_id=instance.project_contribution_id, ) if project_error is not None: return project_error @@ -995,6 +1043,28 @@ def update(self, request, *args, **kwargs): return notes_error with transaction.atomic(): + locked_type = ( + ContributionType.objects + .select_for_update() + .get(id=contribution_type.id) + ) + if not keeps_same_contribution_type: + if locked_type.is_full(): + return Response( + {'error': 'This contribution type has reached its submission limit.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if locked_type.is_weekly_full_for_user( + request.user, + now=instance.created_at, + ): + return Response( + {'error': 'You have reached your weekly submission limit for this contribution type.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + contribution_type = locked_type + serializer.validated_data['contribution_type'] = locked_type + if is_milestone_contribution_type(contribution_type): # Lock the project contribution row (as create does) so # concurrent edits pointing at the same project cannot be @@ -1002,11 +1072,14 @@ def update(self, request, *args, **kwargs): locked_project_contribution = Contribution.objects.select_for_update().get( id=milestone_project_contribution.id ) - if not accepted_project_contributions_for_user(request.user).filter( + if not highlighted_project_contributions_for_user( + request.user, + include_project_id=instance.project_contribution_id, + ).filter( id=locked_project_contribution.id, ).exists(): return Response( - {'error': 'Select one of your accepted projects before submitting a milestone.'}, + {'error': 'Select one of your highlighted projects before submitting a milestone.'}, status=status.HTTP_403_FORBIDDEN, ) serializer.validated_data['project_contribution'] = locked_project_contribution @@ -1123,9 +1196,23 @@ def my_submissions(self, request): @action(detail=False, methods=['get'], url_path='accepted-projects') def accepted_projects(self, request): - """Return the user's accepted Projects contributions milestones can link to.""" + """Return highlighted Projects contributions milestones can link to.""" + submission = None + submission_id = request.query_params.get('submission') + if submission_id: + submission = get_object_or_404(self.get_queryset(), pk=submission_id) + existing_project_id = ( + submission.project_contribution_id + if submission + and submission.state in ['pending', 'more_info_needed'] + and is_milestone_contribution_type(submission.contribution_type) + else None + ) project_contributions = ( - accepted_project_contributions_for_user(request.user) + highlighted_project_contributions_for_user( + request.user, + include_project_id=existing_project_id, + ) .prefetch_related('evidence_items') .order_by('-contribution_date', '-id') ) @@ -2275,7 +2362,7 @@ def review(self, request, pk=None): if is_milestone_contribution_type(contribution_type): if not project_contribution: return Response( - {'detail': 'Milestones must be linked to an accepted project before acceptance.'}, + {'detail': 'Milestones must be linked to a project before acceptance.'}, status=status.HTTP_400_BAD_REQUEST, ) project_contribution = Contribution.objects.select_for_update().get( @@ -2283,11 +2370,19 @@ def review(self, request, pk=None): ) # Validate against the user the contribution will belong to # (stewards can reassign it), not the original submitter. - if not accepted_project_contributions_for_user(contribution_user).filter( + existing_project_id = ( + submission.project_contribution_id + if is_milestone_contribution_type(submission.contribution_type) + else None + ) + if not highlighted_project_contributions_for_user( + contribution_user, + include_project_id=existing_project_id, + ).filter( id=project_contribution.id, ).exists(): return Response( - {'detail': 'Milestones can only be accepted for a project contribution owned by the selected user.'}, + {'detail': 'Milestones can only be accepted for a highlighted project contribution owned by the selected user.'}, status=status.HTTP_400_BAD_REQUEST, ) if project_contribution != submission.project_contribution: @@ -2983,7 +3078,7 @@ def users(self, request): @action(detail=False, methods=['get'], url_path='accepted-projects') def accepted_projects(self, request): - """Get accepted Projects contributions for a user during steward review.""" + """Get highlighted Projects contributions for a user during steward review.""" from users.models import User user_id = request.query_params.get('user') @@ -3009,8 +3104,18 @@ def accepted_projects(self, request): status=status.HTTP_404_NOT_FOUND, ) + existing_project_id = ( + submission.project_contribution_id + if submission + and submission.state in ['pending', 'more_info_needed'] + and is_milestone_contribution_type(submission.contribution_type) + else None + ) project_contributions = ( - accepted_project_contributions_for_user(user) + highlighted_project_contributions_for_user( + user, + include_project_id=existing_project_id, + ) .prefetch_related('evidence_items') .order_by('-created_at') ) @@ -3945,6 +4050,25 @@ def get_queryset(self): Value(0), output_field=IntegerField(), ) + week_start, week_end = utc_week_bounds() + contribution_type_user_weekly_submission_count = ( + SubmittedContribution.objects.filter( + contribution_type_id=OuterRef('contribution_type_id'), + user_id=user.id, + created_at__gte=week_start, + created_at__lt=week_end, + ).values('contribution_type_id').annotate( + count=Count('pk') + ).values('count') + ) + annotations['contribution_type_user_weekly_submission_count'] = Coalesce( + Subquery( + contribution_type_user_weekly_submission_count, + output_field=IntegerField(), + ), + Value(0), + output_field=IntegerField(), + ) queryset = Mission.objects.all().select_related( 'contribution_type', diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 006e3824..bb8a0c9b 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -154,6 +154,23 @@ def _accept_builder_submission(self, user, contribution_type, points): ) return contribution + def _accept_community_submission(self, user, points): + contribution = Contribution.objects.create( + user=user, + contribution_type=self.community_type, + points=points, + frozen_global_points=points, + contribution_date=timezone.now(), + ) + SubmittedContribution.objects.create( + user=user, + contribution_type=self.community_type, + contribution_date=timezone.now(), + state='accepted', + converted_contribution=contribution, + ) + return contribution + def _assert_builder_lookup_empty(self, user): response = self.client.get( '/api/v1/leaderboard/', @@ -925,6 +942,78 @@ def test_generic_community_leaderboard_uses_effective_mee6_points(self): self.assertEqual(response.data['results'][1]['user_address'], truncate_address(portal_user.address)) self.assertEqual(response.data['results'][1]['total_points'], 3000) + def test_community_podium_uses_only_accepted_submission_points(self): + accepted_leader = self._create_user( + 'accepted-leader@example.com', + '0x0000000000000000000000000000000000000040', + ) + xp_leader = self._create_user( + 'xp-leader@example.com', + '0x0000000000000000000000000000000000000041', + ) + third = self._create_user( + 'accepted-third@example.com', + '0x0000000000000000000000000000000000000042', + ) + fourth = self._create_user( + 'accepted-fourth@example.com', + '0x0000000000000000000000000000000000000043', + ) + direct_only = self._create_user( + 'direct-only@example.com', + '0x0000000000000000000000000000000000000044', + ) + + self._accept_community_submission(accepted_leader, 100) + self._accept_community_submission(xp_leader, 80) + self._accept_community_submission(third, 70) + self._accept_community_submission(fourth, 60) + + # These awards affect the normal Community leaderboard but must not + # affect the accepted-submission-only podium. + self._create_current_mee6_xp(xp_leader, 'discord-podium-xp', 10000) + Contribution.objects.create( + user=xp_leader, + contribution_type=self.community_type, + points=2000, + frozen_global_points=2000, + contribution_date=timezone.now(), + ) + Contribution.objects.create( + user=direct_only, + contribution_type=self.community_type, + points=9000, + frozen_global_points=9000, + contribution_date=timezone.now(), + ) + task = SocialTask.objects.create( + slug='community-podium-social-task', + name='Community podium social task', + category=self.community_category, + points=5000, + verification_type='click_through', + action_url='https://example.com', + ) + SocialTaskCompletion.objects.create( + user=xp_leader, + task=task, + points_awarded=5000, + verification_type='click_through', + ) + + response = self.client.get('/api/v1/leaderboard/community-podium/') + + self.assertEqual(response.status_code, 200) + self.assertEqual( + [row['user'] for row in response.data], + [accepted_leader.id, xp_leader.id, third.id], + ) + self.assertEqual( + [row['total_points'] for row in response.data], + [100, 80, 70], + ) + self.assertEqual([row['rank'] for row in response.data], [1, 2, 3]) + def test_community_profile_and_ranking_use_same_total_with_social_tasks(self): user = self._create_user( 'community-social-ranking@example.com', diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 02f0830e..9eb4d359 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -3,7 +3,7 @@ from rest_framework.response import Response from django.utils import timezone from django.utils.dateparse import parse_date -from django.db.models import Count, Q, Sum +from django.db.models import Count, Exists, OuterRef, Q, Sum from django_filters.rest_framework import DjangoFilterBackend from .models import ( GlobalLeaderboardMultiplier, @@ -12,7 +12,7 @@ recalculate_all_leaderboards, ) from .serializers import GlobalLeaderboardMultiplierSerializer, LeaderboardEntrySerializer -from contributions.models import Contribution +from contributions.models import Contribution, SubmittedContribution from users.utils import is_full_address, truncate_address, user_lookup_kwargs ONBOARDING_CONTRIBUTION_TYPE_SLUGS = [ @@ -992,6 +992,49 @@ def get_full_details(user_ids): return Response(response_data) + @action(detail=False, methods=['get'], url_path='community-podium') + def community_podium(self, request): + """Top three Community users by points from accepted submissions only.""" + from users.models import User + from users.serializers import LightUserSerializer + + accepted_submission = SubmittedContribution.objects.filter( + state='accepted', + converted_contribution_id=OuterRef('pk'), + ) + podium_rows = list( + Contribution.objects + .filter( + user__visible=True, + contribution_type__category__slug='community', + ) + .annotate(has_accepted_submission=Exists(accepted_submission)) + .filter(has_accepted_submission=True) + .values('user_id') + .annotate(total_points=Sum('frozen_global_points')) + .filter(total_points__gt=0) + .order_by('-total_points', 'user__name', 'user_id')[:3] + ) + + users_by_id = { + user.id: user + for user in User.objects.filter( + id__in=[row['user_id'] for row in podium_rows], + ) + } + return Response([ + { + 'id': f'community-podium-{row["user_id"]}', + 'user': row['user_id'], + 'user_details': LightUserSerializer(users_by_id[row['user_id']]).data, + 'type': 'community', + 'total_points': row['total_points'], + 'rank': rank, + } + for rank, row in enumerate(podium_rows, start=1) + if row['user_id'] in users_by_id + ]) + @action(detail=False, methods=['get']) def referrals(self, request): """ diff --git a/backend/utils/dates.py b/backend/utils/dates.py index 51f96ffa..0c8ecdc4 100644 --- a/backend/utils/dates.py +++ b/backend/utils/dates.py @@ -1,4 +1,4 @@ -from datetime import datetime, time +from datetime import datetime, time, timedelta, timezone as datetime_timezone from django.utils import timezone @@ -8,3 +8,16 @@ def day_start(value): datetime.combine(value, time.min), timezone.get_current_timezone(), ) + + +def utc_week_bounds(value=None): + """Return the Monday-inclusive, next-Monday-exclusive UTC week bounds.""" + value = value or timezone.now() + value_utc = value.astimezone(datetime_timezone.utc) + week_start = (value_utc - timedelta(days=value_utc.weekday())).replace( + hour=0, + minute=0, + second=0, + microsecond=0, + ) + return week_start, week_start + timedelta(days=7) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 2a9d663a..0038d6dc 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -517,6 +517,8 @@ const routes = { #### Generic UI Components (`src/components/ui/`) Reusable, data-driven display components that accept data via props. Used on Dashboard and can be reused on any page. +- **Community Dashboard rankings**: `routes/Dashboard.svelte` intentionally uses two sources. “Top Community Contributors” reads the existing all-time Community leaderboard (effective Discord/MEE6 XP plus Community portal points), while its podium reads `/leaderboard/community-podium/` and shows only the top three users by points from accepted Community submissions. Builder and Validator dashboard ranking behavior is unchanged. + - **`SectionHeader.svelte`** - Reusable section header with title, subtitle, and "View all" link - Props: `title`, `subtitle`, `linkText="View all"`, `linkPath=""`, `showLink=true`, `showArrow=true` - **`StatCardRow.svelte`** - Row of stat cards with hexagon icons, large numbers, and delta indicators @@ -628,6 +630,8 @@ Investor-oriented home page (`routes/Overview.svelte`), top to bottom: hero → - Includes Google reCAPTCHA v2 verification for spam prevention - reCAPTCHA token validated on backend before submission - Uses VITE_RECAPTCHA_SITE_KEY from environment (falls back to test key) + - Honors optional per-user Monday-Sunday UTC contribution-type limits from `user_weekly_*` API fields and shows the user's remaining weekly capacity. + - New Milestones links can select only highlighted Projects contributions. `EditSubmission.svelte` passes the current submission ID so a pre-policy pending milestone keeps its existing unhighlighted link. - `EditSubmission.svelte` - Edit submitted contributions (supports URL and description evidence only - no file uploads) - `ProfileEdit.svelte` - User profile editing (name and profile fields; node version shown read-only, Grafana-sourced) - `Profile.svelte` - Public participant profile view diff --git a/frontend/src/components/Missions.svelte b/frontend/src/components/Missions.svelte index 26bca745..589d5c38 100644 --- a/frontend/src/components/Missions.svelte +++ b/frontend/src/components/Missions.svelte @@ -88,6 +88,7 @@ function isFull(entity) { if (!entity) return false; + if (entity.user_weekly_is_full === true) return true; if (entity.is_full === true) return true; return ( entity.max_submissions !== null && @@ -101,6 +102,7 @@ function missionCapacityLabel(mission, parentType) { if (mission?.user_is_full === true) return 'Your limit reached'; if (isFull(mission)) return 'Full'; + if (parentType?.user_weekly_is_full === true) return 'Your weekly limit reached'; if (isFull(parentType)) return 'Submissions closed'; if (mission?.max_submissions != null && mission?.submissions_remaining != null) { return spotsLeftLabel(mission.submissions_remaining); diff --git a/frontend/src/components/StewardSubmissionCard.svelte b/frontend/src/components/StewardSubmissionCard.svelte index 1b4221b1..0e6d19fa 100644 --- a/frontend/src/components/StewardSubmissionCard.svelte +++ b/frontend/src/components/StewardSubmissionCard.svelte @@ -812,7 +812,7 @@ } } catch (err) { if (requestId !== acceptedProjectsRequestId) return; - acceptedProjectsError = err.response?.data?.detail || err.message || 'Failed to load accepted projects'; + acceptedProjectsError = err.response?.data?.detail || err.message || 'Failed to load highlighted projects'; acceptedProjectsUser = null; acceptedProjectsLoaded = false; selectedProject = ''; @@ -830,7 +830,7 @@ return; } if (reviewAction === 'accept' && isSelectedMilestoneType && !selectedProject) { - showError('Select the accepted project this milestone belongs to.'); + showError('Select the highlighted project this milestone belongs to.'); return; } @@ -1831,18 +1831,18 @@ Related project * {#if acceptedProjectsLoading} -

Loading accepted projects...

+

Loading highlighted projects...

{:else if acceptedProjectsError}

{acceptedProjectsError}

{:else if acceptedProjects.length === 0} -

This user has no accepted project contributions.

+

This user has no highlighted project contributions.

{:else}