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 @@ } -
+
diff --git a/frontend/src/components/LeaderboardTable.svelte b/frontend/src/components/LeaderboardTable.svelte index 395eac22..52d71323 100644 --- a/frontend/src/components/LeaderboardTable.svelte +++ b/frontend/src/components/LeaderboardTable.svelte @@ -23,6 +23,8 @@ if (rank === 3) return 'bg-[#c9956b] text-white'; return 'bg-[#f8fafc] text-[#506078]'; } + + let pointsLabel = $derived($currentCategory === 'community' ? 'Community Points' : 'Points'); {#if loading} @@ -61,7 +63,7 @@ Participant - Points + {pointsLabel} {#if $currentCategory === 'validator'} diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index bcb6b646..0e969a96 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -317,13 +317,13 @@ Contributions { e.preventDefault(); navigate('/community/referrals'); }} + href="/community/leaderboard" + onclick={(e) => { e.preventDefault(); navigate('/community/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { - isActive('/community/referrals') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' + isActive('/community/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' }" > - Referrals + Leaderboard { e.preventDefault(); navigate('/community/referrals'); }} + href="/community/leaderboard" + onclick={(e) => { e.preventDefault(); navigate('/community/leaderboard'); }} class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { - isActive('/community/referrals') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' + isActive('/community/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' }" > - Referrals + Leaderboard {} } = $props(); @@ -29,6 +29,7 @@ { name: 'category', description: 'Filter by category', values: () => [...new Set(contributionTypes.map(t => t.category).filter(Boolean))] }, { name: 'from', description: 'Search by user name/email/address', values: () => [] }, { name: 'assigned', description: 'Filter by assignment', values: () => ['me', 'unassigned', ...stewardsList.map(s => s.name || s.address?.slice(0, 10))] }, + { name: 'reviewed', description: 'Filter by steward who reviewed', values: () => ['me', ...stewardsList.map(s => s.name || s.address?.slice(0, 10))] }, { name: 'exclude', description: 'Exclude submissions containing text', values: () => ['medium.com'] }, { name: 'include', description: 'Only show submissions containing text', values: () => [] }, { name: 'has', description: 'Filter by presence', values: () => ['url', 'evidence', 'proposal', 'appeal'] }, @@ -235,6 +236,7 @@
category:builderFilter by category (builder, validator)
from:usernameSearch by user name/email/address
assigned:meFilter by assignment (me, unassigned, name)
+
reviewed:meFilter by steward who accepted/rejected it
exclude:medium.comExclude submissions containing text
include:genlayerOnly show submissions containing text
has:urlOnly submissions with URLs
@@ -259,6 +261,7 @@
Examples
assigned:me exclude:medium.com has:url
+
status:accepted reviewed:alice
from:alice -type:referral min-contributions:2
type:bug-report -mission:wallet-login is:resubmitted
diff --git a/frontend/src/components/SubmissionCard.svelte b/frontend/src/components/SubmissionCard.svelte index 480c7e65..43f58f89 100644 --- a/frontend/src/components/SubmissionCard.svelte +++ b/frontend/src/components/SubmissionCard.svelte @@ -7,6 +7,7 @@ import Link from '../lib/components/Link.svelte'; import Avatar from './Avatar.svelte'; import Badge from './Badge.svelte'; + import Icons from './Icons.svelte'; import { parseMarkdown } from '../lib/markdownLoader.js'; import { showSuccess, showError } from '../lib/toastStore'; @@ -33,7 +34,12 @@ onToggleInteresting = null, onRequestNotes = null, onRequestUsers = null, - onAppeal = null + onAppeal = null, + acceptedEdit = null, + canEditAccepted = false, + acceptedUpdating = false, + onAcceptedEditChange = null, + onAcceptedUpdate = null } = $props(); let togglingInteresting = $state(false); @@ -1126,6 +1132,106 @@ submission={submission} showExpand={true} /> + + {#if showReviewForm && canEditAccepted && acceptedEdit} +
+
+
+
+
+ + + +

Accepted contribution settings

+
+

+ Currently saved: {submission.contribution.frozen_global_points ?? submission.contribution.points ?? 0} pts +

+
+ + {#if submission.contribution.highlight} + + + Featured + + {/if} +
+
+ +
+
+ +
+ onAcceptedEditChange?.(submission.id, 'points', event.currentTarget.value === '' ? '' : event.currentTarget.valueAsNumber)} + disabled={acceptedUpdating} + class="h-10 w-28 rounded-md border border-gray-300 px-3 text-sm font-semibold text-gray-900 focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 disabled:opacity-50" + /> + points after save +
+
+ +
+
+ +
Featured highlight
+
+

+ Fill both fields to feature this contribution. Clear both fields to remove the feature. +

+ +
+
+ + onAcceptedEditChange?.(submission.id, 'highlight_title', event.currentTarget.value)} + disabled={acceptedUpdating} + placeholder="Feature title" + class="w-full rounded-md border border-yellow-200 bg-white px-3 py-2 text-sm text-gray-900 focus:border-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-400 disabled:opacity-50" + /> +
+ +
+ + +
+
+
+ +
+ +
+
+
+ {/if} {:else if submission.state === 'rejected'} {#if submission.staff_reply}
diff --git a/frontend/src/components/portal/FeaturedBuilds.svelte b/frontend/src/components/portal/FeaturedBuilds.svelte index 158ace29..a0bde74c 100644 --- a/frontend/src/components/portal/FeaturedBuilds.svelte +++ b/frontend/src/components/portal/FeaturedBuilds.svelte @@ -1,6 +1,6 @@ -{#if isValidator} - +{#if showDashboardFallback} +
+
+
+
- GenLayer + {fallbackEyebrow} Verified
-

- Join Validator Journey +

+ {fallbackTitle}

- The Validator Journey tracks participants who have joined the waitlist and are working towards becoming active validators on the GenLayer network. + {fallbackDescription}

@@ -93,7 +120,7 @@
{:else if loading} -
+
@@ -105,6 +132,8 @@
{:else if hero}
Community
- diff --git a/frontend/src/components/portal/PortalContributionCard.svelte b/frontend/src/components/portal/PortalContributionCard.svelte index 73b3b711..df944e51 100644 --- a/frontend/src/components/portal/PortalContributionCard.svelte +++ b/frontend/src/components/portal/PortalContributionCard.svelte @@ -2,7 +2,7 @@ import { push } from 'svelte-spa-router'; import { format } from 'date-fns'; - let { contribution, category = null, height = 180 } = $props(); + let { contribution, category = null, height = 180, pathPrefix = '/contribution' } = $props(); function getCategoryColors(cat) { const map = { @@ -67,7 +67,7 @@ function handleCardClick(event) { if (event.target.closest('button') || event.target.closest('a')) return; - if (realId) push(`/contribution/${realId}`); + if (realId) push(`${pathPrefix}/${realId}`); } function handleKeydown(event) { diff --git a/frontend/src/components/portal/components.md b/frontend/src/components/portal/components.md index 008e02b1..1caca733 100644 --- a/frontend/src/components/portal/components.md +++ b/frontend/src/components/portal/components.md @@ -87,7 +87,7 @@ Horizontal scrolling list of trending contributor cards. **File**: `FeaturedBuilds.svelte` -Grid of featured project cards (placeholder, needs API). +Grid of project cards loaded from the public projects API. --- @@ -110,7 +110,7 @@ Three side-by-side top-5 leaderboard columns. - Columns: Builders, Validators, Community - Category-colored point values -- Data source: `leaderboardAPI.getBuilders()`, `.getValidators()`, `.getCommunity()` +- Data source: `leaderboardAPI.getLeaderboard()` with `type` set to `builder`, `validator`, or `community` --- diff --git a/frontend/src/components/profile/JourneyActions.svelte b/frontend/src/components/profile/JourneyActions.svelte index 94227577..6511eaa6 100644 --- a/frontend/src/components/profile/JourneyActions.svelte +++ b/frontend/src/components/profile/JourneyActions.svelte @@ -188,8 +188,8 @@

- 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.

@@ -218,7 +218,7 @@ onclick={onJoinCommunity} class="w-full bg-[#101010] text-white py-2 rounded-[24px] text-[14px] font-medium hover:bg-black transition-colors" > - Become a referrer
Community - Growing the community through referrals + Contributing to the GenLayer community
{/if} diff --git a/frontend/src/components/profile/RankingsWidget.svelte b/frontend/src/components/profile/RankingsWidget.svelte index cc22287e..94ecb83f 100644 --- a/frontend/src/components/profile/RankingsWidget.svelte +++ b/frontend/src/components/profile/RankingsWidget.svelte @@ -154,7 +154,8 @@ // Fetch community rank only if user is a creator if (isCreator) { (leaderboardAPI as any) - .getCommunity({ + .getLeaderboard({ + type: "community", limit: 1, user_address: addr, }) @@ -624,7 +625,7 @@ >Join the community Become a referrer to earn community pointsLink socials and submit community contributions
diff --git a/frontend/src/components/profile/ReferralsView.svelte b/frontend/src/components/profile/ReferralsView.svelte index f7b86f43..79d0da5c 100644 --- a/frontend/src/components/profile/ReferralsView.svelte +++ b/frontend/src/components/profile/ReferralsView.svelte @@ -42,8 +42,8 @@ } function referrerEarned(referral) { - const builder = Math.round((referral.builder_contribution_points || 0) * 0.1); - const validator = Math.round((referral.validator_contribution_points || 0) * 0.1); + const builder = Math.floor((referral.builder_contribution_points || 0) * 0.1); + const validator = Math.floor((referral.validator_contribution_points || 0) * 0.1); return builder + validator; } @@ -308,7 +308,7 @@
{#if isOwnProfile}
diff --git a/frontend/src/components/projects/ProjectPageRenderer.svelte b/frontend/src/components/projects/ProjectPageRenderer.svelte new file mode 100644 index 00000000..da02e809 --- /dev/null +++ b/frontend/src/components/projects/ProjectPageRenderer.svelte @@ -0,0 +1,581 @@ + + +{#snippet sectionHeading(title, subtitle)} +
+
+

{title || 'About'}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
+{/snippet} + +{#snippet markdownSection(block)} +
+ {#if block.title} + {@render sectionHeading(block.title, '')} + {/if} + {#if block.body} +
+ {@html renderProjectMarkdown(block.body)} +
+ {:else} +

{block.empty || 'Content has not been added for this section yet.'}

+ {/if} +
+{/snippet} + +{#snippet videoPanel(block)} + {@const demoMedia = getDemoMedia(block.url)} +
+
+

{block.title || 'Demo video'}

+
+ +
+ +
+
+{/snippet} + +{#snippet participantsPanel()} +
+ {@render sectionHeading('Participants', 'Portal users connected to this project')} + + {#if getParticipants().length} +
+ {#if participantListOverflows} + + {/if} + +
+ {#each getParticipants() as user} + + {/each} +
+ + {#if participantListOverflows} + + {/if} +
+ {:else} +
+ No participants have been linked to this project yet. +
+ {/if} +
+{/snippet} + +{#snippet relatedSubmissions(block)} + {#if getSortedContributions().length} +
+
+ {@render sectionHeading(block.title || 'Related Contributions', 'Accepted contributions connected to this project')} +
+ +
+ {#each getSortedContributions() as contribution} + {@const normalized = normalizeContribution(contribution)} + + {/each} +
+
+ {/if} +{/snippet} + +
+
+
+ {@render markdownSection({ + title: 'About', + body: project?.details || '', + empty: 'About content has not been added for this project yet.', + })} +
+ + {#if hasSideRail()} + + {/if} +
+ + {#if getSortedContributions().length} +
+ {@render relatedSubmissions({ title: 'Related Contributions' })} +
+ {/if} +
+ + diff --git a/frontend/src/components/shared/CTABanner.svelte b/frontend/src/components/shared/CTABanner.svelte index 3595cc82..dfb5b00f 100644 --- a/frontend/src/components/shared/CTABanner.svelte +++ b/frontend/src/components/shared/CTABanner.svelte @@ -41,21 +41,13 @@ let rankLabel: string = $state(""); let rankComputed = $state(false); - // Determine which entry type to use based on role eligibility - // Priority: builder (completed) > validator-waitlist > validator > community (with referrals) + // Determine which entry type to use based on leaderboard-backed role eligibility. let eligibleEntryType = $derived.by(() => { const p = participant; if (!p) return null; if (p.builder) return "builder"; if (p.has_validator_waitlist && !p.validator) return "validator-waitlist"; if (p.validator) return "validator"; - // Community only if they have at least one referral - if (p.creator) { - const hasReferrals = - referralData?.total_referrals > 0 || - referralData?.referrals?.length > 0; - if (hasReferrals) return "community"; - } return null; }); @@ -63,7 +55,6 @@ builder: "Builder", validator: "Validator", "validator-waitlist": "Validator Waitlist", - community: "Community", }; // Fetch points-to-next-rank once leaderboard entries are available diff --git a/frontend/src/components/ui/Podium.svelte b/frontend/src/components/ui/Podium.svelte index ad1f9801..f3850130 100644 --- a/frontend/src/components/ui/Podium.svelte +++ b/frontend/src/components/ui/Podium.svelte @@ -29,6 +29,10 @@ firstGradient: 'linear-gradient(135deg, #6bdc8a 0%, #3eb359 48%, #207b39 100%)', glow: 'rgba(62, 179, 89, 0.25)', }, + community: { + firstGradient: 'linear-gradient(135deg, #aa8dff 0%, #7f52e1 48%, #4630a3 100%)', + glow: 'rgba(127, 82, 225, 0.25)', + }, referral: { firstGradient: 'linear-gradient(135deg, #aa8dff 0%, #7f52e1 48%, #4630a3 100%)', glow: 'rgba(127, 82, 225, 0.25)', diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 466b7cec..544bf96f 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -1,5 +1,6 @@ import axios from 'axios'; import { API_BASE_URL } from './config.js'; +import { attachCsrfToken } from './csrf.js'; // Create axios instance with base configuration const api = axios.create({ @@ -13,7 +14,7 @@ const api = axios.create({ // Add request interceptor to ensure credentials are always sent api.interceptors.request.use( - (config) => { + async (config) => { // Ensure withCredentials is always true config.withCredentials = true; @@ -22,7 +23,7 @@ api.interceptors.request.use( delete config.headers['Content-Type']; } - return config; + return attachCsrfToken(config); }, (error) => { return Promise.reject(error); @@ -104,7 +105,20 @@ export const submissionsAPI = { // API endpoints for leaderboard export const leaderboardAPI = { - getLeaderboard: (params) => api.get('/leaderboard/', { params }), + getLeaderboard: (params = {}) => { + const { type, category, ...restParams } = params; + const leaderboardType = type || category; + + if (leaderboardType === 'community') { + return api.get('/leaderboard/community/', { params: restParams }); + } + + if (leaderboardType) { + return api.get('/leaderboard/', { params: { type: leaderboardType, ...restParams } }); + } + + return api.get('/leaderboard/', { params: restParams }); + }, getLeaderboardByType: (type, order = 'asc', additionalParams = {}) => api.get('/leaderboard/', { params: { type, order, ...additionalParams } }), getLeaderboardEntry: (address) => api.get(`/leaderboard/?user_address=${address}`), @@ -116,8 +130,9 @@ export const leaderboardAPI = { getWaitlistTop: (limit = 10) => api.get('/leaderboard/validator-waitlist/top/', { params: { limit } }), getMonthlyLeaderboardByType: (type, limit = 10) => api.get('/leaderboard/monthly/', { params: { type, limit } }), - getCommunity: (params = {}) => api.get('/leaderboard/community/', { params }), - getCommunityContributors: (params = {}) => api.get('/leaderboard/community-contributors/', { params }), + getCommunity: (params = {}) => leaderboardAPI.getLeaderboard({ type: 'community', ...params }), + getCommunityContributors: (params = {}) => leaderboardAPI.getLeaderboard({ type: 'community', ...params }), + getReferrals: (params = {}) => api.get('/leaderboard/referrals/', { params }), getTrending: (limit = 10) => api.get('/leaderboard/trending/', { params: { limit } }), getTypes: () => api.get('/leaderboard/types/'), recalculateAll: () => api.post('/leaderboard/recalculate/') @@ -198,6 +213,13 @@ export const stewardAPI = { // Review a submission (accept, reject, or request more info) reviewSubmission: (id, data) => api.post(`/steward-submissions/${id}/review/`, data), + /** + * Correct points or feature an already accepted submission. + * @param {string | number} id + * @param {{ points: number, create_highlight?: boolean, remove_highlight?: boolean, highlight_title?: string, highlight_description?: string }} data + */ + updateAcceptedSubmission: (id, data) => api.post(`/steward-submissions/${id}/update-accepted/`, data), + // Get all users for reassignment dropdown getUsers: () => api.get('/steward-submissions/users/'), @@ -270,12 +292,26 @@ export const updateUserProfile = async (data) => { // Featured content API export const featuredAPI = { getFeatured: (params) => api.get('/featured/', { params }), - getHero: () => api.get('/featured/', { params: { type: 'hero' } }), - getBuilds: () => api.get('/featured/', { params: { type: 'build' } }), + getHero: (params = {}) => api.get('/featured/', { params: { type: 'hero', ...params } }), getCommunity: () => api.get('/featured/', { params: { type: 'community' } }), getValidatorsStewards: () => api.get('/featured/', { params: { type: 'validator_steward' } }), }; +// Project profile API +export const projectsAPI = { + list: () => api.get('/projects/'), + /** @param {string} slug */ + get: (slug) => api.get(`/projects/${slug}/`), + /** @param {string} slug @param {Record} data */ + updateProfile: (slug, data) => api.patch(`/projects/${slug}/profile/`, data), + /** @param {string} slug @param {FormData} formData */ + uploadImage: (slug, formData) => api.post(`/projects/${slug}/upload-image/`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }), +}; + // Alerts API export const alertsAPI = { getAlerts: () => api.get('/alerts/'), diff --git a/frontend/src/lib/auth.js b/frontend/src/lib/auth.js index c7ae2fb2..8a0d940f 100644 --- a/frontend/src/lib/auth.js +++ b/frontend/src/lib/auth.js @@ -3,6 +3,7 @@ import axios from 'axios'; import { writable } from 'svelte/store'; import { userStore } from './userStore'; import { API_BASE_URL } from './config.js'; +import { attachCsrfToken } from './csrf.js'; // Create a Svelte store for authentication state const createAuthStore = () => { @@ -106,6 +107,14 @@ const authAxios = axios.create({ } }); +authAxios.interceptors.request.use( + async (config) => { + config.withCredentials = true; + return attachCsrfToken(config); + }, + (error) => Promise.reject(error) +); + // Authentication API endpoints (relative to base URL, not api/v1) const API_ENDPOINTS = { NONCE: `${API_BASE_URL}/api/auth/nonce/`, diff --git a/frontend/src/lib/csrf.js b/frontend/src/lib/csrf.js new file mode 100644 index 00000000..029a0ce5 --- /dev/null +++ b/frontend/src/lib/csrf.js @@ -0,0 +1,66 @@ +import axios from 'axios'; +import { API_BASE_URL } from './config.js'; + +const UNSAFE_METHODS = new Set(['post', 'put', 'patch', 'delete']); + +let csrfCookieName = 'csrftoken'; +let csrfTokenRequest = null; + +function getCookie(name) { + if (typeof document === 'undefined' || !document.cookie) { + return ''; + } + + const cookie = document.cookie + .split('; ') + .find((row) => row.startsWith(`${encodeURIComponent(name)}=`)); + + if (!cookie) { + return ''; + } + + return decodeURIComponent(cookie.split('=').slice(1).join('=')); +} + +function getCookieToken() { + return getCookie(csrfCookieName) || getCookie('csrftoken'); +} + +function isUnsafeMethod(method = 'get') { + return UNSAFE_METHODS.has(method.toLowerCase()); +} + +export async function getCsrfToken() { + const existingToken = getCookieToken(); + if (existingToken) { + return existingToken; + } + + if (!csrfTokenRequest) { + csrfTokenRequest = axios + .get(`${API_BASE_URL}/api/csrf/`, { withCredentials: true }) + .then((response) => { + csrfCookieName = response.data?.csrfCookieName || csrfCookieName; + return response.data?.csrfToken || getCookieToken(); + }) + .finally(() => { + csrfTokenRequest = null; + }); + } + + return csrfTokenRequest; +} + +export async function attachCsrfToken(config) { + if (!isUnsafeMethod(config.method)) { + return config; + } + + const token = await getCsrfToken(); + if (token) { + config.headers = config.headers || {}; + config.headers['X-CSRFToken'] = token; + } + + return config; +} diff --git a/frontend/src/lib/projectPageTemplate.js b/frontend/src/lib/projectPageTemplate.js new file mode 100644 index 00000000..fde1eda5 --- /dev/null +++ b/frontend/src/lib/projectPageTemplate.js @@ -0,0 +1,205 @@ +const ATTR_PATTERN = /([a-z_]+)=(?:"([^"]*)"|'([^']*)'|([^\s]+))/gi; +const MEDIA_TAG_PATTERN = /^<(Image|Video)\s+([^>]*)\/>\s*$/i; +const MEDIA_TAG_PATTERN_GLOBAL = /^<(Image|Video)\s+([^>]*)\/>\s*$/gim; +const JSX_LIKE_PATTERN = /<\/?[A-Z][A-Za-z0-9]*(?:\s|>|\/>)/; + +/** @param {string} markdown */ +export function renderProjectMarkdown(markdown) { + const source = String(markdown || '').trim(); + if (!source) return ''; + + return source + .split(/\n{2,}/) + .map((chunk) => renderMarkdownChunk(chunk.trim())) + .filter(Boolean) + .join(''); +} + +/** @param {string} value */ +export function getMarkdownTextLength(value) { + return String(value || '') + .replace(MEDIA_TAG_PATTERN_GLOBAL, '') + .replace(/\s+/g, ' ') + .trim() + .length; +} + +/** + * @param {string} markdown + * @param {string} sectionName + */ +export function validateProjectMarkdownMedia(markdown, sectionName = 'About') { + const issues = []; + const lines = String(markdown || '').replace(/\r\n/g, '\n').split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || !JSX_LIKE_PATTERN.test(trimmed)) continue; + + const mediaMatch = trimmed.match(MEDIA_TAG_PATTERN); + if (!mediaMatch) { + issues.push(`${sectionName} only supports standalone and