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..6af8e7a0c728 100644 --- a/lms/djangoapps/badges/api/serializers.py +++ b/lms/djangoapps/badges/api/serializers.py @@ -5,7 +5,11 @@ from rest_framework import serializers -from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass +from django.contrib.auth import get_user_model +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): @@ -28,3 +32,39 @@ class BadgeAssertionSerializer(serializers.ModelSerializer): class Meta: model = BadgeAssertion fields = ('badge_class', 'image_url', 'assertion_url', 'created') + + +class BadgeUserSerializer(serializers.ModelSerializer): + """ + Serializer for the User 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', 'profile_image_url') + + +class UserLeaderboardSerializer(serializers.ModelSerializer): + """ + Serializer for the LeaderboardEntry model. + """ + user = BadgeUserSerializer(read_only=True) + + class Meta: + model = LeaderboardEntry + fields = '__all__' 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..7c3d690f32ea 100644 --- a/lms/djangoapps/badges/api/views.py +++ b/lms/djangoapps/badges/api/views.py @@ -10,12 +10,17 @@ 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, LeaderboardEntry from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser -from .serializers import BadgeAssertionSerializer +from .serializers import BadgeAssertionSerializer, UserLeaderboardSerializer class InvalidCourseKeyError(APIException): @@ -137,3 +142,11 @@ 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 + queryset = LeaderboardEntry.objects.all().order_by('-score') diff --git a/lms/djangoapps/badges/handlers.py b/lms/djangoapps/badges/handlers.py index d65448128a32..943c230e36e1 100644 --- a/lms/djangoapps/badges/handlers.py +++ b/lms/djangoapps/badges/handlers.py @@ -4,11 +4,15 @@ 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 +from lms.djangoapps.badges.tasks import update_leaderboard_enties @receiver(ENROLL_STATUS_CHANGE) @@ -18,3 +22,44 @@ 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 + Intiate a Celery task as the update could be time intensive. + """ + 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 + + update_leaderboard_enties.delay(course_badge_score, 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_leaderboardentry.py b/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration_leaderboardentry.py new file mode 100644 index 000000000000..65a29e0c3c3f --- /dev/null +++ b/lms/djangoapps/badges/migrations/0005_leaderboardconfiguration_leaderboardentry.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.21 on 2023-11-20 12:45 + +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='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=[ + ('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..7e031528eee1 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -334,3 +334,60 @@ 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, + ) + + @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}" + + 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..008497f10537 --- /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 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/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 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/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 @@