From a79587e2a0194ff1b81ca814ffee6de65035ca4b Mon Sep 17 00:00:00 2001 From: Ali Salman Date: Mon, 20 Nov 2023 10:07:49 +0500 Subject: [PATCH 1/4] update: add leaderboard apis --- lms/djangoapps/badges/admin.py | 4 +- lms/djangoapps/badges/api/serializers.py | 33 +++++++++ lms/djangoapps/badges/api/urls.py | 4 +- lms/djangoapps/badges/api/views.py | 67 ++++++++++++++++++- .../0005_leaderboardconfiguration.py | 27 ++++++++ lms/djangoapps/badges/models.py | 20 ++++++ lms/static/js/dashboard/badges.js | 1 + 7 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py create mode 100644 lms/static/js/dashboard/badges.js diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py index 096ac6f92ce0..21071ee71059 100644 --- a/lms/djangoapps/badges/admin.py +++ b/lms/djangoapps/badges/admin.py @@ -10,7 +10,8 @@ BadgeAssertion, BadgeClass, CourseCompleteImageConfiguration, - CourseEventBadgesConfiguration + CourseEventBadgesConfiguration, + LeaderboardConfiguration ) admin.site.register(CourseCompleteImageConfiguration) @@ -18,3 +19,4 @@ admin.site.register(BadgeAssertion) # Use the standard Configuration Model Admin handler for this model. admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin) +admin.site.register(LeaderboardConfiguration, ConfigurationModelAdmin) diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py index 2bcdd740eb33..d110d16f6d6e 100644 --- a/lms/djangoapps/badges/api/serializers.py +++ b/lms/djangoapps/badges/api/serializers.py @@ -5,8 +5,11 @@ from rest_framework import serializers +from django.contrib.auth import get_user_model from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user +User = get_user_model() class BadgeClassSerializer(serializers.ModelSerializer): """ @@ -28,3 +31,33 @@ class BadgeAssertionSerializer(serializers.ModelSerializer): class Meta: model = BadgeAssertion fields = ('badge_class', 'image_url', 'assertion_url', 'created') + + +class BadgeUserSerializer(serializers.ModelSerializer): + """ + Serializer for the BadgeAssertion model. + """ + name = serializers.CharField(source='profile.name') + + 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 + + +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 + diff --git a/lms/djangoapps/badges/api/urls.py b/lms/djangoapps/badges/api/urls.py index 95129fafcd1f..cc96ab859adb 100644 --- a/lms/djangoapps/badges/api/urls.py +++ b/lms/djangoapps/badges/api/urls.py @@ -6,9 +6,11 @@ from django.conf import settings from django.urls import re_path -from .views import UserBadgeAssertions +from .views import UserBadgeAssertions, LeaderboardView urlpatterns = [ re_path('^assertions/user/' + settings.USERNAME_PATTERN + '/$', UserBadgeAssertions.as_view(), name='user_assertions'), + + re_path('leaderboard/', LeaderboardView.as_view(), name='leaderboard') ] diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py index b5b5b9cac4d4..6130ab17d4a9 100644 --- a/lms/djangoapps/badges/api/views.py +++ b/lms/djangoapps/badges/api/views.py @@ -10,12 +10,18 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import generics from rest_framework.exceptions import APIException +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.pagination import PageNumberPagination -from lms.djangoapps.badges.models import BadgeAssertion +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 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 +from .serializers import BadgeAssertionSerializer, UserLeaderboardSerializer class InvalidCourseKeyError(APIException): @@ -137,3 +143,60 @@ def get_queryset(self): badge_class__issuing_component=self.request.query_params.get('issuing_component', '') ) return queryset + + +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 diff --git a/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py b/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py new file mode 100644 index 000000000000..8be28019ebdb --- /dev/null +++ b/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.21 on 2023-11-19 18:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('badges', '0004_badgeclass_badgr_server_slug'), + ] + + operations = [ + migrations.CreateModel( + name='LeaderboardConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('course_badge_score', models.IntegerField(default=50, help_text='Set the score for a course-completion badge')), + ('event_badge_score', models.IntegerField(default=50, help_text='Set the score for the event badge i.e program badge')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + ), + ] diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 9af390174985..b7bb0c400c3d 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -334,3 +334,23 @@ def clean_fields(self, exclude=tuple()): class Meta: app_label = "badges" + + +class LeaderboardConfiguration(ConfigurationModel): + """ + Model for configuring scores for courses and events badges + """ + COURSE_BADGE_SCORE = 50 + EVENT_BADGE_SCORE = 50 + + course_badge_score = models.IntegerField( + help_text='Set the score for a course-completion badge', + default=COURSE_BADGE_SCORE, + ) + event_badge_score = models.IntegerField( + help_text='Set the score for the event badge i.e program badge', + default=EVENT_BADGE_SCORE, + ) + + class Meta: + app_label = "badges" diff --git a/lms/static/js/dashboard/badges.js b/lms/static/js/dashboard/badges.js new file mode 100644 index 000000000000..7cdc351e8250 --- /dev/null +++ b/lms/static/js/dashboard/badges.js @@ -0,0 +1 @@ +// update this file in your theme directory From f7de9189bba788a41a9805fb3899a09816d736c9 Mon Sep 17 00:00:00 2001 From: Ali Salman Date: Mon, 20 Nov 2023 18:03:06 +0500 Subject: [PATCH 2/4] 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 From 3850f48d5391eca5abcfa23371b6ace8c2ee326f Mon Sep 17 00:00:00 2001 From: Ali Salman Date: Wed, 22 Nov 2023 13:04:37 +0500 Subject: [PATCH 3/4] feat: update designs and optimize tasks --- lms/djangoapps/badges/handlers.py | 9 +-- lms/djangoapps/badges/models.py | 3 + lms/djangoapps/badges/tasks.py | 28 +++++++ lms/static/images/stars.svg | 19 +++++ lms/static/js/dashboard/badges.js | 1 - lms/static/js/dashboard/leaderboard.js | 85 ++++++++++++++++++++++ lms/static/sass/_build-lms-v1.scss | 3 + lms/static/sass/features/_leaderboard.scss | 84 +++++++++++++++++++++ lms/templates/dashboard.html | 21 ++++++ 9 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 lms/djangoapps/badges/tasks.py create mode 100644 lms/static/images/stars.svg delete mode 100644 lms/static/js/dashboard/badges.js create mode 100644 lms/static/js/dashboard/leaderboard.js create mode 100644 lms/static/sass/features/_leaderboard.scss diff --git a/lms/djangoapps/badges/handlers.py b/lms/djangoapps/badges/handlers.py index 923271b5560f..943c230e36e1 100644 --- a/lms/djangoapps/badges/handlers.py +++ b/lms/djangoapps/badges/handlers.py @@ -12,6 +12,7 @@ from lms.djangoapps.badges.events.course_meta import award_enrollment_badge from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardConfiguration, LeaderboardEntry from lms.djangoapps.badges.utils import badges_enabled, calculate_score +from lms.djangoapps.badges.tasks import update_leaderboard_enties @receiver(ENROLL_STATUS_CHANGE) @@ -55,12 +56,10 @@ def update_leaderboard_entry(sender, instance, **kwargs): def update_leaderboard_scores(sender, instance, **kwargs): """ Update scores for all entries when LeaderboardConfiguration is updated + Intiate a Celery task as the update could be time intensive. """ - 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 - ) + + update_leaderboard_enties.delay(course_badge_score, event_badge_score) diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 243e0b3e1661..7e031528eee1 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -388,3 +388,6 @@ class LeaderboardEntry(models.Model): def __str__(self): return f"LeaderboardEntry for {self.user.username}" + + class Meta: + app_label = "badges" diff --git a/lms/djangoapps/badges/tasks.py b/lms/djangoapps/badges/tasks.py new file mode 100644 index 000000000000..122c83fea636 --- /dev/null +++ b/lms/djangoapps/badges/tasks.py @@ -0,0 +1,28 @@ +""" +Defines asynchronous celery task for updateing leaderboard entries +""" +import logging + +from django.db.models import F +from celery import shared_task +from celery_utils.logged_task import LoggedTask +from edx_django_utils.monitoring import set_code_owner_attribute +from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardConfiguration, LeaderboardEntry + + +log = logging.getLogger(__name__) + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def update_leaderboard_enties(course_badge_score, event_badge_score): + """ + Bulk Update scores for all entries in the LeaderboardEntry + """ + leaderboard_entries = LeaderboardEntry.objects.all() + leaderboard_entries.update( + score=F('course_badge_count') * course_badge_score + F('event_badge_count') * event_badge_score + ) + log.info( + f"Updated {leaderboard_entries.count()} enties in the LeaderboardEntry table" + ) diff --git a/lms/static/images/stars.svg b/lms/static/images/stars.svg new file mode 100644 index 000000000000..b46a7af8bd36 --- /dev/null +++ b/lms/static/images/stars.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/lms/static/js/dashboard/badges.js b/lms/static/js/dashboard/badges.js deleted file mode 100644 index 7cdc351e8250..000000000000 --- a/lms/static/js/dashboard/badges.js +++ /dev/null @@ -1 +0,0 @@ -// update this file in your theme directory diff --git a/lms/static/js/dashboard/leaderboard.js b/lms/static/js/dashboard/leaderboard.js new file mode 100644 index 000000000000..d069e4cab835 --- /dev/null +++ b/lms/static/js/dashboard/leaderboard.js @@ -0,0 +1,85 @@ +// Function to fetch data from the API +async function fetchData(url) { + try { + const response = await fetch(url); + const data = await response.json(); + return data; // Assuming the API response is in JSON format + } catch (error) { + console.error('Error fetching data:', error); + } +} + +// Function to render a single user item +function renderUserListItem(data) { + const listItem = document.createElement('li'); + listItem.className = 'user-item'; + + const avatarDiv = document.createElement('div'); + avatarDiv.className = 'avatar'; + const avatarImg = document.createElement('img'); + avatarImg.src = data.user.profile_image_url; + avatarImg.alt = 'User Avatar'; + avatarDiv.appendChild(avatarImg); + + const userInfoDiv = document.createElement('div'); + userInfoDiv.className = 'user-info'; + const userNameDiv = document.createElement('div'); + userNameDiv.className = 'user-name'; + userNameDiv.textContent = data.user.name; + userInfoDiv.appendChild(userNameDiv); + + const userScoreDiv = document.createElement('div'); + userScoreDiv.className = 'user-score'; + userScoreDiv.textContent = data.score; + + listItem.appendChild(avatarDiv); + listItem.appendChild(userInfoDiv); + listItem.appendChild(userScoreDiv); + + return listItem; +} + +// Function to render user list +async function renderUserList() { + const userListElement = document.getElementById('userList'); + let nextPageUrl = '/api/badges/v1/leaderboard/'; + + // Variable to track if data is currently being fetched to avoid multiple simultaneous requests + let fetchingData = false; + + async function fetchAndRenderNextPage() { + fetchingData = true; + + // Fetch the next set of data + if (nextPageUrl){ + const nextPageData = await fetchData(nextPageUrl); + + if (nextPageData.results && Array.isArray(nextPageData.results)) { + nextPageData.results.forEach(user => { + // Create and append list items for the next set of data + const listItem = renderUserListItem(user); + userListElement.appendChild(listItem); + }); + + // Update the next page URL + nextPageUrl = nextPageData.next; + } + + fetchingData = false; + } + } + + // Initial rendering + await fetchAndRenderNextPage(); + + // Add event listener to window scroll + window.addEventListener('scroll', async () => { + // Check if user has scrolled to the bottom + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000 && !fetchingData) { + await fetchAndRenderNextPage(); + } + }); +} + +// Call the function to render the initial user list when the page loads +document.addEventListener('DOMContentLoaded', renderUserList); diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 1171e3d14cdf..e6258836dbb6 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -74,6 +74,9 @@ @import 'features/content-type-gating'; @import 'features/course-duration-limits'; +// sdaia features +@import 'features/leaderboard'; + // search @import 'search/search'; @import 'search/instant-search'; diff --git a/lms/static/sass/features/_leaderboard.scss b/lms/static/sass/features/_leaderboard.scss new file mode 100644 index 000000000000..ee7be22a7628 --- /dev/null +++ b/lms/static/sass/features/_leaderboard.scss @@ -0,0 +1,84 @@ +.leaderboard { + margin: 20px; + background-color: #fff; + border-radius: 8px; + padding: 10px; + border: 1px solid #D9D9D9; + + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + overflow: hidden; + } + + .avatar img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .leaderboard-header { + font-size: 1.1em; + padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #D9D9D9; + + .star-icon { + width: 25%; + } + } + + .header { + list-style: none; + overflow-y: auto; + padding: 0px 10px 0px 10px; + margin: 0px; + + .header-item { + display: flex; + align-items: center; + justify-content: space-between; + color: #7C7C7C; + + .header-info { + flex: 1; + } + } + } + + .user-list { + list-style: none; + max-height: 300px; /* Set your desired maximum height */ + overflow-y: auto; + padding: 0px 10px 0px 10px; + margin: 0px; + + .user-item { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; + + .user-info { + flex: 1; + margin-right: 10px; + margin-left: 10px; + } + + .user-name { + font-weight: 600; + color: #1C355E; + } + + .user-score { + color: #EA6852; + font-size: 1em; /* Adjust font size if necessary */ + } + + } + } +} diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 34358488f9a0..0711cc8c7e71 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -296,6 +296,25 @@

