Skip to content

Commit

Permalink
Leaderboard group endpoints :) (#321)
Browse files Browse the repository at this point in the history
* Pin to python3.10 and fix pyyaml

* backend work for leaderboard groups

* lol

* return names

* add uritemplate

---------

Co-authored-by: Ada Cooke <[email protected]>
  • Loading branch information
Bentechy66 and 0xAda authored Oct 8, 2023
1 parent eab79e9 commit 99e36cb
Show file tree
Hide file tree
Showing 11 changed files with 1,328 additions and 967 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/library/python:3.9-slim
FROM docker.io/library/python:3.10-slim

ARG BUILD_DEPS="build-essential"

Expand Down
2,171 changes: 1,218 additions & 953 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description = "The Django backend for RACTF."
authors = ["RACTF Admins <[email protected]>"]

[tool.poetry.dependencies]
python = "^3.9"
python = "^3.10"
Django = "^4.0"
django-cors-headers = "^3.11"
django-redis = "^5.2"
Expand All @@ -31,14 +31,15 @@ Twisted = "22.10.0"
channels-redis = "^3.2.0"
requests = "^2.31.0"
django-anymail = {extras = ["amazon_ses", "mailgun", "sendgrid", "console", "mailjet", "mandrill", "postal", "postmark", "sendinblue", "sparkpost"], version = "^8.5"}
uritemplate = "^4.1.1"

[tool.poetry.dev-dependencies]
ipython = "^8.10.0"
coverage = "^5.3.1"
django-stubs = "^1.7.0"
black = "^20.8b1"
djangorestframework-stubs = "^1.3.0"
PyYAML = "^5.4.1"
PyYAML = "6.0.1"
autoflake = "^1.4"
pytest = "^6.2.4"
pytest-cov = "^2.12.0"
Expand Down
2 changes: 1 addition & 1 deletion src/backend/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def get_serializer_class(self):
class AdminListModelViewSet(ModelViewSet):
def get_serializer_class(self):
if self.request is None:
return self.admin_serializer_class
return self.serializer_class
if self.action == "list" and not is_exporting(self.request):
if self.request.user.is_staff and not self.request.user.should_deny_admin():
return self.list_admin_serializer_class
Expand Down
7 changes: 5 additions & 2 deletions src/leaderboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,11 @@ def get_position(self, _) -> int:

class LeaderboardTeamScoreSerializer(serializers.ModelSerializer):
team_name = serializers.ReadOnlyField(source="team.name")
leaderboard_group_name = serializers.ReadOnlyField(source="team.leaderboard_group.name")

class Meta:
model = Score
fields = ["points", "timestamp", "team_name", "reason", "metadata"]
fields = ["points", "timestamp", "team_name", "reason", "metadata", "leaderboard_group_name"]


class LeaderboardUserScoreSerializer(serializers.ModelSerializer):
Expand All @@ -35,9 +36,11 @@ class Meta:


class TeamPointsSerializer(serializers.ModelSerializer):
leaderboard_group_name = serializers.ReadOnlyField(source="leaderboard_group.name")

class Meta:
model = Team
fields = ["name", "id", "leaderboard_points"]
fields = ["name", "id", "leaderboard_points", "leaderboard_group_name"]


class UserPointsSerializer(serializers.ModelSerializer):
Expand Down
17 changes: 16 additions & 1 deletion src/leaderboard/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import time

from django.core.cache import caches
from django.db.models import Window, Q
from django.db.models.functions import RowNumber

from rest_framework.generics import ListAPIView
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
Expand Down Expand Up @@ -55,7 +58,19 @@ def get(self, request, *args, **kwargs):
return FormattedResponse(cached_leaderboard)

graph_members = config.get("graph_members")
top_teams = Team.objects.visible().ranked()[:graph_members]

teams_with_row_numbers = Team.objects.visible().annotate(
row_number=Window(
expression=RowNumber(),
partition_by=['leaderboard_group'],
order_by=["-leaderboard_points", "last_score"]
)
)
top_teams = teams_with_row_numbers.filter(
Q(row_number__lte=graph_members) &
(Q(leaderboard_group__has_own_leaderboard=True) | Q(leaderboard_group__isnull=True))
)

top_users = (
Member
.objects.filter(is_visible=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 4.2.6 on 2023-10-08 17:34

import backend.validators
from django.db import migrations, models
import django.db.models.deletion
import django_prometheus.models


class Migration(migrations.Migration):

dependencies = [
("team", "0004_alter_team_name_team_team_team_username_uniq_idx"),
]

operations = [
migrations.CreateModel(
name="LeaderboardGroup",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=31, unique=True, validators=[backend.validators.printable_name])),
("description", models.TextField(blank=True, max_length=255)),
("is_self_assignable", models.BooleanField(default=True)),
("has_own_leaderboard", models.BooleanField(default=True)),
],
bases=(django_prometheus.models.ExportModelOperationsMixin("leaderboard_group"), models.Model),
),
migrations.AddField(
model_name="team",
name="leaderboard_group",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="teams",
to="team.leaderboardgroup",
),
),
]
12 changes: 11 additions & 1 deletion src/team/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from django.db import models
from django.db.models import CASCADE, Prefetch
from django.db.models import CASCADE, SET_NULL, Prefetch
from django.db.models.functions import Lower
from django.utils import timezone
from django_prometheus.models import ExportModelOperationsMixin
Expand Down Expand Up @@ -29,6 +29,15 @@ def prefetch_solves(self) -> "models.QuerySet[Team]":
return self.prefetch_related(Prefetch("solves", queryset=Solve.objects.filter(correct=True)))


