diff --git a/VALIDATOR_JOURNEY_ARCHITECTURE_v3.md b/VALIDATOR_JOURNEY_ARCHITECTURE_v3.md index 4bf2c66..9fbebab 100644 --- a/VALIDATOR_JOURNEY_ARCHITECTURE_v3.md +++ b/VALIDATOR_JOURNEY_ARCHITECTURE_v3.md @@ -212,7 +212,7 @@ def update_user_leaderboard_entries(user): for leaderboard_type in qualified_leaderboards: update_leaderboard_type_ranks(leaderboard_type) - # Also update waitlist ranks if user graduated (they were removed) FIX: with the fix above this is not necesarry + # Also update waitlist ranks if user graduated (they were removed) FIX: with the fix above this is not necessary if 'validator-waitlist-graduation' in qualified_leaderboards: update_leaderboard_type_ranks('validator-waitlist') diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index c9a02b9..57b1ea5 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -186,6 +186,10 @@ GET /api/v1/leaderboard/user_stats/by-address/{address}/ # Multipliers GET /api/v1/multipliers/ GET /api/v1/multiplier-periods/ + +# Steward Submissions (public metrics) +GET /api/v1/steward-submissions/stats/ (public - aggregate stats) +GET /api/v1/steward-submissions/daily-metrics/ (public - time-series data) ``` ## Environment Variables diff --git a/backend/api/metrics_views.py b/backend/api/metrics_views.py index 76455e2..fabf23e 100644 --- a/backend/api/metrics_views.py +++ b/backend/api/metrics_views.py @@ -85,11 +85,11 @@ class ContributionTypesStatsView(APIView): """ Get time series data showing how many contribution types have been assigned on each date. """ - + def get(self, request): from django.db.models.functions import TruncDate from datetime import date, timedelta - + # Get contributions grouped by date and contribution type daily_contributions = ( Contribution.objects @@ -101,51 +101,119 @@ def get(self, request): ) .order_by('date') ) - + # Build cumulative data data = [] cumulative_types = set() - + # Get all contributions to track cumulative unique types all_contributions = ( Contribution.objects .values('contribution_date', 'contribution_type') .order_by('contribution_date') ) - + # Group by date and count cumulative types from collections import defaultdict contributions_by_date = defaultdict(set) - + for contrib in all_contributions: date_key = contrib['contribution_date'].date() contributions_by_date[date_key].add(contrib['contribution_type']) - + if contributions_by_date: # Get date range start_date = min(contributions_by_date.keys()) end_date = max(contributions_by_date.keys()) - + # Extend to today if needed today = date.today() if end_date < today: end_date = today - + # Build continuous time series with cumulative count current_date = start_date - + while current_date <= end_date: # Add new types for this date if current_date in contributions_by_date: cumulative_types.update(contributions_by_date[current_date]) - + data.append({ 'date': current_date.isoformat(), 'count': len(cumulative_types), 'new_types': len(contributions_by_date.get(current_date, set())) }) - + # Move to next day current_date += timedelta(days=1) - + + return Response({'data': data}) + + +class ParticipantsGrowthView(APIView): + """ + Get time series data for validators, waitlist users, and builders growth over time. + """ + + def get(self, request): + from django.db.models.functions import TruncDate + from datetime import date, timedelta + from collections import defaultdict + from validators.models import Validator + from builders.models import Builder + + # Get validators by creation date + validators_by_date = defaultdict(int) + for v in Validator.objects.all(): + date_key = v.created_at.date() + validators_by_date[date_key] += 1 + + # Get waitlist users by contribution date (users with validator-waitlist contribution) + waitlist_by_date = defaultdict(int) + try: + waitlist_type = ContributionType.objects.get(slug='validator-waitlist') + for c in Contribution.objects.filter(contribution_type=waitlist_type): + date_key = c.contribution_date.date() + waitlist_by_date[date_key] += 1 + except ContributionType.DoesNotExist: + pass + + # Get builders by creation date + builders_by_date = defaultdict(int) + for b in Builder.objects.all(): + date_key = b.created_at.date() + builders_by_date[date_key] += 1 + + # Find date range across all sources + all_dates = set(validators_by_date.keys()) | set(waitlist_by_date.keys()) | set(builders_by_date.keys()) + + if not all_dates: + return Response({'data': []}) + + start_date = min(all_dates) + end_date = max(max(all_dates), date.today()) + + # Build cumulative time series + data = [] + cum_validators = 0 + cum_waitlist = 0 + cum_builders = 0 + + current_date = start_date + while current_date <= end_date: + cum_validators += validators_by_date.get(current_date, 0) + cum_waitlist += waitlist_by_date.get(current_date, 0) + cum_builders += builders_by_date.get(current_date, 0) + + data.append({ + 'date': current_date.isoformat(), + 'validators': cum_validators, + 'waitlist': cum_waitlist, + 'builders': cum_builders, + 'total': cum_validators + cum_waitlist + cum_builders + }) + + current_date += timedelta(days=1) + return Response({'data': data}) \ No newline at end of file diff --git a/backend/api/urls.py b/backend/api/urls.py index 883e1e0..aa95b35 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -4,7 +4,7 @@ from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView -from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView +from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView, ParticipantsGrowthView # Create a router and register our viewsets with it router = DefaultRouter() @@ -37,4 +37,5 @@ # Metrics endpoints path('metrics/active-validators/', ActiveValidatorsView.as_view(), name='active-validators'), path('metrics/contribution-types/', ContributionTypesStatsView.as_view(), name='contribution-types-stats'), + path('metrics/participants-growth/', ParticipantsGrowthView.as_view(), name='participants-growth'), ] \ No newline at end of file diff --git a/backend/contributions/views.py b/backend/contributions/views.py index f328a0d..70a59ee 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -4,7 +4,8 @@ from django_filters.rest_framework import DjangoFilterBackend from django_filters import FilterSet, CharFilter, BooleanFilter, NumberFilter from django.utils import timezone -from django.db.models import Count, Max, F, Q, Exists, OuterRef, Subquery +from django.db.models import Count, Max, F, Q, Exists, OuterRef, Subquery, Sum +from django.db.models.functions import TruncDate, TruncWeek, TruncMonth from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce from django.shortcuts import render, redirect, get_object_or_404 @@ -939,9 +940,9 @@ class StewardSubmissionViewSet(viewsets.ModelViewSet): def get_permissions(self): """ Instantiates and returns the list of permissions that this view requires. - Stats endpoint is public, all others require steward permission. + Stats and daily_metrics endpoints are public, all others require steward permission. """ - if self.action == 'stats': + if self.action in ['stats', 'daily_metrics']: return [permissions.AllowAny()] return super().get_permissions() @@ -1018,14 +1019,16 @@ def review(self, request, pk=None): # Manually update leaderboard since ensure_builder_status uses bulk_create (no signals) update_user_leaderboard_entries(contribution_user) - # Copy evidence items - for evidence in submission.evidence_items.all(): - Evidence.objects.create( + # Copy evidence items using bulk_create for better performance + Evidence.objects.bulk_create([ + Evidence( contribution=contribution, description=evidence.description, url=evidence.url, file=evidence.file ) + for evidence in submission.evidence_items.all() + ]) # Create highlight if requested if serializer.validated_data.get('create_highlight'): @@ -1110,7 +1113,211 @@ def stats(self, request): 'total_rejected': total_rejected, 'total_info_requested': total_info_requested }) - + + @action(detail=False, methods=['get'], url_path='daily-metrics') + def daily_metrics(self, request): + """ + Get time-series metrics for submissions. + + Query parameters: + - start_date: Start date (YYYY-MM-DD), defaults to first submission date + - end_date: End date (YYYY-MM-DD), defaults to today + - group_by: Grouping period - 'day', 'week', or 'month' (default: 'week') + - category: Filter by category slug (validator, builder, steward, creator) + - contribution_type: Filter by contribution type ID + + Returns counts grouped by period for: + - ingress: New submissions created + - accepted: Submissions accepted + - rejected: Submissions rejected + - more_info_requested: Submissions requesting more info + - points_awarded: Total points from accepted contributions + """ + from datetime import datetime, timedelta + from django.db.models import Min, Max + import calendar + + # Build base queryset with optional filters first (needed for date detection) + base_qs = SubmittedContribution.objects.all() + + category = request.query_params.get('category') + if category: + base_qs = base_qs.filter(contribution_type__category__slug=category) + + contribution_type_id = request.query_params.get('contribution_type') + if contribution_type_id: + base_qs = base_qs.filter(contribution_type_id=contribution_type_id) + + # Get grouping parameter (default to week) + group_by = request.query_params.get('group_by', 'week') + if group_by not in ['day', 'week', 'month']: + group_by = 'week' + + # Select truncation function based on grouping + trunc_func = { + 'day': TruncDate, + 'week': TruncWeek, + 'month': TruncMonth + }[group_by] + + # Parse date range from query params, or auto-detect from data + end_date = None + start_date = None + + if request.query_params.get('start_date'): + try: + start_date = datetime.strptime( + request.query_params['start_date'], '%Y-%m-%d' + ).date() + except ValueError: + pass + + if request.query_params.get('end_date'): + try: + end_date = datetime.strptime( + request.query_params['end_date'], '%Y-%m-%d' + ).date() + except ValueError: + pass + + # Auto-detect date range from data if not provided + if start_date is None or end_date is None: + date_range = base_qs.aggregate( + min_date=Min('created_at'), + max_date=Max('created_at') + ) + if start_date is None: + start_date = date_range['min_date'].date() if date_range['min_date'] else timezone.now().date() + if end_date is None: + end_date = timezone.now().date() + + # Get ingress (new submissions by created_at) + ingress = ( + base_qs + .filter(created_at__date__gte=start_date, created_at__date__lte=end_date) + .annotate(period=trunc_func('created_at')) + .values('period') + .annotate(count=Count('id')) + .order_by('period') + ) + + # Get reviews by outcome (by reviewed_at date) + reviews_base = base_qs.filter( + reviewed_at__date__gte=start_date, + reviewed_at__date__lte=end_date + ) + + accepted = ( + reviews_base + .filter(state='accepted') + .annotate(period=trunc_func('reviewed_at')) + .values('period') + .annotate(count=Count('id')) + .order_by('period') + ) + + rejected = ( + reviews_base + .filter(state='rejected') + .annotate(period=trunc_func('reviewed_at')) + .values('period') + .annotate(count=Count('id')) + .order_by('period') + ) + + more_info = ( + reviews_base + .filter(state='more_info_needed') + .annotate(period=trunc_func('reviewed_at')) + .values('period') + .annotate(count=Count('id')) + .order_by('period') + ) + + # Get points awarded (from converted contributions) + points_qs = Contribution.objects.filter( + created_at__date__gte=start_date, + created_at__date__lte=end_date, + source_submission__isnull=False # Only from submissions + ) + + # Apply category/type filters to points query if specified + if category: + points_qs = points_qs.filter(contribution_type__category__slug=category) + if contribution_type_id: + points_qs = points_qs.filter(contribution_type_id=contribution_type_id) + + points = ( + points_qs + .annotate(period=trunc_func('created_at')) + .values('period') + .annotate(total_points=Sum('frozen_global_points')) + .order_by('period') + ) + + # Convert querysets to dict for easier lookup (handle both date and datetime) + def to_date(val): + if hasattr(val, 'date'): + return val.date() + return val + + ingress_dict = {to_date(item['period']): item['count'] for item in ingress} + accepted_dict = {to_date(item['period']): item['count'] for item in accepted} + rejected_dict = {to_date(item['period']): item['count'] for item in rejected} + more_info_dict = {to_date(item['period']): item['count'] for item in more_info} + points_dict = {to_date(item['period']): item['total_points'] for item in points} + + # Build response with all periods in range + data = [] + current_date = start_date + + # Align to period start + if group_by == 'week': + # Align to Monday + current_date = current_date - timedelta(days=current_date.weekday()) + elif group_by == 'month': + # Align to first of month + current_date = current_date.replace(day=1) + + while current_date <= end_date: + data.append({ + 'period': current_date.isoformat(), + 'ingress': ingress_dict.get(current_date, 0), + 'accepted': accepted_dict.get(current_date, 0), + 'rejected': rejected_dict.get(current_date, 0), + 'more_info_requested': more_info_dict.get(current_date, 0), + 'points_awarded': points_dict.get(current_date, 0) or 0 + }) + + # Advance to next period + if group_by == 'day': + current_date += timedelta(days=1) + elif group_by == 'week': + current_date += timedelta(weeks=1) + else: # month + # Add one month + if current_date.month == 12: + current_date = current_date.replace(year=current_date.year + 1, month=1) + else: + current_date = current_date.replace(month=current_date.month + 1) + + # Calculate totals for the period + totals = { + 'ingress': sum(d['ingress'] for d in data), + 'accepted': sum(d['accepted'] for d in data), + 'rejected': sum(d['rejected'] for d in data), + 'more_info_requested': sum(d['more_info_requested'] for d in data), + 'points_awarded': sum(d['points_awarded'] for d in data) + } + + return Response({ + 'start_date': start_date.isoformat(), + 'end_date': end_date.isoformat(), + 'group_by': group_by, + 'totals': totals, + 'data': data + }) + @action(detail=False, methods=['get'], url_path='users') def users(self, request): """Get all users sorted alphabetically by name for steward dropdown.""" diff --git a/frontend/src/components/StewardSearchBar.svelte b/frontend/src/components/StewardSearchBar.svelte index e8a6cb0..363e194 100644 --- a/frontend/src/components/StewardSearchBar.svelte +++ b/frontend/src/components/StewardSearchBar.svelte @@ -95,13 +95,16 @@ const before = value.slice(0, start); const after = value.slice(end); - value = before + suggestion + (after.startsWith(' ') ? after : ' ' + after.trimStart()); + // Don't add space if suggestion ends with ':' (user needs to type value) + const needsSpace = !suggestion.endsWith(':'); + const spacer = needsSpace ? ' ' : ''; + value = before + suggestion + (after.startsWith(' ') || !needsSpace ? after.trimStart() : spacer + after.trimStart()); showAutocomplete = false; // Focus input and move cursor if (inputRef) { inputRef.focus(); - const newPos = before.length + suggestion.length + 1; + const newPos = before.length + suggestion.length + (needsSpace ? 1 : 0); setTimeout(() => inputRef.setSelectionRange(newPos, newPos), 0); } diff --git a/frontend/src/lib/components/ContributionSelection.svelte b/frontend/src/lib/components/ContributionSelection.svelte index 81febe1..7f1bd3f 100644 --- a/frontend/src/lib/components/ContributionSelection.svelte +++ b/frontend/src/lib/components/ContributionSelection.svelte @@ -1,9 +1,9 @@