Skip to content

Commit

Permalink
Merge pull request #455 from edly-io/alisalman/sdaia-leaderboard
Browse files Browse the repository at this point in the history
feat: leaderboard apis and design
  • Loading branch information
Ali-Salman29 authored Nov 23, 2023
2 parents 4d4e482 + 7ee68f0 commit cf00324
Show file tree
Hide file tree
Showing 15 changed files with 527 additions and 6 deletions.
4 changes: 3 additions & 1 deletion lms/djangoapps/badges/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@
BadgeAssertion,
BadgeClass,
CourseCompleteImageConfiguration,
CourseEventBadgesConfiguration
CourseEventBadgesConfiguration,
LeaderboardConfiguration
)

admin.site.register(CourseCompleteImageConfiguration)
admin.site.register(BadgeClass)
admin.site.register(BadgeAssertion)
# Use the standard Configuration Model Admin handler for this model.
admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin)
admin.site.register(LeaderboardConfiguration, ConfigurationModelAdmin)
42 changes: 41 additions & 1 deletion lms/djangoapps/badges/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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__'
4 changes: 3 additions & 1 deletion lms/djangoapps/badges/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
]
17 changes: 15 additions & 2 deletions lms/djangoapps/badges/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
47 changes: 46 additions & 1 deletion lms/djangoapps/badges/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
68 changes: 68 additions & 0 deletions lms/djangoapps/badges/management/commands/update_leaderboard.py
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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')),
],
),
]
57 changes: 57 additions & 0 deletions lms/djangoapps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
28 changes: 28 additions & 0 deletions lms/djangoapps/badges/tasks.py
Original file line number Diff line number Diff line change
@@ -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"
)
16 changes: 16 additions & 0 deletions lms/djangoapps/badges/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit cf00324

Please sign in to comment.