Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
28d36e0
Fix reviewed submission metrics
JoaquinBN May 22, 2026
c136442
Update community dashboard metrics
JoaquinBN May 24, 2026
112cd16
Merge pull request #677 from genlayer-foundation/JoaquinBN/community-…
JoaquinBN May 24, 2026
6ef8002
Add project profiles (#678)
JoaquinBN May 24, 2026
73c2212
Fix submission metrics category breakdown (#679)
JoaquinBN May 24, 2026
607740d
Separate referral and community leaderboards
JoaquinBN May 24, 2026
c004701
Merge pull request #680 from genlayer-foundation/JoaquinBN/audit-refe…
JoaquinBN May 24, 2026
6930f54
Add steward controls for accepted submissions
JoaquinBN May 24, 2026
c1fb5b8
Update dashboard contribution sections
JoaquinBN May 24, 2026
37fdc58
Merge pull request #681 from genlayer-foundation/JoaquinBN/accepted-p…
JoaquinBN May 24, 2026
e4eb5fb
Support community monthly leaderboard
JoaquinBN May 24, 2026
832ea85
Merge pull request #682 from genlayer-foundation/JoaquinBN/dashboard-…
JoaquinBN May 24, 2026
c8dd4f1
Add hero banner placement targeting
JoaquinBN May 24, 2026
55f1fe7
Merge pull request #683 from genlayer-foundation/JoaquinBN/hero-banne…
JoaquinBN May 24, 2026
8b34ed0
Improve accepted contribution edit panel
JoaquinBN May 25, 2026
3f1b79b
Fix accepted contribution highlight updates
JoaquinBN May 25, 2026
a1a6036
Merge pull request #684 from genlayer-foundation/JoaquinBN/improve-ac…
JoaquinBN May 25, 2026
e404668
Fix contribution explorer filters
JoaquinBN May 25, 2026
f684990
Merge pull request #685 from genlayer-foundation/JoaquinBN/fix-contri…
JoaquinBN May 25, 2026
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
11 changes: 11 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ backend/
- **Models**: `contributions/models.py`
- Contribution - Individual contribution records
- ContributionType - Categories with slug field, has M2M `accepted_evidence_url_types`
- 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)
- EvidenceURLType - Defines URL type categories (X Post, GitHub PR, etc.) with regex patterns for auto-detection and handle ownership validation
Expand All @@ -79,6 +80,12 @@ backend/
- Custom DRF serializer field for Google reCAPTCHA v2 validation
- Validates tokens from frontend reCAPTCHA widget
- Required for new contribution submissions only (not for edits)

### Projects
- **Models**: `projects/models.py`
- Project - Project profile with website/GitHub links, description, usage notes, details, media, participants, and related accepted contributions
- ProjectMetric - Admin-managed title/value/detail metric rows for project pages
- ProjectPageRevision - Owner-submitted ordered page blocks rendered through whitelisted portal components
- **AI Review**: `contributions/ai_review/views.py`
- `/api/v1/ai-review/` - List pending submissions for the external AI review agent
- `/api/v1/ai-review/{id}/` - Retrieve a pending submission with evidence and user history
Expand Down Expand Up @@ -261,6 +268,10 @@ GET /api/v1/ai-review/templates/
GET /api/v1/partners/ (public, list active partners)
GET /api/v1/partners/{slug}/ (public, partner detail)

# Projects
GET /api/v1/projects/ (public, active project profiles)
GET /api/v1/projects/{slug}/ (public, project detail with metrics and related contributions)

# Gen TV
GET /api/v1/gen-tv/streams/ (public, supports ?category= filter)
GET /api/v1/gen-tv/streams/{slug}/ (public, stream detail)
Expand Down
2 changes: 2 additions & 0 deletions backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet
from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet
from partners.views import PartnerViewSet
from projects.views import ProjectViewSet
from gen_tv.views import StreamCategoryViewSet, StreamViewSet
from poaps.views import PoapDropViewSet
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView
Expand All @@ -22,6 +23,7 @@
router.register(r'missions', MissionViewSet, basename='mission')
router.register(r'startup-requests', StartupRequestViewSet, basename='startup-request')
router.register(r'featured', FeaturedContentViewSet, basename='featured')
router.register(r'projects', ProjectViewSet, basename='project')
router.register(r'alerts', AlertViewSet, basename='alert')
router.register(r'partners', PartnerViewSet, basename='partner')
router.register(r'gen-tv/streams', StreamViewSet, basename='stream')
Expand Down
33 changes: 31 additions & 2 deletions backend/contributions/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.contrib import admin
from django import forms
from django.core.management import call_command
from django.contrib import messages
from django.http import HttpResponseRedirect, JsonResponse
Expand Down Expand Up @@ -32,6 +33,27 @@
NON_CAPACITY_STATES = ['rejected', 'canceled']


class FeaturedContentAdminForm(forms.ModelForm):
hero_placements = forms.MultipleChoiceField(
choices=FeaturedContent.HERO_PLACEMENT_CHOICES,
required=False,
widget=forms.CheckboxSelectMultiple,
help_text=(
"For hero banners only. Select 'All hero surfaces' or choose one "
"or more specific surfaces."
),
)

class Meta:
model = FeaturedContent
fields = '__all__'

def clean_hero_placements(self):
return FeaturedContent.normalize_hero_placements(
self.cleaned_data.get('hero_placements', [])
)


def active_submission_count_subquery(fk_name):
return SubmittedContribution.objects.exclude(
state__in=NON_CAPACITY_STATES
Expand Down Expand Up @@ -851,6 +873,7 @@ def get_status(self, obj):

@admin.register(FeaturedContent)
class FeaturedContentAdmin(CloudinaryUploadMixin, admin.ModelAdmin):
form = FeaturedContentAdminForm
cloudinary_upload_fields = {
'hero_image_url': {
'public_id_field': 'hero_image_public_id',
Expand All @@ -870,7 +893,7 @@ class FeaturedContentAdmin(CloudinaryUploadMixin, admin.ModelAdmin):
},
}

list_display = ('title', 'content_type', 'user', 'status', 'order', 'created_at')
list_display = ('title', 'content_type', 'display_hero_placements', 'user', 'status', 'order', 'created_at')
list_filter = ('content_type', 'status', 'created_at')
search_fields = ('title', 'description', 'user__name', 'user__address')
list_editable = ('order', 'status')
Expand All @@ -882,7 +905,7 @@ class FeaturedContentAdmin(CloudinaryUploadMixin, admin.ModelAdmin):

fieldsets = (
(None, {
'fields': ('content_type', 'title', 'author', 'description', 'status', 'order')
'fields': ('content_type', 'title', 'author', 'description', 'hero_placements', 'status', 'order')
}),
('Relations', {
'fields': ('user', 'contribution')
Expand All @@ -898,6 +921,12 @@ class FeaturedContentAdmin(CloudinaryUploadMixin, admin.ModelAdmin):
}),
)

@admin.display(description='Hero placements')
def display_hero_placements(self, obj):
labels = dict(FeaturedContent.HERO_PLACEMENT_CHOICES)
placements = obj.hero_placements or []
return ', '.join(labels.get(placement, placement) for placement in placements) or '-'


@admin.register(Alert)
class AlertAdmin(admin.ModelAdmin):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 6.0.5 on 2026-05-24

import contributions.models
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('contributions', '0060_contributiontype_required_discord_roles'),
]

operations = [
migrations.AddField(
model_name='featuredcontent',
name='hero_placements',
field=models.JSONField(
blank=True,
default=contributions.models.default_featured_hero_placements,
help_text=(
"Hero banner placements. Use 'all' to show everywhere, or select "
"specific surfaces such as overview, builder, validator, and community."
),
),
),
]
51 changes: 51 additions & 0 deletions backend/contributions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
logger = get_app_logger('contributions')


def default_featured_hero_placements():
return ['overview', 'builder', 'community']


class Category(BaseModel):
"""
Defines a user category (Validator, Builder, Steward).
Expand Down Expand Up @@ -931,6 +935,7 @@ class FeaturedContent(BaseModel):
"""
CONTENT_TYPE_CHOICES = [
('hero', 'Hero Banner'),
# Legacy portal builds are kept for compatibility; new project profiles live in projects.Project.
('build', 'Featured Build'),
('community', 'Featured Community'),
('validator_steward', 'Featured Validator/Steward'),
Expand All @@ -939,6 +944,14 @@ class FeaturedContent(BaseModel):
('active', 'Active'),
('idle', 'Idle'),
]
HERO_PLACEMENT_ALL = 'all'
HERO_PLACEMENT_CHOICES = [
(HERO_PLACEMENT_ALL, 'All hero surfaces'),
('overview', 'Overview'),
('builder', 'Builder dashboard'),
('validator', 'Validator dashboard'),
('community', 'Community dashboard'),
]
content_type = models.CharField(max_length=20, choices=CONTENT_TYPE_CHOICES)
title = models.CharField(max_length=200)
description = models.TextField(blank=True)
Expand All @@ -960,6 +973,14 @@ class FeaturedContent(BaseModel):
user_profile_image_url = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for user profile image')
user_profile_image_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for user profile image')
url = models.URLField(max_length=500, blank=True)
hero_placements = models.JSONField(
default=default_featured_hero_placements,
blank=True,
help_text=(
"Hero banner placements. Use 'all' to show everywhere, or select "
"specific surfaces such as overview, builder, validator, and community."
)
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='active')
order = models.PositiveIntegerField(default=0)

Expand All @@ -974,6 +995,36 @@ def get_link(self):
return f"/badge/{self.contribution_id}"
return self.url or None

@classmethod
def normalize_hero_placements(cls, placements):
if not isinstance(placements, list):
return []
valid = {choice[0] for choice in cls.HERO_PLACEMENT_CHOICES}
normalized = []
for placement in placements:
if placement in valid and placement not in normalized:
normalized.append(placement)
if cls.HERO_PLACEMENT_ALL in normalized:
return [cls.HERO_PLACEMENT_ALL]
return normalized

def clean(self):
super().clean()
if not isinstance(self.hero_placements, list):
raise ValidationError({'hero_placements': 'Hero placements must be a list.'})

valid = {choice[0] for choice in self.HERO_PLACEMENT_CHOICES}
invalid = [placement for placement in self.hero_placements if placement not in valid]
if invalid:
raise ValidationError({
'hero_placements': f"Invalid hero placement value(s): {', '.join(invalid)}"
})
self.hero_placements = self.normalize_hero_placements(self.hero_placements)

def shows_in_placement(self, placement):
placements = self.normalize_hero_placements(self.hero_placements)
return self.HERO_PLACEMENT_ALL in placements or placement in placements

@classmethod
def get_active_by_type(cls, content_type, limit=10):
return cls.objects.filter(
Expand Down
81 changes: 78 additions & 3 deletions backend/contributions/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,23 @@ class LightContributionSerializer(serializers.Serializer):
contribution_date = serializers.DateTimeField(read_only=True)
notes = serializers.CharField(read_only=True)
title = serializers.CharField(read_only=True, allow_blank=True)
is_highlighted = serializers.SerializerMethodField()
highlight = serializers.SerializerMethodField()
evidence_items = serializers.SerializerMethodField()
created_at = serializers.DateTimeField(read_only=True)

def get_highlight(self, obj):
highlight = next(iter(obj.highlights.all()), None)
if not highlight:
return None
return {
'title': highlight.title,
'description': highlight.description,
}

def get_is_highlighted(self, obj):
return bool(self.get_highlight(obj))

def get_evidence_items(self, obj):
"""Returns serialized evidence items for this contribution."""
# ViewSet prefetches evidence_items to avoid N+1 queries
Expand Down Expand Up @@ -830,6 +844,39 @@ def validate(self, data):
return data


class StewardAcceptedSubmissionUpdateSerializer(serializers.Serializer):
"""Serializer for correcting accepted submission awards."""
points = serializers.IntegerField(required=True, min_value=0)
create_highlight = serializers.BooleanField(default=False, required=False)
remove_highlight = serializers.BooleanField(default=False, required=False)
highlight_title = serializers.CharField(max_length=200, required=False, allow_blank=True)
highlight_description = serializers.CharField(required=False, allow_blank=True)

def validate(self, data):
contribution_type = self.context['contribution_type']
points = data['points']
if points < contribution_type.min_points or points > contribution_type.max_points:
raise serializers.ValidationError({
'points': f'Points must be between {contribution_type.min_points} and {contribution_type.max_points} for {contribution_type.name}.'
})

if data.get('create_highlight'):
if data.get('remove_highlight'):
raise serializers.ValidationError({
'remove_highlight': 'Cannot remove and create a highlight in the same update.'
})
if not data.get('highlight_title'):
raise serializers.ValidationError({
'highlight_title': 'Title is required when creating a highlight.'
})
if not data.get('highlight_description'):
raise serializers.ValidationError({
'highlight_description': 'Description is required when creating a highlight.'
})

return data


class SubmissionNoteSerializer(serializers.ModelSerializer):
"""Serializer for CRM notes on submissions."""
user_name = serializers.SerializerMethodField()
Expand Down Expand Up @@ -981,10 +1028,37 @@ def get_evidence_items(self, obj):
return EvidenceSerializer(evidence_items, many=True, context=self.context).data

def get_contribution(self, obj):
if self.context.get('use_light_serializers', False):
return None

if obj.converted_contribution:
if self.context.get('use_light_serializers', False):
contribution = obj.converted_contribution
highlight = next(iter(contribution.highlights.all()), None)
return {
'id': contribution.id,
'user': contribution.user_id,
'user_details': LightUserSerializer(contribution.user).data,
'contribution_type': contribution.contribution_type_id,
'contribution_type_name': contribution.contribution_type.name,
'contribution_type_min_points': contribution.contribution_type.min_points,
'contribution_type_max_points': contribution.contribution_type.max_points,
'contribution_type_details': LightContributionTypeSerializer(
contribution.contribution_type
).data,
'points': contribution.points,
'frozen_global_points': contribution.frozen_global_points,
'multiplier_at_creation': str(contribution.multiplier_at_creation) if contribution.multiplier_at_creation is not None else None,
'contribution_date': contribution.contribution_date,
'notes': contribution.notes,
'title': contribution.title,
'highlight': {
'title': highlight.title,
'description': highlight.description
} if highlight else None,
'is_highlighted': bool(highlight),
'mission': LightMissionSerializer(contribution.mission).data if contribution.mission else None,
'created_at': contribution.created_at,
'updated_at': contribution.updated_at,
}

from .models import ContributionHighlight
contrib_context = self.context.copy()
contrib_context['use_light_serializers'] = True
Expand Down Expand Up @@ -1168,6 +1242,7 @@ class Meta:
model = FeaturedContent
fields = ['id', 'content_type', 'title', 'description', 'author',
'hero_image_url', 'hero_image_url_tablet', 'hero_image_url_mobile',
'hero_placements',
'url', 'link',
'user', 'user_name', 'user_address', 'user_profile_image_url',
'featured_profile_image_url',
Expand Down
Loading
Loading