From b3d3e28044b642c40136b11de290b2d45cb99bcb Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 25 May 2026 11:49:44 +0200 Subject: [PATCH] Fix community role and member metrics --- backend/contributions/views.py | 13 +- .../0002_backfill_community_members.py | 42 +++++++ backend/creators/utils.py | 26 ++++ backend/leaderboard/models.py | 8 ++ backend/leaderboard/tests/test_stats.py | 103 +++++++++++++++- backend/leaderboard/views.py | 112 ++++++++---------- .../commands/import_poap_archive.py | 6 + backend/poaps/signals.py | 7 ++ frontend/src/routes/Dashboard.svelte | 4 +- frontend/src/routes/Metrics.svelte | 2 +- 10 files changed, 253 insertions(+), 70 deletions(-) create mode 100644 backend/creators/migrations/0002_backfill_community_members.py create mode 100644 backend/creators/utils.py diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 461ba05a..696cdde2 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -44,6 +44,8 @@ METRICS_POINTS_EXCLUDED_TYPE_SLUGS = [ 'builder-welcome', 'builder', + 'validator-waitlist', + 'validator', 'community-link-x', 'community-link-discord', ] @@ -302,9 +304,14 @@ def get_queryset(self): contribution_type__slug__in=['builder-welcome', 'validator-waitlist'] ) - # Optionally restrict to contributions whose type is submittable. - # Used by the public contributions explorer to hide system-generated - # contributions (badges, journey rewards) from non-submittable types. + # Optionally remove journey/social-link onboarding records so dashboard + # contribution lists match member metrics. + exclude_onboarding = self.request.query_params.get('exclude_onboarding') + if exclude_onboarding and exclude_onboarding.lower() == 'true': + queryset = queryset.exclude( + contribution_type__slug__in=METRICS_POINTS_EXCLUDED_TYPE_SLUGS + ) + submittable_only = self.request.query_params.get('submittable_only') if submittable_only and submittable_only.lower() == 'true': queryset = queryset.filter(contribution_type__is_submittable=True) diff --git a/backend/creators/migrations/0002_backfill_community_members.py b/backend/creators/migrations/0002_backfill_community_members.py new file mode 100644 index 00000000..aff0c57d --- /dev/null +++ b/backend/creators/migrations/0002_backfill_community_members.py @@ -0,0 +1,42 @@ +from django.db import migrations + + +def backfill_community_members(apps, schema_editor): + Creator = apps.get_model('creators', 'Creator') + Contribution = apps.get_model('contributions', 'Contribution') + PoapClaim = apps.get_model('poaps', 'PoapClaim') + + user_ids = set( + Contribution.objects.filter( + contribution_type__category__slug='community', + ).values_list('user_id', flat=True).distinct() + ) + user_ids.update( + PoapClaim.objects.filter( + user__isnull=False, + ).values_list('user_id', flat=True).distinct() + ) + + existing_user_ids = set( + Creator.objects.filter(user_id__in=user_ids).values_list('user_id', flat=True) + ) + missing_user_ids = user_ids - existing_user_ids + + Creator.objects.bulk_create( + [Creator(user_id=user_id) for user_id in missing_user_ids], + ignore_conflicts=True, + batch_size=500, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('creators', '0001_initial'), + ('contributions', '0061_featuredcontent_hero_placements'), + ('poaps', '0001_initial'), + ] + + operations = [ + migrations.RunPython(backfill_community_members, migrations.RunPython.noop), + ] diff --git a/backend/creators/utils.py b/backend/creators/utils.py new file mode 100644 index 00000000..425299a5 --- /dev/null +++ b/backend/creators/utils.py @@ -0,0 +1,26 @@ +from creators.models import Creator + + +def ensure_creator_status(user): + if not user or not getattr(user, 'pk', None): + return None + + creator, _ = Creator.objects.get_or_create(user=user) + return creator + + +def ensure_creator_status_for_users(user_ids): + user_ids = {user_id for user_id in user_ids if user_id} + if not user_ids: + return + + existing_user_ids = set( + Creator.objects.filter(user_id__in=user_ids).values_list('user_id', flat=True) + ) + missing_user_ids = user_ids - existing_user_ids + if missing_user_ids: + Creator.objects.bulk_create( + [Creator(user_id=user_id) for user_id in missing_user_ids], + ignore_conflicts=True, + batch_size=500, + ) diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index 01d7d07a..690fb3d3 100644 --- a/backend/leaderboard/models.py +++ b/backend/leaderboard/models.py @@ -403,6 +403,14 @@ def update_leaderboard_on_contribution(sender, instance, created, **kwargs): logger.debug(f"Contribution saved: {instance.points} points × {instance.multiplier_at_creation} = " f"{instance.frozen_global_points} global points") + if ( + instance.contribution_type + and instance.contribution_type.category + and instance.contribution_type.category.slug == 'community' + ): + from creators.utils import ensure_creator_status + ensure_creator_status(instance.user) + # Update the user's leaderboard entries update_user_leaderboard_entries(instance.user) diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index d965bcc4..02e7663d 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -6,9 +6,12 @@ Category, Contribution, ContributionType, + Mission, SubmittedContribution, ) -from leaderboard.models import ReferralPoints +from creators.models import Creator +from leaderboard.models import GlobalLeaderboardMultiplier, ReferralPoints +from poaps.models import PoapClaim, PoapDrop from users.models import User @@ -33,6 +36,15 @@ def setUp(self): slug='builder-submission', category=self.builder_category ) + self.community_link_x_type, _ = ContributionType.objects.get_or_create( + slug='community-link-x', + defaults={ + 'name': 'Community Link X', + 'category': self.community_category, + 'min_points': 0, + 'max_points': 100, + }, + ) def _create_user(self, email, address, visible=True): return User.objects.create_user( @@ -124,3 +136,92 @@ def test_community_member_count_uses_accepted_community_contributions(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['community_member_count'], 2) self.assertEqual(response.data['creator_count'], 2) + self.assertEqual(response.data['builder_count'], 1) + + def test_poap_claim_grants_role_but_does_not_count_as_member_metric(self): + 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', + '0x0000000000000000000000000000000000000009' + ) + Contribution.objects.create( + user=social_user, + contribution_type=self.community_link_x_type, + points=20, + frozen_global_points=20, + contribution_date=timezone.now() + ) + + response = self.client.get('/api/v1/leaderboard/stats/', {'type': 'community'}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['community_member_count'], 0) + self.assertEqual(response.data['participant_count'], 0) + self.assertEqual(response.data['contribution_count'], 0) + + def test_mission_backed_non_submittable_community_contribution_is_reflected(self): + contributor = self._create_user( + 'mission-community@example.com', + '0x0000000000000000000000000000000000000008' + ) + mission_type = ContributionType.objects.create( + name='Community Mission Host', + slug='community-mission-host', + category=self.community_category, + min_points=1, + max_points=100, + is_submittable=False, + ) + GlobalLeaderboardMultiplier.objects.create( + contribution_type=mission_type, + multiplier_value=1, + valid_from=timezone.now() - timezone.timedelta(days=30), + ) + mission = Mission.objects.create( + name='Community Mission', + description='Mission-backed community work', + contribution_type=mission_type, + ) + + contribution = Contribution.objects.create( + user=contributor, + contribution_type=mission_type, + mission=mission, + points=25, + contribution_date=timezone.now(), + ) + + stats_response = self.client.get('/api/v1/leaderboard/stats/', {'type': 'community'}) + monthly_response = self.client.get('/api/v1/leaderboard/monthly/', {'type': 'community'}) + + self.assertEqual(stats_response.status_code, 200) + self.assertEqual(stats_response.data['community_member_count'], 1) + self.assertEqual(stats_response.data['contribution_count'], 1) + self.assertTrue(Creator.objects.filter(user=contributor).exists()) + + self.assertEqual(monthly_response.status_code, 200) + self.assertEqual(monthly_response.data[0]['user'], contributor.id) + self.assertEqual(monthly_response.data[0]['total_points'], contribution.frozen_global_points) diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 61b62b04..41c1a1af 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -8,7 +8,15 @@ from .serializers import GlobalLeaderboardMultiplierSerializer, LeaderboardEntrySerializer from contributions.models import Category, Contribution -JOURNEY_AUTO_AWARD_SLUGS = ['builder-welcome', 'builder', 'validator-waitlist', 'validator'] +ONBOARDING_CONTRIBUTION_TYPE_SLUGS = [ + 'builder-welcome', + 'builder', + 'validator-waitlist', + 'validator', + 'community-link-x', + 'community-link-discord', +] +JOURNEY_AUTO_AWARD_SLUGS = ONBOARDING_CONTRIBUTION_TYPE_SLUGS class GlobalLeaderboardMultiplierViewSet(viewsets.ReadOnlyModelViewSet): @@ -219,14 +227,16 @@ def monthly(self, request): now = timezone.localtime(timezone.now()) month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + monthly_query = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug=leaderboard_type, + contribution_date__gte=month_start, + ) + if leaderboard_type != 'community': + monthly_query = monthly_query.filter(user__leaderboard_entries__type=leaderboard_type) + monthly_totals = ( - Contribution.objects - .filter( - user__visible=True, - user__leaderboard_entries__type=leaderboard_type, - contribution_type__category__slug=leaderboard_type, - contribution_date__gte=month_start, - ) + monthly_query .exclude(contribution_type__slug__in=JOURNEY_AUTO_AWARD_SLUGS) .values('user_id') .annotate(total_points=Sum('frozen_global_points')) @@ -291,7 +301,7 @@ def stats(self, request): user__visible=True, contribution_type__category__slug=category ).exclude( - contribution_type__slug__in=['builder-welcome', 'builder', 'validator-waitlist', 'validator'] + contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS ) contribution_count = category_contributions.count() new_contributions_count = category_contributions.filter( @@ -305,10 +315,7 @@ def stats(self, request): new_points_count = category_contributions.filter( created_at__gte=last_month ).aggregate(total=Sum('frozen_global_points'))['total'] or 0 - if category == 'community': - participant_count = category_contributions.values('user_id').distinct().count() - else: - participant_count = leaderboard_entries.count() + participant_count = category_contributions.values('user_id').distinct().count() else: contribution_count = 0 new_contributions_count = 0 @@ -334,14 +341,12 @@ def stats(self, request): new_validators_count = 0 else: # Global stats - participant_count = User.objects.filter( - contributions__isnull=False, - visible=True - ).distinct().count() - - all_contributions = Contribution.objects.exclude( - contribution_type__slug__in=['builder-welcome', 'builder', 'validator-waitlist', 'validator'] + all_contributions = Contribution.objects.filter( + user__visible=True + ).exclude( + contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS ) + participant_count = all_contributions.values('user_id').distinct().count() contribution_count = all_contributions.count() new_contributions_count = all_contributions.filter( created_at__gte=last_month @@ -363,57 +368,36 @@ def stats(self, request): type='validator', user__created_at__gte=last_month ).count() - # Category-specific counts (always included) - # Builders count only users with a Builder profile AND at least one - # accepted contribution that isn't the journey auto-award (`builder-welcome` - # or `builder`). When the request is scoped to ?type=builder we further - # require the qualifying contribution to live in the `builder` category - # so the builder dashboard reflects active builder *work*, not generic - # platform activity (waitlist joins, social-account links, etc.). The - # generic call (no type) keeps the broader definition for landing-page - # / metrics-overview consumers. - from builders.models import Builder - builder_user_ids = Builder.objects.filter( - user__visible=True - ).values_list('user_id', flat=True) - builder_contribs = ( - Contribution.objects - .filter(user_id__in=builder_user_ids) - .exclude(contribution_type__slug__in=['builder-welcome', 'builder']) + builder_contribs = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='builder', + ).exclude( + contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS ) - if leaderboard_type == 'builder': - builder_contribs = builder_contribs.filter( - contribution_type__category__slug='builder' - ) builder_count = builder_contribs.values('user_id').distinct().count() + new_builders_count = builder_contribs.filter( + created_at__gte=last_month + ).values('user_id').distinct().count() - # Validators follow the same rule: a Validator profile + at least one - # accepted contribution that isn't the waitlist or auto-award. When - # scoped to ?type=validator, also require the contribution to live in - # the `validator` category so the validator dashboard reflects real - # validator work. - from validators.models import Validator - validator_user_ids = Validator.objects.filter( - user__visible=True - ).values_list('user_id', flat=True) - validator_contribs = ( - Contribution.objects - .filter(user_id__in=validator_user_ids) - .exclude(contribution_type__slug__in=['validator-waitlist', 'validator']) + validator_contribs = Contribution.objects.filter( + user__visible=True, + contribution_type__category__slug='validator', + ).exclude( + contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS ) - if leaderboard_type == 'validator': - validator_contribs = validator_contribs.filter( - contribution_type__category__slug='validator' - ) validator_count = validator_contribs.values('user_id').distinct().count() - - community_member_count = Contribution.objects.filter( - user__visible=True, - contribution_type__category__slug='community' + new_validators_count = validator_contribs.filter( + created_at__gte=last_month ).values('user_id').distinct().count() - new_community_members_count = Contribution.objects.filter( + + community_contribs = Contribution.objects.filter( user__visible=True, contribution_type__category__slug='community', + ).exclude( + contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS + ) + community_member_count = community_contribs.values('user_id').distinct().count() + new_community_members_count = community_contribs.filter( created_at__gte=last_month ).values('user_id').distinct().count() @@ -637,6 +621,8 @@ def community(self, request): community_contributions = Contribution.objects.filter( user__visible=True, contribution_type__category__slug='community', + ).exclude( + contribution_type__slug__in=ONBOARDING_CONTRIBUTION_TYPE_SLUGS ) search = request.query_params.get('search', '').strip() diff --git a/backend/poaps/management/commands/import_poap_archive.py b/backend/poaps/management/commands/import_poap_archive.py index 124820f4..496539fd 100644 --- a/backend/poaps/management/commands/import_poap_archive.py +++ b/backend/poaps/management/commands/import_poap_archive.py @@ -335,6 +335,12 @@ def _import_claim_rows(self, batch, drop, drop_entry, rows, errors): if claims_to_create: PoapClaim.objects.bulk_create(claims_to_create, batch_size=500) + from creators.utils import ensure_creator_status_for_users + ensure_creator_status_for_users( + [claim.user_id for claim in claims_to_update] + + [claim.user_id for claim in claims_to_create] + ) + batch.imported_count += imported_count batch.matched_count += matched_count batch.unmatched_count += unmatched_count diff --git a/backend/poaps/signals.py b/backend/poaps/signals.py index 41b44402..0f55249b 100644 --- a/backend/poaps/signals.py +++ b/backend/poaps/signals.py @@ -2,6 +2,8 @@ from django.db.models.signals import post_save from django.dispatch import receiver +from creators.utils import ensure_creator_status +from .models import PoapClaim from .services import attach_unmatched_claims_for_user @@ -9,3 +11,8 @@ def attach_legacy_poap_claims(sender, instance, **kwargs): attach_unmatched_claims_for_user(instance) + +@receiver(post_save, sender=PoapClaim) +def grant_community_role_for_poap_claim(sender, instance, **kwargs): + if instance.user_id: + ensure_creator_status(instance.user) diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index c81b73a4..a3a40179 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -129,7 +129,7 @@ if (cat === 'community') { promises.push( - contributionsAPI.getContributions({ limit: 20, category: cat }).then(res => { + contributionsAPI.getContributions({ limit: 20, category: cat, exclude_onboarding: 'true' }).then(res => { const contributions = res.data?.results ?? res.data ?? []; recentContributions = contributions.slice(0, 5); @@ -168,7 +168,7 @@ waitlistLoading = false; }).catch(() => { waitlistLoading = false; }), - contributionsAPI.getContributions({ limit: 5, category: cat }).then(res => { + contributionsAPI.getContributions({ limit: 5, category: cat, exclude_onboarding: 'true' }).then(res => { recentContributions = res.data?.results ?? res.data ?? []; recentLoading = false; }).catch(() => { recentLoading = false; }) diff --git a/frontend/src/routes/Metrics.svelte b/frontend/src/routes/Metrics.svelte index 5dc149f4..e3761335 100644 --- a/frontend/src/routes/Metrics.svelte +++ b/frontend/src/routes/Metrics.svelte @@ -545,7 +545,7 @@ ordering: communitySortBy, page: communityPage, page_size: COMMUNITY_PAGE_SIZE, - submittable_only: 'true' + exclude_onboarding: 'true' }; if (communityContributionType) {