From 28d36e0d5a386dd6ceee5e517bb620a7dc3c1c7e Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Fri, 22 May 2026 15:24:47 +0200 Subject: [PATCH 01/12] Fix reviewed submission metrics --- frontend/src/routes/Metrics.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/Metrics.svelte b/frontend/src/routes/Metrics.svelte index c4e4839f..3ea55b56 100644 --- a/frontend/src/routes/Metrics.svelte +++ b/frontend/src/routes/Metrics.svelte @@ -289,9 +289,10 @@ const totals = submissionsData.totals || {}; const ingress = Number(totals.ingress || 0); const accepted = Number(totals.accepted || 0); + const rejected = Number(totals.rejected || 0); const moreInfoRequested = Number(totals.more_info_requested || 0); const pointsAwarded = Number(totals.points_awarded || 0); - const reviewed = accepted + moreInfoRequested; + const reviewed = accepted + rejected + moreInfoRequested; // Pending review comes from the backend: submissions created in the // selected range that are still in pending state. We can't derive it from // ingress - reviewed because ingress is bucketed by created_at and reviews @@ -306,6 +307,7 @@ moreInfoRequested, pendingReview, pointsAwarded, + rejected, reviewed }; }); @@ -991,6 +993,7 @@ const point = submissionsData.data[items[0]?.dataIndex]; const reviewed = Number(point?.accepted || 0) + + Number(point?.rejected || 0) + Number(point?.more_info_requested || 0); return `Reviewed decisions: ${formatNumber(reviewed)}`; @@ -1998,7 +2001,7 @@

Reviewed

{formatNumber(submissionsSummary.reviewed)}

-

Accepted and more-info combined.

+

All reviewed outcomes combined.

