From 8503f9c5b3e1043ea839bba2de973925af018aee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Mih=C3=A1lik?= Date: Sat, 23 Nov 2024 20:57:15 +0100 Subject: [PATCH 1/2] Freeze results fix --- competition/exceptions.py | 3 - competition/models.py | 26 ++---- competition/results.py | 156 +++++++++++++++++++++++++++++++ competition/serializers.py | 9 +- competition/utils/results.py | 49 ---------- competition/utils/sum_methods.py | 21 ++--- competition/views.py | 105 +++------------------ 7 files changed, 190 insertions(+), 179 deletions(-) delete mode 100644 competition/exceptions.py create mode 100644 competition/results.py delete mode 100644 competition/utils/results.py diff --git a/competition/exceptions.py b/competition/exceptions.py deleted file mode 100644 index c187efde..00000000 --- a/competition/exceptions.py +++ /dev/null @@ -1,3 +0,0 @@ - -class FreezingNotClosedResults(Exception): - """Snažíš sa zamraziť výsledky série, ktorá nemá opravené všetky riešenia""" diff --git a/competition/models.py b/competition/models.py index 7da265ac..d6fc86ac 100644 --- a/competition/models.py +++ b/competition/models.py @@ -1,5 +1,4 @@ import datetime -import json from typing import Optional from django.conf import settings @@ -18,7 +17,6 @@ from base.managers import UnspecifiedValueManager from base.models import RestrictedFileField from base.validators import school_year_validator -from competition.exceptions import FreezingNotClosedResults from competition.querysets import ActiveQuerySet from competition.utils.school_year_manipulation import \ get_school_year_end_by_date @@ -249,11 +247,6 @@ def get_first_series(self) -> 'Series': def get_second_series(self) -> 'Series': return self.series_set.get(order=2) - def freeze_results(self, results): - if any(not series.complete for series in self.series_set.all()): - raise FreezingNotClosedResults() - self.frozen_results = json.dumps(results) - @property def complete(self) -> bool: return self.frozen_results is not None @@ -363,14 +356,6 @@ def get_actual_late_flag(self) -> Optional[LateTag]: .order_by('upper_bound')\ .first() - def freeze_results(self, results): - if any( - problem.num_solutions != problem.num_corrected_solutions - for problem in self.problems.all() - ): - raise FreezingNotClosedResults() - self.frozen_results = json.dumps(results) - @property def num_problems(self) -> int: return self.problems.count() @@ -418,7 +403,8 @@ class Meta: def __str__(self): return f'{self.series.semester.competition.name}-{self.series.semester.year}' \ - f'-{self.series.semester.season[0]}S-S{self.series.order} - {self.order}. úloha' + f'-{self.series.semester.season[0] + }S-S{self.series.order} - {self.order}. úloha' def get_stats(self): stats = {} @@ -607,7 +593,7 @@ def get_registration_by_profile_and_event(profile, event): return registration def __str__(self): - return f'{ self.profile.user.get_full_name() } @ { self.event }' + return f'{self.profile.user.get_full_name()} @ {self.event}' def can_user_modify(self, user): return self.event.can_user_modify(user) @@ -674,18 +660,18 @@ class Meta: verbose_name='internetové riešenie', default=False) def __str__(self): - return f'Riešiteľ: { self.semester_registration } - úloha { self.problem }' + return f'Riešiteľ: {self.semester_registration} - úloha {self.problem}' def get_solution_file_name(self): return f'{self.semester_registration.profile.user.get_full_name_camel_case()}'\ - f'-{self.problem.id}-{self.semester_registration.id}.pdf' + f'-{self.problem.id}-{self.semester_registration.id}.pdf' def get_solution_file_path(self): return f'solutions/user_solutions/{self.get_solution_file_name()}' def get_corrected_solution_file_name(self): return f'{self.semester_registration.profile.user.get_full_name_camel_case()}'\ - f'-{self.problem.id}-{self.semester_registration.id}_corrected.pdf' + f'-{self.problem.id}-{self.semester_registration.id}_corrected.pdf' def get_corrected_solution_file_path(self): return f'solutions/corrected/{self.get_corrected_solution_file_name()}' diff --git a/competition/results.py b/competition/results.py new file mode 100644 index 00000000..e99d745d --- /dev/null +++ b/competition/results.py @@ -0,0 +1,156 @@ +from json import loads as json_loads, dumps as json_dumps +from competition.utils import sum_methods +from competition.serializers import EventRegistrationReadSerializer +from competition.models import Series, Semester, EventRegistration +from operator import itemgetter + + +class FreezingNotClosedResults(Exception): + """Snažíš sa zamraziť výsledky série, ktorá nemá opravené všetky riešenia""" + + +def semester_results(self: Semester) -> dict: + """Vyrobí výsledky semestra""" + if self.frozen_results is not None: + return json_loads(self.frozen_results) + results = [] + for registration in self.eventregistration_set.all(): + results.append(_generate_result_row(registration, self)) + + results.sort(key=itemgetter('total'), reverse=True) + results = _rank_results(results) + return results + + +def freeze_semester_results(semester: Semester): + if any(not series.complete for series in semester.series_set.all()): + raise FreezingNotClosedResults() + + semester.frozen_results = json_dumps(semester_results(semester)) + + +def series_results(series: Series): + results = [ + _generate_result_row(registration, only_series=series) + for registration in series.semester.eventregistration_set.all() + ] + + results.sort(key=itemgetter('total'), reverse=True) + + return _rank_results(results) + + +def freeze_series_results(series: Series): + if any( + problem.num_solutions != problem.num_corrected_solutions + for problem in series.problems.all() + ): + raise FreezingNotClosedResults() + + series.frozen_results = json_dumps(series.results) + + +def generate_praticipant_invitations( + results_with_ranking: list[dict], + number_of_participants: int, + number_of_substitues: int, +) -> list[dict]: + invited_users = [] + for i, result_row in enumerate(results_with_ranking): + if i < number_of_participants: + invited_users.append({ + 'first_name': result_row['registration']['profile']['first_name'], + 'last_name': result_row['registration']['profile']['last_name'], + 'school': result_row['registration']['school'], + 'is_participant': True + }) + elif i < number_of_participants+number_of_substitues: + invited_users.append({ + 'first_name': result_row['registration']['profile']['first_name'], + 'last_name': result_row['registration']['profile']['last_name'], + 'school': result_row['registration']['school'], + 'is_participant': False + }) + return invited_users + + +def _generate_result_row( + semester_registration: EventRegistration, + semester: Semester | None = None, + only_series: Series | None = None, +): + """ + Vygeneruje riadok výsledku pre používateľa. + Ak je uvedený only_semester vygenerujú sa výsledky iba sa daný semester + """ + user_solutions = semester_registration.solution_set + series_set = semester.series_set.order_by( + 'order') if semester is not None else [only_series] + solutions = [] + subtotal = [] + for series in series_set: + series_solutions = [] + solution_points = [] + for problem in series.problems.order_by('order'): + sol = user_solutions.filter(problem=problem).first() + + solution_points.append(sol.score or 0 if sol is not None else 0) + series_solutions.append( + { + 'points': (str(sol.score if sol.score is not None else '?') + if sol is not None else '-'), + 'solution_pk': sol.pk if sol is not None else None, + 'problem_pk': problem.pk, + 'votes': 0 # TODO: Implement votes sol.vote + } + ) + series_sum_func = getattr(sum_methods, series.sum_method or '', + sum_methods.series_simple_sum) + solutions.append(series_solutions) + subtotal.append( + series_sum_func(solution_points, semester_registration) + ) + return { + # Poradie - horná hranica, v prípade deleného miesto(napr. 1.-3.) ide o nižšie miesto(1) + 'rank_start': 0, + # Poradie - dolná hranica, v prípade deleného miesto(napr. 1.-3.) ide o vyššie miesto(3) + 'rank_end': 0, + # Indikuje či sa zmenilo poradie od minulej priečky, slúži na delené miesta + 'rank_changed': True, + # primary key riešiteľovej registrácie do semestra + 'registration': EventRegistrationReadSerializer(semester_registration).data, + # Súčty bodov po sériách + 'subtotal': subtotal, + # Celkový súčet za danú entitu + 'total': sum(subtotal), + # Zoznam riešení, + 'solutions': solutions + } + + +def _rank_results(results: list[dict]) -> list[dict]: + # Spodná hranica + current_rank = 1 + n_teams = 1 + last_points = None + for res in results: + if last_points != res['total']: + current_rank = n_teams + last_points = res['total'] + res['rank_changed'] = True + else: + res['rank_changed'] = False + res['rank_start'] = current_rank + n_teams += 1 + + # Horná hranica + current_rank = len(results) + n_teams = len(results) + last_points = None + for res in reversed(results): + if last_points != res['total']: + current_rank = n_teams + last_points = res['total'] + res['rank_end'] = current_rank + n_teams -= 1 + return results diff --git a/competition/serializers.py b/competition/serializers.py index 9d04d201..b39c517e 100644 --- a/competition/serializers.py +++ b/competition/serializers.py @@ -3,7 +3,6 @@ from rest_framework import serializers from competition import models -from competition.models import Event, Problem, RegistrationLink from personal.serializers import ProfileShortSerializer, SchoolShortSerializer @@ -71,11 +70,11 @@ def create(self, validated_data): registration_link = validated_data.pop('registration_link', None) if registration_link is not None: - registration_link = RegistrationLink.objects.create( + registration_link = models.RegistrationLink.objects.create( **registration_link, ) - return Event.objects.create( + return models.Event.objects.create( registration_link=registration_link, **validated_data, ) @@ -299,7 +298,7 @@ def format_list_of_names(self, names: list[str]) -> str: def format_histogram(self, histogram: list[dict[str, int]]) -> str: return ''.join([f'({item["score"]},{item["count"]})' for item in histogram]) - def get_tex_header(self, obj: Problem) -> str: + def get_tex_header(self, obj: models.Problem) -> str: """Generuje tex hlavicku vzoraku do casaku""" try: corrected_by = [user.get_full_name() @@ -309,7 +308,7 @@ def get_tex_header(self, obj: Problem) -> str: best_solutions = [user.get_full_name() for user in obj.correction.corrected_by.all()] best_solution_suffix = 'e' if len(best_solutions) > 1 else 'a' - except Problem.correction.RelatedObjectDoesNotExist: # pylint: disable=no-member + except models.Problem.correction.RelatedObjectDoesNotExist: # pylint: disable=no-member corrected_by = None corrected_suffix = '' best_solutions = None diff --git a/competition/utils/results.py b/competition/utils/results.py deleted file mode 100644 index c5bd4a99..00000000 --- a/competition/utils/results.py +++ /dev/null @@ -1,49 +0,0 @@ -def rank_results(results: list[dict]) -> list[dict]: - # Spodná hranica - current_rank = 1 - n_teams = 1 - last_points = None - for res in results: - if last_points != res['total']: - current_rank = n_teams - last_points = res['total'] - res['rank_changed'] = True - else: - res['rank_changed'] = False - res['rank_start'] = current_rank - n_teams += 1 - - # Horná hranica - current_rank = len(results) - n_teams = len(results) - last_points = None - for res in reversed(results): - if last_points != res['total']: - current_rank = n_teams - last_points = res['total'] - res['rank_end'] = current_rank - n_teams -= 1 - return results - - -def generate_praticipant_invitations( - results_with_ranking: list[dict], - number_of_participants: int, - number_of_substitues: int) -> list[dict]: - invited_users = [] - for i, result_row in enumerate(results_with_ranking): - if i < number_of_participants: - invited_users.append({ - 'first_name': result_row['registration']['profile']['first_name'], - 'last_name': result_row['registration']['profile']['last_name'], - 'school': result_row['registration']['school'], - 'is_participant': True - }) - elif i < number_of_participants+number_of_substitues: - invited_users.append({ - 'first_name': result_row['registration']['profile']['first_name'], - 'last_name': result_row['registration']['profile']['last_name'], - 'school': result_row['registration']['school'], - 'is_participant': False - }) - return invited_users diff --git a/competition/utils/sum_methods.py b/competition/utils/sum_methods.py index dde2797a..ac40d9bb 100644 --- a/competition/utils/sum_methods.py +++ b/competition/utils/sum_methods.py @@ -1,15 +1,12 @@ -from competition.models import EventRegistration, Solution - - def dot_product(solutions: list[int], weights: list[int]): return sum(s*w for s, w in zip(solutions, weights)) -def solutions_to_list_of_points(solutions: list[Solution]) -> list[int]: +def solutions_to_list_of_points(solutions: list) -> list[int]: return [s.score or 0 if s is not None else 0 for s in solutions] -def solutions_to_list_of_points_pretty(solutions: list[Solution]) -> list[str]: +def solutions_to_list_of_points_pretty(solutions: list) -> list[str]: return [str(s.score or '?') if s is not None else '-' for s in solutions] @@ -29,7 +26,7 @@ def series_general_weighted_sum(solutions: list[int], weights: list[int]): return series_simple_sum(solutions) -def series_Malynar_sum_until_2021(solutions, user_registration: EventRegistration): +def series_Malynar_sum_until_2021(solutions, user_registration): # pylint: disable=invalid-name weights = None if user_registration.grade is not None: @@ -40,7 +37,7 @@ def series_Malynar_sum_until_2021(solutions, user_registration: EventRegistratio return series_general_weighted_sum(solutions, weights) -def series_Malynar_sum(solutions, user_registration: EventRegistration): +def series_Malynar_sum(solutions, user_registration): # pylint: disable=invalid-name weights = None if user_registration.grade is not None: @@ -51,7 +48,7 @@ def series_Malynar_sum(solutions, user_registration: EventRegistration): return series_general_weighted_sum(solutions, weights) -def series_Matik_sum_until_2021(solutions, user_registration: EventRegistration): +def series_Matik_sum_until_2021(solutions, user_registration): # pylint: disable=invalid-name weights = None if user_registration.grade is not None: @@ -62,7 +59,7 @@ def series_Matik_sum_until_2021(solutions, user_registration: EventRegistration) return series_general_weighted_sum(solutions, weights) -def series_Matik_sum(solutions, user_registration: EventRegistration): +def series_Matik_sum(solutions, user_registration): # pylint: disable=invalid-name weights = None if user_registration.grade is not None: @@ -73,7 +70,7 @@ def series_Matik_sum(solutions, user_registration: EventRegistration): return series_general_weighted_sum(solutions, weights) -def series_STROM_sum_until_2021(solutions, user_registration: EventRegistration): +def series_STROM_sum_until_2021(solutions, user_registration): # pylint: disable=invalid-name weights = None if user_registration.grade is not None: @@ -84,7 +81,7 @@ def series_STROM_sum_until_2021(solutions, user_registration: EventRegistration) return series_general_weighted_sum(solutions, weights) -def series_STROM_sum(solutions, user_registration: EventRegistration): +def series_STROM_sum(solutions, user_registration): # pylint: disable=invalid-name weights = None if user_registration.grade is not None: @@ -95,7 +92,7 @@ def series_STROM_sum(solutions, user_registration: EventRegistration): return series_general_weighted_sum(solutions, weights) -def series_STROM_4problems_sum(solutions, user_registration: EventRegistration): +def series_STROM_4problems_sum(solutions, user_registration): # pylint: disable=invalid-name weights = [1, 1, 1, 2] if user_registration.grade is not None: diff --git a/competition/views.py b/competition/views.py index de93f33e..41c0b9bf 100644 --- a/competition/views.py +++ b/competition/views.py @@ -19,7 +19,6 @@ from rest_framework.response import Response from base.utils import mime_type -from competition.exceptions import FreezingNotClosedResults from competition.models import (Comment, Competition, CompetitionType, Event, EventRegistration, Grade, LateTag, Problem, Publication, PublicationType, Semester, Series, @@ -27,6 +26,11 @@ from competition.permissions import (CommentPermission, CompetitionRestrictedPermission, ProblemPermission) +from competition.results import (FreezingNotClosedResults, + freeze_semester_results, + freeze_series_results, + generate_praticipant_invitations, + semester_results, series_results) from competition.serializers import (CommentSerializer, CompetitionSerializer, CompetitionTypeSerializer, EventRegistrationReadSerializer, @@ -40,9 +44,6 @@ SemesterWithProblemsSerializer, SeriesWithProblemsSerializer, SolutionSerializer) -from competition.utils import sum_methods -from competition.utils.results import (generate_praticipant_invitations, - rank_results) from personal.models import Profile, School from personal.serializers import ProfileExportSerializer, SchoolSerializer from webstrom.settings import EMAIL_ALERT @@ -58,60 +59,6 @@ def get_serializer_context(self): return context -def generate_result_row( - semester_registration: EventRegistration, - semester: Semester = None, - only_series: Series = None -): - """ - Vygeneruje riadok výsledku pre používateľa. - Ak je uvedený only_semester vygenerujú sa výsledky iba sa daný semester - """ - user_solutions = semester_registration.solution_set - series_set = semester.series_set.order_by( - 'order') if semester is not None else [only_series] - solutions = [] - subtotal = [] - for series in series_set: - series_solutions = [] - solution_points = [] - for problem in series.problems.order_by('order'): - sol = user_solutions.filter(problem=problem).first() - - solution_points.append(sol.score or 0 if sol is not None else 0) - series_solutions.append( - { - 'points': (str(sol.score if sol.score is not None else '?') - if sol is not None else '-'), - 'solution_pk': sol.pk if sol is not None else None, - 'problem_pk': problem.pk, - 'votes': 0 # TODO: Implement votes sol.vote - } - ) - series_sum_func = getattr(sum_methods, series.sum_method or '', - sum_methods.series_simple_sum) - solutions.append(series_solutions) - subtotal.append( - series_sum_func(solution_points, semester_registration) - ) - return { - # Poradie - horná hranica, v prípade deleného miesto(napr. 1.-3.) ide o nižšie miesto(1) - 'rank_start': 0, - # Poradie - dolná hranica, v prípade deleného miesto(napr. 1.-3.) ide o vyššie miesto(3) - 'rank_end': 0, - # Indikuje či sa zmenilo poradie od minulej priečky, slúži na delené miesta - 'rank_changed': True, - # primary key riešiteľovej registrácie do semestra - 'registration': EventRegistrationReadSerializer(semester_registration).data, - # Súčty bodov po sériách - 'subtotal': subtotal, - # Celkový súčet za danú entitu - 'total': sum(subtotal), - # Zoznam riešení, - 'solutions': solutions - } - - class CompetitionViewSet(mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, @@ -484,36 +431,26 @@ def perform_create(self, serializer): raise exceptions.PermissionDenied( 'Nedostatočné práva na vytvorenie tohoto objektu') - @staticmethod - def __create_result_json(series: Series) -> dict: - results = [] - for registration in series.semester.eventregistration_set.all(): - results.append( - generate_result_row(registration, only_series=series) - ) - results.sort(key=itemgetter('total'), reverse=True) - return rank_results(results) - @action(methods=['get'], detail=True) def results(self, request: Request, pk: Optional[int] = None): """Vráti výsledkovku pre sériu""" series = self.get_object() if series.frozen_results is not None: return Response(json.loads(series.frozen_results), status=status.HTTP_200_OK) - results = self.__create_result_json(series) + results = series_results(series) return Response(results, status=status.HTTP_200_OK) @action(methods=['post'], detail=True, url_path='results/freeze') def freeze_results(self, request: Request, pk: Optional[int] = None): series: Series = self.get_object() try: - series.freeze_results(self.__create_result_json(series)) + freeze_series_results(series) except FreezingNotClosedResults as exc: raise exceptions.MethodNotAllowed( method='series/results/freeze', detail='Séria nemá opravené všetky úlohy a teda sa nedá uzavrieť.') from exc try: - series.semester.freeze_results() + freeze_semester_results(series.semester) except FreezingNotClosedResults: pass return Response('Séria bola uzavretá', status=status.HTTP_200_OK) @@ -658,24 +595,11 @@ def perform_create(self, serializer): raise exceptions.PermissionDenied( 'Nedostatočné práva na vytvorenie tohoto objektu') - @staticmethod - def semester_results(semester): - """Vyrobí výsledky semestra""" - if semester.frozen_results is not None: - return json.loads(semester.frozen_results) - results = [] - for registration in semester.eventregistration_set.all(): - results.append(generate_result_row(registration, semester)) - - results.sort(key=itemgetter('total'), reverse=True) - results = rank_results(results) - return results - @action(methods=['post'], detail=True, url_path='results/freeze') def freeze_results(self, request: Request, pk: Optional[int] = None): semester: Semester = self.get_object() try: - semester.freeze_results(self.semester_results(semester)) + freeze_semester_results(semester) except FreezingNotClosedResults as exc: raise exceptions.MethodNotAllowed( method='series/results/freeze', @@ -686,7 +610,7 @@ def freeze_results(self, request: Request, pk: Optional[int] = None): def results(self, request, pk=None): """Vráti výsledkovku semestra""" semester = self.get_object() - current_results = SemesterViewSet.semester_results(semester) + current_results = semester_results(semester) return Response(current_results, status=status.HTTP_200_OK) @action(methods=['get'], detail=True, permission_classes=[IsAdminUser]) @@ -718,7 +642,7 @@ def invitations(self, request, pk=None, num_participants=32, num_substitutes=20) num_participants = int(num_participants) num_substitutes = int(num_substitutes) participants = generate_praticipant_invitations( - SemesterViewSet.semester_results(semester), + semester_results(semester), num_participants, num_substitutes ) @@ -736,7 +660,7 @@ def school_invitations(self, request, pk=None, num_participants=32, num_substitu num_substitutes = int(num_substitutes) semester = self.get_object() participants = generate_praticipant_invitations( - SemesterViewSet.semester_results(semester), + semester_results(semester), num_participants, num_substitutes ) @@ -768,7 +692,7 @@ def current_results(self, request, competition_id=None): """Vráti výsledky pre aktuálny semester""" current_semester = self.get_queryset().filter( competition=competition_id).current() - current_results = SemesterViewSet.semester_results(current_semester) + current_results = semester_results(current_semester) return Response(current_results, status=status.HTTP_201_CREATED) def __get_participants(self): @@ -923,7 +847,8 @@ def download_publication(self, request, pk=None): publication = self.get_object() response = HttpResponse( publication.file, content_type=mime_type(publication.file)) - response['Content-Disposition'] = f'attachment; filename="{publication.name}"' + response['Content-Disposition'] = f'attachment; filename="{ + publication.name}"' return response @action(methods=['post'], detail=False, url_path='upload', permission_classes=[IsAdminUser]) From c7c35e7817abaccdb33377cedbef44e9e6aa1a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Mih=C3=A1lik?= Date: Sat, 23 Nov 2024 21:03:28 +0100 Subject: [PATCH 2/2] Fixing formatting issues --- competition/models.py | 4 ++-- competition/views.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/competition/models.py b/competition/models.py index d6fc86ac..e48e65fd 100644 --- a/competition/models.py +++ b/competition/models.py @@ -403,8 +403,8 @@ class Meta: def __str__(self): return f'{self.series.semester.competition.name}-{self.series.semester.year}' \ - f'-{self.series.semester.season[0] - }S-S{self.series.order} - {self.order}. úloha' + f'-{self.series.semester.season[0]}S-S{self.series.order}'\ + f' - {self.order}. úloha' def get_stats(self): stats = {} diff --git a/competition/views.py b/competition/views.py index 41c0b9bf..43562050 100644 --- a/competition/views.py +++ b/competition/views.py @@ -847,8 +847,8 @@ def download_publication(self, request, pk=None): publication = self.get_object() response = HttpResponse( publication.file, content_type=mime_type(publication.file)) - response['Content-Disposition'] = f'attachment; filename="{ - publication.name}"' + response['Content-Disposition'] = f'attachment; '\ + f'filename="{publication.name}"' return response @action(methods=['post'], detail=False, url_path='upload', permission_classes=[IsAdminUser])