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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/api/urls.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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')
Expand Down
1 change: 1 addition & 0 deletions backend/community_xp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

188 changes: 188 additions & 0 deletions backend/community_xp/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
from django.contrib import admin
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.shortcuts import redirect
from django.urls import path

from .models import (
Mee6CurrentXP,
Mee6PlayerSnapshot,
Mee6SyncLock,
Mee6SyncRun,
)
from .services import Mee6SyncAlreadyRunning, Mee6SyncError, apply_sync_run, run_mee6_sync


@admin.register(Mee6SyncRun)
class Mee6SyncRunAdmin(admin.ModelAdmin):
actions = ('fetch_new_snapshot', 'apply_as_active_baseline')
change_list_template = 'admin/community_xp/mee6syncrun/change_list.html'
list_display = (
'id',
'guild_id',
'guild_name',
'status',
'applied_at',
'players_fetched',
'matched_players',
'unmatched_players',
'duplicate_players',
'started_at',
'completed_at',
)
list_filter = ('status', 'guild_id')
search_fields = ('guild_id', 'guild_name', 'error_message')
readonly_fields = (
'created_at',
'updated_at',
'started_at',
'completed_at',
'applied_at',
'applied_by',
)

def get_urls(self):
return [
path(
'fetch-new-snapshot/',
self.admin_site.admin_view(self.fetch_new_snapshot_view),
name='community_xp_mee6syncrun_fetch_new_snapshot',
),
] + super().get_urls()

def fetch_new_snapshot_view(self, request):
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == 'POST':
self._fetch_new_snapshot(request)
return redirect('..')

def changelist_view(self, request, extra_context=None):
extra_context = extra_context or {}
extra_context['can_fetch_new_snapshot'] = self.has_add_permission(request)
return super().changelist_view(request, extra_context=extra_context)

def _fetch_new_snapshot(self, request, guild_id=None, page_size=None):
try:
result = run_mee6_sync(
guild_id=guild_id,
page_size=page_size,
)
except Mee6SyncAlreadyRunning as exc:
elapsed = f" for {exc.elapsed_seconds:.0f}s" if exc.elapsed_seconds is not None else ''
self.message_user(
request,
f'MEE6 XP sync already running{elapsed}.',
level=messages.ERROR,
)
return
except Mee6SyncError as exc:
self.message_user(request, str(exc), level=messages.ERROR)
return

self.message_user(
request,
(
f"Fetched MEE6 sync #{result['run_id']}: "
f"{result['players_fetched']} players, "
f"{result['matched_players']} matched, "
f"{result['unmatched_players']} unmatched, "
f"{result['pages_fetched']} pages. "
"Apply it to update active community XP."
),
level=messages.SUCCESS,
)

@admin.action(description='Fetch new MEE6 XP snapshot using selected run settings')
def fetch_new_snapshot(self, request, queryset):
if not self.has_add_permission(request) or not self.has_change_permission(request):
raise PermissionDenied
if queryset.count() != 1:
self.message_user(
request,
'Select exactly one MEE6 sync run to reuse its guild and page size.',
level=messages.ERROR,
)
return

source_run = queryset.first()
self._fetch_new_snapshot(
request,
guild_id=source_run.guild_id,
page_size=source_run.page_size,
)

@admin.action(description='Apply selected MEE6 run as active community XP baseline')
def apply_as_active_baseline(self, request, queryset):
if not self.has_change_permission(request):
raise PermissionDenied
if queryset.count() != 1:
self.message_user(
request,
'Select exactly one successful MEE6 sync run to apply.',
level=messages.ERROR,
)
return

run = queryset.first()
try:
result = apply_sync_run(run, applied_by=request.user)
except Mee6SyncError as exc:
self.message_user(request, str(exc), level=messages.ERROR)
return

self.message_user(
request,
(
f"Applied MEE6 sync #{run.id}: "
f"{result['players_applied']} players, "
f"{result['matched_players']} matched, "
f"{result['unmatched_players']} unmatched."
),
level=messages.SUCCESS,
)


@admin.register(Mee6PlayerSnapshot)
class Mee6PlayerSnapshotAdmin(admin.ModelAdmin):
list_display = (
'run',
'rank',
'username',
'discord_id',
'xp',
'level',
)
list_filter = ('guild_id', 'run__status')
search_fields = (
'discord_id',
'username',
)
readonly_fields = ('created_at', 'updated_at')


@admin.register(Mee6CurrentXP)
class Mee6CurrentXPAdmin(admin.ModelAdmin):
list_display = (
'rank',
'username',
'discord_id',
'xp',
'level',
'matched_user',
'synced_at',
)
list_filter = ('guild_id',)
search_fields = (
'discord_id',
'username',
'matched_user__email',
'matched_user__address',
)
readonly_fields = ('created_at', 'updated_at', 'synced_at')


@admin.register(Mee6SyncLock)
class Mee6SyncLockAdmin(admin.ModelAdmin):
list_display = ('name', 'owner_token', 'acquired_at', 'heartbeat_at', 'released_at')
readonly_fields = ('name', 'owner_token', 'acquired_at', 'heartbeat_at', 'released_at')
10 changes: 10 additions & 0 deletions backend/community_xp/apps.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions backend/community_xp/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
COMMUNITY_XP_EXCLUDED_TYPE_SLUGS = (
'builder-welcome',
'builder',
'validator-waitlist',
'validator',
'community-link-x',
'community-link-discord',
)
1 change: 1 addition & 0 deletions backend/community_xp/management/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

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

38 changes: 38 additions & 0 deletions backend/community_xp/management/commands/sync_mee6_xp.py
Original file line number Diff line number Diff line change
@@ -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."
))
Loading
Loading