From c1364429e8bd928f71f7037a371e6c9fad87d9a5 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 24 May 2026 13:17:37 +0200 Subject: [PATCH 02/12] Update community dashboard metrics --- backend/leaderboard/tests/test_stats.py | 126 ++++++++++++ backend/leaderboard/views.py | 32 +-- frontend/src/App.svelte | 2 +- .../src/components/portal/LiveStats.svelte | 4 +- frontend/src/components/ui/Podium.svelte | 4 + frontend/src/routes/Dashboard.svelte | 182 ++++++++++++------ 6 files changed, 281 insertions(+), 69 deletions(-) create mode 100644 backend/leaderboard/tests/test_stats.py 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..5b223e93 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -276,18 +276,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 +304,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 +315,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 +406,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 +422,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, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9997d42c..780fa5de 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -130,7 +130,7 @@ '/leaderboard': Leaderboard, '/participants': Validators, '/referrals': Referrals, - '/community': ReferralProgram, + '/community': Dashboard, '/community/contributions': Contributions, '/community/all-contributions': AllContributions, '/community/referrals': Referrals, diff --git a/frontend/src/components/portal/LiveStats.svelte b/frontend/src/components/portal/LiveStats.svelte index fdd7e52d..4af11f58 100644 --- a/frontend/src/components/portal/LiveStats.svelte +++ b/frontend/src/components/portal/LiveStats.svelte @@ -48,9 +48,9 @@ category: 'validator', }, { - key: 'creator_count', + key: 'community_member_count', label: 'Community Members', - value: stats ? formatNumber(stats.creator_count ?? stats.participant_count) : '—', + value: stats ? formatNumber(stats.community_member_count ?? stats.creator_count ?? stats.participant_count) : '—', delta: '+15%', category: 'community', }, 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/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 7cd0b3a8..d452246b 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -35,16 +35,34 @@ let category = $derived($currentCategory); let isBuilder = $derived(category === 'builder'); let isValidator = $derived(category === 'validator'); + let isCommunity = $derived(category === 'community'); + let accentColor = $derived(isBuilder ? '#ee8521' : isCommunity ? '#7f52e1' : '#4f76f6'); + let valueLabel = $derived(isBuilder ? 'BP' : isCommunity ? 'CP' : 'VP'); + let dashboardTitle = $derived( + isBuilder ? "Builder's Live Dashboard" : isCommunity ? "Community Live Dashboard" : "Validator's Live Dashboard" + ); + let leaderboardTitle = $derived(isCommunity ? 'Top Community Contributors' : 'Top Contributors'); + let leaderboardSubtitle = $derived(isCommunity ? 'Highest community contribution points' : 'This month curated builds'); + let leaderboardPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/leaderboard'); + let podiumTitle = $derived(isCommunity ? 'Community Podium' : "This month's Podium"); + let podiumSubtitle = $derived( + isCommunity ? "Who's contributing most to the community?" : "Who's contributing more to GenLayer this month?" + ); + let newestTitle = $derived(isBuilder ? 'Newest Builders' : isCommunity ? 'Newest Community Contributors' : 'Newest Validators'); + let newestPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/participants'); + let highlightsPath = $derived( + isBuilder ? '/builders/all-contributions?view=highlights' : isCommunity ? '/community/all-contributions?view=highlights' : '/validators/all-contributions?view=highlights' + ); // Map newest members data to UserCardScroller entry format let newestAsEntries = $derived(newestMembers.map(m => ({ - user_name: m.name || m.user_name, - user_address: m.address || m.user_address, - profile_image_url: m.profile_image_url, - total_points: m.total_points || 0, - builder: m.builder ?? false, - validator: m.validator ?? false, - steward: m.steward ?? false, + user_name: m.name || m.user_name || m.user_details?.name, + user_address: m.address || m.user_address || m.user_details?.address, + profile_image_url: m.profile_image_url || m.user_details?.profile_image_url, + total_points: m.total_points || m.community_points || m.frozen_global_points || m.points || 0, + builder: m.builder ?? m.user_details?.builder ?? false, + validator: m.validator ?? m.user_details?.validator ?? false, + steward: m.steward ?? m.user_details?.steward ?? false, }))); // Map API stats response to StatCardRow format @@ -57,6 +75,13 @@ { value: data.contribution_count, label: 'Total Contributions', delta: data.new_contributions_count || '', iconSrc: '/assets/icons/gradient-icon-contributions.svg' }, ]; } + if (cat === 'community') { + return [ + { value: data.community_member_count ?? data.creator_count ?? data.participant_count, label: 'Community Members', delta: data.new_community_members_count || '', category: 'community' }, + { value: data.total_points, label: 'Community points earned', delta: data.new_points_count || '', iconSrc: '/assets/icons/gradient-icon-points.svg' }, + { value: data.contribution_count, label: 'Community Contributions', delta: data.new_contributions_count || '', iconSrc: '/assets/icons/gradient-icon-contributions.svg' }, + ]; + } // validator return [ { value: data.validator_count ?? data.participant_count, label: 'Validators', delta: data.new_validators_count || '', category: 'validator' }, @@ -76,23 +101,51 @@ statsLoading = false; }).catch(() => { statsLoading = false; }), - // Monthly leaderboard top 5, counted from day 1 of the current month. - leaderboardAPI.getMonthlyLeaderboardByType(cat, 5).then(res => { + // Top contributors. Community uses actual community contribution points, + // not referral points. + (cat === 'community' + ? leaderboardAPI.getCommunityContributors({ limit: 5 }) + : leaderboardAPI.getMonthlyLeaderboardByType(cat, 5) + ).then(res => { leaderboardEntries = Array.isArray(res.data) ? res.data : (res.data?.results ?? []); leaderboardLoading = false; }).catch(() => { leaderboardLoading = false; }), - // Newest members - (cat === 'builder' - ? buildersAPI.getNewestBuilders(10) - : validatorsAPI.getNewestValidators(10) - ).then(res => { - newestMembers = res.data?.results ?? res.data ?? []; - membersLoading = false; - }).catch(() => { membersLoading = false; }), - ]; + if (cat === 'community') { + promises.push( + contributionsAPI.getContributions({ limit: 20, category: cat }).then(res => { + const contributions = res.data?.results ?? res.data ?? []; + recentContributions = contributions.slice(0, 5); + + const seen = new Set(); + newestMembers = contributions.filter((contrib) => { + const address = contrib.user_details?.address || contrib.user_address || contrib.address; + if (!address || seen.has(address)) return false; + seen.add(address); + return true; + }).slice(0, 10); + + membersLoading = false; + recentLoading = false; + }).catch(() => { + membersLoading = false; + recentLoading = false; + }) + ); + } else { + promises.push( + (cat === 'builder' + ? buildersAPI.getNewestBuilders(10) + : validatorsAPI.getNewestValidators(10) + ).then(res => { + newestMembers = res.data?.results ?? res.data ?? []; + membersLoading = false; + }).catch(() => { membersLoading = false; }) + ); + } + // Validator-only fetches if (cat === 'validator') { promises.push( @@ -131,6 +184,12 @@ return dateStr; } } + + function contributionPath(contrib) { + if (isCommunity) return `/community/contribution/${contrib.id}`; + if (isBuilder) return `/builders/contribution/${contrib.id}`; + return `/badge/${contrib.id}`; + }
@@ -140,7 +199,7 @@
@@ -151,30 +210,30 @@
@@ -182,10 +241,10 @@
@@ -214,7 +273,7 @@ title="Highlighted Contributions" subtitle="Latest standout contributions" linkText="Explore all" - linkPath={isBuilder ? '/builders/all-contributions?view=highlights' : '/validators/all-contributions?view=highlights'} + linkPath={highlightsPath} />
- - {#if isValidator} -
-
- - -
+ + {#if isValidator || isCommunity} +
+ {#if isValidator} +
+ + +
+ {/if}
{#if recentLoading} @@ -268,13 +329,13 @@
{#each recentContributions as contrib} + {/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/lib/api.js b/frontend/src/lib/api.js index 466b7cec..fc6bb823 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); @@ -271,11 +272,25 @@ export const updateUserProfile = async (data) => { export const featuredAPI = { getFeatured: (params) => api.get('/featured/', { params }), getHero: () => api.get('/featured/', { params: { type: 'hero' } }), - getBuilds: () => api.get('/featured/', { params: { type: 'build' } }), 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
{/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/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/lib/api.js b/frontend/src/lib/api.js index fc6bb823..d12e8ad3 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -105,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}`), @@ -117,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/') diff --git a/frontend/src/routes/Community.svelte b/frontend/src/routes/Community.svelte index de695b74..fa45b9d4 100644 --- a/frontend/src/routes/Community.svelte +++ b/frontend/src/routes/Community.svelte @@ -2,7 +2,6 @@ import { onMount } from 'svelte'; import { push } from 'svelte-spa-router'; import Avatar from '../components/Avatar.svelte'; - import Icon from '../components/Icons.svelte'; import { leaderboardAPI } from '../lib/api'; const PAGE_SIZE = 50; @@ -23,7 +22,7 @@ error = null; offset = 0; - const response = await leaderboardAPI.getCommunity({ limit: PAGE_SIZE, offset: 0 }); + const response = await leaderboardAPI.getLeaderboard({ type: 'community', limit: PAGE_SIZE, offset: 0 }); const data = response.data; communityMembers = data.results || []; @@ -40,7 +39,7 @@ async function loadMore() { try { loadingMore = true; - const response = await leaderboardAPI.getCommunity({ limit: PAGE_SIZE, offset }); + const response = await leaderboardAPI.getLeaderboard({ type: 'community', limit: PAGE_SIZE, offset }); const data = response.data; const newResults = data.results || []; @@ -97,7 +96,7 @@

No Community Members Yet

-

Be the first to earn referral points by inviting people to the GenLayer ecosystem!

+

Be the first to earn community points through community contributions.

{:else}
@@ -112,7 +111,7 @@ Participant - Total Referral Points + Community Points @@ -143,17 +142,7 @@
- {member.total_points} -
-
- - {member.referral_builder_points} -
-
- - {member.referral_validator_points} -
-
+ {member.community_points ?? member.total_points}
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index d452246b..2cde3c73 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -43,7 +43,7 @@ ); let leaderboardTitle = $derived(isCommunity ? 'Top Community Contributors' : 'Top Contributors'); let leaderboardSubtitle = $derived(isCommunity ? 'Highest community contribution points' : 'This month curated builds'); - let leaderboardPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/leaderboard'); + let leaderboardPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/leaderboard' : '/validators/leaderboard'); let podiumTitle = $derived(isCommunity ? 'Community Podium' : "This month's Podium"); let podiumSubtitle = $derived( isCommunity ? "Who's contributing most to the community?" : "Who's contributing more to GenLayer this month?" @@ -104,7 +104,7 @@ // Top contributors. Community uses actual community contribution points, // not referral points. (cat === 'community' - ? leaderboardAPI.getCommunityContributors({ limit: 5 }) + ? leaderboardAPI.getLeaderboard({ type: 'community', limit: 5 }) : leaderboardAPI.getMonthlyLeaderboardByType(cat, 5) ).then(res => { leaderboardEntries = Array.isArray(res.data) ? res.data : (res.data?.results ?? []); diff --git a/frontend/src/routes/Leaderboard.svelte b/frontend/src/routes/Leaderboard.svelte index c0693a99..24a81498 100644 --- a/frontend/src/routes/Leaderboard.svelte +++ b/frontend/src/routes/Leaderboard.svelte @@ -106,7 +106,7 @@ return leaderboardAPI.getLeaderboard(params); } - return leaderboardAPI.getLeaderboardByType(category, 'asc', params); + return leaderboardAPI.getLeaderboard({ type: category, order: 'asc', ...params }); } async function fetchLeaderboard(page = 1, { reset = false } = {}) { @@ -119,17 +119,24 @@ pageLoading = !shouldShowFullLoader; error = null; - const tableOffset = (activeSearch ? 0 : PODIUM_SIZE) + ((page - 1) * PAGE_SIZE); - const [podiumResponse, tableResponse] = await Promise.all([ - fetchEntries(category, { limit: PODIUM_SIZE, offset: 0 }), - fetchEntries(category, { limit: REQUEST_SIZE, offset: tableOffset }), - ]); + const podiumResponse = await fetchEntries(category, { limit: PODIUM_SIZE, offset: 0 }); if (requestId !== requestSequence) return; if (category !== ($currentCategory || 'global')) return; - const tableData = tableResponse.data || []; - podiumEntries = podiumResponse.data || []; + const podiumData = podiumResponse.data?.results || podiumResponse.data || []; + const availableForPodium = podiumResponse.data?.count ?? podiumData.length; + const tableOffset = activeSearch || availableForPodium < PODIUM_SIZE + ? ((page - 1) * PAGE_SIZE) + : PODIUM_SIZE + ((page - 1) * PAGE_SIZE); + + const tableResponse = await fetchEntries(category, { limit: REQUEST_SIZE, offset: tableOffset }); + + if (requestId !== requestSequence) return; + if (category !== ($currentCategory || 'global')) return; + + const tableData = tableResponse.data?.results || tableResponse.data || []; + podiumEntries = podiumData; leaderboard = tableData.slice(0, PAGE_SIZE); currentPage = page; hasNextPage = tableData.length > PAGE_SIZE; diff --git a/frontend/src/routes/LegacyReferralRedirect.svelte b/frontend/src/routes/LegacyReferralRedirect.svelte new file mode 100644 index 00000000..d12dba99 --- /dev/null +++ b/frontend/src/routes/LegacyReferralRedirect.svelte @@ -0,0 +1,12 @@ + + +
+ Redirecting to referrals... +
diff --git a/frontend/src/routes/Profile.svelte b/frontend/src/routes/Profile.svelte index b1ba2b4d..37904f91 100644 --- a/frontend/src/routes/Profile.svelte +++ b/frontend/src/routes/Profile.svelte @@ -243,7 +243,7 @@ const response = await creatorAPI.joinAsCreator(); if (response.status === 201 || response.status === 200) { showSuccess( - "You are now a Community Member! Start growing the community through referrals.", + "You are now a Community Member! Start contributing to the community.", ); participant = await getCurrentUser(); } diff --git a/frontend/src/routes/ReferralProgram.svelte b/frontend/src/routes/ReferralProgram.svelte index 71dd1401..f560fed6 100644 --- a/frontend/src/routes/ReferralProgram.svelte +++ b/frontend/src/routes/ReferralProgram.svelte @@ -126,9 +126,9 @@ $effect(() => { setPageMeta({ title: 'Referral Program', - description: 'Invite Builders, Validators, and Community members to GenLayer. Earn 10% of all points from their successful contributions — forever, with no cap.', + description: 'Invite builders and validators to GenLayer. Earn 10% of eligible builder and validator contribution points — forever, with no cap.', image: 'https://portal.genlayer.foundation/assets/referral_og_image.png', - url: 'https://portal.genlayer.foundation/#/community', + url: 'https://portal.genlayer.foundation/#/referral-program', }); return () => resetPageMeta(); }); @@ -184,9 +184,9 @@ function handleGetReferral() { if ($authState.isAuthenticated) { - push('/community/referrals'); + push('/referrals'); } else { - sessionStorage.setItem('redirectAfterLogin', '/community/referrals'); + sessionStorage.setItem('redirectAfterLogin', '/referrals'); const authButton = document.querySelector('[data-auth-button]'); if (authButton) authButton.click(); } @@ -209,10 +209,10 @@ Referral Program

- Invite Builders, Validators & Community members + Invite Builders & Validators

- Earn 10% of the points of their successful contributions + Earn 10% of eligible builder and validator contribution points

diff --git a/frontend/src/routes/Referrals.svelte b/frontend/src/routes/Referrals.svelte index 5ac3d2f8..a013b19f 100644 --- a/frontend/src/routes/Referrals.svelte +++ b/frontend/src/routes/Referrals.svelte @@ -109,7 +109,7 @@ error = null; if (query) { - const response = await leaderboardAPI.getCommunity({ limit: SEARCH_LIMIT, offset: 0 }); + const response = await leaderboardAPI.getReferrals({ limit: SEARCH_LIMIT, offset: 0 }); if (requestId !== requestSequence) return; const entries = (response.data?.results || []) @@ -124,7 +124,7 @@ return; } - const podiumResponse = await leaderboardAPI.getCommunity({ limit: PODIUM_SIZE, offset: 0 }); + const podiumResponse = await leaderboardAPI.getReferrals({ limit: PODIUM_SIZE, offset: 0 }); if (requestId !== requestSequence) return; const topEntries = (podiumResponse.data?.results || []).map((member, index) => normalizeReferral(member, index + 1)); @@ -133,7 +133,7 @@ ? PODIUM_SIZE + ((page - 1) * PAGE_SIZE) : ((page - 1) * PAGE_SIZE); - const tableResponse = await leaderboardAPI.getCommunity({ limit: REQUEST_SIZE, offset: tableOffset }); + const tableResponse = await leaderboardAPI.getReferrals({ limit: REQUEST_SIZE, offset: tableOffset }); if (requestId !== requestSequence) return; const tableData = (tableResponse.data?.results || []).map((member, index) => normalizeReferral(member, tableOffset + index + 1)); @@ -301,7 +301,7 @@ Referrer - Points + Referral Points Breakdown diff --git a/frontend/src/tests/setupTests.js b/frontend/src/tests/setupTests.js index 3b8899ac..dc6807c9 100644 --- a/frontend/src/tests/setupTests.js +++ b/frontend/src/tests/setupTests.js @@ -146,6 +146,8 @@ vi.mock('../lib/api', () => { getParticipantCount: vi.fn().mockResolvedValue({ data: { count: 10 } }), getCurrentUser: vi.fn().mockResolvedValue(mockUserData.data), updateUserProfile: vi.fn().mockResolvedValue(mockUserData.data), + getReferrals: vi.fn().mockResolvedValue({ data: { total_referrals: 0, builder_points: 0, validator_points: 0, referrals: [] } }), + getReferralPoints: vi.fn().mockResolvedValue({ data: { builder_points: 0, validator_points: 0 } }), searchUsers: vi.fn().mockResolvedValue({ data: { results: [] } }) }, contributionsAPI: { @@ -164,6 +166,8 @@ vi.mock('../lib/api', () => { getMultiplierPeriods: vi.fn().mockResolvedValue({ data: { results: [] } }), getStats: vi.fn().mockResolvedValue(mockStatsData), getMonthlyLeaderboardByType: vi.fn().mockResolvedValue(mockLeaderboardData), + getCommunity: vi.fn().mockResolvedValue({ data: { results: [] } }), + getReferrals: vi.fn().mockResolvedValue({ data: { results: [] } }), getWaitlistTop: vi.fn().mockResolvedValue({ data: [] }), getTrending: vi.fn().mockResolvedValue({ data: [] }) }, From 6930f54cea4bfc3d8848e461b75ca9e4eb5f35ee Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 24 May 2026 17:55:44 +0200 Subject: [PATCH 06/12] Add steward controls for accepted submissions --- backend/contributions/serializers.py | 61 +++++++- .../tests/test_steward_permissions.py | 81 ++++++++++- backend/contributions/views.py | 85 ++++++++++- .../src/components/StewardSearchBar.svelte | 5 +- frontend/src/lib/api.js | 7 + frontend/src/lib/searchParser.js | 4 +- frontend/src/lib/searchToParams.js | 28 ++++ frontend/src/routes/StewardSubmissions.svelte | 132 +++++++++++++++++- 8 files changed, 394 insertions(+), 9 deletions(-) diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index e3f01775..0e533a51 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -844,6 +844,34 @@ 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) + 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 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() @@ -995,10 +1023,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 diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index 8f8c15d3..e4e4b345 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,83 @@ 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_points_validation(self): """Test that points are validated within contribution type limits.""" @@ -273,4 +350,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..caa0a422 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) @@ -999,6 +1000,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 +1087,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 +1304,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 +1469,67 @@ 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 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'] + ) + + 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), + }, + ) + + 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.""" diff --git a/frontend/src/components/StewardSearchBar.svelte b/frontend/src/components/StewardSearchBar.svelte index 5f2f18c1..76ac4247 100644 --- a/frontend/src/components/StewardSearchBar.svelte +++ b/frontend/src/components/StewardSearchBar.svelte @@ -7,7 +7,7 @@ stewardsList = [], templates = [], missions = [], - placeholder = 'type:blog-post assigned:me mission:name...', + placeholder = 'type:blog-post reviewed:me mission:name...', onSearch = () => {} } = $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/lib/api.js b/frontend/src/lib/api.js index fc6bb823..3a8aaccf 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -199,6 +199,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, 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/'), diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js index 44e68aea..743ded8e 100644 --- a/frontend/src/lib/searchParser.js +++ b/frontend/src/lib/searchParser.js @@ -6,6 +6,7 @@ * - type:contribution-type-name * - from:username * - assigned:me|unassigned|steward-name + * - reviewed:me|steward-name * - exclude:text (multiple allowed) * - include:text (multiple allowed) * - has:url|evidence|proposal|appeal @@ -19,7 +20,7 @@ * Quoted values: tag:"value with spaces" */ -const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'sort', 'confidence', 'template', 'proposal', 'mission']; +const SINGLE_VALUE_TAGS = ['status', 'type', 'category', 'from', 'assigned', 'reviewed', 'sort', 'confidence', 'template', 'proposal', 'mission']; const MULTI_VALUE_TAGS = ['exclude', 'include', 'has', 'no', 'is', 'not']; const NUMERIC_TAGS = ['min-contributions']; @@ -112,6 +113,7 @@ export function parseSearch(query) { category: null, from: null, assigned: null, + reviewed: null, exclude: [], include: [], has: [], diff --git a/frontend/src/lib/searchToParams.js b/frontend/src/lib/searchToParams.js index 4790414a..cd1a4c04 100644 --- a/frontend/src/lib/searchToParams.js +++ b/frontend/src/lib/searchToParams.js @@ -109,6 +109,34 @@ export function searchToParams(parsed, options = {}) { } } + // reviewed → reviewed_by (the steward who actually accepted/rejected/requested info) + if (filters.reviewed) { + const val = filters.reviewed.value.toLowerCase(); + let reviewedValue = null; + + if (val === 'me' && currentUserId) { + reviewedValue = currentUserId; + } else { + const steward = stewardsList.find(s => + String(s.user_id) === filters.reviewed.value || + s.name?.toLowerCase().includes(val) || + s.user_name?.toLowerCase().includes(val) || + s.address?.toLowerCase().includes(val) + ); + if (steward) { + reviewedValue = steward.user_id; + } + } + + if (reviewedValue) { + if (filters.reviewed.negated) { + params.exclude_reviewed_by = reviewedValue; + } else { + params.reviewed_by = reviewedValue; + } + } + } + // exclude → exclude_content (comma-separated for multiple values) if (filters.exclude && filters.exclude.length > 0) { params.exclude_content = filters.exclude.join(','); diff --git a/frontend/src/routes/StewardSubmissions.svelte b/frontend/src/routes/StewardSubmissions.svelte index dbcd760c..018cd3c6 100644 --- a/frontend/src/routes/StewardSubmissions.svelte +++ b/frontend/src/routes/StewardSubmissions.svelte @@ -31,6 +31,8 @@ let searchQuery = $state(''); let stewardsList = $state([]); let assigningSubmissions = $state(new Set()); // Track which submissions are being assigned + let acceptedEdits = $state({}); + let updatingAccepted = $state(new Set()); // Review states let processingSubmissions = $state(new Set()); @@ -277,7 +279,11 @@ highlight_description: '' }; } + if (sub.state === 'accepted' && sub.contribution && !acceptedEdits[sub.id]) { + acceptedEdits[sub.id] = getAcceptedEditData(sub); + } }); + acceptedEdits = { ...acceptedEdits }; loadVisibleNotes(loadedSubmissions, requestId); } catch (err) { @@ -442,6 +448,13 @@ const idx = submissions.findIndex(s => s.id === submissionId); if (idx !== -1) { submissions[idx] = updatedSub; + if (updatedSub.state === 'accepted' && updatedSub.contribution) { + acceptedEdits[submissionId] = getAcceptedEditData(updatedSub); + acceptedEdits = { ...acceptedEdits }; + } else if (acceptedEdits[submissionId]) { + delete acceptedEdits[submissionId]; + acceptedEdits = { ...acceptedEdits }; + } submissions = [...submissions]; } // Reload notes since review creates a CRM note @@ -458,6 +471,65 @@ } } + function getAcceptedEditData(submission) { + const highlight = submission.contribution?.highlight; + return { + points: submission.contribution?.points ?? 0, + highlight_title: highlight?.title || '', + highlight_description: highlight?.description || '' + }; + } + + function canEditAcceptedSubmission(submission) { + return permissionsMap[submission.contribution_type]?.includes('accept'); + } + + async function handleAcceptedUpdate(submissionId) { + const data = acceptedEdits[submissionId]; + if (!data) return; + + const highlightTitle = data.highlight_title?.trim() || ''; + const highlightDescription = data.highlight_description?.trim() || ''; + const createHighlight = Boolean(highlightTitle || highlightDescription); + const points = parseInt(data.points); + + if (Number.isNaN(points)) { + showError('Please enter a valid point value'); + return; + } + + if (createHighlight && (!highlightTitle || !highlightDescription)) { + showError('Feature title and description are both required'); + return; + } + + updatingAccepted.add(submissionId); + updatingAccepted = new Set(updatingAccepted); + + try { + const response = await stewardAPI.updateAcceptedSubmission(submissionId, { + points, + create_highlight: createHighlight, + highlight_title: highlightTitle, + highlight_description: highlightDescription + }); + + const idx = submissions.findIndex(s => s.id === submissionId); + if (idx !== -1) { + submissions[idx] = response.data; + acceptedEdits[submissionId] = getAcceptedEditData(response.data); + submissions = [...submissions]; + acceptedEdits = { ...acceptedEdits }; + } + showSuccess('Accepted post updated'); + } catch (err) { + showError('Failed to update accepted post: ' + (err.response?.data?.detail || err.message)); + } finally { + updatingAccepted.delete(submissionId); + updatingAccepted = new Set(updatingAccepted); + } + } + async function handlePropose(submissionId, data) { processingSubmissions.add(submissionId); processingSubmissions = new Set(processingSubmissions); @@ -617,7 +689,7 @@ {templates} {missions} onSearch={handleSearchChange} - placeholder="type:blog-post -mission:name has:appeal is:resubmitted..." + placeholder="type:blog-post reviewed:me -mission:name has:appeal..." /> @@ -772,6 +844,64 @@ {/if} + {#if submission.state === 'accepted' && submission.contribution && acceptedEdits[submission.id] && canEditAcceptedSubmission(submission)} +
+
+
+ + +

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

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + Date: Sun, 24 May 2026 17:56:26 +0200 Subject: [PATCH 07/12] Update dashboard contribution sections --- .../portal/PortalContributionCard.svelte | 4 +- frontend/src/routes/Dashboard.svelte | 205 ++++++++++++++---- 2 files changed, 162 insertions(+), 47 deletions(-) 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/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 2cde3c73..c81b73a4 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -10,6 +10,7 @@ import RankedList from '../components/ui/RankedList.svelte'; import UserCardScroller from '../components/ui/UserCardScroller.svelte'; import PortalHighlights from '../components/portal/PortalHighlights.svelte'; + import PortalContributionCard from '../components/portal/PortalContributionCard.svelte'; import CTASection from '../components/ui/CTASection.svelte'; import Podium from '../components/ui/Podium.svelte'; @@ -31,6 +32,9 @@ let waitlistLoading = $state(true); let trendingLoading = $state(true); let recentLoading = $state(true); + let recentSlider = $state(null); + let canRecentLeft = $state(false); + let canRecentRight = $state(false); let category = $derived($currentCategory); let isBuilder = $derived(category === 'builder'); @@ -42,11 +46,21 @@ isBuilder ? "Builder's Live Dashboard" : isCommunity ? "Community Live Dashboard" : "Validator's Live Dashboard" ); let leaderboardTitle = $derived(isCommunity ? 'Top Community Contributors' : 'Top Contributors'); - let leaderboardSubtitle = $derived(isCommunity ? 'Highest community contribution points' : 'This month curated builds'); + let leaderboardSubtitle = $derived( + isCommunity + ? 'This month community contributions' + : isValidator + ? 'All-time validator contributors' + : 'This month curated builds' + ); let leaderboardPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/leaderboard' : '/validators/leaderboard'); - let podiumTitle = $derived(isCommunity ? 'Community Podium' : "This month's Podium"); + let podiumTitle = $derived(isValidator ? 'All-time Podium' : "This month's Podium"); let podiumSubtitle = $derived( - isCommunity ? "Who's contributing most to the community?" : "Who's contributing more to GenLayer this month?" + isCommunity + ? "Who's contributing most to the community this month?" + : isValidator + ? "Who's contributed most to GenLayer?" + : "Who's contributing more to GenLayer this month?" ); let newestTitle = $derived(isBuilder ? 'Newest Builders' : isCommunity ? 'Newest Community Contributors' : 'Newest Validators'); let newestPath = $derived(isBuilder ? '/builders/leaderboard' : isCommunity ? '/community/all-contributions' : '/validators/participants'); @@ -78,8 +92,8 @@ if (cat === 'community') { return [ { value: data.community_member_count ?? data.creator_count ?? data.participant_count, label: 'Community Members', delta: data.new_community_members_count || '', category: 'community' }, - { value: data.total_points, label: 'Community points earned', delta: data.new_points_count || '', iconSrc: '/assets/icons/gradient-icon-points.svg' }, - { value: data.contribution_count, label: 'Community Contributions', delta: data.new_contributions_count || '', iconSrc: '/assets/icons/gradient-icon-contributions.svg' }, + { value: data.total_points, label: 'Community points earned', delta: data.new_points_count || '', category: 'genlayer', hexCategory: 'community' }, + { value: data.contribution_count, label: 'Community Contributions', delta: data.new_contributions_count || '', category: 'community' }, ]; } // validator @@ -101,11 +115,11 @@ statsLoading = false; }).catch(() => { statsLoading = false; }), - // Top contributors. Community uses actual community contribution points, - // not referral points. - (cat === 'community' - ? leaderboardAPI.getLeaderboard({ type: 'community', limit: 5 }) - : leaderboardAPI.getMonthlyLeaderboardByType(cat, 5) + // Top contributors. Validator dashboard is intentionally all-time; + // builder and community dashboards use current-month contribution totals. + (cat === 'validator' + ? leaderboardAPI.getLeaderboard({ type: 'validator', order: 'asc', limit: 5 }) + : leaderboardAPI.getMonthlyLeaderboardByType(cat, 5) ).then(res => { leaderboardEntries = Array.isArray(res.data) ? res.data : (res.data?.results ?? []); leaderboardLoading = false; @@ -190,6 +204,27 @@ if (isBuilder) return `/builders/contribution/${contrib.id}`; return `/badge/${contrib.id}`; } + + function updateRecentArrows() { + if (!recentSlider) return; + const { scrollLeft, scrollWidth, clientWidth } = recentSlider; + canRecentLeft = scrollLeft > 4; + canRecentRight = scrollLeft + clientWidth < scrollWidth - 4; + } + + function scrollRecent(direction) { + if (!recentSlider) return; + recentSlider.scrollBy({ + left: direction * Math.round(recentSlider.clientWidth * 0.8), + behavior: 'smooth', + }); + } + + $effect(() => { + if (!recentSlider) return; + void recentContributions.length; + requestAnimationFrame(updateRecentArrows); + });
@@ -309,46 +344,115 @@ linkText="View all" linkPath={isCommunity ? '/community/all-contributions' : '/validators/contributions'} /> -
- {#if recentLoading} -
- {#each [1, 2, 3, 4, 5] as _} -
-
-
-
-
+ {#if isCommunity} +
+ {#if recentLoading} +
+ {#each [1, 2, 3, 4, 5] as _} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if recentContributions.length === 0} +
+ No recent contributions +
+ {:else} +
+ {#each recentContributions as contribution (contribution.id)} +
+
-
-
- {/each} -
- {:else if recentContributions.length === 0} -
No recent contributions
- {:else} -
- {#each recentContributions as contrib} + {/each} +
+ {#if canRecentLeft} + {/if} + {#if canRecentRight} + + {/if} + {/if} +
+ {:else} +
+ {#if recentLoading} +
+ {#each [1, 2, 3, 4, 5] as _} +
+
+
+
+
- {/if} -
-

{contrib.contribution_type_name || 'Contribution'}

-

{contrib.user_details?.name || contrib.user_name || 'Anonymous'}

+
- {formatContribDate(contrib.contribution_date)} - - {/each} -
- {/if} -
+ {/each} +
+ {:else if recentContributions.length === 0} +
No recent contributions
+ {:else} +
+ {#each recentContributions as contrib} + + {/each} +
+ {/if} +
+ {/if}
{/if} @@ -402,3 +506,14 @@ {/if}
+ + From e4eb5fbddc1535a32a9fe498bcd0d2df9a944194 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 24 May 2026 18:00:37 +0200 Subject: [PATCH 08/12] Support community monthly leaderboard --- backend/leaderboard/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index f5b9e16f..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, From c8dd4f1786612c6ce0e04d5437561ad2143e72e8 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Sun, 24 May 2026 18:33:52 +0200 Subject: [PATCH 09/12] Add hero banner placement targeting --- backend/contributions/admin.py | 33 ++++++++- .../0061_featuredcontent_hero_placements.py | 26 +++++++ backend/contributions/models.py | 50 +++++++++++++ backend/contributions/serializers.py | 1 + .../tests/test_featured_content_api.py | 71 +++++++++++++++++++ backend/contributions/views.py | 19 +++++ .../src/components/portal/HeroBanner.svelte | 65 ++++++++++++----- frontend/src/lib/api.js | 2 +- 8 files changed, 246 insertions(+), 21 deletions(-) create mode 100644 backend/contributions/migrations/0061_featuredcontent_hero_placements.py create mode 100644 backend/contributions/tests/test_featured_content_api.py 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 00847820..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). @@ -940,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) @@ -961,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) @@ -975,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 0e533a51..94d9de16 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -1237,6 +1237,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_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/views.py b/backend/contributions/views.py index ad0b3df8..9d734f8b 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -2282,6 +2282,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/frontend/src/components/portal/HeroBanner.svelte b/frontend/src/components/portal/HeroBanner.svelte index 255aaeda..b77fe140 100644 --- a/frontend/src/components/portal/HeroBanner.svelte +++ b/frontend/src/components/portal/HeroBanner.svelte @@ -11,7 +11,37 @@ let loading = $state(true); let hero = $derived(heroes.length > 0 ? heroes[currentIndex] : null); - let isValidator = $derived(category === 'validator'); + let placement = $derived(showNewsLink ? 'overview' : category); + let showDashboardFallback = $derived(!showNewsLink && !loading && !hero); + let fallbackGradient = $derived( + category === 'validator' + ? 'linear-gradient(135deg, #1f56f2 0%, #4f76f6 48%, #b8c7ff 100%)' + : category === 'community' + ? 'linear-gradient(135deg, #6f35d7 0%, #7f52e1 48%, #d6c3ff 100%)' + : 'linear-gradient(135deg, #d96816 0%, #ee8521 48%, #ffd1a3 100%)' + ); + let loadingGradient = $derived(showNewsLink ? 'linear-gradient(to right, #c4bfe8, #eae9f3)' : fallbackGradient); + let fallbackEyebrow = $derived( + category === 'validator' ? 'Validator Dashboard' : category === 'community' ? 'Community Dashboard' : 'Builder Dashboard' + ); + let fallbackTitle = $derived( + category === 'validator' ? 'Join Validator Journey' : category === 'community' ? 'Community Journey' : 'Builder Journey' + ); + let fallbackDescription = $derived( + category === 'validator' + ? 'Track validator progress, waitlist activity, and network participation across the GenLayer ecosystem.' + : category === 'community' + ? 'Follow community contributions, active members, and the latest progress across GenLayer.' + : 'Discover builder activity, featured contributions, and the teams shaping GenLayer.' + ); + let fallbackActionLabel = $derived(category === 'validator' ? 'Join the waitlist' : 'View contributions'); + let fallbackActionPath = $derived( + category === 'validator' + ? '/validators/waitlist/join' + : category === 'community' + ? '/community/all-contributions' + : '/builders/all-contributions' + ); function startAutoAdvance() { stopAutoAdvance(); @@ -30,13 +60,8 @@ } onMount(async () => { - if (isValidator) { - // Validator uses a static banner, no API fetch needed - loading = false; - return; - } try { - const response = await featuredAPI.getHero(); + const response = await featuredAPI.getHero({ placement }); if (response.data && response.data.length > 0) { heroes = response.data; startAutoAdvance(); @@ -52,16 +77,18 @@ stopAutoAdvance(); }); - let bgImage = $derived(hero?.hero_image_url || '/assets/hero-bg.png'); let projectLink = $derived(hero?.link || hero?.url || '#'); -{#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}
{ // Featured content API export const featuredAPI = { getFeatured: (params) => api.get('/featured/', { params }), - getHero: () => api.get('/featured/', { params: { type: 'hero' } }), + getHero: (params = {}) => api.get('/featured/', { params: { type: 'hero', ...params } }), getCommunity: () => api.get('/featured/', { params: { type: 'community' } }), getValidatorsStewards: () => api.get('/featured/', { params: { type: 'validator_steward' } }), }; From 8b34ed08f73f7e7e14ba9007f982c2bfb55e0f84 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 08:14:48 +0200 Subject: [PATCH 10/12] Improve accepted contribution edit panel --- frontend/src/components/SubmissionCard.svelte | 108 +++++++++++++++++- frontend/src/routes/StewardSubmissions.svelte | 73 +++--------- 2 files changed, 122 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/SubmissionCard.svelte b/frontend/src/components/SubmissionCard.svelte index 480c7e65..68ff4454 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)} + 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/routes/StewardSubmissions.svelte b/frontend/src/routes/StewardSubmissions.svelte index 018cd3c6..7ebf19aa 100644 --- a/frontend/src/routes/StewardSubmissions.svelte +++ b/frontend/src/routes/StewardSubmissions.svelte @@ -484,6 +484,16 @@ return permissionsMap[submission.contribution_type]?.includes('accept'); } + function handleAcceptedEditChange(submissionId, field, value) { + if (!acceptedEdits[submissionId]) return; + + acceptedEdits[submissionId] = { + ...acceptedEdits[submissionId], + [field]: value + }; + acceptedEdits = { ...acceptedEdits }; + } + async function handleAcceptedUpdate(submissionId) { const data = acceptedEdits[submissionId]; if (!data) return; @@ -844,64 +854,6 @@
{/if} - {#if submission.state === 'accepted' && submission.contribution && acceptedEdits[submission.id] && canEditAcceptedSubmission(submission)} -
-
-
- - -

- Final: {submission.contribution.frozen_global_points ?? submission.contribution.points ?? 0} pts -

-
- -
-
- - -
-
- - -
-
- - -
-
- {/if} -
{/each} From 3f1b79ba54070013d8c9f9b8cee675a693acc63c Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 08:19:13 +0200 Subject: [PATCH 11/12] Fix accepted contribution highlight updates --- backend/contributions/serializers.py | 5 +++ .../tests/test_steward_permissions.py | 34 +++++++++++++++++++ backend/contributions/views.py | 5 ++- frontend/src/components/SubmissionCard.svelte | 2 +- frontend/src/lib/api.js | 2 +- frontend/src/routes/StewardSubmissions.svelte | 7 ++-- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index 94d9de16..b98bdaa3 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -848,6 +848,7 @@ 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) @@ -860,6 +861,10 @@ def validate(self, data): }) 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.' diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index e4e4b345..a5828f0b 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -293,6 +293,40 @@ def test_steward_can_feature_accepted_submission(self): ) 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.""" diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 9d734f8b..1493e091 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -1479,7 +1479,7 @@ def review(self, request, pk=None): @action(detail=True, methods=['post'], url_path='update-accepted') @transaction.atomic def update_accepted(self, request, pk=None): - """Correct points or add/update a highlight for an accepted submission.""" + """Correct points or add/update/remove a highlight for an accepted submission.""" submission = self.get_object() contribution = submission.converted_contribution @@ -1519,6 +1519,8 @@ def update_accepted(self, request, pk=None): 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, @@ -1529,6 +1531,7 @@ def update_accepted(self, request, pk=None): 'action': 'update_accepted', 'points': points, 'create_highlight': serializer.validated_data.get('create_highlight', False), + 'remove_highlight': serializer.validated_data.get('remove_highlight', False), }, ) diff --git a/frontend/src/components/SubmissionCard.svelte b/frontend/src/components/SubmissionCard.svelte index 68ff4454..43f58f89 100644 --- a/frontend/src/components/SubmissionCard.svelte +++ b/frontend/src/components/SubmissionCard.svelte @@ -1169,7 +1169,7 @@ type="number" min="0" value={acceptedEdit.points} - oninput={(event) => onAcceptedEditChange?.(submission.id, 'points', event.currentTarget.value)} + oninput={(event) => 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" /> diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index e114d7d7..544bf96f 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -216,7 +216,7 @@ export const stewardAPI = { /** * Correct points or feature an already accepted submission. * @param {string | number} id - * @param {{ points: number, create_highlight?: boolean, highlight_title?: string, highlight_description?: string }} data + * @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), diff --git a/frontend/src/routes/StewardSubmissions.svelte b/frontend/src/routes/StewardSubmissions.svelte index 7ebf19aa..4ba17bef 100644 --- a/frontend/src/routes/StewardSubmissions.svelte +++ b/frontend/src/routes/StewardSubmissions.svelte @@ -501,10 +501,10 @@ const highlightTitle = data.highlight_title?.trim() || ''; const highlightDescription = data.highlight_description?.trim() || ''; const createHighlight = Boolean(highlightTitle || highlightDescription); - const points = parseInt(data.points); + const points = Number(data.points); - if (Number.isNaN(points)) { - showError('Please enter a valid point value'); + if (!Number.isFinite(points) || !Number.isInteger(points)) { + showError('Please enter a valid whole-number point value'); return; } @@ -520,6 +520,7 @@ const response = await stewardAPI.updateAcceptedSubmission(submissionId, { points, create_highlight: createHighlight, + remove_highlight: Boolean(submissions.find(s => s.id === submissionId)?.contribution?.highlight && !createHighlight), highlight_title: highlightTitle, highlight_description: highlightDescription }); From e404668f817de5b7fae1d0cb2272b5c92463ce99 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 08:59:34 +0200 Subject: [PATCH 12/12] Fix contribution explorer filters --- .../tests/test_public_explorer_filters.py | 147 ++++++++++++++++++ backend/contributions/views.py | 18 ++- frontend/src/routes/AllContributions.svelte | 83 ++++++---- 3 files changed, 216 insertions(+), 32 deletions(-) create mode 100644 backend/contributions/tests/test_public_explorer_filters.py 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/views.py b/backend/contributions/views.py index 1493e091..461ba05a 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -309,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): @@ -2210,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) diff --git a/frontend/src/routes/AllContributions.svelte b/frontend/src/routes/AllContributions.svelte index 051ea27d..cfcabc14 100644 --- a/frontend/src/routes/AllContributions.svelte +++ b/frontend/src/routes/AllContributions.svelte @@ -72,12 +72,13 @@ let contributionsError = $state(null); // Catalogs (for filter dropdowns) - let allTypes = $state([]); // submittable types only + let allTypes = $state([]); // public explorer types let allMissions = $state([]); let typesLoading = $state(false); - // Cached names for filter chips when URL targets a non-submittable type + // Cached names for filter chips when URL targets a type/mission outside the loaded catalog let activeTypeName = $state(''); + let activeTypeIsPublic = $state(null); let activeMissionName = $state(''); let participantDetails = $state(null); @@ -87,25 +88,41 @@ let typesForCategory = $derived( category === 'all' ? allTypes : allTypes.filter(t => t.category === category) ); - // Surface the URL-applied type as a synthetic option when it's not in the submittable list + // Surface the URL-applied type as a synthetic option when it's not in the public list let typesForDropdown = $derived.by(() => { if (!typeId) return typesForCategory; if (typesForCategory.some(t => String(t.id) === String(typeId))) return typesForCategory; - if (!activeTypeName) return typesForCategory; - return [{ id: typeId, name: activeTypeName }, ...typesForCategory]; + return [{ id: typeId, name: activeTypeName || 'Selected type' }, ...typesForCategory]; + }); + let missionsForType = $derived.by(() => { + if (!typeId) return []; + const filtered = allMissions.filter(m => String(m.contribution_type) === String(typeId)); + if ( + missionId && + !filtered.some(m => String(m.id) === String(missionId)) && + activeMissionName + ) { + return [{ id: missionId, name: activeMissionName, contribution_type: typeId }, ...filtered]; + } + return filtered; }); - let missionsForType = $derived( - typeId ? allMissions.filter(m => String(m.contribution_type) === String(typeId)) : [] - ); let hasActiveFilters = $derived( - category !== routeCategory || !!typeId || !!missionId || !!participantQuery || sortBy !== '-contribution_date' + category !== routeCategory || + !!typeId || + !!missionId || + !!participantQuery || + sortBy !== '-contribution_date' ); function looksLikeAddress(s) { return s && s.trim().toLowerCase().startsWith('0x'); } + function isPublicContributionType(type) { + return type?.is_submittable === true || type?.show_in_contributions === true; + } + // === Search syntax === function parseSearchInput(input) { let sortValue = '-contribution_date'; @@ -174,14 +191,13 @@ } // === Single helper: reset paging, sync URL, refetch === - function resetAndLoad() { + async function resetAndLoad() { highlightsPage = 1; allPage = 1; updateUrl(); + await Promise.all([resolveTypeName(), resolveMissionName()]); loadHighlights(); loadAllContributions(); - resolveTypeName(); - resolveMissionName(); } // === Filter actions === @@ -191,6 +207,7 @@ typeId = ''; missionId = ''; activeTypeName = ''; + activeTypeIsPublic = null; activeMissionName = ''; resetAndLoad(); } @@ -202,6 +219,7 @@ function onTypeChange() { missionId = ''; activeTypeName = ''; + activeTypeIsPublic = null; activeMissionName = ''; resetAndLoad(); } @@ -231,6 +249,7 @@ participantQuery = ''; sortBy = '-contribution_date'; activeTypeName = ''; + activeTypeIsPublic = null; activeMissionName = ''; participantDetails = null; searchInput = ''; @@ -240,6 +259,7 @@ typeId = ''; missionId = ''; activeTypeName = ''; + activeTypeIsPublic = null; activeMissionName = ''; resetAndLoad(); } @@ -270,15 +290,13 @@ } // === Loaders === - // Set of submittable type IDs (string-keyed for easy lookup); used to filter - // out non-submittable contributions client-side from the highlights endpoint + // Set of public explorer type IDs (string-keyed for easy lookup); used to filter + // out private/system contributions client-side from the highlights endpoint // (which only honors `category` server-side). - let submittableTypeIds = $derived(new Set(allTypes.map(t => String(t.id)))); + let publicTypeIds = $derived(new Set(allTypes.map(t => String(t.id)))); function buildBaseParams() { - // Always restrict to submittable contribution types — non-submittable types - // (badges, journey rewards) are not user-facing in the explorer. - const params = { submittable_only: 'true' }; + const params = { public_explorer_only: 'true' }; if (category !== 'all') params.category = category; if (typeId) params.contribution_type = typeId; if (missionId) params.mission = missionId; @@ -308,11 +326,16 @@ function filterHighlightsClientSide(items) { const q = participantQuery?.trim().toLowerCase() || ''; const isAddress = looksLikeAddress(q); - // Only enforce the submittable filter once the catalog has loaded; otherwise + // Only enforce the public-type filter once the catalog has loaded; otherwise // we'd briefly render an empty list while `allTypes` is still empty. - const enforceSubmittable = submittableTypeIds.size > 0; + const selectedTypeIsPublic = !typeId || + publicTypeIds.has(String(typeId)) || + activeTypeIsPublic === true; + const selectedTypeIsHidden = typeId && activeTypeIsPublic === false; + const enforcePublicTypes = publicTypeIds.size > 0; return items.filter(h => { - if (enforceSubmittable && !submittableTypeIds.has(String(h.contribution_type_id))) return false; + if (selectedTypeIsHidden || !selectedTypeIsPublic) return false; + if (enforcePublicTypes && !publicTypeIds.has(String(h.contribution_type_id))) return false; if (typeId && String(h.contribution_type_id) !== String(typeId)) return false; if (missionId && String(h.mission_id ?? h.mission?.id ?? '') !== String(missionId)) return false; if (!q) return true; @@ -394,18 +417,22 @@ async function resolveTypeName() { if (!typeId) { activeTypeName = ''; + activeTypeIsPublic = null; return; } const fromList = allTypes.find(t => String(t.id) === String(typeId)); if (fromList) { activeTypeName = fromList.name; + activeTypeIsPublic = true; return; } try { const res = await contributionsAPI.getContributionType(typeId); activeTypeName = res.data?.name || ''; + activeTypeIsPublic = isPublicContributionType(res.data); } catch { activeTypeName = ''; + activeTypeIsPublic = false; } } @@ -444,10 +471,12 @@ typesLoading = true; try { const [types, missions] = await Promise.all([ - getContributionTypes({ is_submittable: 'true' }), - getMissions(missionId ? { include_inactive: true } : { is_active: true }), + getContributionTypes(), + getMissions({ include_inactive: true, page_size: 100 }), ]); - allTypes = Array.isArray(types) ? [...types].sort((a, b) => a.name.localeCompare(b.name)) : []; + allTypes = Array.isArray(types) + ? types.filter(isPublicContributionType).sort((a, b) => a.name.localeCompare(b.name)) + : []; allMissions = Array.isArray(missions) ? missions : []; } catch { allTypes = []; @@ -508,14 +537,12 @@ let isMounted = $state(false); let lastHash = $state(''); - function handleHashChange() { + async function handleHashChange() { if (!isMounted) return; if (window.location.hash === lastHash) return; lastHash = window.location.hash; parseUrlParams(); - loadParticipantDetails(); - resolveTypeName(); - resolveMissionName(); + await Promise.all([loadParticipantDetails(), resolveTypeName(), resolveMissionName()]); loadHighlights(); loadAllContributions(); }