Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions backend/api/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from django.contrib import admin

from .models import MetricSnapshot


@admin.register(MetricSnapshot)
class MetricSnapshotAdmin(admin.ModelAdmin):
list_display = (
'metric_key',
'source',
'status',
'value',
'unit',
'observed_at',
'created_at',
)
list_filter = ('metric_key', 'source', 'status', 'unit')
search_fields = ('metric_key', 'source', 'label', 'error')
readonly_fields = (
'metric_key',
'source',
'label',
'value',
'unit',
'observed_at',
'dimensions',
'raw_payload',
'status',
'error',
'created_at',
'updated_at',
)
ordering = ('-observed_at', '-created_at')
date_hierarchy = 'observed_at'
actions = None

def has_add_permission(self, request):
return False

def has_delete_permission(self, request, obj=None):
return False
12 changes: 10 additions & 2 deletions backend/api/metrics_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from utils.pagination import SafePageNumberPagination
from validators.permissions import IsCronToken
from .overview_metrics import (
build_overview_payload,
empty_network_activity_payload,
empty_overview_payload,
latest_network_activity,
Expand All @@ -34,12 +35,19 @@ class OverviewMetricsView(APIView):
Public investor overview payload.

Served from the latest composite ``overview_payload`` MetricSnapshot that the
cron persists. Public page reads never fetch or aggregate source providers.
cron persists. If no snapshot exists, the view performs a live overview
rebuild with ``build_overview_payload()`` and returns the empty payload only
if that fallback fails.
"""
permission_classes = [permissions.AllowAny]

def get(self, request):
return Response(latest_overview_payload() or empty_overview_payload())
try:
payload = latest_overview_payload() or build_overview_payload()
except Exception:
logger.exception("Failed to build public overview metrics payload")
payload = empty_overview_payload()
return Response(payload)


class RefreshOverviewMetricsView(APIView):
Expand Down
65 changes: 63 additions & 2 deletions backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,14 +661,75 @@ def test_public_overview_returns_latest_aggregated_payload(self):
self.assertEqual(response.data['top_validators'][1]['name'], 'Validator One')
self.assertEqual(response.data['top_validators'][1]['total_stake_gen'], 250)

def test_public_overview_without_aggregate_does_not_rebuild_live_payload(self):
def test_public_overview_without_aggregate_includes_live_portal_counts(self):
builder_user = self._create_user(
'overview-fallback-builder@example.com',
'0x0000000000000000000000000000000000001201',
'Fallback Builder',
)
validator_user = self._create_user(
'overview-fallback-validator@example.com',
'0x0000000000000000000000000000000000001202',
'Fallback Validator',
)
community_user = self._create_user(
'overview-fallback-community@example.com',
'0x0000000000000000000000000000000000001203',
'Fallback Community',
)
Validator.objects.create(user=validator_user)

builder_category, _ = Category.objects.get_or_create(
slug='builder',
defaults={'name': 'Builder'},
)
builder_type, _ = ContributionType.objects.get_or_create(
slug='overview-fallback-builder-work',
defaults={
'name': 'Fallback builder work',
'category': builder_category,
'is_submittable': True,
},
)
community_category, _ = Category.objects.get_or_create(
slug='community',
defaults={'name': 'Community'},
)
community_type, _ = ContributionType.objects.get_or_create(
slug='overview-fallback-community-post',
defaults={
'name': 'Fallback community post',
'category': community_category,
'is_submittable': True,
},
)
Contribution.objects.bulk_create([
Contribution(
user=builder_user,
contribution_type=builder_type,
points=10,
frozen_global_points=10,
contribution_date=timezone.now(),
),
Contribution(
user=community_user,
contribution_type=community_type,
points=5,
frozen_global_points=5,
contribution_date=timezone.now(),
)
])
MetricSnapshot.objects.create(metric_key='discord_members', source='discord', value=111)

response = self.client.get('/api/v1/metrics/overview/')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['version'], 1)
self.assertIsNone(response.data['metrics']['discord_members'])
self.assertEqual(response.data['metrics']['builders']['value'], 1)
self.assertEqual(response.data['metrics']['validators']['value'], 1)
self.assertEqual(response.data['metrics']['community_members']['value'], 1)
self.assertEqual(response.data['metrics']['contributions']['value'], 2)
self.assertEqual(response.data['metrics']['discord_members']['value'], 111.0)
self.assertEqual(response.data['top_validators'], [])

@override_settings(CRON_SYNC_TOKEN='overview-secret')
Expand Down
63 changes: 63 additions & 0 deletions backend/contributions/tests/test_canceled_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,69 @@ def test_canceled_submissions_are_not_counted_as_rejected(self):
self.assertEqual(response.data['totals']['rejected'], 1)
self.assertEqual(response.data['totals']['canceled'], 1)

def test_daily_metrics_are_public_aggregate_counts(self):
self._create_submission(state='accepted', staff_reply='Accepted')
self.client.force_authenticate(user=None)

today = timezone.now().date().isoformat()
response = self.client.get(
'/api/v1/steward-submissions/daily-metrics/',
{
'group_by': 'day',
'start_date': today,
'end_date': today,
},
)

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['totals']['accepted'], 1)

def test_daily_metrics_rejects_inverted_date_range(self):
self.client.force_authenticate(user=None)

response = self.client.get(
'/api/v1/steward-submissions/daily-metrics/',
{
'group_by': 'day',
'start_date': '2026-02-01',
'end_date': '2026-01-01',
},
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['detail'], 'start_date must be before or equal to end_date.')

def test_daily_metrics_rejects_too_large_public_date_range(self):
self.client.force_authenticate(user=None)

response = self.client.get(
'/api/v1/steward-submissions/daily-metrics/',
{
'group_by': 'day',
'start_date': '2020-01-01',
'end_date': '2022-01-01',
},
)

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data['detail'], 'Date range is too large for group_by=day.')

def test_steward_stats_are_public_aggregate_counts_for_anonymous_users(self):
self._create_submission(state='pending')
self._create_submission(state='accepted', staff_reply='Accepted')
self._create_submission(state='rejected', staff_reply='Rejected')
self._create_submission(state='canceled', staff_reply='Canceled by user')
self.client.force_authenticate(user=None)

response = self.client.get('/api/v1/steward-submissions/stats/')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['pending_count'], 1)
self.assertEqual(response.data['total_reviewed'], 2)
self.assertEqual(response.data['total_accepted'], 1)
self.assertEqual(response.data['total_rejected'], 1)
self.assertEqual(response.data['acceptance_rate'], 50.0)

def test_canceled_submissions_are_not_reviewed_decisions(self):
self._create_submission(state='rejected', staff_reply='Not valid')
self._create_submission(state='canceled', staff_reply='Canceled by user')
Expand Down
7 changes: 2 additions & 5 deletions backend/contributions/tests/test_is_submittable.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,12 @@ def setUp(self):

self.api_url = reverse('contributiontype-list')

def test_contribution_types_require_authentication(self):
def test_contribution_types_allow_public_read_access(self):
self.client.force_authenticate(user=None)

response = self.client.get(self.api_url)

self.assertIn(
response.status_code,
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
)
self.assertEqual(response.status_code, status.HTTP_200_OK)

def test_is_submittable_field_default_value(self):
"""Test that is_submittable defaults to True."""
Expand Down
28 changes: 17 additions & 11 deletions backend/contributions/tests/test_steward_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ def setUp(self):

self.client = APIClient()

def test_non_authenticated_cannot_access_steward_endpoints(self):
"""Test that non-authenticated users cannot access steward endpoints."""
def test_non_authenticated_user_can_view_stats_but_not_protected_steward_endpoints(self):
"""Test that anonymous users can view stats but not protected steward endpoints."""
# Try to access steward submissions list
response = self.client.get('/api/v1/steward-submissions/')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
Expand All @@ -118,12 +118,14 @@ def test_non_authenticated_cannot_access_steward_endpoints(self):
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

# Steward stats require steward permission.
# Steward stats are public aggregate counts for the dashboard.
response = self.client.get('/api/v1/steward-submissions/stats/')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['pending_count'], 1)
self.assertEqual(response.data['total_reviewed'], 0)

def test_regular_user_cannot_access_steward_endpoints(self):
"""Test that regular users cannot access steward endpoints."""
def test_regular_user_can_view_stats_but_not_protected_steward_endpoints(self):
"""Test that regular users can view stats but not protected steward endpoints."""
# Authenticate as regular user
self.client.force_authenticate(user=self.regular_user)

Expand All @@ -138,9 +140,11 @@ def test_regular_user_cannot_access_steward_endpoints(self):
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

# Steward stats require steward permission.
# Steward stats are public aggregate counts for the dashboard.
response = self.client.get('/api/v1/steward-submissions/stats/')
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['pending_count'], 1)
self.assertEqual(response.data['total_reviewed'], 0)

def test_steward_can_access_steward_endpoints(self):
"""Test that stewards can access steward endpoints."""
Expand Down Expand Up @@ -214,9 +218,11 @@ def test_propose_only_steward_only_sees_pending_permitted_submissions(self):
response = self.client.get('/api/v1/steward-submissions/daily-metrics/')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['totals']['pending_review'], 1)
self.assertEqual(response.data['totals']['accepted'], 0)
self.assertEqual(response.data['totals']['points_awarded'], 0)
# Daily metrics are public aggregate data for the Overview > Metrics
# page, so they are not scoped to the steward's review permissions.
self.assertEqual(response.data['totals']['pending_review'], 2)
self.assertEqual(response.data['totals']['accepted'], 1)
self.assertEqual(response.data['totals']['points_awarded'], accepted_contribution.frozen_global_points)

def test_propose_only_steward_can_edit_active_proposal_note(self):
"""Proposal-only stewards can correct their generated note while pending."""
Expand Down
60 changes: 50 additions & 10 deletions backend/contributions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class ContributionTypeViewSet(viewsets.ReadOnlyModelViewSet):
"""
queryset = ContributionType.objects.all()
serializer_class = ContributionTypeSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [permissions.AllowAny]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['name', 'description']
ordering_fields = ['name', 'created_at']
Expand Down Expand Up @@ -2355,17 +2355,33 @@ def update_accepted(self, request, pk=None):
status=status.HTTP_200_OK
)

