From e121f85e09bae5ae48b46d79e2051de5c0c1840b Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 12:52:25 +0200 Subject: [PATCH 1/8] Refine feature scoring UI (#828) --- frontend/src/App.svelte | 12 ++-- .../src/routes/StewardFeatureReviews.svelte | 62 ++++++++++++------- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 86654b52..a62017f1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -262,13 +262,15 @@ setRouteMeta($location); }); + let normalizedLocation = $derived(($location || '/').replace(/\/+$/, '') || '/'); + // Pages that need full-bleed (no padding): immersive docs and full-width steward review surfaces let isFullBleedPage = $derived( - $location === '/how-it-works' || - $location === '/stewards/feature-reviews' || - $location.startsWith('/genesis') || - $location.startsWith('/foundations') || - $location === '/manifesto' + normalizedLocation === '/how-it-works' || + normalizedLocation === '/stewards/feature-reviews' || + normalizedLocation.startsWith('/genesis') || + normalizedLocation.startsWith('/foundations') || + normalizedLocation === '/manifesto' ); // Function to hide tooltips - used for route changes diff --git a/frontend/src/routes/StewardFeatureReviews.svelte b/frontend/src/routes/StewardFeatureReviews.svelte index c7f80d71..4a419bc5 100644 --- a/frontend/src/routes/StewardFeatureReviews.svelte +++ b/frontend/src/routes/StewardFeatureReviews.svelte @@ -9,13 +9,18 @@ const TITLE_PREVIEW_LENGTH = 80; const DESCRIPTION_PREVIEW_LENGTH = 220; + const socialIconPaths = { + GitHub: 'M12 2C6.477 2 2 6.477 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.45-1.15-1.11-1.46-1.11-1.46-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z', + X: 'M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z', + Discord: 'M20.317 4.37A19.79 19.79 0 0 0 15.366 2.8a.074.074 0 0 0-.079.037c-.213.38-.45.875-.616 1.265a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.626-1.265.077.077 0 0 0-.079-.037 19.74 19.74 0 0 0-4.951 1.57.07.07 0 0 0-.032.026C.533 8.824-.319 13.144.079 17.41a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 6.073 3.075.078.078 0 0 0 .084-.027c.468-.638.885-1.312 1.24-2.02a.076.076 0 0 0-.041-.105 13.1 13.1 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.126-.094.252-.192.372-.291a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.009c.12.099.246.198.373.292a.077.077 0 0 1-.007.128 12.3 12.3 0 0 1-1.873.891.077.077 0 0 0-.04.106c.36.707.777 1.381 1.238 2.019a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.082-3.075.077.077 0 0 0 .031-.055c.477-4.932-.8-9.216-3.419-13.015a.061.061 0 0 0-.031-.028ZM8.02 14.801c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.211 0 2.176 1.096 2.157 2.419 0 1.333-.955 2.419-2.157 2.419Zm7.975 0c-1.183 0-2.157-1.086-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.211 0 2.176 1.096 2.157 2.419 0 1.333-.946 2.419-2.157 2.419Z', + }; const scoreOptions = [ { value: 0, label: 'Not interesting', - guideClass: 'border-gray-200 bg-gray-50 text-gray-700', - selectedClass: 'border-gray-700 bg-gray-800 text-white shadow-sm', - numberClass: 'bg-gray-200 text-gray-800', + guideClass: 'border-red-200 bg-red-50 text-red-700', + selectedClass: 'border-red-600 bg-red-600 text-white shadow-sm', + numberClass: 'bg-red-100 text-red-700', }, { value: 1, @@ -188,30 +193,37 @@ if (github) { connections.push({ platform: 'GitHub', - label: `GitHub ${github}`, + label: 'GitHub', + title: `GitHub ${github}`, href: `https://github.com/${encodeURIComponent(github)}`, - className: 'border-gray-200 bg-gray-50 text-gray-700 hover:border-gray-400 hover:text-gray-950', + className: 'border-gray-200 bg-white text-gray-700 hover:border-gray-400 hover:text-gray-950', }); } if (twitter) { connections.push({ platform: 'X', - label: `X ${twitter}`, + label: 'X', + title: `X ${twitter}`, href: `https://x.com/${encodeURIComponent(twitter)}`, - className: 'border-sky-100 bg-sky-50 text-sky-700 hover:border-sky-300 hover:text-sky-900', + className: 'border-gray-200 bg-white text-gray-900 hover:border-gray-400', }); } if (discord) { connections.push({ platform: 'Discord', - label: `Discord ${discord}`, + label: 'Discord', + title: `Discord ${discord}`, href: '', - className: 'border-indigo-100 bg-indigo-50 text-indigo-700', + className: 'border-gray-200 bg-white text-indigo-600', }); } return connections; } + function socialIconPath(platform) { + return socialIconPaths[platform] || socialIconPaths.Discord; + } + function evidenceUrl(item) { return item?.url || item?.file || ''; } @@ -413,10 +425,9 @@ {reviewFilterTab === 'scored' ? 'No scored candidates yet.' : 'All visible candidates have been scored.'} {:else} -
-
+
{#each filteredCandidates as candidate} -
+

@@ -442,23 +453,23 @@ { event.preventDefault(); goToProfile(candidate.user_details); }} - class="inline-flex items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-2.5 py-1.5 text-xs font-semibold text-[#137f4c] transition-colors hover:border-[#19A663] hover:bg-white" + class="inline-flex items-center gap-2 text-sm font-semibold text-gray-950 transition-colors hover:text-[#137f4c]" > {#if candidate.user_details?.profile_image_url} {:else} - + {profileInitial(candidate.user_details)} {/if} {displayName(candidate.user_details)} {:else} - by {displayName(candidate.user_details)} + by {displayName(candidate.user_details)} {/if} {#each socialConnections(candidate.user_details) as social} {#if social.href} @@ -466,13 +477,23 @@ href={social.href} target="_blank" rel="noopener noreferrer" - class="rounded-full border px-2.5 py-1 text-xs font-semibold transition-colors {social.className}" + aria-label={social.title} + title={social.title} + class="inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors {social.className}" > - {social.label} + {:else} - - {social.label} + + {/if} {/each} @@ -562,7 +583,6 @@

{/each} -
{/if} {/if} From e71352ce35c0d815e86e1bd1338f72398d50ced6 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 14:30:55 +0200 Subject: [PATCH 2/8] Add steward score reasons (#829) * Add steward score reasons * Address steward score review feedback --- backend/stewards/admin.py | 6 +- .../0012_feature_candidate_score_reason.py | 16 ++++ backend/stewards/models.py | 6 ++ backend/stewards/serializers.py | 9 +- backend/stewards/tests.py | 24 ++++- backend/stewards/views.py | 44 +++++++--- frontend/src/lib/api.js | 2 +- .../src/routes/StewardFeatureReviews.svelte | 88 +++++++++++++++++-- 8 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 backend/stewards/migrations/0012_feature_candidate_score_reason.py diff --git a/backend/stewards/admin.py b/backend/stewards/admin.py index ed605aa7..50ef2117 100644 --- a/backend/stewards/admin.py +++ b/backend/stewards/admin.py @@ -63,7 +63,7 @@ class StewardPermissionAdmin(admin.ModelAdmin): @admin.register(FeatureCandidateScore) class FeatureCandidateScoreAdmin(admin.ModelAdmin): - list_display = ('submission', 'steward', 'score', 'created_at', 'updated_at') + list_display = ('submission', 'steward', 'score', 'reason_preview', 'created_at', 'updated_at') list_filter = ('score', 'created_at', 'updated_at') search_fields = ( 'submission__title', @@ -76,6 +76,10 @@ class FeatureCandidateScoreAdmin(admin.ModelAdmin): readonly_fields = ('created_at', 'updated_at') ordering = ('-updated_at',) + def reason_preview(self, obj): + return obj.reason[:80] + '...' if len(obj.reason) > 80 else obj.reason + reason_preview.short_description = 'Reason' + @admin.register(ReviewTemplate) class ReviewTemplateAdmin(admin.ModelAdmin): diff --git a/backend/stewards/migrations/0012_feature_candidate_score_reason.py b/backend/stewards/migrations/0012_feature_candidate_score_reason.py new file mode 100644 index 00000000..9fac39dc --- /dev/null +++ b/backend/stewards/migrations/0012_feature_candidate_score_reason.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stewards', '0011_feature_candidate_scoring'), + ] + + operations = [ + migrations.AddField( + model_name='featurecandidatescore', + name='reason', + field=models.TextField(blank=True, default='', help_text='Reviewer note explaining what stood out for this score.', max_length=2000), + ), + ] diff --git a/backend/stewards/models.py b/backend/stewards/models.py index c1959241..39b53540 100644 --- a/backend/stewards/models.py +++ b/backend/stewards/models.py @@ -42,6 +42,12 @@ class FeatureCandidateScore(BaseModel): validators=[MinValueValidator(0), MaxValueValidator(3)], help_text="0 = not interesting, 1 = weak, 2 = good, 3 = strong.", ) + reason = models.TextField( + max_length=2000, + blank=True, + default='', + help_text="Reviewer note explaining what stood out for this score.", + ) class Meta: unique_together = ['submission', 'steward'] diff --git a/backend/stewards/serializers.py b/backend/stewards/serializers.py index ad974a90..9f55ed63 100644 --- a/backend/stewards/serializers.py +++ b/backend/stewards/serializers.py @@ -81,13 +81,18 @@ class FeatureCandidateSubmissionSerializer(serializers.Serializer): created_at = serializers.DateTimeField(read_only=True) updated_at = serializers.DateTimeField(read_only=True) own_score = serializers.SerializerMethodField() + own_score_reason = serializers.SerializerMethodField() user_details = serializers.SerializerMethodField() contribution_type_details = serializers.SerializerMethodField() evidence_items = serializers.SerializerMethodField() def get_own_score(self, obj): - score_map = self.context.get('own_score_map', {}) - return score_map.get(obj.id) + review_map = self.context.get('own_review_map', {}) + return (review_map.get(obj.id) or {}).get('score') + + def get_own_score_reason(self, obj): + review_map = self.context.get('own_review_map', {}) + return (review_map.get(obj.id) or {}).get('reason') or '' def get_user_details(self, obj): return feature_candidate_user_data(obj.user) diff --git a/backend/stewards/tests.py b/backend/stewards/tests.py index b82d10f8..4c682833 100644 --- a/backend/stewards/tests.py +++ b/backend/stewards/tests.py @@ -193,6 +193,7 @@ def test_reviewer_list_is_blind_and_only_includes_interesting_accepted_projects( self.assertEqual(row['state'], 'accepted') self.assertEqual(row['contribution_type_details']['slug'], 'projects') self.assertIsNone(row['own_score']) + self.assertEqual(row['own_score_reason'], '') self.assertNotIn('id', row['user_details']) self.assertEqual(row['user_details']['address'], self.submitter.address) self.assertEqual(row['user_details']['github_connection'], {'platform_username': 'submitter-gh'}) @@ -220,21 +221,29 @@ def test_reviewer_can_create_and_edit_own_score(self): create_response = self.client.post( f'/api/v1/stewards/feature-reviews/{self.submission.id}/score/', - {'score': 2}, + {'score': 2, 'reason': 'Strong ecosystem fit, but the demo needs polish.'}, ) edit_response = self.client.post( f'/api/v1/stewards/feature-reviews/{self.submission.id}/score/', - {'score': 3}, + {'score': 3, 'reason': 'The contract design and working demo stood out.'}, ) self.assertEqual(create_response.status_code, status.HTTP_200_OK) + self.assertEqual(create_response.data['reason'], 'Strong ecosystem fit, but the demo needs polish.') self.assertEqual(edit_response.status_code, status.HTTP_200_OK) scores = FeatureCandidateScore.objects.filter( submission=self.submission, steward=self.reviewer_steward, ) self.assertEqual(scores.count(), 1) - self.assertEqual(scores.get().score, 3) + score = scores.get() + self.assertEqual(score.score, 3) + self.assertEqual(score.reason, 'The contract design and working demo stood out.') + + list_response = self.client.get('/api/v1/stewards/feature-reviews/') + row = list_response.data['results'][0] + self.assertEqual(row['own_score'], 3) + self.assertEqual(row['own_score_reason'], 'The contract design and working demo stood out.') def test_unpermitted_steward_cannot_score(self): self.client.force_authenticate(user=self.denied_user) @@ -264,6 +273,15 @@ def test_reviewer_score_validation_rejects_invalid_values(self): self.assertIn('score', response.data) self.assertFalse(FeatureCandidateScore.objects.exists()) + response = self.client.post( + url, + {'score': 2, 'reason': 'x' * 2001}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('reason', response.data) + self.assertFalse(FeatureCandidateScore.objects.exists()) + def test_staff_admin_sees_aggregates_and_manual_review_flag(self): second_user = User.objects.create_user( email='second-reviewer@example.com', diff --git a/backend/stewards/views.py b/backend/stewards/views.py index 02897537..1f389fc8 100644 --- a/backend/stewards/views.py +++ b/backend/stewards/views.py @@ -69,17 +69,21 @@ def _interesting_queryset(self, include_scores=False): queryset = queryset.prefetch_related('feature_candidate_scores') return queryset - def _own_score_map(self, request): + def _own_review_map(self, request): if not hasattr(request.user, 'steward'): return {} - return dict( - FeatureCandidateScore.objects.filter( + return { + review['submission_id']: { + 'score': review['score'], + 'reason': review['reason'] or '', + } + for review in FeatureCandidateScore.objects.filter( steward=request.user.steward, submission__is_interesting=True, submission__state='accepted', submission__contribution_type__slug='projects', - ).values_list('submission_id', 'score') - ) + ).values('submission_id', 'score', 'reason') + } def _summary_map(self, submissions): result = {} @@ -106,13 +110,13 @@ def list(self, request): ) submissions = list(self._interesting_queryset()) - own_score_map = self._own_score_map(request) + own_review_map = self._own_review_map(request) serializer = FeatureCandidateSubmissionSerializer( submissions, many=True, - context={'own_score_map': own_score_map}, + context={'own_review_map': own_review_map}, ) - scored_count = sum(1 for submission in submissions if submission.id in own_score_map) + scored_count = sum(1 for submission in submissions if submission.id in own_review_map) return Response({ 'results': serializer.data, 'progress': { @@ -129,21 +133,35 @@ def score(self, request, pk=None): status=status.HTTP_403_FORBIDDEN, ) + errors = {} try: score = serializers.IntegerField(min_value=0, max_value=3).run_validation( request.data.get('score') ) except serializers.ValidationError as exc: - return Response({'score': exc.detail}, status=status.HTTP_400_BAD_REQUEST) + errors['score'] = exc.detail + + try: + reason = serializers.CharField( + required=False, + allow_blank=True, + trim_whitespace=True, + max_length=2000, + ).run_validation(request.data.get('reason', '')) + except serializers.ValidationError as exc: + errors['reason'] = exc.detail + + if errors: + return Response(errors, status=status.HTTP_400_BAD_REQUEST) submission = get_object_or_404(self._interesting_queryset(), pk=pk) FeatureCandidateScore.objects.update_or_create( submission=submission, steward=request.user.steward, - defaults={'score': score}, + defaults={'score': score, 'reason': reason}, ) - return Response({'submission': str(submission.id), 'score': score}) + return Response({'submission': str(submission.id), 'score': score, 'reason': reason}) @action(detail=False, methods=['get'], url_path='admin') def admin(self, request): @@ -154,7 +172,7 @@ def admin(self, request): ) submissions = list(self._interesting_queryset(include_scores=True)) - own_score_map = self._own_score_map(request) + own_review_map = self._own_review_map(request) summary_map = self._summary_map(submissions) submissions.sort( key=lambda submission: ( @@ -168,7 +186,7 @@ def admin(self, request): serializer = FeatureCandidateAdminSerializer( submissions, many=True, - context={'own_score_map': own_score_map, 'summary_map': summary_map}, + context={'own_review_map': own_review_map, 'summary_map': summary_map}, ) return Response({'results': serializer.data}) diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index e0b60ce7..075065a4 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -324,7 +324,7 @@ export const stewardAPI = { // Feature candidate scoring getFeatureReviewAccess: () => api.get('/stewards/feature-reviews/access/'), getFeatureReviewCandidates: () => api.get('/stewards/feature-reviews/'), - scoreFeatureReviewCandidate: (id, score) => api.post(`/stewards/feature-reviews/${id}/score/`, { score }), + scoreFeatureReviewCandidate: (id, score, reason = '') => api.post(`/stewards/feature-reviews/${id}/score/`, { score, reason }), getFeatureReviewAdmin: () => api.get('/stewards/feature-reviews/admin/') }; diff --git a/frontend/src/routes/StewardFeatureReviews.svelte b/frontend/src/routes/StewardFeatureReviews.svelte index 4a419bc5..8762a585 100644 --- a/frontend/src/routes/StewardFeatureReviews.svelte +++ b/frontend/src/routes/StewardFeatureReviews.svelte @@ -56,6 +56,7 @@ let activeTab = $state('review'); let reviewFilterTab = $state('not_scored'); let saving = $state(new Set()); + let scoreDrafts = $state({}); let expandedTitles = $state(new Set()); let expandedDescriptions = $state(new Set()); let expandedAdminRows = $state(new Set()); @@ -116,6 +117,15 @@ const response = await stewardAPI.getFeatureReviewCandidates(); candidates = response.data?.results || []; progress = response.data?.progress || { scored: 0, total: candidates.length }; + scoreDrafts = Object.fromEntries( + candidates.map(candidate => [ + candidate.id, + { + score: candidate.own_score, + reason: candidate.own_score_reason || '', + }, + ]) + ); } async function loadAdminRows() { @@ -138,21 +148,64 @@ } } - async function scoreCandidate(candidate, score) { + function draftFor(candidate) { + return scoreDrafts[candidate.id] || { + score: candidate.own_score, + reason: candidate.own_score_reason || '', + }; + } + + function draftScore(candidate) { + return draftFor(candidate).score; + } + + function draftReason(candidate) { + return draftFor(candidate).reason || ''; + } + + function updateScoreDraft(candidate, patch) { + scoreDrafts = { + ...scoreDrafts, + [candidate.id]: { + ...draftFor(candidate), + ...patch, + }, + }; + } + + async function scoreCandidate(candidate) { + const draft = draftFor(candidate); + if (!Number.isInteger(draft.score)) { + showError('Select a score before saving.'); + return; + } + saving.add(candidate.id); saving = new Set(saving); try { - await stewardAPI.scoreFeatureReviewCandidate(candidate.id, score); + const response = await stewardAPI.scoreFeatureReviewCandidate( + candidate.id, + draft.score, + draft.reason || '' + ); + const savedReason = response.data?.reason ?? draft.reason ?? ''; const wasUnscored = candidate.own_score === null || candidate.own_score === undefined; candidates = candidates.map(item => - item.id === candidate.id ? { ...item, own_score: score } : item + item.id === candidate.id + ? { ...item, own_score: draft.score, own_score_reason: savedReason } + : item ); + updateScoreDraft(candidate, { score: draft.score, reason: savedReason }); if (wasUnscored) { progress = { ...progress, scored: progress.scored + 1 }; } showSuccess('Score saved'); } catch (err) { - showError(err.response?.data?.detail || err.response?.data?.score || 'Failed to save score.'); + const apiError = err.response?.data; + const scoreError = Array.isArray(apiError?.score) ? apiError.score[0] : apiError?.score; + const reasonError = Array.isArray(apiError?.reason) ? apiError.reason[0] : apiError?.reason; + const message = apiError?.detail || scoreError || reasonError || 'Failed to save score.'; + showError(typeof message === 'string' ? message : 'Failed to save score.'); } finally { saving.delete(candidate.id); saving = new Set(saving); @@ -308,12 +361,12 @@ } function scoreButtonClass(candidate, option) { - if (candidate.own_score === option.value) return option.selectedClass; + if (draftScore(candidate) === option.value) return option.selectedClass; return `${option.guideClass} hover:border-[#19A663] hover:bg-white hover:text-gray-950`; } function scoreNumberClass(candidate, option) { - if (candidate.own_score === option.value) return 'bg-white/20 text-current'; + if (draftScore(candidate) === option.value) return 'bg-white/20 text-current'; return option.numberClass; } @@ -564,12 +617,12 @@ {/if}
-
+
{#each scoreOptions as option} {/each} + +
{/each} From efd5b59212c0f3f651eef3c55202c6fd5d2e6520 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 16:16:08 +0200 Subject: [PATCH 3/8] Add metric snapshots admin --- backend/api/admin.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 backend/api/admin.py diff --git a/backend/api/admin.py b/backend/api/admin.py new file mode 100644 index 00000000..d0052b92 --- /dev/null +++ b/backend/api/admin.py @@ -0,0 +1,41 @@ +from django.contrib import admin + +from .models import MetricSnapshot + + +@admin.register(MetricSnapshot) +class MetricSnapshotAdmin(admin.ModelAdmin): + list_display = ( + 'metric_key', + 'source', + 'status', + 'value', + 'unit', + 'observed_at', + 'created_at', + ) + list_filter = ('metric_key', 'source', 'status', 'unit') + search_fields = ('metric_key', 'source', 'label', 'error') + readonly_fields = ( + 'metric_key', + 'source', + 'label', + 'value', + 'unit', + 'observed_at', + 'dimensions', + 'raw_payload', + 'status', + 'error', + 'created_at', + 'updated_at', + ) + ordering = ('-observed_at', '-created_at') + date_hierarchy = 'observed_at' + actions = None + + def has_add_permission(self, request): + return False + + def has_delete_permission(self, request, obj=None): + return False From ec40c3ab050d51ad1844680ef160ef3f7952d523 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 16:56:41 +0200 Subject: [PATCH 4/8] Make public dashboards and profiles accessible --- backend/api/metrics_views.py | 8 +- backend/api/tests.py | 65 ++++- .../tests/test_canceled_submissions.py | 33 +++ .../tests/test_is_submittable.py | 7 +- .../tests/test_steward_permissions.py | 20 +- backend/contributions/views.py | 43 ++- backend/leaderboard/tests/test_stats.py | 4 +- backend/leaderboard/views.py | 2 +- backend/stewards/tests.py | 24 ++ backend/stewards/views.py | 2 +- backend/tally/settings.py | 3 + backend/users/tests/test_email_security.py | 71 ++++- backend/users/views.py | 21 +- frontend/src/App.svelte | 24 +- frontend/src/components/AuthButton.svelte | 7 + frontend/src/components/Navbar.svelte | 18 +- frontend/src/components/Sidebar.svelte | 154 ++++------- .../src/components/ui/SectionHeader.svelte | 23 +- frontend/src/routes/Dashboard.svelte | 258 ++++++------------ frontend/src/routes/Leaderboard.svelte | 88 +++--- frontend/src/routes/Metrics.svelte | 47 +--- 21 files changed, 499 insertions(+), 423 deletions(-) diff --git a/backend/api/metrics_views.py b/backend/api/metrics_views.py index 585c3e7d..ed458f30 100644 --- a/backend/api/metrics_views.py +++ b/backend/api/metrics_views.py @@ -19,6 +19,7 @@ from utils.pagination import SafePageNumberPagination from validators.permissions import IsCronToken from .overview_metrics import ( + build_overview_payload, empty_network_activity_payload, empty_overview_payload, latest_network_activity, @@ -39,7 +40,12 @@ class OverviewMetricsView(APIView): permission_classes = [permissions.AllowAny] def get(self, request): - return Response(latest_overview_payload() or empty_overview_payload()) + try: + payload = latest_overview_payload() or build_overview_payload() + except Exception: + logger.exception("Failed to build public overview metrics payload") + payload = empty_overview_payload() + return Response(payload) class RefreshOverviewMetricsView(APIView): diff --git a/backend/api/tests.py b/backend/api/tests.py index 387c50b6..7ce6ccea 100644 --- a/backend/api/tests.py +++ b/backend/api/tests.py @@ -661,14 +661,75 @@ def test_public_overview_returns_latest_aggregated_payload(self): self.assertEqual(response.data['top_validators'][1]['name'], 'Validator One') self.assertEqual(response.data['top_validators'][1]['total_stake_gen'], 250) - def test_public_overview_without_aggregate_does_not_rebuild_live_payload(self): + def test_public_overview_without_aggregate_includes_live_portal_counts(self): + builder_user = self._create_user( + 'overview-fallback-builder@example.com', + '0x0000000000000000000000000000000000001201', + 'Fallback Builder', + ) + validator_user = self._create_user( + 'overview-fallback-validator@example.com', + '0x0000000000000000000000000000000000001202', + 'Fallback Validator', + ) + community_user = self._create_user( + 'overview-fallback-community@example.com', + '0x0000000000000000000000000000000000001203', + 'Fallback Community', + ) + Validator.objects.create(user=validator_user) + + builder_category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder'}, + ) + builder_type, _ = ContributionType.objects.get_or_create( + slug='overview-fallback-builder-work', + defaults={ + 'name': 'Fallback builder work', + 'category': builder_category, + 'is_submittable': True, + }, + ) + community_category, _ = Category.objects.get_or_create( + slug='community', + defaults={'name': 'Community'}, + ) + community_type, _ = ContributionType.objects.get_or_create( + slug='overview-fallback-community-post', + defaults={ + 'name': 'Fallback community post', + 'category': community_category, + 'is_submittable': True, + }, + ) + Contribution.objects.bulk_create([ + Contribution( + user=builder_user, + contribution_type=builder_type, + points=10, + frozen_global_points=10, + contribution_date=timezone.now(), + ), + Contribution( + user=community_user, + contribution_type=community_type, + points=5, + frozen_global_points=5, + contribution_date=timezone.now(), + ) + ]) MetricSnapshot.objects.create(metric_key='discord_members', source='discord', value=111) response = self.client.get('/api/v1/metrics/overview/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['version'], 1) - self.assertIsNone(response.data['metrics']['discord_members']) + self.assertEqual(response.data['metrics']['builders']['value'], 1) + self.assertEqual(response.data['metrics']['validators']['value'], 1) + self.assertEqual(response.data['metrics']['community_members']['value'], 1) + self.assertEqual(response.data['metrics']['contributions']['value'], 2) + self.assertEqual(response.data['metrics']['discord_members']['value'], 111.0) self.assertEqual(response.data['top_validators'], []) @override_settings(CRON_SYNC_TOKEN='overview-secret') diff --git a/backend/contributions/tests/test_canceled_submissions.py b/backend/contributions/tests/test_canceled_submissions.py index 26ed9869..b4a0a5af 100644 --- a/backend/contributions/tests/test_canceled_submissions.py +++ b/backend/contributions/tests/test_canceled_submissions.py @@ -93,6 +93,39 @@ def test_canceled_submissions_are_not_counted_as_rejected(self): self.assertEqual(response.data['totals']['rejected'], 1) self.assertEqual(response.data['totals']['canceled'], 1) + def test_daily_metrics_are_public_aggregate_counts(self): + self._create_submission(state='accepted', staff_reply='Accepted') + self.client.force_authenticate(user=None) + + today = timezone.now().date().isoformat() + response = self.client.get( + '/api/v1/steward-submissions/daily-metrics/', + { + 'group_by': 'day', + 'start_date': today, + 'end_date': today, + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['totals']['accepted'], 1) + + def test_steward_stats_are_public_aggregate_counts_for_anonymous_users(self): + self._create_submission(state='pending') + self._create_submission(state='accepted', staff_reply='Accepted') + self._create_submission(state='rejected', staff_reply='Rejected') + self._create_submission(state='canceled', staff_reply='Canceled by user') + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/steward-submissions/stats/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['pending_count'], 1) + self.assertEqual(response.data['total_reviewed'], 2) + self.assertEqual(response.data['total_accepted'], 1) + self.assertEqual(response.data['total_rejected'], 1) + self.assertEqual(response.data['acceptance_rate'], 50.0) + def test_canceled_submissions_are_not_reviewed_decisions(self): self._create_submission(state='rejected', staff_reply='Not valid') self._create_submission(state='canceled', staff_reply='Canceled by user') diff --git a/backend/contributions/tests/test_is_submittable.py b/backend/contributions/tests/test_is_submittable.py index d308aacd..242f7e5c 100644 --- a/backend/contributions/tests/test_is_submittable.py +++ b/backend/contributions/tests/test_is_submittable.py @@ -69,15 +69,12 @@ def setUp(self): self.api_url = reverse('contributiontype-list') - def test_contribution_types_require_authentication(self): + def test_contribution_types_allow_public_read_access(self): self.client.force_authenticate(user=None) response = self.client.get(self.api_url) - self.assertIn( - response.status_code, - [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], - ) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_is_submittable_field_default_value(self): """Test that is_submittable defaults to True.""" diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index 7ce0f332..a800355c 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -118,9 +118,11 @@ def test_non_authenticated_cannot_access_steward_endpoints(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # Steward stats require steward permission. + # Steward stats are public aggregate counts for the dashboard. response = self.client.get('/api/v1/steward-submissions/stats/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['pending_count'], 1) + self.assertEqual(response.data['total_reviewed'], 0) def test_regular_user_cannot_access_steward_endpoints(self): """Test that regular users cannot access steward endpoints.""" @@ -138,9 +140,11 @@ def test_regular_user_cannot_access_steward_endpoints(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # Steward stats require steward permission. + # Steward stats are public aggregate counts for the dashboard. response = self.client.get('/api/v1/steward-submissions/stats/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['pending_count'], 1) + self.assertEqual(response.data['total_reviewed'], 0) def test_steward_can_access_steward_endpoints(self): """Test that stewards can access steward endpoints.""" @@ -214,9 +218,11 @@ def test_propose_only_steward_only_sees_pending_permitted_submissions(self): response = self.client.get('/api/v1/steward-submissions/daily-metrics/') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['totals']['pending_review'], 1) - self.assertEqual(response.data['totals']['accepted'], 0) - self.assertEqual(response.data['totals']['points_awarded'], 0) + # Daily metrics are public aggregate data for the Overview > Metrics + # page, so they are not scoped to the steward's review permissions. + self.assertEqual(response.data['totals']['pending_review'], 2) + self.assertEqual(response.data['totals']['accepted'], 1) + self.assertEqual(response.data['totals']['points_awarded'], accepted_contribution.frozen_global_points) def test_propose_only_steward_can_edit_active_proposal_note(self): """Proposal-only stewards can correct their generated note while pending.""" diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 9f27e065..713efb5d 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -68,7 +68,7 @@ class ContributionTypeViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = ContributionType.objects.all() serializer_class = ContributionTypeSerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.AllowAny] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['name', 'description'] ordering_fields = ['name', 'created_at'] @@ -2355,17 +2355,33 @@ def update_accepted(self, request, pk=None): status=status.HTTP_200_OK ) - @action(detail=False, methods=['get'], url_path='stats') + @action( + detail=False, + methods=['get'], + url_path='stats', + permission_classes=[permissions.AllowAny], + ) def stats(self, request): """Get statistics for steward dashboard.""" - visible_qs = self._visible_submission_queryset() - total_pending = visible_qs.filter(state='pending').count() + is_steward_user = bool( + request.user + and request.user.is_authenticated + and hasattr(request.user, 'steward') + ) + if is_steward_user: + visible_qs = self._visible_submission_queryset() + reviewed_qs = visible_qs.filter(reviewed_by=request.user).exclude(state='canceled') + else: + visible_qs = SubmittedContribution.objects.filter(user__visible=True) + reviewed_qs = visible_qs.filter( + state__in=['accepted', 'rejected', 'more_info_needed'] + ) - reviewed_qs = visible_qs.filter(reviewed_by=request.user).exclude(state='canceled') + total_pending = visible_qs.filter(state='pending').count() total_reviewed = reviewed_qs.count() # Get last review time - last_review = reviewed_qs.order_by('-reviewed_at').first() + last_review = reviewed_qs.filter(reviewed_at__isnull=False).order_by('-reviewed_at').first() last_review_time = last_review.reviewed_at if last_review else None @@ -2388,7 +2404,13 @@ def stats(self, request): 'total_info_requested': total_info_requested }) - @action(detail=False, methods=['get'], url_path='daily-metrics') + @action( + detail=False, + methods=['get'], + url_path='daily-metrics', + permission_classes=[permissions.AllowAny], + authentication_classes=[], + ) def daily_metrics(self, request): """ Get time-series metrics for submissions. @@ -2414,9 +2436,10 @@ def daily_metrics(self, request): """ from datetime import datetime, timedelta from django.db.models import Min, Max - # Build base queryset with steward visibility and optional filters first - # (needed for date detection). - base_qs = self._visible_submission_queryset() + # Public aggregate metrics for the Overview > Metrics page. This action + # returns counts only; detailed steward review lists/actions remain + # protected by the viewset's default IsSteward permission. + base_qs = SubmittedContribution.objects.filter(user__visible=True) category = request.query_params.get('category') if category: diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 36fe932c..2de1a9e7 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -135,12 +135,12 @@ def _create_current_mee6_xp(self, user, discord_id, xp): synced_at=now, ) - def test_leaderboard_requires_authentication(self): + def test_leaderboard_allows_public_read_access(self): self.client.force_authenticate(user=None) response = self.client.get('/api/v1/leaderboard/') - self.assertIn(response.status_code, [401, 403]) + self.assertEqual(response.status_code, 200) def test_community_member_count_uses_accepted_community_contributions(self): now = timezone.now() diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index d8c65a0d..cf7dfe4c 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -60,7 +60,7 @@ class LeaderboardViewSet(viewsets.ReadOnlyModelViewSet): """ queryset = LeaderboardEntry.objects.filter(user__visible=True) serializer_class = LeaderboardEntrySerializer - permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.AllowAny] filter_backends = [filters.SearchFilter, filters.OrderingFilter] search_fields = ['user__name', 'user__address'] ordering_fields = ['rank', 'total_points', 'updated_at'] diff --git a/backend/stewards/tests.py b/backend/stewards/tests.py index b82d10f8..f41de52a 100644 --- a/backend/stewards/tests.py +++ b/backend/stewards/tests.py @@ -42,6 +42,30 @@ def test_regular_user_cannot_mutate_arbitrary_steward_profile(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + def test_public_steward_list_only_includes_visible_users(self): + visible_user = User.objects.create_user( + email='visible-steward@example.com', + password='testpass123', + name='Visible Steward', + visible=True, + ) + hidden_user = User.objects.create_user( + email='hidden-steward@example.com', + password='testpass123', + name='Hidden Steward', + visible=False, + ) + Steward.objects.create(user=visible_user) + Steward.objects.create(user=hidden_user) + self.client.force_authenticate(user=None) + + response = self.client.get('/api/v1/stewards/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + names = {item['name'] for item in response.data} + self.assertIn('Visible Steward', names) + self.assertNotIn('Hidden Steward', names) + class FeatureCandidateReviewAPITestCase(APITestCase): def setUp(self): diff --git a/backend/stewards/views.py b/backend/stewards/views.py index 02897537..10444cc6 100644 --- a/backend/stewards/views.py +++ b/backend/stewards/views.py @@ -225,7 +225,7 @@ def list(self, request, *args, **kwargs): List all stewards with user details, role, and permitted categories. Allow public access to view steward list. """ - stewards = self.get_queryset().select_related('user').prefetch_related( + stewards = self.get_queryset().filter(user__visible=True).select_related('user').prefetch_related( 'permissions__contribution_type' ) data = [] diff --git a/backend/tally/settings.py b/backend/tally/settings.py index 9692514f..e52a9415 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -211,6 +211,9 @@ def get_required_env(key): 'poap_claim_secret': '10/minute', # SIWE nonce/login are unauthenticated: bound per-IP brute force 'siwe_auth': '30/minute', + # Public profile/search surfaces: allow normal browsing, bound scraping + 'public_user_search': '30/minute', + 'public_user_profile': '60/minute', # Wallet linking is a one-time action per validator 'wallet_link': '10/hour', }, diff --git a/backend/users/tests/test_email_security.py b/backend/users/tests/test_email_security.py index 43f4cfef..33421fc1 100644 --- a/backend/users/tests/test_email_security.py +++ b/backend/users/tests/test_email_security.py @@ -1,11 +1,15 @@ """ Tests for email security - authentication emails are only exposed to the owner. """ +from unittest.mock import patch + +from django.core.cache import cache from django.test import TestCase from django.contrib.auth import get_user_model from django.utils import timezone from rest_framework.test import APIClient from rest_framework import status +from rest_framework.throttling import SimpleRateThrottle from rest_framework_simplejwt.tokens import RefreshToken from contributions.node_upgrade.models import TargetNodeVersion from leaderboard.models import LeaderboardEntry @@ -20,6 +24,7 @@ class EmailSecurityTests(TestCase): def setUp(self): """Set up test client and users.""" + cache.clear() self.client = APIClient() # Create a user with auto-generated email (unverified) @@ -150,9 +155,11 @@ def test_verified_email_shown_in_own_profile(self): self.assertTrue(response.data['is_email_verified']) def test_unverified_email_not_exposed_in_public_profile(self): - """Test that user profile endpoint requires auth and hides email from other users.""" + """Test that public user profile endpoint hides email fields.""" response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') - self.assert_requires_authentication(response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotIn('email', response.data) + self.assertNotIn('is_email_verified', response.data) self.authenticate(self.other_user) response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') @@ -163,7 +170,18 @@ def test_unverified_email_not_exposed_in_public_profile(self): def test_verified_email_not_shown_in_public_profile(self): """Test that verified email is not exposed to anonymous or other users.""" response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') - self.assert_requires_authentication(response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for field in [ + 'email', + 'is_email_verified', + 'is_banned', + 'ban_reason', + 'referral_code', + 'referred_by_info', + 'total_referrals', + 'referral_details', + ]: + self.assertNotIn(field, response.data) self.authenticate(self.other_user) response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') @@ -251,10 +269,12 @@ def test_email_becomes_visible_after_verification(self): self.assertEqual(response.data['email'], 'newemail@gmail.com') self.assertTrue(response.data['is_email_verified']) - # User endpoint should reject anonymous clients + # Public profile should not expose the authentication email. self.client.credentials() # Clear authentication response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') - self.assert_requires_authentication(response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotIn('email', response.data) + self.assertNotIn('is_email_verified', response.data) def test_no_email_field_leakage_in_list_view(self): """Test that auth emails are not exposed in authenticated user list view.""" @@ -333,15 +353,49 @@ def test_hidden_users_are_not_publicly_enumerable(self): response = self.client.get(f'/api/v1/users/by-address/{self.hidden_user.address}/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.client.credentials() + response = self.client.get(f'/api/v1/users/by-address/{self.hidden_user.address}/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + response = self.client.get(f'/api/v1/users/by-address/{self.hidden_user.address}/highlights/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.authenticate(self.hidden_user) response = self.client.get('/api/v1/users/me/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['email'], 'hidden@example.com') + def test_public_profile_lookup_is_throttled(self): + """Test public profile reads are rate-limited for anonymous clients.""" + cache.clear() + try: + with patch.dict(SimpleRateThrottle.THROTTLE_RATES, {'public_user_profile': '1/minute'}): + response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get(f'/api/v1/users/by-address/{self.unverified_user.address}/') + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + finally: + cache.clear() + + def test_public_search_is_throttled(self): + """Test public user search is rate-limited for anonymous clients.""" + cache.clear() + try: + with patch.dict(SimpleRateThrottle.THROTTLE_RATES, {'public_user_search': '1/minute'}): + response = self.client.get('/api/v1/users/search/', {'q': 'Verified'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + response = self.client.get('/api/v1/users/search/', {'q': 'Other'}) + self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS) + finally: + cache.clear() + def test_public_search_does_not_match_auth_email(self): - """Test that user search requires auth and cannot use auth email as a lookup key.""" + """Test that public user search cannot use auth email as a lookup key.""" response = self.client.get('/api/v1/users/search/', {'q': 'verified@example.com'}) - self.assert_requires_authentication(response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) self.authenticate(self.other_user) response = self.client.get('/api/v1/users/search/', {'q': 'verified@example.com'}) @@ -373,7 +427,8 @@ def test_leaderboard_search_does_not_match_auth_email(self): ) response = self.client.get('/api/v1/leaderboard/', {'search': 'verified@example.com'}) - self.assert_requires_authentication(response) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) self.authenticate(self.other_user) response = self.client.get('/api/v1/leaderboard/', {'search': 'verified@example.com'}) diff --git a/backend/users/views.py b/backend/users/views.py index e129961c..00aef20c 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -40,6 +40,22 @@ class UserViewSet(UserPoapMixin, viewsets.ReadOnlyModelViewSet): filter_backends = [filters.OrderingFilter] ordering_fields = ['date_joined', 'created_at'] + def get_permissions(self): + public_actions = {'retrieve', 'by_address', 'user_highlights', 'search'} + if self.action in public_actions: + return [permissions.AllowAny()] + return super().get_permissions() + + def get_throttles(self): + action = getattr(self, 'action', None) + if action in {'retrieve', 'by_address', 'user_highlights'}: + self.throttle_scope = 'public_user_profile' + elif action == 'search': + self.throttle_scope = 'public_user_search' + else: + self.throttle_scope = None + return super().get_throttles() + def _can_view_user(self, user): request_user = self.request.user return bool( @@ -117,6 +133,9 @@ def user_highlights(self, request, address=None): from contributions.serializers import ContributionHighlightSerializer user = get_object_or_404(User, address__iexact=address) + if not self._can_view_user(user): + raise Http404 + limit = int(request.query_params.get('limit', 5)) category = request.query_params.get('category') @@ -870,7 +889,7 @@ def referrals(self, request): from leaderboard.models import get_referral_breakdown return Response(get_referral_breakdown(request.user)) - @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) + @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def search(self, request): """Search visible users by public identifiers.""" query = request.query_params.get('q', '').strip() diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a62017f1..5af48bb7 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -163,18 +163,18 @@ // Global/Testnet Asimov routes // Overview and Testnet Asimov routes '/': Overview, - '/testnets': protectedRoute(GlobalDashboard), + '/testnets': GlobalDashboard, '/how-it-works': HowItWorks, '/contributions': protectedRoute(Contributions), '/all-contributions': protectedRoute(AllContributions), - '/leaderboard': protectedRoute(Leaderboard), + '/leaderboard': Leaderboard, '/participants': Validators, '/referrals': protectedRoute(Referrals), - '/community': protectedRoute(Dashboard), + '/community': Dashboard, '/community/contributions': protectedRoute(Contributions), '/community/all-contributions': protectedRoute(AllContributions), '/community/referrals': LegacyReferralRedirect, - '/community/leaderboard': protectedRoute(Leaderboard), + '/community/leaderboard': Leaderboard, '/community/poaps': CommunityPoaps, '/community/poaps/recover': PoapRecovery, '/community/poaps/:slug': PoapDetail, @@ -186,10 +186,10 @@ '/referral-program': ReferralProgram, // Builders routes - '/builders': protectedRoute(Dashboard), + '/builders': Dashboard, '/builders/contributions': protectedRoute(Contributions), '/builders/all-contributions': protectedRoute(AllContributions), - '/builders/leaderboard': protectedRoute(Leaderboard), + '/builders/leaderboard': Leaderboard, '/builders/resources': Resources, '/builders/projects/:slug/edit': protectedRoute(ProjectPageEditor), @@ -198,10 +198,10 @@ '/builders/startup-requests/:id': StartupRequestDetail, // Validators routes - '/validators': protectedRoute(Dashboard), + '/validators': Dashboard, '/validators/contributions': protectedRoute(Contributions), '/validators/all-contributions': protectedRoute(AllContributions), - '/validators/leaderboard': protectedRoute(Leaderboard), + '/validators/leaderboard': Leaderboard, '/validators/tasks': protectedRoute(SocialTasks), '/validators/participants': Validators, '/validators/wall-of-shame': WallOfShame, @@ -210,7 +210,7 @@ '/validators/waitlist/join': ValidatorWaitlist, // Shared routes - '/participant/:address': protectedRoute(Profile), + '/participant/:address': Profile, '/contribution/:id': protectedRoute(ContributionPreview), '/builders/contribution/:id': protectedRoute(ContributionPreview), '/validators/contribution/:id': protectedRoute(ContributionPreview), @@ -226,9 +226,9 @@ // Steward routes '/stewards': StewardDashboard, - '/stewards/submissions': StewardSubmissions, - '/stewards/feature-reviews': StewardFeatureReviews, - '/stewards/discord-xp': StewardDiscordXP, + '/stewards/submissions': protectedRoute(StewardSubmissions), + '/stewards/feature-reviews': protectedRoute(StewardFeatureReviews), + '/stewards/discord-xp': protectedRoute(StewardDiscordXP), // Legal routes '/terms-of-use': TermsOfUse, diff --git a/frontend/src/components/AuthButton.svelte b/frontend/src/components/AuthButton.svelte index a7749143..099f5dd3 100644 --- a/frontend/src/components/AuthButton.svelte +++ b/frontend/src/components/AuthButton.svelte @@ -194,6 +194,13 @@ font-size: 0.875rem; } + .auth-button:not(.connected) { + min-width: 168px; + height: 44px; + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + } + .auth-button:hover { background-color: #1a1a24; } diff --git a/frontend/src/components/Navbar.svelte b/frontend/src/components/Navbar.svelte index d4c8b72f..7f9b905e 100644 --- a/frontend/src/components/Navbar.svelte +++ b/frontend/src/components/Navbar.svelte @@ -158,17 +158,17 @@ diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index ad04324c..2e95d41e 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -119,7 +119,7 @@ // Determine which top-level section is active function getActiveSection() { const path = $location; - if (path === '/' || path === '/testnets' || path === '/metrics') return 'global'; + if (path === '/' || path === '/testnets' || path === '/metrics' || path === '/leaderboard') return 'global'; if (path.startsWith('/builders')) return 'builder'; if (path.startsWith('/validators')) return 'validator'; if (path.startsWith('/community')) return 'community'; @@ -217,6 +217,15 @@ > Metrics + { e.preventDefault(); navigate('/leaderboard'); }} + class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + isActive('/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' + }" + > + Leaderboards +
{/if} @@ -250,7 +259,7 @@ {/if} - {#if !collapsed && getActiveSection() === 'builder'} + {#if !collapsed && getActiveSection() === 'builder' && $authState.isAuthenticated}
Contributions - { e.preventDefault(); navigate('/builders/leaderboard'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { - isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' - }" - > - Leaderboard - { e.preventDefault(); navigate('/builders/resources'); }} @@ -303,7 +303,7 @@ {/if} - {#if !collapsed && getActiveSection() === 'validator'} + {#if !collapsed && getActiveSection() === 'validator' && $authState.isAuthenticated} {/if}
@@ -374,7 +356,7 @@ {/if} - {#if !collapsed && getActiveSection() === 'community'} + {#if !collapsed && getActiveSection() === 'community' && $authState.isAuthenticated}
Contributions - { 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/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' - }" - > - Leaderboard - { e.preventDefault(); navigate('/community/poaps'); }} @@ -580,22 +553,24 @@ {/if} - {#if !collapsed} - - {:else} - + {#if $authState.isAuthenticated} + {#if !collapsed} + + {:else} + + {/if} {/if} @@ -699,6 +674,15 @@ > Metrics + { e.preventDefault(); navigate('/leaderboard'); }} + class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + isActive('/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' + }" + > + Leaderboards +
{/if} @@ -721,7 +705,7 @@ Builders - {#if getActiveSection() === 'builder'} + {#if getActiveSection() === 'builder' && $authState.isAuthenticated}
Contributions - { e.preventDefault(); navigate('/builders/leaderboard'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { - isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]' - }" - > - Leaderboard - { e.preventDefault(); navigate('/builders/resources'); }} @@ -767,7 +742,7 @@ Validators - {#if getActiveSection() === 'validator'} + {#if getActiveSection() === 'validator' && $authState.isAuthenticated} {/if} @@ -831,7 +788,7 @@ Community - {#if getActiveSection() === 'community'} + {#if getActiveSection() === 'community' && $authState.isAuthenticated}
Contributions - { 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/leaderboard') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]' - }" - > - Leaderboard - { e.preventDefault(); navigate('/community/poaps'); }} @@ -989,13 +937,15 @@ How it works - + {#if $authState.isAuthenticated} + + {/if} {#if $authState.isAuthenticated} - {/if} +
+
+
+ +
+
+

Working Groups

+ + {workingGroups.length} groups + + + {totalParticipantCount} members + +
+

+ Focused steward groups with member rosters and private coordination links where available. +

+
+ + {#if isSteward} + + {/if}
- -
- -

- Specialized teams focused on different aspects of the GenLayer ecosystem. -

- - -
+
+ Contact @ras on + Discord + to join. +
- +
{#if loading} -
-
+
+ {#each [1, 2] as _} +
+
+
+
+
+
+
+
+ {/each}
- {:else if workingGroups.length === 0} -
- - - -

No working groups yet

+
+
+ +
+

No working groups yet

{#if isSteward} {/if}
- {:else} -
+
{#each workingGroups as group (group.id)} -
- -
-
- {group.icon || '๐Ÿ‘ฅ'} -

{group.name}

- - {group.participant_count || group.participants?.length || 0} members - -
- {#if isSteward} -
- - +
+
+
+
+
+ {#if group.icon} + + {:else} + + {/if} +
+
+
+

{group.name}

+ + {memberCount(group)} members + +
+ {#if group.description} +
+ {@html parseMarkdown(group.description)} +
+ {/if} +
+
+ +
+ {#if group.discord_url} + + + Discord + + {/if} + + {#if isSteward} + + + + {/if} +
- {/if} -
- - - {#if group.description} -
- {@html parseMarkdown(group.description)}
- {/if} +
- - {#if group.participants && group.participants.length > 0} -
- {#each group.participants as participant} -
-
-
- -
- -
-
-
+ {#if isGroupExpanded(group.id)} +
+ {#if group.participants && group.participants.length > 0} +
+ {#each group.participants as participant} +
+ {#if isSteward} {/if}
-
+ {/each}
- {/each} -
- {:else} -
- No members yet + {:else} +
+ No members yet +
+ {/if}
{/if} -
+
{/each}
{/if}
-
+
{#if showGroupModal} @@ -380,39 +448,43 @@
- +
- +
- +
- +
@@ -427,7 +499,7 @@ @@ -444,11 +516,12 @@

Add Participant

- + {#if searching} @@ -518,6 +591,59 @@ {/if} From c293fd1f2335c6b2afc4856d41dcf9f2271094a3 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 17:20:16 +0200 Subject: [PATCH 6/8] Address public access review feedback --- backend/api/metrics_views.py | 4 ++- .../tests/test_canceled_submissions.py | 30 +++++++++++++++++++ .../tests/test_steward_permissions.py | 8 ++--- backend/contributions/views.py | 17 +++++++++++ backend/leaderboard/tests/test_stats.py | 30 +++++++++++++++++++ backend/leaderboard/views.py | 14 +++++++++ backend/users/serializers.py | 9 ++++++ backend/users/tests/test_email_security.py | 15 +++++++--- backend/users/views.py | 7 +++-- frontend/src/components/AuthButton.svelte | 9 +----- frontend/src/components/Sidebar.svelte | 2 -- .../src/components/ui/SectionHeader.svelte | 11 ++++--- 12 files changed, 128 insertions(+), 28 deletions(-) diff --git a/backend/api/metrics_views.py b/backend/api/metrics_views.py index ed458f30..cd078ce4 100644 --- a/backend/api/metrics_views.py +++ b/backend/api/metrics_views.py @@ -35,7 +35,9 @@ class OverviewMetricsView(APIView): Public investor overview payload. Served from the latest composite ``overview_payload`` MetricSnapshot that the - cron persists. Public page reads never fetch or aggregate source providers. + cron persists. If no snapshot exists, the view performs a live overview + rebuild with ``build_overview_payload()`` and returns the empty payload only + if that fallback fails. """ permission_classes = [permissions.AllowAny] diff --git a/backend/contributions/tests/test_canceled_submissions.py b/backend/contributions/tests/test_canceled_submissions.py index b4a0a5af..e8f031b3 100644 --- a/backend/contributions/tests/test_canceled_submissions.py +++ b/backend/contributions/tests/test_canceled_submissions.py @@ -110,6 +110,36 @@ def test_daily_metrics_are_public_aggregate_counts(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['totals']['accepted'], 1) + def test_daily_metrics_rejects_inverted_date_range(self): + self.client.force_authenticate(user=None) + + response = self.client.get( + '/api/v1/steward-submissions/daily-metrics/', + { + 'group_by': 'day', + 'start_date': '2026-02-01', + 'end_date': '2026-01-01', + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'start_date must be before or equal to end_date.') + + def test_daily_metrics_rejects_too_large_public_date_range(self): + self.client.force_authenticate(user=None) + + response = self.client.get( + '/api/v1/steward-submissions/daily-metrics/', + { + 'group_by': 'day', + 'start_date': '2020-01-01', + 'end_date': '2022-01-01', + }, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'Date range is too large for group_by=day.') + def test_steward_stats_are_public_aggregate_counts_for_anonymous_users(self): self._create_submission(state='pending') self._create_submission(state='accepted', staff_reply='Accepted') diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index a800355c..1afb3a75 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -105,8 +105,8 @@ def setUp(self): self.client = APIClient() - def test_non_authenticated_cannot_access_steward_endpoints(self): - """Test that non-authenticated users cannot access steward endpoints.""" + def test_non_authenticated_user_can_view_stats_but_not_protected_steward_endpoints(self): + """Test that anonymous users can view stats but not protected steward endpoints.""" # Try to access steward submissions list response = self.client.get('/api/v1/steward-submissions/') self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -124,8 +124,8 @@ def test_non_authenticated_cannot_access_steward_endpoints(self): self.assertEqual(response.data['pending_count'], 1) self.assertEqual(response.data['total_reviewed'], 0) - def test_regular_user_cannot_access_steward_endpoints(self): - """Test that regular users cannot access steward endpoints.""" + def test_regular_user_can_view_stats_but_not_protected_steward_endpoints(self): + """Test that regular users can view stats but not protected steward endpoints.""" # Authenticate as regular user self.client.force_authenticate(user=self.regular_user) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 713efb5d..1cccb433 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -2495,6 +2495,23 @@ def daily_metrics(self, request): if end_date is None: end_date = timezone.now().date() + if start_date > end_date: + return Response( + {'detail': 'start_date must be before or equal to end_date.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + max_days_by_group = { + 'day': 366, + 'week': 366 * 5, + 'month': 366 * 10, + } + if (end_date - start_date).days > max_days_by_group[group_by]: + return Response( + {'detail': f'Date range is too large for group_by={group_by}.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + start_datetime = day_start(start_date) end_datetime = day_start(end_date + timedelta(days=1)) diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 2de1a9e7..1677283a 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -142,6 +142,36 @@ def test_leaderboard_allows_public_read_access(self): self.assertEqual(response.status_code, 200) + def test_public_user_stats_do_not_expose_hidden_users(self): + hidden_user = self._create_user( + 'hidden-stats@example.com', + '0x00000000000000000000000000000000000000aa', + visible=False, + ) + self.client.force_authenticate(user=None) + + response = self.client.get(f'/api/v1/leaderboard/user/{hidden_user.id}/') + self.assertEqual(response.status_code, 404) + + response = self.client.get( + f'/api/v1/leaderboard/user_stats/by-address/{hidden_user.address}/' + ) + self.assertEqual(response.status_code, 404) + + def test_hidden_user_can_view_own_stats(self): + hidden_user = self._create_user( + 'own-hidden-stats@example.com', + '0x00000000000000000000000000000000000000ab', + visible=False, + ) + self.client.force_authenticate(user=hidden_user) + + response = self.client.get( + f'/api/v1/leaderboard/user_stats/by-address/{hidden_user.address}/' + ) + + self.assertEqual(response.status_code, 200) + def test_community_member_count_uses_accepted_community_contributions(self): now = timezone.now() community_user = self._create_user( diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index cf7dfe4c..298988c6 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -67,6 +67,16 @@ class LeaderboardViewSet(viewsets.ReadOnlyModelViewSet): ordering = ['rank'] pagination_class = None # Disable pagination to return all entries + def _can_view_user_stats(self, user): + request_user = self.request.user + return bool( + user.visible + or ( + request_user.is_authenticated + and (request_user.id == user.id or request_user.is_staff) + ) + ) + def get_queryset(self): """ Filter leaderboard by type, user address, and handle ordering. @@ -626,6 +636,8 @@ def user_stats(self, request, user_id=None): user = User.objects.get(pk=user_id) except User.DoesNotExist: return Response({'error': 'User not found'}, status=404) + if not self._can_view_user_stats(user): + return Response({'error': 'User not found'}, status=404) # Get category filter from query params category = request.query_params.get('category') @@ -643,6 +655,8 @@ def user_stats_by_address(self, request, address=None): user = User.objects.get(address__iexact=address) except User.DoesNotExist: return Response({'error': 'User not found'}, status=404) + if not self._can_view_user_stats(user): + return Response({'error': 'User not found'}, status=404) # Get category filter from query params category = request.query_params.get('category') diff --git a/backend/users/serializers.py b/backend/users/serializers.py index b7699e92..099eb1ce 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -881,6 +881,15 @@ def to_representation(self, instance): ]: data.pop(field, None) + if self.context.get('public_profile', False): + for field in [ + 'referral_code', + 'referred_by_info', + 'total_referrals', + 'referral_details', + ]: + data.pop(field, None) + return data diff --git a/backend/users/tests/test_email_security.py b/backend/users/tests/test_email_security.py index 33421fc1..f21d80af 100644 --- a/backend/users/tests/test_email_security.py +++ b/backend/users/tests/test_email_security.py @@ -153,6 +153,9 @@ def test_verified_email_shown_in_own_profile(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['email'], 'verified@example.com') self.assertTrue(response.data['is_email_verified']) + self.verified_user.refresh_from_db() + self.assertEqual(response.data['referral_code'], self.verified_user.referral_code) + self.assertIsNotNone(response.data['referred_by_info']) def test_unverified_email_not_exposed_in_public_profile(self): """Test that public user profile endpoint hides email fields.""" @@ -227,7 +230,7 @@ def test_public_profile_only_exposes_public_social_identifiers(self): self.assertNotIn(private_value, serialized) def test_verified_email_shown_on_own_public_profile_when_authenticated(self): - """Test that a user can see their own email on any profile endpoint.""" + """Test that public profile endpoints do not include referral details.""" self.authenticate(self.verified_user) response = self.client.get(f'/api/v1/users/by-address/{self.verified_user.address}/') @@ -236,9 +239,13 @@ def test_verified_email_shown_on_own_public_profile_when_authenticated(self): self.assertTrue(response.data['is_email_verified']) self.assertTrue(response.data['is_banned']) self.assertEqual(response.data['ban_reason'], 'private moderation note') - self.verified_user.refresh_from_db() - self.assertEqual(response.data['referral_code'], self.verified_user.referral_code) - self.assertIsNotNone(response.data['referred_by_info']) + for field in [ + 'referral_code', + 'referred_by_info', + 'total_referrals', + 'referral_details', + ]: + self.assertNotIn(field, response.data) self.assertEqual(response.data['github_connection']['platform_username'], 'verified-gh') self.assertEqual(response.data['twitter_connection']['platform_username'], 'verified-x') self.assertEqual(response.data['discord_connection']['platform_username'], 'verified-discord') diff --git a/backend/users/views.py b/backend/users/views.py index 00aef20c..3d13eb7d 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -100,8 +100,9 @@ def get_serializer_context(self): context = super().get_serializer_context() # Use light serializers for list view, full for detail/by_address context['use_light_serializers'] = self.action == 'list' - # Include referral_details only for detail/by_address views - context['include_referral_details'] = self.action in ['retrieve', 'by_address'] + # Referral breakdowns belong on owner-only endpoints such as /users/me/. + context['include_referral_details'] = False + context['public_profile'] = self.action in ['retrieve', 'by_address'] return context @action(detail=False, methods=['get'], url_path='by-address/(?P
[^/.]+)') @@ -120,7 +121,7 @@ def by_address(self, request, address=None): # Override context for by_address to include full details context = self.get_serializer_context() context['use_light_serializers'] = False - context['include_referral_details'] = True + context['include_referral_details'] = False serializer = self.get_serializer(user, context=context) return Response(serializer.data) diff --git a/frontend/src/components/AuthButton.svelte b/frontend/src/components/AuthButton.svelte index 099f5dd3..1469d784 100644 --- a/frontend/src/components/AuthButton.svelte +++ b/frontend/src/components/AuthButton.svelte @@ -122,7 +122,7 @@ {#if showLink && linkPath} - + {/if}
From 335a5edefe404eda1dcfa19b547a9efebb361f0d Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 17:36:36 +0200 Subject: [PATCH 7/8] Cap public user highlights limit --- backend/users/tests/test_email_security.py | 62 ++++++++++++++++++++++ backend/users/views.py | 27 +++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/backend/users/tests/test_email_security.py b/backend/users/tests/test_email_security.py index f21d80af..c939c33b 100644 --- a/backend/users/tests/test_email_security.py +++ b/backend/users/tests/test_email_security.py @@ -133,6 +133,46 @@ def assert_requires_authentication(self, response): response.status_code, [status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN], ) + + def create_public_highlights(self, count): + from contributions.models import ( + Category, + Contribution, + ContributionHighlight, + ContributionType, + ) + from leaderboard.models import GlobalLeaderboardMultiplier + + category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder'}, + ) + contribution_type, _ = ContributionType.objects.get_or_create( + slug='public-highlight-test', + defaults={'name': 'Public Highlight Test', 'category': category}, + ) + GlobalLeaderboardMultiplier.objects.get_or_create( + contribution_type=contribution_type, + defaults={ + 'multiplier_value': 1, + 'valid_from': timezone.now() - timezone.timedelta(days=1), + 'description': 'Public highlight test multiplier', + }, + ) + + for index in range(count): + contribution = Contribution.objects.create( + user=self.verified_user, + contribution_type=contribution_type, + points=1, + contribution_date=timezone.now(), + title=f'Contribution {index}', + ) + ContributionHighlight.objects.create( + contribution=contribution, + title=f'Highlight {index}', + description='Public highlight test', + ) def test_unverified_email_not_exposed_in_own_profile(self): """Test that unverified email is not exposed when user views own profile.""" @@ -372,6 +412,28 @@ def test_hidden_users_are_not_publicly_enumerable(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['email'], 'hidden@example.com') + def test_public_highlights_reject_malformed_limit(self): + """Test public profile highlights handle invalid limits without server errors.""" + response = self.client.get( + f'/api/v1/users/by-address/{self.verified_user.address}/highlights/', + {'limit': 'invalid'}, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['detail'], 'limit must be a non-negative integer.') + + def test_public_highlights_cap_large_limit(self): + """Test public profile highlights enforce a maximum limit.""" + self.create_public_highlights(21) + + response = self.client.get( + f'/api/v1/users/by-address/{self.verified_user.address}/highlights/', + {'limit': '999'}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 20) + def test_public_profile_lookup_is_throttled(self): """Test public profile reads are rate-limited for anonymous clients.""" cache.clear() diff --git a/backend/users/views.py b/backend/users/views.py index 3d13eb7d..fde39179 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -39,6 +39,26 @@ class UserViewSet(UserPoapMixin, viewsets.ReadOnlyModelViewSet): lookup_field = 'address' # Change default lookup field from 'pk' to 'address' filter_backends = [filters.OrderingFilter] ordering_fields = ['date_joined', 'created_at'] + public_highlights_default_limit = 5 + public_highlights_max_limit = 20 + + def _parse_public_limit(self, default, max_limit): + raw_limit = self.request.query_params.get('limit', default) + try: + limit = int(raw_limit) + except (TypeError, ValueError): + return None, Response( + {'detail': 'limit must be a non-negative integer.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if limit < 0: + return None, Response( + {'detail': 'limit must be a non-negative integer.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return min(limit, max_limit), None def get_permissions(self): public_actions = {'retrieve', 'by_address', 'user_highlights', 'search'} @@ -137,7 +157,12 @@ def user_highlights(self, request, address=None): if not self._can_view_user(user): raise Http404 - limit = int(request.query_params.get('limit', 5)) + limit, error_response = self._parse_public_limit( + self.public_highlights_default_limit, + self.public_highlights_max_limit, + ) + if error_response is not None: + return error_response category = request.query_params.get('category') # Build the queryset for filtering From 3f00304b73027c545719c27dffa17609086727a3 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 23 Jun 2026 18:09:19 +0200 Subject: [PATCH 8/8] Fix route OG previews (#833) --- frontend/public/sitemap.xml | 6 ++ frontend/src/lib/routeMeta.js | 10 +++ frontend/src/tests/routeMeta.test.js | 99 +++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index b3455c0d..e3fa8394 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -66,6 +66,12 @@ weekly 0.8 + + https://portal.genlayer.foundation/validators + 2026-06-23 + weekly + 0.8 + https://portal.genlayer.foundation/validators/waitlist/join 2026-06-14 diff --git a/frontend/src/lib/routeMeta.js b/frontend/src/lib/routeMeta.js index 4f321ce6..42de8d41 100644 --- a/frontend/src/lib/routeMeta.js +++ b/frontend/src/lib/routeMeta.js @@ -27,6 +27,7 @@ export const OG_IMAGES = { builderProject: ogImage('builder-project.png'), communityPoaps: ogImage('community-poaps.png'), participants: ogImage('participants.png'), + validators: ogImage('validators-participants.png'), validatorsParticipants: ogImage('validators-participants.png'), validatorsWaitlist: ogImage('validators-waitlist.png'), validatorsWallOfShame: ogImage('validators-wall-of-shame.png'), @@ -231,6 +232,14 @@ export const ROUTE_META = { imageWidth: OG_IMAGES.participants.width, imageHeight: OG_IMAGES.participants.height, }, + '/validators': { + title: 'GenLayer Validators', + description: + 'Explore GenLayer validator programs, operator activity, contribution opportunities, leaderboards, and network reliability signals.', + image: OG_IMAGES.validators.src, + imageWidth: OG_IMAGES.validators.width, + imageHeight: OG_IMAGES.validators.height, + }, '/validators/participants': { title: 'GenLayer Validator Participants', description: @@ -300,6 +309,7 @@ export const STATIC_OG_ROUTES = [ '/builders/resources', '/community/poaps', '/participants', + '/validators', '/validators/participants', '/validators/waitlist/join', '/validators/wall-of-shame', diff --git a/frontend/src/tests/routeMeta.test.js b/frontend/src/tests/routeMeta.test.js index de3e64c8..ac5a946b 100644 --- a/frontend/src/tests/routeMeta.test.js +++ b/frontend/src/tests/routeMeta.test.js @@ -1,8 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import path from 'node:path'; -import { OG_IMAGES, SITE_URL, STATIC_OG_ROUTES, resolveRouteMeta } from '../lib/routeMeta.js'; +import { + NOINDEX_ROBOTS, + OG_IMAGES, + ROUTE_META, + ROUTE_META_ALIASES, + SITE_URL, + STATIC_OG_ROUTES, + resolveRouteMeta, +} from '../lib/routeMeta.js'; const expectedRouteImages = { '/': '/assets/og/portal.png', @@ -20,6 +28,7 @@ const expectedRouteImages = { '/builders/resources': '/assets/og/builders-resources.png', '/community/poaps': '/assets/og/community-poaps.png', '/participants': '/assets/og/participants.png', + '/validators': '/assets/og/validators-participants.png', '/validators/participants': '/assets/og/validators-participants.png', '/validators/waitlist/join': '/assets/og/validators-waitlist.png', '/validators/wall-of-shame': '/assets/og/validators-wall-of-shame.png', @@ -27,6 +36,41 @@ const expectedRouteImages = { '/privacy-policy': '/assets/og/privacy-policy.png', }; +const dynamicMetaRoutes = new Set([ + '/builders/projects/:slug', + '/builders/startup-requests/:id', + '/community/poaps/:slug', + '/badge/:id', +]); + +const routeParamSamples = { + address: '0x0000000000000000000000000000000000000000', + id: '42', + slug: 'sample-project', + token: 'sample-token', +}; + +function appRoutePaths() { + const app = readFileSync(path.join(process.cwd(), 'src', 'App.svelte'), 'utf8'); + const routeBlock = app.match(/const routes = \{([\s\S]*?)\n\s*\};/)?.[1] || ''; + + return [...routeBlock.matchAll(/^\s*'([^']+)'\s*:/gm)] + .map((match) => match[1]) + .filter((route) => route !== '*'); +} + +function concreteRoute(route) { + return route.replace(/:([A-Za-z0-9_]+)/g, (_, name) => routeParamSamples[name] || 'sample'); +} + +function hasSpecificRouteMeta(route) { + return ( + Object.hasOwn(ROUTE_META, route) || + Object.hasOwn(ROUTE_META_ALIASES, route) || + dynamicMetaRoutes.has(route) + ); +} + describe('route metadata', () => { it('uses final 1200x630 OG images instead of raw backdrops', () => { for (const image of Object.values(OG_IMAGES)) { @@ -46,12 +90,51 @@ describe('route metadata', () => { } }); + it('registers and routes every finished top-level OG asset', () => { + const assetDir = path.join(process.cwd(), 'public', 'assets', 'og'); + const finalAssets = readdirSync(assetDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.png')) + .map((entry) => `/assets/og/${entry.name}`) + .sort(); + const registeredImages = new Set( + Object.values(OG_IMAGES).map((image) => new URL(image.src).pathname) + ); + const routedImages = new Set( + Object.keys(ROUTE_META).map((route) => new URL(resolveRouteMeta(route).image).pathname) + ); + + expect([...registeredImages].sort()).toEqual(finalAssets); + + for (const image of registeredImages) { + expect(routedImages.has(image)).toBe(true); + } + }); + it('resolves route-specific images for key portal routes', () => { for (const [route, imagePath] of Object.entries(expectedRouteImages)) { expect(resolveRouteMeta(route).image).toContain(imagePath); } }); + it('resolves complete specific or default preview metadata for every app route', () => { + for (const route of appRoutePaths()) { + const meta = resolveRouteMeta(concreteRoute(route)); + + expect(meta.title, route).toBeTruthy(); + expect(meta.description, route).toBeTruthy(); + expect(meta.image, route).toMatch(/^https:\/\/portal\.genlayer\.foundation\/assets\/og\/.+\.png$/); + expect(meta.imageWidth, route).toBe('1200'); + expect(meta.imageHeight, route).toBe('630'); + expect(meta.url, route).toMatch(/^https:\/\/portal\.genlayer\.foundation\//); + expect(meta.url, route).not.toContain('#'); + expect(meta.robots, route).toBeTruthy(); + + if (!hasSpecificRouteMeta(route)) { + expect(meta.robots, route).toBe(NOINDEX_ROBOTS); + } + } + }); + it('uses non-hash canonical URLs for every public OG route', () => { for (const route of ['/', ...STATIC_OG_ROUTES]) { const meta = resolveRouteMeta(route); @@ -83,6 +166,7 @@ describe('route metadata', () => { '/builders/resources', '/community/poaps', '/participants', + '/validators', '/validators/participants', '/validators/waitlist/join', '/validators/wall-of-shame', @@ -130,4 +214,15 @@ describe('route metadata', () => { expect(robots).toContain('Allow: /community/poaps/'); expect(robots).toContain('Disallow: /community/poaps/recover'); }); + + it('generates static OG pages for every sitemap route', () => { + const sitemap = readFileSync(path.join(process.cwd(), 'public', 'sitemap.xml'), 'utf8'); + const sitemapRoutes = [...sitemap.matchAll(/https:\/\/portal\.genlayer\.foundation(.*?)<\/loc>/g)] + .map((match) => match[1] || '/'); + const staticRoutes = new Set(['/', ...STATIC_OG_ROUTES]); + + for (const route of sitemapRoutes) { + expect(staticRoutes.has(route)).toBe(true); + } + }); });