diff --git a/bullet/bullet_admin/forms/review.py b/bullet/bullet_admin/forms/review.py index 6e1ed735..3c332b68 100644 --- a/bullet/bullet_admin/forms/review.py +++ b/bullet/bullet_admin/forms/review.py @@ -17,5 +17,9 @@ def clean(self): def get_review_formset(team: Team): - problems = team.venue.category.problems.count() + problems = ( + team.venue.category.competition.problem_count + - team.venue.category.first_problem + + 1 + ) return formset_factory(ReviewForm, min_num=problems, max_num=problems) diff --git a/bullet/bullet_admin/urls/__init__.py b/bullet/bullet_admin/urls/__init__.py index cdad3925..8a3fa636 100644 --- a/bullet/bullet_admin/urls/__init__.py +++ b/bullet/bullet_admin/urls/__init__.py @@ -6,7 +6,6 @@ archive, auth, category, - categoryproblems, competition, content, education, @@ -277,11 +276,6 @@ education.SchoolCreateView.as_view(), name="school_create", ), - path( - "problems/generate/", - categoryproblems.ProblemsGenerateView.as_view(), - name="problems_generate", - ), path("gallery/albums/", albums.AlbumListView.as_view(), name="album_list"), path("gallery/albums/new/", albums.AlbumCreateView.as_view(), name="album_create"), path( diff --git a/bullet/bullet_admin/views/categoryproblems.py b/bullet/bullet_admin/views/categoryproblems.py deleted file mode 100644 index ad605bc4..00000000 --- a/bullet/bullet_admin/views/categoryproblems.py +++ /dev/null @@ -1,45 +0,0 @@ -from competitions.models import Category -from django.db import transaction -from django.shortcuts import redirect -from django.views.generic import FormView -from problems.models import CategoryProblem, Problem - -from bullet_admin.access import BranchAdminAccess -from bullet_admin.forms.problem import ProblemsGenerateForm -from bullet_admin.utils import get_active_competition -from bullet_admin.views import GenericForm - - -class ProblemsGenerateView(BranchAdminAccess, GenericForm, FormView): - form_class = ProblemsGenerateForm - form_title = "Problem generation" - - def get_form_kwargs(self): - kw = super().get_form_kwargs() - kw["competition"] = get_active_competition(self.request) - return kw - - @transaction.atomic - def form_valid(self, form): - problem_count = form.cleaned_data["problem_count"] - competition = get_active_competition(self.request) - for i in range(problem_count): - Problem(competition=competition, name=f"{i+1:02d}").save() - - for key in form.cleaned_data.keys(): - if not key.startswith("offset_"): - continue - - offset = form.cleaned_data[key] - category = Category.objects.get(competition=competition, identifier=key[7:]) - for i in range(problem_count - offset): - CategoryProblem( - problem=Problem.objects.get( - name=f"{i+1+offset:02d}", - competition=competition, - ), - category=category, - number=i + 1 + offset, - ).save() - - return redirect("badmin:home") diff --git a/bullet/bullet_admin/views/scanning.py b/bullet/bullet_admin/views/scanning.py index d42b4891..25502413 100644 --- a/bullet/bullet_admin/views/scanning.py +++ b/bullet/bullet_admin/views/scanning.py @@ -23,7 +23,7 @@ mark_problem_unsolved, ) from problems.logic.scanner import ScannedBarcode, parse_barcode, save_scan -from problems.models import CategoryProblem, Problem, ScannerLog, SolvedProblem +from problems.models import Problem, ScannerLog, SolvedProblem from users.models import Team from bullet_admin.forms.review import get_review_formset @@ -161,11 +161,9 @@ def scan(self, request): if scanned_barcode.team.is_reviewed: raise ValueError("The team was already reviewed.") - problem_count = CategoryProblem.objects.filter( - category=scanned_barcode.team.venue.category - ).count() + max_problem_number = self.venue.category.competition.problem_count last = get_last_problem_for_team(scanned_barcode.team) - if last < problem_count: + if last < max_problem_number: if scanned_barcode.problem_number != last + 1: raise ValueError( f"Expected problem number {last + 1}, got " @@ -291,22 +289,22 @@ def get_form_class(self): return get_review_formset(self.team) def get_initial(self): - category_problems = { - cp.number: cp.problem_id - for cp in CategoryProblem.objects.filter( - category=self.team.venue.category - ).order_by("number") - } + competition = get_active_competition(self.request) + problems = Problem.objects.filter(competition=competition) + solved_timestamps = { sp.problem_id: sp.competition_time for sp in self.team.solved_problems.all() } initial = [] - for num, id in category_problems.items(): - row = {"number": num} - if id in solved_timestamps: + for problem in problems: + if problem.number < self.team.venue.category.first_problem: + continue + + row = {"number": problem.number} + if problem.id in solved_timestamps: row["is_solved"] = True - row["competition_time"] = solved_timestamps[id] + row["competition_time"] = solved_timestamps[problem.id] initial.append(row) @@ -318,11 +316,9 @@ def get_context_data(self, **kwargs): return ctx def form_valid(self, form): - category_problems: dict[int, Problem] = { - cp.number: cp.problem - for cp in CategoryProblem.objects.filter(category=self.team.venue.category) - .order_by("number") - .select_related("problem") + competition = get_active_competition(self.request) + problems: dict[int, Problem] = { + p.number: p for p in Problem.objects.filter(competition=competition) } solved: dict[int, SolvedProblem] = { sp.problem_id: sp for sp in self.team.solved_problems.all() @@ -331,9 +327,10 @@ def form_valid(self, form): changed = False for row in form: num = row.cleaned_data.get("number") - if num not in category_problems: + if num not in problems or num < self.team.venue.category.first_problem: continue - problem: Problem = category_problems[num] + + problem: Problem = problems[num] is_solved: bool = row.cleaned_data.get("is_solved") competition_time: timedelta = row.cleaned_data.get("competition_time") @@ -383,8 +380,7 @@ def post(self, request, *args, **kwargs): continue problem = Problem.objects.filter( competition=team.venue.category.competition, - category_problems__category=team.venue.category, - category_problems__number=solve["problem"], + number=solve["problem"], ).first() if not problem: continue diff --git a/bullet/bullet_admin/views/venues.py b/bullet/bullet_admin/views/venues.py index fe8969fd..5e3160c1 100644 --- a/bullet/bullet_admin/views/venues.py +++ b/bullet/bullet_admin/views/venues.py @@ -20,7 +20,7 @@ from documents.generators.tearoff import TearoffGenerator, TearoffRequirementMissing from documents.models import TexJob from problems.logic.results import save_country_ranks, save_venue_ranks -from problems.models import CategoryProblem, Problem +from problems.models import Problem from users.logic import get_venue_waiting_list, move_eligible_teams from users.models import Team @@ -205,14 +205,10 @@ def get_form_kwargs(self): kw = super().get_form_kwargs() competition = get_active_competition(self.request) problem_count = Problem.objects.filter(competition=competition).count() - first_problem = ( - CategoryProblem.objects.filter(category=self.venue.category) - .order_by("number") - .first() - ) + kw["problems"] = problem_count kw["venue"] = self.venue - kw["first_problem"] = first_problem.number if first_problem else 1 + kw["first_problem"] = self.venue.category.first_problem return kw def form_valid(self, form): diff --git a/bullet/competitions/models/venues.py b/bullet/competitions/models/venues.py index c2d22704..f6a5a7ad 100644 --- a/bullet/competitions/models/venues.py +++ b/bullet/competitions/models/venues.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import TYPE_CHECKING from bullet_admin.utils import get_active_competition @@ -136,7 +137,7 @@ def contact_email(self): return self.email @property - def start_time(self): + def start_time(self) -> datetime: if self.local_start: return self.local_start return self.category.competition.competition_start diff --git a/bullet/problems/admin.py b/bullet/problems/admin.py index 03486537..e28ddff6 100644 --- a/bullet/problems/admin.py +++ b/bullet/problems/admin.py @@ -3,15 +3,15 @@ from problems import models -class CategoryProblemAdminInline(admin.TabularInline): - model = models.CategoryProblem +class ProblemStatementInline(admin.StackedInline): + model = models.ProblemStatement @admin.register(models.Problem) class ProblemAdmin(admin.ModelAdmin): list_filter = ("competition", "competition__branch") - list_display = ("name", "competition") - inlines = (CategoryProblemAdminInline,) + list_display = ("number", "competition") + inlines = (ProblemStatementInline,) @admin.register(models.ProblemStatement) diff --git a/bullet/problems/factories/generate.py b/bullet/problems/factories/generate.py index 56168847..3bedda34 100644 --- a/bullet/problems/factories/generate.py +++ b/bullet/problems/factories/generate.py @@ -1,10 +1,9 @@ from competitions.models import Competition -from problems.factories.problems import CategoryProblemFactory, ProblemFactory +from problems.factories.problems import ProblemFactory from problems.models import Problem def create_problems(competition: Competition) -> list[Problem]: problems = ProblemFactory.create_batch(42, competition=competition) - CategoryProblemFactory.create_batch(42) return problems diff --git a/bullet/problems/factories/problems.py b/bullet/problems/factories/problems.py index 9b2bd768..7c0a9169 100644 --- a/bullet/problems/factories/problems.py +++ b/bullet/problems/factories/problems.py @@ -1,23 +1,13 @@ import factory -from competitions.models import Category, Competition +from competitions.models import Competition from factory.django import DjangoModelFactory -from problems.models import CategoryProblem, Problem +from problems.models import Problem class ProblemFactory(DjangoModelFactory): class Meta: model = Problem - name = factory.Faker("sentence") - competition = factory.Faker("random_element", elements=Competition.objects.all()) - - -class CategoryProblemFactory(DjangoModelFactory): - class Meta: - model = CategoryProblem - django_get_or_create = ("problem", "category", "number") - - problem = factory.Faker("random_element", elements=Problem.objects.all()) - category = factory.Faker("random_element", elements=Category.objects.all()) number = factory.Faker("random_int") + competition = factory.Faker("random_element", elements=Competition.objects.all()) diff --git a/bullet/problems/logic/__init__.py b/bullet/problems/logic/__init__.py index 9256497e..603279e2 100644 --- a/bullet/problems/logic/__init__.py +++ b/bullet/problems/logic/__init__.py @@ -8,6 +8,9 @@ def get_last_problem_for_team(team: Team): + """ + Return the number of the highest problem the team should have on their table. + """ return ( SolvedProblem.objects.filter(team=team).count() + team.venue.category.problems_per_team diff --git a/bullet/problems/logic/results.py b/bullet/problems/logic/results.py index 5b95db22..9ac2961d 100644 --- a/bullet/problems/logic/results.py +++ b/bullet/problems/logic/results.py @@ -9,12 +9,12 @@ from django_rq import job from users.models import Team -from problems.models import CategoryProblem, ResultRow, SolvedProblem +from problems.models import ResultRow, SolvedProblem def get_results( team_filter: Q, - time: timedelta = None, + time: timedelta | None = None, ) -> QuerySet[ResultRow]: rows = ResultRow.objects.filter(team__is_disqualified=False) @@ -33,12 +33,14 @@ def get_results( ) -def get_venue_results(venue: Venue, time: timedelta = None) -> QuerySet[ResultRow]: +def get_venue_results( + venue: Venue, time: timedelta | None = None +) -> QuerySet[ResultRow]: return get_results(Q(team__venue=venue), time) def get_country_results( - country: str | Country, category: Category, time: timedelta = None + country: str | Country, category: Category, time: timedelta | None = None ) -> QuerySet[ResultRow]: return get_results( Q( @@ -51,7 +53,7 @@ def get_country_results( def get_category_results( - category: Category, time: timedelta = None + category: Category, time: timedelta | None = None ) -> QuerySet[ResultRow]: return get_results( Q(team__venue__category=category, team__venue__is_isolated=False), @@ -70,7 +72,7 @@ class ResultsTime: def results_time( competition: Competition, realtime: datetime, - start_time: datetime = None, + start_time: datetime | None = None, is_admin: bool = False, ) -> ResultsTime: if not start_time: @@ -97,18 +99,13 @@ def _set_solved_problems(rr: ResultRow): problems = ( SolvedProblem.objects.select_for_update() .filter(team=rr.team, competition_time__lte=rr.competition_time) - .values_list("problem") - ) - solved_problems = set( - CategoryProblem.objects.filter( - problem__in=problems, category=rr.team.venue.category - ).values_list("number", flat=True) + .values_list("problem__number", flat=True) ) - rr.solved_count = len(solved_problems) + rr.solved_count = len(problems) solved_bin = 0 - for p in solved_problems: + for p in problems: solved_bin |= 1 << (p - 1) rr.solved_problems = solved_bin.to_bytes(16, "big") @@ -139,35 +136,20 @@ def squash_results(competition: Competition | int): teams = Team.objects.filter(venue__category__competition=competition) for team in teams: - problems = ( - SolvedProblem.objects.filter(team=team) - .values_list("problem") - .order_by("-competition_time") - ) last_problem = ( SolvedProblem.objects.filter(team=team) .order_by("-competition_time") .first() ) - solved_problems = set( - CategoryProblem.objects.filter( - problem__in=problems, category=team.venue.category - ).values_list("number", flat=True) - ) if not last_problem: continue - result_row = ResultRow() - result_row.team = team - result_row.solved_count = len(solved_problems) - - solved_bin = 0 - for p in solved_problems: - solved_bin |= 1 << (p - 1) - result_row.solved_problems = solved_bin.to_bytes(16, "big") - - result_row.competition_time = last_problem.competition_time + result_row = ResultRow( + team=team, + competition_time=last_problem.competition_time, + ) + _set_solved_problems(result_row) result_row.save() diff --git a/bullet/problems/logic/scanner.py b/bullet/problems/logic/scanner.py index d70e0856..995e5240 100644 --- a/bullet/problems/logic/scanner.py +++ b/bullet/problems/logic/scanner.py @@ -63,16 +63,21 @@ def parse_barcode( f"Could not find team {match.group('team')} in {venue.shortcode}." ) - if allow_endmark and int(match.group("problem")) == 0: + problem_number = int(match.group("problem")) + if problem_number < venue.category.first_problem: + raise ValueError(f"Problem {problem_number} is not valid for this category.") + + if allow_endmark and problem_number == 0: problem = None else: problem = Problem.objects.filter( competition=competition, - category_problems__category=venue.category, - category_problems__number=match.group("problem"), + number=problem_number, ).first() if not problem: - raise ValueError(f"Could not find problem {match.group('problem')}.") + raise ValueError( + f"Problem {problem_number} is not valid for this category." + ) return ScannedBarcode(venue, team, int(match.group("problem")), problem) diff --git a/bullet/problems/logic/stats.py b/bullet/problems/logic/stats.py index eb471171..3133deca 100644 --- a/bullet/problems/logic/stats.py +++ b/bullet/problems/logic/stats.py @@ -5,7 +5,7 @@ from django_rq import job from users.models import Team -from problems.models import ProblemStat, SolvedProblem +from problems.models import Problem, ProblemStat, SolvedProblem @job @@ -19,12 +19,10 @@ def generate_stats(competition: Competition | int): def generate_stats_category(category: Category): - category_problems = category.problems.all() + problems = Problem.objects.filter(competition=category.competition).all() first_problem = category.first_problem - problem_number_map: dict[int, int] = { - p.problem.id: p.number for p in category_problems - } - problem_id_map: dict[int, int] = {p.number: p.problem.id for p in category_problems} + problem_number_map: dict[int, int] = {p.id: p.number for p in problems} + problem_id_map: dict[int, int] = {p.number: p.id for p in problems} solves = SolvedProblem.objects.filter(team__venue__category=category).order_by( "competition_time" @@ -54,7 +52,7 @@ def generate_stats_category(category: Category): stats = [] for team in teams: for number, received in receive_times[team.id].items(): - if number > len(category_problems) + first_problem - 1: + if number > len(problems) + first_problem - 1: continue solved = None if number in solve_times[team.id]: diff --git a/bullet/problems/migrations/0006_problem_number.py b/bullet/problems/migrations/0006_problem_number.py new file mode 100644 index 00000000..033b2e17 --- /dev/null +++ b/bullet/problems/migrations/0006_problem_number.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.5 on 2025-02-03 16:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("problems", "0005_problemstat"), + ] + + operations = [ + migrations.AddField( + model_name="problem", + name="number", + field=models.PositiveIntegerField(null=True), + ), + ] diff --git a/bullet/problems/migrations/0007_auto_20250203_1642.py b/bullet/problems/migrations/0007_auto_20250203_1642.py new file mode 100644 index 00000000..4c65ae2c --- /dev/null +++ b/bullet/problems/migrations/0007_auto_20250203_1642.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.5 on 2025-02-03 16:42 + +from django.db import migrations + + +def migrate_problem_numbers(apps, schema_editor): + Problem = apps.get_model("problems", "Problem") + + for p in Problem.objects.all(): + p.number = int(p.name) + p.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("problems", "0006_problem_number"), + ] + + operations = [ + migrations.RunPython(migrate_problem_numbers), + ] diff --git a/bullet/problems/migrations/0008_remove_problem_name_alter_problem_number.py b/bullet/problems/migrations/0008_remove_problem_name_alter_problem_number.py new file mode 100644 index 00000000..c9acd293 --- /dev/null +++ b/bullet/problems/migrations/0008_remove_problem_name_alter_problem_number.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.5 on 2025-02-03 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("problems", "0007_auto_20250203_1642"), + ] + + operations = [ + migrations.RemoveField( + model_name="problem", + name="name", + ), + migrations.AlterField( + model_name="problem", + name="number", + field=models.PositiveIntegerField(), + ), + ] diff --git a/bullet/problems/migrations/0009_solvedproblem_solvedproblem__team_problem.py b/bullet/problems/migrations/0009_solvedproblem_solvedproblem__team_problem.py new file mode 100644 index 00000000..e5da2ebe --- /dev/null +++ b/bullet/problems/migrations/0009_solvedproblem_solvedproblem__team_problem.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.5 on 2025-02-03 17:57 + +from django.db import migrations, models + + +def remove_duplicates(apps, schema_editor): + SolvedProblem = apps.get_model("problems", "SolvedProblem") + + distinct = SolvedProblem.objects.order_by("team_id", "problem_id").distinct( + "team_id", "problem_id" + ) + SolvedProblem.objects.exclude(id__in=distinct).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("problems", "0008_remove_problem_name_alter_problem_number"), + ("users", "0031_alter_spanishteamdata_agreement"), + ] + + operations = [ + migrations.RunPython(remove_duplicates), + migrations.AddConstraint( + model_name="solvedproblem", + constraint=models.UniqueConstraint( + models.F("team"), + models.F("problem"), + name="solvedproblem__team_problem", + ), + ), + ] diff --git a/bullet/problems/migrations/0010_delete_categoryproblem.py b/bullet/problems/migrations/0010_delete_categoryproblem.py new file mode 100644 index 00000000..5a062960 --- /dev/null +++ b/bullet/problems/migrations/0010_delete_categoryproblem.py @@ -0,0 +1,15 @@ +# Generated by Django 5.1.5 on 2025-02-03 18:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("problems", "0009_solvedproblem_solvedproblem__team_problem"), + ] + + operations = [ + migrations.DeleteModel( + name="CategoryProblem", + ), + ] diff --git a/bullet/problems/models.py b/bullet/problems/models.py index e13171f7..87e833b2 100644 --- a/bullet/problems/models.py +++ b/bullet/problems/models.py @@ -7,30 +7,8 @@ class Problem(models.Model): competition = models.ForeignKey( "competitions.Competition", on_delete=models.CASCADE, related_name="+" ) - name = models.CharField(max_length=128) - - -class CategoryProblem(models.Model): - problem = models.ForeignKey( - Problem, on_delete=models.CASCADE, related_name="category_problems" - ) - category = models.ForeignKey( - "competitions.Category", - on_delete=models.CASCADE, - related_name="problems", - ) number = models.PositiveIntegerField() - class Meta: - constraints = ( - UniqueConstraint( - "problem", - "category", - "number", - name="categoryproblem__problem_category_number", - ), - ) - class SolvedProblem(models.Model): team = models.ForeignKey( @@ -39,6 +17,11 @@ class SolvedProblem(models.Model): problem = models.ForeignKey(Problem, on_delete=models.RESTRICT, related_name="+") competition_time = models.DurationField() + class Meta: + constraints = [ + UniqueConstraint("team", "problem", name="solvedproblem__team_problem") + ] + class ProblemStat(models.Model): team = models.ForeignKey("users.Team", on_delete=models.CASCADE, related_name="+") diff --git a/bullet/problems/templates/archive/problems.html b/bullet/problems/templates/archive/problems.html index 055dbecd..e60bf3d8 100644 --- a/bullet/problems/templates/archive/problems.html +++ b/bullet/problems/templates/archive/problems.html @@ -24,7 +24,8 @@