diff --git a/leaderboards/management/commands/refreshleaderboard.py b/leaderboards/management/commands/refreshleaderboard.py index 738d5e9..7e6d6ae 100644 --- a/leaderboards/management/commands/refreshleaderboard.py +++ b/leaderboards/management/commands/refreshleaderboard.py @@ -4,6 +4,7 @@ from leaderboards.enums import LeaderboardAccessType from leaderboards.models import Leaderboard +from leaderboards.services import update_membership from profiles.models import OsuUser @@ -26,7 +27,7 @@ def handle(self, *args, **options): else: users = leaderboard.members.all().values("id") for user_id in tqdm([u["id"] for u in users]): - leaderboard.update_membership(user_id) + update_membership(leaderboard, user_id) self.stdout.write( self.style.SUCCESS( diff --git a/leaderboards/models.py b/leaderboards/models.py index e1fb909..95a1bea 100644 --- a/leaderboards/models.py +++ b/leaderboards/models.py @@ -1,9 +1,7 @@ import typing -from datetime import datetime -from django.db import models, transaction +from django.db import models from django.db.models import Max, Q -from rest_framework.exceptions import PermissionDenied from common.osu.enums import Gamemode from common.osu.utils import calculate_pp_total @@ -115,131 +113,6 @@ def get_top_membership(self): .order_by("-pp") ).first() - def update_membership(self, user_id): - """ - Update a membership for a user_id ensuring all scores that fit the criteria are added - """ - # Dont't update memberships for archived leaderboards - if self.archived: - return - - # Get or create Membership model - try: - membership = self.memberships.select_for_update().get(user_id=user_id) - # Clear all currently added scores - membership.scores.clear() - join_date = membership.join_date - except Membership.DoesNotExist: - if ( - self.access_type - in ( - LeaderboardAccessType.PUBLIC_INVITE_ONLY, - LeaderboardAccessType.PRIVATE, - ) - and self.owner_id != user_id - ): - # Check if user has been invited - try: - invitees = self.invitees.filter(id=user_id) - except OsuUser.DoesNotExist: - raise PermissionDenied( - "You must be invited to join this leaderboard." - ) - - # Invite is being accepted - self.invitees.remove(*invitees) - - # Create new membership - membership = Membership(user_id=user_id, leaderboard=self) - join_date = datetime.now() - - # Get scores - scores = Score.objects.filter( - user_stats__user_id=user_id, gamemode=self.gamemode - ) - - if not self.allow_past_scores: - scores = scores.filter(date__gte=join_date) - - if self.score_filter: - scores = scores.apply_score_filter(self.score_filter) - - scores = scores.get_score_set(score_set=self.score_set) - membership.score_count = len( - scores - ) # len because we're evaluating the queryset anyway - - # Add scores to membership - if self.score_set == ScoreSet.NORMAL: - membership.pp = calculate_pp_total( - score.performance_total for score in scores - ) - elif self.score_set == ScoreSet.NEVER_CHOKE: - membership.pp = calculate_pp_total( - ( - score.nochoke_performance_total - if score.result & ScoreResult.CHOKE - else score.performance_total - ) - for score in scores - ) - elif self.score_set == ScoreSet.ALWAYS_FULL_COMBO: - membership.pp = calculate_pp_total( - score.nochoke_performance_total for score in scores - ) - - # Fetch rank - membership.rank = self.memberships.filter(pp__gt=membership.pp).count() + 1 - - if self.notification_discord_webhook_url != "": - # Check for new top score - pp_record = self.get_pp_record() - player_top_score = scores.first() - if ( - pp_record is not None - and player_top_score is not None - and player_top_score.performance_total > pp_record - ): - # TODO: fix this being here. needs to be here to avoid a circular import at the moment - from leaderboards.tasks import send_leaderboard_top_score_notification - - # NOTE: need to use a function with default params here so the closure has the correct variables - def send_notification( - leaderboard_id=self.id, - score_id=player_top_score.id, - ): - send_leaderboard_top_score_notification.delay( - leaderboard_id, score_id - ) - - transaction.on_commit(send_notification) - - # Check for new top player - leaderboard_top_player = self.get_top_membership() - if ( - leaderboard_top_player is not None - and leaderboard_top_player.user_id != membership.user_id - and membership.rank == 1 - and membership.pp > 0 - ): - from leaderboards.tasks import send_leaderboard_top_player_notification - - # NOTE: need to use a function with default params here so the closure has the correct variables - def send_notification( - leaderboard_id=self.id, - user_id=membership.user_id, - ): - send_leaderboard_top_player_notification.delay( - leaderboard_id, user_id - ) - - transaction.on_commit(send_notification) - - membership.save() - membership.scores.add(*scores) - - return membership - def update_member_count(self): """ Updates Leaderboard.member_count with the count of Leaderboard.memberships diff --git a/leaderboards/services.py b/leaderboards/services.py index c63d1d8..92e360d 100644 --- a/leaderboards/services.py +++ b/leaderboards/services.py @@ -1,6 +1,11 @@ from django.db import transaction +from rest_framework.exceptions import PermissionDenied -from leaderboards.models import Leaderboard +from common.osu.utils import calculate_pp_total +from leaderboards.enums import LeaderboardAccessType +from leaderboards.models import Leaderboard, Membership, MembershipScore +from profiles.enums import ScoreResult, ScoreSet +from profiles.models import OsuUser, Score, UserStats @transaction.atomic @@ -13,7 +18,7 @@ def create_leaderboard(owner_id, leaderboard): leaderboard.member_count = 1 leaderboard.score_filter.save() leaderboard.save() - leaderboard.update_membership(owner_id) + update_membership(leaderboard, owner_id) return leaderboard @@ -23,7 +28,7 @@ def create_membership(leaderboard_id, user_id): Creates a membership with a community leaderboard and update Leaderboard.member_count """ leaderboard = Leaderboard.community_leaderboards.get(id=leaderboard_id) - membership = leaderboard.update_membership(user_id) + membership = update_membership(leaderboard, user_id) leaderboard.update_member_count() return membership @@ -36,3 +41,135 @@ def delete_membership(membership): membership.delete() membership.leaderboard.update_member_count() return True + + +@transaction.atomic +def update_membership(leaderboard: Leaderboard, user_id: int): + """ + Creates or updates a membership for a given user on a given leaderboard + """ + if leaderboard.archived: + raise ValueError("Cannot update membership on an archived leaderboard") + + try: + membership = leaderboard.memberships.select_for_update().get(user_id=user_id) + except Membership.DoesNotExist: + if ( + leaderboard.access_type + in ( + LeaderboardAccessType.PUBLIC_INVITE_ONLY, + LeaderboardAccessType.PRIVATE, + ) + and leaderboard.owner_id != user_id + ): + # Check if user has been invited + try: + invitees = leaderboard.invitees.filter(id=user_id) + except OsuUser.DoesNotExist: + raise PermissionDenied("You must be invited to join this leaderboard.") + + # Invite is being accepted + leaderboard.invitees.remove(*invitees) + + # Create new membership + membership = Membership.objects.create( + user_id=user_id, + leaderboard=leaderboard, + pp=0, + score_count=0, + rank=leaderboard.member_count + 1, + ) + + try: + user_stats = UserStats.objects.get( + user_id=user_id, gamemode=leaderboard.gamemode + ) + except UserStats.DoesNotExist: + return membership + + if leaderboard.score_filter: + scores = user_stats.scores.apply_score_filter(leaderboard.score_filter) + else: + scores = user_stats.scores.all() + + if not leaderboard.allow_past_scores: + scores = scores.filter(date__gte=membership.join_date) + + scores = scores.get_score_set(score_set=leaderboard.score_set) + + def get_performance_total(score: Score, score_set: ScoreSet): + if score_set == ScoreSet.NORMAL: + return score.performance_total + elif score_set == ScoreSet.NEVER_CHOKE: + return ( + score.nochoke_performance_total + if score.result & ScoreResult.CHOKE + else score.performance_total + ) + elif score_set == ScoreSet.ALWAYS_FULL_COMBO: + return score.nochoke_performance_total + + membership_scores = [ + MembershipScore( + membership=membership, + score=score, + performance_total=get_performance_total( + score, ScoreSet(leaderboard.score_set) + ), + ) + for score in scores + ] + + MembershipScore.objects.bulk_create( + membership_scores, + update_conflicts=True, + update_fields=["performance_total"], + unique_fields=["membership_id", "score_id"], + ) + + membership.score_count = len(membership_scores) + + membership.pp = calculate_pp_total( + score.performance_total for score in membership_scores + ) + + membership.rank = leaderboard.memberships.filter(pp__gt=membership.pp).count() + 1 + + membership.save() + + if leaderboard.notification_discord_webhook_url != "": + # Check for new top score + pp_record = leaderboard.get_pp_record() + player_top_score = scores.first() + if ( + pp_record is not None + and player_top_score is not None + and player_top_score.performance_total > pp_record + ): + # NOTE: need to use a function with default params here so the closure has the correct variables + def send_notification( + leaderboard_id=leaderboard.id, + score_id=player_top_score.id, + ): + send_leaderboard_top_score_notification.delay(leaderboard_id, score_id) + + transaction.on_commit(send_notification) + + # Check for new top player + leaderboard_top_player = leaderboard.get_top_membership() + if ( + leaderboard_top_player is not None + and leaderboard_top_player.user_id != membership.user_id + and membership.rank == 1 + and membership.pp > 0 + ): + # NOTE: need to use a function with default params here so the closure has the correct variables + def send_notification( + leaderboard_id=leaderboard.id, + user_id=membership.user_id, + ): + send_leaderboard_top_player_notification.delay(leaderboard_id, user_id) + + transaction.on_commit(send_notification) + + return membership diff --git a/leaderboards/tasks.py b/leaderboards/tasks.py index c41f1f1..3627819 100644 --- a/leaderboards/tasks.py +++ b/leaderboards/tasks.py @@ -7,16 +7,13 @@ from common.discord_webhook_sender import DiscordWebhookSender from common.osu.enums import Gamemode, Mods -from common.osu.utils import ( - calculate_pp_total, - get_gamemode_string_from_gamemode, - get_mods_string, -) +from common.osu.utils import get_gamemode_string_from_gamemode, get_mods_string from leaderboards.enums import LeaderboardAccessType -from leaderboards.models import Leaderboard, Membership, MembershipScore +from leaderboards.models import Leaderboard, Membership +from leaderboards.services import update_membership from leaderboards.utils import get_leaderboard_type_string_from_leaderboard_access_type -from profiles.enums import ScoreResult, ScoreSet -from profiles.models import Score, UserStats +from profiles.enums import ScoreResult +from profiles.models import Score @shared_task @@ -30,100 +27,9 @@ def update_memberships(user_id, gamemode=Gamemode.STANDARD): ).filter( user_id=user_id, leaderboard__gamemode=gamemode, leaderboard__archived=False ) - user_stats = UserStats.objects.get(user_id=user_id, gamemode=gamemode) for membership in memberships: - leaderboard = membership.leaderboard - if leaderboard.score_filter: - scores = user_stats.scores.apply_score_filter(leaderboard.score_filter) - else: - scores = user_stats.scores.all() - - if not leaderboard.allow_past_scores: - scores = scores.filter(date__gte=membership.join_date) - - scores = scores.get_score_set(score_set=leaderboard.score_set) - - def get_performance_total(score: Score, score_set: ScoreSet): - if score_set == ScoreSet.NORMAL: - return score.performance_total - elif score_set == ScoreSet.NEVER_CHOKE: - return ( - score.nochoke_performance_total - if score.result & ScoreResult.CHOKE - else score.performance_total - ) - elif score_set == ScoreSet.ALWAYS_FULL_COMBO: - return score.nochoke_performance_total - - membership_scores = [ - MembershipScore( - membership=membership, - score=score, - performance_total=get_performance_total( - score, ScoreSet(leaderboard.score_set) - ), - ) - for score in scores - ] - - MembershipScore.objects.bulk_create( - membership_scores, - update_conflicts=True, - update_fields=["performance_total"], - unique_fields=["membership_id", "score_id"], - ) - - membership.score_count = len(membership_scores) - - membership.pp = calculate_pp_total( - score.performance_total for score in membership_scores - ) - - membership.rank = ( - leaderboard.memberships.filter(pp__gt=membership.pp).count() + 1 - ) - - membership.save() - - if leaderboard.notification_discord_webhook_url != "": - # Check for new top score - pp_record = leaderboard.get_pp_record() - player_top_score = scores.first() - if ( - pp_record is not None - and player_top_score is not None - and player_top_score.performance_total > pp_record - ): - # NOTE: need to use a function with default params here so the closure has the correct variables - def send_notification( - leaderboard_id=leaderboard.id, - score_id=player_top_score.id, - ): - send_leaderboard_top_score_notification.delay( - leaderboard_id, score_id - ) - - transaction.on_commit(send_notification) - - # Check for new top player - leaderboard_top_player = leaderboard.get_top_membership() - if ( - leaderboard_top_player is not None - and leaderboard_top_player.user_id != membership.user_id - and membership.rank == 1 - and membership.pp > 0 - ): - # NOTE: need to use a function with default params here so the closure has the correct variables - def send_notification( - leaderboard_id=leaderboard.id, - user_id=membership.user_id, - ): - send_leaderboard_top_player_notification.delay( - leaderboard_id, user_id - ) - - transaction.on_commit(send_notification) + update_membership(membership.leaderboard, user_id) return memberships