Skip to content

Commit

Permalink
fix: add static models for retrieving leaderboard data
Browse files Browse the repository at this point in the history
  • Loading branch information
Ali Salman authored and Ali Salman committed Nov 20, 2023
1 parent a79587e commit f7de918
Show file tree
Hide file tree
Showing 7 changed files with 204 additions and 72 deletions.
43 changes: 25 additions & 18 deletions lms/djangoapps/badges/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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__'
54 changes: 2 additions & 52 deletions lms/djangoapps/badges/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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')
48 changes: 47 additions & 1 deletion lms/djangoapps/badges/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
)
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
@@ -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
Expand All @@ -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=[
Expand Down
34 changes: 34 additions & 0 deletions lms/djangoapps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
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

0 comments on commit f7de918

Please sign in to comment.