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
14 changes: 11 additions & 3 deletions backend/contributions/tests/test_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,18 @@ def test_pagination_with_state_filter(self):

def test_pagination_max_page_size(self):
"""Test that page_size is limited to max_page_size"""
for i in range(30):
SubmittedContribution.objects.create(
user=self.user,
contribution_type=self.contribution_type,
contribution_date=date.today(),
notes=f'Extra submission {i+1}',
state='pending'
)

response = self.client.get('/api/v1/steward-submissions/?page_size=200')
self.assertEqual(response.status_code, status.HTTP_200_OK)

data = response.json()
self.assertEqual(data['count'], 37)
# Should be limited to max_page_size (100)
self.assertEqual(len(data['results']), 37) # All 37 items fit within max of 100
self.assertEqual(data['count'], 67)
self.assertEqual(len(data['results']), 50)
11 changes: 9 additions & 2 deletions backend/contributions/tests/test_security_boundaries.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,11 +332,18 @@ def _listed_ids(self, params=None):
return {item['id'] for item in results}

def test_anonymous_list_hides_unstarted_missions(self):
ids = self._listed_ids({'include_inactive': '1', 'page_size': 100})
ids = self._listed_ids({'include_inactive': '1', 'page_size': 50})
self.assertIn(self.active_mission.id, ids)
self.assertIn(self.expired_mission.id, ids) # historical filters need these
self.assertNotIn(self.unstarted_mission.id, ids)

def test_invalid_contribution_type_filter_returns_400(self):
response = self.client.get('/api/v1/missions/', {
'contribution_type': 'undefined',
})

self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_anonymous_cannot_retrieve_unstarted_mission(self):
response = self.client.get(f'/api/v1/missions/{self.unstarted_mission.id}/')
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
Expand All @@ -353,7 +360,7 @@ def test_anonymous_can_still_access_expired_mission_detail_and_stats(self):

def test_steward_sees_unstarted_missions(self):
self.client.force_authenticate(user=self.steward_user)
ids = self._listed_ids({'include_inactive': '1', 'page_size': 100})
ids = self._listed_ids({'include_inactive': '1', 'page_size': 50})
self.assertIn(self.unstarted_mission.id, ids)

response = self.client.get(f'/api/v1/missions/{self.unstarted_mission.id}/')
Expand Down
2 changes: 1 addition & 1 deletion backend/contributions/tests/test_submission_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ def test_mission_list_exposes_capacity_fields_with_constant_queries(self):
self._create_submission(state='pending', mission=other_mission, user=self.other_user)

with CaptureQueriesContext(connection) as queries:
response = self.client.get('/api/v1/missions/?include_inactive=true&page_size=100')
response = self.client.get('/api/v1/missions/?include_inactive=true&page_size=50')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertLessEqual(len(queries), 4)
Expand Down
10 changes: 8 additions & 2 deletions backend/contributions/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3516,8 +3516,14 @@ def get_queryset(self):
queryset = queryset.exclude(start_date__gt=timezone.now())

# Filter by contribution type if specified
contribution_type = self.request.query_params.get('contribution_type', None)
if contribution_type:
contribution_type = self.request.query_params.get('contribution_type')
if contribution_type not in (None, ''):
try:
contribution_type = int(contribution_type)
except (TypeError, ValueError) as err:
raise serializers.ValidationError({
'contribution_type': 'Must be a valid contribution type id.'
}) from err
queryset = queryset.filter(contribution_type_id=contribution_type)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Filter by category if specified
Expand Down
39 changes: 33 additions & 6 deletions backend/leaderboard/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.db import models, transaction
import zlib

from django.db import connection, models, transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.conf import settings
Expand All @@ -12,6 +14,25 @@

logger = get_app_logger('leaderboard')

LEADERBOARD_RANK_LOCK_NAMESPACE = 0x1EADBEEF


def _signed_int32(value):
return value - 2**32 if value >= 2**31 else value


def _lock_leaderboard_rank_update(leaderboard_type):
if connection.vendor != 'postgresql':
return

lock_key = _signed_int32(zlib.crc32(leaderboard_type.encode('utf-8')))
with connection.cursor() as cursor:
cursor.execute(
'SELECT pg_advisory_xact_lock(%s, %s)',
[LEADERBOARD_RANK_LOCK_NAMESPACE, lock_key],
)