${course_dir}

% endif +
+
+
${_('Leaderboard')}
+ + Leaderboard +
+
    +
  • +
    +
    +
    ${_('name')}
    +
    +
    ${_('score')}
    +
  • +
+
    +
+
+ <%block name="skip_links"> % if settings.FEATURES.get('ENABLE_ANNOUNCEMENTS'): ${_("Skip to list of announcements")} @@ -401,4 +420,6 @@

+ + <%include file="dashboard/_dashboard_entitlement_unenrollment_modal.html"/> From 65ed3de1bd85fadae12ef6a6f8e954122c9bc826 Mon Sep 17 00:00:00 2001 From: Ali Salman Date: Wed, 22 Nov 2023 13:10:30 +0500 Subject: [PATCH 4/4] fix: remove unused imports and update doc strings --- lms/djangoapps/badges/api/serializers.py | 4 ++-- lms/djangoapps/badges/tasks.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py index 9013c071d96f..6af8e7a0c728 100644 --- a/lms/djangoapps/badges/api/serializers.py +++ b/lms/djangoapps/badges/api/serializers.py @@ -36,7 +36,7 @@ class Meta: class BadgeUserSerializer(serializers.ModelSerializer): """ - Serializer for the BadgeAssertion model. + Serializer for the User model. """ name = serializers.CharField(source='profile.name') profile_image_url = serializers.SerializerMethodField() @@ -61,7 +61,7 @@ class Meta: class UserLeaderboardSerializer(serializers.ModelSerializer): """ - Serializer for the BadgeAssertion model. + Serializer for the LeaderboardEntry model. """ user = BadgeUserSerializer(read_only=True) diff --git a/lms/djangoapps/badges/tasks.py b/lms/djangoapps/badges/tasks.py index 122c83fea636..008497f10537 100644 --- a/lms/djangoapps/badges/tasks.py +++ b/lms/djangoapps/badges/tasks.py @@ -7,7 +7,7 @@ from celery import shared_task from celery_utils.logged_task import LoggedTask from edx_django_utils.monitoring import set_code_owner_attribute -from lms.djangoapps.badges.models import BadgeAssertion, LeaderboardConfiguration, LeaderboardEntry +from lms.djangoapps.badges.models import LeaderboardEntry log = logging.getLogger(__name__)