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
3 changes: 3 additions & 0 deletions backend/community_xp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
'builder',
'validator-waitlist',
'validator',
)

COMMUNITY_MEMBER_EXCLUDED_TYPE_SLUGS = (
'community-link-x',
'community-link-discord',
)
16 changes: 8 additions & 8 deletions backend/community_xp/tests/test_mee6_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,18 +263,18 @@ def test_pre_sync_uses_all_portal_community_points(self):
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):
def test_onboarding_link_rewards_are_auditable_and_count_as_points(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)
self.assertEqual(breakdown['total_points'], 60)
self.assertEqual(breakdown['tracked_portal_points_all_time'], 60)
self.assertEqual(breakdown['pending_portal_points'], 60)
self.assertEqual(breakdown['community_contribution_count'], 2)

def test_pending_onboarding_link_reward_does_not_block_baseline_apply(self):
self.link_discord(self.user)
Expand All @@ -290,9 +290,9 @@ def test_pending_onboarding_link_reward_does_not_block_baseline_apply(self):
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)
self.assertEqual(breakdown['pending_portal_points'], 40)
self.assertEqual(breakdown['tracked_portal_points_all_time'], 40)
self.assertEqual(breakdown['total_points'], 140)

def test_sync_preserves_contributions_and_counts_mee6_plus_pending_portal_points(self):
self.link_discord(self.user)
Expand Down
244 changes: 188 additions & 56 deletions backend/community_xp/utils.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
from django.db.models import Case, Count, F, IntegerField, Sum, Value, When
from django.db.models.functions import Greatest
from django.db.models import (
BooleanField,
Case,
Count,
DateTimeField,
F,
IntegerField,
OuterRef,
Q,
Subquery,
Sum,
Value,
When,
)
from django.db.models.functions import Coalesce, Greatest, Lower

from contributions.models import Contribution, ContributionDiscordXPState

from .constants import COMMUNITY_XP_EXCLUDED_TYPE_SLUGS
from .constants import COMMUNITY_MEMBER_EXCLUDED_TYPE_SLUGS, COMMUNITY_XP_EXCLUDED_TYPE_SLUGS
from .models import Mee6CurrentXP, Mee6SyncRun
from .services import get_default_guild_id

Expand Down Expand Up @@ -34,6 +47,17 @@ def _community_contributions(user_ids=None):
return queryset