@action(detail=False, methods=['get'], url_path='stats')
@action(
detail=False,
methods=['get'],
url_path='stats',
permission_classes=[permissions.AllowAny],
)
def stats(self, request):
"""Get statistics for steward dashboard."""
visible_qs = self._visible_submission_queryset()
total_pending = visible_qs.filter(state='pending').count()
is_steward_user = bool(
request.user
and request.user.is_authenticated
and hasattr(request.user, 'steward')
)
if is_steward_user:
visible_qs = self._visible_submission_queryset()
reviewed_qs = visible_qs.filter(reviewed_by=request.user).exclude(state='canceled')
else:
visible_qs = SubmittedContribution.objects.filter(user__visible=True)
reviewed_qs = visible_qs.filter(
state__in=['accepted', 'rejected', 'more_info_needed']
)

reviewed_qs = visible_qs.filter(reviewed_by=request.user).exclude(state='canceled')
total_pending = visible_qs.filter(state='pending').count()
total_reviewed = reviewed_qs.count()

# Get last review time
last_review = reviewed_qs.order_by('-reviewed_at').first()
last_review = reviewed_qs.filter(reviewed_at__isnull=False).order_by('-reviewed_at').first()

last_review_time = last_review.reviewed_at if last_review else None

Expand All @@ -2388,7 +2404,13 @@ def stats(self, request):
'total_info_requested': total_info_requested
})

