Skip to content

Commit

Permalink
Add leaderboard and overview of achievements (#3686)
Browse files Browse the repository at this point in the history
* Add leaderboard for overall score

* Update achievement score, make percentage cached

* Update achievement_score

* Implement pagination achievements

* Fix tests
  • Loading branch information
jonasdeluna authored Jan 29, 2025
1 parent 47454c3 commit 36170fa
Show file tree
Hide file tree
Showing 9 changed files with 140 additions and 0 deletions.
4 changes: 4 additions & 0 deletions lego/api/v1.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import include, path
from rest_framework import routers

from lego.apps.achievements.views import LeaderBoardViewSet
from lego.apps.articles.views import ArticlesViewSet
from lego.apps.comments.views import CommentViewSet
from lego.apps.companies.views import (
Expand Down Expand Up @@ -81,6 +82,9 @@
from lego.utils.views import SiteMetaViewSet

router = routers.DefaultRouter()
router.register(
r"achievements/leaderboard", LeaderBoardViewSet, basename="achievements"
)
router.register(r"announcements", AnnouncementViewSet, basename="announcements")
router.register(r"articles", ArticlesViewSet)
router.register(r"bdb", AdminCompanyViewSet, basename="bdb")
Expand Down
1 change: 1 addition & 0 deletions lego/apps/achievements/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Achievement(TypedDict):

AchievementCollection = dict[str, Achievement]

# Remember to update rarity list in /utils/calculation_utils.py when adding new achievement

EVENT_IDENTIFIER = "event_count"
EVENT_RANK_IDENTIFIER = "event_rank"
Expand Down
8 changes: 8 additions & 0 deletions lego/apps/achievements/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.db import models

from lego.apps.achievements.utils.calculation_utils import calculate_user_rank
from lego.apps.users.models import User
from lego.utils.models import BasisModel

Expand All @@ -26,6 +27,13 @@ def percentage(self):
)
return (achievement_users / total_users) * 100

def save(self, *args, **kwargs):
super().save(*args, **kwargs)

# Recalculate and update the user's achievement score
self.user.achievements_score = calculate_user_rank(self.user)
self.user.save(update_fields=["achievements_score"])

class Meta:
constraints = [
models.UniqueConstraint(
Expand Down
7 changes: 7 additions & 0 deletions lego/apps/achievements/pagination.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from lego.utils.pagination import CursorPagination


class AchievementLeaderboardPagination(CursorPagination):
page_size = 25
max_page_size = 25
ordering = "-achievements_score"
41 changes: 41 additions & 0 deletions lego/apps/achievements/utils/calculation_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from lego.apps.achievements.constants import (
EVENT_IDENTIFIER,
EVENT_PRICE_IDENTIFIER,
EVENT_RANK_IDENTIFIER,
MEETING_IDENTIFIER,
PENALTY_IDENTIFIER,
POLL_IDENTIFIER,
QUOTE_IDENTIFIER,
)

ACHIEVEMENT_RARITIES = {
EVENT_IDENTIFIER: [0, 1, 2, 3, 4, 6],
EVENT_RANK_IDENTIFIER: [7, 8, 9],
QUOTE_IDENTIFIER: [1],
EVENT_PRICE_IDENTIFIER: [2, 3, 5],
MEETING_IDENTIFIER: [1],
POLL_IDENTIFIER: [0, 2, 4],
PENALTY_IDENTIFIER: [0, 3, 5, 6],
}

delta = 0.1
# Remember to update this rarity list when adding new achievement
MAX_POSSIBLE_SCORE = sum(
max(rarity_list) + 1 + (max(rarity_list) * delta)
for key, rarity_list in ACHIEVEMENT_RARITIES.items()
if key != EVENT_RANK_IDENTIFIER
)


def calculate_user_rank(user):
score = 0.0
if not user.achievements.exists():
return 0
user_achievements = user.achievements.all()
for achievement in user_achievements:
rarity_list = ACHIEVEMENT_RARITIES.get(achievement.identifier, [])

value = rarity_list[achievement.level]
score += value + 1 + (achievement.level * delta)

return score if MAX_POSSIBLE_SCORE else 0
28 changes: 28 additions & 0 deletions lego/apps/achievements/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from rest_framework import mixins, permissions, viewsets
from rest_framework.response import Response

from lego.apps.achievements.pagination import AchievementLeaderboardPagination
from lego.apps.users.models import User
from lego.apps.users.serializers.users import PublicUserWithGroupsSerializer


class LeaderBoardViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
serializer_class = PublicUserWithGroupsSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = AchievementLeaderboardPagination

def get_queryset(self):
return (
User.objects.filter(achievements__isnull=False)
.order_by("-achievements_score")
.distinct()
)

def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
28 changes: 28 additions & 0 deletions lego/apps/users/migrations/0045_user_achievement_score.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from django.apps import apps
from django.db import migrations, models


def populate_achievements_score(apps, schema_editor):
from lego.apps.achievements.utils.calculation_utils import calculate_user_rank

User = apps.get_model("users", "User")

for user in User.objects.all():
user.achievements_score = calculate_user_rank(user)
user.save(update_fields=["achievements_score"])


class Migration(migrations.Migration):

dependencies = [
("users", "0044_alter_membership_role_alter_membershiphistory_role"),
]

operations = [
migrations.AddField(
model_name="user",
name="achievements_score",
field=models.FloatField(default=0),
),
migrations.RunPython(populate_achievements_score),
]
10 changes: 10 additions & 0 deletions lego/apps/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ class User(
help_text="Enter a valid LinkedIn ID.",
validators=[linkedin_id_validator, ReservedNameValidator()],
)
achievements_score = models.FloatField(default=0, null=False, blank=False)

objects = AbakusUserManager() # type: ignore

Expand Down Expand Up @@ -421,6 +422,15 @@ def delete(self, using=None, force=False):
event.unregister(event.registrations.get(user=self))
super(User, self).delete(using=using, force=force)

def save(self, *args, **kwargs):
if self.pk:
from lego.apps.achievements.utils.calculation_utils import (
calculate_user_rank,
)

self.achievements_score = calculate_user_rank(self)
super().save(*args, **kwargs)

@property
def full_name(self):
return self.get_full_name()
Expand Down
13 changes: 13 additions & 0 deletions lego/apps/users/serializers/users.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from rest_framework import exceptions, serializers

from lego.apps.achievements.serializers import AchievementSerializer
from lego.apps.achievements.utils.calculation_utils import MAX_POSSIBLE_SCORE
from lego.apps.email.serializers import PublicEmailListSerializer
from lego.apps.files.fields import ImageField
from lego.apps.ical.models import ICalToken
Expand Down Expand Up @@ -53,12 +54,18 @@ class PublicUserWithGroupsSerializer(PublicUserWithAbakusGroupsSerializer):
past_memberships = PastMembershipSerializer(many=True)
memberships = MembershipSerializer(many=True)
achievements = AchievementSerializer(many=True)
achievements_score = serializers.SerializerMethodField()

def get_achievements_score(self, obj):

return round((obj.achievements_score / MAX_POSSIBLE_SCORE) * 100, 2)

class Meta(PublicUserSerializer.Meta):
fields = PublicUserWithAbakusGroupsSerializer.Meta.fields + ( # type: ignore
"past_memberships",
"memberships",
"achievements",
"achievements_score",
)


Expand Down Expand Up @@ -184,6 +191,7 @@ class CurrentUserSerializer(serializers.ModelSerializer):
abakus_email_lists = PublicEmailListSerializer(many=True)
photo_consents = serializers.SerializerMethodField()
achievements = AchievementSerializer(many=True)
achievements_score = serializers.SerializerMethodField()

def get_user_ical_token(self, user):
ical_token = ICalToken.objects.get_or_create(user=user)[0]
Expand Down Expand Up @@ -217,6 +225,10 @@ def validate_username(self, username):

return username

def get_achievements_score(self, obj):

return round((obj.achievements_score / MAX_POSSIBLE_SCORE) * 100, 2)

class Meta:
model = User
fields = (
Expand Down Expand Up @@ -250,6 +262,7 @@ class Meta:
"github_username",
"linkedin_id",
"achievements",
"achievements_score",
)


Expand Down

0 comments on commit 36170fa

Please sign in to comment.