From 695beb64b922f007b752867202908466915a1515 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 16:50:36 +0200 Subject: [PATCH 01/10] Add community Discord XP tracking --- backend/api/urls.py | 3 +- backend/contributions/admin.py | 167 ++++++- .../migrations/0061_community_discord_xp.py | 91 ++++ backend/contributions/models.py | 185 +++++++- backend/contributions/serializers.py | 88 +++- .../contributions/tests/test_discord_xp.py | 281 +++++++++++ backend/contributions/views.py | 372 ++++++++++++++- frontend/src/App.svelte | 2 + frontend/src/components/Sidebar.svelte | 64 +++ .../src/components/StewardXPSearchBar.svelte | 388 +++++++++++++++ frontend/src/lib/api.js | 10 + frontend/src/lib/searchParser.js | 4 +- frontend/src/lib/xpSearchToParams.js | 68 +++ frontend/src/routes/StewardDiscordXP.svelte | 446 ++++++++++++++++++ 14 files changed, 2159 insertions(+), 10 deletions(-) create mode 100644 backend/contributions/migrations/0061_community_discord_xp.py create mode 100644 backend/contributions/tests/test_discord_xp.py create mode 100644 frontend/src/components/StewardXPSearchBar.svelte create mode 100644 frontend/src/lib/xpSearchToParams.js create mode 100644 frontend/src/routes/StewardDiscordXP.svelte 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/contributions/admin.py b/backend/contributions/admin.py index 94c5e752..ea31db78 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -21,7 +21,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 @@ -74,6 +90,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') @@ -482,6 +511,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/models.py b/backend/contributions/models.py index 00847820..b1135910 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 @@ -300,6 +300,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 0e533a51..51e2d2bd 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 ad0b3df8..34a15088 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', @@ -1259,6 +1267,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/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} +
+
XP Search
+
+
+
type:amaCommunity contribution type
+
from:aliceName, email, wallet, or Discord username
+
include:genlayerTitle, notes, type, or evidence contains text
+
exclude:duplicateHide matching title, notes, type, or evidence
+
sort:-pointscreated, date, points, distributed, or their negative form
+
+
+
Examples
+
from:alice include:thread sort:-date
+
type:community-call sort:-points
+
include:thread exclude:duplicate
+
+
Untagged text searches contributor, Discord, title, notes, type, and evidence.
+
+
+ {/if} +
+ + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 99ce983e..6815faa1 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -207,6 +207,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..81424fc9 100644 --- a/frontend/src/lib/searchParser.js +++ b/frontend/src/lib/searchParser.js @@ -158,9 +158,7 @@ export function parseSearch(query) { 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/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} + +
+
+ + + + + + + + + + + + {#each rows as row (row.id)} + + + + + + + + {/each} + +
ContributorContributionCommunity PointsStatusActions
+
{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} +
+
+
+
+ + {#if totalCount > pageSize} + + {/if} + {/if} +
+
From b22f0eb70b2766c1573efde822766be608da6ae7 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 17:01:25 +0200 Subject: [PATCH 02/10] Restrict project edit access --- backend/projects/models.py | 12 +++ backend/projects/serializers.py | 4 +- backend/projects/tests/test_projects.py | 80 ++++++++++++++++++++ backend/projects/views.py | 7 +- frontend/src/routes/ProjectPageEditor.svelte | 10 ++- frontend/src/tests/ProjectPageEditor.test.js | 12 +++ 6 files changed, 115 insertions(+), 10 deletions(-) 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/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/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' } } }); From 25b1b58041fc7d689986f631f062ee6fe040629e Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 17:03:15 +0200 Subject: [PATCH 03/10] Require Discord link for POAP claims --- backend/poaps/services.py | 14 ++++++++ backend/poaps/tests/test_poaps.py | 48 +++++++++++++++++++++++++++ frontend/src/routes/PoapClaim.svelte | 47 ++++++++++++++++++++++++++ frontend/src/routes/PoapDetail.svelte | 34 ++++++++++++++++++- 4 files changed, 142 insertions(+), 1 deletion(-) 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/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}
Date: Mon, 25 May 2026 17:10:40 +0200 Subject: [PATCH 04/10] Merge contribution migration heads --- ..._discord_xp_0061_featuredcontent_hero_placements.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 backend/contributions/migrations/0062_merge_0061_community_discord_xp_0061_featuredcontent_hero_placements.py 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 = [] From f8338f3b969f55dc4fdd45147b32e5a156264740 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 26 May 2026 12:25:06 +0200 Subject: [PATCH 05/10] Fix steward search negation parsing (#693) --- frontend/src/lib/searchParser.js | 18 ++++++++- frontend/src/tests/searchParser.test.js | 49 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 frontend/src/tests/searchParser.test.js diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js index 81424fc9..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,7 +165,8 @@ 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)) { 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", + }); + }); +}); From 8417df14b22a2b3b0e834575680e7e477e6b0632 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 26 May 2026 13:29:45 +0200 Subject: [PATCH 06/10] Add MEE6 Discord XP sync --- backend/.env.example | 2 +- backend/community_xp/__init__.py | 1 + backend/community_xp/admin.py | 114 ++++ backend/community_xp/apps.py | 10 + backend/community_xp/constants.py | 8 + backend/community_xp/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../management/commands/sync_mee6_xp.py | 38 ++ .../community_xp/migrations/0001_initial.py | 144 +++++ backend/community_xp/migrations/__init__.py | 1 + backend/community_xp/models.py | 148 +++++ backend/community_xp/services.py | 582 ++++++++++++++++++ backend/community_xp/signals.py | 36 ++ backend/community_xp/tests/__init__.py | 1 + backend/community_xp/tests/test_mee6_sync.py | 453 ++++++++++++++ backend/community_xp/utils.py | 204 ++++++ backend/leaderboard/tests/test_stats.py | 90 +++ backend/leaderboard/views.py | 176 ++++-- backend/social_connections/serializers.py | 39 ++ backend/tally/settings.py | 1 + frontend/src/components/SocialLink.svelte | 71 ++- .../components/profile/ProfileHeader.svelte | 74 ++- 22 files changed, 2127 insertions(+), 68 deletions(-) create mode 100644 backend/community_xp/__init__.py create mode 100644 backend/community_xp/admin.py create mode 100644 backend/community_xp/apps.py create mode 100644 backend/community_xp/constants.py create mode 100644 backend/community_xp/management/__init__.py create mode 100644 backend/community_xp/management/commands/__init__.py create mode 100644 backend/community_xp/management/commands/sync_mee6_xp.py create mode 100644 backend/community_xp/migrations/0001_initial.py create mode 100644 backend/community_xp/migrations/__init__.py create mode 100644 backend/community_xp/models.py create mode 100644 backend/community_xp/services.py create mode 100644 backend/community_xp/signals.py create mode 100644 backend/community_xp/tests/__init__.py create mode 100644 backend/community_xp/tests/test_mee6_sync.py create mode 100644 backend/community_xp/utils.py 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/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..a22f950a --- /dev/null +++ b/backend/community_xp/admin.py @@ -0,0 +1,114 @@ +from django.contrib import admin +from django.contrib import messages + +from .models import ( + Mee6CurrentXP, + Mee6PlayerSnapshot, + Mee6SyncLock, + Mee6SyncRun, +) +from .services import Mee6SyncError, apply_sync_run + + +@admin.register(Mee6SyncRun) +class Mee6SyncRunAdmin(admin.ModelAdmin): + actions = ('apply_as_active_baseline',) + 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', + ) + + @admin.action(description='Apply selected MEE6 run as active community XP baseline') + def apply_as_active_baseline(self, request, queryset): + 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', + 'matched_user', + ) + list_filter = ('guild_id', 'run__status') + search_fields = ( + 'discord_id', + 'username', + 'matched_user__email', + 'matched_user__address', + ) + 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..d6959322 --- /dev/null +++ b/backend/community_xp/migrations/0001_initial.py @@ -0,0 +1,144 @@ +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)), + ('matched_at', models.DateTimeField(blank=True, null=True)), + ('matched_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mee6_player_snapshots', to=settings.AUTH_USER_MODEL)), + ('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='mee6playersnapshot', + index=models.Index(fields=['matched_user'], name='community_x_matched_5af9af_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..6bdd3016 --- /dev/null +++ b/backend/community_xp/models.py @@ -0,0 +1,148 @@ +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) + matched_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='mee6_player_snapshots', + ) + matched_at = models.DateTimeField(null=True, 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']), + models.Index(fields=['matched_user']), + ] + + 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..c35fb85f --- /dev/null +++ b/backend/community_xp/services.py @@ -0,0 +1,582 @@ +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 = set() + snapshots = [] + + for player in fetch_result.players: + connection = connections_by_discord_id.get(player.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) + + 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, + matched_user=matched_user, + matched_at=matched_at, + )) + + 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() + 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) + + 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(matched_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: + 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/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..14545f70 --- /dev/null +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -0,0 +1,453 @@ +from datetime import timedelta + +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient +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', + ) + + 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_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_distributed_points_stop_counting_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'], 100) + self.assertEqual(distributed_not_yet_synced['pending_portal_points'], 0) + + 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'], 100) + + 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.assertIsNone(snapshot.matched_user) + 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_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..f7616c94 --- /dev/null +++ b/backend/community_xp/utils.py @@ -0,0 +1,204 @@ +from django.db.models import Count, F, IntegerField, Sum, Value +from django.db.models.functions import Greatest + +from contributions.models import Contribution, ContributionDiscordXPState +from social_connections.models import DiscordConnection + +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): + pending_expr = Greatest( + F('contribution__frozen_global_points') - F('awarded_amount'), + Value(0), + 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(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 {} + + connections = ( + DiscordConnection.objects + .filter(user_id__in=users_by_id.keys()) + .exclude(platform_user_id='') + .select_related('user') + ) + user_by_discord_id = { + str(connection.platform_user_id): connection.user_id + for connection in connections + } + if not user_by_discord_id: + return {} + + current_rows = Mee6CurrentXP.objects.filter( + guild_id=guild_id, + discord_id__in=user_by_discord_id.keys(), + ) + + result = {} + for current in current_rows: + user_id = user_by_discord_id.get(str(current.discord_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', + ) + } + + latest_sync = get_latest_applied_sync(guild_id) + all_time = _aggregate_community_points(user_ids=users_by_id.keys()) + pending_portal_points_by_user = _aggregate_pending_portal_points(user_ids=users_by_id.keys()) + 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_sync_completed_at': latest_sync.completed_at if latest_sync else 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_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_sync_completed_at': None, + 'latest_applied_sync_completed_at': None, + 'latest_applied_at': None, + 'community_contribution_count': 0, + }) diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 02e7663d..575a6e90 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -9,9 +9,11 @@ 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 @@ -45,6 +47,27 @@ 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.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 +77,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( @@ -182,6 +239,39 @@ 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_mission_backed_non_submittable_community_contribution_is_reflected(self): contributor = self._create_user( 'mission-community@example.com', diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 41c1a1af..1a6fd0f5 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -271,14 +271,31 @@ 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 leaderboard_type = request.query_params.get('type') now = timezone.now() last_month = now - timezone.timedelta(days=30) + effective_community_summary = None + + def get_effective_community_summary(): + nonlocal effective_community_summary + if effective_community_summary is None: + from community_xp.utils import build_effective_community_scores + + entries = [ + score + for score in build_effective_community_scores(visible_only=True).values() + if (score['total_points'] or 0) > 0 + ] + effective_community_summary = { + 'member_count': len(entries), + 'total_points': sum(score['total_points'] or 0 for score in entries), + 'user_ids': {score['user'].id for score in entries}, + } + return effective_community_summary if leaderboard_type: # Category-specific stats @@ -307,15 +324,21 @@ 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'] + 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 @@ -346,16 +369,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['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 @@ -396,7 +433,8 @@ def stats(self, request): ).exclude( contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS ) - community_member_count = community_contribs.values('user_id').distinct().count() + community_summary = get_effective_community_summary() + community_member_count = community_summary['member_count'] new_community_members_count = community_contribs.filter( created_at__gte=last_month ).values('user_id').distinct().count() @@ -431,10 +469,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 +501,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 +513,26 @@ 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_sync_completed_at': community_xp_breakdown['latest_sync_completed_at'], + '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 +662,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 +679,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 +731,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/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/components/SocialLink.svelte b/frontend/src/components/SocialLink.svelte index e9643e2b..5db9b2e6 100644 --- a/frontend/src/components/SocialLink.svelte +++ b/frontend/src/components/SocialLink.svelte @@ -228,10 +228,18 @@ if (platform !== 'discord') return []; return [...(connection?.roles || [])].sort((a, b) => (b.position || 0) - (a.position || 0) || String(a.name).localeCompare(String(b.name))); }); + let hasDiscordRank = $derived(platform === 'discord' && connection?.mee6_rank !== null && connection?.mee6_rank !== undefined); + let hasDiscordLevel = $derived(platform === 'discord' && connection?.mee6_level !== null && connection?.mee6_level !== undefined); + let hasDiscordLeaderboardStats = $derived(hasDiscordRank || hasDiscordLevel); let compactTitle = $derived.by(() => { if (platform !== 'discord' || !connection) return undefined; - if (discordRoles.length === 0) return 'Discord roles not synced yet'; - return `Discord roles: ${discordRoles.map((role) => role.name).join(', ')}`; + const stats = []; + if (hasDiscordLevel) stats.push(`level ${formatNumber(connection.mee6_level)}`); + if (hasDiscordRank) stats.push(`rank #${formatNumber(connection.mee6_rank)}`); + const roles = discordRoles.length > 0 + ? `Discord roles: ${discordRoles.map((role) => role.name).join(', ')}` + : 'Discord roles not synced yet'; + return stats.length ? `Discord ${stats.join(', ')}. ${roles}` : roles; }); function getDiscordRoleColor(role) { @@ -241,6 +249,12 @@ return '#b5bac1'; } + function formatNumber(value) { + const number = Number(value); + if (!Number.isFinite(number)) return value; + return new Intl.NumberFormat().format(number); + } + const refreshHandlers = { github: socialAPI.refreshGitHubUsername, discord: socialAPI.refreshDiscordUsername, @@ -358,7 +372,8 @@ {:else} - @@ -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; From 6d21d44f7cdc69d7ba16f1bc0b8ac53656080839 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 26 May 2026 14:57:43 +0200 Subject: [PATCH 07/10] Trim MEE6 XP sync internals --- backend/community_xp/admin.py | 3 --- .../community_xp/migrations/0001_initial.py | 6 ------ backend/community_xp/models.py | 9 --------- backend/community_xp/services.py | 13 ++++-------- backend/community_xp/tests/test_mee6_sync.py | 2 +- backend/community_xp/utils.py | 20 ++----------------- backend/leaderboard/views.py | 1 - 7 files changed, 7 insertions(+), 47 deletions(-) diff --git a/backend/community_xp/admin.py b/backend/community_xp/admin.py index a22f950a..50d3dd8b 100644 --- a/backend/community_xp/admin.py +++ b/backend/community_xp/admin.py @@ -75,14 +75,11 @@ class Mee6PlayerSnapshotAdmin(admin.ModelAdmin): 'discord_id', 'xp', 'level', - 'matched_user', ) list_filter = ('guild_id', 'run__status') search_fields = ( 'discord_id', 'username', - 'matched_user__email', - 'matched_user__address', ) readonly_fields = ('created_at', 'updated_at') diff --git a/backend/community_xp/migrations/0001_initial.py b/backend/community_xp/migrations/0001_initial.py index d6959322..0a4a407f 100644 --- a/backend/community_xp/migrations/0001_initial.py +++ b/backend/community_xp/migrations/0001_initial.py @@ -69,8 +69,6 @@ class Migration(migrations.Migration): ('message_count', models.PositiveIntegerField(default=0)), ('detailed_xp', models.JSONField(blank=True, default=list)), ('raw_player', models.JSONField(blank=True, default=dict)), - ('matched_at', models.DateTimeField(blank=True, null=True)), - ('matched_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mee6_player_snapshots', to=settings.AUTH_USER_MODEL)), ('run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_snapshots', to='community_xp.mee6syncrun')), ], options={ @@ -121,10 +119,6 @@ class Migration(migrations.Migration): model_name='mee6playersnapshot', index=models.Index(fields=['run', 'rank'], name='community_x_run_id_4608f5_idx'), ), - migrations.AddIndex( - model_name='mee6playersnapshot', - index=models.Index(fields=['matched_user'], name='community_x_matched_5af9af_idx'), - ), migrations.AddIndex( model_name='mee6currentxp', index=models.Index(fields=['guild_id', 'rank'], name='community_x_guild_i_c99401_idx'), diff --git a/backend/community_xp/models.py b/backend/community_xp/models.py index 6bdd3016..a70f7692 100644 --- a/backend/community_xp/models.py +++ b/backend/community_xp/models.py @@ -65,14 +65,6 @@ class Mee6PlayerSnapshot(BaseModel): message_count = models.PositiveIntegerField(default=0) detailed_xp = models.JSONField(default=list, blank=True) raw_player = models.JSONField(default=dict, blank=True) - matched_user = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='mee6_player_snapshots', - ) - matched_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['run', 'rank'] @@ -80,7 +72,6 @@ class Meta: indexes = [ models.Index(fields=['guild_id', 'discord_id']), models.Index(fields=['run', 'rank']), - models.Index(fields=['matched_user']), ] def __str__(self): diff --git a/backend/community_xp/services.py b/backend/community_xp/services.py index c35fb85f..5f8d5269 100644 --- a/backend/community_xp/services.py +++ b/backend/community_xp/services.py @@ -321,16 +321,13 @@ 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 = set() + matched_user_ids = { + connection.user_id + for connection in connections_by_discord_id.values() + } snapshots = [] for player in fetch_result.players: - connection = connections_by_discord_id.get(player.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) - snapshots.append(Mee6PlayerSnapshot( run=run, guild_id=fetch_result.guild_id, @@ -344,8 +341,6 @@ def store_fetch_result(run, fetch_result): message_count=player.message_count, detailed_xp=player.detailed_xp, raw_player=player.raw_player, - matched_user=matched_user, - matched_at=matched_at, )) with transaction.atomic(): diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py index 14545f70..06aa9200 100644 --- a/backend/community_xp/tests/test_mee6_sync.py +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -270,7 +270,7 @@ 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.assertIsNone(snapshot.matched_user) + self.assertEqual(snapshot.xp, 77) self.assertFalse(Mee6CurrentXP.objects.filter(discord_id='unmatched-discord').exists()) apply_sync_run(run) diff --git a/backend/community_xp/utils.py b/backend/community_xp/utils.py index f7616c94..6cee1a90 100644 --- a/backend/community_xp/utils.py +++ b/backend/community_xp/utils.py @@ -2,7 +2,6 @@ from django.db.models.functions import Greatest from contributions.models import Contribution, ContributionDiscordXPState -from social_connections.models import DiscordConnection from .constants import COMMUNITY_XP_EXCLUDED_TYPE_SLUGS from .models import Mee6CurrentXP, Mee6SyncRun @@ -86,27 +85,14 @@ def _current_xp_by_user(users_by_id, guild_id): if not users_by_id: return {} - connections = ( - DiscordConnection.objects - .filter(user_id__in=users_by_id.keys()) - .exclude(platform_user_id='') - .select_related('user') - ) - user_by_discord_id = { - str(connection.platform_user_id): connection.user_id - for connection in connections - } - if not user_by_discord_id: - return {} - current_rows = Mee6CurrentXP.objects.filter( guild_id=guild_id, - discord_id__in=user_by_discord_id.keys(), + matched_user_id__in=users_by_id.keys(), ) result = {} for current in current_rows: - user_id = user_by_discord_id.get(str(current.discord_id)) + user_id = current.matched_user_id if not user_id: continue existing = result.get(user_id) @@ -174,7 +160,6 @@ def build_effective_community_scores(user_ids=None, guild_id=None, visible_only= 'tracked_portal_points_all_time': all_time_points, 'total_points': total_points, 'has_discord_xp_snapshot': current_xp is not None, - 'latest_sync_completed_at': latest_sync.completed_at if latest_sync else 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, @@ -197,7 +182,6 @@ def get_effective_community_points(user, guild_id=None): 'tracked_portal_points_all_time': 0, 'total_points': 0, 'has_discord_xp_snapshot': False, - 'latest_sync_completed_at': None, 'latest_applied_sync_completed_at': None, 'latest_applied_at': None, 'community_contribution_count': 0, diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 1a6fd0f5..ac9607f6 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -527,7 +527,6 @@ def _get_user_stats(self, user, category=None): '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_sync_completed_at': community_xp_breakdown['latest_sync_completed_at'], 'latest_applied_sync_completed_at': community_xp_breakdown['latest_applied_sync_completed_at'], 'latest_applied_at': community_xp_breakdown['latest_applied_at'], }) From c722b5aacbbef0e10d7e471fc436ab78a45575f1 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 26 May 2026 18:27:17 +0200 Subject: [PATCH 08/10] Add admin MEE6 XP sync flow --- backend/community_xp/admin.py | 81 +++++++++++++- .../community_xp/mee6syncrun/change_list.html | 13 +++ backend/community_xp/tests/test_mee6_sync.py | 102 +++++++++++++++++- backend/community_xp/utils.py | 31 +++++- package-lock.json | 2 +- 5 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 backend/community_xp/templates/admin/community_xp/mee6syncrun/change_list.html diff --git a/backend/community_xp/admin.py b/backend/community_xp/admin.py index 50d3dd8b..09cc4e00 100644 --- a/backend/community_xp/admin.py +++ b/backend/community_xp/admin.py @@ -1,5 +1,8 @@ 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, @@ -7,12 +10,13 @@ Mee6SyncLock, Mee6SyncRun, ) -from .services import Mee6SyncError, apply_sync_run +from .services import Mee6SyncAlreadyRunning, Mee6SyncError, apply_sync_run, run_mee6_sync @admin.register(Mee6SyncRun) class Mee6SyncRunAdmin(admin.ModelAdmin): - actions = ('apply_as_active_baseline',) + actions = ('fetch_new_snapshot', 'apply_as_active_baseline') + change_list_template = 'admin/community_xp/mee6syncrun/change_list.html' list_display = ( 'id', 'guild_id', @@ -37,8 +41,81 @@ class Mee6SyncRunAdmin(admin.ModelAdmin): '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, 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 %} +
  • +
    + {% csrf_token %} + +
    +
  • + {% endif %} + {{ block.super }} +{% endblock %} diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py index 06aa9200..a0a635e1 100644 --- a/backend/community_xp/tests/test_mee6_sync.py +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -1,8 +1,12 @@ from datetime import timedelta +from unittest.mock import Mock, patch -from django.test import TestCase, override_settings +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, @@ -112,6 +116,7 @@ def setUp(self): address='0x0000000000000000000000000000000000000001', name='User One', ) + self.request_factory = RequestFactory() def add_community_contribution(self, user, points): return Contribution.objects.create( @@ -160,6 +165,93 @@ def fetch_and_apply_mee6_run(self, players): 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) @@ -235,7 +327,7 @@ def test_sync_preserves_contributions_and_counts_mee6_plus_pending_portal_points self.assertEqual(breakdown['total_points'], 150) self.assertTrue(Creator.objects.filter(user=self.user).exists()) - def test_distributed_points_stop_counting_until_next_mee6_baseline(self): + 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) @@ -249,13 +341,13 @@ def test_distributed_points_stop_counting_until_next_mee6_baseline(self): 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'], 100) - self.assertEqual(distributed_not_yet_synced['pending_portal_points'], 0) + 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'], 100) + self.assertEqual(fetched_but_not_applied['total_points'], 110) apply_sync_run(second_run) second_breakdown = get_effective_community_points(self.user) diff --git a/backend/community_xp/utils.py b/backend/community_xp/utils.py index 6cee1a90..4521ec17 100644 --- a/backend/community_xp/utils.py +++ b/backend/community_xp/utils.py @@ -1,4 +1,4 @@ -from django.db.models import Count, F, IntegerField, Sum, Value +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 @@ -57,17 +57,35 @@ def _discord_xp_states(user_ids=None): return queryset -def _aggregate_pending_portal_points(user_ids=None): +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(pending_expr)) + .annotate(pending_total=Sum(effective_pending_expr)) } @@ -122,9 +140,12 @@ def build_effective_community_scores(user_ids=None, guild_id=None, visible_only= ) } - latest_sync = get_latest_applied_sync(guild_id) all_time = _aggregate_community_points(user_ids=users_by_id.keys()) - pending_portal_points_by_user = _aggregate_pending_portal_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(): diff --git a/package-lock.json b/package-lock.json index 21d3e9e0..46a540be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "karachi", + "name": "cairo", "lockfileVersion": 3, "requires": true, "packages": { From d0101aa594a1eb8a81c656ebbef3b3ca49472f77 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Tue, 26 May 2026 21:14:24 +0200 Subject: [PATCH 09/10] Fix validator overview metric (#696) --- backend/leaderboard/tests/test_stats.py | 70 +++++++++++++++++++++++++ backend/leaderboard/views.py | 26 +++++---- package-lock.json | 2 +- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 575a6e90..951bb1e7 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -15,6 +15,7 @@ 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): @@ -38,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={ @@ -61,6 +71,13 @@ def setUp(self): '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={ @@ -195,6 +212,59 @@ 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_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_poap_claim_grants_role_but_does_not_count_as_member_metric(self): poap_user = self._create_user( 'poap@example.com', diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index ac9607f6..83544ce5 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -273,6 +273,7 @@ def stats(self, request): """ from django.db.models import Sum from contributions.models import Contribution + from validators.models import Validator leaderboard_type = request.query_params.get('type') @@ -280,6 +281,9 @@ def stats(self, request): 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: @@ -330,6 +334,11 @@ def get_effective_community_summary(): 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') @@ -356,8 +365,8 @@ def get_effective_community_summary(): 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 @@ -416,16 +425,11 @@ def get_effective_community_summary(): 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, diff --git a/package-lock.json b/package-lock.json index 46a540be..08f557ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "cairo", + "name": "denver", "lockfileVersion": 3, "requires": true, "packages": { From 9a5c8732218cabca46fae481499ddfe8057f7f32 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Wed, 27 May 2026 11:16:10 +0200 Subject: [PATCH 10/10] Fix community XP ranking and profile ordering (#697) --- backend/community_xp/services.py | 7 +- backend/community_xp/tests/test_mee6_sync.py | 7 ++ backend/community_xp/utils.py | 70 +++++++++++ backend/leaderboard/tests/test_stats.py | 94 ++++++++++---- backend/leaderboard/views.py | 22 +++- frontend/src/components/TopLeaderboard.svelte | 10 +- .../components/profile/RankingsWidget.svelte | 115 +++++++++++++----- .../src/components/shared/CTABanner.svelte | 94 ++++++++++---- frontend/src/lib/api.js | 8 +- frontend/src/routes/Profile.svelte | 3 +- 10 files changed, 341 insertions(+), 89 deletions(-) diff --git a/backend/community_xp/services.py b/backend/community_xp/services.py index 5f8d5269..1d691d58 100644 --- a/backend/community_xp/services.py +++ b/backend/community_xp/services.py @@ -442,6 +442,7 @@ def apply_sync_run(run, applied_by=None): 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: @@ -450,6 +451,8 @@ def apply_sync_run(run, applied_by=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, @@ -472,7 +475,7 @@ def apply_sync_run(run, applied_by=None): 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(matched_user_ids) + 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) @@ -559,7 +562,7 @@ def match_current_xp_for_connection(connection, guild_id=None, create_creator=Tr current.matched_at = timezone.now() current.save(update_fields=['matched_user', 'matched_at', 'updated_at']) - if create_creator: + if create_creator and current.xp > 0: Creator.objects.get_or_create(user=connection.user) return current diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py index a0a635e1..3aac5621 100644 --- a/backend/community_xp/tests/test_mee6_sync.py +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -392,6 +392,13 @@ def test_late_discord_link_matches_current_xp_and_creates_creator_profile(self): 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)]) diff --git a/backend/community_xp/utils.py b/backend/community_xp/utils.py index 4521ec17..2b64d362 100644 --- a/backend/community_xp/utils.py +++ b/backend/community_xp/utils.py @@ -189,6 +189,76 @@ def build_effective_community_scores(user_ids=None, guild_id=None, visible_only= 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], diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 951bb1e7..4e1ee9cf 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -212,6 +212,31 @@ 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_and_counts_as_member_metric(self): + poap_user = self._create_user( + 'poap@example.com', + '0x0000000000000000000000000000000000000007' + ) + drop = PoapDrop.objects.create( + title='Community Call', + slug='community-call', + event_start_at=timezone.now(), + status=PoapDrop.STATUS_ACTIVE, + ) + + PoapClaim.objects.create( + drop=drop, + user=poap_user, + claim_method=PoapClaim.CLAIM_ADMIN, + ) + + response = self.client.get('/api/v1/leaderboard/stats/') + + self.assertEqual(response.status_code, 200) + 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', @@ -265,30 +290,6 @@ def test_validator_count_uses_visible_validator_table_rows(self): self.assertEqual(validator_response.data['validator_count'], 1) self.assertEqual(validator_response.data['participant_count'], 1) - def test_poap_claim_grants_role_but_does_not_count_as_member_metric(self): - poap_user = self._create_user( - 'poap@example.com', - '0x0000000000000000000000000000000000000007' - ) - drop = PoapDrop.objects.create( - title='Community Call', - slug='community-call', - event_start_at=timezone.now(), - status=PoapDrop.STATUS_ACTIVE, - ) - - PoapClaim.objects.create( - drop=drop, - user=poap_user, - claim_method=PoapClaim.CLAIM_ADMIN, - ) - - response = self.client.get('/api/v1/leaderboard/stats/') - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['community_member_count'], 0) - self.assertTrue(Creator.objects.filter(user=poap_user).exists()) - def test_community_social_link_contributions_do_not_count_as_members_or_activity(self): social_user = self._create_user( 'social@example.com', @@ -342,6 +343,50 @@ def test_community_stats_use_effective_mee6_points_and_members(self): 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', @@ -379,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 83544ce5..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 @@ -287,7 +290,10 @@ def get_validators(): def get_effective_community_summary(): nonlocal effective_community_summary if effective_community_summary is None: - from community_xp.utils import build_effective_community_scores + from community_xp.utils import ( + build_effective_community_scores, + get_community_member_user_ids, + ) entries = [ score @@ -295,10 +301,13 @@ def get_effective_community_summary(): if (score['total_points'] or 0) > 0 ] effective_community_summary = { - 'member_count': len(entries), + '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: @@ -387,7 +396,7 @@ def get_effective_community_summary(): .values_list('user_id', flat=True) .distinct() ) - participant_user_ids.update(community_summary['user_ids']) + 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( @@ -439,9 +448,10 @@ def get_effective_community_summary(): ) community_summary = get_effective_community_summary() community_member_count = community_summary['member_count'] - new_community_members_count = community_contribs.filter( - created_at__gte=last_month - ).values('user_id').distinct().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, 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/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 93beff31..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/'), diff --git a/frontend/src/routes/Profile.svelte b/frontend/src/routes/Profile.svelte index 37904f91..2de0ebf5 100644 --- a/frontend/src/routes/Profile.svelte +++ b/frontend/src/routes/Profile.svelte @@ -847,6 +847,7 @@ - + {/if} {:else}