def _community_member_contributions(user_ids=None):
queryset = Contribution.objects.filter(
contribution_type__category__slug='community',
).exclude(
contribution_type__slug__in=COMMUNITY_MEMBER_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']: {
Expand Down Expand Up @@ -119,7 +143,39 @@ def _current_xp_by_user(users_by_id, guild_id):
return result


def build_effective_community_scores(user_ids=None, guild_id=None, visible_only=True):
def _community_points_case(baseline_completed_at=None):
if baseline_completed_at is None:
return F('frozen_global_points')

pending_expr = Greatest(
F('frozen_global_points') - F('discord_xp_state__awarded_amount'),
Value(0),
output_field=IntegerField(),
)
return Case(
When(discord_xp_state__isnull=True, then=F('frozen_global_points')),
When(
discord_xp_state__status=ContributionDiscordXPState.STATUS_DISTRIBUTED,
discord_xp_state__distributed_at__lte=baseline_completed_at,
then=Value(0),
),
When(
discord_xp_state__status=ContributionDiscordXPState.STATUS_DISTRIBUTED,
discord_xp_state__distributed_at__gt=baseline_completed_at,
then=F('frozen_global_points'),
),
default=pending_expr,
output_field=IntegerField(),
)


def build_effective_community_scores_queryset(user_ids=None, guild_id=None, visible_only=True):
"""
Return users annotated with the same effective community score fields as
build_effective_community_scores(), without materializing the full ranking.
Effective points are MEE6 current XP plus portal points not covered by the
applied MEE6 baseline.
"""
from users.models import User

guild_id = str(guild_id or get_default_guild_id())
Expand All @@ -129,61 +185,129 @@ def build_effective_community_scores(user_ids=None, guild_id=None, visible_only=
if user_ids is not None:
user_queryset = user_queryset.filter(id__in=user_ids)

users_by_id = {
user.id: user
for user in user_queryset.only(
'id',
'name',
'address',
'profile_image_url',
'visible',
)
}

all_time = _aggregate_community_points(user_ids=users_by_id.keys())
latest_sync = get_latest_applied_sync(guild_id)
pending_portal_points_by_user = _aggregate_pending_portal_points(
user_ids=users_by_id.keys(),
baseline_completed_at=latest_sync.completed_at if latest_sync else None,

current_xp_queryset = (
Mee6CurrentXP.objects
.filter(guild_id=guild_id, matched_user_id=OuterRef('pk'))
.order_by('-xp', 'id')
)
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
community_contributions = (
Contribution.objects
.filter(
user_id=OuterRef('pk'),
contribution_type__category__slug='community',
)
current_xp_by_user = _current_xp_by_user(users_by_id, guild_id)
.exclude(contribution_type__slug__in=COMMUNITY_XP_EXCLUDED_TYPE_SLUGS)
.values('user_id')
)
pending_points_queryset = (
community_contributions
.annotate(pending_total=Sum(_community_points_case(
latest_sync.completed_at if latest_sync else None
)))
.values('pending_total')[:1]
)
all_time_points_queryset = (
community_contributions
.annotate(total=Sum('frozen_global_points'))
.values('total')[:1]
)
contribution_count_queryset = (
community_contributions
.annotate(count=Count('id'))
.values('count')[:1]
)

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())
return (
user_queryset
.only('id', 'name', 'address', 'profile_image_url', 'visible')
.annotate(
discord_xp=Coalesce(
Subquery(current_xp_queryset.values('xp')[:1], output_field=IntegerField()),
Value(0),
output_field=IntegerField(),
),
discord_xp_synced_at=Subquery(
current_xp_queryset.values('synced_at')[:1],
output_field=DateTimeField(),
),
current_xp_row_id=Subquery(
current_xp_queryset.values('id')[:1],
output_field=IntegerField(),
),
pending_portal_points=Coalesce(
Subquery(pending_points_queryset, output_field=IntegerField()),
Value(0),
output_field=IntegerField(),
),
tracked_portal_points_all_time=Coalesce(
Subquery(all_time_points_queryset, output_field=IntegerField()),
Value(0),
output_field=IntegerField(),
),
community_contribution_count=Coalesce(
Subquery(contribution_count_queryset, output_field=IntegerField()),
Value(0),
output_field=IntegerField(),
),
latest_applied_sync_completed_at=Value(
latest_sync.completed_at if latest_sync else None,
output_field=DateTimeField(),
),
latest_applied_at=Value(
latest_sync.applied_at if latest_sync else None,
output_field=DateTimeField(),
),
)
.annotate(
total_points=F('discord_xp') + F('pending_portal_points'),
has_discord_xp_snapshot=Case(
When(current_xp_row_id__isnull=False, then=Value(True)),
default=Value(False),
output_field=BooleanField(),
),
community_sort_name=Lower(Coalesce('name', Value(''))),
)
)

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
def effective_community_ranking_queryset(user_ids=None, guild_id=None, visible_only=True):
return (
build_effective_community_scores_queryset(
user_ids=user_ids,
guild_id=guild_id,
visible_only=visible_only,
)
.filter(total_points__gt=0)
.order_by('-total_points', 'community_sort_name', 'id')
)


total_points = discord_xp + pending_portal_points
def build_effective_community_scores(user_ids=None, guild_id=None, visible_only=True):
scores_queryset = build_effective_community_scores_queryset(
user_ids=user_ids,
guild_id=guild_id,
visible_only=visible_only,
)
if user_ids is None:
scores_queryset = scores_queryset.filter(
Q(total_points__gt=0) | Q(community_contribution_count__gt=0)
)

scores[user_id] = {
scores = {}
for user in scores_queryset:
scores[user.id] = {
'user': user,
'discord_xp': discord_xp,
'discord_xp_synced_at': current_xp.synced_at if current_xp else None,
'pending_portal_points': pending_portal_points,
'tracked_portal_points_all_time': all_time_points,
'total_points': total_points,
'has_discord_xp_snapshot': current_xp is not None,
'latest_applied_sync_completed_at': latest_sync.completed_at if latest_sync else None,
'latest_applied_at': latest_sync.applied_at if latest_sync else None,
'community_contribution_count': all_time_count,
'discord_xp': user.discord_xp,
'discord_xp_synced_at': user.discord_xp_synced_at,
'pending_portal_points': user.pending_portal_points,
'tracked_portal_points_all_time': user.tracked_portal_points_all_time,
'total_points': user.total_points,
'has_discord_xp_snapshot': user.has_discord_xp_snapshot,
'latest_applied_sync_completed_at': user.latest_applied_sync_completed_at,
'latest_applied_at': user.latest_applied_at,
'community_contribution_count': user.community_contribution_count,
}

return scores
Expand All @@ -193,16 +317,24 @@ def get_community_member_user_ids(user_ids=None, guild_id=None, visible_only=Tru
from creators.models import Creator
from poaps.models import PoapClaim

score_map = build_effective_community_scores(
score_queryset = effective_community_ranking_queryset(
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
}
score_member_user_ids = set(
score_queryset
.filter(discord_xp__gt=0)
.values_list('id', flat=True)
)
member_contributions = _community_member_contributions(user_ids=user_ids)
if visible_only:
member_contributions = member_contributions.filter(user__visible=True)
score_member_user_ids.update(
member_contributions
.values_list('user_id', flat=True)
.distinct()
)
member_user_ids = set(score_member_user_ids if since is None else [])

contribution_filters = {
Expand Down
1 change: 1 addition & 0 deletions backend/leaderboard/tests/test_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ def test_community_social_link_contributions_do_not_count_as_members_or_activity
self.assertEqual(response.data['community_member_count'], 0)
self.assertEqual(response.data['participant_count'], 0)
self.assertEqual(response.data['contribution_count'], 0)
self.assertEqual(response.data['total_points'], 20)

def test_community_stats_use_effective_mee6_points_and_members(self):
mee6_only_user = self._create_user(
Expand Down
Loading
Loading