# Onboarding/journey contribution slugs excluded from referral point calculations.
# These are signup markers and journey milestones, not actual contribution work.
# Gate 1 (user eligibility) is handled by get_eligible_referred_user_ids().
Expand Down Expand Up @@ -388,7 +409,13 @@ def update_leaderboard_ranks(cls, leaderboard_type):
"""
if leaderboard_type not in LEADERBOARD_CONFIG:
return


with transaction.atomic():
_lock_leaderboard_rank_update(leaderboard_type)
cls._update_leaderboard_ranks_unlocked(leaderboard_type)

@classmethod
def _update_leaderboard_ranks_unlocked(cls, leaderboard_type):
config = LEADERBOARD_CONFIG[leaderboard_type]
ranking_order = config['ranking_order']

Expand Down Expand Up @@ -543,7 +570,7 @@ def update_user_leaderboard_entries(user):

# Step 5: Update ranks for all affected leaderboards
affected_leaderboards = set(qualified_leaderboards) | removed_leaderboards
for leaderboard_type in affected_leaderboards:
for leaderboard_type in sorted(affected_leaderboards):
LeaderboardEntry.update_leaderboard_ranks(leaderboard_type)


Expand Down Expand Up @@ -578,7 +605,7 @@ def update_leaderboard_on_contribution_delete(sender, instance, **kwargs):
entry.delete()
affected_types.add(entry.type)

for leaderboard_type in affected_types:
for leaderboard_type in sorted(affected_types):
LeaderboardEntry.update_leaderboard_ranks(leaderboard_type)

# Mirror the save path: a referred user's contribution also feeds the
Expand Down Expand Up @@ -793,7 +820,7 @@ def update_all_ranks():
Only visible users are ranked. Non-visible users get null rank.
"""
# Update each leaderboard type
for leaderboard_type in LEADERBOARD_CONFIG.keys():
for leaderboard_type in sorted(LEADERBOARD_CONFIG.keys()):
LeaderboardEntry.update_leaderboard_ranks(leaderboard_type)


Expand Down Expand Up @@ -1071,7 +1098,7 @@ def recalculate_all_leaderboards():
LeaderboardEntry.objects.bulk_create(entries_to_create, batch_size=500)
ReferralPoints.objects.bulk_create(referral_points_to_create, batch_size=500)

for leaderboard_type in ['validator', 'builder', 'validator-waitlist', 'validator-waitlist-graduation']:
for leaderboard_type in sorted(LEADERBOARD_CONFIG.keys()):
LeaderboardEntry.update_leaderboard_ranks(leaderboard_type)

return (
Expand Down
8 changes: 7 additions & 1 deletion backend/leaderboard/tests/test_recalculation.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ def setUp(self):
'description': 'Double points for node running'
}
)
self.multiplier1.multiplier_value = Decimal('2.0')
self.multiplier1.valid_from = timezone.now() - timezone.timedelta(days=30)
self.multiplier1.save(update_fields=['multiplier_value', 'valid_from'])

self.multiplier2, _ = GlobalLeaderboardMultiplier.objects.get_or_create(
contribution_type=self.builder_type,
Expand All @@ -132,6 +135,9 @@ def setUp(self):
'description': '1.5x points for builders'
}
)
self.multiplier2.multiplier_value = Decimal('1.5')
self.multiplier2.valid_from = timezone.now() - timezone.timedelta(days=30)
self.multiplier2.save(update_fields=['multiplier_value', 'valid_from'])

# Add multipliers for waitlist and validator types too
GlobalLeaderboardMultiplier.objects.create(
Expand All @@ -140,7 +146,7 @@ def setUp(self):
valid_from=timezone.now() - timezone.timedelta(days=30),
description='Standard points for waitlist',
)

