From f7de9189bba788a41a9805fb3899a09816d736c9 Mon Sep 17 00:00:00 2001 From: Ali Salman Date: Mon, 20 Nov 2023 18:03:06 +0500 Subject: [PATCH] fix: add static models for retrieving leaderboard data --- lms/djangoapps/badges/api/serializers.py | 43 +++++++----- lms/djangoapps/badges/api/views.py | 54 +-------------- lms/djangoapps/badges/handlers.py | 48 ++++++++++++- .../management/commands/update_leaderboard.py | 68 +++++++++++++++++++ ...derboardconfiguration_leaderboardentry.py} | 13 +++- lms/djangoapps/badges/models.py | 34 ++++++++++ lms/djangoapps/badges/utils.py | 16 +++++ 7 files changed, 204 insertions(+), 72 deletions(-) create mode 100644 lms/djangoapps/badges/management/commands/update_leaderboard.py rename lms/djangoapps/badges/migrations/{0005_leaderboardconfiguration.py => 0005_leaderboardconfiguration_leaderboardentry.py} (64%) diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py index d110d16f6d6e..9013c071d96f 100644 --- a/lms/djangoapps/badges/api/serializers.py +++ b/lms/djangoapps/badges/api/serializers.py @@ -6,11 +6,12 @@ from rest_framework import serializers from django.contrib.auth import get_user_model -from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass +from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass, LeaderboardEntry from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user User = get_user_model() + class BadgeClassSerializer(serializers.ModelSerializer): """ Serializer for BadgeClass model. @@ -38,26 +39,32 @@ class BadgeUserSerializer(serializers.ModelSerializer): Serializer for the BadgeAssertion model. """ name = serializers.CharField(source='profile.name') + profile_image_url = serializers.SerializerMethodField() + + def get_profile_image_url(self, instance): + """ + Get the profile image URL for the given user instance. + + Args: + instance: The instance of the model representing the user. + Returns: + str: The profile image URL. + + """ + return get_profile_image_urls_for_user(instance)['medium'] + class Meta: model = User - fields = ('username', 'name') - - def to_representation(self, instance): - data = super().to_representation(instance) - data['profile_image_url'] = get_profile_image_urls_for_user(instance)['medium'] - return data + fields = ('username', 'name', 'profile_image_url') -class UserLeaderboardSerializer(serializers.Serializer): - user = BadgeUserSerializer() - badge_count = serializers.IntegerField() - course_badge_count = serializers.IntegerField() - event_badge_count = serializers.IntegerField() - score = serializers.IntegerField() - badges = BadgeAssertionSerializer(many=True) - - def to_representation(self, instance): - data = super().to_representation(instance) - return data +class UserLeaderboardSerializer(serializers.ModelSerializer): + """ + Serializer for the BadgeAssertion model. + """ + user = BadgeUserSerializer(read_only=True) + class Meta: + model = LeaderboardEntry + fields = '__all__' diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py index 6130ab17d4a9..7c3d690f32ea 100644 --- a/lms/djangoapps/badges/api/views.py +++ b/lms/djangoapps/badges/api/views.py @@ -16,10 +16,9 @@ from django.db.models import Count, Case, When, Value, IntegerField, Sum from django.utils.translation import gettext as _ -from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardConfiguration +from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardEntry from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser -from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user from .serializers import BadgeAssertionSerializer, UserLeaderboardSerializer @@ -150,53 +149,4 @@ class LeaderboardView(generics.ListAPIView): Leaderboard List API View """ serializer_class = UserLeaderboardSerializer - - def get_queryset(self): - """ - leaderboard queryset - """ - leaderboard_conf = LeaderboardConfiguration.current() - - if leaderboard_conf and leaderboard_conf.enabled: - course_badge_score = leaderboard_conf.course_badge_score - event_badge_score = leaderboard_conf.event_badge_score - else: - course_badge_score = LeaderboardConfiguration.COURSE_BADGE_SCORE - event_badge_score = LeaderboardConfiguration.EVENT_BADGE_SCORE - - leaderboard_data = ( - BadgeAssertion.objects - .values('user__username', 'badge_class__issuing_component') - .annotate( - points=Case( - When(badge_class__issuing_component='', then=Value(course_badge_score)), - When(badge_class__issuing_component='openedx__course', then=Value(event_badge_score)), - default=Value(0), - output_field=IntegerField() - ) - ).values('user__username') - .annotate(score=Sum('points')) - .order_by('-score') - ) - - formatted_data = [] - for entry in leaderboard_data: - badges = ( - BadgeAssertion.objects - .filter(user__username=entry['user__username']) - .order_by('-created') - ) - badge_count = badges.count() - event_badge_count = badges.filter(badge_class__issuing_component='openedx__course').count() - course_badge_count = badge_count - event_badge_count - - formatted_data.append({ - 'user': badges[0].user, - 'badge_count':badge_count, - 'event_badge_count': event_badge_count, - 'course_badge_count': course_badge_count, - 'score': entry['score'], - 'badges': list(badges), - }) - - return formatted_data + queryset = LeaderboardEntry.objects.all().order_by('-score') diff --git a/lms/djangoapps/badges/handlers.py b/lms/djangoapps/badges/handlers.py index d65448128a32..923271b5560f 100644 --- a/lms/djangoapps/badges/handlers.py +++ b/lms/djangoapps/badges/handlers.py @@ -4,11 +4,14 @@ from django.dispatch import receiver +from django.db.models import F +from django.db.models.signals import post_save from common.djangoapps.student.models import EnrollStatusChange from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE from lms.djangoapps.badges.events.course_meta import award_enrollment_badge -from lms.djangoapps.badges.utils import badges_enabled +from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardConfiguration, LeaderboardEntry +from lms.djangoapps.badges.utils import badges_enabled, calculate_score @receiver(ENROLL_STATUS_CHANGE) @@ -18,3 +21,46 @@ def award_badge_on_enrollment(sender, event=None, user=None, **kwargs): # pylin """ if badges_enabled and event == EnrollStatusChange.enroll: award_enrollment_badge(user) + + +@receiver(post_save, sender=BadgeAssertion) +def update_leaderboard_entry(sender, instance, **kwargs): + """ + Update or create a leaderboard entry when a BadgeAssertion is saved. + """ + user = instance.user + badges = BadgeAssertion.objects.filter(user=user) + + course_badge_score, event_badge_score = LeaderboardConfiguration.get_current_or_default_values() + + course_badge_count = badges.filter(badge_class__issuing_component='').count() + event_badge_count = badges.filter(badge_class__issuing_component='openedx__course').count() + + leaderboard_entry, created = LeaderboardEntry.objects.get_or_create(user=user) + leaderboard_entry.badge_count = badges.count() + leaderboard_entry.event_badge_count = event_badge_count + leaderboard_entry.course_badge_count = course_badge_count + + leaderboard_entry.score = calculate_score( + course_badge_score, + event_badge_score, + course_badge_count, + event_badge_count + ) + + leaderboard_entry.save() + + +@receiver(post_save, sender=LeaderboardConfiguration) +def update_leaderboard_scores(sender, instance, **kwargs): + """ + Update scores for all entries when LeaderboardConfiguration is updated + """ + leaderboard_entries = LeaderboardEntry.objects.all() + course_badge_score, event_badge_score = instance.course_badge_score, instance.event_badge_score + if not instance.enabled: + course_badge_score, event_badge_score = instance.COURSE_BADGE_SCORE, instance.EVENT_BADGE_SCORE + + leaderboard_entries.update( + score=F('course_badge_count') * course_badge_score + F('event_badge_count') * event_badge_score + ) diff --git a/lms/djangoapps/badges/management/commands/update_leaderboard.py b/lms/djangoapps/badges/management/commands/update_leaderboard.py new file mode 100644 index 000000000000..e57eb77d9c59 --- /dev/null +++ b/lms/djangoapps/badges/management/commands/update_leaderboard.py @@ -0,0 +1,68 @@ +import logging +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db.models import Sum, Case, When, Value, IntegerField, Count +from lms.djangoapps.badges.utils import calculate_score +from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardConfiguration, LeaderboardEntry + +logger = logging.getLogger(__name__) # pylint: disable=invalid-name +User = get_user_model() + + +class Command(BaseCommand): + """ + Command to populate or update leaderboard entries + Example: + ./manage.py lms update_leaderboard + """ + help = 'Populate or update leaderboard entries' + + def get_leaderboard_data(self): + """ + Get leaderboard data from BadgeAssertion model. + + Returns: + QuerySet: A queryset containing aggregated leaderboard data. + """ + leaderboard_data = ( + BadgeAssertion.objects + .values('user__id', 'badge_class__issuing_component') + .annotate( + is_course_badge=Case( + When(badge_class__issuing_component='', then=Value(1)), + default=Value(0), + output_field=IntegerField() + ), + is_event_badge=Case( + When(badge_class__issuing_component='openedx__course', then=Value(1)), + default=Value(0), + output_field=IntegerField() + ) + ).values('user__id') + .annotate(badge_count=Count('id'), course_badge_count=Sum('is_course_badge'), event_badge_count=Sum('is_event_badge')) + ) + + return leaderboard_data + + def populate_or_update_leaderboard_entries(self): + """ + Populate or create leaderboard entries based on BadgeAssertion data. + """ + leaderboard_data = self.get_leaderboard_data() + course_badge_score, event_badge_score = LeaderboardConfiguration.get_current_or_default_values() + + for entry in leaderboard_data: + user_id = entry['user__id'] + score = calculate_score(course_badge_score, event_badge_score, entry['course_badge_count'], entry['event_badge_count']) + + LeaderboardEntry.objects.update_or_create( + user_id=user_id, + badge_count=entry['badge_count'], + course_badge_count=entry['course_badge_count'], + event_badge_count=entry['event_badge_count'], + score=score, + ) + + def handle(self, *args, **options): + self.populate_or_update_leaderboard_entries() + logger.info('Successfully updated leaderboard entries') diff --git a/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py b/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration_leaderboardentry.py similarity index 64% rename from lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py rename to lms/djangoapps/badges/migrations/0005_leaderboardconfiguration_leaderboardentry.py index 8be28019ebdb..65a29e0c3c3f 100644 --- a/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py +++ b/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration_leaderboardentry.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.21 on 2023-11-19 18:58 +# Generated by Django 3.2.21 on 2023-11-20 12:45 from django.conf import settings from django.db import migrations, models @@ -13,6 +13,17 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='LeaderboardEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('badge_count', models.IntegerField(default=0)), + ('event_badge_count', models.IntegerField(default=0)), + ('course_badge_count', models.IntegerField(default=0)), + ('score', models.IntegerField(default=0)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='LeaderboardConfiguration', fields=[ diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index b7bb0c400c3d..243e0b3e1661 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -352,5 +352,39 @@ class LeaderboardConfiguration(ConfigurationModel): default=EVENT_BADGE_SCORE, ) + @classmethod + def get_current_or_default_values(cls): + """ + Get the current or default values for course and event badge scores. + + Returns: + Tuple[int, int]: A tuple containing the current or default values for + course badge score and event badge score, respectively. + """ + leaderboard_conf = cls.current() + + if leaderboard_conf and leaderboard_conf.enabled: + course_badge_score = leaderboard_conf.course_badge_score + event_badge_score = leaderboard_conf.event_badge_score + else: + course_badge_score = LeaderboardConfiguration.COURSE_BADGE_SCORE + event_badge_score = LeaderboardConfiguration.EVENT_BADGE_SCORE + + return course_badge_score, event_badge_score + class Meta: app_label = "badges" + + +class LeaderboardEntry(models.Model): + """ + Model for storing pre-calculated scores for users + """ + user = models.OneToOneField(User, on_delete=models.CASCADE, unique=True) + badge_count = models.IntegerField(default=0) + event_badge_count = models.IntegerField(default=0) + course_badge_count = models.IntegerField(default=0) + score = models.IntegerField(default=0) + + def __str__(self): + return f"LeaderboardEntry for {self.user.username}" diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py index 150834e04cdb..6ed7f078d8b9 100644 --- a/lms/djangoapps/badges/utils.py +++ b/lms/djangoapps/badges/utils.py @@ -46,3 +46,19 @@ def deserialize_count_specs(text): specs = text.splitlines() specs = [line.split(',') for line in specs if line.strip()] return {int(num): slug.strip().lower() for num, slug in specs} + + +def calculate_score(course_badge_score, event_badge_score, course_badge_count, event_badge_count): + """ + Calculate the total score for a user based on the provided scores and counts. + + Args: + course_badge_score (int): The score assigned to each course completion badge. + event_badge_score (int): The score assigned to each event badge (program badge). + course_badge_count (int): The count of course completion badges earned by the user. + event_badge_count (int): The count of event badges (program badges) earned by the user. + + Returns: + int: The calculated total score for the user. + """ + return course_badge_score * course_badge_count + event_badge_score * event_badge_count