diff --git a/oioioi/scoresreveal/admin.py b/oioioi/scoresreveal/admin.py index 25d69a314..4c8a7dda4 100644 --- a/oioioi/scoresreveal/admin.py +++ b/oioioi/scoresreveal/admin.py @@ -3,9 +3,9 @@ from oioioi.base import admin from oioioi.base.forms import AlwaysChangedModelForm -from oioioi.contests.admin import ProblemInstanceAdmin, SubmissionAdmin +from oioioi.contests.admin import ContestAdmin, ProblemInstanceAdmin, SubmissionAdmin from oioioi.scoresreveal.models import ScoreRevealConfig -from oioioi.scoresreveal.utils import is_revealed +from oioioi.scoresreveal.utils import get_scores_reveal_config_universal, is_revealed class RevealedFilter(SimpleListFilter): @@ -28,6 +28,7 @@ class ScoresRevealConfigInline(admin.TabularInline): can_delete = True extra = 0 form = AlwaysChangedModelForm + exclude = ("contest",) class ScoresRevealProblemInstanceAdminMixin: @@ -37,10 +38,44 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.inlines = tuple(self.inlines) + (ScoresRevealConfigInline,) + def get_inline_instances(self, request, obj=None): + inline_instances = super().get_inline_instances(request, obj) + + contest = getattr(obj, "contest", None) + if not contest: + return inline_instances + if get_scores_reveal_config_universal(contest) is None: + additional_description = _(" (no contest-wide default set)") + else: + additional_description = _(" (overriding contest-wide default)") + + for inline in inline_instances: + if isinstance(inline, ScoresRevealConfigInline): + inline.verbose_name += additional_description + inline.verbose_name_plural += additional_description + return inline_instances + ProblemInstanceAdmin.mix_in(ScoresRevealProblemInstanceAdminMixin) +class ScoresRevealContestConfigInline(admin.TabularInline): + model = ScoreRevealConfig + extra = 0 + form = AlwaysChangedModelForm + category = _("Advanced") + exclude = ("problem_instance",) + + +class ScoresRevealContestConfigAdminMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.inlines = tuple(self.inlines) + (ScoresRevealContestConfigInline,) + + +ContestAdmin.mix_in(ScoresRevealContestConfigAdminMixin) + + class ScoresRevealSubmissionAdminMixin: """Adds reveal info and filter to an admin panel.""" diff --git a/oioioi/scoresreveal/controllers.py b/oioioi/scoresreveal/controllers.py index d561cee60..1ff9ee5d1 100644 --- a/oioioi/scoresreveal/controllers.py +++ b/oioioi/scoresreveal/controllers.py @@ -9,7 +9,7 @@ from oioioi.contests.models import Submission from oioioi.programs.controllers import ProgrammingContestController from oioioi.scoresreveal.models import ScoreReveal -from oioioi.scoresreveal.utils import has_scores_reveal, is_revealed +from oioioi.scoresreveal.utils import get_scores_reveal_config_for_problem_instance, has_scores_reveal, is_revealed class ScoresRevealContestControllerMixin: @@ -28,10 +28,10 @@ def get_revealed_submissions(self, user, problem_instance): return Submission.objects.filter(user=user, problem_instance=problem_instance, revealed__isnull=False) def get_scores_reveals_disable_time(self, problem_instance): - return problem_instance.scores_reveal_config.disable_time + return get_scores_reveal_config_for_problem_instance(problem_instance).disable_time def get_scores_reveals_limit(self, problem_instance): - return problem_instance.scores_reveal_config.reveal_limit + return get_scores_reveal_config_for_problem_instance(problem_instance).reveal_limit def is_scores_reveals_limit_reached(self, user, problem_instance): limit = self.get_scores_reveals_limit(problem_instance) diff --git a/oioioi/scoresreveal/migrations/0006_scorerevealconfig_contest_and_more.py b/oioioi/scoresreveal/migrations/0006_scorerevealconfig_contest_and_more.py new file mode 100644 index 000000000..77b35b121 --- /dev/null +++ b/oioioi/scoresreveal/migrations/0006_scorerevealconfig_contest_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.12 on 2026-06-03 11:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contests', '0025_merge_0017_submission_max_score_0024_roundstartdelay'), + ('scoresreveal', '0005_auto_20211123_1728'), + ] + + operations = [ + migrations.AddField( + model_name='scorerevealconfig', + name='contest', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scores_reveal_config', to='contests.contest', verbose_name='contest'), + ), + migrations.AlterField( + model_name='scorerevealconfig', + name='problem_instance', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='scores_reveal_config', to='contests.probleminstance', verbose_name='problem instance'), + ), + migrations.AlterField( + model_name='scorerevealconfig', + name='reveal_limit', + field=models.IntegerField(blank=True, help_text='If empty, all submissions are revealed automatically. A value of 0 disables reveals.', null=True, verbose_name='reveal limit'), + ), + ] diff --git a/oioioi/scoresreveal/models.py b/oioioi/scoresreveal/models.py index 6ef699463..7f22eeb1e 100644 --- a/oioioi/scoresreveal/models.py +++ b/oioioi/scoresreveal/models.py @@ -1,7 +1,8 @@ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from oioioi.contests.models import ProblemInstance, Submission +from oioioi.contests.models import Contest, ProblemInstance, Submission class ScoreReveal(models.Model): @@ -23,10 +24,20 @@ class ScoreRevealConfig(models.Model): verbose_name=_("problem instance"), related_name="scores_reveal_config", on_delete=models.CASCADE, + blank=True, + null=True, + ) + contest = models.OneToOneField( + Contest, + verbose_name=_("contest"), + related_name="scores_reveal_config", + on_delete=models.CASCADE, + blank=True, + null=True, ) reveal_limit = models.IntegerField( verbose_name=_("reveal limit"), - help_text=_("If empty, all submissions are revealed automatically."), + help_text=_("If empty, all submissions are revealed automatically. A value of 0 disables reveals."), blank=True, null=True, ) @@ -35,3 +46,10 @@ class ScoreRevealConfig(models.Model): class Meta: verbose_name = _("score reveal config") verbose_name_plural = _("score reveal configs") + + def clean(self): + super().clean() + if not self.problem_instance and not self.contest: + raise ValidationError(_("ScoresRevealConfig must be attached to either a contest or a problem instance.")) + if self.problem_instance and self.contest: + raise ValidationError(_("ScoresRevealConfig cannot be attached to both a contest and a problem instance simultaneously.")) diff --git a/oioioi/scoresreveal/utils.py b/oioioi/scoresreveal/utils.py index ae87b1d64..cc20cf66c 100644 --- a/oioioi/scoresreveal/utils.py +++ b/oioioi/scoresreveal/utils.py @@ -1,11 +1,34 @@ -from oioioi.scoresreveal.models import ScoreReveal, ScoreRevealConfig +from oioioi.scoresreveal.models import ScoreReveal + + +# Django doesn't cache a lack of the related object, so we do it manually. +def get_scores_reveal_config_universal(obj): + key = "_scores_reveal_config_cache" + if not hasattr(obj, key): + setattr(obj, key, getattr(obj, "scores_reveal_config", None)) + return getattr(obj, key) + + +def get_scores_reveal_config_for_problem_instance(problem_instance): + pi_config = get_scores_reveal_config_universal(problem_instance) + if pi_config is not None: + return pi_config + + if problem_instance.contest_id is None: + return None + # This performs well for many problem instances if the contest object is shared across + # them (e.g. in problem list view), so prefetch_related or annotate_known_related for + # the contest are needed in such places. + return get_scores_reveal_config_universal(problem_instance.contest) def has_scores_reveal(problem_instance): - try: - return bool(problem_instance.scores_reveal_config) - except ScoreRevealConfig.DoesNotExist: + scores_reveal_config = get_scores_reveal_config_for_problem_instance(problem_instance) + if scores_reveal_config is None: return False + reveal_limit = scores_reveal_config.reveal_limit + # None means auto-reveal. + return reveal_limit is None or reveal_limit > 0 def is_revealed(submission):