diff --git a/PyYAML-6.0.1-cp311-cp311-win_amd64.whl b/PyYAML-6.0.1-cp311-cp311-win_amd64.whl new file mode 100644 index 00000000..5e19319a Binary files /dev/null and b/PyYAML-6.0.1-cp311-cp311-win_amd64.whl differ diff --git a/src/backend/viewsets.py b/src/backend/viewsets.py index 0c755639..722ea748 100644 --- a/src/backend/viewsets.py +++ b/src/backend/viewsets.py @@ -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 diff --git a/src/leaderboard/serializers.py b/src/leaderboard/serializers.py index df972b3b..36e9a191 100644 --- a/src/leaderboard/serializers.py +++ b/src/leaderboard/serializers.py @@ -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): @@ -37,7 +38,7 @@ class Meta: class TeamPointsSerializer(serializers.ModelSerializer): class Meta: model = Team - fields = ["name", "id", "leaderboard_points"] + fields = ["name", "id", "leaderboard_points", "leaderboard_group"] class UserPointsSerializer(serializers.ModelSerializer): diff --git a/src/leaderboard/views.py b/src/leaderboard/views.py index cbe65b39..432b6616 100644 --- a/src/leaderboard/views.py +++ b/src/leaderboard/views.py @@ -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 @@ -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) diff --git a/src/team/migrations/0005_leaderboardgroup_team_leaderboard_group.py b/src/team/migrations/0005_leaderboardgroup_team_leaderboard_group.py new file mode 100644 index 00000000..f8e502e3 --- /dev/null +++ b/src/team/migrations/0005_leaderboardgroup_team_leaderboard_group.py @@ -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", + ), + ), + ] diff --git a/src/team/models.py b/src/team/models.py index 839207cf..a1fae109 100644 --- a/src/team/models.py +++ b/src/team/models.py @@ -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 @@ -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.""" @@ -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() diff --git a/src/team/serializers.py b/src/team/serializers.py index fcde1c4a..0f31d836 100644 --- a/src/team/serializers.py +++ b/src/team/serializers.py @@ -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): @@ -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): @@ -52,6 +53,7 @@ class Meta: "incorrect_solves", "points", "leaderboard_points", + "leaderboard_group" ] def get_incorrect_solves(self, instance): @@ -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 = ["name", "description", "is_self_assignable", "has_own_leaderboard"] class AdminTeamSerializer(IncorrectSolvesMixin, serializers.ModelSerializer): @@ -86,6 +94,7 @@ class Meta: "size_limit_exempt", "points", "leaderboard_points", + "leaderboard_group" ] @@ -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) diff --git a/src/team/urls.py b/src/team/urls.py index d27e23b9..082c21b7 100644 --- a/src/team/urls.py +++ b/src/team/urls.py @@ -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"), ] diff --git a/src/team/views.py b/src/team/views.py index 31f211df..6a37236e 100644 --- a/src/team/views.py +++ b/src/team/views.py @@ -15,7 +15,7 @@ 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 @@ -30,6 +30,7 @@ ListTeamSerializer, SelfTeamSerializer, TeamSerializer, + LeaderboardGroupSerializer ) @@ -56,6 +57,14 @@ 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 + + class TeamViewSet(AdminListModelViewSet): permission_classes = (AdminOrReadOnlyVisible,) throttle_scope = "team"