@action(detail=False, methods=['get'], url_path='daily-metrics')
@action(
detail=False,
methods=['get'],
url_path='daily-metrics',
permission_classes=[permissions.AllowAny],
authentication_classes=[],
)
def daily_metrics(self, request):
"""
Get time-series metrics for submissions.
Expand All @@ -2414,9 +2436,10 @@ def daily_metrics(self, request):
"""
from datetime import datetime, timedelta
from django.db.models import Min, Max
# Build base queryset with steward visibility and optional filters first
# (needed for date detection).
base_qs = self._visible_submission_queryset()
# Public aggregate metrics for the Overview > Metrics page. This action
# returns counts only; detailed steward review lists/actions remain
# protected by the viewset's default IsSteward permission.
base_qs = SubmittedContribution.objects.filter(user__visible=True)

category = request.query_params.get('category')
if category:
Expand Down Expand Up @@ -2472,6 +2495,23 @@ def daily_metrics(self, request):
if end_date is None:
end_date = timezone.now().date()

if start_date > end_date:
return Response(
{'detail': 'start_date must be before or equal to end_date.'},
status=status.HTTP_400_BAD_REQUEST,
)

max_days_by_group = {
'day': 366,
'week': 366 * 5,
'month': 366 * 10,
}
if (end_date - start_date).days > max_days_by_group[group_by]:
return Response(
{'detail': f'Date range is too large for group_by={group_by}.'},
status=status.HTTP_400_BAD_REQUEST,
)

start_datetime = day_start(start_date)
end_datetime = day_start(end_date + timedelta(days=1))

Expand Down
Loading