Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion backend/contributions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
]
Original file line number Diff line number Diff line change
@@ -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',
),
),
]
53 changes: 53 additions & 0 deletions backend/contributions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -902,6 +946,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)
Expand All @@ -917,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',
),
]


Expand Down
19 changes: 14 additions & 5 deletions backend/contributions/project_milestones.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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):
Expand Down
Loading
Loading