diff --git a/backend/.env.example b/backend/.env.example
index 3134cab5..95335e98 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -102,7 +102,7 @@ TWITTER_CLIENT_SECRET=your_twitter_client_secret
# 3. Required scopes: identify, guilds.members.read
DISCORD_CLIENT_ID=your_discord_client_id
DISCORD_CLIENT_SECRET=your_discord_client_secret
-# Discord Guild (Server) ID for membership checks
+# Discord Guild (Server) ID for membership checks and MEE6 XP sync
DISCORD_GUILD_ID=your_discord_server_id
# Discord bot token for guild role/member sync. The bot must be in DISCORD_GUILD_ID.
DISCORD_BOT_TOKEN=your_discord_bot_token
diff --git a/backend/api/urls.py b/backend/api/urls.py
index 430a09cb..3061ba2a 100644
--- a/backend/api/urls.py
+++ b/backend/api/urls.py
@@ -1,7 +1,7 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from users.views import UserViewSet
-from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet
+from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, StewardDiscordXPViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet
from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet
from partners.views import PartnerViewSet
from projects.views import ProjectViewSet
@@ -20,6 +20,7 @@
router.register(r'leaderboard', LeaderboardViewSet)
router.register(r'submissions', SubmittedContributionViewSet, basename='submission')
router.register(r'steward-submissions', StewardSubmissionViewSet, basename='steward-submission')
+router.register(r'steward-discord-xp', StewardDiscordXPViewSet, basename='steward-discord-xp')
router.register(r'missions', MissionViewSet, basename='mission')
router.register(r'startup-requests', StartupRequestViewSet, basename='startup-request')
router.register(r'featured', FeaturedContentViewSet, basename='featured')
diff --git a/backend/community_xp/__init__.py b/backend/community_xp/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/community_xp/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/community_xp/admin.py b/backend/community_xp/admin.py
new file mode 100644
index 00000000..09cc4e00
--- /dev/null
+++ b/backend/community_xp/admin.py
@@ -0,0 +1,188 @@
+from django.contrib import admin
+from django.contrib import messages
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import redirect
+from django.urls import path
+
+from .models import (
+ Mee6CurrentXP,
+ Mee6PlayerSnapshot,
+ Mee6SyncLock,
+ Mee6SyncRun,
+)
+from .services import Mee6SyncAlreadyRunning, Mee6SyncError, apply_sync_run, run_mee6_sync
+
+
+@admin.register(Mee6SyncRun)
+class Mee6SyncRunAdmin(admin.ModelAdmin):
+ actions = ('fetch_new_snapshot', 'apply_as_active_baseline')
+ change_list_template = 'admin/community_xp/mee6syncrun/change_list.html'
+ list_display = (
+ 'id',
+ 'guild_id',
+ 'guild_name',
+ 'status',
+ 'applied_at',
+ 'players_fetched',
+ 'matched_players',
+ 'unmatched_players',
+ 'duplicate_players',
+ 'started_at',
+ 'completed_at',
+ )
+ list_filter = ('status', 'guild_id')
+ search_fields = ('guild_id', 'guild_name', 'error_message')
+ readonly_fields = (
+ 'created_at',
+ 'updated_at',
+ 'started_at',
+ 'completed_at',
+ 'applied_at',
+ 'applied_by',
+ )
+
+ def get_urls(self):
+ return [
+ path(
+ 'fetch-new-snapshot/',
+ self.admin_site.admin_view(self.fetch_new_snapshot_view),
+ name='community_xp_mee6syncrun_fetch_new_snapshot',
+ ),
+ ] + super().get_urls()
+
+ def fetch_new_snapshot_view(self, request):
+ if not self.has_add_permission(request):
+ raise PermissionDenied
+ if request.method == 'POST':
+ self._fetch_new_snapshot(request)
+ return redirect('..')
+
+ def changelist_view(self, request, extra_context=None):
+ extra_context = extra_context or {}
+ extra_context['can_fetch_new_snapshot'] = self.has_add_permission(request)
+ return super().changelist_view(request, extra_context=extra_context)
+
+ def _fetch_new_snapshot(self, request, guild_id=None, page_size=None):
+ try:
+ result = run_mee6_sync(
+ guild_id=guild_id,
+ page_size=page_size,
+ )
+ except Mee6SyncAlreadyRunning as exc:
+ elapsed = f" for {exc.elapsed_seconds:.0f}s" if exc.elapsed_seconds is not None else ''
+ self.message_user(
+ request,
+ f'MEE6 XP sync already running{elapsed}.',
+ level=messages.ERROR,
+ )
+ return
+ except Mee6SyncError as exc:
+ self.message_user(request, str(exc), level=messages.ERROR)
+ return
+
+ self.message_user(
+ request,
+ (
+ f"Fetched MEE6 sync #{result['run_id']}: "
+ f"{result['players_fetched']} players, "
+ f"{result['matched_players']} matched, "
+ f"{result['unmatched_players']} unmatched, "
+ f"{result['pages_fetched']} pages. "
+ "Apply it to update active community XP."
+ ),
+ level=messages.SUCCESS,
+ )
+
+ @admin.action(description='Fetch new MEE6 XP snapshot using selected run settings')
+ def fetch_new_snapshot(self, request, queryset):
+ if not self.has_add_permission(request) or not self.has_change_permission(request):
+ raise PermissionDenied
+ if queryset.count() != 1:
+ self.message_user(
+ request,
+ 'Select exactly one MEE6 sync run to reuse its guild and page size.',
+ level=messages.ERROR,
+ )
+ return
+
+ source_run = queryset.first()
+ self._fetch_new_snapshot(
+ request,
+ guild_id=source_run.guild_id,
+ page_size=source_run.page_size,
+ )
+
+ @admin.action(description='Apply selected MEE6 run as active community XP baseline')
+ def apply_as_active_baseline(self, request, queryset):
+ if not self.has_change_permission(request):
+ raise PermissionDenied
+ if queryset.count() != 1:
+ self.message_user(
+ request,
+ 'Select exactly one successful MEE6 sync run to apply.',
+ level=messages.ERROR,
+ )
+ return
+
+ run = queryset.first()
+ try:
+ result = apply_sync_run(run, applied_by=request.user)
+ except Mee6SyncError as exc:
+ self.message_user(request, str(exc), level=messages.ERROR)
+ return
+
+ self.message_user(
+ request,
+ (
+ f"Applied MEE6 sync #{run.id}: "
+ f"{result['players_applied']} players, "
+ f"{result['matched_players']} matched, "
+ f"{result['unmatched_players']} unmatched."
+ ),
+ level=messages.SUCCESS,
+ )
+
+
+@admin.register(Mee6PlayerSnapshot)
+class Mee6PlayerSnapshotAdmin(admin.ModelAdmin):
+ list_display = (
+ 'run',
+ 'rank',
+ 'username',
+ 'discord_id',
+ 'xp',
+ 'level',
+ )
+ list_filter = ('guild_id', 'run__status')
+ search_fields = (
+ 'discord_id',
+ 'username',
+ )
+ readonly_fields = ('created_at', 'updated_at')
+
+
+@admin.register(Mee6CurrentXP)
+class Mee6CurrentXPAdmin(admin.ModelAdmin):
+ list_display = (
+ 'rank',
+ 'username',
+ 'discord_id',
+ 'xp',
+ 'level',
+ 'matched_user',
+ 'synced_at',
+ )
+ list_filter = ('guild_id',)
+ search_fields = (
+ 'discord_id',
+ 'username',
+ 'matched_user__email',
+ 'matched_user__address',
+ )
+ readonly_fields = ('created_at', 'updated_at', 'synced_at')
+
+
+@admin.register(Mee6SyncLock)
+class Mee6SyncLockAdmin(admin.ModelAdmin):
+ list_display = ('name', 'owner_token', 'acquired_at', 'heartbeat_at', 'released_at')
+ readonly_fields = ('name', 'owner_token', 'acquired_at', 'heartbeat_at', 'released_at')
diff --git a/backend/community_xp/apps.py b/backend/community_xp/apps.py
new file mode 100644
index 00000000..de4921ce
--- /dev/null
+++ b/backend/community_xp/apps.py
@@ -0,0 +1,10 @@
+from django.apps import AppConfig
+
+
+class CommunityXpConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'community_xp'
+ verbose_name = 'Community XP'
+
+ def ready(self):
+ from . import signals # noqa: F401
diff --git a/backend/community_xp/constants.py b/backend/community_xp/constants.py
new file mode 100644
index 00000000..3a403a1c
--- /dev/null
+++ b/backend/community_xp/constants.py
@@ -0,0 +1,8 @@
+COMMUNITY_XP_EXCLUDED_TYPE_SLUGS = (
+ 'builder-welcome',
+ 'builder',
+ 'validator-waitlist',
+ 'validator',
+ 'community-link-x',
+ 'community-link-discord',
+)
diff --git a/backend/community_xp/management/__init__.py b/backend/community_xp/management/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/community_xp/management/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/community_xp/management/commands/__init__.py b/backend/community_xp/management/commands/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/community_xp/management/commands/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/community_xp/management/commands/sync_mee6_xp.py b/backend/community_xp/management/commands/sync_mee6_xp.py
new file mode 100644
index 00000000..cfc69023
--- /dev/null
+++ b/backend/community_xp/management/commands/sync_mee6_xp.py
@@ -0,0 +1,38 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from community_xp.services import Mee6SyncAlreadyRunning, Mee6SyncError, run_mee6_sync
+
+
+class Command(BaseCommand):
+ help = 'Fetch MEE6 Discord XP snapshots without applying them as the active community XP baseline'
+
+ def add_arguments(self, parser):
+ parser.add_argument('--guild-id', default=None, help='Discord guild ID to sync')
+ parser.add_argument('--page-size', type=int, default=None, help='MEE6 leaderboard page size')
+ parser.add_argument(
+ '--no-lock',
+ action='store_true',
+ help='Run without acquiring the database sync lock',
+ )
+
+ def handle(self, *args, **options):
+ try:
+ result = run_mee6_sync(
+ guild_id=options.get('guild_id'),
+ page_size=options.get('page_size'),
+ use_lock=not options.get('no_lock'),
+ )
+ except Mee6SyncAlreadyRunning as exc:
+ elapsed = f" for {exc.elapsed_seconds:.0f}s" if exc.elapsed_seconds is not None else ''
+ raise CommandError(f'MEE6 XP sync already running{elapsed}') from exc
+ except Mee6SyncError as exc:
+ raise CommandError(str(exc)) from exc
+
+ self.stdout.write(self.style.SUCCESS(
+ 'MEE6 XP snapshot fetch completed: '
+ f"{result['players_fetched']} players, "
+ f"{result['matched_players']} matched, "
+ f"{result['unmatched_players']} unmatched, "
+ f"{result['pages_fetched']} pages. "
+ "Apply this run in Django admin to update portal community scores."
+ ))
diff --git a/backend/community_xp/migrations/0001_initial.py b/backend/community_xp/migrations/0001_initial.py
new file mode 100644
index 00000000..0a4a407f
--- /dev/null
+++ b/backend/community_xp/migrations/0001_initial.py
@@ -0,0 +1,138 @@
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Mee6SyncRun',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('guild_id', models.CharField(db_index=True, max_length=100)),
+ ('guild_name', models.CharField(blank=True, max_length=255)),
+ ('status', models.CharField(choices=[('running', 'Running'), ('success', 'Success'), ('failed', 'Failed')], default='running', max_length=20)),
+ ('page_size', models.PositiveIntegerField(default=1000)),
+ ('pages_fetched', models.PositiveIntegerField(default=0)),
+ ('players_fetched', models.PositiveIntegerField(default=0)),
+ ('duplicate_players', models.PositiveIntegerField(default=0)),
+ ('matched_players', models.PositiveIntegerField(default=0)),
+ ('unmatched_players', models.PositiveIntegerField(default=0)),
+ ('started_at', models.DateTimeField(default=django.utils.timezone.now)),
+ ('completed_at', models.DateTimeField(blank=True, null=True)),
+ ('applied_at', models.DateTimeField(blank=True, null=True)),
+ ('error_message', models.TextField(blank=True)),
+ ('applied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applied_mee6_sync_runs', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-started_at', '-id'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Mee6SyncLock',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=100, unique=True)),
+ ('owner_token', models.CharField(blank=True, db_index=True, max_length=32, null=True)),
+ ('acquired_at', models.DateTimeField(blank=True, null=True)),
+ ('heartbeat_at', models.DateTimeField(blank=True, null=True)),
+ ('released_at', models.DateTimeField(blank=True, null=True)),
+ ],
+ options={
+ 'db_table': 'community_xp_mee6_sync_lock',
+ },
+ ),
+ migrations.CreateModel(
+ name='Mee6PlayerSnapshot',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('guild_id', models.CharField(db_index=True, max_length=100)),
+ ('discord_id', models.CharField(db_index=True, max_length=100)),
+ ('username', models.CharField(blank=True, max_length=100)),
+ ('discriminator', models.CharField(blank=True, max_length=10)),
+ ('avatar_hash', models.CharField(blank=True, max_length=100)),
+ ('rank', models.PositiveIntegerField()),
+ ('xp', models.PositiveIntegerField(default=0)),
+ ('level', models.PositiveIntegerField(default=0)),
+ ('message_count', models.PositiveIntegerField(default=0)),
+ ('detailed_xp', models.JSONField(blank=True, default=list)),
+ ('raw_player', models.JSONField(blank=True, default=dict)),
+ ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_snapshots', to='community_xp.mee6syncrun')),
+ ],
+ options={
+ 'ordering': ['run', 'rank'],
+ 'unique_together': {('run', 'discord_id')},
+ },
+ ),
+ migrations.CreateModel(
+ name='Mee6CurrentXP',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('guild_id', models.CharField(db_index=True, max_length=100)),
+ ('discord_id', models.CharField(db_index=True, max_length=100)),
+ ('username', models.CharField(blank=True, max_length=100)),
+ ('discriminator', models.CharField(blank=True, max_length=10)),
+ ('avatar_hash', models.CharField(blank=True, max_length=100)),
+ ('rank', models.PositiveIntegerField()),
+ ('xp', models.PositiveIntegerField(default=0)),
+ ('level', models.PositiveIntegerField(default=0)),
+ ('message_count', models.PositiveIntegerField(default=0)),
+ ('detailed_xp', models.JSONField(blank=True, default=list)),
+ ('matched_at', models.DateTimeField(blank=True, null=True)),
+ ('synced_at', models.DateTimeField()),
+ ('matched_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mee6_current_xp_rows', to=settings.AUTH_USER_MODEL)),
+ ('source_snapshot', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='current_xp_rows', to='community_xp.mee6playersnapshot')),
+ ('sync_run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='current_xp_rows', to='community_xp.mee6syncrun')),
+ ],
+ options={
+ 'ordering': ['rank'],
+ 'unique_together': {('guild_id', 'discord_id')},
+ },
+ ),
+ migrations.AddIndex(
+ model_name='mee6syncrun',
+ index=models.Index(fields=['guild_id', 'status', '-completed_at'], name='community_x_guild_i_e893a4_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6syncrun',
+ index=models.Index(fields=['status', '-started_at'], name='community_x_status_b13a2e_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6playersnapshot',
+ index=models.Index(fields=['guild_id', 'discord_id'], name='community_x_guild_i_3d03ef_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6playersnapshot',
+ index=models.Index(fields=['run', 'rank'], name='community_x_run_id_4608f5_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6currentxp',
+ index=models.Index(fields=['guild_id', 'rank'], name='community_x_guild_i_c99401_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6currentxp',
+ index=models.Index(fields=['guild_id', 'discord_id'], name='community_x_guild_i_c04a8b_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6currentxp',
+ index=models.Index(fields=['matched_user'], name='community_x_matched_04c14e_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='mee6currentxp',
+ index=models.Index(fields=['sync_run'], name='community_x_sync_ru_274a7d_idx'),
+ ),
+ ]
diff --git a/backend/community_xp/migrations/__init__.py b/backend/community_xp/migrations/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/community_xp/migrations/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/community_xp/models.py b/backend/community_xp/models.py
new file mode 100644
index 00000000..a70f7692
--- /dev/null
+++ b/backend/community_xp/models.py
@@ -0,0 +1,139 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+from utils.models import BaseModel
+
+
+class Mee6SyncRun(BaseModel):
+ STATUS_RUNNING = 'running'
+ STATUS_SUCCESS = 'success'
+ STATUS_FAILED = 'failed'
+
+ STATUS_CHOICES = [
+ (STATUS_RUNNING, 'Running'),
+ (STATUS_SUCCESS, 'Success'),
+ (STATUS_FAILED, 'Failed'),
+ ]
+
+ guild_id = models.CharField(max_length=100, db_index=True)
+ guild_name = models.CharField(max_length=255, blank=True)
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_RUNNING)
+ page_size = models.PositiveIntegerField(default=1000)
+ pages_fetched = models.PositiveIntegerField(default=0)
+ players_fetched = models.PositiveIntegerField(default=0)
+ duplicate_players = models.PositiveIntegerField(default=0)
+ matched_players = models.PositiveIntegerField(default=0)
+ unmatched_players = models.PositiveIntegerField(default=0)
+ started_at = models.DateTimeField(default=timezone.now)
+ completed_at = models.DateTimeField(null=True, blank=True)
+ applied_at = models.DateTimeField(null=True, blank=True)
+ applied_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='applied_mee6_sync_runs',
+ )
+ error_message = models.TextField(blank=True)
+
+ class Meta:
+ ordering = ['-started_at', '-id']
+ indexes = [
+ models.Index(fields=['guild_id', 'status', '-completed_at']),
+ models.Index(fields=['status', '-started_at']),
+ ]
+
+ def __str__(self):
+ return f"MEE6 sync {self.guild_id} #{self.pk} ({self.status})"
+
+
+class Mee6PlayerSnapshot(BaseModel):
+ run = models.ForeignKey(
+ Mee6SyncRun,
+ on_delete=models.CASCADE,
+ related_name='player_snapshots',
+ )
+ guild_id = models.CharField(max_length=100, db_index=True)
+ discord_id = models.CharField(max_length=100, db_index=True)
+ username = models.CharField(max_length=100, blank=True)
+ discriminator = models.CharField(max_length=10, blank=True)
+ avatar_hash = models.CharField(max_length=100, blank=True)
+ rank = models.PositiveIntegerField()
+ xp = models.PositiveIntegerField(default=0)
+ level = models.PositiveIntegerField(default=0)
+ message_count = models.PositiveIntegerField(default=0)
+ detailed_xp = models.JSONField(default=list, blank=True)
+ raw_player = models.JSONField(default=dict, blank=True)
+
+ class Meta:
+ ordering = ['run', 'rank']
+ unique_together = [('run', 'discord_id')]
+ indexes = [
+ models.Index(fields=['guild_id', 'discord_id']),
+ models.Index(fields=['run', 'rank']),
+ ]
+
+ def __str__(self):
+ return f"{self.discord_id} - {self.xp} XP ({self.guild_id})"
+
+
+class Mee6CurrentXP(BaseModel):
+ guild_id = models.CharField(max_length=100, db_index=True)
+ discord_id = models.CharField(max_length=100, db_index=True)
+ username = models.CharField(max_length=100, blank=True)
+ discriminator = models.CharField(max_length=10, blank=True)
+ avatar_hash = models.CharField(max_length=100, blank=True)
+ rank = models.PositiveIntegerField()
+ xp = models.PositiveIntegerField(default=0)
+ level = models.PositiveIntegerField(default=0)
+ message_count = models.PositiveIntegerField(default=0)
+ detailed_xp = models.JSONField(default=list, blank=True)
+ sync_run = models.ForeignKey(
+ Mee6SyncRun,
+ on_delete=models.PROTECT,
+ related_name='current_xp_rows',
+ )
+ source_snapshot = models.ForeignKey(
+ Mee6PlayerSnapshot,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='current_xp_rows',
+ )
+ matched_user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='mee6_current_xp_rows',
+ )
+ matched_at = models.DateTimeField(null=True, blank=True)
+ synced_at = models.DateTimeField()
+
+ class Meta:
+ ordering = ['rank']
+ unique_together = [('guild_id', 'discord_id')]
+ indexes = [
+ models.Index(fields=['guild_id', 'rank']),
+ models.Index(fields=['guild_id', 'discord_id']),
+ models.Index(fields=['matched_user']),
+ models.Index(fields=['sync_run']),
+ ]
+
+ def __str__(self):
+ return f"{self.discord_id} - {self.xp} current XP ({self.guild_id})"
+
+
+class Mee6SyncLock(models.Model):
+ name = models.CharField(max_length=100, unique=True)
+ owner_token = models.CharField(max_length=32, null=True, blank=True, db_index=True)
+ acquired_at = models.DateTimeField(null=True, blank=True)
+ heartbeat_at = models.DateTimeField(null=True, blank=True)
+ released_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ db_table = 'community_xp_mee6_sync_lock'
+
+ def __str__(self):
+ return f"Mee6SyncLock({self.name}, acquired={self.acquired_at})"
diff --git a/backend/community_xp/services.py b/backend/community_xp/services.py
new file mode 100644
index 00000000..1d691d58
--- /dev/null
+++ b/backend/community_xp/services.py
@@ -0,0 +1,580 @@
+import random
+import secrets
+import time
+from dataclasses import dataclass
+from email.utils import parsedate_to_datetime
+
+import requests
+from django.conf import settings
+from django.db import IntegrityError, transaction
+from django.utils import timezone
+from requests import RequestException
+
+from creators.models import Creator
+from social_connections.models import DiscordConnection
+from tally.middleware.logging_utils import get_app_logger
+
+from .constants import COMMUNITY_XP_EXCLUDED_TYPE_SLUGS
+from .models import Mee6CurrentXP, Mee6PlayerSnapshot, Mee6SyncLock, Mee6SyncRun
+
+logger = get_app_logger('community_xp')
+
+DEFAULT_GUILD_ID = '1237055789441487021'
+DEFAULT_PAGE_SIZE = 1000
+LOCK_NAME = 'mee6_xp_sync'
+
+
+class Mee6SyncError(Exception):
+ """Raised when a MEE6 sync cannot safely complete."""
+
+
+class Mee6SyncValidationError(Mee6SyncError):
+ """Raised when sync input is invalid before contacting MEE6."""
+
+
+class Mee6SyncAlreadyRunning(Mee6SyncError):
+ def __init__(self, elapsed_seconds):
+ self.elapsed_seconds = elapsed_seconds
+ super().__init__('MEE6 XP sync already running')
+
+
+@dataclass(frozen=True)
+class NormalizedMee6Player:
+ discord_id: str
+ username: str
+ discriminator: str
+ avatar_hash: str
+ rank: int
+ xp: int
+ level: int
+ message_count: int
+ detailed_xp: list
+ raw_player: dict
+
+
+@dataclass(frozen=True)
+class Mee6FetchResult:
+ guild_id: str
+ guild_name: str
+ page_size: int
+ pages_fetched: int
+ players: list[NormalizedMee6Player]
+ duplicate_players: int
+
+
+def get_default_guild_id():
+ return (
+ getattr(settings, 'MEE6_GUILD_ID', '')
+ or getattr(settings, 'DISCORD_GUILD_ID', '')
+ or DEFAULT_GUILD_ID
+ )
+
+
+def get_default_page_size():
+ return int(getattr(settings, 'MEE6_PAGE_SIZE', DEFAULT_PAGE_SIZE) or DEFAULT_PAGE_SIZE)
+
+
+def _as_non_negative_int(value, field_name):
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError) as exc:
+ raise Mee6SyncError(f'Malformed MEE6 player field: {field_name}') from exc
+ if parsed < 0:
+ raise Mee6SyncError(f'Malformed MEE6 player field: {field_name} must be non-negative')
+ return parsed
+
+
+def _retry_after_seconds(value):
+ if not value:
+ return None
+ try:
+ return max(float(value), 0)
+ except (TypeError, ValueError):
+ pass
+
+ try:
+ retry_at = parsedate_to_datetime(value)
+ except (TypeError, ValueError):
+ return None
+ return max((retry_at - timezone.now()).total_seconds(), 0)
+
+
+class Mee6Client:
+ base_url = 'https://mee6.xyz/api/plugins/levels/leaderboard'
+
+ def __init__(
+ self,
+ session=None,
+ timeout=None,
+ max_retries=None,
+ base_delay=None,
+ max_delay=None,
+ inter_page_delay=None,
+ sleep_func=time.sleep,
+ ):
+ self.session = session or requests.Session()
+ self.timeout = timeout if timeout is not None else float(getattr(settings, 'MEE6_REQUEST_TIMEOUT', 20))
+ self.max_retries = max_retries if max_retries is not None else int(getattr(settings, 'MEE6_MAX_RETRIES', 3))
+ self.base_delay = base_delay if base_delay is not None else float(getattr(settings, 'MEE6_RETRY_BASE_DELAY', 1))
+ self.max_delay = max_delay if max_delay is not None else float(getattr(settings, 'MEE6_RETRY_MAX_DELAY', 30))
+ self.inter_page_delay = (
+ inter_page_delay
+ if inter_page_delay is not None
+ else float(getattr(settings, 'MEE6_INTER_PAGE_DELAY', 0.25))
+ )
+ self.sleep_func = sleep_func
+
+ def fetch_all_players(self, guild_id, page_size):
+ players = []
+ seen_discord_ids = set()
+ duplicate_players = 0
+ pages_fetched = 0
+ guild_name = ''
+
+ page = 0
+ while True:
+ payload = self.fetch_page(guild_id, page, page_size)
+ guild = payload.get('guild') or {}
+ response_guild_id = str(guild.get('id') or guild_id)
+ if response_guild_id != str(guild_id):
+ raise Mee6SyncError(
+ f'MEE6 response guild mismatch: expected {guild_id}, got {response_guild_id}'
+ )
+
+ if guild.get('name'):
+ guild_name = str(guild['name'])[:255]
+
+ page_players = payload.get('players')
+ if not isinstance(page_players, list):
+ raise Mee6SyncError('Malformed MEE6 response: players must be a list')
+ if page == 0 and not page_players:
+ raise Mee6SyncError('MEE6 returned no players on the first page')
+
+ pages_fetched += 1
+ for index, raw_player in enumerate(page_players):
+ player = self.normalize_player(raw_player, rank=(page * page_size) + index + 1)
+ if player.discord_id in seen_discord_ids:
+ duplicate_players += 1
+ continue
+ seen_discord_ids.add(player.discord_id)
+ players.append(player)
+
+ if len(page_players) < page_size:
+ break
+ if self.inter_page_delay > 0:
+ self.sleep_func(self.inter_page_delay)
+ page += 1
+
+ return Mee6FetchResult(
+ guild_id=str(guild_id),
+ guild_name=guild_name,
+ page_size=page_size,
+ pages_fetched=pages_fetched,
+ players=players,
+ duplicate_players=duplicate_players,
+ )
+
+ def fetch_page(self, guild_id, page, page_size):
+ url = f'{self.base_url}/{guild_id}'
+ params = {'page': page, 'limit': page_size}
+ last_error = None
+
+ for attempt in range(self.max_retries + 1):
+ try:
+ response = self.session.get(url, params=params, timeout=self.timeout)
+ except RequestException as exc:
+ last_error = exc
+ if attempt >= self.max_retries:
+ raise Mee6SyncError(f'MEE6 request failed for page {page}: {exc}') from exc
+ self._sleep_before_retry(attempt)
+ continue
+
+ if response.status_code == 429 or response.status_code >= 500:
+ last_error = f'HTTP {response.status_code}'
+ if attempt >= self.max_retries:
+ raise Mee6SyncError(f'MEE6 request failed for page {page}: HTTP {response.status_code}')
+ self._sleep_before_retry(attempt, response=response)
+ continue
+
+ if response.status_code >= 400:
+ raise Mee6SyncError(f'MEE6 request failed for page {page}: HTTP {response.status_code}')
+
+ try:
+ return response.json()
+ except ValueError as exc:
+ raise Mee6SyncError(f'MEE6 returned malformed JSON for page {page}') from exc
+
+ raise Mee6SyncError(f'MEE6 request failed for page {page}: {last_error}')
+
+ def _sleep_before_retry(self, attempt, response=None):
+ retry_after = _retry_after_seconds(response.headers.get('Retry-After')) if response is not None else None
+ delay = retry_after
+ if delay is None:
+ delay = min(self.max_delay, self.base_delay * (2 ** attempt))
+ delay += random.uniform(0, 0.25)
+ self.sleep_func(delay)
+
+ @staticmethod
+ def normalize_player(raw_player, rank):
+ if not isinstance(raw_player, dict):
+ raise Mee6SyncError('Malformed MEE6 response: player must be an object')
+
+ discord_id = str(raw_player.get('id') or '').strip()
+ if not discord_id:
+ raise Mee6SyncError('Malformed MEE6 player field: id')
+
+ detailed_xp = raw_player.get('detailed_xp')
+ if not isinstance(detailed_xp, list):
+ detailed_xp = []
+
+ return NormalizedMee6Player(
+ discord_id=discord_id,
+ username=str(raw_player.get('username') or '')[:100],
+ discriminator=str(raw_player.get('discriminator') or '')[:10],
+ avatar_hash=str(raw_player.get('avatar') or '')[:100],
+ rank=rank,
+ xp=_as_non_negative_int(raw_player.get('xp', 0), 'xp'),
+ level=_as_non_negative_int(raw_player.get('level', 0), 'level'),
+ message_count=_as_non_negative_int(raw_player.get('message_count', 0), 'message_count'),
+ detailed_xp=detailed_xp,
+ raw_player=raw_player,
+ )
+
+
+def _ensure_sync_lock_row():
+ try:
+ Mee6SyncLock.objects.get_or_create(name=LOCK_NAME)
+ except IntegrityError:
+ pass
+
+
+def acquire_sync_lock(stale_after_seconds=None):
+ stale_after_seconds = stale_after_seconds or int(getattr(settings, 'MEE6_SYNC_LOCK_STALE_AFTER_SECONDS', 3600))
+ _ensure_sync_lock_row()
+
+ with transaction.atomic():
+ now = timezone.now()
+ lock_row = Mee6SyncLock.objects.select_for_update().get(name=LOCK_NAME)
+ is_running = (
+ lock_row.owner_token is not None
+ and lock_row.heartbeat_at is not None
+ and (lock_row.released_at is None or lock_row.heartbeat_at > lock_row.released_at)
+ )
+
+ if is_running:
+ elapsed_seconds = (now - lock_row.heartbeat_at).total_seconds()
+ if elapsed_seconds <= stale_after_seconds:
+ return None, elapsed_seconds
+ logger.warning("Stale MEE6 XP sync lock detected after %ss", f"{elapsed_seconds:.0f}")
+
+ owner_token = secrets.token_hex(16)
+ lock_row.owner_token = owner_token
+ lock_row.acquired_at = now
+ lock_row.heartbeat_at = now
+ lock_row.released_at = None
+ lock_row.save(update_fields=['owner_token', 'acquired_at', 'heartbeat_at', 'released_at'])
+
+ return owner_token, None
+
+
+def release_sync_lock(owner_token):
+ if not owner_token:
+ return
+ now = timezone.now()
+ Mee6SyncLock.objects.filter(
+ name=LOCK_NAME,
+ owner_token=owner_token,
+ ).update(owner_token=None, heartbeat_at=now, released_at=now)
+
+
+def _connection_map(discord_ids):
+ connections = (
+ DiscordConnection.objects
+ .filter(platform_user_id__in=discord_ids)
+ .select_related('user')
+ .order_by('id')
+ )
+
+ by_discord_id = {}
+ for connection in connections:
+ by_discord_id.setdefault(str(connection.platform_user_id), connection)
+ return by_discord_id
+
+
+def _auto_create_creator_profiles(user_ids):
+ user_ids = {user_id for user_id in user_ids if user_id}
+ if not user_ids:
+ return 0
+
+ existing = set(
+ Creator.objects
+ .filter(user_id__in=user_ids)
+ .values_list('user_id', flat=True)
+ )
+ missing = [Creator(user_id=user_id) for user_id in user_ids - existing]
+ if missing:
+ Creator.objects.bulk_create(missing, ignore_conflicts=True)
+ return len(missing)
+
+
+def store_fetch_result(run, fetch_result):
+ now = timezone.now()
+ discord_ids = [player.discord_id for player in fetch_result.players]
+ connections_by_discord_id = _connection_map(discord_ids)
+ matched_user_ids = {
+ connection.user_id
+ for connection in connections_by_discord_id.values()
+ }
+ snapshots = []
+
+ for player in fetch_result.players:
+ snapshots.append(Mee6PlayerSnapshot(
+ run=run,
+ guild_id=fetch_result.guild_id,
+ discord_id=player.discord_id,
+ username=player.username,
+ discriminator=player.discriminator,
+ avatar_hash=player.avatar_hash,
+ rank=player.rank,
+ xp=player.xp,
+ level=player.level,
+ message_count=player.message_count,
+ detailed_xp=player.detailed_xp,
+ raw_player=player.raw_player,
+ ))
+
+ with transaction.atomic():
+ Mee6PlayerSnapshot.objects.bulk_create(snapshots, batch_size=1000)
+ run.guild_name = fetch_result.guild_name
+ run.status = Mee6SyncRun.STATUS_SUCCESS
+ run.page_size = fetch_result.page_size
+ run.pages_fetched = fetch_result.pages_fetched
+ run.players_fetched = len(fetch_result.players)
+ run.duplicate_players = fetch_result.duplicate_players
+ run.matched_players = len(matched_user_ids)
+ run.unmatched_players = len(fetch_result.players) - len(matched_user_ids)
+ run.completed_at = now
+ run.error_message = ''
+ run.save(update_fields=[
+ 'guild_name',
+ 'status',
+ 'page_size',
+ 'pages_fetched',
+ 'players_fetched',
+ 'duplicate_players',
+ 'matched_players',
+ 'unmatched_players',
+ 'completed_at',
+ 'error_message',
+ 'updated_at',
+ ])
+
+ return {
+ 'run_id': run.id,
+ 'guild_id': run.guild_id,
+ 'status': run.status,
+ 'players_fetched': run.players_fetched,
+ 'pages_fetched': run.pages_fetched,
+ 'matched_players': run.matched_players,
+ 'unmatched_players': run.unmatched_players,
+ 'duplicate_players': run.duplicate_players,
+ 'applied': False,
+ 'creator_profiles_created': 0,
+ }
+
+
+def _validate_contribution_xp_state_before_applying(run):
+ from contributions.models import ContributionDiscordXPState
+
+ late_distribution = (
+ ContributionDiscordXPState.objects
+ .filter(
+ contribution__contribution_type__category__slug='community',
+ distributed_at__gt=run.completed_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:
+ raise Mee6SyncError(
+ f'Cannot apply MEE6 sync #{run.id}; contribution '
+ f'#{late_distribution.contribution_id} was marked distributed after this snapshot was fetched. '
+ 'Fetch a newer MEE6 snapshot before applying a new baseline.'
+ )
+
+
+def apply_sync_run(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:
+ raise Mee6SyncError('Cannot apply a MEE6 sync run before it has completed')
+
+ latest_applied_run = (
+ Mee6SyncRun.objects
+ .filter(
+ guild_id=run.guild_id,
+ status=Mee6SyncRun.STATUS_SUCCESS,
+ completed_at__isnull=False,
+ applied_at__isnull=False,
+ )
+ .exclude(id=run.id)
+ .order_by('-completed_at', '-id')
+ .first()
+ )
+ if latest_applied_run and run.completed_at < latest_applied_run.completed_at:
+ raise Mee6SyncError(
+ f'Cannot apply MEE6 sync #{run.id}; sync #{latest_applied_run.id} is newer and already applied'
+ )
+
+ _validate_contribution_xp_state_before_applying(run)
+
+ now = timezone.now()
+ snapshots = list(
+ Mee6PlayerSnapshot.objects
+ .filter(run=run)
+ .order_by('rank')
+ )
+ if not snapshots:
+ raise Mee6SyncError('Cannot apply a MEE6 sync run with no player snapshots')
+
+ discord_ids = [snapshot.discord_id for snapshot in snapshots]
+ connections_by_discord_id = _connection_map(discord_ids)
+ matched_user_ids = set()
+ creator_user_ids = set()
+ current_rows = []
+
+ for snapshot in snapshots:
+ connection = connections_by_discord_id.get(str(snapshot.discord_id))
+ matched_user = connection.user if connection else None
+ matched_at = now if matched_user else None
+ if matched_user:
+ matched_user_ids.add(matched_user.id)
+ if snapshot.xp > 0:
+ creator_user_ids.add(matched_user.id)
+
+ current_rows.append(Mee6CurrentXP(
+ guild_id=snapshot.guild_id,
+ discord_id=snapshot.discord_id,
+ username=snapshot.username,
+ discriminator=snapshot.discriminator,
+ avatar_hash=snapshot.avatar_hash,
+ rank=snapshot.rank,
+ xp=snapshot.xp,
+ level=snapshot.level,
+ message_count=snapshot.message_count,
+ detailed_xp=snapshot.detailed_xp,
+ sync_run=run,
+ source_snapshot=snapshot,
+ matched_user=matched_user,
+ matched_at=matched_at,
+ synced_at=run.completed_at,
+ ))
+
+ with transaction.atomic():
+ Mee6CurrentXP.objects.filter(guild_id=run.guild_id).delete()
+ Mee6CurrentXP.objects.bulk_create(current_rows, batch_size=1000)
+ creator_profiles_created = _auto_create_creator_profiles(creator_user_ids)
+
+ run.matched_players = len(matched_user_ids)
+ run.unmatched_players = len(snapshots) - len(matched_user_ids)
+ run.applied_at = now
+ run.applied_by = applied_by if getattr(applied_by, 'pk', None) else None
+ run.save(update_fields=[
+ 'matched_players',
+ 'unmatched_players',
+ 'applied_at',
+ 'applied_by',
+ 'updated_at',
+ ])
+
+ return {
+ 'run_id': run.id,
+ 'guild_id': run.guild_id,
+ 'status': run.status,
+ 'players_applied': len(snapshots),
+ 'matched_players': run.matched_players,
+ 'unmatched_players': run.unmatched_players,
+ 'creator_profiles_created': creator_profiles_created,
+ 'applied_at': run.applied_at,
+ }
+
+
+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:
+ page_size = int(page_size or get_default_page_size())
+ except (TypeError, ValueError) as exc:
+ raise Mee6SyncValidationError('MEE6 page size must be a positive integer') from exc
+ if page_size <= 0:
+ raise Mee6SyncValidationError('MEE6 page size must be positive')
+
+ owner_token = None
+ if use_lock:
+ owner_token, elapsed_seconds = acquire_sync_lock()
+ if not owner_token:
+ raise Mee6SyncAlreadyRunning(elapsed_seconds)
+
+ run = Mee6SyncRun.objects.create(
+ guild_id=guild_id,
+ page_size=page_size,
+ status=Mee6SyncRun.STATUS_RUNNING,
+ )
+
+ try:
+ client = client or Mee6Client()
+ fetch_result = client.fetch_all_players(guild_id, page_size)
+ if not fetch_result.players:
+ raise Mee6SyncError('MEE6 returned no players')
+ return store_fetch_result(run, fetch_result)
+ except Exception as exc:
+ run.status = Mee6SyncRun.STATUS_FAILED
+ run.completed_at = timezone.now()
+ run.error_message = str(exc)[:4000]
+ run.save(update_fields=['status', 'completed_at', 'error_message', 'updated_at'])
+ logger.error("MEE6 XP sync failed: %s", exc, exc_info=True)
+ raise
+ finally:
+ if owner_token:
+ release_sync_lock(owner_token)
+
+
+def match_current_xp_for_connection(connection, guild_id=None, create_creator=True):
+ guild_id = str(guild_id or get_default_guild_id())
+ discord_id = str(connection.platform_user_id or '')
+ if not discord_id:
+ return None
+
+ with transaction.atomic():
+ current = (
+ Mee6CurrentXP.objects
+ .select_for_update()
+ .filter(guild_id=guild_id, discord_id=discord_id)
+ .first()
+ )
+ if not current:
+ return None
+ if current.matched_user_id == connection.user_id:
+ return current
+
+ current.matched_user = connection.user
+ current.matched_at = timezone.now()
+ current.save(update_fields=['matched_user', 'matched_at', 'updated_at'])
+
+ if create_creator and current.xp > 0:
+ Creator.objects.get_or_create(user=connection.user)
+
+ return current
+
+
+def clear_current_xp_match_for_connection(connection, guild_id=None):
+ guild_id = str(guild_id or get_default_guild_id())
+ discord_id = str(connection.platform_user_id or '')
+ if not discord_id:
+ return 0
+ return Mee6CurrentXP.objects.filter(
+ guild_id=guild_id,
+ discord_id=discord_id,
+ matched_user=connection.user,
+ ).update(matched_user=None, matched_at=None)
diff --git a/backend/community_xp/signals.py b/backend/community_xp/signals.py
new file mode 100644
index 00000000..fa035fa2
--- /dev/null
+++ b/backend/community_xp/signals.py
@@ -0,0 +1,36 @@
+from django.db.models.signals import post_delete, post_save, pre_save
+from django.dispatch import receiver
+
+from social_connections.models import DiscordConnection
+
+from .services import clear_current_xp_match_for_connection, match_current_xp_for_connection
+
+
+@receiver(pre_save, sender=DiscordConnection)
+def clear_mee6_xp_before_discord_relink(sender, instance, **kwargs):
+ if not instance.pk:
+ instance._mee6_platform_user_id_changed = True
+ return
+ try:
+ previous = sender.objects.get(pk=instance.pk)
+ except sender.DoesNotExist:
+ instance._mee6_platform_user_id_changed = True
+ return
+
+ platform_user_id_changed = previous.platform_user_id != instance.platform_user_id
+ instance._mee6_platform_user_id_changed = platform_user_id_changed
+
+ if platform_user_id_changed:
+ clear_current_xp_match_for_connection(previous)
+
+
+@receiver(post_save, sender=DiscordConnection)
+def match_mee6_xp_after_discord_link(sender, instance, created, **kwargs):
+ if not created and not getattr(instance, '_mee6_platform_user_id_changed', False):
+ return
+ match_current_xp_for_connection(instance)
+
+
+@receiver(post_delete, sender=DiscordConnection)
+def clear_mee6_xp_after_discord_unlink(sender, instance, **kwargs):
+ clear_current_xp_match_for_connection(instance)
diff --git a/backend/community_xp/templates/admin/community_xp/mee6syncrun/change_list.html b/backend/community_xp/templates/admin/community_xp/mee6syncrun/change_list.html
new file mode 100644
index 00000000..6cc1ef77
--- /dev/null
+++ b/backend/community_xp/templates/admin/community_xp/mee6syncrun/change_list.html
@@ -0,0 +1,13 @@
+{% extends "admin/change_list.html" %}
+
+{% block object-tools-items %}
+ {% if can_fetch_new_snapshot %}
+
+
+
+ {% endif %}
+ {{ block.super }}
+{% endblock %}
diff --git a/backend/community_xp/tests/__init__.py b/backend/community_xp/tests/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/community_xp/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py
new file mode 100644
index 00000000..3aac5621
--- /dev/null
+++ b/backend/community_xp/tests/test_mee6_sync.py
@@ -0,0 +1,552 @@
+from datetime import timedelta
+from unittest.mock import Mock, patch
+
+from django.contrib.admin.sites import AdminSite
+from django.core.exceptions import PermissionDenied
+from django.test import RequestFactory, TestCase, override_settings
+from django.utils import timezone
+from rest_framework.test import APIClient
+from community_xp.admin import Mee6SyncRunAdmin
+from community_xp.models import (
+ Mee6CurrentXP,
+ Mee6PlayerSnapshot,
+ Mee6SyncRun,
+)
+from community_xp.services import (
+ Mee6FetchResult,
+ Mee6SyncError,
+ NormalizedMee6Player,
+ apply_sync_run,
+ run_mee6_sync,
+)
+from community_xp.utils import get_effective_community_points
+from contributions.models import Category, Contribution, ContributionDiscordXPState, ContributionType
+from creators.models import Creator
+from leaderboard.models import GlobalLeaderboardMultiplier
+from social_connections.serializers import DiscordConnectionSerializer
+from social_connections.models import DiscordConnection
+from users.models import User
+
+
+class FakeMee6Client:
+ def __init__(self, players=None, error=None, pages_fetched=1, duplicate_players=0):
+ self.players = players or []
+ self.error = error
+ self.pages_fetched = pages_fetched
+ self.duplicate_players = duplicate_players
+
+ def fetch_all_players(self, guild_id, page_size):
+ if self.error:
+ raise self.error
+ return Mee6FetchResult(
+ guild_id=str(guild_id),
+ guild_name='GenLayer',
+ page_size=page_size,
+ pages_fetched=self.pages_fetched,
+ players=self.players,
+ duplicate_players=self.duplicate_players,
+ )
+
+
+def mee6_player(discord_id, xp, rank=1, username='discord-user'):
+ return NormalizedMee6Player(
+ discord_id=str(discord_id),
+ username=username,
+ discriminator='0',
+ avatar_hash='avatar',
+ rank=rank,
+ xp=xp,
+ level=1,
+ message_count=10,
+ detailed_xp=[0, 100, xp],
+ raw_player={
+ 'id': str(discord_id),
+ 'username': username,
+ 'xp': xp,
+ 'level': 1,
+ 'message_count': 10,
+ 'detailed_xp': [0, 100, xp],
+ },
+ )
+
+
+@override_settings(DISCORD_GUILD_ID='guild-1')
+class Mee6SyncTest(TestCase):
+ def setUp(self):
+ self.api_client = APIClient()
+ self.community_category, _ = Category.objects.get_or_create(
+ slug='community',
+ defaults={
+ 'name': 'Community',
+ 'description': 'Community',
+ 'profile_model': '',
+ },
+ )
+ self.community_type = ContributionType.objects.create(
+ name='Special Quest',
+ slug='community-special-quest-test',
+ category=self.community_category,
+ min_points=0,
+ max_points=1_000_000,
+ is_submittable=True,
+ )
+ self.discord_link_type = ContributionType.objects.create(
+ name='Link Discord Account',
+ slug='community-link-discord',
+ category=self.community_category,
+ min_points=20,
+ max_points=20,
+ is_submittable=False,
+ )
+ GlobalLeaderboardMultiplier.objects.create(
+ contribution_type=self.community_type,
+ multiplier_value=1,
+ valid_from=timezone.now() - timedelta(days=30),
+ description='Test multiplier',
+ )
+ GlobalLeaderboardMultiplier.objects.create(
+ contribution_type=self.discord_link_type,
+ multiplier_value=1,
+ valid_from=timezone.now() - timedelta(days=30),
+ description='Test link multiplier',
+ )
+ self.user = User.objects.create_user(
+ email='user@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000001',
+ name='User One',
+ )
+ self.request_factory = RequestFactory()
+
+ def add_community_contribution(self, user, points):
+ return Contribution.objects.create(
+ user=user,
+ contribution_type=self.community_type,
+ points=points,
+ contribution_date=timezone.now(),
+ notes='Community audit row',
+ )
+
+ def add_discord_link_contribution(self, user):
+ return Contribution.objects.create(
+ user=user,
+ contribution_type=self.discord_link_type,
+ points=20,
+ contribution_date=timezone.now(),
+ notes='Discord link audit row',
+ )
+
+ def mark_discord_xp_distributed(self, contribution):
+ state = contribution.discord_xp_state
+ state.awarded_amount = int(contribution.frozen_global_points or 0)
+ state.status = ContributionDiscordXPState.STATUS_DISTRIBUTED
+ state.distributed_at = timezone.now()
+ state.save(update_fields=['awarded_amount', 'status', 'distributed_at', 'updated_at'])
+ return state
+
+ def link_discord(self, user, discord_id='discord-1'):
+ return DiscordConnection.objects.create(
+ user=user,
+ platform_user_id=discord_id,
+ platform_username=f'user-{discord_id}',
+ linked_at=timezone.now(),
+ )
+
+ def fetch_mee6_run(self, players):
+ result = run_mee6_sync(
+ client=FakeMee6Client(players=players),
+ use_lock=False,
+ )
+ return Mee6SyncRun.objects.get(pk=result['run_id'])
+
+ def fetch_and_apply_mee6_run(self, players):
+ run = self.fetch_mee6_run(players)
+ apply_sync_run(run)
+ run.refresh_from_db()
+ return run
+
+ def test_admin_action_fetches_new_snapshot_from_selected_run_settings(self):
+ source_run = Mee6SyncRun.objects.create(
+ guild_id='guild-2',
+ page_size=500,
+ status=Mee6SyncRun.STATUS_SUCCESS,
+ )
+ request = Mock(user=self.user)
+ model_admin = Mee6SyncRunAdmin(Mee6SyncRun, AdminSite())
+ model_admin.has_add_permission = Mock(return_value=True)
+ model_admin.has_change_permission = Mock(return_value=True)
+ model_admin.message_user = Mock()
+
+ with patch('community_xp.admin.run_mee6_sync') as run_mee6_sync_mock:
+ run_mee6_sync_mock.return_value = {
+ 'run_id': 123,
+ 'players_fetched': 10,
+ 'matched_players': 7,
+ 'unmatched_players': 3,
+ 'pages_fetched': 1,
+ }
+ model_admin.fetch_new_snapshot(
+ request,
+ Mee6SyncRun.objects.filter(pk=source_run.pk),
+ )
+
+ run_mee6_sync_mock.assert_called_once_with(
+ guild_id='guild-2',
+ page_size=500,
+ )
+ self.assertIn('Fetched MEE6 sync #123', model_admin.message_user.call_args.args[1])
+
+ def test_admin_fetch_button_fetches_new_snapshot_with_default_settings(self):
+ request = self.request_factory.post('/admin/community_xp/mee6syncrun/fetch-new-snapshot/')
+ request.user = self.user
+ model_admin = Mee6SyncRunAdmin(Mee6SyncRun, AdminSite())
+ model_admin.has_add_permission = Mock(return_value=True)
+ model_admin.message_user = Mock()
+
+ with patch('community_xp.admin.run_mee6_sync') as run_mee6_sync_mock:
+ run_mee6_sync_mock.return_value = {
+ 'run_id': 124,
+ 'players_fetched': 11,
+ 'matched_players': 8,
+ 'unmatched_players': 3,
+ 'pages_fetched': 2,
+ }
+ response = model_admin.fetch_new_snapshot_view(request)
+
+ run_mee6_sync_mock.assert_called_once_with(
+ guild_id=None,
+ page_size=None,
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertIn('Fetched MEE6 sync #124', model_admin.message_user.call_args.args[1])
+
+ def test_admin_fetch_button_requires_add_permission(self):
+ request = self.request_factory.post('/admin/community_xp/mee6syncrun/fetch-new-snapshot/')
+ request.user = self.user
+ model_admin = Mee6SyncRunAdmin(Mee6SyncRun, AdminSite())
+ model_admin.has_add_permission = Mock(return_value=False)
+
+ with patch('community_xp.admin.run_mee6_sync') as run_mee6_sync_mock:
+ with self.assertRaises(PermissionDenied):
+ model_admin.fetch_new_snapshot_view(request)
+
+ run_mee6_sync_mock.assert_not_called()
+
+ def test_admin_selected_run_fetch_requires_add_and_change_permission(self):
+ source_run = Mee6SyncRun.objects.create(
+ guild_id='guild-2',
+ page_size=500,
+ status=Mee6SyncRun.STATUS_SUCCESS,
+ )
+ request = Mock(user=self.user)
+ model_admin = Mee6SyncRunAdmin(Mee6SyncRun, AdminSite())
+ model_admin.has_add_permission = Mock(return_value=True)
+ model_admin.has_change_permission = Mock(return_value=False)
+
+ with patch('community_xp.admin.run_mee6_sync') as run_mee6_sync_mock:
+ with self.assertRaises(PermissionDenied):
+ model_admin.fetch_new_snapshot(
+ request,
+ Mee6SyncRun.objects.filter(pk=source_run.pk),
+ )
+
+ run_mee6_sync_mock.assert_not_called()
+
+ def test_pre_sync_uses_all_portal_community_points(self):
+ self.add_community_contribution(self.user, 40)
+
+ breakdown = get_effective_community_points(self.user)
+
+ self.assertEqual(breakdown['total_points'], 40)
+ self.assertEqual(breakdown['tracked_portal_points_all_time'], 40)
+ self.assertEqual(breakdown['pending_portal_points'], 40)
+ self.assertEqual(breakdown['discord_xp'], 0)
+ self.assertFalse(breakdown['has_discord_xp_snapshot'])
+
+ def test_onboarding_link_rewards_stay_auditable_but_do_not_count(self):
+ link_contribution = self.add_discord_link_contribution(self.user)
+ self.add_community_contribution(self.user, 40)
+
+ breakdown = get_effective_community_points(self.user)
+
+ self.assertTrue(ContributionDiscordXPState.objects.filter(contribution=link_contribution).exists())
+ self.assertEqual(Contribution.objects.count(), 2)
+ self.assertEqual(breakdown['total_points'], 40)
+ self.assertEqual(breakdown['tracked_portal_points_all_time'], 40)
+ self.assertEqual(breakdown['pending_portal_points'], 40)
+ self.assertEqual(breakdown['community_contribution_count'], 1)
+
+ def test_pending_onboarding_link_reward_does_not_block_baseline_apply(self):
+ self.link_discord(self.user)
+ link_contribution = self.add_discord_link_contribution(self.user)
+
+ run = self.fetch_mee6_run([mee6_player('discord-1', 100)])
+ apply_sync_run(run)
+ post_baseline_link_contribution = self.add_discord_link_contribution(self.user)
+ breakdown = get_effective_community_points(self.user)
+
+ link_contribution.discord_xp_state.refresh_from_db()
+ post_baseline_link_contribution.discord_xp_state.refresh_from_db()
+ self.assertEqual(link_contribution.discord_xp_state.status, ContributionDiscordXPState.STATUS_PENDING)
+ self.assertEqual(post_baseline_link_contribution.discord_xp_state.status, ContributionDiscordXPState.STATUS_PENDING)
+ self.assertEqual(breakdown['discord_xp'], 100)
+ self.assertEqual(breakdown['pending_portal_points'], 0)
+ self.assertEqual(breakdown['tracked_portal_points_all_time'], 0)
+ self.assertEqual(breakdown['total_points'], 100)
+
+ def test_sync_preserves_contributions_and_counts_mee6_plus_pending_portal_points(self):
+ self.link_discord(self.user)
+ old_contribution = self.add_community_contribution(self.user, 40)
+ before = list(Contribution.objects.values('id', 'points', 'frozen_global_points', 'notes'))
+
+ run = self.fetch_mee6_run([mee6_player('discord-1', 100)])
+ pre_apply_breakdown = get_effective_community_points(self.user)
+ self.assertEqual(pre_apply_breakdown['total_points'], 40)
+ self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='discord-1').exists())
+
+ apply_sync_run(run)
+ run.refresh_from_db()
+ post_contribution = self.add_community_contribution(self.user, 10)
+
+ after_existing = list(
+ Contribution.objects
+ .filter(id__in=[old_contribution.id])
+ .values('id', 'points', 'frozen_global_points', 'notes')
+ )
+ breakdown = get_effective_community_points(self.user)
+
+ self.assertEqual(before, after_existing)
+ self.assertEqual(Contribution.objects.count(), 2)
+ self.assertEqual(old_contribution.points, 40)
+ self.assertEqual(post_contribution.points, 10)
+ self.assertEqual(run.status, Mee6SyncRun.STATUS_SUCCESS)
+ self.assertIsNotNone(run.applied_at)
+ self.assertEqual(breakdown['discord_xp'], 100)
+ self.assertEqual(breakdown['pending_portal_points'], 50)
+ self.assertEqual(breakdown['tracked_portal_points_all_time'], 50)
+ self.assertEqual(breakdown['total_points'], 150)
+ self.assertTrue(Creator.objects.filter(user=self.user).exists())
+
+ def test_post_baseline_distributed_points_count_until_next_mee6_baseline(self):
+ self.link_discord(self.user)
+ first_contribution = self.add_community_contribution(self.user, 40)
+ self.mark_discord_xp_distributed(first_contribution)
+
+ self.fetch_and_apply_mee6_run([mee6_player('discord-1', 100)])
+ second_contribution = self.add_community_contribution(self.user, 10)
+
+ first_breakdown = get_effective_community_points(self.user)
+ self.assertEqual(first_breakdown['total_points'], 110)
+ self.assertEqual(first_breakdown['pending_portal_points'], 10)
+
+ self.mark_discord_xp_distributed(second_contribution)
+ distributed_not_yet_synced = get_effective_community_points(self.user)
+ self.assertEqual(distributed_not_yet_synced['total_points'], 110)
+ self.assertEqual(distributed_not_yet_synced['pending_portal_points'], 10)
+
+ second_run = self.fetch_mee6_run([mee6_player('discord-1', 150)])
+ fetched_but_not_applied = get_effective_community_points(self.user)
+ self.assertEqual(fetched_but_not_applied['discord_xp'], 100)
+ self.assertEqual(fetched_but_not_applied['total_points'], 110)
+
+ apply_sync_run(second_run)
+ second_breakdown = get_effective_community_points(self.user)
+
+ self.assertEqual(second_breakdown['discord_xp'], 150)
+ self.assertEqual(second_breakdown['pending_portal_points'], 0)
+ self.assertEqual(second_breakdown['tracked_portal_points_all_time'], 50)
+ self.assertEqual(second_breakdown['total_points'], 150)
+ self.assertEqual(Contribution.objects.count(), 2)
+
+ def test_unmatched_snapshot_is_stored_without_creating_user(self):
+ run = self.fetch_mee6_run([mee6_player('unmatched-discord', 77)])
+
+ snapshot = Mee6PlayerSnapshot.objects.get(discord_id='unmatched-discord')
+ self.assertEqual(snapshot.xp, 77)
+ self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='unmatched-discord').exists())
+
+ apply_sync_run(run)
+ current = Mee6CurrentXP.objects.get(discord_id='unmatched-discord')
+
+ self.assertIsNone(current.matched_user)
+ self.assertEqual(User.objects.count(), 1)
+
+ def test_late_discord_link_matches_current_xp_and_creates_creator_profile(self):
+ run = self.fetch_mee6_run([mee6_player('late-discord', 77)])
+ late_user = User.objects.create_user(
+ email='late@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000002',
+ name='Late User',
+ )
+
+ self.link_discord(late_user, discord_id='late-discord')
+ self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='late-discord').exists())
+
+ apply_sync_run(run)
+ current = Mee6CurrentXP.objects.get(discord_id='late-discord')
+ breakdown = get_effective_community_points(late_user)
+
+ self.assertEqual(current.matched_user, late_user)
+ self.assertTrue(Creator.objects.filter(user=late_user).exists())
+ self.assertEqual(breakdown['discord_xp'], 77)
+ self.assertEqual(breakdown['total_points'], 77)
+
+ def test_zero_xp_migration_does_not_create_creator_profile(self):
+ self.link_discord(self.user, discord_id='zero-discord')
+
+ self.fetch_and_apply_mee6_run([mee6_player('zero-discord', 0)])
+
+ self.assertFalse(Creator.objects.filter(user=self.user).exists())
+
+ def test_discord_connection_serializer_exposes_mee6_level_and_rank(self):
+ connection = self.link_discord(self.user, discord_id='discord-1')
+ self.fetch_and_apply_mee6_run([mee6_player('discord-1', 100, rank=7)])
+
+ data = DiscordConnectionSerializer(connection).data
+
+ self.assertEqual(data['mee6_xp'], 100)
+ self.assertEqual(data['mee6_level'], 1)
+ self.assertEqual(data['mee6_rank'], 7)
+ self.assertIsNotNone(data['mee6_synced_at'])
+
+ def test_failed_run_does_not_apply_current_xp(self):
+ self.link_discord(self.user)
+ self.fetch_and_apply_mee6_run([mee6_player('discord-1', 100)])
+
+ with self.assertRaises(Mee6SyncError):
+ run_mee6_sync(
+ client=FakeMee6Client(error=Mee6SyncError('MEE6 unavailable')),
+ use_lock=False,
+ )
+
+ current = Mee6CurrentXP.objects.get(discord_id='discord-1')
+ self.assertEqual(current.xp, 100)
+ self.assertTrue(Mee6SyncRun.objects.filter(status=Mee6SyncRun.STATUS_FAILED).exists())
+
+ def test_disappearing_user_loses_mee6_baseline_on_next_successful_sync(self):
+ self.link_discord(self.user)
+ self.fetch_and_apply_mee6_run([mee6_player('discord-1', 100)])
+
+ second_run = self.fetch_mee6_run([mee6_player('other-discord', 5)])
+ fetched_but_not_applied = get_effective_community_points(self.user)
+ self.assertTrue(Mee6CurrentXP.objects.filter(discord_id='discord-1').exists())
+ self.assertEqual(fetched_but_not_applied['discord_xp'], 100)
+
+ apply_sync_run(second_run)
+ breakdown = get_effective_community_points(self.user)
+
+ self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='discord-1').exists())
+ self.assertEqual(breakdown['discord_xp'], 0)
+ self.assertEqual(breakdown['total_points'], 0)
+
+ def test_discord_relink_uses_current_connection_and_clears_old_cached_match(self):
+ connection = self.link_discord(self.user, discord_id='old-discord')
+ self.fetch_and_apply_mee6_run([
+ mee6_player('old-discord', 100, rank=1),
+ mee6_player('new-discord', 200, rank=2),
+ ])
+
+ connection.platform_user_id = 'new-discord'
+ connection.platform_username = 'new-user'
+ connection.save(update_fields=['platform_user_id', 'platform_username'])
+
+ old_current = Mee6CurrentXP.objects.get(discord_id='old-discord')
+ new_current = Mee6CurrentXP.objects.get(discord_id='new-discord')
+ breakdown = get_effective_community_points(self.user)
+
+ self.assertIsNone(old_current.matched_user)
+ self.assertEqual(new_current.matched_user, self.user)
+ self.assertEqual(breakdown['discord_xp'], 200)
+ self.assertEqual(breakdown['total_points'], 200)
+
+ def test_discord_role_sync_save_does_not_rewrite_current_xp_match(self):
+ connection = self.link_discord(self.user, discord_id='discord-1')
+ self.fetch_and_apply_mee6_run([mee6_player('discord-1', 100)])
+ current = Mee6CurrentXP.objects.get(discord_id='discord-1')
+ original_matched_at = current.matched_at
+ original_updated_at = current.updated_at
+
+ connection.guild_checked_at = timezone.now()
+ connection.save(update_fields=['guild_checked_at'])
+
+ current.refresh_from_db()
+ self.assertEqual(current.matched_user, self.user)
+ self.assertEqual(current.matched_at, original_matched_at)
+ self.assertEqual(current.updated_at, original_updated_at)
+
+ def test_invalid_page_size_raises_sync_error_without_creating_run(self):
+ run_count = Mee6SyncRun.objects.count()
+
+ with self.assertRaises(Mee6SyncError):
+ run_mee6_sync(page_size='not-a-number', client=FakeMee6Client(), use_lock=False)
+
+ self.assertEqual(Mee6SyncRun.objects.count(), run_count)
+
+ def test_community_contributors_uses_effective_points(self):
+ self.link_discord(self.user)
+ other = User.objects.create_user(
+ email='other@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000003',
+ name='Other User',
+ )
+
+ self.fetch_and_apply_mee6_run([mee6_player('discord-1', 100)])
+ self.add_community_contribution(other, 80)
+
+ response = self.api_client.get('/api/v1/leaderboard/community-contributors/')
+
+ self.assertEqual(response.status_code, 200)
+ results = response.json()['results']
+ self.assertEqual(results[0]['user_address'], self.user.address)
+ self.assertEqual(results[0]['total_points'], 100)
+ self.assertEqual(results[0]['discord_xp'], 100)
+ self.assertEqual(results[1]['user_address'], other.address)
+ self.assertEqual(results[1]['total_points'], 80)
+ self.assertEqual(results[1]['pending_portal_points'], 80)
+
+ def test_pending_pre_snapshot_contribution_counts_on_top_of_mee6_baseline(self):
+ self.link_discord(self.user, discord_id='discord-1')
+ self.add_community_contribution(self.user, 25)
+
+ run = self.fetch_mee6_run([mee6_player('discord-1', 100)])
+ apply_sync_run(run)
+
+ breakdown = get_effective_community_points(self.user)
+
+ self.assertTrue(Mee6CurrentXP.objects.filter(discord_id='discord-1').exists())
+ self.assertEqual(breakdown['discord_xp'], 100)
+ self.assertEqual(breakdown['pending_portal_points'], 25)
+ self.assertEqual(breakdown['total_points'], 125)
+
+ def test_cannot_apply_mee6_snapshot_when_contribution_was_distributed_after_snapshot(self):
+ self.link_discord(self.user, discord_id='discord-1')
+ contribution = self.add_community_contribution(self.user, 25)
+ stale_run = self.fetch_mee6_run([mee6_player('discord-1', 100)])
+ self.mark_discord_xp_distributed(contribution)
+
+ with self.assertRaisesRegex(Mee6SyncError, 'marked distributed after this snapshot'):
+ apply_sync_run(stale_run)
+
+ self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='discord-1').exists())
+
+ def test_cannot_apply_mee6_snapshot_when_newer_contribution_was_distributed_after_snapshot(self):
+ self.link_discord(self.user, discord_id='discord-1')
+ stale_run = self.fetch_mee6_run([mee6_player('discord-1', 100)])
+ contribution = self.add_community_contribution(self.user, 25)
+ self.mark_discord_xp_distributed(contribution)
+
+ with self.assertRaisesRegex(Mee6SyncError, 'marked distributed after this snapshot'):
+ apply_sync_run(stale_run)
+
+ self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='discord-1').exists())
+
+ def test_cannot_apply_older_mee6_snapshot_after_newer_baseline(self):
+ self.link_discord(self.user, discord_id='discord-1')
+ older_run = self.fetch_mee6_run([mee6_player('discord-1', 100)])
+ newer_run = self.fetch_mee6_run([mee6_player('discord-1', 150)])
+ apply_sync_run(newer_run)
+
+ with self.assertRaises(Mee6SyncError):
+ apply_sync_run(older_run)
diff --git a/backend/community_xp/utils.py b/backend/community_xp/utils.py
new file mode 100644
index 00000000..2b64d362
--- /dev/null
+++ b/backend/community_xp/utils.py
@@ -0,0 +1,279 @@
+from django.db.models import Case, Count, F, IntegerField, Sum, Value, When
+from django.db.models.functions import Greatest
+
+from contributions.models import Contribution, ContributionDiscordXPState
+
+from .constants import COMMUNITY_XP_EXCLUDED_TYPE_SLUGS
+from .models import Mee6CurrentXP, Mee6SyncRun
+from .services import get_default_guild_id
+
+
+def get_latest_applied_sync(guild_id=None):
+ guild_id = str(guild_id or get_default_guild_id())
+ return (
+ Mee6SyncRun.objects
+ .filter(
+ guild_id=guild_id,
+ status=Mee6SyncRun.STATUS_SUCCESS,
+ completed_at__isnull=False,
+ applied_at__isnull=False,
+ )
+ .order_by('-applied_at', '-completed_at', '-id')
+ .first()
+ )
+
+
+def _community_contributions(user_ids=None):
+ queryset = Contribution.objects.filter(
+ contribution_type__category__slug='community',
+ ).exclude(
+ contribution_type__slug__in=COMMUNITY_XP_EXCLUDED_TYPE_SLUGS,
+ )
+ if user_ids is not None:
+ queryset = queryset.filter(user_id__in=user_ids)
+ return queryset
+
+
+def _aggregate_community_points(user_ids=None):
+ return {
+ row['user_id']: {
+ 'total': row['total'] or 0,
+ 'count': row['count'] or 0,
+ }
+ for row in _community_contributions(user_ids=user_ids)
+ .values('user_id')
+ .annotate(total=Sum('frozen_global_points'), count=Count('id'))
+ }
+
+
+def _discord_xp_states(user_ids=None):
+ queryset = ContributionDiscordXPState.objects.filter(
+ contribution__contribution_type__category__slug='community',
+ ).exclude(
+ contribution__contribution_type__slug__in=COMMUNITY_XP_EXCLUDED_TYPE_SLUGS,
+ )
+ if user_ids is not None:
+ queryset = queryset.filter(contribution__user_id__in=user_ids)
+ return queryset
+
+
+def _aggregate_pending_portal_points(user_ids=None, baseline_completed_at=None):
+ pending_expr = Greatest(
+ F('contribution__frozen_global_points') - F('awarded_amount'),
+ Value(0),
+ output_field=IntegerField(),
+ )
+ if baseline_completed_at is None:
+ effective_pending_expr = F('contribution__frozen_global_points')
+ else:
+ effective_pending_expr = Case(
+ When(
+ status=ContributionDiscordXPState.STATUS_DISTRIBUTED,
+ distributed_at__lte=baseline_completed_at,
+ then=Value(0),
+ ),
+ When(
+ status=ContributionDiscordXPState.STATUS_DISTRIBUTED,
+ distributed_at__gt=baseline_completed_at,
+ then=F('contribution__frozen_global_points'),
+ ),
+ default=pending_expr,
+ output_field=IntegerField(),
+ )
+
+ return {
+ row['contribution__user_id']: row['pending_total'] or 0
+ for row in _discord_xp_states(user_ids=user_ids)
+ .values('contribution__user_id')
+ .annotate(pending_total=Sum(effective_pending_expr))
+ }
+
+
+def _aggregate_missing_state_portal_points(user_ids=None):
+ return {
+ row['user_id']: row['total'] or 0
+ for row in _community_contributions(user_ids=user_ids)
+ .filter(discord_xp_state__isnull=True)
+ .values('user_id')
+ .annotate(total=Sum('frozen_global_points'))
+ }
+
+
+def _current_xp_by_user(users_by_id, guild_id):
+ if not users_by_id:
+ return {}
+
+ current_rows = Mee6CurrentXP.objects.filter(
+ guild_id=guild_id,
+ matched_user_id__in=users_by_id.keys(),
+ )
+
+ result = {}
+ for current in current_rows:
+ user_id = current.matched_user_id
+ if not user_id:
+ continue
+ existing = result.get(user_id)
+ if existing is None or current.xp > existing.xp:
+ result[user_id] = current
+ return result
+
+
+def build_effective_community_scores(user_ids=None, guild_id=None, visible_only=True):
+ from users.models import User
+
+ guild_id = str(guild_id or get_default_guild_id())
+ user_queryset = User.objects.all()
+ if visible_only:
+ user_queryset = user_queryset.filter(visible=True)
+ if user_ids is not None:
+ user_queryset = user_queryset.filter(id__in=user_ids)
+
+ users_by_id = {
+ user.id: user
+ for user in user_queryset.only(
+ 'id',
+ 'name',
+ 'address',
+ 'profile_image_url',
+ 'visible',
+ )
+ }
+
+ all_time = _aggregate_community_points(user_ids=users_by_id.keys())
+ latest_sync = get_latest_applied_sync(guild_id)
+ pending_portal_points_by_user = _aggregate_pending_portal_points(
+ user_ids=users_by_id.keys(),
+ baseline_completed_at=latest_sync.completed_at if latest_sync else None,
+ )
+ for user_id, missing_state_points in _aggregate_missing_state_portal_points(
+ user_ids=users_by_id.keys()
+ ).items():
+ pending_portal_points_by_user[user_id] = (
+ pending_portal_points_by_user.get(user_id, 0) + missing_state_points
+ )
+ current_xp_by_user = _current_xp_by_user(users_by_id, guild_id)
+
+ candidate_user_ids = set(users_by_id.keys() if user_ids is not None else [])
+ candidate_user_ids.update(all_time.keys())
+ candidate_user_ids.update(pending_portal_points_by_user.keys())
+ candidate_user_ids.update(current_xp_by_user.keys())
+
+ scores = {}
+ for user_id in candidate_user_ids:
+ user = users_by_id.get(user_id)
+ if not user:
+ continue
+
+ all_time_points = all_time.get(user_id, {}).get('total', 0)
+ all_time_count = all_time.get(user_id, {}).get('count', 0)
+ pending_portal_points = pending_portal_points_by_user.get(user_id, 0)
+ current_xp = current_xp_by_user.get(user_id)
+ discord_xp = current_xp.xp if current_xp else 0
+
+ total_points = discord_xp + pending_portal_points
+
+ scores[user_id] = {
+ 'user': user,
+ 'discord_xp': discord_xp,
+ 'discord_xp_synced_at': current_xp.synced_at if current_xp else None,
+ 'pending_portal_points': pending_portal_points,
+ 'tracked_portal_points_all_time': all_time_points,
+ 'total_points': total_points,
+ 'has_discord_xp_snapshot': current_xp is not None,
+ 'latest_applied_sync_completed_at': latest_sync.completed_at if latest_sync else None,
+ 'latest_applied_at': latest_sync.applied_at if latest_sync else None,
+ 'community_contribution_count': all_time_count,
+ }
+
+ return scores
+
+
+def get_community_member_user_ids(user_ids=None, guild_id=None, visible_only=True, since=None):
+ from creators.models import Creator
+ from poaps.models import PoapClaim
+
+ score_map = build_effective_community_scores(
+ user_ids=user_ids,
+ guild_id=guild_id,
+ visible_only=visible_only,
+ )
+ score_member_user_ids = {
+ user_id
+ for user_id, score in score_map.items()
+ if (score['total_points'] or 0) > 0
+ }
+ member_user_ids = set(score_member_user_ids if since is None else [])
+
+ contribution_filters = {
+ 'contribution_type__category__slug': 'community',
+ 'contribution_type__is_submittable': True,
+ }
+ poap_filters = {
+ 'user__isnull': False,
+ }
+ if visible_only:
+ contribution_filters['user__visible'] = True
+ poap_filters['user__visible'] = True
+ if user_ids is not None:
+ contribution_filters['user_id__in'] = user_ids
+ poap_filters['user_id__in'] = user_ids
+ if since is not None:
+ contribution_filters['created_at__gte'] = since
+ poap_filters['created_at__gte'] = since
+ recent_effective_contributions = _community_contributions(user_ids=user_ids)
+ if visible_only:
+ recent_effective_contributions = recent_effective_contributions.filter(user__visible=True)
+ member_user_ids.update(
+ recent_effective_contributions
+ .filter(created_at__gte=since)
+ .values_list('user_id', flat=True)
+ .distinct()
+ )
+ creator_filters = {
+ 'user_id__in': score_member_user_ids,
+ 'created_at__gte': since,
+ }
+ if visible_only:
+ creator_filters['user__visible'] = True
+ member_user_ids.update(
+ Creator.objects
+ .filter(**creator_filters)
+ .values_list('user_id', flat=True)
+ .distinct()
+ )
+
+ member_user_ids.update(
+ Contribution.objects
+ .filter(**contribution_filters)
+ .values_list('user_id', flat=True)
+ .distinct()
+ )
+ member_user_ids.update(
+ PoapClaim.objects
+ .filter(**poap_filters)
+ .values_list('user_id', flat=True)
+ .distinct()
+ )
+
+ return member_user_ids
+
+
+def get_effective_community_points(user, guild_id=None):
+ scores = build_effective_community_scores(
+ user_ids=[user.id],
+ guild_id=guild_id,
+ visible_only=False,
+ )
+ return scores.get(user.id, {
+ 'user': user,
+ 'discord_xp': 0,
+ 'discord_xp_synced_at': None,
+ 'pending_portal_points': 0,
+ 'tracked_portal_points_all_time': 0,
+ 'total_points': 0,
+ 'has_discord_xp_snapshot': False,
+ 'latest_applied_sync_completed_at': None,
+ 'latest_applied_at': None,
+ 'community_contribution_count': 0,
+ })
diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py
index 53cdf6d1..aa12a17a 100644
--- a/backend/contributions/admin.py
+++ b/backend/contributions/admin.py
@@ -22,7 +22,23 @@
from django.core.exceptions import ValidationError
from django.contrib.auth import get_user_model
from datetime import datetime
-from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert, BlocklistedURL, EvidenceURLType
+from .models import (
+ Category,
+ ContributionType,
+ Contribution,
+ SubmittedContribution,
+ Evidence,
+ ContributionHighlight,
+ Mission,
+ StartupRequest,
+ SubmissionNote,
+ FeaturedContent,
+ Alert,
+ BlocklistedURL,
+ EvidenceURLType,
+ ContributionDiscordXPState,
+ DiscordXPDistributionEvent,
+)
from .validator_forms import CreateValidatorForm
from leaderboard.models import GlobalLeaderboardMultiplier
from social_connections.models import DiscordRole
@@ -96,6 +112,19 @@ def queryset(self, request, queryset):
return queryset
+class ReadOnlyAdminMixin:
+ actions = None
+
+ def has_add_permission(self, request):
+ return False
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def get_readonly_fields(self, request, obj=None):
+ return [field.name for field in self.model._meta.fields]
+
+
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'description', 'created_at')
@@ -504,6 +533,142 @@ class Media:
js = ('admin/js/contribution_type_dynamic.js',)
+@admin.register(ContributionDiscordXPState)
+class ContributionDiscordXPStateAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
+ list_display = (
+ 'contribution_link',
+ 'contributor',
+ 'discord_username',
+ 'contribution_type',
+ 'community_points',
+ 'awarded_amount',
+ 'pending_amount_display',
+ 'status',
+ 'last_copied_at',
+ 'distributed_at',
+ )
+ list_filter = (
+ 'status',
+ 'contribution__contribution_type__category',
+ 'contribution__contribution_type',
+ 'last_copied_at',
+ 'distributed_at',
+ )
+ search_fields = (
+ 'contribution__id',
+ 'contribution__title',
+ 'contribution__notes',
+ 'contribution__user__name',
+ 'contribution__user__email',
+ 'contribution__user__address',
+ 'contribution__user__discordconnection__platform_username',
+ 'contribution__user__discordconnection__guild_nick',
+ 'contribution__contribution_type__name',
+ )
+ ordering = ('-contribution__created_at',)
+ show_facets = admin.ShowFacets.NEVER
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).select_related(
+ 'contribution',
+ 'contribution__user',
+ 'contribution__user__discordconnection',
+ 'contribution__contribution_type',
+ 'contribution__contribution_type__category',
+ )
+
+ def contribution_link(self, obj):
+ url = reverse('admin:contributions_contribution_change', args=[obj.contribution_id])
+ return format_html('#{}', url, obj.contribution_id)
+ contribution_link.short_description = 'Contribution'
+ contribution_link.admin_order_field = 'contribution_id'
+
+ def contributor(self, obj):
+ user = obj.contribution.user
+ return user.name or user.email or user.address
+ contributor.admin_order_field = 'contribution__user__name'
+
+ def discord_username(self, obj):
+ connection = getattr(obj.contribution.user, 'discordconnection', None)
+ if not connection:
+ return '-'
+ return connection.guild_nick or connection.platform_username or '-'
+ discord_username.short_description = 'Discord'
+
+ def contribution_type(self, obj):
+ return obj.contribution.contribution_type
+ contribution_type.admin_order_field = 'contribution__contribution_type'
+
+ def community_points(self, obj):
+ return obj.contribution.frozen_global_points
+ community_points.admin_order_field = 'contribution__frozen_global_points'
+
+ def pending_amount_display(self, obj):
+ return obj.pending_amount
+ pending_amount_display.short_description = 'Pending'
+
+
+@admin.register(DiscordXPDistributionEvent)
+class DiscordXPDistributionEventAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
+ list_display = (
+ 'created_at',
+ 'action',
+ 'contribution_link',
+ 'contributor',
+ 'discord_username',
+ 'amount',
+ 'actor',
+ )
+ list_filter = (
+ 'action',
+ 'created_at',
+ 'state__contribution__contribution_type__category',
+ 'state__contribution__contribution_type',
+ )
+ search_fields = (
+ 'state__contribution__id',
+ 'state__contribution__title',
+ 'state__contribution__user__name',
+ 'state__contribution__user__email',
+ 'state__contribution__user__address',
+ 'state__contribution__user__discordconnection__platform_username',
+ 'state__contribution__user__discordconnection__guild_nick',
+ 'actor__name',
+ 'actor__email',
+ )
+ ordering = ('-created_at',)
+ show_facets = admin.ShowFacets.NEVER
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).select_related(
+ 'state',
+ 'state__contribution',
+ 'state__contribution__user',
+ 'state__contribution__user__discordconnection',
+ 'state__contribution__contribution_type',
+ 'actor',
+ )
+
+ def contribution_link(self, obj):
+ contribution_id = obj.state.contribution_id
+ url = reverse('admin:contributions_contribution_change', args=[contribution_id])
+ return format_html('#{}', url, contribution_id)
+ contribution_link.short_description = 'Contribution'
+ contribution_link.admin_order_field = 'state__contribution_id'
+
+ def contributor(self, obj):
+ user = obj.state.contribution.user
+ return user.name or user.email or user.address
+ contributor.admin_order_field = 'state__contribution__user__name'
+
+ def discord_username(self, obj):
+ connection = getattr(obj.state.contribution.user, 'discordconnection', None)
+ if not connection:
+ return '-'
+ return connection.guild_nick or connection.platform_username or '-'
+ discord_username.short_description = 'Discord'
+
+
class SubmissionNoteInline(admin.TabularInline):
model = SubmissionNote
extra = 0
diff --git a/backend/contributions/migrations/0061_community_discord_xp.py b/backend/contributions/migrations/0061_community_discord_xp.py
new file mode 100644
index 00000000..c3fb2933
--- /dev/null
+++ b/backend/contributions/migrations/0061_community_discord_xp.py
@@ -0,0 +1,91 @@
+# Generated for community contribution Discord XP tracking.
+
+from django.conf import settings
+from django.db import migrations, models
+from django.utils import timezone
+import django.db.models.deletion
+
+
+def backfill_community_discord_xp_states(apps, schema_editor):
+ Contribution = apps.get_model('contributions', 'Contribution')
+ ContributionDiscordXPState = apps.get_model('contributions', 'ContributionDiscordXPState')
+
+ community_contribution_ids = Contribution.objects.filter(
+ contribution_type__category__slug='community',
+ ).values_list('id', flat=True)
+
+ now = timezone.now()
+ batch = []
+ for contribution_id in community_contribution_ids.iterator(chunk_size=1000):
+ batch.append(ContributionDiscordXPState(
+ contribution_id=contribution_id,
+ status='pending',
+ awarded_amount=0,
+ created_at=now,
+ updated_at=now,
+ ))
+ if len(batch) >= 1000:
+ ContributionDiscordXPState.objects.bulk_create(
+ batch,
+ ignore_conflicts=True,
+ batch_size=1000,
+ )
+ batch = []
+
+ if batch:
+ ContributionDiscordXPState.objects.bulk_create(
+ batch,
+ ignore_conflicts=True,
+ batch_size=1000,
+ )
+
+
+def noop_reverse(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contributions', '0060_contributiontype_required_discord_roles'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContributionDiscordXPState',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('status', models.CharField(choices=[('pending', 'Pending'), ('distributed', 'Distributed'), ('needs_review', 'Needs review')], db_index=True, default='pending', max_length=20)),
+ ('awarded_amount', models.PositiveIntegerField(default=0)),
+ ('distributed_at', models.DateTimeField(blank=True, db_index=True, null=True)),
+ ('last_copied_at', models.DateTimeField(blank=True, null=True)),
+ ('contribution', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='discord_xp_state', to='contributions.contribution')),
+ ('distributed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='discord_xp_states_distributed', to=settings.AUTH_USER_MODEL)),
+ ('last_copied_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='discord_xp_states_copied', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-contribution__created_at'],
+ 'indexes': [models.Index(fields=['status', 'distributed_at'], name='contrib_xp_status_dist_idx')],
+ },
+ ),
+ migrations.CreateModel(
+ name='DiscordXPDistributionEvent',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('amount', models.PositiveIntegerField()),
+ ('action', models.CharField(choices=[('copied', 'Copied command'), ('distributed', 'Marked distributed'), ('unset', 'Unset distributed')], db_index=True, max_length=20)),
+ ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='discord_xp_distribution_events_made', to=settings.AUTH_USER_MODEL)),
+ ('state', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='contributions.contributiondiscordxpstate')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['action', 'created_at'], name='xp_event_action_created_idx'), models.Index(fields=['state', 'created_at'], name='xp_event_state_created_idx')],
+ },
+ ),
+ migrations.RunPython(backfill_community_discord_xp_states, noop_reverse),
+ ]
diff --git a/backend/contributions/migrations/0062_merge_0061_community_discord_xp_0061_featuredcontent_hero_placements.py b/backend/contributions/migrations/0062_merge_0061_community_discord_xp_0061_featuredcontent_hero_placements.py
new file mode 100644
index 00000000..4c68a228
--- /dev/null
+++ b/backend/contributions/migrations/0062_merge_0061_community_discord_xp_0061_featuredcontent_hero_placements.py
@@ -0,0 +1,10 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("contributions", "0061_community_discord_xp"),
+ ("contributions", "0061_featuredcontent_hero_placements"),
+ ]
+
+ operations = []
diff --git a/backend/contributions/models.py b/backend/contributions/models.py
index 15521b69..30e1b339 100644
--- a/backend/contributions/models.py
+++ b/backend/contributions/models.py
@@ -1,7 +1,7 @@
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
-from django.db.models.signals import pre_save
+from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from django.utils import timezone
from utils.models import BaseModel
@@ -304,6 +304,189 @@ def validate_multiplier_at_creation(sender, instance, **kwargs):
instance.frozen_global_points = instance.points
+def is_community_contribution(contribution):
+ """Return whether a contribution belongs to the community category."""
+ contribution_type = getattr(contribution, 'contribution_type', None)
+ category = getattr(contribution_type, 'category', None)
+ return bool(category and category.slug == 'community')
+
+
+class ContributionDiscordXPState(BaseModel):
+ """
+ Per-contribution Discord XP distribution state for community contributions.
+ This intentionally tracks contribution rows, not user aggregates, so each
+ portal award has a clear manual Discord distribution status and audit trail.
+ """
+ STATUS_PENDING = 'pending'
+ STATUS_DISTRIBUTED = 'distributed'
+ STATUS_NEEDS_REVIEW = 'needs_review'
+
+ STATUS_CHOICES = [
+ (STATUS_PENDING, 'Pending'),
+ (STATUS_DISTRIBUTED, 'Distributed'),
+ (STATUS_NEEDS_REVIEW, 'Needs review'),
+ ]
+
+ contribution = models.OneToOneField(
+ Contribution,
+ on_delete=models.CASCADE,
+ related_name='discord_xp_state',
+ )
+ status = models.CharField(
+ max_length=20,
+ choices=STATUS_CHOICES,
+ default=STATUS_PENDING,
+ db_index=True,
+ )
+ awarded_amount = models.PositiveIntegerField(default=0)
+ distributed_at = models.DateTimeField(null=True, blank=True, db_index=True)
+ distributed_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='discord_xp_states_distributed',
+ )
+ last_copied_at = models.DateTimeField(null=True, blank=True)
+ last_copied_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='discord_xp_states_copied',
+ )
+
+ class Meta:
+ ordering = ['-contribution__created_at']
+ indexes = [
+ models.Index(fields=['status', 'distributed_at'], name='contrib_xp_status_dist_idx'),
+ ]
+
+ @property
+ def target_amount(self):
+ return int(self.contribution.frozen_global_points or 0)
+
+ @property
+ def pending_amount(self):
+ return max(self.target_amount - int(self.awarded_amount or 0), 0)
+
+ @property
+ def command(self):
+ amount = self.pending_amount
+ connection = getattr(self.contribution.user, 'discordconnection', None)
+ username = getattr(connection, 'platform_username', '') if connection else ''
+ if not username or amount <= 0:
+ return ''
+ username = username.lstrip('@')
+ return f"/give-xp member:@{username} amount:{amount}"
+
+ def clean(self):
+ super().clean()
+ if self.contribution_id and not is_community_contribution(self.contribution):
+ raise ValidationError('Discord XP state can only be created for community contributions.')
+
+ def save(self, *args, **kwargs):
+ self.full_clean()
+ super().save(*args, **kwargs)
+
+ def refresh_status_from_contribution(self, save=True):
+ """
+ Reconcile state against the current frozen portal points.
+ Point reductions below manually-distributed XP are not auto-correctable
+ in Discord, so they move to needs_review for steward visibility.
+ """
+ target = self.target_amount
+ awarded = int(self.awarded_amount or 0)
+ next_status = self.status
+
+ if awarded > target:
+ next_status = self.STATUS_NEEDS_REVIEW
+ elif awarded == target:
+ next_status = self.STATUS_DISTRIBUTED
+ elif self.status != self.STATUS_NEEDS_REVIEW:
+ next_status = self.STATUS_PENDING
+
+ if next_status != self.status:
+ self.status = next_status
+ if save:
+ self.save(update_fields=['status', 'updated_at'])
+
+ return self.status
+
+ def __str__(self):
+ return f"Discord XP for contribution {self.contribution_id}: {self.status}"
+
+
+class DiscordXPDistributionEvent(BaseModel):
+ """Audit row for manual Discord XP command copy/distribution actions."""
+ ACTION_COPIED = 'copied'
+ ACTION_DISTRIBUTED = 'distributed'
+ ACTION_UNSET = 'unset'
+
+ ACTION_CHOICES = [
+ (ACTION_COPIED, 'Copied command'),
+ (ACTION_DISTRIBUTED, 'Marked distributed'),
+ (ACTION_UNSET, 'Unset distributed'),
+ ]
+
+ state = models.ForeignKey(
+ ContributionDiscordXPState,
+ on_delete=models.CASCADE,
+ related_name='events',
+ )
+ actor = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name='discord_xp_distribution_events_made',
+ )
+ amount = models.PositiveIntegerField()
+ action = models.CharField(
+ max_length=20,
+ choices=ACTION_CHOICES,
+ db_index=True,
+ )
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['action', 'created_at'], name='xp_event_action_created_idx'),
+ models.Index(fields=['state', 'created_at'], name='xp_event_state_created_idx'),
+ ]
+
+ def __str__(self):
+ return f"Discord XP event {self.id} for contribution {self.state.contribution_id}: {self.action}"
+
+
+def sync_discord_xp_state_for_contribution(contribution):
+ """
+ Ensure only community contributions have Discord XP state.
+ Non-community states are deleted defensively if a contribution changes type.
+ """
+ if not contribution.pk:
+ return None
+
+ if not is_community_contribution(contribution):
+ ContributionDiscordXPState.objects.filter(contribution=contribution).delete()
+ return None
+
+ state, _ = ContributionDiscordXPState.objects.get_or_create(
+ contribution=contribution,
+ defaults={
+ 'status': ContributionDiscordXPState.STATUS_PENDING,
+ 'awarded_amount': 0,
+ },
+ )
+ state.refresh_status_from_contribution(save=True)
+ return state
+
+
+@receiver(post_save, sender=Contribution)
+def sync_contribution_discord_xp_state(sender, instance, **kwargs):
+ sync_discord_xp_state_for_contribution(instance)
+
+
class SubmittedContribution(BaseModel):
"""
Represents a contribution submission that needs staff review.
diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py
index b98bdaa3..d21dd12b 100644
--- a/backend/contributions/serializers.py
+++ b/backend/contributions/serializers.py
@@ -1,6 +1,11 @@
from django.db import models, transaction
from rest_framework import serializers
-from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert, EvidenceURLType
+from .models import (
+ ContributionType, Contribution, SubmittedContribution, Evidence,
+ ContributionHighlight, Mission, StartupRequest, SubmissionNote,
+ FeaturedContent, Alert, EvidenceURLType, ContributionDiscordXPState,
+ DiscordXPDistributionEvent,
+)
from users.serializers import UserSerializer, LightUserSerializer
from users.models import User
from stewards.models import ReviewTemplate
@@ -300,6 +305,87 @@ def to_representation(self, instance):
return ret
+class DiscordXPDistributionEventSerializer(serializers.ModelSerializer):
+ actor = LightUserSerializer(read_only=True)
+
+ class Meta:
+ model = DiscordXPDistributionEvent
+ fields = [
+ 'id', 'amount', 'action', 'actor',
+ 'created_at', 'updated_at',
+ ]
+ read_only_fields = fields
+
+
+class ContributionDiscordXPStateSerializer(serializers.ModelSerializer):
+ contributor = serializers.SerializerMethodField()
+ discord = serializers.SerializerMethodField()
+ contribution_type = serializers.SerializerMethodField()
+ contribution_title = serializers.CharField(source='contribution.title', read_only=True)
+ contribution_notes = serializers.CharField(source='contribution.notes', read_only=True)
+ contribution_date = serializers.DateTimeField(source='contribution.contribution_date', read_only=True)
+ contribution_created_at = serializers.DateTimeField(source='contribution.created_at', read_only=True)
+ community_points = serializers.IntegerField(source='contribution.frozen_global_points', read_only=True)
+ frozen_global_points = serializers.IntegerField(source='contribution.frozen_global_points', read_only=True)
+ pending_amount = serializers.SerializerMethodField()
+ command = serializers.SerializerMethodField()
+ distributed_by = LightUserSerializer(read_only=True)
+ last_copied_by = LightUserSerializer(read_only=True)
+ latest_event = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ContributionDiscordXPState
+ fields = [
+ 'id', 'contribution', 'status', 'awarded_amount',
+ 'community_points', 'frozen_global_points', 'pending_amount', 'command',
+ 'distributed_at', 'distributed_by',
+ 'last_copied_at', 'last_copied_by',
+ 'contributor', 'discord', 'contribution_type',
+ 'contribution_title', 'contribution_notes', 'contribution_date',
+ 'contribution_created_at', 'latest_event', 'created_at',
+ 'updated_at',
+ ]
+ read_only_fields = fields
+
+ def get_contributor(self, obj):
+ return LightUserSerializer(obj.contribution.user).data
+
+ def get_discord(self, obj):
+ connection = getattr(obj.contribution.user, 'discordconnection', None)
+ if not connection:
+ return None
+
+ return {
+ 'platform_username': connection.platform_username,
+ 'guild_nick': connection.guild_nick,
+ 'guild_member': connection.guild_member,
+ 'avatar_url': connection.avatar_url,
+ }
+
+ def get_contribution_type(self, obj):
+ return LightContributionTypeSerializer(obj.contribution.contribution_type).data
+
+ def get_pending_amount(self, obj):
+ annotated = getattr(obj, 'pending_xp', None)
+ if annotated is not None:
+ return annotated
+ return obj.pending_amount
+
+ def get_command(self, obj):
+ return obj.command
+
+ def get_latest_event(self, obj):
+ events = list(getattr(obj, 'latest_events', []))
+ if not events:
+ events = list(getattr(obj, '_prefetched_objects_cache', {}).get('events', []))
+ if events:
+ return DiscordXPDistributionEventSerializer(events[0]).data
+ latest = obj.events.order_by('-created_at').first()
+ if not latest:
+ return None
+ return DiscordXPDistributionEventSerializer(latest).data
+
+
class SubmittedEvidenceSerializer(serializers.ModelSerializer):
"""Serializer for evidence items belonging to submitted contributions."""
id = serializers.IntegerField(required=False) # Optional: present for existing evidence, absent for new
diff --git a/backend/contributions/tests/test_discord_xp.py b/backend/contributions/tests/test_discord_xp.py
new file mode 100644
index 00000000..0520262c
--- /dev/null
+++ b/backend/contributions/tests/test_discord_xp.py
@@ -0,0 +1,281 @@
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
+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,
+ ContributionDiscordXPState,
+ ContributionType,
+ DiscordXPDistributionEvent,
+ Evidence,
+)
+from leaderboard.models import GlobalLeaderboardMultiplier
+from social_connections.encryption import encrypt_token
+from social_connections.models import DiscordConnection
+from stewards.models import Steward, StewardPermission
+
+User = get_user_model()
+
+
+class StewardDiscordXPTest(TestCase):
+ def setUp(self):
+ self.client = APIClient()
+ now = timezone.now()
+
+ self.community_category = Category.objects.create(
+ name='Community',
+ slug='community',
+ )
+ self.builder_category = Category.objects.create(
+ name='Builder',
+ slug='builder',
+ )
+ self.community_type = ContributionType.objects.create(
+ name='Community Call',
+ slug='community-call',
+ category=self.community_category,
+ min_points=0,
+ max_points=500,
+ )
+ self.other_type = ContributionType.objects.create(
+ name='Builder Post',
+ slug='builder-post',
+ category=self.builder_category,
+ min_points=0,
+ max_points=500,
+ )
+ for contribution_type in [self.community_type, self.other_type]:
+ GlobalLeaderboardMultiplier.objects.create(
+ contribution_type=contribution_type,
+ multiplier_value=1,
+ valid_from=now - timedelta(days=1),
+ )
+
+ self.user = User.objects.create_user(
+ email='alice@example.com',
+ address='0x1111111111111111111111111111111111111111',
+ password='testpass123',
+ name='Alice',
+ )
+ self.other_user = User.objects.create_user(
+ email='bob@example.com',
+ address='0x2222222222222222222222222222222222222222',
+ password='testpass123',
+ name='Bob',
+ )
+ self.steward_user = User.objects.create_user(
+ email='steward@example.com',
+ address='0x3333333333333333333333333333333333333333',
+ password='testpass123',
+ name='Steward',
+ )
+ self.steward = Steward.objects.create(user=self.steward_user)
+ StewardPermission.objects.create(
+ steward=self.steward,
+ contribution_type=self.community_type,
+ action='accept',
+ )
+ self.client.force_authenticate(user=self.steward_user)
+
+ def create_contribution(self, *, user=None, contribution_type=None, points=50, title='Community update', notes='GenLayer update notes'):
+ return Contribution.objects.create(
+ user=user or self.user,
+ contribution_type=contribution_type or self.community_type,
+ points=points,
+ contribution_date=timezone.now(),
+ title=title,
+ notes=notes,
+ )
+
+ def link_discord(self, user=None, username='alice_discord', platform_user_id='999'):
+ return DiscordConnection.objects.create(
+ user=user or self.user,
+ platform_user_id=platform_user_id,
+ platform_username=username,
+ access_token=encrypt_token('discord-access-token'),
+ linked_at=timezone.now(),
+ guild_member=True,
+ )
+
+ def test_community_contributions_create_xp_state_only_for_community(self):
+ community_contribution = self.create_contribution(points=40)
+ other_contribution = self.create_contribution(
+ contribution_type=self.other_type,
+ points=40,
+ title='Builder post',
+ )
+
+ self.assertTrue(
+ ContributionDiscordXPState.objects.filter(contribution=community_contribution).exists()
+ )
+ self.assertFalse(
+ ContributionDiscordXPState.objects.filter(contribution=other_contribution).exists()
+ )
+
+ with self.assertRaises(ValidationError):
+ ContributionDiscordXPState.objects.create(contribution=other_contribution)
+
+ def test_xp_list_is_community_only_positive_points_and_searchable(self):
+ self.link_discord(username='alice_xp')
+ contribution = self.create_contribution(points=80, title='Deep community thread')
+ Evidence.objects.create(
+ contribution=contribution,
+ url='https://example.com/deep-thread',
+ description='forum evidence',
+ )
+ zero = self.create_contribution(
+ user=self.other_user,
+ points=0,
+ title='Zero point community note',
+ )
+
+ response = self.client.get('/api/v1/steward-discord-xp/')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data['count'], 1)
+ self.assertEqual(response.data['results'][0]['contribution'], contribution.id)
+ self.assertNotEqual(response.data['results'][0]['contribution'], zero.id)
+
+ response = self.client.get('/api/v1/steward-discord-xp/', {'status': 'pending'})
+ self.assertEqual(response.data['count'], 1)
+
+ response = self.client.get('/api/v1/steward-discord-xp/', {'username_search': 'alice_xp'})
+ self.assertEqual(response.data['count'], 1)
+
+ response = self.client.get('/api/v1/steward-discord-xp/', {'include_content': 'deep-thread'})
+ self.assertEqual(response.data['count'], 1)
+
+ response = self.client.get('/api/v1/steward-discord-xp/', {'exclude_content': 'deep'})
+ self.assertEqual(response.data['count'], 0)
+
+ def test_zero_point_overdistributed_state_remains_visible_and_unsettable(self):
+ self.link_discord(username='alice_xp')
+ contribution = self.create_contribution(points=30)
+ state = contribution.discord_xp_state
+ state.awarded_amount = 30
+ state.distributed_at = timezone.now()
+ state.distributed_by = self.steward_user
+ state.status = ContributionDiscordXPState.STATUS_DISTRIBUTED
+ state.save()
+
+ contribution.points = 0
+ contribution.frozen_global_points = 0
+ contribution.save()
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_NEEDS_REVIEW)
+
+ response = self.client.get('/api/v1/steward-discord-xp/', {'status': 'needs_review'})
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data['count'], 1)
+ self.assertEqual(response.data['results'][0]['contribution'], contribution.id)
+
+ response = self.client.post(f'/api/v1/steward-discord-xp/{contribution.id}/unset-distributed/')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_PENDING)
+ self.assertEqual(state.awarded_amount, 0)
+
+ @patch('social_connections.oauth_service.requests')
+ def test_record_copy_refreshes_discord_username_and_updates_copy_state(self, mock_requests):
+ self.link_discord(username='alice_xp')
+ contribution = self.create_contribution(points=75)
+ user_response = MagicMock()
+ user_response.raise_for_status = MagicMock()
+ user_response.json.return_value = {
+ 'id': '999',
+ 'username': 'alice_latest',
+ 'discriminator': '0',
+ 'avatar': 'avatar-hash',
+ }
+ mock_requests.get.return_value = user_response
+
+ response = self.client.post(f'/api/v1/steward-discord-xp/{contribution.id}/record-copy/')
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ state = contribution.discord_xp_state
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_PENDING)
+ self.assertEqual(state.awarded_amount, 0)
+ self.assertEqual(state.last_copied_by, self.steward_user)
+ self.assertIsNotNone(state.last_copied_at)
+ self.user.discordconnection.refresh_from_db()
+ self.assertEqual(self.user.discordconnection.platform_username, 'alice_latest')
+ self.assertEqual(response.data['command'], '/give-xp member:@alice_latest amount:75')
+
+ event = DiscordXPDistributionEvent.objects.get(state=state)
+ self.assertEqual(event.action, DiscordXPDistributionEvent.ACTION_COPIED)
+ self.assertEqual(event.amount, 75)
+
+ @patch('social_connections.oauth_service.requests')
+ def test_record_copy_rejects_discord_account_mismatch(self, mock_requests):
+ self.link_discord(username='alice_xp', platform_user_id='999')
+ contribution = self.create_contribution(points=75)
+ user_response = MagicMock()
+ user_response.raise_for_status = MagicMock()
+ user_response.json.return_value = {
+ 'id': 'different-discord-id',
+ 'username': 'alice_latest',
+ 'discriminator': '0',
+ 'avatar': 'avatar-hash',
+ }
+ mock_requests.get.return_value = user_response
+
+ response = self.client.post(f'/api/v1/steward-discord-xp/{contribution.id}/record-copy/')
+
+ self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
+ self.assertIn('Discord account mismatch', response.data['detail'])
+ self.assertFalse(DiscordXPDistributionEvent.objects.filter(state=contribution.discord_xp_state).exists())
+
+ def test_mark_and_unset_distribution_flag_are_audited(self):
+ self.link_discord(username='alice_xp')
+ contribution = self.create_contribution(points=30)
+
+ response = self.client.post(f'/api/v1/steward-discord-xp/{contribution.id}/mark-distributed/')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ state = contribution.discord_xp_state
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_DISTRIBUTED)
+ self.assertEqual(state.awarded_amount, 30)
+ self.assertEqual(state.distributed_by, self.steward_user)
+
+ response = self.client.post(f'/api/v1/steward-discord-xp/{contribution.id}/unset-distributed/')
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_PENDING)
+ self.assertEqual(state.awarded_amount, 0)
+ self.assertIsNone(state.distributed_by)
+
+ actions = list(
+ DiscordXPDistributionEvent.objects.filter(state=state)
+ .order_by('created_at')
+ .values_list('action', flat=True)
+ )
+ self.assertEqual(actions, [
+ DiscordXPDistributionEvent.ACTION_DISTRIBUTED,
+ DiscordXPDistributionEvent.ACTION_UNSET,
+ ])
+
+ def test_point_changes_create_pending_delta_or_needs_review(self):
+ contribution = self.create_contribution(points=40)
+ state = contribution.discord_xp_state
+ state.awarded_amount = 40
+ state.status = ContributionDiscordXPState.STATUS_DISTRIBUTED
+ state.save(update_fields=['awarded_amount', 'status', 'updated_at'])
+
+ contribution.frozen_global_points = 70
+ contribution.save(update_fields=['frozen_global_points', 'updated_at'])
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_PENDING)
+ self.assertEqual(state.pending_amount, 30)
+
+ contribution.frozen_global_points = 25
+ contribution.save(update_fields=['frozen_global_points', 'updated_at'])
+ state.refresh_from_db()
+ self.assertEqual(state.status, ContributionDiscordXPState.STATUS_NEEDS_REVIEW)
+ self.assertEqual(state.pending_amount, 0)
diff --git a/backend/contributions/views.py b/backend/contributions/views.py
index 696cdde2..78f318fd 100644
--- a/backend/contributions/views.py
+++ b/backend/contributions/views.py
@@ -14,19 +14,25 @@
IntegerField,
Max,
OuterRef,
+ Prefetch,
Q,
Subquery,
Sum,
Value,
)
from django.db.models.functions import TruncDate, TruncWeek, TruncMonth
-from django.db.models.functions import Coalesce
+from django.db.models.functions import Coalesce, Greatest
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib import messages
from django.views.generic import ListView
from django.utils.decorators import method_decorator
-from .models import ContributionType, Contribution, Evidence, SubmittedContribution, SubmissionNote, ContributionHighlight, Mission, StartupRequest, FeaturedContent, Alert
+from .models import (
+ ContributionType, Contribution, Evidence, SubmittedContribution,
+ SubmissionNote, ContributionHighlight, Mission, StartupRequest,
+ FeaturedContent, Alert, ContributionDiscordXPState,
+ DiscordXPDistributionEvent, sync_discord_xp_state_for_contribution,
+)
from .serializers import (ContributionTypeSerializer, ContributionSerializer,
EvidenceSerializer, SubmittedContributionSerializer,
SubmittedEvidenceSerializer, ContributionHighlightSerializer,
@@ -34,12 +40,14 @@
StewardAcceptedSubmissionUpdateSerializer,
SubmissionNoteSerializer, SubmissionProposeSerializer,
MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer,
- FeaturedContentSerializer, AlertSerializer)
+ FeaturedContentSerializer, AlertSerializer,
+ ContributionDiscordXPStateSerializer)
from .forms import SubmissionReviewForm
from .permissions import IsSteward, steward_has_permission, steward_permitted_type_ids
from leaderboard.models import GlobalLeaderboardMultiplier
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from ethereum_auth.authentication import EthereumAuthentication
+import requests
METRICS_POINTS_EXCLUDED_TYPE_SLUGS = [
'builder-welcome',
@@ -1273,6 +1281,364 @@ class Meta:
fields = ['state', 'contribution_type', 'user']
+class StewardDiscordXPFilterSet(FilterSet):
+ """Server-side filtering for steward Discord XP tracking."""
+ status = CharFilter(method='filter_status')
+ contribution_type = NumberFilter(method='filter_contribution_type')
+ exclude_contribution_type = NumberFilter(method='filter_exclude_contribution_type')
+ username_search = CharFilter(method='filter_username')
+ search = CharFilter(method='filter_search')
+ include_content = CharFilter(method='filter_include_content')
+ exclude_content = CharFilter(method='filter_exclude_content')
+
+ def _normalize_status(self, value):
+ status_value = (value or '').strip().lower().replace('-', '_')
+ allowed = {
+ ContributionDiscordXPState.STATUS_PENDING,
+ ContributionDiscordXPState.STATUS_DISTRIBUTED,
+ ContributionDiscordXPState.STATUS_NEEDS_REVIEW,
+ }
+ return status_value if status_value in allowed else None
+
+ def _content_query(self, term):
+ has_matching_evidence = Evidence.objects.filter(
+ contribution_id=OuterRef('contribution_id')
+ ).filter(
+ Q(url__icontains=term) | Q(description__icontains=term)
+ )
+ return (
+ Q(contribution__title__icontains=term) |
+ Q(contribution__notes__icontains=term) |
+ Q(contribution__contribution_type__name__icontains=term) |
+ Q(contribution__contribution_type__slug__icontains=term) |
+ Exists(has_matching_evidence)
+ )
+
+ def filter_status(self, queryset, name, value):
+ status_value = self._normalize_status(value)
+ if status_value:
+ return queryset.filter(status=status_value)
+ return queryset
+
+ def filter_contribution_type(self, queryset, name, value):
+ if value:
+ return queryset.filter(contribution__contribution_type_id=value)
+ return queryset
+
+ def filter_exclude_contribution_type(self, queryset, name, value):
+ if value:
+ return queryset.exclude(contribution__contribution_type_id=value)
+ return queryset
+
+ def filter_username(self, queryset, name, value):
+ if value:
+ return queryset.filter(
+ Q(contribution__user__name__icontains=value) |
+ Q(contribution__user__email__icontains=value) |
+ Q(contribution__user__address__icontains=value) |
+ Q(contribution__user__discord_handle__icontains=value) |
+ Q(contribution__user__discordconnection__platform_username__icontains=value) |
+ Q(contribution__user__discordconnection__guild_nick__icontains=value)
+ )
+ return queryset
+
+ def filter_search(self, queryset, name, value):
+ if value:
+ return queryset.filter(
+ self._content_query(value) |
+ Q(contribution__user__name__icontains=value) |
+ Q(contribution__user__email__icontains=value) |
+ Q(contribution__user__address__icontains=value) |
+ Q(contribution__user__discord_handle__icontains=value) |
+ Q(contribution__user__discordconnection__platform_username__icontains=value) |
+ Q(contribution__user__discordconnection__guild_nick__icontains=value)
+ )
+ return queryset
+
+ def filter_include_content(self, queryset, name, value):
+ if value:
+ for term in value.split(','):
+ term = term.strip()
+ if term:
+ queryset = queryset.filter(self._content_query(term))
+ return queryset
+
+ def filter_exclude_content(self, queryset, name, value):
+ if value:
+ for term in value.split(','):
+ term = term.strip()
+ if term:
+ queryset = queryset.exclude(self._content_query(term))
+ return queryset
+
+ class Meta:
+ model = ContributionDiscordXPState
+ fields = ['status', 'contribution_type']
+
+
+class StewardDiscordXPViewSet(viewsets.ReadOnlyModelViewSet):
+ """
+ Steward endpoint for community contribution Discord XP tracking and awards.
+ """
+ serializer_class = ContributionDiscordXPStateSerializer
+ authentication_classes = [EthereumAuthentication]
+ permission_classes = [IsSteward]
+ filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
+ filterset_class = StewardDiscordXPFilterSet
+ lookup_field = 'contribution_id'
+ lookup_url_kwarg = 'contribution_id'
+ ordering_fields = [
+ 'contribution__created_at',
+ 'contribution__contribution_date',
+ 'contribution__frozen_global_points',
+ 'pending_xp',
+ 'distributed_at',
+ ]
+ ordering = ['-contribution__created_at']
+
+ def get_queryset(self):
+ queryset = ContributionDiscordXPState.objects.filter(
+ contribution__contribution_type__category__slug='community',
+ ).filter(
+ Q(contribution__frozen_global_points__gt=0) |
+ Q(awarded_amount__gt=0)
+ )
+
+ if self.request.user and self.request.user.is_authenticated and hasattr(self.request.user, 'steward'):
+ permitted_ids = steward_permitted_type_ids(self.request.user, actions=['accept'])
+ if permitted_ids:
+ queryset = queryset.filter(contribution__contribution_type_id__in=permitted_ids)
+ else:
+ queryset = queryset.none()
+
+ latest_events = DiscordXPDistributionEvent.objects.select_related(
+ 'actor',
+ ).order_by('-created_at')
+
+ return queryset.select_related(
+ 'contribution',
+ 'contribution__user',
+ 'contribution__user__discordconnection',
+ 'contribution__contribution_type',
+ 'contribution__contribution_type__category',
+ 'distributed_by',
+ 'last_copied_by',
+ ).prefetch_related(
+ Prefetch('events', queryset=latest_events[:1], to_attr='latest_events'),
+ ).annotate(
+ pending_xp=Greatest(
+ F('contribution__frozen_global_points') - F('awarded_amount'),
+ Value(0),
+ output_field=IntegerField(),
+ )
+ )
+
+ def _get_locked_state(self, contribution_id):
+ state = self.get_queryset().select_for_update(of=('self',)).get(
+ contribution_id=contribution_id,
+ )
+ contribution = state.contribution
+
+ if not steward_has_permission(self.request.user, contribution.contribution_type_id, 'accept'):
+ return None, Response(
+ {'detail': 'You do not have permission to manage XP for this contribution type.'},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ sync_discord_xp_state_for_contribution(contribution)
+ state = ContributionDiscordXPState.objects.select_for_update(of=('self',)).select_related(
+ 'contribution',
+ 'contribution__user',
+ 'contribution__user__discordconnection',
+ 'contribution__contribution_type',
+ 'contribution__contribution_type__category',
+ 'distributed_by',
+ 'last_copied_by',
+ ).get(pk=state.pk)
+ state.refresh_status_from_contribution(save=True)
+ return state, None
+
+ def _record_event(self, state, action, amount):
+ return DiscordXPDistributionEvent.objects.create(
+ state=state,
+ actor=self.request.user,
+ amount=max(int(amount or 0), 0),
+ action=action,
+ )
+
+ def _refresh_discord_connection(self, connection):
+ from social_connections.oauth_service import DiscordOAuthService
+
+ try:
+ refreshed_connection, _ = DiscordOAuthService().refresh_connection_username(connection)
+ except ValueError as e:
+ error_code = str(e)
+ if error_code == 'account_mismatch':
+ return None, Response(
+ {'detail': 'Discord account mismatch. The contributor must reconnect Discord before XP can be copied.'},
+ status=status.HTTP_409_CONFLICT,
+ )
+ if error_code in (
+ 'missing_access_token',
+ 'invalid_access_token',
+ 'missing_refresh_token',
+ 'invalid_refresh_token',
+ 'refresh_not_supported',
+ 'no_access_token',
+ ):
+ return None, Response(
+ {'detail': 'Discord authorization is no longer valid. The contributor must reconnect Discord before XP can be copied.'},
+ status=status.HTTP_409_CONFLICT,
+ )
+ return None, Response(
+ {'detail': 'Failed to refresh Discord username before copying XP.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ except requests.RequestException:
+ return None, Response(
+ {'detail': 'Failed to reach Discord while refreshing the contributor username. Please try again.'},
+ status=status.HTTP_502_BAD_GATEWAY,
+ )
+
+ return refreshed_connection, None
+
+ @action(detail=True, methods=['post'], url_path='record-copy')
+ def record_copy(self, request, contribution_id=None):
+ try:
+ with transaction.atomic():
+ state, response = self._get_locked_state(contribution_id)
+ if response:
+ return response
+
+ if state.status == ContributionDiscordXPState.STATUS_NEEDS_REVIEW:
+ return Response(
+ {
+ 'detail': 'This contribution has more XP marked distributed than its current community points.',
+ 'state': self.get_serializer(state).data,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+
+ discord_connection = getattr(state.contribution.user, 'discordconnection', None)
+ if not discord_connection:
+ return Response(
+ {'detail': 'This contributor does not have a linked Discord account.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+
+ if state.pending_amount <= 0:
+ return Response(
+ {'detail': 'This contribution has no pending XP to copy.', 'state': self.get_serializer(state).data},
+ status=status.HTTP_409_CONFLICT,
+ )
+
+ discord_connection, response = self._refresh_discord_connection(discord_connection)
+ if response:
+ return response
+ state.contribution.user._state.fields_cache.pop('discordconnection', None)
+
+ now = timezone.now()
+ state.last_copied_at = now
+ state.last_copied_by = request.user
+ state.save(update_fields=[
+ 'last_copied_at',
+ 'last_copied_by',
+ 'updated_at',
+ ])
+ self._record_event(
+ state,
+ DiscordXPDistributionEvent.ACTION_COPIED,
+ state.pending_amount,
+ )
+ return Response(self.get_serializer(state).data)
+ except ContributionDiscordXPState.DoesNotExist:
+ return self._not_found_or_non_community(contribution_id)
+
+ @action(detail=True, methods=['post'], url_path='mark-distributed')
+ def mark_distributed(self, request, contribution_id=None):
+ try:
+ with transaction.atomic():
+ state, response = self._get_locked_state(contribution_id)
+ if response:
+ return response
+
+ if state.status == ContributionDiscordXPState.STATUS_NEEDS_REVIEW:
+ return Response(
+ {
+ 'detail': 'Unset this contribution before marking it distributed again.',
+ 'state': self.get_serializer(state).data,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+
+ pending_amount = state.pending_amount
+ if pending_amount <= 0:
+ return Response(self.get_serializer(state).data)
+
+ now = timezone.now()
+ state.awarded_amount = int(state.awarded_amount or 0) + pending_amount
+ state.distributed_at = now
+ state.distributed_by = request.user
+ state.refresh_status_from_contribution(save=False)
+ state.save(update_fields=[
+ 'awarded_amount',
+ 'distributed_at',
+ 'distributed_by',
+ 'status',
+ 'updated_at',
+ ])
+ self._record_event(
+ state,
+ DiscordXPDistributionEvent.ACTION_DISTRIBUTED,
+ pending_amount,
+ )
+ return Response(self.get_serializer(state).data)
+ except ContributionDiscordXPState.DoesNotExist:
+ return self._not_found_or_non_community(contribution_id)
+
+ @action(detail=True, methods=['post'], url_path='unset-distributed')
+ def unset_distributed(self, request, contribution_id=None):
+ try:
+ with transaction.atomic():
+ state, response = self._get_locked_state(contribution_id)
+ if response:
+ return response
+
+ previous_amount = int(state.awarded_amount or 0)
+ state.awarded_amount = 0
+ state.status = ContributionDiscordXPState.STATUS_PENDING
+ state.distributed_at = None
+ state.distributed_by = None
+ state.save(update_fields=[
+ 'awarded_amount',
+ 'status',
+ 'distributed_at',
+ 'distributed_by',
+ 'updated_at',
+ ])
+ self._record_event(
+ state,
+ DiscordXPDistributionEvent.ACTION_UNSET,
+ previous_amount,
+ )
+ return Response(self.get_serializer(state).data)
+ except ContributionDiscordXPState.DoesNotExist:
+ return self._not_found_or_non_community(contribution_id)
+
+ def _not_found_or_non_community(self, contribution_id):
+ if Contribution.objects.filter(pk=contribution_id).exclude(
+ contribution_type__category__slug='community',
+ ).exists():
+ return Response(
+ {'detail': 'Discord XP can only be managed for community contributions.'},
+ status=status.HTTP_400_BAD_REQUEST,
+ )
+ return Response(
+ {'detail': 'Community contribution XP state not found.'},
+ status=status.HTTP_404_NOT_FOUND,
+ )
+
+
class StewardSubmissionViewSet(viewsets.ModelViewSet):
"""
API endpoint for stewards to review submissions.
diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py
index 02e7663d..4e1ee9cf 100644
--- a/backend/leaderboard/tests/test_stats.py
+++ b/backend/leaderboard/tests/test_stats.py
@@ -9,10 +9,13 @@
Mission,
SubmittedContribution,
)
+from community_xp.models import Mee6CurrentXP, Mee6SyncRun
from creators.models import Creator
from leaderboard.models import GlobalLeaderboardMultiplier, ReferralPoints
from poaps.models import PoapClaim, PoapDrop
+from social_connections.models import DiscordConnection
from users.models import User
+from validators.models import Validator
class LeaderboardStatsTest(TestCase):
@@ -36,6 +39,15 @@ def setUp(self):
slug='builder-submission',
category=self.builder_category
)
+ self.validator_category, _ = Category.objects.get_or_create(
+ slug='validator',
+ defaults={'name': 'Validator'}
+ )
+ self.validator_type = ContributionType.objects.create(
+ name='Validator Uptime',
+ slug='validator-uptime',
+ category=self.validator_category
+ )
self.community_link_x_type, _ = ContributionType.objects.get_or_create(
slug='community-link-x',
defaults={
@@ -45,6 +57,34 @@ def setUp(self):
'max_points': 100,
},
)
+ GlobalLeaderboardMultiplier.objects.get_or_create(
+ contribution_type=self.community_type,
+ defaults={
+ 'multiplier_value': 1,
+ 'valid_from': timezone.now() - timezone.timedelta(days=30),
+ },
+ )
+ GlobalLeaderboardMultiplier.objects.get_or_create(
+ contribution_type=self.builder_type,
+ defaults={
+ 'multiplier_value': 1,
+ 'valid_from': timezone.now() - timezone.timedelta(days=30),
+ },
+ )
+ GlobalLeaderboardMultiplier.objects.get_or_create(
+ contribution_type=self.validator_type,
+ defaults={
+ 'multiplier_value': 1,
+ 'valid_from': timezone.now() - timezone.timedelta(days=30),
+ },
+ )
+ GlobalLeaderboardMultiplier.objects.get_or_create(
+ contribution_type=self.community_link_x_type,
+ defaults={
+ 'multiplier_value': 1,
+ 'valid_from': timezone.now() - timezone.timedelta(days=30),
+ },
+ )
def _create_user(self, email, address, visible=True):
return User.objects.create_user(
@@ -54,6 +94,40 @@ def _create_user(self, email, address, visible=True):
visible=visible
)
+ def _create_current_mee6_xp(self, user, discord_id, xp):
+ now = timezone.now()
+ run = Mee6SyncRun.objects.create(
+ guild_id='1237055789441487021',
+ guild_name='GenLayer',
+ status=Mee6SyncRun.STATUS_SUCCESS,
+ page_size=1000,
+ pages_fetched=1,
+ players_fetched=1,
+ matched_players=1,
+ unmatched_players=0,
+ completed_at=now,
+ applied_at=now,
+ )
+ DiscordConnection.objects.create(
+ user=user,
+ platform_user_id=discord_id,
+ platform_username=f'discord-{discord_id}',
+ linked_at=now,
+ )
+ return Mee6CurrentXP.objects.create(
+ guild_id=run.guild_id,
+ discord_id=discord_id,
+ username=f'discord-{discord_id}',
+ rank=1,
+ xp=xp,
+ level=4,
+ message_count=12,
+ sync_run=run,
+ matched_user=user,
+ matched_at=now,
+ synced_at=now,
+ )
+
def test_community_member_count_uses_accepted_community_contributions(self):
now = timezone.now()
community_user = self._create_user(
@@ -138,7 +212,7 @@ def test_community_member_count_uses_accepted_community_contributions(self):
self.assertEqual(response.data['creator_count'], 2)
self.assertEqual(response.data['builder_count'], 1)
- def test_poap_claim_grants_role_but_does_not_count_as_member_metric(self):
+ def test_poap_claim_grants_role_and_counts_as_member_metric(self):
poap_user = self._create_user(
'poap@example.com',
'0x0000000000000000000000000000000000000007'
@@ -159,9 +233,63 @@ def test_poap_claim_grants_role_but_does_not_count_as_member_metric(self):
response = self.client.get('/api/v1/leaderboard/stats/')
self.assertEqual(response.status_code, 200)
- self.assertEqual(response.data['community_member_count'], 0)
+ self.assertEqual(response.data['community_member_count'], 1)
+ self.assertEqual(response.data['creator_count'], 1)
self.assertTrue(Creator.objects.filter(user=poap_user).exists())
+ def test_validator_count_uses_visible_validator_table_rows(self):
+ validator_user = self._create_user(
+ 'validator@example.com',
+ '0x0000000000000000000000000000000000000012'
+ )
+ validator_activity_only_user = self._create_user(
+ 'validator-activity@example.com',
+ '0x0000000000000000000000000000000000000013'
+ )
+ hidden_validator_user = self._create_user(
+ 'hidden-validator@example.com',
+ '0x0000000000000000000000000000000000000014',
+ visible=False
+ )
+
+ Validator.objects.create(user=validator_user)
+ Validator.objects.create(user=hidden_validator_user)
+
+ Contribution.objects.bulk_create([
+ Contribution(
+ user=validator_user,
+ contribution_type=self.validator_type,
+ points=10,
+ frozen_global_points=10,
+ contribution_date=timezone.now()
+ ),
+ Contribution(
+ user=validator_activity_only_user,
+ contribution_type=self.validator_type,
+ points=10,
+ frozen_global_points=10,
+ contribution_date=timezone.now()
+ ),
+ Contribution(
+ user=hidden_validator_user,
+ contribution_type=self.validator_type,
+ points=10,
+ frozen_global_points=10,
+ contribution_date=timezone.now()
+ ),
+ ])
+
+ global_response = self.client.get('/api/v1/leaderboard/stats/')
+ validator_response = self.client.get('/api/v1/leaderboard/stats/', {'type': 'validator'})
+
+ self.assertEqual(global_response.status_code, 200)
+ self.assertEqual(global_response.data['validator_count'], 1)
+ self.assertEqual(global_response.data['new_validators_count'], 1)
+
+ self.assertEqual(validator_response.status_code, 200)
+ self.assertEqual(validator_response.data['validator_count'], 1)
+ self.assertEqual(validator_response.data['participant_count'], 1)
+
def test_community_social_link_contributions_do_not_count_as_members_or_activity(self):
social_user = self._create_user(
'social@example.com',
@@ -182,6 +310,83 @@ def test_community_social_link_contributions_do_not_count_as_members_or_activity
self.assertEqual(response.data['participant_count'], 0)
self.assertEqual(response.data['contribution_count'], 0)
+ def test_community_stats_use_effective_mee6_points_and_members(self):
+ mee6_only_user = self._create_user(
+ 'mee6-only@example.com',
+ '0x0000000000000000000000000000000000000010'
+ )
+ pending_portal_user = self._create_user(
+ 'pending-portal@example.com',
+ '0x0000000000000000000000000000000000000011'
+ )
+ self._create_current_mee6_xp(mee6_only_user, 'discord-mee6-only', 100)
+ Contribution.objects.create(
+ user=pending_portal_user,
+ contribution_type=self.community_type,
+ points=80,
+ frozen_global_points=80,
+ contribution_date=timezone.now()
+ )
+
+ community_response = self.client.get('/api/v1/leaderboard/stats/', {'type': 'community'})
+ global_response = self.client.get('/api/v1/leaderboard/stats/')
+
+ self.assertEqual(community_response.status_code, 200)
+ self.assertEqual(community_response.data['total_points'], 180)
+ self.assertEqual(community_response.data['participant_count'], 2)
+ self.assertEqual(community_response.data['community_member_count'], 2)
+ self.assertEqual(community_response.data['creator_count'], 2)
+ self.assertEqual(community_response.data['contribution_count'], 1)
+
+ self.assertEqual(global_response.status_code, 200)
+ self.assertEqual(global_response.data['total_points'], 180)
+ self.assertEqual(global_response.data['participant_count'], 2)
+ self.assertEqual(global_response.data['community_member_count'], 2)
+
+ def test_recent_mee6_sync_does_not_count_existing_member_as_new(self):
+ mee6_user = self._create_user(
+ 'existing-mee6@example.com',
+ '0x0000000000000000000000000000000000000014'
+ )
+ self._create_current_mee6_xp(mee6_user, 'discord-existing-mee6', 100)
+ creator = Creator.objects.create(user=mee6_user)
+ Creator.objects.filter(pk=creator.pk).update(
+ created_at=timezone.now() - timezone.timedelta(days=60)
+ )
+
+ response = self.client.get('/api/v1/leaderboard/stats/', {'type': 'community'})
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['community_member_count'], 1)
+ self.assertEqual(response.data['new_community_members_count'], 0)
+
+ def test_generic_community_leaderboard_uses_effective_mee6_points(self):
+ mee6_user = self._create_user(
+ 'generic-mee6@example.com',
+ '0x0000000000000000000000000000000000000012'
+ )
+ portal_user = self._create_user(
+ 'generic-portal@example.com',
+ '0x0000000000000000000000000000000000000013'
+ )
+ self._create_current_mee6_xp(mee6_user, 'discord-generic-mee6', 100)
+ Contribution.objects.create(
+ user=portal_user,
+ contribution_type=self.community_type,
+ points=80,
+ frozen_global_points=80,
+ contribution_date=timezone.now()
+ )
+
+ response = self.client.get('/api/v1/leaderboard/', {'type': 'community'})
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['count'], 2)
+ self.assertEqual(response.data['results'][0]['user_address'], mee6_user.address)
+ self.assertEqual(response.data['results'][0]['total_points'], 100)
+ self.assertEqual(response.data['results'][1]['user_address'], portal_user.address)
+ self.assertEqual(response.data['results'][1]['total_points'], 80)
+
def test_mission_backed_non_submittable_community_contribution_is_reflected(self):
contributor = self._create_user(
'mission-community@example.com',
@@ -219,6 +424,7 @@ def test_mission_backed_non_submittable_community_contribution_is_reflected(self
self.assertEqual(stats_response.status_code, 200)
self.assertEqual(stats_response.data['community_member_count'], 1)
+ self.assertEqual(stats_response.data['new_community_members_count'], 1)
self.assertEqual(stats_response.data['contribution_count'], 1)
self.assertTrue(Creator.objects.filter(user=contributor).exists())
diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py
index 41c1a1af..77f8270d 100644
--- a/backend/leaderboard/views.py
+++ b/backend/leaderboard/views.py
@@ -144,6 +144,9 @@ def list(self, request, *args, **kwargs):
"""
Override to apply offset and limit after filtering/ordering.
"""
+ if request.query_params.get('type') == 'community':
+ return self.community(request)
+
queryset = self.filter_queryset(self.get_queryset())
# Parse offset and limit
@@ -271,14 +274,41 @@ def stats(self, request):
Get statistics for the dashboard.
Supports optional 'type' parameter for category-specific stats.
"""
- from django.db.models import Sum, Count
+ from django.db.models import Sum
from contributions.models import Contribution
- from users.models import User
+ from validators.models import Validator
leaderboard_type = request.query_params.get('type')
now = timezone.now()
last_month = now - timezone.timedelta(days=30)
+ effective_community_summary = None
+
+ def get_validators():
+ return Validator.objects.filter(user__visible=True)
+
+ def get_effective_community_summary():
+ nonlocal effective_community_summary
+ if effective_community_summary is None:
+ from community_xp.utils import (
+ build_effective_community_scores,
+ get_community_member_user_ids,
+ )
+
+ entries = [
+ score
+ for score in build_effective_community_scores(visible_only=True).values()
+ if (score['total_points'] or 0) > 0
+ ]
+ effective_community_summary = {
+ 'member_user_ids': get_community_member_user_ids(visible_only=True),
+ 'total_points': sum(score['total_points'] or 0 for score in entries),
+ 'user_ids': {score['user'].id for score in entries},
+ }
+ effective_community_summary['member_count'] = len(
+ effective_community_summary['member_user_ids']
+ )
+ return effective_community_summary
if leaderboard_type:
# Category-specific stats
@@ -307,15 +337,26 @@ def stats(self, request):
new_contributions_count = category_contributions.filter(
created_at__gte=last_month
).count()
- # Total points and new points exclude welcome/waitlist auto-awards
- # so the dashboard reflects real contribution activity, not journey starts.
- total_points = category_contributions.aggregate(
- total=Sum('frozen_global_points')
- )['total'] or 0
+ # Raw contribution counts stay audit/activity metrics. Community
+ # displayed score is MEE6 baseline + pending portal XP state.
+ if leaderboard_type == 'community':
+ community_summary = get_effective_community_summary()
+ total_points = community_summary['total_points']
+ participant_count = community_summary['member_count']
+ elif leaderboard_type == 'validator':
+ total_points = category_contributions.aggregate(
+ total=Sum('frozen_global_points')
+ )['total'] or 0
+ participant_count = get_validators().count()
+ else:
+ total_points = category_contributions.aggregate(
+ total=Sum('frozen_global_points')
+ )['total'] or 0
+ participant_count = category_contributions.values('user_id').distinct().count()
+
new_points_count = category_contributions.filter(
created_at__gte=last_month
).aggregate(total=Sum('frozen_global_points'))['total'] or 0
- participant_count = category_contributions.values('user_id').distinct().count()
else:
contribution_count = 0
new_contributions_count = 0
@@ -333,8 +374,8 @@ def stats(self, request):
new_validators_count = 0
elif leaderboard_type == 'validator':
new_builders_count = 0
- new_validators_count = LeaderboardEntry.objects.filter(
- type='validator', user__created_at__gte=last_month
+ new_validators_count = get_validators().filter(
+ created_at__gte=last_month
).count()
else:
new_builders_count = 0
@@ -346,16 +387,30 @@ def stats(self, request):
).exclude(
contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS
)
- participant_count = all_contributions.values('user_id').distinct().count()
+ non_community_contributions = all_contributions.exclude(
+ contribution_type__category__slug='community'
+ )
+ community_summary = get_effective_community_summary()
+ participant_user_ids = set(
+ non_community_contributions
+ .values_list('user_id', flat=True)
+ .distinct()
+ )
+ participant_user_ids.update(community_summary['member_user_ids'])
+ participant_count = len(participant_user_ids)
contribution_count = all_contributions.count()
new_contributions_count = all_contributions.filter(
created_at__gte=last_month
).count()
- # Total / new points also exclude welcome/waitlist auto-awards.
- total_points = all_contributions.aggregate(
- total=Sum('frozen_global_points')
- )['total'] or 0
+ # Global displayed score uses raw non-community points plus the
+ # effective community score, so raw community rows are not counted
+ # again after they have been passed to Discord XP.
+ total_points = (
+ non_community_contributions.aggregate(
+ total=Sum('frozen_global_points')
+ )['total'] or 0
+ ) + community_summary['total_points']
new_points_count = all_contributions.filter(
created_at__gte=last_month
@@ -379,16 +434,11 @@ def stats(self, request):
created_at__gte=last_month
).values('user_id').distinct().count()
- validator_contribs = Contribution.objects.filter(
- user__visible=True,
- contribution_type__category__slug='validator',
- ).exclude(
- contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS
- )
- validator_count = validator_contribs.values('user_id').distinct().count()
- new_validators_count = validator_contribs.filter(
+ validators = get_validators()
+ validator_count = validators.count()
+ new_validators_count = validators.filter(
created_at__gte=last_month
- ).values('user_id').distinct().count()
+ ).count()
community_contribs = Contribution.objects.filter(
user__visible=True,
@@ -396,10 +446,12 @@ def stats(self, request):
).exclude(
contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS
)
- community_member_count = community_contribs.values('user_id').distinct().count()
- new_community_members_count = community_contribs.filter(
- created_at__gte=last_month
- ).values('user_id').distinct().count()
+ community_summary = get_effective_community_summary()
+ community_member_count = community_summary['member_count']
+ from community_xp.utils import get_community_member_user_ids
+ new_community_members_count = len(
+ get_community_member_user_ids(visible_only=True, since=last_month)
+ )
return Response({
'participant_count': participant_count,
@@ -431,10 +483,18 @@ def _get_user_stats(self, user, category=None):
if category:
contributions = contributions.filter(contribution_type__category__slug=category)
- # Get user's total points
- total_points = contributions.aggregate(
+ # Get user's raw portal contribution points. For community, these remain
+ # the audit trail while displayed points use MEE6 as the weekly baseline.
+ raw_total_points = contributions.aggregate(
total=Sum('frozen_global_points')
)['total'] or 0
+ total_points = raw_total_points
+
+ community_xp_breakdown = None
+ if category == 'community':
+ from community_xp.utils import get_effective_community_points
+ community_xp_breakdown = get_effective_community_points(user)
+ total_points = community_xp_breakdown['total_points']
# Get user's contribution count
contribution_count = contributions.count()
@@ -455,7 +515,8 @@ def _get_user_stats(self, user, category=None):
).order_by('-total_points')
for type_data in types_data:
- percentage = (type_data['total_points'] / total_points * 100) if total_points > 0 else 0
+ percentage_base = raw_total_points if category == 'community' else total_points
+ percentage = (type_data['total_points'] / percentage_base * 100) if percentage_base > 0 else 0
contribution_types.append({
'id': type_data['contribution_type__id'],
'name': type_data['contribution_type__name'],
@@ -466,12 +527,25 @@ def _get_user_stats(self, user, category=None):
'percentage': percentage,
})
- return {
+ result = {
'totalContributions': contribution_count,
'totalPoints': total_points,
'averagePoints': avg_points,
'contributionTypes': contribution_types,
}
+
+ if community_xp_breakdown:
+ result.update({
+ 'discord_xp': community_xp_breakdown['discord_xp'],
+ 'discord_xp_synced_at': community_xp_breakdown['discord_xp_synced_at'],
+ 'pending_portal_points': community_xp_breakdown['pending_portal_points'],
+ 'tracked_portal_points_all_time': community_xp_breakdown['tracked_portal_points_all_time'],
+ 'has_discord_xp_snapshot': community_xp_breakdown['has_discord_xp_snapshot'],
+ 'latest_applied_sync_completed_at': community_xp_breakdown['latest_applied_sync_completed_at'],
+ 'latest_applied_at': community_xp_breakdown['latest_applied_at'],
+ })
+
+ return result
@action(detail=False, methods=['get'], url_path='user/(?P[^/.]+)')
def user_stats(self, request, user_id=None):
@@ -601,11 +675,11 @@ def types(self, request):
def community(self, request):
"""
Get community statistics and paginated community members.
- Returns users sorted by actual community contribution points.
+ Returns users sorted by effective community points.
Supports limit/offset pagination and user_address lookup.
"""
- from users.models import User
from users.serializers import LightUserSerializer
+ from community_xp.utils import build_effective_community_scores
try:
limit = int(request.query_params.get('limit', 20))
@@ -618,57 +692,51 @@ def community(self, request):
except (ValueError, TypeError):
offset = 0
- community_contributions = Contribution.objects.filter(
- user__visible=True,
- contribution_type__category__slug='community',
- ).exclude(
- contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS
- )
+ score_map = build_effective_community_scores(visible_only=True)
+ entries = [
+ score
+ for score in score_map.values()
+ if (score['total_points'] or 0) > 0
+ ]
- search = request.query_params.get('search', '').strip()
+ search = request.query_params.get('search', '').strip().lower()
if search:
- community_contributions = community_contributions.filter(
- Q(user__name__icontains=search) |
- Q(user__address__icontains=search)
+ entries = [
+ score
+ for score in entries
+ if search in (score['user'].name or '').lower()
+ or search in (score['user'].address or '').lower()
+ ]
+
+ entries.sort(
+ key=lambda score: (
+ -(score['total_points'] or 0),
+ (score['user'].name or '').lower(),
+ score['user'].id,
)
-
- community_totals = (
- community_contributions.values('user_id')
- .annotate(
- total_points=Sum('frozen_global_points'),
- contribution_count=Count('id'),
- )
- .filter(total_points__gt=0)
- .order_by('-total_points', 'user__name')
)
- count = community_totals.count()
+ count = len(entries)
user_address = request.query_params.get('user_address')
user_rank = None
user_total_points = None
if user_address:
- user_entry = community_totals.filter(user__address__iexact=user_address).first()
- if user_entry:
- user_total_points = user_entry['total_points'] or 0
- if user_total_points > 0:
- user_rank = community_totals.filter(total_points__gt=user_total_points).count() + 1
+ user_address = user_address.lower()
+ for rank, score in enumerate(entries, start=1):
+ if (score['user'].address or '').lower() == user_address:
+ user_rank = rank
+ user_total_points = score['total_points'] or 0
+ break
- page = list(community_totals[offset:offset + limit])
- users_by_id = {
- user.id: user
- for user in User.objects.filter(id__in=[entry['user_id'] for entry in page])
- .select_related('validator', 'builder', 'steward', 'creator')
- }
+ page = entries[offset:offset + limit]
results = []
- for index, entry in enumerate(page, start=offset + 1):
- user = users_by_id.get(entry['user_id'])
- if not user:
- continue
+ for index, score in enumerate(page, start=offset + 1):
+ user = score['user']
user_data = LightUserSerializer(user).data
- total_points = entry['total_points'] or 0
+ total_points = score['total_points'] or 0
results.append({
**user_data,
'user_details': user_data,
@@ -676,7 +744,14 @@ def community(self, request):
'user_name': user.name,
'community_points': total_points,
'total_points': total_points,
- 'contribution_count': entry['contribution_count'] or 0,
+ 'contribution_count': score['community_contribution_count'] or 0,
+ 'discord_xp': score['discord_xp'],
+ 'discord_xp_synced_at': score['discord_xp_synced_at'],
+ 'pending_portal_points': score['pending_portal_points'],
+ 'tracked_portal_points_all_time': score['tracked_portal_points_all_time'],
+ 'has_discord_xp_snapshot': score['has_discord_xp_snapshot'],
+ 'latest_applied_sync_completed_at': score['latest_applied_sync_completed_at'],
+ 'latest_applied_at': score['latest_applied_at'],
'rank': index,
})
diff --git a/backend/poaps/services.py b/backend/poaps/services.py
index b91c05b9..57bde78f 100644
--- a/backend/poaps/services.py
+++ b/backend/poaps/services.py
@@ -8,6 +8,7 @@
from django.db.models import F
from django.utils import timezone
from django.utils.crypto import constant_time_compare, salted_hmac
+from social_connections.models import DiscordConnection
from .models import PoapClaim, PoapDistribution, PoapDrop, PoapMintLink
@@ -28,6 +29,10 @@ class ClaimClosedError(PoapClaimError):
status_code = 400
+class MissingDiscordConnectionError(PoapClaimError):
+ status_code = 403
+
+
def normalize_secret(value):
return ' '.join((value or '').strip().lower().split())
@@ -77,6 +82,13 @@ def validate_distribution(distribution):
raise ClaimClosedError('This distribution is not currently open.')
+def validate_discord_connection(user):
+ if not DiscordConnection.objects.filter(user_id=user.pk).exists():
+ raise MissingDiscordConnectionError(
+ 'You must link your Discord account to claim this POAP.'
+ )
+
+
def _create_claim(*, drop, user, distribution, mint_link=None, method=None):
if PoapClaim.objects.filter(drop=drop, user=user).exists():
raise AlreadyClaimedError('You already claimed this POAP.')
@@ -106,6 +118,7 @@ def _create_claim(*, drop, user, distribution, mint_link=None, method=None):
def claim_with_secret(*, drop_slug, user, secret):
if not user or not user.is_authenticated:
raise InvalidClaimError('Authentication is required.')
+ validate_discord_connection(user)
candidate_hash = hash_secret(secret)
with transaction.atomic():
@@ -149,6 +162,7 @@ def claim_with_mint_link(*, token, user):
raise InvalidClaimError('Authentication is required.')
if not token:
raise InvalidClaimError('Mint link token is missing.')
+ validate_discord_connection(user)
token_digest = hash_token(token)
with transaction.atomic():
diff --git a/backend/poaps/tests/test_poaps.py b/backend/poaps/tests/test_poaps.py
index b8b8eecb..1c21d9a2 100644
--- a/backend/poaps/tests/test_poaps.py
+++ b/backend/poaps/tests/test_poaps.py
@@ -17,6 +17,7 @@
from poaps.admin import PoapDistributionAdminForm, PoapDropAdminForm
from poaps.models import PoapClaim, PoapDistribution, PoapDrop, PoapImportBatch
from poaps.services import generate_mint_links, hash_secret
+from social_connections.models import DiscordConnection
User = get_user_model()
@@ -48,6 +49,16 @@ def setUp(self):
event_start_at=timezone.now(),
status=PoapDrop.STATUS_ACTIVE,
)
+ self._link_discord(self.user, 'discord-user')
+ self._link_discord(self.other_user, 'discord-other')
+
+ def _link_discord(self, user, platform_user_id):
+ return DiscordConnection.objects.create(
+ user=user,
+ platform_user_id=platform_user_id,
+ platform_username=platform_user_id,
+ linked_at=timezone.now(),
+ )
def _secret_distribution(self, secret='friend-scientist-natural', **kwargs):
defaults = {
@@ -114,6 +125,23 @@ def test_secret_claim_success(self):
distribution.refresh_from_db()
self.assertEqual(distribution.claimed_count, 1)
+ def test_secret_claim_requires_discord_connection(self):
+ distribution = self._secret_distribution()
+ DiscordConnection.objects.filter(user=self.user).delete()
+ self.client.force_authenticate(user=self.user)
+
+ response = self.client.post(
+ '/api/v1/poaps/ama-session/claim-secret/',
+ {'secret': 'friend-scientist-natural'},
+ format='json',
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertIn('link your Discord', response.data['error'])
+ self.assertFalse(PoapClaim.objects.filter(drop=self.drop, user=self.user).exists())
+ distribution.refresh_from_db()
+ self.assertEqual(distribution.claimed_count, 0)
+
def test_secret_claim_rejects_invalid_secret(self):
self._secret_distribution()
self.client.force_authenticate(user=self.user)
@@ -186,6 +214,26 @@ def test_mint_link_claim_reports_missing_token(self):
'Mint link token was not found. Check that the full Claim URL was copied from the admin.',
)
+ def test_mint_link_claim_requires_discord_connection(self):
+ distribution = PoapDistribution.objects.create(
+ drop=self.drop,
+ method=PoapDistribution.METHOD_MINT_LINK,
+ active=True,
+ )
+ [(link, token)] = generate_mint_links(distribution=distribution, count=1)
+ DiscordConnection.objects.filter(user=self.user).delete()
+ self.client.force_authenticate(user=self.user)
+
+ response = self.client.post(f'/api/v1/poaps/claim-link/{token}/')
+
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+ self.assertIn('link your Discord', response.data['error'])
+ self.assertFalse(PoapClaim.objects.filter(drop=self.drop, user=self.user).exists())
+ link.refresh_from_db()
+ distribution.refresh_from_db()
+ self.assertEqual(link.used_count, 0)
+ self.assertEqual(distribution.claimed_count, 0)
+
def test_mint_link_claim_reports_inactive_distribution(self):
distribution = PoapDistribution.objects.create(
drop=self.drop,
diff --git a/backend/projects/models.py b/backend/projects/models.py
index 20e61c8d..f0303b97 100644
--- a/backend/projects/models.py
+++ b/backend/projects/models.py
@@ -82,3 +82,15 @@ def get_link(self):
if self.slug:
return f"/builders/projects/{self.slug}"
return self.url or None
+
+ def can_be_edited_by(self, user):
+ if not user or not user.is_authenticated:
+ return False
+ if user.is_superuser:
+ return True
+ if self.user_id == user.id:
+ return True
+ prefetched = getattr(self, '_prefetched_objects_cache', {})
+ if 'participants' in prefetched:
+ return any(participant.id == user.id for participant in prefetched['participants'])
+ return self.participants.filter(id=user.id).exists()
diff --git a/backend/projects/serializers.py b/backend/projects/serializers.py
index 98a0cb61..3efe02ad 100644
--- a/backend/projects/serializers.py
+++ b/backend/projects/serializers.py
@@ -76,9 +76,7 @@ def get_link(self, obj):
def get_can_edit(self, obj):
request = self.context.get('request')
user = getattr(request, 'user', None)
- if not user or not user.is_authenticated:
- return False
- return user.is_staff or obj.user_id == user.id
+ return obj.can_be_edited_by(user)
class ProjectDetailSerializer(ProjectListSerializer):
diff --git a/backend/projects/tests/test_projects.py b/backend/projects/tests/test_projects.py
index 41f6eab2..ca345383 100644
--- a/backend/projects/tests/test_projects.py
+++ b/backend/projects/tests/test_projects.py
@@ -5,6 +5,7 @@
from contributions.models import Category, Contribution, ContributionType, FeaturedContent
from projects.models import Project
+from stewards.models import Steward
from users.models import User
@@ -106,6 +107,85 @@ def test_non_owner_cannot_update_project_profile(self):
self.assertEqual(response.status_code, 403)
+ def test_project_participant_can_update_project_profile(self):
+ project = self.create_project()
+ participant = User.objects.create_user(
+ email='participant-editor@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000005',
+ name='Participant Editor',
+ )
+ project.participants.add(participant)
+ self.client.force_login(participant)
+
+ response = self.client.patch(
+ f'/api/v1/projects/{project.slug}/profile/',
+ data={'description': 'Participant update.'},
+ content_type='application/json',
+ )
+
+ self.assertEqual(response.status_code, 200)
+ project.refresh_from_db()
+ self.assertEqual(project.description, 'Participant update.')
+
+ def test_staff_user_cannot_update_project_profile_without_project_access(self):
+ project = self.create_project()
+ staff_user = User.objects.create_user(
+ email='staff@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000006',
+ name='Staff User',
+ is_staff=True,
+ )
+ self.client.force_login(staff_user)
+
+ response = self.client.patch(
+ f'/api/v1/projects/{project.slug}/profile/',
+ data={'description': 'Staff update.'},
+ content_type='application/json',
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ def test_steward_cannot_update_project_profile_without_project_access(self):
+ project = self.create_project()
+ steward_user = User.objects.create_user(
+ email='steward@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000007',
+ name='Steward User',
+ )
+ Steward.objects.create(user=steward_user)
+ self.client.force_login(steward_user)
+
+ response = self.client.patch(
+ f'/api/v1/projects/{project.slug}/profile/',
+ data={'description': 'Steward update.'},
+ content_type='application/json',
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ def test_superuser_can_update_project_profile(self):
+ project = self.create_project()
+ superuser = User.objects.create_superuser(
+ email='admin@example.com',
+ password='pass',
+ address='0x0000000000000000000000000000000000000008',
+ name='Admin User',
+ )
+ self.client.force_login(superuser)
+
+ response = self.client.patch(
+ f'/api/v1/projects/{project.slug}/profile/',
+ data={'description': 'Admin update.'},
+ content_type='application/json',
+ )
+
+ self.assertEqual(response.status_code, 200)
+ project.refresh_from_db()
+ self.assertEqual(project.description, 'Admin update.')
+
def test_project_owner_can_update_profile_fields_and_participants(self):
project = self.create_project()
participant = User.objects.create_user(
diff --git a/backend/projects/views.py b/backend/projects/views.py
index 88a5ecf0..b7282a28 100644
--- a/backend/projects/views.py
+++ b/backend/projects/views.py
@@ -42,11 +42,8 @@ def get_serializer_class(self):
return ProjectListSerializer
def ensure_project_editor(self, project):
- user = self.request.user
- if not user or not user.is_authenticated:
- raise PermissionDenied('Authentication is required.')
- if not user.is_staff and project.user_id != user.id:
- raise PermissionDenied('You can only edit projects assigned to your account.')
+ if not project.can_be_edited_by(self.request.user):
+ raise PermissionDenied('You can only edit projects you own or participate in.')
@action(detail=True, methods=['patch'], url_path='profile', permission_classes=[permissions.IsAuthenticated])
def profile(self, request, slug=None):
diff --git a/backend/social_connections/serializers.py b/backend/social_connections/serializers.py
index 58698892..18f3d85a 100644
--- a/backend/social_connections/serializers.py
+++ b/backend/social_connections/serializers.py
@@ -37,6 +37,28 @@ class DiscordConnectionSerializer(serializers.ModelSerializer):
avatar_url = serializers.SerializerMethodField()
roles = serializers.SerializerMethodField()
next_manual_role_sync_at = serializers.SerializerMethodField()
+ mee6_xp = serializers.SerializerMethodField()
+ mee6_level = serializers.SerializerMethodField()
+ mee6_rank = serializers.SerializerMethodField()
+ mee6_synced_at = serializers.SerializerMethodField()
+
+ def _get_mee6_current_xp(self, obj):
+ if hasattr(obj, '_mee6_current_xp_cache'):
+ return obj._mee6_current_xp_cache
+
+ from community_xp.models import Mee6CurrentXP
+ from community_xp.services import get_default_guild_id
+
+ current = (
+ Mee6CurrentXP.objects
+ .filter(
+ guild_id=get_default_guild_id(),
+ discord_id=obj.platform_user_id,
+ )
+ .first()
+ )
+ obj._mee6_current_xp_cache = current
+ return current
def get_avatar_url(self, obj):
return obj.avatar_url
@@ -51,6 +73,22 @@ def get_next_manual_role_sync_at(self, obj):
return None
return next_allowed_at
+ def get_mee6_xp(self, obj):
+ current = self._get_mee6_current_xp(obj)
+ return current.xp if current else None
+
+ def get_mee6_level(self, obj):
+ current = self._get_mee6_current_xp(obj)
+ return current.level if current else None
+
+ def get_mee6_rank(self, obj):
+ current = self._get_mee6_current_xp(obj)
+ return current.rank if current else None
+
+ def get_mee6_synced_at(self, obj):
+ current = self._get_mee6_current_xp(obj)
+ return current.synced_at if current else None
+
class Meta:
model = DiscordConnection
fields = [
@@ -58,5 +96,6 @@ class Meta:
'avatar_url', 'roles', 'roles_synced_at', 'roles_sync_error',
'roles_manual_synced_at', 'next_manual_role_sync_at',
'guild_joined_at', 'guild_nick',
+ 'mee6_xp', 'mee6_level', 'mee6_rank', 'mee6_synced_at',
]
read_only_fields = fields
diff --git a/backend/tally/settings.py b/backend/tally/settings.py
index 0d4cef9b..ca085af3 100644
--- a/backend/tally/settings.py
+++ b/backend/tally/settings.py
@@ -81,6 +81,7 @@ def get_required_env(key):
'partners',
'gen_tv',
'social_connections',
+ 'community_xp.apps.CommunityXpConfig',
'poaps.apps.PoapsConfig',
]
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 57aa7f85..860fd108 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -92,6 +92,7 @@
import LoaderShowcase from './routes/LoaderShowcase.svelte';
import StewardDashboard from './routes/StewardDashboard.svelte';
import StewardSubmissions from './routes/StewardSubmissions.svelte';
+ import StewardDiscordXP from './routes/StewardDiscordXP.svelte';
import StewardManageUsers from './routes/StewardManageUsers.svelte';
import ValidatorWaitlist from './routes/ValidatorWaitlist.svelte';
import Waitlist from './routes/Waitlist.svelte';
@@ -186,6 +187,7 @@
// Steward routes
'/stewards': StewardDashboard,
'/stewards/submissions': StewardSubmissions,
+ '/stewards/discord-xp': StewardDiscordXP,
'/stewards/manage-users': StewardManageUsers,
// Legal routes
diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte
index 0e969a96..dc3af8fa 100644
--- a/frontend/src/components/Sidebar.svelte
+++ b/frontend/src/components/Sidebar.svelte
@@ -1,10 +1,52 @@
+
+
+
+
+ {#if showAutocomplete && suggestions.length > 0}
+
+ {#each suggestions as suggestion, index}
+
+ {/each}
+
+ {/if}
+
+ {#if showHelp}
+
+ {/if}
+
+
+
diff --git a/frontend/src/components/TopLeaderboard.svelte b/frontend/src/components/TopLeaderboard.svelte
index 23ce6794..691db365 100644
--- a/frontend/src/components/TopLeaderboard.svelte
+++ b/frontend/src/components/TopLeaderboard.svelte
@@ -35,11 +35,11 @@
let response;
if (categoryToUse === 'global') {
response = await leaderboardAPI.getLeaderboard({ limit });
- leaderboard = response.data || [];
+ leaderboard = extractEntries(response.data);
} else {
// Use type-specific endpoint with limit for efficiency
response = await leaderboardAPI.getLeaderboardByType(categoryToUse, 'asc', { limit });
- leaderboard = response.data || [];
+ leaderboard = extractEntries(response.data);
}
// Backend should return limited results, but slice as fallback
@@ -53,6 +53,10 @@
}
}
+ function extractEntries(data) {
+ return Array.isArray(data) ? data : (data?.results ?? []);
+ }
+
// If entries are provided as prop, use them instead of fetching
$effect(() => {
if (entries !== null) {
@@ -101,4 +105,4 @@
{hideAddress}
{showHeader}
/>
-
\ No newline at end of file
+
diff --git a/frontend/src/components/profile/ProfileHeader.svelte b/frontend/src/components/profile/ProfileHeader.svelte
index 7055c9d6..fed92740 100644
--- a/frontend/src/components/profile/ProfileHeader.svelte
+++ b/frontend/src/components/profile/ProfileHeader.svelte
@@ -39,11 +39,18 @@
const roles = participant?.discord_connection?.roles || [];
return [...roles].sort((a, b) => (b.position || 0) - (a.position || 0) || String(a.name).localeCompare(String(b.name)));
});
- let discordRolesLabel = $derived(
- discordRoles.length > 0
+ let hasDiscordRank = $derived(participant?.discord_connection?.mee6_rank !== null && participant?.discord_connection?.mee6_rank !== undefined);
+ let hasDiscordLevel = $derived(participant?.discord_connection?.mee6_level !== null && participant?.discord_connection?.mee6_level !== undefined);
+ let hasDiscordLeaderboardStats = $derived(hasDiscordRank || hasDiscordLevel);
+ let discordRolesLabel = $derived.by(() => {
+ const stats = [];
+ if (hasDiscordLevel) stats.push(`level ${formatNumber(participant.discord_connection.mee6_level)}`);
+ if (hasDiscordRank) stats.push(`rank #${formatNumber(participant.discord_connection.mee6_rank)}`);
+ const roles = discordRoles.length > 0
? `Discord roles: ${discordRoles.map((role) => role.name).join(", ")}`
- : "Discord roles not synced yet",
- );
+ : "Discord roles not synced yet";
+ return stats.length ? `Discord ${stats.join(", ")}. ${roles}` : roles;
+ });
function getDiscordRoleColor(role) {
if (role?.color && role.color > 0 && role.color_hex) {
@@ -52,6 +59,12 @@
return "#b5bac1";
}
+ function formatNumber(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return value;
+ return new Intl.NumberFormat().format(number);
+ }
+
// UI state for copy-to-clipboard feedback
let copiedAddress = $state(false);
@@ -247,13 +260,30 @@
{/if}
{#if participant?.discord_connection?.platform_username}
-
@@ -404,6 +434,38 @@
line-height: 1.1;
}
+ .discord-xp-summary {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .discord-xp-stat {
+ min-width: 0;
+ border: 1px solid #393943;
+ border-radius: 8px;
+ background: rgba(88, 101, 242, 0.16);
+ padding: 9px 10px;
+ }
+
+ .discord-xp-label {
+ display: block;
+ color: #b5bac1;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1;
+ text-transform: uppercase;
+ }
+
+ .discord-xp-value {
+ display: block;
+ margin-top: 5px;
+ color: #ffffff;
+ font-size: 15px;
+ font-weight: 700;
+ line-height: 1;
+ }
+
.discord-role-list {
display: flex;
flex-wrap: wrap;
diff --git a/frontend/src/components/profile/RankingsWidget.svelte b/frontend/src/components/profile/RankingsWidget.svelte
index 94ecb83f..5fdeb352 100644
--- a/frontend/src/components/profile/RankingsWidget.svelte
+++ b/frontend/src/components/profile/RankingsWidget.svelte
@@ -7,6 +7,7 @@
let {
participant = null,
isOwnProfile = false,
+ topRole = null,
builderStats = null,
validatorStats = null,
communityStats = null,
@@ -14,6 +15,7 @@
} = $props();
let communityRank: number | null = $state(null);
+ const DEFAULT_TAB_ORDER = ["Builders", "Validators", "Community"];
// Role checks
let isBuilder = $derived(!!participant?.builder);
@@ -25,23 +27,66 @@
participant?.has_validator_waitlist && !participant?.validator,
);
- // Only show tabs for roles the user actually has
+ function getTabForRoleKey(roleKey: string) {
+ if (roleKey === "builder") return "Builders";
+ if (roleKey === "validator") return "Validators";
+ if (roleKey === "community") return "Community";
+ return null;
+ }
+
+ let topRoleTabs = $derived.by(() => {
+ const tabs: string[] = [];
+ const badges = topRole?.badges || [];
+
+ for (const badge of badges) {
+ const tab = getTabForRoleKey(badge?.key || badge?.category);
+ if (tab && !tabs.includes(tab)) tabs.push(tab);
+ }
+
+ const categoryTab = getTabForRoleKey(topRole?.category);
+ if (categoryTab && !tabs.includes(categoryTab)) {
+ tabs.push(categoryTab);
+ }
+
+ return tabs;
+ });
+
+ // Only show tabs for roles the user actually has, ordered by top role first.
let availableTabs = $derived.by(() => {
- const tabs = [];
+ const tabs: string[] = [];
if (isBuilder) tabs.push("Builders");
if (isValidator) tabs.push("Validators");
- return tabs;
+ if (isCreator) tabs.push("Community");
+
+ const preferred = topRoleTabs.filter((tab) => tabs.includes(tab));
+ const remaining = DEFAULT_TAB_ORDER.filter(
+ (tab) => tabs.includes(tab) && !preferred.includes(tab),
+ );
+
+ return [...preferred, ...remaining];
});
let hasAnyRankableRole = $derived(isBuilder || isValidator || isCreator);
let activeTab: string | null = $state(null);
+ let activeTabAddress: string | null = $state(null);
+ let userSelectedTab = $state(false);
// Set initial tab when available tabs change
$effect(() => {
const tabs = availableTabs;
+ const addr = participant?.address || null;
+ if (addr !== activeTabAddress) {
+ activeTabAddress = addr;
+ userSelectedTab = false;
+ activeTab = tabs[0] || null;
+ return;
+ }
+
if (tabs.length > 0 && (!activeTab || !tabs.includes(activeTab))) {
activeTab = tabs[0];
+ } else if (tabs.length > 0 && !userSelectedTab && activeTab !== tabs[0]) {
+ activeTab = tabs[0];
}
});
@@ -61,7 +106,8 @@
apiType = isValidatorWaitlist
? "validator-waitlist"
: "validator";
- } else apiType = "builder";
+ } else if (tab === "Community") apiType = "community";
+ else apiType = "builder";
const topRes = await leaderboardAPI.getLeaderboard({
type: apiType,
@@ -90,9 +136,14 @@
type: apiType,
user_address: participant.address,
});
- const userEntries =
- userEntryRes.data?.results || userEntryRes.data || [];
- if (userEntries.length > 0) {
+ if (apiType === "community") {
+ userRank = userEntryRes.data?.user_rank || null;
+ }
+
+ const userEntries = Array.isArray(userEntryRes.data)
+ ? userEntryRes.data
+ : userEntryRes.data?.results || [];
+ if (!userRank && userEntries.length > 0) {
userRank = userEntries[0].rank;
}
} catch {}
@@ -139,37 +190,44 @@
}
}
- let initializedForAddress = $state(null);
+ let loadedLeaderboardKey: string | null = $state(null);
+ let loadedCommunityRankForAddress: string | null = $state(null);
$effect(() => {
const addr = participant?.address;
- if (!addr || addr === initializedForAddress || !hasAnyRankableRole)
- return;
- initializedForAddress = addr;
+ if (!addr || !hasAnyRankableRole || !activeTab) return;
- if (activeTab) {
+ const key = `${addr}:${activeTab}`;
+ if (key !== loadedLeaderboardKey) {
+ loadedLeaderboardKey = key;
loadTabLeaderboard(activeTab);
}
+ });
- // Fetch community rank only if user is a creator
- if (isCreator) {
- (leaderboardAPI as any)
- .getLeaderboard({
- type: "community",
- limit: 1,
- user_address: addr,
- })
- .then((res: any) => {
- if (res.data?.user_rank) {
- communityRank = res.data.user_rank;
- }
- })
- .catch(() => {});
- }
+ $effect(() => {
+ const addr = participant?.address;
+ if (!addr || !isCreator || addr === loadedCommunityRankForAddress)
+ return;
+
+ loadedCommunityRankForAddress = addr;
+ communityRank = null;
+ (leaderboardAPI as any)
+ .getLeaderboard({
+ type: "community",
+ limit: 1,
+ user_address: addr,
+ })
+ .then((res: any) => {
+ if (res.data?.user_rank) {
+ communityRank = res.data.user_rank;
+ }
+ })
+ .catch(() => {});
});
function switchTab(t: string) {
if (t !== activeTab) {
+ userSelectedTab = true;
activeTab = t;
loadTabLeaderboard(t);
}
@@ -390,7 +448,8 @@
{formatPoints(
- row.points ||
+ row.community_points ||
+ row.points ||
row.total_points ||
0,
)}
diff --git a/frontend/src/components/shared/CTABanner.svelte b/frontend/src/components/shared/CTABanner.svelte
index dfb5b00f..e8159b47 100644
--- a/frontend/src/components/shared/CTABanner.svelte
+++ b/frontend/src/components/shared/CTABanner.svelte
@@ -6,7 +6,7 @@
import { authState } from "../../lib/auth.js";
import CategoryIcon from "../portal/CategoryIcon.svelte";
- let { variant = "dark", participant = null, referralData = null } = $props();
+ let { variant = "dark", participant = null, referralData = null, topRole = null } = $props();
// For light variant without participant, derive auth state from stores
let storeValue = $state({ user: null });
@@ -40,14 +40,37 @@
let isTopRank = $state(false);
let rankLabel: string = $state("");
let rankComputed = $state(false);
+ let rankComputationKey: string | null = $state(null);
+
+ function getRankableEntryType(roleKey: string | null, p: any) {
+ if (!roleKey || !p) return null;
+ if (roleKey === "builder") return p.builder ? "builder" : null;
+ if (roleKey === "validator") {
+ if (p.has_validator_waitlist && !p.validator) return "validator-waitlist";
+ if (p.validator) return "validator";
+ }
+ if (roleKey === "community") return p.creator ? "community" : null;
+ return null;
+ }
- // Determine which entry type to use based on leaderboard-backed role eligibility.
+ // Determine which entry type to use from the same top-role logic used by the profile header.
let eligibleEntryType = $derived.by(() => {
const p = participant;
if (!p) return null;
+
+ const badges = topRole?.badges || [];
+ for (const badge of badges) {
+ const entryType = getRankableEntryType(badge?.key || badge?.category, p);
+ if (entryType) return entryType;
+ }
+
+ const categoryEntryType = getRankableEntryType(topRole?.category, p);
+ if (categoryEntryType) return categoryEntryType;
+
if (p.builder) return "builder";
if (p.has_validator_waitlist && !p.validator) return "validator-waitlist";
if (p.validator) return "validator";
+ if (p.creator) return "community";
return null;
});
@@ -55,40 +78,63 @@
builder: "Builder",
validator: "Validator",
"validator-waitlist": "Validator Waitlist",
+ community: "Community",
};
// Fetch points-to-next-rank once leaderboard entries are available
$effect(() => {
- if (
- participant?.address &&
- participant?.leaderboard_entries?.length > 0 &&
- eligibleEntryType &&
- !rankComputed
- ) {
- computePointsToNextRank();
+ const key = `${participant?.address || ""}:${eligibleEntryType || ""}`;
+ const hasLeaderboardEntries = (participant?.leaderboard_entries?.length || 0) > 0;
+ if (!participant?.address || !eligibleEntryType || key === rankComputationKey) {
+ return;
+ }
+ if (eligibleEntryType !== "community" && !hasLeaderboardEntries) {
+ return;
}
+
+ rankComputationKey = key;
+ pointsToNextRank = null;
+ nextRank = null;
+ isTopRank = false;
+ rankLabel = "";
+ rankComputed = false;
+ computePointsToNextRank(key);
});
- async function computePointsToNextRank() {
- if (rankComputed) return;
+ async function computePointsToNextRank(key: string) {
try {
const entries = participant.leaderboard_entries || [];
- const targetEntry = entries.find(
- (e) => e.type === eligibleEntryType && e.rank && e.rank > 0,
- );
+ let userRank: number | null = null;
+ let userPoints = 0;
+ rankLabel = roleLabelMap[eligibleEntryType] || "Overall";
- if (!targetEntry) {
- rankComputed = true;
- return;
+ if (eligibleEntryType === "community") {
+ const userRankRes = await leaderboardAPI.getLeaderboard({
+ type: "community",
+ user_address: participant.address,
+ limit: 1,
+ });
+ userRank = userRankRes.data?.user_rank || null;
+ userPoints = userRankRes.data?.user_total_points || 0;
+ } else {
+ const targetEntry = entries.find(
+ (e) => e.type === eligibleEntryType && e.rank && e.rank > 0,
+ );
+
+ if (!targetEntry) {
+ return;
+ }
+
+ userRank = targetEntry.rank;
+ userPoints = targetEntry.total_points || 0;
}
- const userRank = targetEntry.rank;
- const userPoints = targetEntry.total_points || 0;
- rankLabel = roleLabelMap[eligibleEntryType] || "Overall";
+ if (!userRank) {
+ return;
+ }
if (userRank <= 1) {
isTopRank = true;
- rankComputed = true;
return;
}
@@ -101,7 +147,7 @@
offset: Math.max(0, userRank - 1 - fetchLimit),
limit: fetchLimit,
});
- const results = res.data?.results || res.data || [];
+ const results = Array.isArray(res.data) ? res.data : res.data?.results || [];
// Walk from the rank closest to the user upward until we find a
// user with strictly more points.
let targetUser = null;
@@ -122,7 +168,9 @@
} catch (err) {
// Silently fail - banner will show without rank info
} finally {
- rankComputed = true;
+ if (rankComputationKey === key) {
+ rankComputed = true;
+ }
}
}
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 544bf96f..6019d92f 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -119,8 +119,12 @@ export const leaderboardAPI = {
return api.get('/leaderboard/', { params: restParams });
},
- getLeaderboardByType: (type, order = 'asc', additionalParams = {}) =>
- api.get('/leaderboard/', { params: { type, order, ...additionalParams } }),
+ getLeaderboardByType: (type, order = 'asc', additionalParams = {}) => {
+ if (type === 'community') {
+ return api.get('/leaderboard/community/', { params: { order, ...additionalParams } });
+ }
+ return api.get('/leaderboard/', { params: { type, order, ...additionalParams } });
+ },
getLeaderboardEntry: (address) => api.get(`/leaderboard/?user_address=${address}`),
getMultipliers: () => api.get('/multipliers/'),
getActiveMultipliers: () => api.get('/multipliers/active/'),
@@ -207,6 +211,16 @@ export const stewardAPI = {
// Get all submissions for review
getSubmissions: (params = {}) => api.get('/steward-submissions/', { params }),
+ // Get community contribution Discord XP states
+ getDiscordXP: (params = {}) => api.get('/steward-discord-xp/', { params }),
+
+ // Record that a steward copied the manual Discord XP command
+ recordDiscordXPCopy: (contributionId) => api.post(`/steward-discord-xp/${contributionId}/record-copy/`),
+
+ // Mark or unset manual Discord XP distribution
+ markDiscordXPDistributed: (contributionId) => api.post(`/steward-discord-xp/${contributionId}/mark-distributed/`),
+ unsetDiscordXPDistributed: (contributionId) => api.post(`/steward-discord-xp/${contributionId}/unset-distributed/`),
+
// Get a single submission
getSubmission: (id) => api.get(`/steward-submissions/${id}/`),
diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js
index 743ded8e..2cd2f73d 100644
--- a/frontend/src/lib/searchParser.js
+++ b/frontend/src/lib/searchParser.js
@@ -23,6 +23,14 @@
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'];
+const NEGATED_MULTI_VALUE_TAGS = {
+ exclude: 'include',
+ include: 'exclude',
+ has: 'no',
+ no: 'has',
+ is: 'not',
+ not: 'is'
+};
/**
* Tokenize the search query, respecting quoted strings.
@@ -130,10 +138,12 @@ export function parseSearch(query) {
}
const tokens = tokenize(query);
+ let negateNext = false;
for (const token of tokens) {
// Handle "NOT tag:value" as two tokens
if (token.toUpperCase() === 'NOT') {
+ negateNext = true;
continue; // Will be handled with next token
}
@@ -141,10 +151,13 @@ export function parseSearch(query) {
if (!parsed) {
// Untagged text — collect as free-text search terms
filters.freeText.push(token);
+ negateNext = false;
continue;
}
- const { tag, value, negated } = parsed;
+ const { tag, value } = parsed;
+ const negated = parsed.negated || negateNext;
+ negateNext = false;
// Handle single-value tags
if (SINGLE_VALUE_TAGS.includes(tag)) {
@@ -152,15 +165,14 @@ export function parseSearch(query) {
}
// Handle multi-value tags
else if (MULTI_VALUE_TAGS.includes(tag)) {
- filters[tag].push(value);
+ const targetTag = negated ? NEGATED_MULTI_VALUE_TAGS[tag] : tag;
+ filters[targetTag].push(value);
}
// Handle numeric tags
else if (NUMERIC_TAGS.includes(tag)) {
const num = parseInt(value, 10);
if (!isNaN(num)) {
- if (tag === 'min-contributions') {
- filters.minContributions = num;
- }
+ filters.minContributions = num;
}
}
}
diff --git a/frontend/src/lib/xpSearchToParams.js b/frontend/src/lib/xpSearchToParams.js
new file mode 100644
index 00000000..4208bd0b
--- /dev/null
+++ b/frontend/src/lib/xpSearchToParams.js
@@ -0,0 +1,68 @@
+/**
+ * Convert parsed steward Discord XP search filters to API query parameters.
+ */
+
+function findContributionType(value, contributionTypes) {
+ const typeValue = String(value || '').toLowerCase();
+ return contributionTypes.find(t =>
+ t.slug?.toLowerCase() === typeValue ||
+ t.name?.toLowerCase() === typeValue ||
+ t.name?.toLowerCase().replace(/\s+/g, '-') === typeValue ||
+ String(t.id) === String(value)
+ );
+}
+
+/**
+ * @param {Object} parsed - Output from parseSearch()
+ * @param {Object} options
+ * @param {Array} options.contributionTypes
+ * @returns {Object}
+ */
+export function xpSearchToParams(parsed, options = {}) {
+ const { contributionTypes = [] } = options;
+ const { filters } = parsed;
+ const params = {};
+
+ if (filters.type) {
+ const type = findContributionType(filters.type.value, contributionTypes);
+ if (type) {
+ if (filters.type.negated) {
+ params.exclude_contribution_type = type.id;
+ } else {
+ params.contribution_type = type.id;
+ }
+ }
+ }
+
+ if (filters.from) {
+ params.username_search = filters.from.value;
+ }
+
+ if (filters.freeText && filters.freeText.length > 0 && !params.username_search) {
+ params.search = filters.freeText.join(' ');
+ }
+
+ if (filters.include && filters.include.length > 0) {
+ params.include_content = filters.include.join(',');
+ }
+
+ if (filters.exclude && filters.exclude.length > 0) {
+ params.exclude_content = filters.exclude.join(',');
+ }
+
+ if (filters.sort) {
+ const sortMap = {
+ created: 'contribution__created_at',
+ '-created': '-contribution__created_at',
+ date: 'contribution__contribution_date',
+ '-date': '-contribution__contribution_date',
+ points: 'contribution__frozen_global_points',
+ '-points': '-contribution__frozen_global_points',
+ distributed: 'distributed_at',
+ '-distributed': '-distributed_at',
+ };
+ params.ordering = sortMap[filters.sort.value] || filters.sort.value;
+ }
+
+ return params;
+}
diff --git a/frontend/src/routes/PoapClaim.svelte b/frontend/src/routes/PoapClaim.svelte
index 8da7dff9..f24a2a61 100644
--- a/frontend/src/routes/PoapClaim.svelte
+++ b/frontend/src/routes/PoapClaim.svelte
@@ -2,8 +2,10 @@
import { onMount } from 'svelte';
import { params, push } from 'svelte-spa-router';
import { authState } from '../lib/auth.js';
+ import { userStore } from '../lib/userStore.js';
import { poapsAPI } from '../lib/api.js';
import { showError, showSuccess } from '../lib/toastStore.js';
+ import SocialLink from '../components/SocialLink.svelte';
import PoapBadgeImage from '../components/poaps/PoapBadgeImage.svelte';
let status = $state('idle');
@@ -14,6 +16,10 @@
let routeToken = $derived($params?.token || '');
let token = $derived(routeToken || tokenFromUrl());
+ let requiresDiscordLink = $derived(
+ status === 'error' && message.toLowerCase().includes('discord')
+ );
+ let discordConnection = $derived($userStore.user?.discord_connection || null);
function tokenFromUrl() {
if (typeof window === 'undefined') return '';
@@ -27,9 +33,16 @@
}
}
+ /** @param {any} err */
+ function isDiscordLinkError(err) {
+ return err?.response?.status === 403
+ && String(err?.response?.data?.error || '').toLowerCase().includes('discord');
+ }
+
/** @param {any} err */
function isAuthError(err) {
const statusCode = err?.response?.status;
+ if (isDiscordLinkError(err)) return false;
return statusCode === 401 || statusCode === 403;
}
@@ -88,6 +101,18 @@
}
}
+ /** @param {any} updatedUser */
+ function handleDiscordLinked(updatedUser) {
+ if (updatedUser) userStore.setUser(updatedUser);
+ if (!token) return;
+ attempted = false;
+ status = 'claiming';
+ message = 'Claiming your POAP...';
+ window.setTimeout(() => {
+ claim();
+ }, 0);
+ }
+
onMount(() => {
if (!$authState.isAuthenticated) {
status = 'auth';
@@ -104,8 +129,15 @@
}
});
+ $effect(() => {
+ if ($authState.isAuthenticated && !$userStore.user && !$userStore.loading) {
+ userStore.loadUser().catch(() => {});
+ }
+ });
+
function heading() {
if (status === 'claimed') return 'POAP claimed';
+ if (requiresDiscordLink) return 'Link Discord';
if (status === 'error') return 'Mint link not available';
if (status === 'auth') return 'Connect wallet';
return 'Claim POAP';
@@ -113,6 +145,7 @@
function statusText() {
if (status === 'claimed') return 'Claimed';
+ if (requiresDiscordLink) return 'Discord required';
if (status === 'error') return 'Unavailable';
if (status === 'claiming') return 'Claiming';
if (status === 'auth') return 'Wallet required';
@@ -140,6 +173,16 @@
{:else if status === 'claimed' && drop?.slug}
+ {:else if requiresDiscordLink}
+
+
+
{:else if status === 'error'}
{/if}
@@ -249,6 +292,10 @@
margin-top: 22px;
}
+ .poap-discord-gate {
+ width: min(100%, 260px);
+ }
+
.poap-primary-action,
.poap-secondary-action {
border-radius: 12px;
diff --git a/frontend/src/routes/PoapDetail.svelte b/frontend/src/routes/PoapDetail.svelte
index 3781ba53..4908ac18 100644
--- a/frontend/src/routes/PoapDetail.svelte
+++ b/frontend/src/routes/PoapDetail.svelte
@@ -2,8 +2,10 @@
import { params, push } from 'svelte-spa-router';
import { format } from 'date-fns';
import { authState } from '../lib/auth.js';
+ import { userStore } from '../lib/userStore.js';
import { poapsAPI } from '../lib/api.js';
import { showError, showSuccess } from '../lib/toastStore.js';
+ import SocialLink from '../components/SocialLink.svelte';
import PoapBadgeImage from '../components/poaps/PoapBadgeImage.svelte';
import { getCategoryGradientStyle } from '../lib/categoryPresentation.js';
@@ -28,6 +30,8 @@
let activeClaimDistributions = $derived((poap?.distributions || []).filter(distributionIsOpen));
let hasSecretClaim = $derived(canClaim && activeClaimDistributions.some((distribution) => distribution.method === 'secret'));
let hasMintLinkClaim = $derived(canClaim && activeClaimDistributions.some((distribution) => distribution.method === 'mint_link'));
+ let discordConnection = $derived($userStore.user?.discord_connection || null);
+ let hasDiscordConnection = $derived(Boolean(discordConnection));
let collectorCount = $derived(poap?.claimed_count ?? claimsCount ?? 0);
let statusLabel = $derived(poap?.status === 'active' ? 'Live' : poap?.status === 'draft' ? 'Draft' : 'Archived');
let statusClass = $derived(
@@ -69,9 +73,16 @@
return true;
}
+ /** @param {any} err */
+ function isDiscordLinkError(err) {
+ return err?.response?.status === 403
+ && String(err?.response?.data?.error || '').toLowerCase().includes('discord');
+ }
+
/** @param {any} err */
function isAuthError(err) {
const statusCode = err?.response?.status;
+ if (isDiscordLinkError(err)) return false;
return statusCode === 401 || statusCode === 403;
}
@@ -83,6 +94,11 @@
}, 0);
}
+ /** @param {any} updatedUser */
+ function handleDiscordLinked(updatedUser) {
+ if (updatedUser) userStore.setUser(updatedUser);
+ }
+
async function loadPoap() {
if (!slug) return;
loading = true;
@@ -155,6 +171,12 @@
loadClaims(1);
}
});
+
+ $effect(() => {
+ if ($authState.isAuthenticated && !$userStore.user && !$userStore.loading) {
+ userStore.loadUser().catch(() => {});
+ }
+ });
@@ -213,7 +235,17 @@
{/if}
- {#if hasSecretClaim}
+ {#if hasSecretClaim && !hasDiscordConnection}
+
+
+
+ {:else if hasSecretClaim}
-
+
{/if}
{:else}
diff --git a/frontend/src/routes/ProjectPageEditor.svelte b/frontend/src/routes/ProjectPageEditor.svelte
index 388ed27d..157dcb50 100644
--- a/frontend/src/routes/ProjectPageEditor.svelte
+++ b/frontend/src/routes/ProjectPageEditor.svelte
@@ -120,10 +120,16 @@
error = null;
notice = null;
const projectResponse = await projectsAPI.get(slug);
- project = projectResponse.data;
+ const loadedProject = projectResponse.data;
+ if (!loadedProject?.can_edit) {
+ project = null;
+ error = 'You do not have permission to edit this project.';
+ return;
+ }
+ project = loadedProject;
selectedParticipants = getProjectParticipantsForEditing(project);
selectedContributions = [...(project.related_contributions || [])];
- hydrateForm(projectResponse.data);
+ hydrateForm(loadedProject);
} catch (err) {
const requestError = /** @type {{ response?: { data?: { detail?: string } }, message?: string }} */ (err);
error = requestError.response?.data?.detail || requestError.message || 'Failed to load project editor';
diff --git a/frontend/src/routes/StewardDiscordXP.svelte b/frontend/src/routes/StewardDiscordXP.svelte
new file mode 100644
index 00000000..f5217c7c
--- /dev/null
+++ b/frontend/src/routes/StewardDiscordXP.svelte
@@ -0,0 +1,446 @@
+
+
+
+
+
+
+
Discord XP
+
{formatNumber(totalCount)} community contribution{totalCount === 1 ? '' : 's'}
+
+
+ {#each FILTERS as filter}
+
+ {/each}
+
+
+
+
+
+
+
+ {#if loading}
+
+ {:else if error}
+
+ {error}
+
+ {:else if rows.length === 0}
+
+ No Discord XP rows found.
+
+ {:else}
+ {#if totalCount > pageSize}
+
+ {/if}
+
+
+
+
+
+
+ | Contributor |
+ Contribution |
+ Community Points |
+ Status |
+ Actions |
+
+
+
+ {#each rows as row (row.id)}
+
+ |
+ {displayName(row)}
+ {discordName(row)}
+ |
+
+ {contributionTitle(row)}
+
+ {row.contribution_type?.name || 'Community'}
+ /
+ {formatDate(row.contribution_date)}
+
+ {#if row.last_copied_at}
+ Copied {formatDate(row.last_copied_at)}
+ {/if}
+ |
+ {formatNumber(frozenPoints(row))} |
+
+
+ {statusLabel(row.status)}
+
+ |
+
+
+ {#if row.status === 'distributed' || row.status === 'needs_review'}
+
+ {:else}
+
+
+ {/if}
+
+ |
+
+ {/each}
+
+
+
+
+
+ {#if totalCount > pageSize}
+
+ {/if}
+ {/if}
+
+
diff --git a/frontend/src/tests/ProjectPageEditor.test.js b/frontend/src/tests/ProjectPageEditor.test.js
index 9e31cba4..cc9f41f9 100644
--- a/frontend/src/tests/ProjectPageEditor.test.js
+++ b/frontend/src/tests/ProjectPageEditor.test.js
@@ -73,6 +73,18 @@ describe('ProjectPageEditor', () => {
expect(screen.queryByLabelText('Callout text')).toBeNull();
});
+ it('does not render the editor when the project is not editable by the user', async () => {
+ getProjectMock.mockResolvedValueOnce({ data: { ...project, can_edit: false } });
+
+ renderWithEffects(ProjectPageEditor, { props: { params: { slug: 'buildersclaw' } } });
+
+ await waitForApiCall(projectsAPI.get, 'buildersclaw');
+
+ expect(screen.getByText('Project editor unavailable')).toBeDefined();
+ expect(screen.getByText('You do not have permission to edit this project.')).toBeDefined();
+ expect(screen.queryByRole('button', { name: /save changes/i })).toBeNull();
+ });
+
it('saves profile fields directly', async () => {
renderWithEffects(ProjectPageEditor, { props: { params: { slug: 'buildersclaw' } } });
diff --git a/frontend/src/tests/searchParser.test.js b/frontend/src/tests/searchParser.test.js
new file mode 100644
index 00000000..40152ed9
--- /dev/null
+++ b/frontend/src/tests/searchParser.test.js
@@ -0,0 +1,49 @@
+import { describe, expect, it } from "vitest";
+import { parseSearch } from "../lib/searchParser.js";
+import { searchToParams } from "../lib/searchToParams.js";
+
+function paramsFor(query) {
+ return searchToParams(parseSearch(query));
+}
+
+describe("steward search negation", () => {
+ it("normalizes negated presence filters to absence filters", () => {
+ const { filters } = parseSearch("-has:proposal -has:url -has:appeal");
+
+ expect(filters.has).toEqual([]);
+ expect(filters.no).toEqual(["proposal", "url", "appeal"]);
+ expect(paramsFor("-has:proposal")).toEqual({ has_proposal: false });
+ expect(paramsFor("-has:url")).toEqual({ only_empty_evidence: true });
+ expect(paramsFor("-has:appeal")).toEqual({ has_appeal: false });
+ });
+
+ it("normalizes negated flag filters to not filters", () => {
+ const { filters } = parseSearch("-is:interesting -is:resubmitted");
+
+ expect(filters.is).toEqual([]);
+ expect(filters.not).toEqual(["interesting", "resubmitted"]);
+ expect(paramsFor("-is:interesting")).toEqual({ is_interesting: false });
+ expect(paramsFor("-is:resubmitted")).toEqual({ resubmitted_more_info: false });
+ });
+
+ it("supports NOT before multi-value filters", () => {
+ expect(paramsFor("NOT has:proposal")).toEqual({ has_proposal: false });
+ expect(paramsFor("NOT is:interesting")).toEqual({ is_interesting: false });
+ });
+
+ it("inverts explicit negative aliases when prefixed with a dash", () => {
+ expect(paramsFor("-no:proposal")).toEqual({ has_proposal: true });
+ expect(paramsFor("-not:interesting")).toEqual({ is_interesting: true });
+ });
+
+ it("normalizes negated include and exclude text filters", () => {
+ const { filters } = parseSearch("-include:spam -exclude:genlayer");
+
+ expect(filters.include).toEqual(["genlayer"]);
+ expect(filters.exclude).toEqual(["spam"]);
+ expect(paramsFor("-include:spam -exclude:genlayer")).toEqual({
+ exclude_content: "spam",
+ include_content: "genlayer",
+ });
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 21d3e9e0..08f557ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "karachi",
+ "name": "denver",
"lockfileVersion": 3,
"requires": true,
"packages": {