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
31 changes: 17 additions & 14 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ backend/
- MultiplierPeriod - Time-based multiplier changes
- **Views**: `leaderboard/views.py`
- `/api/v1/leaderboard/` - Get rankings
- `/api/v1/leaderboard/monthly/` - Top contribution totals for the current month by default, or for an explicit `start_date`/`end_date` range
- `/api/v1/leaderboard/monthly/` - Top portal point totals for the current month by default, or for an explicit `start_date`/`end_date` range. Combines all category contributions (including onboarding/link awards) with social-task completions and returns `contribution_points`, `social_task_points`, and `total_points`. Non-community categories keep their normal leaderboard eligibility gate. Cumulative Discord chat XP is not included because it has no earning-event timestamp for monthly attribution.
- `/api/v1/leaderboard/stats/` - Global statistics
- `/api/v1/leaderboard/user_stats/by-address/{address}/` - User-specific stats
- **Builder leaderboard eligibility is write-time**: a `type='builder'` LeaderboardEntry
Expand All @@ -164,12 +164,12 @@ backend/
while replaying history on a fresh database, before `social_tasks` 0001 has run — the
guard makes completions read as empty until the table exists (a try/except would abort
the surrounding PostgreSQL migration transaction).
Note: the community leaderboard ranking (`community_xp.utils.effective_community_ranking_queryset`,
MEE6 + community contributions) does NOT include social-task points — community-category
task points are profile-only (`socialTaskTotal` in user stats) and reach the community
ranking only once a steward distributes them as Discord XP (steward Discord XP view →
MEE6 picks them up; see the Social Tasks "Discord XP integration" note). Builder /
validator category task points DO feed their leaderboards.
The community leaderboard ranking (`community_xp.utils.effective_community_ranking_queryset`)
uses the same effective total as community profiles and aggregate stats: MEE6 XP plus
contribution and community social-task points not covered by the applied MEE6 baseline.
Pending social-task points count immediately; after steward distribution they remain pending
until a newer MEE6 snapshot contains them, then roll into the baseline without double counting.
Builder/validator category task points feed their stored leaderboard totals directly.

### Social Tasks
- **Models**: `social_tasks/models.py`
Expand Down Expand Up @@ -201,8 +201,11 @@ backend/
DB check constraint); `target_amount` reads `frozen_global_points` or
`points_awarded` accordingly. Detail actions (`record-copy`,
`mark-distributed`, `unset-distributed`) are keyed by the **state id** (not
contribution id). Stewards with 'accept' permission on any community
contribution type can manage social-task XP rows. Migration
contribution id). These mutations share the MEE6 fetch/apply lock and return
409 while a sync is active; applied snapshots reject any distribution at or
after `run.started_at`, the conservative boundary that covers every fetched
page. Manual admin apply acquires the same lock. Stewards with 'accept'
permission on any community contribution type can manage social-task XP rows. Migration
`contributions/0069` backfills states for pre-existing community completions.
- **Verifier registry**: `social_tasks/verifiers/` package
- `base.py` exposes `Verifier` base class, `VerifierResult` dataclass, `@register`
Expand Down Expand Up @@ -255,10 +258,10 @@ backend/
Both are in the `community` category and award 500 points
(migration 0002 bumped the seeds and changed the model default from 10 to
500; migration 0003 deactivated `check-out-genlayer-on-x`; completions made
before the bump keep their frozen `points_awarded`). Community ranking is
MEE6-based and does not include social-task points, so these seeds award
profile-only points (`socialTaskTotal`); builder / validator category tasks feed
their leaderboards when created.
before the bump keep their frozen `points_awarded`). Community task points
count immediately in the shared effective community total and roll into the
MEE6 baseline after distribution without double counting; builder / validator
category tasks feed their stored leaderboards when created.

### Validators
- **Models**: `validators/models.py`
Expand Down Expand Up @@ -337,7 +340,7 @@ backend/
- **Admin**: `poaps/admin.py` - `PoapDropAdmin` keeps `created_by` as an autocomplete field, includes only `PoapDistributionInline` in `inlines`, and exposes claims through the read-only `claims_link` field instead of rendering `PoapClaim` rows inline. This keeps heavily claimed drops editable without exceeding Django's POST field limit. Ruff RUF012 warnings on the Django admin `inlines = [PoapDistributionInline]` list literal are intentional false positives for this standard admin pattern.