GlobalLeaderboardMultiplier.objects.create(
contribution_type=self.validator_type,
multiplier_value=Decimal('1.0'),
Expand Down
2 changes: 1 addition & 1 deletion backend/partners/tests/test_partners.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def test_show_in_overview_filter(self):
self.assertNotIn('inactive', slugs) # inactive everywhere
self.assertNotIn('missing-overview-logo', slugs) # selected but unusable for marquee
# Without the flag, an overview-hidden-but-active partner is still listed.
no_flag = self.client.get('/api/v1/partners/?page_size=100')
no_flag = self.client.get('/api/v1/partners/?page_size=50')
no_flag_data = no_flag.json()
no_flag_results = no_flag_data['results'] if isinstance(no_flag_data, dict) and 'results' in no_flag_data else no_flag_data
no_flag_slugs = {p['slug'] for p in no_flag_results}
Expand Down
11 changes: 8 additions & 3 deletions backend/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ echo "Starting Django application startup..."
echo "Collecting static files..."
python3 manage.py collectstatic --noinput

echo "Running database migrations..."
python3 manage.py migrate --noinput
# production should later run migrations once during deploy, then set RUN_MIGRATIONS_ON_STARTUP=false.
if [ "${RUN_MIGRATIONS_ON_STARTUP:-true}" = "false" ]; then
echo "Skipping database migrations."
else
echo "Running database migrations with advisory lock..."
python3 manage.py migrate_with_lock --noinput
fi

echo "Startup complete. Starting Django server..."
exec "$@"
exec "$@"
1 change: 1 addition & 0 deletions backend/utils/management/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 1 addition & 0 deletions backend/utils/management/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

62 changes: 62 additions & 0 deletions backend/utils/management/commands/migrate_with_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import zlib

from django.core.management import BaseCommand, call_command
from django.db import connections


MIGRATION_LOCK_NAMESPACE = 0x54414C59 # "TALY", signed int32-safe.
# ponytail: hardcoded; make it a flag/env var if a deploy ever needs to tune it.
LOCK_TIMEOUT_SECONDS = 900


def _signed_int32(value):
return value - 2**32 if value >= 2**31 else value


MIGRATION_LOCK_KEY = _signed_int32(zlib.crc32(b'tally:migrations:default'))
Comment on lines +12 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate _signed_int32 helper across files.

The identical _signed_int32 conversion helper is redefined in backend/leaderboard/models.py (used for the leaderboard rank advisory lock key). Consider extracting this (and possibly a shared "acquire advisory lock" helper) into backend/utils/ to avoid divergence if one copy is changed later.

♻️ Suggested consolidation
# backend/utils/locks.py
def signed_int32(value: int) -> int:
    return value - 2**32 if value >= 2**31 else value

Then import signed_int32 from both migrate_with_lock.py and leaderboard/models.py.

🧰 Tools
🪛 Ruff (0.15.20)

[warning] 12-12: Missing return type annotation for private function _signed_int32

(ANN202)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/utils/management/commands/migrate_with_lock.py` around lines 12 - 16,
The `_signed_int32` helper is duplicated and should be centralized to avoid
drift. Move the shared 32-bit signed conversion logic into a utility under
backend/utils/ (for example a locks helper) and update migrate_with_lock.py and
leaderboard/models.py to import and use that shared function. If the
advisory-lock code is also duplicated, consider extracting the common
lock-acquisition helper there as well so both call sites use the same
implementation.



class Command(BaseCommand):
help = 'Run Django migrations under a PostgreSQL advisory lock.'

def add_arguments(self, parser):
parser.add_argument(
'--noinput',
'--no-input',
action='store_false',
dest='interactive',
default=True,
help='Do not prompt the user for input.',
)

def handle(self, *args, **options):
connection = connections['default']
migrate_options = {
'interactive': options['interactive'],
'verbosity': options['verbosity'],
'stdout': self.stdout,
'stderr': self.stderr,
}

if connection.vendor != 'postgresql':
self.stdout.write(
f'Database backend is {connection.vendor}; running migrations without advisory lock.'
)
call_command('migrate', **migrate_options)
return

# Postgres does the waiting: lock_timeout bounds the blocking
# pg_advisory_lock call, and the session-level lock releases itself
# when this command's connection closes.
connection.ensure_connection()
with connection.cursor() as cursor:
cursor.execute(f"SET lock_timeout = '{int(LOCK_TIMEOUT_SECONDS)}s'")
self.stdout.write('Waiting for PostgreSQL migration advisory lock...')
cursor.execute(
'SELECT pg_advisory_lock(%s, %s)',
[MIGRATION_LOCK_NAMESPACE, MIGRATION_LOCK_KEY],
)
cursor.execute('SET lock_timeout = 0')
Comment thread
coderabbitai[bot] marked this conversation as resolved.

self.stdout.write('Acquired PostgreSQL migration advisory lock.')
call_command('migrate', **migrate_options)
2 changes: 1 addition & 1 deletion backend/utils/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class SafePageNumberPagination(PageNumberPagination):
# Enable client to control page size using 'page_size' query parameter
page_size_query_param = 'page_size'
# Set maximum page size to prevent abuse
max_page_size = 100
max_page_size = 50

def paginate_queryset(self, queryset, request, view=None):
"""
Expand Down
74 changes: 74 additions & 0 deletions backend/utils/tests/test_migrate_with_lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from io import StringIO
from unittest.mock import patch

from django.test import SimpleTestCase

from utils.management.commands import migrate_with_lock
from utils.management.commands.migrate_with_lock import Command


class FakeCursor:
def __init__(self, connection):
self.connection = connection

def __enter__(self):
return self

def __exit__(self, exc_type, exc, traceback):
return False

def execute(self, sql, params=None):
self.connection.executed.append((sql, params))


class FakeConnection:
def __init__(self, vendor='postgresql'):
self.vendor = vendor
self.executed = []
self.ensure_connection_calls = 0

def ensure_connection(self):
self.ensure_connection_calls += 1

def cursor(self):
return FakeCursor(self)


class MigrateWithLockCommandTests(SimpleTestCase):
def run_command(self, connection):
stdout = StringIO()
command = Command(stdout=stdout, stderr=StringIO())
with (
patch.object(migrate_with_lock, 'connections', {'default': connection}),
patch.object(migrate_with_lock, 'call_command') as call_command,
):
command.handle(interactive=False, verbosity=1)
return call_command, stdout.getvalue()

def test_non_postgres_runs_migrate_without_lock(self):
connection = FakeConnection(vendor='sqlite')

call_command, stdout = self.run_command(connection)

self.assertIn('without advisory lock', stdout)
self.assertEqual(connection.executed, [])
call_command.assert_called_once()

def test_postgres_takes_advisory_lock_with_bounded_wait(self):
connection = FakeConnection()

call_command, stdout = self.run_command(connection)

self.assertEqual(connection.ensure_connection_calls, 1)
self.assertIn('Acquired PostgreSQL migration advisory lock', stdout)
executed_sql = [sql for sql, _params in connection.executed]
self.assertEqual(executed_sql, [
"SET lock_timeout = '900s'",
'SELECT pg_advisory_lock(%s, %s)',
'SET lock_timeout = 0',
])
self.assertEqual(
connection.executed[1][1],
[migrate_with_lock.MIGRATION_LOCK_NAMESPACE, migrate_with_lock.MIGRATION_LOCK_KEY],
)
call_command.assert_called_once()
4 changes: 2 additions & 2 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ const routes = {
- `statsAPI` - Dashboard statistics
- `journeyAPI` - Onboarding journeys (startBuilderJourney, startValidatorJourney, completeBuilderJourney, linkXAccount, linkDiscordAccount)
- `creatorAPI` - Community/creator membership (joinAsCreator)
- `partnersAPI` - Ecosystem partners directory (`list`, `get(slug)`)
- `partnersAPI` - Ecosystem partners directory (`listAll`, `get(slug)`)
- `genTvAPI` - Gen TV streams (`list`, `get(slug)`)
- `poapsAPI` - POAP list/detail/claims, user POAPs, secret claims, mint-link claims, and recovery wallet verification
- `validatorsAPI.getWallOfShame(params)` - Public Wall of Shame list for active validators with Grafana metrics/logs status badges (renders in `routes/WallOfShame.svelte`)
Expand Down Expand Up @@ -535,7 +535,7 @@ Investor-oriented home page (`routes/Overview.svelte`), top to bottom: hero →
- **`NetworkActivity.svelte`** - two-column section: LEFT `PortalStats`, RIGHT the network-activity panel (headline KPIs — decisions, chain TXs, DeFiLlama rank — + `DecisionsChart`). Fetches `metricsAPI.getNetworkActivity()`. (The old "Securing the network" validators panel was removed.)
- **`PortalStats.svelte`** - "Portal contributors" panel: Builders / Validators / Community members / Contributions in a column, hexagon `CategoryIcon` style. Reads the **public** `metricsAPI.getOverview()` (`metrics.{builders,validators,community_members,contributions}.value`) — NOT `statsAPI.getDashboardStats()`, which is auth-only and would render blank for public visitors.
- **`DecisionsChart.svelte`** - chart.js (already a dep) smooth multi-line area chart: decisions/day for Studio + asimov + bradbury over 7 days. IMPORTANT: chart.js mutates the arrays it receives, so de-proxy Svelte `$props` arrays before passing them to `new Chart` (else `state_descriptors_fixed`); the canvas is always mounted so `bind:this` is set before data arrives.
- **`PartnerLogoBanner.svelte`** - logos-only infinite marquee in TWO rows (even indices one row, odd the other, opposite directions, each tripled for a seamless loop). Fetches `partnersAPI.list({ show_in_overview: true, page_size: 100 })` so admins curate which partners appear (Partner.show_in_overview). Logos are flattened to grey silhouettes on a light band.
- **`PartnerLogoBanner.svelte`** - logos-only infinite marquee in TWO rows (even indices one row, odd the other, opposite directions, each tripled for a seamless loop). Fetches `partnersAPI.listAll({ show_in_overview: true })` so admins curate which partners appear (Partner.show_in_overview). Logos are flattened to grey silhouettes on a light band.
- `metricsAPI` (`lib/api.js`): `getOverview()` (public; includes builders/validators/community_members/contributions counts + social + top_validators) and `getNetworkActivity()` (DB-snapshot-backed chart payload).

#### Ecosystem Partners Components (`src/components/portal/partners/`)
Expand Down
Loading