Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: leaderboard apis and design #455

Merged
merged 1 commit into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading