diff --git a/backend/contributions/views.py b/backend/contributions/views.py index e63446b..fc10bc1 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -1050,7 +1050,9 @@ def review(self, request, pk=None): and not hasattr(contribution_user, 'builder')): from leaderboard.models import ensure_builder_status, update_user_leaderboard_entries ensure_builder_status(contribution_user, submission.contribution_date) - update_user_leaderboard_entries(contribution_user) + # Re-fetch user to avoid stale reverse-relation cache from the hasattr check above + fresh_user = type(contribution_user).objects.get(pk=contribution_user.pk) + update_user_leaderboard_entries(fresh_user) # Copy evidence items using bulk_create for better performance Evidence.objects.bulk_create([ diff --git a/backend/leaderboard/apps.py b/backend/leaderboard/apps.py index 590a452..a2d8d01 100644 --- a/backend/leaderboard/apps.py +++ b/backend/leaderboard/apps.py @@ -4,3 +4,10 @@ class LeaderboardConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'leaderboard' + + def ready(self): + from django.db.models.signals import post_save + from leaderboard.models import update_leaderboard_on_builder_creation + + Builder = self.apps.get_model('builders', 'Builder') + post_save.connect(update_leaderboard_on_builder_creation, sender=Builder) diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index a51eb18..faa4b87 100644 --- a/backend/leaderboard/models.py +++ b/backend/leaderboard/models.py @@ -397,6 +397,22 @@ def update_leaderboard_on_contribution(sender, instance, created, **kwargs): update_referrer_points(instance) +def update_leaderboard_on_builder_creation(sender, instance, created, **kwargs): + """ + When a Builder profile is created, update the user's leaderboard entries. + This ensures users appear on the builder leaderboard immediately after + their Builder profile is created, even if the profile was created after + their contributions (which is the case in complete_builder_journey). + """ + if created: + from users.models import User + # Re-fetch user from DB to avoid stale reverse-relation cache + # (hasattr(user, 'builder') may have been cached as False before the Builder was created) + user = User.objects.get(pk=instance.user_id) + logger.debug(f"Builder profile created for {user.email}, updating leaderboard entries") + update_user_leaderboard_entries(user) + + def update_user_leaderboard_entries(user): """ Core function that manages all of a user's leaderboard placements. @@ -603,211 +619,219 @@ def update_all_ranks(): def recalculate_all_leaderboards(): """Recalculate all leaderboard entries and referral points from scratch.""" from django.db import transaction + from django.db.models.signals import post_save from users.models import User from builders.models import Builder from validators.models import Validator from collections import defaultdict - with transaction.atomic(): - existing_graduations = { - entry.user_id: { - 'points': entry.total_points, - 'graduation_date': entry.graduation_date - } - for entry in LeaderboardEntry.objects.filter(type='validator-waitlist-graduation') - } - - LeaderboardEntry.objects.all().delete() - ReferralPoints.objects.all().delete() - - # Auto-grant builder status to users with builder contributions but no Builder profile - initial_contributions = list(Contribution.objects.select_related( - 'contribution_type__category' - ).values( - 'user_id', - 'contribution_type__slug', - 'contribution_type__category__slug', - 'contribution_date' - )) - - initial_builders_set = set(Builder.objects.values_list('user_id', flat=True)) - user_builder_contribs = defaultdict(list) - for contrib in initial_contributions: - if (contrib['contribution_type__category__slug'] == 'builder' and - contrib['contribution_type__slug'] not in ['builder-welcome', 'builder']): - user_builder_contribs[contrib['user_id']].append(contrib['contribution_date']) - - for user_id, dates in user_builder_contribs.items(): - if user_id not in initial_builders_set: - try: - user = User.objects.get(id=user_id) - earliest_date = min(dates) - ensure_builder_status(user, earliest_date) - except User.DoesNotExist: - pass - - # Load all contribution data (including newly created) - contributions = list(Contribution.objects.select_related( - 'contribution_type__category' - ).values( - 'id', - 'user_id', - 'user__referred_by_id', - 'user__visible', - 'contribution_type__slug', - 'contribution_type__category__slug', - 'contribution_date', - 'frozen_global_points' - )) - - builders_set = set(Builder.objects.values_list('user_id', flat=True)) - validators_set = set(Validator.objects.values_list('user_id', flat=True)) - - user_contributions = defaultdict(list) - for contrib in contributions: - user_contributions[contrib['user_id']].append(contrib) + # Disconnect builder creation signal during bulk recalculation to avoid + # IntegrityError conflicts with the bulk_create at the end of this function. + post_save.disconnect(update_leaderboard_on_builder_creation, sender=Builder) - referrer_contributions = defaultdict(list) - for contrib in contributions: - if contrib['user__referred_by_id']: - referrer_contributions[contrib['user__referred_by_id']].append(contrib) - - user_badges = defaultdict(set) - for contrib in contributions: - user_badges[contrib['user_id']].add(contrib['contribution_type__slug']) - - users_eligible_for_referrals = set() - for contrib in contributions: - if contrib['contribution_type__slug'] not in ['builder-welcome', 'validator-waitlist']: - users_eligible_for_referrals.add(contrib['user_id']) + try: + with transaction.atomic(): + existing_graduations = { + entry.user_id: { + 'points': entry.total_points, + 'graduation_date': entry.graduation_date + } + for entry in LeaderboardEntry.objects.filter(type='validator-waitlist-graduation') + } - entries_to_create = [] - referral_points_to_create = [] + LeaderboardEntry.objects.all().delete() + ReferralPoints.objects.all().delete() + + # Auto-grant builder status to users with builder contributions but no Builder profile + initial_contributions = list(Contribution.objects.select_related( + 'contribution_type__category' + ).values( + 'user_id', + 'contribution_type__slug', + 'contribution_type__category__slug', + 'contribution_date' + )) - for user_id, user_contribs in user_contributions.items(): - qualified_leaderboards = [] + initial_builders_set = set(Builder.objects.values_list('user_id', flat=True)) + user_builder_contribs = defaultdict(list) + for contrib in initial_contributions: + if (contrib['contribution_type__category__slug'] == 'builder' and + contrib['contribution_type__slug'] not in ['builder-welcome', 'builder']): + user_builder_contribs[contrib['user_id']].append(contrib['contribution_date']) + + for user_id, dates in user_builder_contribs.items(): + if user_id not in initial_builders_set: + try: + user = User.objects.get(id=user_id) + earliest_date = min(dates) + ensure_builder_status(user, earliest_date) + except User.DoesNotExist: + pass + + # Load all contribution data (including newly created) + contributions = list(Contribution.objects.select_related( + 'contribution_type__category' + ).values( + 'id', + 'user_id', + 'user__referred_by_id', + 'user__visible', + 'contribution_type__slug', + 'contribution_type__category__slug', + 'contribution_date', + 'frozen_global_points' + )) - if user_id in validators_set: - qualified_leaderboards.append('validator') + builders_set = set(Builder.objects.values_list('user_id', flat=True)) + validators_set = set(Validator.objects.values_list('user_id', flat=True)) - if user_id in builders_set: - qualified_leaderboards.append('builder') + user_contributions = defaultdict(list) + for contrib in contributions: + user_contributions[contrib['user_id']].append(contrib) - if 'validator-waitlist' in user_badges[user_id] and user_id not in validators_set: - qualified_leaderboards.append('validator-waitlist') + referrer_contributions = defaultdict(list) + for contrib in contributions: + if contrib['user__referred_by_id']: + referrer_contributions[contrib['user__referred_by_id']].append(contrib) - if 'validator-waitlist' in user_badges[user_id] and user_id in validators_set: - qualified_leaderboards.append('validator-waitlist-graduation') + user_badges = defaultdict(set) + for contrib in contributions: + user_badges[contrib['user_id']].add(contrib['contribution_type__slug']) - for leaderboard_type in qualified_leaderboards: - points = 0 - graduation_date = None + users_eligible_for_referrals = set() + for contrib in contributions: + if contrib['contribution_type__slug'] not in ['builder-welcome', 'validator-waitlist']: + users_eligible_for_referrals.add(contrib['user_id']) - if leaderboard_type == 'validator': - for contrib in user_contribs: - if contrib['contribution_type__category__slug'] == 'validator': - points += contrib['frozen_global_points'] or 0 + entries_to_create = [] + referral_points_to_create = [] - elif leaderboard_type == 'builder': - for contrib in user_contribs: - if contrib['contribution_type__category__slug'] == 'builder': - points += contrib['frozen_global_points'] or 0 + for user_id, user_contribs in user_contributions.items(): + qualified_leaderboards = [] - elif leaderboard_type == 'validator-waitlist': - for contrib in user_contribs: - if (contrib['contribution_type__category__slug'] == 'validator' and - contrib['contribution_type__slug'] != 'validator'): - points += contrib['frozen_global_points'] or 0 + if user_id in validators_set: + qualified_leaderboards.append('validator') - if user_id in referrer_contributions: - builder_referral = 0 - validator_referral = 0 + if user_id in builders_set: + qualified_leaderboards.append('builder') - for referred_contrib in referrer_contributions[user_id]: - referred_user_id = referred_contrib['user_id'] - category = referred_contrib['contribution_type__category__slug'] - contrib_points = referred_contrib['frozen_global_points'] or 0 + if 'validator-waitlist' in user_badges[user_id] and user_id not in validators_set: + qualified_leaderboards.append('validator-waitlist') - if referred_user_id in users_eligible_for_referrals: - if category == 'builder': - builder_referral += int(contrib_points * 0.1) - elif category == 'validator': - validator_referral += int(contrib_points * 0.1) + if 'validator-waitlist' in user_badges[user_id] and user_id in validators_set: + qualified_leaderboards.append('validator-waitlist-graduation') - points += builder_referral + validator_referral + for leaderboard_type in qualified_leaderboards: + points = 0 + graduation_date = None - elif leaderboard_type == 'validator-waitlist-graduation': - if user_id in existing_graduations: - points = existing_graduations[user_id]['points'] - graduation_date = existing_graduations[user_id]['graduation_date'] - else: - grad_date = None + if leaderboard_type == 'validator': for contrib in user_contribs: - if contrib['contribution_type__slug'] == 'validator': - contrib_date = contrib['contribution_date'] - if grad_date is None or contrib_date < grad_date: - grad_date = contrib_date + if contrib['contribution_type__category__slug'] == 'validator': + points += contrib['frozen_global_points'] or 0 - graduation_date = grad_date + elif leaderboard_type == 'builder': + for contrib in user_contribs: + if contrib['contribution_type__category__slug'] == 'builder': + points += contrib['frozen_global_points'] or 0 - if grad_date is not None: + elif leaderboard_type == 'validator-waitlist': + for contrib in user_contribs: + if (contrib['contribution_type__category__slug'] == 'validator' and + contrib['contribution_type__slug'] != 'validator'): + points += contrib['frozen_global_points'] or 0 + + if user_id in referrer_contributions: + builder_referral = 0 + validator_referral = 0 + + for referred_contrib in referrer_contributions[user_id]: + referred_user_id = referred_contrib['user_id'] + category = referred_contrib['contribution_type__category__slug'] + contrib_points = referred_contrib['frozen_global_points'] or 0 + + if referred_user_id in users_eligible_for_referrals: + if category == 'builder': + builder_referral += int(contrib_points * 0.1) + elif category == 'validator': + validator_referral += int(contrib_points * 0.1) + + points += builder_referral + validator_referral + + elif leaderboard_type == 'validator-waitlist-graduation': + if user_id in existing_graduations: + points = existing_graduations[user_id]['points'] + graduation_date = existing_graduations[user_id]['graduation_date'] + else: + grad_date = None for contrib in user_contribs: - if contrib['contribution_type__category__slug'] == 'validator': - if (contrib['contribution_date'] <= grad_date and - contrib['contribution_type__slug'] != 'validator'): - points += contrib['frozen_global_points'] or 0 - - if user_id in referrer_contributions: - builder_referral = 0 - validator_referral = 0 - - for referred_contrib in referrer_contributions[user_id]: - if referred_contrib['contribution_date'] <= grad_date: - referred_user_id = referred_contrib['user_id'] - category = referred_contrib['contribution_type__category__slug'] - contrib_points = referred_contrib['frozen_global_points'] or 0 - - if referred_user_id in users_eligible_for_referrals: - if category == 'builder': - builder_referral += int(contrib_points * 0.1) - elif category == 'validator': - validator_referral += int(contrib_points * 0.1) - - points += builder_referral + validator_referral - - # Create entry - entries_to_create.append(LeaderboardEntry( - user_id=user_id, - type=leaderboard_type, - total_points=points, - graduation_date=graduation_date + if contrib['contribution_type__slug'] == 'validator': + contrib_date = contrib['contribution_date'] + if grad_date is None or contrib_date < grad_date: + grad_date = contrib_date + + graduation_date = grad_date + + if grad_date is not None: + for contrib in user_contribs: + if contrib['contribution_type__category__slug'] == 'validator': + if (contrib['contribution_date'] <= grad_date and + contrib['contribution_type__slug'] != 'validator'): + points += contrib['frozen_global_points'] or 0 + + if user_id in referrer_contributions: + builder_referral = 0 + validator_referral = 0 + + for referred_contrib in referrer_contributions[user_id]: + if referred_contrib['contribution_date'] <= grad_date: + referred_user_id = referred_contrib['user_id'] + category = referred_contrib['contribution_type__category__slug'] + contrib_points = referred_contrib['frozen_global_points'] or 0 + + if referred_user_id in users_eligible_for_referrals: + if category == 'builder': + builder_referral += int(contrib_points * 0.1) + elif category == 'validator': + validator_referral += int(contrib_points * 0.1) + + points += builder_referral + validator_referral + + # Create entry + entries_to_create.append(LeaderboardEntry( + user_id=user_id, + type=leaderboard_type, + total_points=points, + graduation_date=graduation_date + )) + + for referrer_id, referred_contribs in referrer_contributions.items(): + builder_points = 0 + validator_points = 0 + + for contrib in referred_contribs: + referred_user_id = contrib['user_id'] + category = contrib['contribution_type__category__slug'] + contrib_points = contrib['frozen_global_points'] or 0 + + if referred_user_id in users_eligible_for_referrals: + if category == 'builder': + builder_points += int(contrib_points * 0.1) + elif category == 'validator': + validator_points += int(contrib_points * 0.1) + + referral_points_to_create.append(ReferralPoints( + user_id=referrer_id, + builder_points=builder_points, + validator_points=validator_points )) - for referrer_id, referred_contribs in referrer_contributions.items(): - builder_points = 0 - validator_points = 0 - - for contrib in referred_contribs: - referred_user_id = contrib['user_id'] - category = contrib['contribution_type__category__slug'] - contrib_points = contrib['frozen_global_points'] or 0 - - if referred_user_id in users_eligible_for_referrals: - if category == 'builder': - builder_points += int(contrib_points * 0.1) - elif category == 'validator': - validator_points += int(contrib_points * 0.1) - - referral_points_to_create.append(ReferralPoints( - user_id=referrer_id, - builder_points=builder_points, - validator_points=validator_points - )) - - LeaderboardEntry.objects.bulk_create(entries_to_create, batch_size=500) - ReferralPoints.objects.bulk_create(referral_points_to_create, batch_size=500) + LeaderboardEntry.objects.bulk_create(entries_to_create, batch_size=500) + ReferralPoints.objects.bulk_create(referral_points_to_create, batch_size=500) - for leaderboard_type in ['validator', 'builder', 'validator-waitlist', 'validator-waitlist-graduation']: - LeaderboardEntry.update_leaderboard_ranks(leaderboard_type) + for leaderboard_type in ['validator', 'builder', 'validator-waitlist', 'validator-waitlist-graduation']: + LeaderboardEntry.update_leaderboard_ranks(leaderboard_type) - return f"Recalculated {len(user_contributions)} users across {len(LEADERBOARD_CONFIG)} leaderboards with {len(referral_points_to_create)} referrers" \ No newline at end of file + return f"Recalculated {len(user_contributions)} users across {len(LEADERBOARD_CONFIG)} leaderboards with {len(referral_points_to_create)} referrers" + finally: + post_save.connect(update_leaderboard_on_builder_creation, sender=Builder) \ No newline at end of file diff --git a/backend/users/views.py b/backend/users/views.py index 0e0a8ef..6eff19e 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -548,6 +548,13 @@ def complete_builder_journey(self, request): Builder.objects.create(user=user) builder_created = True + # Ensure leaderboard is updated with fresh user state after transaction commits. + # The post_save signal on Contribution fires before the Builder profile exists, + # so we explicitly recalculate here with a fresh user that has the Builder relation. + from leaderboard.models import update_user_leaderboard_entries + fresh_user = type(user).objects.get(pk=user.pk) + update_user_leaderboard_entries(fresh_user) + # Transaction successful, return response serializer = self.get_serializer(user) return Response({