### Earned Discord Roles (social_connections)
- **Logic**: `social_connections/earned_roles.py:assign_earned_community_roles(dry_run=False)` assigns the Synapse (14,000 effective CP + 8 POAPs) and Brain (80,000 effective CP + 16 POAPs + Neurocreative role held) Discord roles to qualifying users. Add-only: never removes roles; each role's threshold is evaluated independently. CP comes from `community_xp.utils.build_effective_community_scores_queryset` (MEE6 baseline + pending portal points), POAPs from `PoapClaim` counts, held roles from `DiscordConnection.current_roles` (the 15-min sync is the reconciler; a successful assignment also updates the local cache). Aborts the run on 429/401/403 (rate limit, missing Manage Roles, hierarchy); other per-user failures log and continue. No-op unless all three `DISCORD_*_ROLE_ID` env vars are set.
- **Logic**: `social_connections/earned_roles.py:assign_earned_community_roles(dry_run=False)` assigns the Synapse (14,000 effective CP + 8 POAPs) and Brain (80,000 effective CP + 16 POAPs + Neurocreative role held) Discord roles to qualifying users. Add-only: never removes roles; each role's threshold is evaluated independently. CP comes from `community_xp.utils.build_effective_community_scores_queryset` (MEE6 baseline + pending contribution and community social-task points), POAPs from `PoapClaim` counts, held roles from `DiscordConnection.current_roles` (the 15-min sync is the reconciler; a successful assignment also updates the local cache). Aborts the run on 429/401/403 (rate limit, missing Manage Roles, hierarchy); other per-user failures log and continue. No-op unless all three `DISCORD_*_ROLE_ID` env vars are set.
- **Service**: `DiscordRoleSyncService.add_member_role(discord_user_id, role_id)` PUTs to Discord; 404 (member left) returns False.
- **Trigger**: `POST /api/v1/users/discord/assign-earned-roles/` (cron-protected, background thread + `DiscordRoleSyncLock` row `discord_earned_role_assign`), called daily at 00:30 UTC by `.github/workflows/assign-discord-roles.yml` (after the 00:00 MEE6 XP sync). Manual/backfill: `python manage.py assign_earned_discord_roles [--dry-run]`; dry run prints the would-assign list for review.
- **Ops**: the bot needs Manage Roles and its role above Synapse/Brain in the guild hierarchy.
Expand Down
10 changes: 10 additions & 0 deletions backend/api/metrics_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def _community_members_by_date(self):
from community_xp.models import Mee6CurrentXP
from community_xp.services import get_default_guild_id
from poaps.models import PoapClaim
from social_tasks.models import SocialTaskCompletion

first_seen_by_user = {}
guild_id = str(get_default_guild_id())
Expand All @@ -142,6 +143,15 @@ def _community_members_by_date(self):
for entry in contribution_members:
self._add_first_seen(first_seen_by_user, entry['user_id'], entry['first_contribution'])

social_task_members = (
SocialTaskCompletion.objects
.filter(task__category__slug='community', user__visible=True)
.values('user_id')
.annotate(first_completion=Min('completed_at'))
)
for entry in social_task_members:
self._add_first_seen(first_seen_by_user, entry['user_id'], entry['first_completion'])

