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}
- Select accepted project...
+ Select highlighted project...
{#each acceptedProjects as project}
{project.title} (next v{project.next_milestone_version || 1})
{/each}
diff --git a/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte b/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte
index 27f5ce30..467bc37d 100644
--- a/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte
+++ b/frontend/src/components/portal/submit-contribution/SubmitContribution.svelte
@@ -152,7 +152,7 @@
formData.contribution_type = "";
searchQuery = "";
showTypeDropdown = true;
- error = "This contribution type has reached its submission limit.";
+ error = typeLimitError(parentType);
} else if (isMissionSubmittable(mission)) {
selectedType = parentType;
selectedMission = mission.id;
@@ -189,7 +189,7 @@
formData.contribution_type = "";
searchQuery = "";
showTypeDropdown = true;
- error = "This contribution type has reached its submission limit.";
+ error = typeLimitError(type);
} else {
showTypeDropdown = true;
}
@@ -209,7 +209,7 @@
formData.contribution_type = "";
searchQuery = "";
showTypeDropdown = true;
- error = "This contribution type has reached its submission limit.";
+ error = typeLimitError(type);
} else {
showTypeDropdown = true;
}
@@ -374,6 +374,7 @@
function isTypeFull(type) {
if (!type) return false;
+ if (type.user_weekly_is_full === true) return true;
if (type.is_full === true) return true;
return (
type.max_submissions !== null &&
@@ -384,6 +385,13 @@
);
}
+ function typeLimitError(type) {
+ if (type?.user_weekly_is_full === true) {
+ return "You have reached your weekly submission limit for this contribution type.";
+ }
+ return "This contribution type has reached its submission limit.";
+ }
+
function canSubmitTypeDirectly(type) {
return type.is_submittable && !isTypeFull(type);
}
@@ -404,7 +412,7 @@
const response = await submissionsAPI.getAcceptedProjects();
acceptedProjects = response.data || [];
} catch (err) {
- // Keep failures distinct from "no accepted projects" so a transient
+ // Keep failures distinct from "no highlighted projects" so a transient
// error never tells a builder they are not eligible for milestones.
projectsError = true;
} finally {
@@ -418,7 +426,7 @@
let selectedProjectLabel = $derived(
selectedProjectData
? `${selectedProjectData.title} (next v${selectedProjectData.next_milestone_version || 1})`
- : "Select accepted project..."
+ : "Select highlighted project..."
);
let selectedProjectIndex = $derived(
acceptedProjects.findIndex((project) => String(project.id) === String(selectedProject))
@@ -552,7 +560,7 @@
function selectType(t) {
if (isTypeFull(t)) {
- error = "This contribution type has reached its submission limit.";
+ error = typeLimitError(t);
return;
}
@@ -578,7 +586,7 @@
selectType(item.data);
} else if (item.itemType === "mission") {
if (isTypeFull(item.parentType)) {
- error = "This contribution type has reached its submission limit.";
+ error = typeLimitError(item.parentType);
return;
}
if (!isMissionSubmittable(item.data)) {
@@ -675,7 +683,7 @@
function selectProject(project, options = {}) {
selectedProject = String(project.id);
closeProjectDropdown({ focusTrigger: options.focusTrigger ?? true });
- if (error === "Please select the accepted project this milestone belongs to.") {
+ if (error === "Please select the highlighted project this milestone belongs to.") {
error = "";
}
}
@@ -1015,7 +1023,7 @@
}
if (isMilestoneType(selectedType) && !selectedProject) {
- error = "Please select the accepted project this milestone belongs to.";
+ error = "Please select the highlighted project this milestone belongs to.";
return;
}
@@ -1124,7 +1132,7 @@
try {
if (isTypeFull(selectedType)) {
- error = "This contribution type has reached its submission limit.";
+ error = typeLimitError(selectedType);
submitting = false;
return;
}
@@ -1523,6 +1531,12 @@
>{spotsLeftLabel(item.data.submissions_remaining)}
{/if}
+ {#if item.data.max_submissions_per_user_per_week != null && item.data.user_weekly_submissions_remaining != null}
+ {spotsLeftLabel(item.data.user_weekly_submissions_remaining)} this week
+ {/if}
{/if}
@@ -1582,6 +1596,11 @@
Capacity: {spotsLeftLabel(selectedType.submissions_remaining)}
{/if}
+ {#if selectedType.max_submissions_per_user_per_week != null && selectedType.user_weekly_submissions_remaining != null}
+
+ Your weekly capacity: {spotsLeftLabel(selectedType.user_weekly_submissions_remaining)}
+
+ {/if}
{#if selectedType.description}
{selectedType.description}
@@ -1614,7 +1633,7 @@
Linked Project
- Milestones must belong to an accepted project. Stewards review
+ Milestones must belong to a highlighted project. Stewards review
your project's GitHub repository for the changes you describe.
@@ -1627,12 +1646,12 @@
{#if loadingProjects}
- Loading accepted projects...
+ Loading highlighted projects...
{:else if projectsError}
- We couldn't load your accepted projects.
+ We couldn't load your highlighted projects.
- You need an accepted project before submitting milestones.
+ You need a highlighted project before submitting milestones.
- Submit a Projects contribution first. Once it is accepted, milestones can be linked to it.
+ Submit a Projects contribution first. Once it is accepted and highlighted, milestones can be linked to it.
{:else}
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 3fd670b8..fdac386f 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -179,7 +179,11 @@ export const contributionsAPI = {
// API endpoints for the submitter-side submission flows
export const submissionsAPI = {
appeal: (id, reason) => api.post(`/submissions/${id}/appeal/`, { reason }),
- getAcceptedProjects: () => api.get('/submissions/accepted-projects/')
+ /** @param {string | null} submissionId */
+ getAcceptedProjects: (submissionId = null) =>
+ api.get('/submissions/accepted-projects/', {
+ params: submissionId ? { submission: submissionId } : {}
+ })
};
// Portal notifications API
@@ -231,6 +235,7 @@ export const leaderboardAPI = {
api.get('/leaderboard/monthly/', { params: { type, limit, ...additionalParams } }),
getCommunity: (params = {}) => leaderboardAPI.getLeaderboard({ type: 'community', ...params }),
getCommunityContributors: (params = {}) => leaderboardAPI.getLeaderboard({ type: 'community', ...params }),
+ getCommunityPodium: () => api.get('/leaderboard/community-podium/'),
getReferrals: (params = {}) => api.get('/leaderboard/referrals/', { params }),
getTrending: (limit = 10, params = {}) => api.get('/leaderboard/trending/', { params: { limit, ...params } }),
getTypes: () => api.get('/leaderboard/types/'),
@@ -349,7 +354,7 @@ export const stewardAPI = {
// Get all users for reassignment dropdown
getUsers: () => api.get('/steward-submissions/users/'),
- // Get accepted Projects contributions for a selected award user
+ // Get highlighted Projects contributions for a selected award user
getAcceptedProjectsForUser: (userId, submissionId = null) =>
api.get('/steward-submissions/accepted-projects/', {
params: {
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte
index d69b6737..0516cfb1 100644
--- a/frontend/src/routes/Dashboard.svelte
+++ b/frontend/src/routes/Dashboard.svelte
@@ -20,12 +20,14 @@
// State
let statsData = $state([]);
let leaderboardEntries = $state([]);
+ let communityPodiumEntries = $state([]);
let newestMembers = $state([]);
let trendingEntries = $state([]);
let recentContributions = $state([]);
let statsLoading = $state(true);
let leaderboardLoading = $state(true);
+ let communityPodiumLoading = $state(true);
let membersLoading = $state(true);
let trendingLoading = $state(true);
let recentLoading = $state(true);
@@ -49,20 +51,28 @@
let leaderboardTitle = $derived(isCommunity ? 'Top Community Contributors' : 'Top Contributors');
let leaderboardSubtitle = $derived(
isCommunity
- ? 'Community contributions from the last 30 days'
+ ? 'All-time XP and Community points'
: isValidator
? 'All-time validator contributors'
: 'Curated builds from the last 30 days'
);
let leaderboardPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/leaderboard' : '/validators/leaderboard');
- let podiumTitle = $derived(isValidator ? 'All-time Podium' : 'Last 30 Days Podium');
+ let podiumTitle = $derived(
+ isCommunity ? 'Accepted Submissions Podium' : isValidator ? 'All-time Podium' : 'Last 30 Days Podium'
+ );
let podiumSubtitle = $derived(
isCommunity
- ? "Who's contributing most to the community over the last 30 days?"
+ ? 'Top Community contributors by points from accepted submissions'
: isValidator
? "Who's contributed most to GenLayer?"
: "Who's contributing more to GenLayer over the last 30 days?"
);
+ let podiumEntries = $derived(
+ isCommunity ? communityPodiumEntries : leaderboardEntries.slice(0, 3)
+ );
+ let podiumLoading = $derived(
+ isCommunity ? communityPodiumLoading : leaderboardLoading
+ );
let newestTitle = $derived(isBuilder ? 'Newest Builders' : isCommunity ? 'Newest Community Contributors' : 'Newest Validators');
let newestPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/participants');
let highlightsPath = $derived(
@@ -142,11 +152,13 @@
function resetDashboardState() {
statsData = [];
leaderboardEntries = [];
+ communityPodiumEntries = [];
newestMembers = [];
trendingEntries = [];
recentContributions = [];
statsLoading = true;
leaderboardLoading = true;
+ communityPodiumLoading = true;
membersLoading = true;
trendingLoading = true;
recentLoading = true;
@@ -169,11 +181,11 @@
if (requestId === dashboardRequestSequence) statsLoading = false;
}),
- // Top contributors. Validator dashboard is intentionally all-time;
- // builder and community dashboards use rolling 30-day contribution totals.
- (cat === 'validator'
- ? leaderboardAPI.getLeaderboard({ type: 'validator', order: 'asc', limit: 5 })
- : leaderboardAPI.getMonthlyLeaderboardByType(cat, 5, getLast30DaysParams())
+ // Top contributors. Community and Validator use their all-time
+ // leaderboards; Builder keeps its rolling 30-day view.
+ (cat === 'builder'
+ ? leaderboardAPI.getMonthlyLeaderboardByType(cat, 5, getLast30DaysParams())
+ : leaderboardAPI.getLeaderboard({ type: cat, order: 'asc', limit: 5 })
).then(res => {
if (requestId !== dashboardRequestSequence) return;
leaderboardEntries = Array.isArray(res.data) ? res.data : (res.data?.results ?? []);
@@ -184,6 +196,20 @@
];
+ if (cat === 'community') {
+ promises.push(
+ leaderboardAPI.getCommunityPodium().then(res => {
+ if (requestId !== dashboardRequestSequence) return;
+ communityPodiumEntries = Array.isArray(res.data) ? res.data : (res.data?.results ?? []);
+ communityPodiumLoading = false;
+ }).catch(() => {
+ if (requestId === dashboardRequestSequence) communityPodiumLoading = false;
+ })
+ );
+ } else {
+ communityPodiumLoading = false;
+ }
+
if (cat === 'community') {
promises.push(
contributionsAPI.getContributions({ limit: 20, category: cat, exclude_onboarding: 'true' }).then(res => {
@@ -305,8 +331,8 @@
showLink={false}
/>
*
{#if loadingProjects}
- Loading accepted projects...
+ Loading highlighted projects...
{:else if projectsError}
- We couldn't load your accepted projects.
+ We couldn't load your highlighted projects.
{:else if acceptedProjects.length === 0}
- You need an accepted project before submitting milestones.
- Submit a Projects contribution first; once it is accepted,
+ You need a highlighted project before submitting milestones.
+ Submit a Projects contribution first; once it is accepted and highlighted,
milestones can be linked to it.
{:else}
@@ -766,7 +766,7 @@
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
required
>
- Select accepted project...
+ Select highlighted project...
{#each acceptedProjects as project}
{project.title}
diff --git a/frontend/src/routes/MissionDetail.svelte b/frontend/src/routes/MissionDetail.svelte
index 2ef4b4cf..53a0fe9e 100644
--- a/frontend/src/routes/MissionDetail.svelte
+++ b/frontend/src/routes/MissionDetail.svelte
@@ -68,7 +68,8 @@
Number(mission.submissions_remaining) <= 0)
);
let contributionTypeIsFull = $derived(
- contributionType?.is_full === true ||
+ contributionType?.user_weekly_is_full === true ||
+ contributionType?.is_full === true ||
(contributionType?.max_submissions != null &&
contributionType?.submissions_remaining != null &&
Number(contributionType.submissions_remaining) <= 0)
@@ -87,6 +88,8 @@
? 'Mission not started'
: mission?.user_is_full === true
? 'Limit reached'
+ : contributionType?.user_weekly_is_full === true
+ ? 'Weekly limit reached'
: submissionClosed
? 'Submissions closed'
: 'Submit to mission'
@@ -95,6 +98,7 @@
if (missionEnded) return 'Ended';
if (missionUnavailable) return 'Not started';
if (mission?.user_is_full === true) return 'Your limit reached';
+ if (contributionType?.user_weekly_is_full === true) return 'Your weekly limit reached';
if (mission?.max_submissions == null) {
return contributionTypeIsFull ? 'Submissions closed' : 'Unlimited';
}
diff --git a/frontend/src/tests/communityDashboardRankings.test.js b/frontend/src/tests/communityDashboardRankings.test.js
new file mode 100644
index 00000000..414c3b3d
--- /dev/null
+++ b/frontend/src/tests/communityDashboardRankings.test.js
@@ -0,0 +1,49 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/svelte/svelte5';
+
+import Dashboard from '../routes/Dashboard.svelte';
+import { contributionsAPI, leaderboardAPI, statsAPI } from '../lib/api.js';
+import { currentCategory } from '../stores/category.js';
+
+
+describe('Community dashboard rankings', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ currentCategory.set('community');
+
+ statsAPI.getDashboardStats.mockResolvedValue({ data: {} });
+ contributionsAPI.getContributions.mockResolvedValue({ data: { results: [] } });
+ leaderboardAPI.getLeaderboard.mockResolvedValue({ data: { results: [] } });
+ leaderboardAPI.getCommunityPodium.mockResolvedValue({
+ data: [
+ { rank: 1, user: 1, user_details: { id: 1, name: 'Accepted One' }, total_points: 300 },
+ { rank: 2, user: 2, user_details: { id: 2, name: 'Accepted Two' }, total_points: 200 },
+ { rank: 3, user: 3, user_details: { id: 3, name: 'Third Place' }, total_points: 100 },
+ ],
+ });
+ });
+
+ afterEach(() => {
+ currentCategory.set('builder');
+ });
+
+ it('uses all-time leaderboard totals and a separate accepted-submission podium', async () => {
+ render(Dashboard);
+
+ await waitFor(() => {
+ expect(leaderboardAPI.getLeaderboard).toHaveBeenCalledWith({
+ type: 'community',
+ order: 'asc',
+ limit: 5,
+ });
+ expect(leaderboardAPI.getCommunityPodium).toHaveBeenCalledTimes(1);
+ });
+
+ expect(leaderboardAPI.getMonthlyLeaderboardByType).not.toHaveBeenCalled();
+ expect(screen.getByText('All-time XP and Community points')).toBeDefined();
+ expect(screen.getByText('Accepted Submissions Podium')).toBeDefined();
+ expect(screen.getByText('Accepted One')).toBeDefined();
+ expect(screen.getByText('Accepted Two')).toBeDefined();
+ expect(screen.getByText('Third Place')).toBeDefined();
+ });
+});
diff --git a/frontend/src/tests/setupTests.js b/frontend/src/tests/setupTests.js
index a302e7e4..9b011ae8 100644
--- a/frontend/src/tests/setupTests.js
+++ b/frontend/src/tests/setupTests.js
@@ -168,6 +168,7 @@ vi.mock('../lib/api', () => {
getStats: vi.fn().mockResolvedValue(mockStatsData),
getMonthlyLeaderboardByType: vi.fn().mockResolvedValue(mockLeaderboardData),
getCommunity: vi.fn().mockResolvedValue({ data: { results: [] } }),
+ getCommunityPodium: vi.fn().mockResolvedValue({ data: [] }),
getReferrals: vi.fn().mockResolvedValue({ data: { results: [] } }),
getWaitlistTop: vi.fn().mockResolvedValue({ data: [] }),
getTrending: vi.fn().mockResolvedValue({ data: [] })