class LeaderboardGroup(ExportModelOperationsMixin("leaderboard_group"), models.Model):
"""Represents a group which teams can assign themselves to."""

name = models.CharField(max_length=31, unique=True, validators=[printable_name])
description = models.TextField(blank=True, max_length=255)
is_self_assignable = models.BooleanField(default=True)
has_own_leaderboard = models.BooleanField(default=True)


class Team(ExportModelOperationsMixin("team"), models.Model):
"""Represents a team of one or more Members."""

Expand All @@ -41,6 +50,7 @@ class Team(ExportModelOperationsMixin("team"), models.Model):
leaderboard_points = models.IntegerField(default=0)
last_score = models.DateTimeField(default=timezone.now)
size_limit_exempt = models.BooleanField(default=False)
leaderboard_group = models.ForeignKey(LeaderboardGroup, on_delete=SET_NULL, related_name="teams", null=True)

objects = TeamQuerySet.as_manager()

Expand Down
24 changes: 20 additions & 4 deletions src/team/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from backend.signals import team_create
from challenge.serializers import SolveSerializer
from member.serializers import MinimalMemberSerializer
from team.models import Team
from team.models import Team, LeaderboardGroup


class SelfTeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
Expand All @@ -30,8 +30,9 @@ class Meta:
"incorrect_solves",
"points",
"leaderboard_points",
"leaderboard_group"
]
read_only_fields = ["id", "is_visible", "incorrect_solves"]
read_only_fields = ["id", "is_visible", "incorrect_solves", "leaderboard_group"]


class TeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
Expand All @@ -52,6 +53,7 @@ class Meta:
"incorrect_solves",
"points",
"leaderboard_points",
"leaderboard_group"
]

def get_incorrect_solves(self, instance):
Expand All @@ -63,7 +65,13 @@ class ListTeamSerializer(serializers.ModelSerializer):

class Meta:
model = Team
fields = ["id", "name", "members"]
fields = ["id", "name", "members", "leaderboard_group"]


class LeaderboardGroupSerializer(serializers.ModelSerializer):
class Meta:
model = LeaderboardGroup
fields = ["id", "name", "description", "is_self_assignable", "has_own_leaderboard"]


class AdminTeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
Expand All @@ -86,6 +94,7 @@ class Meta:
"size_limit_exempt",
"points",
"leaderboard_points",
"leaderboard_group"
]


Expand All @@ -98,18 +107,25 @@ class Meta:
class CreateTeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = ["id", "is_visible", "name", "owner", "password"]
fields = ["id", "is_visible", "name", "owner", "password", "leaderboard_group"]
read_only_fields = ["id", "is_visible", "owner"]

def create(self, validated_data):
try:
name = validated_data["name"]
password = validated_data["password"]
leaderboard_group = validated_data.get("leaderboard_group", None)

if leaderboard_group is not None and not leaderboard_group.is_self_assignable:
raise ValidationError("illegal_leaderboard_group")

team = Team.objects.create(
name=name,
password=password,
owner=self.context["request"].user,
leaderboard_group=leaderboard_group
)

self.context["request"].user.team = team
self.context["request"].user.save()
team_create.send(sender=self.__class__, team=team)
Expand Down
4 changes: 4 additions & 0 deletions src/team/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
router = DefaultRouter()
router.register(r"", views.TeamViewSet, basename="team")

group_router = DefaultRouter()
group_router.register(r"", views.LeaderboardGroupViewSet, basename="groups")

urlpatterns = [
path("self/", views.SelfView.as_view(), name="team-self"),
path("create/", views.CreateTeamView.as_view(), name="team-create"),
path("join/", views.JoinTeamView.as_view(), name="team-join"),
path("leave/", views.LeaveTeamView.as_view(), name="team-leave"),
path("groups/", include(group_router.urls), name="leaderboard-groups"),
path("", include(router.urls), name="team"),
]
14 changes: 12 additions & 2 deletions src/team/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,22 @@
from rest_framework.views import APIView

from backend.exceptions import FormattedException
from backend.permissions import AdminOrReadOnlyVisible, ReadOnlyBot
from backend.permissions import AdminOrReadOnlyVisible, ReadOnlyBot, AdminOrReadOnly
from backend.response import FormattedResponse
from backend.signals import team_join, team_join_attempt, team_join_reject
from backend.viewsets import AdminListModelViewSet
from challenge.models import Solve
from config import config
from member.models import Member
from team.models import Team
from team.models import Team, LeaderboardGroup
from team.permissions import HasTeam, IsTeamOwnerOrReadOnly, TeamsEnabled
from team.serializers import (
AdminTeamSerializer,
CreateTeamSerializer,
ListTeamSerializer,
SelfTeamSerializer,
TeamSerializer,
LeaderboardGroupSerializer
)


Expand All @@ -56,6 +57,15 @@ def get_object(self):
)


class LeaderboardGroupViewSet(AdminListModelViewSet):
permission_classes = (AdminOrReadOnly,)
serializer_class = LeaderboardGroupSerializer
admin_serializer_class = LeaderboardGroupSerializer
list_serializer_class = LeaderboardGroupSerializer
list_admin_serializer_class = LeaderboardGroupSerializer
queryset = LeaderboardGroup.objects.all()


class TeamViewSet(AdminListModelViewSet):
permission_classes = (AdminOrReadOnlyVisible,)
throttle_scope = "team"
Expand Down

0 comments on commit 99e36cb

Please sign in to comment.