mee6_members = (
Mee6CurrentXP.objects
.filter(
Expand Down
41 changes: 41 additions & 0 deletions backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from builders.models import Builder
from community_xp.models import Mee6CurrentXP, Mee6SyncRun
from contributions.models import Category, Contribution, ContributionType
from social_tasks.models import SocialTask, SocialTaskCompletion
from users.models import User
from validators.models import Validator, ValidatorWallet

Expand Down Expand Up @@ -165,6 +166,46 @@ def test_participants_growth_scopes_mee6_members_to_default_guild(self):
self.assertEqual(final_point['unique_contributors'], 1)
self.assertEqual(final_point['total'], 1)

def test_participants_growth_includes_community_social_task_members(self):
completed_at = timezone.now() - timedelta(days=2)
user = self._create_user(
'community-task-member@example.com',
'0x0000000000000000000000000000000000000103',
)
hidden_user = self._create_user(
'hidden-community-task-member@example.com',
'0x0000000000000000000000000000000000000104',
visible=False,
)
task = SocialTask.objects.create(
slug='metrics-community-task',
name='Metrics community task',
category=self.community_category,
points=500,
verification_type='click_through',
action_url='https://example.com',
)
completions = [
SocialTaskCompletion.objects.create(
user=member,
task=task,
points_awarded=500,
verification_type='click_through',
)
for member in (user, hidden_user)
]
SocialTaskCompletion.objects.filter(
id__in=[completion.id for completion in completions]
).update(completed_at=completed_at)

response = self.client.get('/api/v1/metrics/participants-growth/')

self.assertEqual(response.status_code, 200)
final_point = response.data['data'][-1]
self.assertEqual(final_point['community_members'], 1)
self.assertEqual(final_point['unique_contributors'], 1)
self.assertEqual(final_point['total'], 1)

def test_participants_growth_deduplicates_overlapping_roles(self):
base = timezone.now() - timedelta(days=3)

Expand Down
45 changes: 38 additions & 7 deletions backend/community_xp/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,29 +374,35 @@ def store_fetch_result(run, fetch_result):
}


def _validate_contribution_xp_state_before_applying(run):
def _validate_xp_state_before_applying(run):
from contributions.models import ContributionDiscordXPState
from django.db.models import Q

late_distribution = (
ContributionDiscordXPState.objects
.filter(
contribution__contribution_type__category__slug='community',
distributed_at__gt=run.completed_at,
Q(contribution__contribution_type__category__slug='community') |
Q(social_task_completion__task__category__slug='community'),
distributed_at__gte=run.started_at,
awarded_amount__gt=0,
)
.exclude(contribution__contribution_type__slug__in=COMMUNITY_XP_EXCLUDED_TYPE_SLUGS)
.order_by('-distributed_at', '-id')
.first()
)
if late_distribution:
if late_distribution.contribution_id:
source = f'contribution #{late_distribution.contribution_id}'
else:
source = f'social task completion #{late_distribution.social_task_completion_id}'
raise Mee6SyncError(
f'Cannot apply MEE6 sync #{run.id}; contribution '
f'#{late_distribution.contribution_id} was marked distributed after this snapshot was fetched. '
f'Cannot apply MEE6 sync #{run.id}; {source} was marked distributed '
'after this snapshot fetch started. '
'Fetch a newer MEE6 snapshot before applying a new baseline.'
)


def apply_sync_run(run, applied_by=None):
def _apply_sync_run_unlocked(run, applied_by=None):
if run.status != Mee6SyncRun.STATUS_SUCCESS:
raise Mee6SyncError('Only successful MEE6 sync runs can be applied as the baseline')
if not run.completed_at:
Expand All @@ -419,7 +425,7 @@ def apply_sync_run(run, applied_by=None):
f'Cannot apply MEE6 sync #{run.id}; sync #{latest_applied_run.id} is newer and already applied'
)

_validate_contribution_xp_state_before_applying(run)
_validate_xp_state_before_applying(run)

now = timezone.now()
snapshots = list(
Expand Down Expand Up @@ -487,6 +493,31 @@ def apply_sync_run(run, applied_by=None):
}


def apply_sync_run(run, applied_by=None, lock_owner_token=None):
"""Apply a fetched snapshot while excluding concurrent XP mutations.

The scheduled fetch/apply workflow passes the lock token it already owns.
Manual/admin callers acquire the same lock for the duration of application.
"""
acquired_here = False
owner_token = lock_owner_token

if owner_token:
if not refresh_sync_lock(owner_token):
raise Mee6SyncError('MEE6 XP sync lock ownership was lost before apply')
else:
owner_token, elapsed_seconds = acquire_sync_lock()
if not owner_token:
raise Mee6SyncAlreadyRunning(elapsed_seconds)
acquired_here = True

try:
return _apply_sync_run_unlocked(run, applied_by=applied_by)
finally:
if acquired_here:
release_sync_lock(owner_token)


def run_mee6_sync(guild_id=None, page_size=None, client=None, use_lock=True):
guild_id = str(guild_id or get_default_guild_id())
try:
Expand Down
Loading