diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 46c70ebe..5f103c92 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 @@ -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 @@ -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) diff --git a/backend/api/urls.py b/backend/api/urls.py index 91496650..430a09cb 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -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 @@ -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') diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index 94c5e752..53cdf6d1 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -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 @@ -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 @@ -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', @@ -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') @@ -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') @@ -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): diff --git a/backend/contributions/migrations/0061_featuredcontent_hero_placements.py b/backend/contributions/migrations/0061_featuredcontent_hero_placements.py new file mode 100644 index 00000000..e689a3a6 --- /dev/null +++ b/backend/contributions/migrations/0061_featuredcontent_hero_placements.py @@ -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." + ), + ), + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 1f86f832..15521b69 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -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). @@ -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'), @@ -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) @@ -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) @@ -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( diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index f323df45..b98bdaa3 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -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 @@ -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() @@ -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 @@ -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', diff --git a/backend/contributions/tests/test_canceled_submissions.py b/backend/contributions/tests/test_canceled_submissions.py index 98722070..60ba0e02 100644 --- a/backend/contributions/tests/test_canceled_submissions.py +++ b/backend/contributions/tests/test_canceled_submissions.py @@ -1,10 +1,13 @@ +from datetime import timedelta + from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone from rest_framework import status from rest_framework.test import APIClient -from contributions.models import Category, ContributionType, SubmittedContribution +from contributions.models import Category, Contribution, ContributionType, SubmittedContribution +from leaderboard.models import GlobalLeaderboardMultiplier User = get_user_model() @@ -67,3 +70,112 @@ def test_canceled_submissions_are_not_reviewed_decisions(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['total_reviewed'], 1) self.assertEqual(response.data['total_rejected'], 1) + + def test_daily_metrics_points_exclude_onboarding_and_social_linking_awards(self): + builder_category = Category.objects.create( + name='Builder', slug='builder', description='Builder', + ) + community_category = Category.objects.create( + name='Community', slug='community', description='Community', + ) + real_type = ContributionType.objects.create( + name='Real Contribution', + slug='real-contribution', + description='Real contribution', + category=builder_category, + min_points=0, + max_points=1000, + ) + excluded_types = [ + ContributionType.objects.create( + name='Builder Welcome', + slug='builder-welcome', + description='Builder welcome', + category=builder_category, + min_points=0, + max_points=1000, + ), + ContributionType.objects.create( + name='Builder', + slug='builder', + description='Builder social linking', + category=builder_category, + min_points=0, + max_points=1000, + ), + ContributionType.objects.create( + name='Link X Account', + slug='community-link-x', + description='Community X link', + category=community_category, + min_points=0, + max_points=1000, + ), + ContributionType.objects.create( + name='Link Discord Account', + slug='community-link-discord', + description='Community Discord link', + category=community_category, + min_points=0, + max_points=1000, + ), + ] + for contribution_type in [real_type, *excluded_types]: + GlobalLeaderboardMultiplier.objects.create( + contribution_type=contribution_type, + multiplier_value=1.0, + valid_from=timezone.now() - timedelta(days=1), + ) + + today = timezone.now() + real_submission = SubmittedContribution.objects.create( + user=self.user, + contribution_type=real_type, + contribution_date=today, + notes='Real submission', + state='accepted', + reviewed_at=today, + ) + real_contribution = Contribution.objects.create( + user=self.user, + contribution_type=real_type, + points=100, + frozen_global_points=100, + contribution_date=today, + notes='Real contribution', + ) + real_submission.converted_contribution = real_contribution + real_submission.save(update_fields=['converted_contribution']) + + for contribution_type in excluded_types: + submission = SubmittedContribution.objects.create( + user=self.user, + contribution_type=contribution_type, + contribution_date=today, + notes='Excluded submission', + state='accepted', + reviewed_at=today, + ) + contribution = Contribution.objects.create( + user=self.user, + contribution_type=contribution_type, + points=25, + frozen_global_points=25, + contribution_date=today, + notes='Excluded contribution', + ) + submission.converted_contribution = contribution + submission.save(update_fields=['converted_contribution']) + + response = self.client.get( + '/api/v1/steward-submissions/daily-metrics/', + { + 'group_by': 'day', + 'start_date': today.date().isoformat(), + 'end_date': today.date().isoformat(), + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['totals']['accepted'], 5) + self.assertEqual(response.data['totals']['points_awarded'], 100) diff --git a/backend/contributions/tests/test_featured_content_api.py b/backend/contributions/tests/test_featured_content_api.py new file mode 100644 index 00000000..74bae5bf --- /dev/null +++ b/backend/contributions/tests/test_featured_content_api.py @@ -0,0 +1,71 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from contributions.models import FeaturedContent + +User = get_user_model() + + +class FeaturedContentAPITest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email='featured-user@example.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + + def create_hero(self, title, placements=None, status_value='active', order=0): + data = { + 'content_type': 'hero', + 'title': title, + 'description': 'Featured hero', + 'user': self.user, + 'status': status_value, + 'order': order, + } + if placements is not None: + data['hero_placements'] = placements + return FeaturedContent.objects.create(**data) + + def test_hero_placement_filter_includes_all_and_matching_surface(self): + self.create_hero('All surfaces', placements=['all'], order=0) + self.create_hero('Builder only', placements=['builder'], order=1) + self.create_hero('Overview only', placements=['overview'], order=2) + self.create_hero('Inactive builder', placements=['builder'], status_value='idle', order=3) + + response = self.client.get('/api/v1/featured/?type=hero&placement=builder') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item['title'] for item in response.json()], + ['All surfaces', 'Builder only'], + ) + + def test_hero_placement_filter_supports_overview(self): + self.create_hero('All surfaces', placements=['all'], order=0) + self.create_hero('Overview only', placements=['overview'], order=1) + self.create_hero('Community only', placements=['community'], order=2) + + response = self.client.get('/api/v1/featured/?type=hero&placement=overview') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + [item['title'] for item in response.json()], + ['All surfaces', 'Overview only'], + ) + + def test_hero_placement_filter_rejects_unknown_surface(self): + response = self.client.get('/api/v1/featured/?type=hero&placement=unknown') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_default_hero_placements_keep_existing_surfaces(self): + hero = self.create_hero('Default surfaces') + + self.assertTrue(hero.shows_in_placement('overview')) + self.assertTrue(hero.shows_in_placement('builder')) + self.assertTrue(hero.shows_in_placement('community')) + self.assertFalse(hero.shows_in_placement('validator')) diff --git a/backend/contributions/tests/test_public_explorer_filters.py b/backend/contributions/tests/test_public_explorer_filters.py new file mode 100644 index 00000000..92762879 --- /dev/null +++ b/backend/contributions/tests/test_public_explorer_filters.py @@ -0,0 +1,147 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from contributions.models import Category, Contribution, ContributionType, Mission +from leaderboard.models import GlobalLeaderboardMultiplier + +User = get_user_model() + + +class PublicExplorerFiltersTest(TestCase): + def setUp(self): + self.client = APIClient() + self.category = Category.objects.create( + name='Explorer Test', + slug='explorer-test', + description='Explorer test category', + ) + self.user = User.objects.create_user( + email='explorer-user@test.com', + address='0x0000000000000000000000000000000000000001', + password='testpass123', + ) + + self.submittable_type = self._create_type( + 'Submittable Explorer Type', + 'submittable-explorer-type', + is_submittable=True, + ) + self.visible_type = self._create_type( + 'Visible Non-Submittable Explorer Type', + 'visible-non-submittable-explorer-type', + is_submittable=False, + show_in_contributions=True, + ) + self.hidden_type = self._create_type( + 'Hidden Non-Submittable Explorer Type', + 'hidden-non-submittable-explorer-type', + is_submittable=False, + show_in_contributions=False, + ) + + self.active_mission = Mission.objects.create( + name='Active Explorer Mission', + description='Active mission', + contribution_type=self.submittable_type, + start_date=timezone.now() - timedelta(days=1), + end_date=timezone.now() + timedelta(days=1), + ) + self.inactive_mission = Mission.objects.create( + name='Inactive Explorer Mission', + description='Inactive mission', + contribution_type=self.submittable_type, + start_date=timezone.now() - timedelta(days=3), + end_date=timezone.now() - timedelta(days=1), + ) + + self.submittable_contribution = self._create_contribution(self.submittable_type) + self.visible_contribution = self._create_contribution(self.visible_type) + self.hidden_contribution = self._create_contribution(self.hidden_type) + self.active_mission_contribution = self._create_contribution( + self.submittable_type, + mission=self.active_mission, + ) + self.inactive_mission_contribution = self._create_contribution( + self.submittable_type, + mission=self.inactive_mission, + ) + + def _create_type(self, name, slug, **kwargs): + contribution_type = ContributionType.objects.create( + name=name, + slug=slug, + description=name, + category=self.category, + min_points=1, + max_points=100, + **kwargs, + ) + GlobalLeaderboardMultiplier.objects.create( + contribution_type=contribution_type, + multiplier_value=1, + valid_from=timezone.now() - timedelta(days=30), + ) + return contribution_type + + def _create_contribution(self, contribution_type, mission=None): + return Contribution.objects.create( + user=self.user, + contribution_type=contribution_type, + mission=mission, + points=10, + contribution_date=timezone.now(), + title=f'{contribution_type.name} contribution', + ) + + def _result_ids(self, response): + data = response.json() + return {item['id'] for item in data['results']} + + def test_public_explorer_includes_public_non_submittable_types(self): + response = self.client.get('/api/v1/contributions/', { + 'category': self.category.slug, + 'public_explorer_only': 'true', + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = self._result_ids(response) + self.assertIn(self.submittable_contribution.id, result_ids) + self.assertIn(self.visible_contribution.id, result_ids) + self.assertNotIn(self.hidden_contribution.id, result_ids) + + def test_public_explorer_does_not_leak_hidden_type_when_type_is_explicit(self): + response = self.client.get('/api/v1/contributions/', { + 'contribution_type': self.hidden_type.id, + 'public_explorer_only': 'true', + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotIn(self.hidden_contribution.id, self._result_ids(response)) + + def test_contributions_can_filter_by_inactive_mission(self): + response = self.client.get('/api/v1/contributions/', { + 'category': self.category.slug, + 'mission': self.inactive_mission.id, + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = self._result_ids(response) + self.assertIn(self.inactive_mission_contribution.id, result_ids) + self.assertNotIn(self.active_mission_contribution.id, result_ids) + self.assertNotIn(self.submittable_contribution.id, result_ids) + + def test_mission_list_can_include_inactive_missions(self): + response = self.client.get('/api/v1/missions/', { + 'include_inactive': 'true', + 'category': self.category.slug, + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result_ids = {item['id'] for item in response.json()['results']} + self.assertIn(self.inactive_mission.id, result_ids) + self.assertIn(self.active_mission.id, result_ids) diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index 8f8c15d3..a5828f0b 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from rest_framework.test import APIClient from rest_framework import status -from contributions.models import SubmittedContribution, ContributionType, Category +from contributions.models import SubmittedContribution, ContributionType, Category, ContributionHighlight from stewards.models import Steward, StewardPermission from datetime import datetime from django.utils import timezone @@ -216,6 +216,117 @@ def test_steward_can_create_highlight(self): self.assertTrue(self.submission.converted_contribution.highlights.exists()) highlight = self.submission.converted_contribution.highlights.first() self.assertEqual(highlight.title, 'Outstanding Contribution') + + def test_steward_can_update_accepted_submission_points(self): + """Test that stewards can correct points after accepting.""" + self.client.force_authenticate(user=self.steward_user) + self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id + }, + format='json' + ) + + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/update-accepted/', + {'points': 80}, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.submission.refresh_from_db() + self.assertEqual(self.submission.converted_contribution.points, 80) + self.assertEqual(response.data['contribution']['points'], 80) + + def test_accepted_submission_list_includes_contribution_points(self): + """Accepted submissions list includes enough contribution data for steward edits.""" + self.client.force_authenticate(user=self.steward_user) + self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id + }, + format='json' + ) + + response = self.client.get('/api/v1/steward-submissions/?state=accepted') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + result = response.data['results'][0] + self.assertIsNotNone(result['contribution']) + self.assertEqual(result['contribution']['points'], 50) + self.assertIn('highlight', result['contribution']) + + def test_steward_can_feature_accepted_submission(self): + """Test that stewards can feature a contribution after accepting.""" + self.client.force_authenticate(user=self.steward_user) + self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id + }, + format='json' + ) + + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/update-accepted/', + { + 'points': 50, + 'create_highlight': True, + 'highlight_title': 'Featured after review', + 'highlight_description': 'Added after points were assigned' + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.submission.refresh_from_db() + highlight = ContributionHighlight.objects.get( + contribution=self.submission.converted_contribution + ) + self.assertEqual(highlight.title, 'Featured after review') + self.assertEqual(response.data['contribution']['highlight']['title'], 'Featured after review') + + def test_steward_can_remove_accepted_submission_highlight(self): + """Test that stewards can remove a feature after accepting.""" + self.client.force_authenticate(user=self.steward_user) + self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id, + 'create_highlight': True, + 'highlight_title': 'Featured after review', + 'highlight_description': 'Added after points were assigned' + }, + format='json' + ) + + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/update-accepted/', + { + 'points': 50, + 'remove_highlight': True + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.submission.refresh_from_db() + self.assertFalse( + ContributionHighlight.objects.filter( + contribution=self.submission.converted_contribution + ).exists() + ) + self.assertIsNone(response.data['contribution']['highlight']) def test_points_validation(self): """Test that points are validated within contribution type limits.""" @@ -273,4 +384,4 @@ def test_required_fields_validation(self): format='json' ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertIn('staff_reply', response.data) \ No newline at end of file + self.assertIn('staff_reply', response.data) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 11509228..461ba05a 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -31,6 +31,7 @@ EvidenceSerializer, SubmittedContributionSerializer, SubmittedEvidenceSerializer, ContributionHighlightSerializer, StewardSubmissionSerializer, StewardSubmissionReviewSerializer, + StewardAcceptedSubmissionUpdateSerializer, SubmissionNoteSerializer, SubmissionProposeSerializer, MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer, FeaturedContentSerializer, AlertSerializer) @@ -40,6 +41,13 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from ethereum_auth.authentication import EthereumAuthentication +METRICS_POINTS_EXCLUDED_TYPE_SLUGS = [ + 'builder-welcome', + 'builder', + 'community-link-x', + 'community-link-discord', +] + class ContributionTypeViewSet(viewsets.ReadOnlyModelViewSet): """ @@ -301,6 +309,13 @@ def get_queryset(self): if submittable_only and submittable_only.lower() == 'true': queryset = queryset.filter(contribution_type__is_submittable=True) + public_explorer_only = self.request.query_params.get('public_explorer_only') + if public_explorer_only and public_explorer_only.lower() == 'true': + queryset = queryset.filter( + Q(contribution_type__is_submittable=True) | + Q(contribution_type__show_in_contributions=True) + ) + return queryset def get_serializer_context(self): @@ -999,6 +1014,8 @@ class StewardSubmissionFilterSet(FilterSet): exclude_username = CharFilter(method='filter_exclude_username') assigned_to = CharFilter(method='filter_assigned_to') exclude_assigned_to = CharFilter(method='filter_exclude_assigned_to') + reviewed_by = CharFilter(method='filter_reviewed_by') + exclude_reviewed_by = CharFilter(method='filter_exclude_reviewed_by') exclude_contribution_type = NumberFilter(method='filter_exclude_contribution_type') exclude_content = CharFilter(method='filter_exclude_content') include_content = CharFilter(method='filter_include_content') @@ -1084,6 +1101,18 @@ def filter_exclude_assigned_to(self, queryset, name, value): return queryset.exclude(assigned_to_id=value) return queryset + def filter_reviewed_by(self, queryset, name, value): + """Filter by steward who took the review action.""" + if value: + return queryset.filter(reviewed_by_id=value) + return queryset + + def filter_exclude_reviewed_by(self, queryset, name, value): + """Exclude submissions reviewed by a specific steward.""" + if value: + return queryset.exclude(reviewed_by_id=value) + return queryset + def filter_exclude_contribution_type(self, queryset, name, value): """Exclude submissions of a specific contribution type.""" if value: @@ -1289,12 +1318,19 @@ def get_queryset(self): 'reviewed_by', 'assigned_to', 'converted_contribution', + 'converted_contribution__user', + 'converted_contribution__contribution_type', + 'converted_contribution__contribution_type__category', + 'converted_contribution__mission', 'mission', 'proposed_by', 'proposed_contribution_type', 'proposed_user', 'proposed_template', - ).prefetch_related('evidence_items').annotate( + ).prefetch_related( + 'evidence_items', + 'converted_contribution__highlights', + ).annotate( internal_notes_count=Coalesce( Subquery(notes_count, output_field=IntegerField()), Value(0), @@ -1447,6 +1483,70 @@ def review(self, request, pk=None): status=status.HTTP_200_OK ) + @action(detail=True, methods=['post'], url_path='update-accepted') + @transaction.atomic + def update_accepted(self, request, pk=None): + """Correct points or add/update/remove a highlight for an accepted submission.""" + submission = self.get_object() + contribution = submission.converted_contribution + + if submission.state != 'accepted' or not contribution: + return Response( + {'detail': 'Only accepted submissions with a created contribution can be updated.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not steward_has_permission(request.user, submission.contribution_type_id, 'accept'): + return Response( + {'detail': 'You do not have permission to update accepted submissions of this contribution type.'}, + status=status.HTTP_403_FORBIDDEN + ) + + serializer = StewardAcceptedSubmissionUpdateSerializer( + data=request.data, + context={'contribution_type': contribution.contribution_type} + ) + serializer.is_valid(raise_exception=True) + + points = serializer.validated_data['points'] + contribution.points = points + multiplier = float(contribution.multiplier_at_creation or 1) + contribution.frozen_global_points = round(points * multiplier) + contribution.save(update_fields=['points', 'frozen_global_points', 'updated_at']) + + if serializer.validated_data.get('create_highlight'): + highlight = ContributionHighlight.objects.filter(contribution=contribution).first() + if highlight: + highlight.title = serializer.validated_data['highlight_title'] + highlight.description = serializer.validated_data['highlight_description'] + highlight.save(update_fields=['title', 'description', 'updated_at']) + else: + ContributionHighlight.objects.create( + contribution=contribution, + title=serializer.validated_data['highlight_title'], + description=serializer.validated_data['highlight_description'] + ) + elif serializer.validated_data.get('remove_highlight'): + ContributionHighlight.objects.filter(contribution=contribution).delete() + + SubmissionNote.objects.create( + submitted_contribution=submission, + user=request.user, + message=f"Updated accepted contribution: **{points} points**", + is_proposal=False, + data={ + 'action': 'update_accepted', + 'points': points, + 'create_highlight': serializer.validated_data.get('create_highlight', False), + 'remove_highlight': serializer.validated_data.get('remove_highlight', False), + }, + ) + + return Response( + self.get_serializer(submission).data, + status=status.HTTP_200_OK + ) + @action(detail=False, methods=['get'], url_path='stats') def stats(self, request): """Get statistics for steward dashboard.""" @@ -1522,7 +1622,8 @@ def daily_metrics(self, request): - accepted: Submissions accepted - rejected: Submissions rejected - more_info_requested: Submissions requesting more info - - points_awarded: Total points from accepted contributions + - points_awarded: Total points from accepted contributions, excluding + onboarding and social-linking awards The `totals` block also includes `pending_review`: submissions created in the date range that are still in the pending state, respecting the @@ -1643,6 +1744,8 @@ def daily_metrics(self, request): created_at__date__gte=start_date, created_at__date__lte=end_date, source_submission__isnull=False # Only from submissions + ).exclude( + contribution_type__slug__in=METRICS_POINTS_EXCLUDED_TYPE_SLUGS ) # Apply category/type filters to points query if specified @@ -2114,12 +2217,15 @@ def get_queryset(self): # Detail lookups must be able to resolve expired missions so historical # submissions/contributions can keep their mission identity. - if self.action != 'retrieve' and not include_inactive: + if self.action != 'retrieve': active_q = self._active_mission_q() - if is_active is None or is_active.lower() in ['1', 'true', 'yes']: + if is_active is not None: + if is_active.lower() in ['1', 'true', 'yes']: + queryset = queryset.filter(active_q) + elif is_active.lower() in ['0', 'false', 'no']: + queryset = queryset.exclude(active_q) + elif not include_inactive: queryset = queryset.filter(active_q) - elif is_active.lower() in ['0', 'false', 'no']: - queryset = queryset.exclude(active_q) # Filter by contribution type if specified contribution_type = self.request.query_params.get('contribution_type', None) @@ -2189,6 +2295,25 @@ class FeaturedContentViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [permissions.AllowAny] pagination_class = None + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + placement = request.query_params.get('placement') + + if placement: + valid_placements = {choice[0] for choice in FeaturedContent.HERO_PLACEMENT_CHOICES} + if placement not in valid_placements - {FeaturedContent.HERO_PLACEMENT_ALL}: + return Response( + {'detail': f"Invalid placement '{placement}'."}, + status=status.HTTP_400_BAD_REQUEST, + ) + queryset = [ + item for item in queryset + if item.shows_in_placement(placement) + ] + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + def get_queryset(self): include_inactive = ( self.request.query_params.get('include_inactive', '').lower() diff --git a/backend/creators/models.py b/backend/creators/models.py index 5f30b1fd..f22fd219 100644 --- a/backend/creators/models.py +++ b/backend/creators/models.py @@ -5,7 +5,7 @@ class Creator(BaseModel): """ - Creator profile - for users who focus on referrals. + Community profile for users participating in community activities. One-to-one relationship with User. """ user = models.OneToOneField( diff --git a/backend/creators/views.py b/backend/creators/views.py index fcee06ad..766c2429 100644 --- a/backend/creators/views.py +++ b/backend/creators/views.py @@ -10,22 +10,20 @@ @permission_classes([IsAuthenticated]) def join_creator_view(request): """ - Allow authenticated users to become creators. + Allow authenticated users to join the community program. """ user = request.user - # Check if user already has a creator profile if hasattr(user, 'creator'): return Response( - {'message': 'You are already a creator!'}, + {'message': 'You are already a community member!'}, status=status.HTTP_400_BAD_REQUEST ) - # Create creator profile creator = Creator.objects.create(user=user) serializer = CreatorSerializer(creator) return Response({ - 'message': 'Successfully joined as a creator!', + 'message': 'Successfully joined the community!', 'creator': serializer.data }, status=status.HTTP_201_CREATED) diff --git a/backend/leaderboard/admin.py b/backend/leaderboard/admin.py index 4e89cc72..4bc39bc0 100644 --- a/backend/leaderboard/admin.py +++ b/backend/leaderboard/admin.py @@ -90,7 +90,7 @@ class ReferralPointsAdmin(admin.ModelAdmin): def total_referral_points(self, obj): """Display total referral points (builder + validator)""" return obj.builder_points + obj.validator_points - total_referral_points.short_description = 'Total Points' + total_referral_points.short_description = 'Total Referral Points' def has_add_permission(self, request): # Don't allow manual creation diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index 2e2ed900..01d7d07a 100644 --- a/backend/leaderboard/models.py +++ b/backend/leaderboard/models.py @@ -93,6 +93,8 @@ def calculate_waitlist_points(user): user_id__in=eligible_ids, contribution_type__category__slug='builder', contribution_date__lte=grad_contrib.contribution_date + ).exclude( + contribution_type__slug__in=REFERRAL_EXCLUDED_SLUGS ).aggregate(Sum('frozen_global_points'))['frozen_global_points__sum'] or 0) * 0.1) validator_referral = int((Contribution.objects.filter( @@ -943,4 +945,4 @@ def recalculate_all_leaderboards(): return f"Recalculated {len(user_contributions)} users across {len(LEADERBOARD_CONFIG)} leaderboards with {len(referral_points_to_create)} referrers" finally: - post_save.connect(update_leaderboard_on_builder_creation, sender=Builder) \ No newline at end of file + post_save.connect(update_leaderboard_on_builder_creation, sender=Builder) diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py new file mode 100644 index 00000000..d965bcc4 --- /dev/null +++ b/backend/leaderboard/tests/test_stats.py @@ -0,0 +1,126 @@ +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from contributions.models import ( + Category, + Contribution, + ContributionType, + SubmittedContribution, +) +from leaderboard.models import ReferralPoints +from users.models import User + + +class LeaderboardStatsTest(TestCase): + def setUp(self): + self.client = APIClient() + self.community_category, _ = Category.objects.get_or_create( + slug='community', + defaults={'name': 'Community'} + ) + self.builder_category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder'} + ) + self.community_type = ContributionType.objects.create( + name='Community Post', + slug='community-post', + category=self.community_category + ) + self.builder_type = ContributionType.objects.create( + name='Builder Submission', + slug='builder-submission', + category=self.builder_category + ) + + def _create_user(self, email, address, visible=True): + return User.objects.create_user( + email=email, + password='pass', + address=address, + visible=visible + ) + + def test_community_member_count_uses_accepted_community_contributions(self): + now = timezone.now() + community_user = self._create_user( + 'community@example.com', + '0x0000000000000000000000000000000000000001' + ) + repeat_community_user = self._create_user( + 'repeat@example.com', + '0x0000000000000000000000000000000000000002' + ) + builder_only_user = self._create_user( + 'builder@example.com', + '0x0000000000000000000000000000000000000003' + ) + hidden_community_user = self._create_user( + 'hidden@example.com', + '0x0000000000000000000000000000000000000004', + visible=False + ) + referral_only_user = self._create_user( + 'referral@example.com', + '0x0000000000000000000000000000000000000005' + ) + pending_user = self._create_user( + 'pending@example.com', + '0x0000000000000000000000000000000000000006' + ) + + Contribution.objects.bulk_create([ + Contribution( + user=community_user, + contribution_type=self.community_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + Contribution( + user=repeat_community_user, + contribution_type=self.community_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + Contribution( + user=repeat_community_user, + contribution_type=self.community_type, + points=5, + frozen_global_points=5, + contribution_date=now + ), + Contribution( + user=builder_only_user, + contribution_type=self.builder_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + Contribution( + user=hidden_community_user, + contribution_type=self.community_type, + points=10, + frozen_global_points=10, + contribution_date=now + ), + ]) + ReferralPoints.objects.create( + user=referral_only_user, + builder_points=100, + validator_points=100 + ) + SubmittedContribution.objects.create( + user=pending_user, + contribution_type=self.community_type, + contribution_date=now, + state='pending' + ) + + response = self.client.get('/api/v1/leaderboard/stats/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['community_member_count'], 2) + self.assertEqual(response.data['creator_count'], 2) diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 9f7b115c..61b62b04 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -204,7 +204,8 @@ def monthly(self, request): from users.serializers import LightUserSerializer leaderboard_type = request.query_params.get('type', 'validator') - if leaderboard_type not in LEADERBOARD_CONFIG: + monthly_types = set(LEADERBOARD_CONFIG.keys()) | {'community'} + if leaderboard_type not in monthly_types: return Response( {'detail': f'Unknown leaderboard type: {leaderboard_type}'}, status=status.HTTP_400_BAD_REQUEST, @@ -276,18 +277,18 @@ def stats(self, request): user__visible=True ) - participant_count = leaderboard_entries.count() - # Get contribution count for this category category_map = { 'validator': 'validator', 'builder': 'builder', - 'steward': 'steward' + 'steward': 'steward', + 'community': 'community' } category = category_map.get(leaderboard_type) if category: category_contributions = Contribution.objects.filter( + user__visible=True, contribution_type__category__slug=category ).exclude( contribution_type__slug__in=['builder-welcome', 'builder', 'validator-waitlist', 'validator'] @@ -304,6 +305,10 @@ def stats(self, request): new_points_count = category_contributions.filter( created_at__gte=last_month ).aggregate(total=Sum('frozen_global_points'))['total'] or 0 + if category == 'community': + participant_count = category_contributions.values('user_id').distinct().count() + else: + participant_count = leaderboard_entries.count() else: contribution_count = 0 new_contributions_count = 0 @@ -311,6 +316,7 @@ def stats(self, request): total_points = leaderboard_entries.aggregate( total=Sum('total_points') )['total'] or 0 + participant_count = leaderboard_entries.count() # New participants in the last 30 days for the specific leaderboard type if leaderboard_type == 'builder': @@ -401,13 +407,15 @@ def stats(self, request): ) validator_count = validator_contribs.values('user_id').distinct().count() - from .models import ReferralPoints - from django.db.models import F - creator_count = ReferralPoints.objects.filter( - user__visible=True - ).annotate( - total_pts=F('builder_points') + F('validator_points') - ).filter(total_pts__gt=0).count() + community_member_count = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='community' + ).values('user_id').distinct().count() + new_community_members_count = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='community', + created_at__gte=last_month + ).values('user_id').distinct().count() return Response({ 'participant_count': participant_count, @@ -415,7 +423,10 @@ def stats(self, request): 'total_points': total_points, 'builder_count': builder_count, 'validator_count': validator_count, - 'creator_count': creator_count, + 'community_member_count': community_member_count, + # Backward-compatible alias for older clients. + 'creator_count': community_member_count, + 'new_community_members_count': new_community_members_count, 'new_builders_count': new_builders_count, 'new_validators_count': new_validators_count, 'new_contributions_count': new_contributions_count, @@ -605,80 +616,10 @@ def types(self, request): @action(detail=False, methods=['get']) def community(self, request): """ - Get community statistics and paginated community members with referral points. - Returns users with referrals sorted by total referral points. + Get community statistics and paginated community members. + Returns users sorted by actual community contribution points. Supports limit/offset pagination and user_address lookup. """ - from .models import ReferralPoints - from users.serializers import LightUserSerializer - from django.db.models import F - - limit = min(int(request.query_params.get('limit', 20)), 100) - offset = int(request.query_params.get('offset', 0)) - - referral_qs = ReferralPoints.objects.select_related('user').annotate( - total_points=F('builder_points') + F('validator_points') - ).filter(user__visible=True, total_points__gt=0).order_by('-total_points') - - # Aggregate totals at DB level - totals = referral_qs.aggregate( - total_community=Count('id'), - total_builder=Sum('builder_points'), - total_validator=Sum('validator_points') - ) - - # Check if user_address is requested for rank lookup - user_address = request.query_params.get('user_address') - user_rank = None - user_total_points = None - if user_address: - # Find user's points first with a single DB query - try: - user_rp = ReferralPoints.objects.select_related('user').annotate( - total_points=F('builder_points') + F('validator_points') - ).get(user__address__iexact=user_address, user__visible=True) - user_tp = user_rp.builder_points + user_rp.validator_points - if user_tp > 0: - user_total_points = user_tp - # Count how many users have more points (DB-level rank calculation) - user_rank = referral_qs.filter(total_points__gt=user_tp).count() + 1 - except ReferralPoints.DoesNotExist: - pass - - # Paginated results with light serializer - page = referral_qs[offset:offset + limit] - results = [] - for rank, rp in enumerate(page, start=offset + 1): - user_data = LightUserSerializer(rp.user).data - results.append({ - **user_data, - 'referral_builder_points': rp.builder_points, - 'referral_validator_points': rp.validator_points, - 'total_referral_points': rp.builder_points + rp.validator_points, - 'total_points': rp.builder_points + rp.validator_points, - 'rank': rank, - }) - - response_data = { - 'total_community': totals['total_community'] or 0, - 'total_builder_points': totals['total_builder'] or 0, - 'total_validator_points': totals['total_validator'] or 0, - 'count': totals['total_community'] or 0, - 'results': results, - } - - if user_address: - response_data['user_rank'] = user_rank - response_data['user_total_points'] = user_total_points - - return Response(response_data) - - @action(detail=False, methods=['get'], url_path='community-contributors') - def community_contributors(self, request): - """ - Get top contributors by actual community contribution points. - This intentionally excludes referral points, which are tracked separately. - """ from users.models import User from users.serializers import LightUserSerializer @@ -693,13 +634,20 @@ def community_contributors(self, request): except (ValueError, TypeError): offset = 0 - community_totals = ( - Contribution.objects - .filter( - user__visible=True, - contribution_type__category__slug='community', + community_contributions = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='community', + ) + + search = request.query_params.get('search', '').strip() + if search: + community_contributions = community_contributions.filter( + Q(user__name__icontains=search) | + Q(user__address__icontains=search) ) - .values('user_id') + + community_totals = ( + community_contributions.values('user_id') .annotate( total_points=Sum('frozen_global_points'), contribution_count=Count('id'), @@ -709,6 +657,17 @@ def community_contributors(self, request): ) count = community_totals.count() + user_address = request.query_params.get('user_address') + user_rank = None + user_total_points = None + + if user_address: + user_entry = community_totals.filter(user__address__iexact=user_address).first() + if user_entry: + user_total_points = user_entry['total_points'] or 0 + if user_total_points > 0: + user_rank = community_totals.filter(total_points__gt=user_total_points).count() + 1 + page = list(community_totals[offset:offset + limit]) users_by_id = { user.id: user @@ -735,10 +694,97 @@ def community_contributors(self, request): 'rank': index, }) - return Response({ + response_data = { + 'total_community': count, 'count': count, 'results': results, - }) + } + + if user_address: + response_data['user_rank'] = user_rank + response_data['user_total_points'] = user_total_points + + return Response(response_data) + + @action(detail=False, methods=['get']) + def referrals(self, request): + """ + Get referral leaderboard entries sorted by total referral points. + """ + from .models import ReferralPoints + from users.serializers import LightUserSerializer + from django.db.models import F + + try: + limit = int(request.query_params.get('limit', 20)) + except (ValueError, TypeError): + limit = 20 + limit = min(max(limit, 1), 100) + + try: + offset = int(request.query_params.get('offset', 0)) + except (ValueError, TypeError): + offset = 0 + + referral_qs = ReferralPoints.objects.select_related('user').annotate( + total_points=F('builder_points') + F('validator_points') + ).filter(user__visible=True, total_points__gt=0).order_by('-total_points') + + search = request.query_params.get('search', '').strip() + if search: + referral_qs = referral_qs.filter( + Q(user__name__icontains=search) | + Q(user__address__icontains=search) + ) + + totals = referral_qs.aggregate( + total_referrers=Count('id'), + total_builder=Sum('builder_points'), + total_validator=Sum('validator_points') + ) + + user_address = request.query_params.get('user_address') + user_rank = None + user_total_points = None + if user_address: + try: + user_rp = referral_qs.get(user__address__iexact=user_address) + user_total_points = user_rp.builder_points + user_rp.validator_points + user_rank = referral_qs.filter(total_points__gt=user_total_points).count() + 1 + except ReferralPoints.DoesNotExist: + pass + + page = referral_qs[offset:offset + limit] + results = [] + for rank, rp in enumerate(page, start=offset + 1): + user_data = LightUserSerializer(rp.user).data + results.append({ + **user_data, + 'referral_builder_points': rp.builder_points, + 'referral_validator_points': rp.validator_points, + 'total_referral_points': rp.builder_points + rp.validator_points, + 'total_points': rp.builder_points + rp.validator_points, + 'rank': rank, + }) + + response_data = { + 'total_referrers': totals['total_referrers'] or 0, + 'total_builder_points': totals['total_builder'] or 0, + 'total_validator_points': totals['total_validator'] or 0, + 'count': totals['total_referrers'] or 0, + 'results': results, + } + + if user_address: + response_data['user_rank'] = user_rank + response_data['user_total_points'] = user_total_points + + return Response(response_data) + + @action(detail=False, methods=['get'], url_path='community-contributors') + def community_contributors(self, request): + """Backward-compatible alias for the community points leaderboard.""" + return self.community(request) @action(detail=False, methods=['get']) def trending(self, request): diff --git a/backend/projects/__init__.py b/backend/projects/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/projects/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/projects/admin.py b/backend/projects/admin.py new file mode 100644 index 00000000..4f509c9d --- /dev/null +++ b/backend/projects/admin.py @@ -0,0 +1,77 @@ +from django.contrib import admin + +from utils.admin_mixins import CloudinaryUploadMixin + +from .models import Project + + +@admin.register(Project) +class ProjectAdmin(CloudinaryUploadMixin, admin.ModelAdmin): + cloudinary_upload_fields = { + 'hero_image_url': { + 'public_id_field': 'hero_image_public_id', + 'folder': 'tally/projects', + }, + 'hero_image_url_tablet': { + 'public_id_field': 'hero_image_tablet_public_id', + 'folder': 'tally/projects', + }, + 'hero_image_url_mobile': { + 'public_id_field': 'hero_image_mobile_public_id', + 'folder': 'tally/projects', + }, + 'user_profile_image_url': { + 'public_id_field': 'user_profile_image_public_id', + 'folder': 'tally/projects/avatars', + }, + } + + list_display = ('title', 'slug', 'user', 'status', 'order', 'created_at') + list_filter = ('status', 'created_at') + search_fields = ('title', 'slug', 'description', 'details', 'user__name', 'user__address') + list_editable = ('order', 'status') + raw_id_fields = ('user',) + filter_horizontal = ('participants', 'related_contributions') + prepopulated_fields = {'slug': ('title',)} + readonly_fields = ( + 'created_at', + 'updated_at', + 'hero_image_public_id', + 'hero_image_tablet_public_id', + 'hero_image_mobile_public_id', + 'user_profile_image_public_id', + ) + ordering = ('order', '-created_at') + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'author', 'description', 'status', 'order'), + }), + ('Relations', { + 'fields': ('user', 'participants', 'related_contributions'), + }), + ('Project Detail', { + 'fields': ( + 'details', + 'url', + 'github_url', + 'x_url', + 'telegram_url', + 'discord_url', + 'demo_url', + ), + }), + ('Media', { + 'fields': ( + 'hero_image_url', + 'hero_image_url_tablet', + 'hero_image_url_mobile', + 'user_profile_image_url', + ), + 'description': 'Upload images directly or paste Cloudinary URLs. Tablet/mobile hero images are optional and fall back to the main hero image.', + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',), + }), + ) diff --git a/backend/projects/apps.py b/backend/projects/apps.py new file mode 100644 index 00000000..afae4983 --- /dev/null +++ b/backend/projects/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'projects' diff --git a/backend/projects/migrations/0001_initial.py b/backend/projects/migrations/0001_initial.py new file mode 100644 index 00000000..56a87938 --- /dev/null +++ b/backend/projects/migrations/0001_initial.py @@ -0,0 +1,135 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.utils.text import slugify + + +def _unique_slug(Project, title, used_slugs, db_alias): + base_slug = slugify(title)[:200] or 'project' + slug = base_slug + suffix = 2 + while slug in used_slugs or Project.objects.using(db_alias).filter(slug=slug).exists(): + suffix_text = f'-{suffix}' + slug = f'{base_slug[:220 - len(suffix_text)]}{suffix_text}' + suffix += 1 + used_slugs.add(slug) + return slug + + +def copy_featured_builds_to_projects(apps, schema_editor): + FeaturedContent = apps.get_model('contributions', 'FeaturedContent') + Contribution = apps.get_model('contributions', 'Contribution') + Project = apps.get_model('projects', 'Project') + db_alias = schema_editor.connection.alias + used_slugs = set() + + featured_builds = FeaturedContent.objects.using(db_alias).filter( + content_type='build', + ).order_by('order', 'created_at', 'id') + + for featured in featured_builds: + project = Project.objects.using(db_alias).create( + title=featured.title, + slug=_unique_slug(Project, featured.title, used_slugs, db_alias), + description=featured.description, + author=featured.author, + user_id=featured.user_id, + hero_image_url=featured.hero_image_url, + hero_image_public_id=featured.hero_image_public_id, + hero_image_url_tablet=featured.hero_image_url_tablet, + hero_image_tablet_public_id=featured.hero_image_tablet_public_id, + hero_image_url_mobile=featured.hero_image_url_mobile, + hero_image_mobile_public_id=featured.hero_image_mobile_public_id, + user_profile_image_url=featured.user_profile_image_url, + user_profile_image_public_id=featured.user_profile_image_public_id, + url=featured.url, + status=featured.status, + order=featured.order, + ) + if featured.contribution_id: + project.related_contributions.add(featured.contribution_id) + contribution = Contribution.objects.using(db_alias).filter( + id=featured.contribution_id, + ).only('user_id').first() + if contribution and contribution.user_id: + project.participants.add(contribution.user_id) + + +def copy_projects_to_featured_builds(apps, schema_editor): + FeaturedContent = apps.get_model('contributions', 'FeaturedContent') + Project = apps.get_model('projects', 'Project') + db_alias = schema_editor.connection.alias + + projects = Project.objects.using(db_alias).all().order_by('order', 'created_at', 'id') + for project in projects: + if not project.user_id: + continue + contribution = project.related_contributions.order_by('id').first() + FeaturedContent.objects.using(db_alias).create( + content_type='build', + title=project.title, + description=project.description, + author=project.author, + contribution_id=contribution.id if contribution else None, + user_id=project.user_id, + hero_image_url=project.hero_image_url, + hero_image_public_id=project.hero_image_public_id, + hero_image_url_tablet=project.hero_image_url_tablet, + hero_image_tablet_public_id=project.hero_image_tablet_public_id, + hero_image_url_mobile=project.hero_image_url_mobile, + hero_image_mobile_public_id=project.hero_image_mobile_public_id, + user_profile_image_url=project.user_profile_image_url, + user_profile_image_public_id=project.user_profile_image_public_id, + url=project.url, + status=project.status, + order=project.order, + ) + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contributions', '0059_add_canceled_submission_state'), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField(blank=True, max_length=220, unique=True)), + ('description', models.TextField(blank=True)), + ('author', models.CharField(blank=True, max_length=200)), + ('hero_image_url', models.URLField(blank=True, help_text='Cloudinary URL for hero image', max_length=500)), + ('hero_image_public_id', models.CharField(blank=True, help_text='Cloudinary public ID for hero image', max_length=255)), + ('hero_image_url_tablet', models.URLField(blank=True, help_text='Cloudinary URL for tablet hero image (768-1023px). Falls back to hero_image_url if empty.', max_length=500)), + ('hero_image_tablet_public_id', models.CharField(blank=True, help_text='Cloudinary public ID for tablet hero image', max_length=255)), + ('hero_image_url_mobile', models.URLField(blank=True, help_text='Cloudinary URL for mobile hero image (<768px). Falls back to hero_image_url if empty.', max_length=500)), + ('hero_image_mobile_public_id', models.CharField(blank=True, help_text='Cloudinary public ID for mobile hero image', max_length=255)), + ('user_profile_image_url', models.URLField(blank=True, help_text='Cloudinary URL for project author image', max_length=500)), + ('user_profile_image_public_id', models.CharField(blank=True, help_text='Cloudinary public ID for project author image', max_length=255)), + ('url', models.URLField(blank=True, help_text='Project website or demo URL', max_length=500)), + ('github_url', models.URLField(blank=True, max_length=500)), + ('x_url', models.URLField(blank=True, max_length=500)), + ('telegram_url', models.URLField(blank=True, max_length=500)), + ('discord_url', models.URLField(blank=True, max_length=500)), + ('demo_url', models.URLField(blank=True, max_length=500)), + ('details', models.TextField(blank=True)), + ('status', models.CharField(choices=[('active', 'Active'), ('idle', 'Idle')], default='active', max_length=10)), + ('order', models.PositiveIntegerField(default=0)), + ('participants', models.ManyToManyField(blank=True, related_name='participating_projects', to=settings.AUTH_USER_MODEL)), + ('related_contributions', models.ManyToManyField(blank=True, related_name='related_projects', to='contributions.contribution')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_projects', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['order', '-created_at'], + }, + ), + migrations.RunPython(copy_featured_builds_to_projects, copy_projects_to_featured_builds), + ] diff --git a/backend/projects/migrations/__init__.py b/backend/projects/migrations/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/projects/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/projects/models.py b/backend/projects/models.py new file mode 100644 index 00000000..20e61c8d --- /dev/null +++ b/backend/projects/models.py @@ -0,0 +1,84 @@ +from django.conf import settings +from django.db import models +from django.utils.text import slugify + +from utils.models import BaseModel + + +class Project(BaseModel): + """A project profile managed independently from portal content.""" + + STATUS_ACTIVE = 'active' + STATUS_IDLE = 'idle' + STATUS_CHOICES = [ + (STATUS_ACTIVE, 'Active'), + (STATUS_IDLE, 'Idle'), + ] + + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=220, unique=True, blank=True) + description = models.TextField(blank=True) + author = models.CharField(max_length=200, blank=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name='owned_projects', + null=True, + blank=True, + ) + hero_image_url = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for hero image') + hero_image_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for hero image') + hero_image_url_tablet = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for tablet hero image (768-1023px). Falls back to hero_image_url if empty.') + hero_image_tablet_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for tablet hero image') + hero_image_url_mobile = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for mobile hero image (<768px). Falls back to hero_image_url if empty.') + hero_image_mobile_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for mobile hero image') + user_profile_image_url = models.URLField(max_length=500, blank=True, help_text='Cloudinary URL for project author image') + user_profile_image_public_id = models.CharField(max_length=255, blank=True, help_text='Cloudinary public ID for project author image') + url = models.URLField(max_length=500, blank=True, help_text='Project website or demo URL') + github_url = models.URLField(max_length=500, blank=True) + x_url = models.URLField(max_length=500, blank=True) + telegram_url = models.URLField(max_length=500, blank=True) + discord_url = models.URLField(max_length=500, blank=True) + demo_url = models.URLField(max_length=500, blank=True) + details = models.TextField(blank=True) + related_contributions = models.ManyToManyField( + 'contributions.Contribution', + blank=True, + related_name='related_projects', + ) + participants = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name='participating_projects', + ) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=STATUS_ACTIVE) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order', '-created_at'] + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title)[:200] or 'project' + slug = base_slug + suffix = 2 + queryset = type(self).objects.filter(slug=slug) + if self.pk: + queryset = queryset.exclude(pk=self.pk) + while queryset.exists(): + suffix_text = f"-{suffix}" + slug = f"{base_slug[:220 - len(suffix_text)]}{suffix_text}" + queryset = type(self).objects.filter(slug=slug) + if self.pk: + queryset = queryset.exclude(pk=self.pk) + suffix += 1 + self.slug = slug + super().save(*args, **kwargs) + + def get_link(self): + if self.slug: + return f"/builders/projects/{self.slug}" + return self.url or None diff --git a/backend/projects/serializers.py b/backend/projects/serializers.py new file mode 100644 index 00000000..98a0cb61 --- /dev/null +++ b/backend/projects/serializers.py @@ -0,0 +1,179 @@ +from rest_framework import serializers + +from contributions.models import Contribution +from contributions.serializers import LightContributionSerializer +from users.serializers import LightUserSerializer +from users.models import User + +from .models import Project + + +class ProjectListSerializer(serializers.ModelSerializer): + user_name = serializers.SerializerMethodField() + user_address = serializers.SerializerMethodField() + user_profile_image_url = serializers.SerializerMethodField() + owner_profile_image_url = serializers.SerializerMethodField() + featured_profile_image_url = serializers.CharField( + source='user_profile_image_url', + read_only=True, + ) + link = serializers.SerializerMethodField() + can_edit = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = [ + 'id', + 'slug', + 'title', + 'description', + 'author', + 'hero_image_url', + 'hero_image_url_tablet', + 'hero_image_url_mobile', + 'url', + 'github_url', + 'x_url', + 'telegram_url', + 'discord_url', + 'demo_url', + 'details', + 'link', + 'user', + 'user_name', + 'user_address', + 'user_profile_image_url', + 'owner_profile_image_url', + 'featured_profile_image_url', + 'status', + 'order', + 'created_at', + 'can_edit', + ] + read_only_fields = fields + + def get_user_name(self, obj): + return obj.user.name if obj.user else '' + + def get_user_address(self, obj): + return obj.user.address if obj.user else '' + + def get_user_profile_image_url(self, obj): + if obj.user_profile_image_url: + return obj.user_profile_image_url + if obj.user and obj.user.profile_image_url: + return obj.user.profile_image_url + return '' + + def get_owner_profile_image_url(self, obj): + if obj.user and obj.user.profile_image_url: + return obj.user.profile_image_url + return '' + + def get_link(self, obj): + return obj.get_link() + + def get_can_edit(self, obj): + request = self.context.get('request') + user = getattr(request, 'user', None) + if not user or not user.is_authenticated: + return False + return user.is_staff or obj.user_id == user.id + + +class ProjectDetailSerializer(ProjectListSerializer): + related_contributions = LightContributionSerializer(many=True, read_only=True) + participants = LightUserSerializer(many=True, read_only=True) + + class Meta(ProjectListSerializer.Meta): + fields = ProjectListSerializer.Meta.fields + ['participants', 'related_contributions'] + read_only_fields = fields + + +class ProjectProfileUpdateSerializer(serializers.Serializer): + description = serializers.CharField(required=False, allow_blank=True, trim_whitespace=True, max_length=2000) + details = serializers.CharField(required=False, allow_blank=True, trim_whitespace=True, max_length=12000) + url = serializers.URLField(required=False, allow_blank=True, max_length=500) + github_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + x_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + telegram_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + discord_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + demo_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + hero_image_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + hero_image_url_tablet = serializers.URLField(required=False, allow_blank=True, max_length=500) + hero_image_url_mobile = serializers.URLField(required=False, allow_blank=True, max_length=500) + user_profile_image_url = serializers.URLField(required=False, allow_blank=True, max_length=500) + participant_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + allow_empty=True, + max_length=30, + ) + related_contribution_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + required=False, + allow_empty=True, + max_length=100, + ) + + def validate_participant_ids(self, value): + user_ids = list(dict.fromkeys(value)) + users = User.objects.filter(id__in=user_ids, visible=True) + found_ids = set(users.values_list('id', flat=True)) + missing_ids = [user_id for user_id in user_ids if user_id not in found_ids] + if missing_ids: + raise serializers.ValidationError(f"Unknown participant id(s): {', '.join(map(str, missing_ids))}.") + return user_ids + + def validate_related_contribution_ids(self, value): + contribution_ids = list(dict.fromkeys(value)) + contributions = Contribution.objects.filter(id__in=contribution_ids, user__visible=True) + found_ids = set(contributions.values_list('id', flat=True)) + missing_ids = [contribution_id for contribution_id in contribution_ids if contribution_id not in found_ids] + if missing_ids: + raise serializers.ValidationError(f"Unknown contribution id(s): {', '.join(map(str, missing_ids))}.") + return contribution_ids + + def validate(self, attrs): + related_contribution_ids = attrs.get('related_contribution_ids') + if related_contribution_ids is None: + return attrs + + participant_ids = attrs.get( + 'participant_ids', + list(self.instance.participants.values_list('id', flat=True)) if self.instance else [], + ) + allowed_user_ids = set(participant_ids) + if self.instance and self.instance.user_id: + allowed_user_ids.add(self.instance.user_id) + + if allowed_user_ids: + unrelated = ( + Contribution.objects + .filter(id__in=related_contribution_ids, user__visible=True) + .exclude(user_id__in=allowed_user_ids) + .values_list('id', flat=True) + ) + unrelated_ids = list(unrelated) + if unrelated_ids: + raise serializers.ValidationError({ + 'related_contribution_ids': [ + f"Contribution id(s) must belong to selected participants: {', '.join(map(str, unrelated_ids))}." + ] + }) + + return attrs + + def update(self, instance, validated_data): + participant_ids = validated_data.pop('participant_ids', None) + related_contribution_ids = validated_data.pop('related_contribution_ids', None) + for field_name, value in validated_data.items(): + setattr(instance, field_name, value) + instance.save(update_fields=[*validated_data.keys(), 'updated_at'] if validated_data else None) + if participant_ids is not None: + instance.participants.set(User.objects.filter(id__in=participant_ids, visible=True)) + if related_contribution_ids is not None: + instance.related_contributions.set( + Contribution.objects.filter(id__in=related_contribution_ids, user__visible=True) + ) + return instance diff --git a/backend/projects/tests/__init__.py b/backend/projects/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/backend/projects/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/projects/tests/test_projects.py b/backend/projects/tests/test_projects.py new file mode 100644 index 00000000..41f6eab2 --- /dev/null +++ b/backend/projects/tests/test_projects.py @@ -0,0 +1,200 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from contributions.models import Category, Contribution, ContributionType, FeaturedContent +from projects.models import Project +from users.models import User + + +class ProjectAPITest(TestCase): + def setUp(self): + Project.objects.all().delete() + self.user = User.objects.create_user( + email='builder@example.com', + password='pass', + address='0x0000000000000000000000000000000000000001', + name='Project Builder', + ) + self.category = Category.objects.create(name='Project Test Builder', slug='project-test-builder') + self.contribution_type = ContributionType.objects.create( + name='Project Submission', + slug='project-submission-test', + category=self.category, + min_points=0, + max_points=100, + ) + + def create_project(self, **overrides): + defaults = { + 'title': 'Cognocracy', + 'description': 'A governance project built on GenLayer.', + 'author': 'Cognocracy Team', + 'user': self.user, + 'url': 'https://cognocracy.example.com', + 'github_url': '', + 'details': 'Longer project context.', + 'status': Project.STATUS_ACTIVE, + 'order': 10, + } + defaults.update(overrides) + return Project.objects.create(**defaults) + + def create_contribution(self): + contribution = Contribution( + user=self.user, + contribution_type=self.contribution_type, + points=42, + frozen_global_points=42, + contribution_date=timezone.now() - timedelta(days=1), + title='Built the project interface', + notes='Accepted project work.', + ) + Contribution.objects.bulk_create([contribution]) + return Contribution.objects.get(title='Built the project interface') + + def test_project_list_excludes_inactive_and_featured_content_builds(self): + active = self.create_project(title='Active Project') + self.create_project(title='Inactive Project', status=Project.STATUS_IDLE) + FeaturedContent.objects.create( + content_type='hero', + title='Hero Content', + user=self.user, + status='active', + ) + + response = self.client.get('/api/v1/projects/') + + self.assertEqual(response.status_code, 200) + payload = response.json() + slugs = {item['slug'] for item in payload} + self.assertEqual(slugs, {active.slug}) + self.assertEqual(payload[0]['link'], f'/builders/projects/{active.slug}') + + def test_project_detail_includes_related_contributions(self): + project = self.create_project(github_url='https://github.com/example/cognocracy') + project.participants.add(self.user) + contribution = self.create_contribution() + project.related_contributions.add(contribution) + + response = self.client.get(f'/api/v1/projects/{project.slug}/') + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload['slug'], project.slug) + self.assertEqual(payload['github_url'], 'https://github.com/example/cognocracy') + self.assertEqual(payload['participants'][0]['name'], 'Project Builder') + self.assertEqual(len(payload['related_contributions']), 1) + self.assertEqual(payload['related_contributions'][0]['title'], 'Built the project interface') + + def test_non_owner_cannot_update_project_profile(self): + project = self.create_project() + other_user = User.objects.create_user( + email='other@example.com', + password='pass', + address='0x0000000000000000000000000000000000000002', + name='Other Builder', + ) + self.client.force_login(other_user) + + response = self.client.patch( + f'/api/v1/projects/{project.slug}/profile/', + data={'description': 'Nope'}, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 403) + + def test_project_owner_can_update_profile_fields_and_participants(self): + project = self.create_project() + participant = User.objects.create_user( + email='participant@example.com', + password='pass', + address='0x0000000000000000000000000000000000000003', + name='Participant Builder', + ) + participant_contribution = Contribution( + user=participant, + contribution_type=self.contribution_type, + points=28, + frozen_global_points=28, + contribution_date=timezone.now() - timedelta(days=2), + title='Participant related work', + notes='Accepted participant work.', + ) + Contribution.objects.bulk_create([participant_contribution]) + participant_contribution = Contribution.objects.get(title='Participant related work') + self.client.force_login(self.user) + + response = self.client.patch( + f'/api/v1/projects/{project.slug}/profile/', + data={ + 'description': 'Updated short description.', + 'details': 'Updated about content.', + 'url': 'https://updated.example.com', + 'github_url': 'https://github.com/example/project', + 'x_url': 'https://x.com/example', + 'telegram_url': 'https://t.me/example', + 'discord_url': 'https://discord.gg/example', + 'demo_url': 'https://youtu.be/dQw4w9WgXcQ', + 'participant_ids': [self.user.id, participant.id], + 'related_contribution_ids': [participant_contribution.id], + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.description, 'Updated short description.') + self.assertEqual(project.details, 'Updated about content.') + self.assertEqual(project.url, 'https://updated.example.com') + self.assertEqual(project.x_url, 'https://x.com/example') + self.assertEqual(project.demo_url, 'https://youtu.be/dQw4w9WgXcQ') + self.assertEqual(project.participants.count(), 2) + self.assertEqual(project.related_contributions.count(), 1) + self.assertEqual( + [participant['name'] for participant in response.json()['participants']], + ['Project Builder', 'Participant Builder'], + ) + self.assertEqual(response.json()['related_contributions'][0]['title'], 'Participant related work') + + def test_project_profile_rejects_related_contribution_outside_participants(self): + project = self.create_project() + other_user = User.objects.create_user( + email='outside@example.com', + password='pass', + address='0x0000000000000000000000000000000000000004', + name='Outside Builder', + ) + outside_contribution = Contribution( + user=other_user, + contribution_type=self.contribution_type, + points=12, + frozen_global_points=12, + contribution_date=timezone.now() - timedelta(days=2), + title='Outside work', + notes='Accepted outside work.', + ) + Contribution.objects.bulk_create([outside_contribution]) + outside_contribution = Contribution.objects.get(title='Outside work') + self.client.force_login(self.user) + + response = self.client.patch( + f'/api/v1/projects/{project.slug}/profile/', + data={ + 'participant_ids': [self.user.id], + 'related_contribution_ids': [outside_contribution.id], + }, + content_type='application/json', + ) + + self.assertEqual(response.status_code, 400) + self.assertIn('related_contribution_ids', response.json()) + + def test_project_detail_404s_for_inactive_project(self): + project = self.create_project(status=Project.STATUS_IDLE) + + response = self.client.get(f'/api/v1/projects/{project.slug}/') + + self.assertEqual(response.status_code, 404) diff --git a/backend/projects/views.py b/backend/projects/views.py new file mode 100644 index 00000000..88a5ecf0 --- /dev/null +++ b/backend/projects/views.py @@ -0,0 +1,105 @@ +from django.core.exceptions import PermissionDenied +from rest_framework import status +from rest_framework import permissions, viewsets +from rest_framework.decorators import action +from rest_framework.response import Response + +from users.cloudinary_service import CloudinaryService + +from .models import Project +from .serializers import ( + ProjectDetailSerializer, + ProjectListSerializer, + ProjectProfileUpdateSerializer, +) + + +class ProjectViewSet(viewsets.ReadOnlyModelViewSet): + """Public read-only API for project profiles.""" + + permission_classes = [permissions.AllowAny] + pagination_class = None + lookup_field = 'slug' + + def get_queryset(self): + return ( + Project.objects + .filter(status=Project.STATUS_ACTIVE) + .select_related('user') + .prefetch_related( + 'participants', + 'related_contributions__user', + 'related_contributions__contribution_type', + 'related_contributions__evidence_items', + 'related_contributions__highlights', + ) + .order_by('order', '-created_at') + ) + + def get_serializer_class(self): + if self.action == 'retrieve': + return ProjectDetailSerializer + return ProjectListSerializer + + def ensure_project_editor(self, project): + user = self.request.user + if not user or not user.is_authenticated: + raise PermissionDenied('Authentication is required.') + if not user.is_staff and project.user_id != user.id: + raise PermissionDenied('You can only edit projects assigned to your account.') + + @action(detail=True, methods=['patch'], url_path='profile', permission_classes=[permissions.IsAuthenticated]) + def profile(self, request, slug=None): + project = self.get_object() + self.ensure_project_editor(project) + + serializer = ProjectProfileUpdateSerializer(instance=project, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + project.refresh_from_db() + response_serializer = ProjectDetailSerializer(project, context={'request': request}) + return Response(response_serializer.data) + + @action(detail=True, methods=['post'], url_path='upload-image', permission_classes=[permissions.IsAuthenticated]) + def upload_image(self, request, slug=None): + project = self.get_object() + self.ensure_project_editor(project) + + image = request.FILES.get('image') + image_type = request.data.get('image_type') + field_config = { + 'logo': ('user_profile_image_url', 'user_profile_image_public_id', 'logos'), + 'desktop': ('hero_image_url', 'hero_image_public_id', 'desktop'), + 'tablet': ('hero_image_url_tablet', 'hero_image_tablet_public_id', 'tablet'), + 'mobile': ('hero_image_url_mobile', 'hero_image_mobile_public_id', 'mobile'), + }.get(image_type) + + if not field_config: + return Response({'error': 'Unknown image type.'}, status=status.HTTP_400_BAD_REQUEST) + if not image: + return Response({'error': 'Image file is required.'}, status=status.HTTP_400_BAD_REQUEST) + if not getattr(image, 'content_type', '').startswith('image/'): + return Response({'error': 'Only image files are supported.'}, status=status.HTTP_400_BAD_REQUEST) + + url_field, public_id_field, folder_name = field_config + old_public_id = getattr(project, public_id_field, '') + + try: + result = CloudinaryService.upload_image( + image, + folder=f'tally/projects/{project.slug}/{folder_name}', + ) + setattr(project, url_field, result['url']) + setattr(project, public_id_field, result['public_id']) + project.save(update_fields=[url_field, public_id_field, 'updated_at']) + if old_public_id: + CloudinaryService.delete_image(old_public_id) + except Exception as exc: + return Response({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) + + return Response({ + 'image_type': image_type, + 'field': url_field, + 'url': result['url'], + 'public_id': result['public_id'], + }) diff --git a/backend/tally/settings.py b/backend/tally/settings.py index fca60dbb..0d4cef9b 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -71,6 +71,7 @@ def get_required_env(key): 'users', 'contributions', 'contributions.node_upgrade', + 'projects', 'leaderboard', 'ethereum_auth', 'validators', @@ -265,6 +266,18 @@ def get_required_env(key): # CSRF settings CSRF_TRUSTED_ORIGINS = get_required_env('CSRF_TRUSTED_ORIGINS').split(',') +if DEBUG: + # Local frontend ports vary across Vite, Conductor, and ad-hoc preview + # servers. The Vite dev proxy normalizes Origin for /api requests, and + # these entries cover the common direct-backend cases during development. + CSRF_TRUSTED_ORIGINS = list(dict.fromkeys([ + *CSRF_TRUSTED_ORIGINS, + FRONTEND_URL, + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://localhost:55010', + 'http://127.0.0.1:55010', + ])) # Session settings SESSION_COOKIE_HTTPONLY = True diff --git a/backend/tally/urls.py b/backend/tally/urls.py index aadbe1d0..a001a508 100644 --- a/backend/tally/urls.py +++ b/backend/tally/urls.py @@ -23,7 +23,8 @@ from django.conf.urls.static import static from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt +from django.middleware.csrf import get_token +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.utils.decorators import method_decorator from django.views import View @@ -33,6 +34,15 @@ def health_check(request): """Simple health check endpoint that bypasses host validation""" return JsonResponse({"status": "healthy", "service": "tally-backend"}) + +@ensure_csrf_cookie +def csrf_token(request): + """Expose the active CSRF cookie name and ensure the cookie is set.""" + return JsonResponse({ + "csrfToken": get_token(request), + "csrfCookieName": settings.CSRF_COOKIE_NAME, + }) + # Schema view for Swagger documentation schema_view = get_schema_view( openapi.Info( @@ -52,6 +62,9 @@ def health_check(request): # Health check endpoint path('health/', health_check, name='health_check'), + + # CSRF bootstrap endpoint for the SPA + path('api/csrf/', csrf_token, name='csrf_token'), # API endpoints path('api/v1/', include('api.urls')), diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 8374b989..e2daa575 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -507,39 +507,13 @@ def get_rank(self, obj): class CreatorSerializer(serializers.ModelSerializer): """ - Serializer for Creator profile. + Serializer for Community profile. """ - total_referrals = serializers.SerializerMethodField() - referral_points = serializers.SerializerMethodField() - class Meta: model = Creator - fields = ['total_referrals', 'referral_points', 'created_at', 'updated_at'] + fields = ['created_at', 'updated_at'] read_only_fields = ['created_at', 'updated_at'] - def get_total_referrals(self, obj): - """Get total number of users referred by this creator.""" - return obj.user.referrals.count() - - def get_referral_points(self, obj): - """Get total points earned from referrals.""" - from contributions.models import Contribution - from django.db.models import Sum - - # Get all contributions from referred users - referred_users = obj.user.referrals.all() - if not referred_users.exists(): - return 0 - - # Calculate 10% of points from referred users' contributions - total_points = Contribution.objects.filter( - user__in=referred_users - ).aggregate( - total=Sum('frozen_global_points') - )['total'] or 0 - - return int(total_points * 0.1) # 10% of referred users' points - class UserSerializer(serializers.ModelSerializer): leaderboard_entry = serializers.SerializerMethodField() @@ -843,4 +817,4 @@ class Meta: class BanAppealReviewSerializer(serializers.Serializer): """Serializer for steward review of ban appeals.""" action = serializers.ChoiceField(choices=['approve', 'deny']) - review_notes = serializers.CharField(required=False, default='', allow_blank=True) \ No newline at end of file + review_notes = serializers.CharField(required=False, default='', allow_blank=True) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9997d42c..57aa7f85 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -7,7 +7,7 @@ import Sidebar from './components/Sidebar.svelte'; import ToastContainer from './components/ToastContainer.svelte'; import ProfileCompletionGuard from './components/ProfileCompletionGuard.svelte'; - import { categoryTheme, currentCategory, detectCategoryFromRoute } from './stores/category.js'; + import { currentCategory, detectCategoryFromRoute } from './stores/category.js'; import { location } from 'svelte-spa-router'; import { resetPageMeta } from './lib/meta.js'; @@ -103,6 +103,7 @@ import GenNews from './routes/GenNews.svelte'; import GenTV from './routes/GenTV.svelte'; import Referrals from './routes/Referrals.svelte'; + import LegacyReferralRedirect from './routes/LegacyReferralRedirect.svelte'; import CommunityPoaps from './routes/CommunityPoaps.svelte'; import PoapDetail from './routes/PoapDetail.svelte'; import PoapClaim from './routes/PoapClaim.svelte'; @@ -114,6 +115,8 @@ import HowItWorks from './routes/HowItWorks.svelte'; import StartupRequestDetail from './routes/StartupRequestDetail.svelte'; import ContributionPreview from './routes/ContributionPreview.svelte'; + import ProjectDetail from './routes/ProjectDetail.svelte'; + import ProjectPageEditor from './routes/ProjectPageEditor.svelte'; import GlobalDashboard from './components/GlobalDashboard.svelte'; import SystemAlerts from './components/portal/SystemAlerts.svelte'; @@ -130,10 +133,11 @@ '/leaderboard': Leaderboard, '/participants': Validators, '/referrals': Referrals, - '/community': ReferralProgram, + '/community': Dashboard, '/community/contributions': Contributions, '/community/all-contributions': AllContributions, - '/community/referrals': Referrals, + '/community/referrals': LegacyReferralRedirect, + '/community/leaderboard': Leaderboard, '/community/poaps': CommunityPoaps, '/community/poaps/recover': PoapRecovery, '/community/poaps/:slug': PoapDetail, @@ -150,6 +154,8 @@ '/builders/leaderboard': Leaderboard, '/builders/resources': Resources, + '/builders/projects/:slug/edit': ProjectPageEditor, + '/builders/projects/:slug': ProjectDetail, '/builders/startup-requests/:id': StartupRequestDetail, // Validators routes @@ -377,7 +383,7 @@ } -
category:builderFilter by category (builder, validator)from:usernameSearch by user name/email/addressassigned:meFilter by assignment (me, unassigned, name)reviewed:meFilter by steward who accepted/rejected itexclude:medium.comExclude submissions containing textinclude:genlayerOnly show submissions containing texthas:urlOnly submissions with URLs+ Currently saved: {submission.contribution.frozen_global_points ?? submission.contribution.points ?? 0} pts +
++ Fill both fields to feature this contribution. Clear both fields to remove the feature. +
+ +- The Validator Journey tracks participants who have joined the waitlist and are working towards becoming active validators on the GenLayer network. + {fallbackDescription}
- Invite others to GenLayer and earn 10% of the points they make, - forever. + Link your social accounts and submit community work to earn community + points.
{subtitle}
+ {/if} +{block.empty || 'Content has not been added for this section yet.'}
+ {/if} +{getParticipantName(user)}
+{formatAddress(user.address)}
+${rest}
` : ''}`; + } + + return `${lines.map(renderInline).join('
')}
$1')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/\*([^*]+)\*/g, '$1');
+}
+
+/** @param {string | undefined} value */
+function escapeHtml(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js
index 44e68aea..743ded8e 100644
--- a/frontend/src/lib/searchParser.js
+++ b/frontend/src/lib/searchParser.js
@@ -6,6 +6,7 @@
* - type:contribution-type-name
* - from:username
* - assigned:me|unassigned|steward-name
+ * - reviewed:me|steward-name
* - exclude:text (multiple allowed)
* - include:text (multiple allowed)
* - has:url|evidence|proposal|appeal
@@ -19,7 +20,7 @@
* Quoted values: tag:"value with spaces"
*/
-const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'sort', 'confidence', 'template', 'proposal', 'mission'];
+const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'reviewed', 'sort', 'confidence', 'template', 'proposal', 'mission'];
const MULTI_VALUE_TAGS = ['exclude', 'include', 'has', 'no', 'is', 'not'];
const NUMERIC_TAGS = ['min-contributions'];
@@ -112,6 +113,7 @@ export function parseSearch(query) {
category: null,
from: null,
assigned: null,
+ reviewed: null,
exclude: [],
include: [],
has: [],
diff --git a/frontend/src/lib/searchToParams.js b/frontend/src/lib/searchToParams.js
index 4790414a..cd1a4c04 100644
--- a/frontend/src/lib/searchToParams.js
+++ b/frontend/src/lib/searchToParams.js
@@ -109,6 +109,34 @@ export function searchToParams(parsed, options = {}) {
}
}
+ // reviewed → reviewed_by (the steward who actually accepted/rejected/requested info)
+ if (filters.reviewed) {
+ const val = filters.reviewed.value.toLowerCase();
+ let reviewedValue = null;
+
+ if (val === 'me' && currentUserId) {
+ reviewedValue = currentUserId;
+ } else {
+ const steward = stewardsList.find(s =>
+ String(s.user_id) === filters.reviewed.value ||
+ s.name?.toLowerCase().includes(val) ||
+ s.user_name?.toLowerCase().includes(val) ||
+ s.address?.toLowerCase().includes(val)
+ );
+ if (steward) {
+ reviewedValue = steward.user_id;
+ }
+ }
+
+ if (reviewedValue) {
+ if (filters.reviewed.negated) {
+ params.exclude_reviewed_by = reviewedValue;
+ } else {
+ params.reviewed_by = reviewedValue;
+ }
+ }
+ }
+
// exclude → exclude_content (comma-separated for multiple values)
if (filters.exclude && filters.exclude.length > 0) {
params.exclude_content = filters.exclude.join(',');
diff --git a/frontend/src/routes/AllContributions.svelte b/frontend/src/routes/AllContributions.svelte
index 051ea27d..cfcabc14 100644
--- a/frontend/src/routes/AllContributions.svelte
+++ b/frontend/src/routes/AllContributions.svelte
@@ -72,12 +72,13 @@
let contributionsError = $state(null);
// Catalogs (for filter dropdowns)
- let allTypes = $state([]); // submittable types only
+ let allTypes = $state([]); // public explorer types
let allMissions = $state([]);
let typesLoading = $state(false);
- // Cached names for filter chips when URL targets a non-submittable type
+ // Cached names for filter chips when URL targets a type/mission outside the loaded catalog
let activeTypeName = $state('');
+ let activeTypeIsPublic = $state(null);
let activeMissionName = $state('');
let participantDetails = $state(null);
@@ -87,25 +88,41 @@
let typesForCategory = $derived(
category === 'all' ? allTypes : allTypes.filter(t => t.category === category)
);
- // Surface the URL-applied type as a synthetic option when it's not in the submittable list
+ // Surface the URL-applied type as a synthetic option when it's not in the public list
let typesForDropdown = $derived.by(() => {
if (!typeId) return typesForCategory;
if (typesForCategory.some(t => String(t.id) === String(typeId))) return typesForCategory;
- if (!activeTypeName) return typesForCategory;
- return [{ id: typeId, name: activeTypeName }, ...typesForCategory];
+ return [{ id: typeId, name: activeTypeName || 'Selected type' }, ...typesForCategory];
+ });
+ let missionsForType = $derived.by(() => {
+ if (!typeId) return [];
+ const filtered = allMissions.filter(m => String(m.contribution_type) === String(typeId));
+ if (
+ missionId &&
+ !filtered.some(m => String(m.id) === String(missionId)) &&
+ activeMissionName
+ ) {
+ return [{ id: missionId, name: activeMissionName, contribution_type: typeId }, ...filtered];
+ }
+ return filtered;
});
- let missionsForType = $derived(
- typeId ? allMissions.filter(m => String(m.contribution_type) === String(typeId)) : []
- );
let hasActiveFilters = $derived(
- category !== routeCategory || !!typeId || !!missionId || !!participantQuery || sortBy !== '-contribution_date'
+ category !== routeCategory ||
+ !!typeId ||
+ !!missionId ||
+ !!participantQuery ||
+ sortBy !== '-contribution_date'
);
function looksLikeAddress(s) {
return s && s.trim().toLowerCase().startsWith('0x');
}
+ function isPublicContributionType(type) {
+ return type?.is_submittable === true || type?.show_in_contributions === true;
+ }
+
// === Search syntax ===
function parseSearchInput(input) {
let sortValue = '-contribution_date';
@@ -174,14 +191,13 @@
}
// === Single helper: reset paging, sync URL, refetch ===
- function resetAndLoad() {
+ async function resetAndLoad() {
highlightsPage = 1;
allPage = 1;
updateUrl();
+ await Promise.all([resolveTypeName(), resolveMissionName()]);
loadHighlights();
loadAllContributions();
- resolveTypeName();
- resolveMissionName();
}
// === Filter actions ===
@@ -191,6 +207,7 @@
typeId = '';
missionId = '';
activeTypeName = '';
+ activeTypeIsPublic = null;
activeMissionName = '';
resetAndLoad();
}
@@ -202,6 +219,7 @@
function onTypeChange() {
missionId = '';
activeTypeName = '';
+ activeTypeIsPublic = null;
activeMissionName = '';
resetAndLoad();
}
@@ -231,6 +249,7 @@
participantQuery = '';
sortBy = '-contribution_date';
activeTypeName = '';
+ activeTypeIsPublic = null;
activeMissionName = '';
participantDetails = null;
searchInput = '';
@@ -240,6 +259,7 @@
typeId = '';
missionId = '';
activeTypeName = '';
+ activeTypeIsPublic = null;
activeMissionName = '';
resetAndLoad();
}
@@ -270,15 +290,13 @@
}
// === Loaders ===
- // Set of submittable type IDs (string-keyed for easy lookup); used to filter
- // out non-submittable contributions client-side from the highlights endpoint
+ // Set of public explorer type IDs (string-keyed for easy lookup); used to filter
+ // out private/system contributions client-side from the highlights endpoint
// (which only honors `category` server-side).
- let submittableTypeIds = $derived(new Set(allTypes.map(t => String(t.id))));
+ let publicTypeIds = $derived(new Set(allTypes.map(t => String(t.id))));
function buildBaseParams() {
- // Always restrict to submittable contribution types — non-submittable types
- // (badges, journey rewards) are not user-facing in the explorer.
- const params = { submittable_only: 'true' };
+ const params = { public_explorer_only: 'true' };
if (category !== 'all') params.category = category;
if (typeId) params.contribution_type = typeId;
if (missionId) params.mission = missionId;
@@ -308,11 +326,16 @@
function filterHighlightsClientSide(items) {
const q = participantQuery?.trim().toLowerCase() || '';
const isAddress = looksLikeAddress(q);
- // Only enforce the submittable filter once the catalog has loaded; otherwise
+ // Only enforce the public-type filter once the catalog has loaded; otherwise
// we'd briefly render an empty list while `allTypes` is still empty.
- const enforceSubmittable = submittableTypeIds.size > 0;
+ const selectedTypeIsPublic = !typeId ||
+ publicTypeIds.has(String(typeId)) ||
+ activeTypeIsPublic === true;
+ const selectedTypeIsHidden = typeId && activeTypeIsPublic === false;
+ const enforcePublicTypes = publicTypeIds.size > 0;
return items.filter(h => {
- if (enforceSubmittable && !submittableTypeIds.has(String(h.contribution_type_id))) return false;
+ if (selectedTypeIsHidden || !selectedTypeIsPublic) return false;
+ if (enforcePublicTypes && !publicTypeIds.has(String(h.contribution_type_id))) return false;
if (typeId && String(h.contribution_type_id) !== String(typeId)) return false;
if (missionId && String(h.mission_id ?? h.mission?.id ?? '') !== String(missionId)) return false;
if (!q) return true;
@@ -394,18 +417,22 @@
async function resolveTypeName() {
if (!typeId) {
activeTypeName = '';
+ activeTypeIsPublic = null;
return;
}
const fromList = allTypes.find(t => String(t.id) === String(typeId));
if (fromList) {
activeTypeName = fromList.name;
+ activeTypeIsPublic = true;
return;
}
try {
const res = await contributionsAPI.getContributionType(typeId);
activeTypeName = res.data?.name || '';
+ activeTypeIsPublic = isPublicContributionType(res.data);
} catch {
activeTypeName = '';
+ activeTypeIsPublic = false;
}
}
@@ -444,10 +471,12 @@
typesLoading = true;
try {
const [types, missions] = await Promise.all([
- getContributionTypes({ is_submittable: 'true' }),
- getMissions(missionId ? { include_inactive: true } : { is_active: true }),
+ getContributionTypes(),
+ getMissions({ include_inactive: true, page_size: 100 }),
]);
- allTypes = Array.isArray(types) ? [...types].sort((a, b) => a.name.localeCompare(b.name)) : [];
+ allTypes = Array.isArray(types)
+ ? types.filter(isPublicContributionType).sort((a, b) => a.name.localeCompare(b.name))
+ : [];
allMissions = Array.isArray(missions) ? missions : [];
} catch {
allTypes = [];
@@ -508,14 +537,12 @@
let isMounted = $state(false);
let lastHash = $state('');
- function handleHashChange() {
+ async function handleHashChange() {
if (!isMounted) return;
if (window.location.hash === lastHash) return;
lastHash = window.location.hash;
parseUrlParams();
- loadParticipantDetails();
- resolveTypeName();
- resolveMissionName();
+ await Promise.all([loadParticipantDetails(), resolveTypeName(), resolveMissionName()]);
loadHighlights();
loadAllContributions();
}
diff --git a/frontend/src/routes/Community.svelte b/frontend/src/routes/Community.svelte
index de695b74..fa45b9d4 100644
--- a/frontend/src/routes/Community.svelte
+++ b/frontend/src/routes/Community.svelte
@@ -2,7 +2,6 @@
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import Avatar from '../components/Avatar.svelte';
- import Icon from '../components/Icons.svelte';
import { leaderboardAPI } from '../lib/api';
const PAGE_SIZE = 50;
@@ -23,7 +22,7 @@
error = null;
offset = 0;
- const response = await leaderboardAPI.getCommunity({ limit: PAGE_SIZE, offset: 0 });
+ const response = await leaderboardAPI.getLeaderboard({ type: 'community', limit: PAGE_SIZE, offset: 0 });
const data = response.data;
communityMembers = data.results || [];
@@ -40,7 +39,7 @@
async function loadMore() {
try {
loadingMore = true;
- const response = await leaderboardAPI.getCommunity({ limit: PAGE_SIZE, offset });
+ const response = await leaderboardAPI.getLeaderboard({ type: 'community', limit: PAGE_SIZE, offset });
const data = response.data;
const newResults = data.results || [];
@@ -97,7 +96,7 @@
Be the first to earn referral points by inviting people to the GenLayer ecosystem!
+Be the first to earn community points through community contributions.
{:else}{contrib.contribution_type_name || 'Contribution'}
-{contrib.user_details?.name || contrib.user_name || 'Anonymous'}
+{contrib.contribution_type_name || 'Contribution'}
+{contrib.user_details?.name || contrib.user_name || 'Anonymous'}
+{title}
+ + {#if showSubmissionCategoryBreakdown} ++ {formatMetricValue(submissionsSummary, metric)} +
++ {getMetricDetail(submissionsSummary, metric)} +
+ {/if} +New submissions
-- {formatNumber(submissionsSummary.ingress)} -
-Submissions created in the selected range.
-Reviewed
-{formatNumber(submissionsSummary.reviewed)}
-Accepted and more-info combined.
-Pending review
-- {formatNumber(submissionsSummary.pendingReview)} -
-Submissions in this range still awaiting a decision.
-Acceptance rate
-- {formatPercent(submissionsSummary.acceptanceRate)} -
-- {formatNumber(submissionsSummary.accepted)} accepted of {formatNumber(submissionsSummary.reviewed)} reviewed. -
-Points awarded
-- {formatNumber(submissionsSummary.pointsAwarded)} -
-- Avg. {formatNumber(submissionsSummary.avgPointsPerAccepted)} per accepted. -
-Project unavailable
+{error}
++ {project.description} +
+ {/if} + + {#if getPageLinks().length} +Project editor unavailable
+{error}
+Upload Cloudinary assets or use a hosted URL fallback.
+{field.label}
+{field.dimensions}
+{imageUploadErrors[field.key]}
+ {/if} +Optional links appear on the banner when filled.
+Search Portal users connected to this project.
+Searching users...
+ {:else if participantSearchError} +{participantSearchError}
+ {:else if participantResults.length} +No participants added yet.
+ {/if} +Search accepted contributions from selected participants.
+Add participants first to select their submissions.
+ {:else if contributionsLoading} +Loading participant submissions...
+ {:else if contributionsError} +{contributionsError}
+ {:else if contributionOptions.length} +No matching submissions.
+ {/if} +No submissions selected yet.
+ {/if} + {:else} +No accepted submissions found for the selected participants.
+ {/if} +{issue}
+ {/each} +- Invite Builders, Validators & Community members + Invite Builders & Validators
- Earn 10% of the points of their successful contributions + Earn 10% of eligible builder and validator contribution points
- Create content, spread the word, and bring new contributors through referrals and outreach. + Create content, spread the word, and earn community points separately from referrals.