From 23bbceb80e77de63fe4a4b0a3618517c62dbc5de Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:41:46 +0300 Subject: [PATCH 1/2] fix(programs): validate calendar edits in API and admin --- partner_programs/admin.py | 38 +++++ partner_programs/management_views.py | 23 +-- partner_programs/program_dates.py | 46 ++++++ partner_programs/serializers/management.py | 16 +- partner_programs/services/program_launch.py | 10 +- partner_programs/tests/test_program_dates.py | 156 +++++++++++++++++++ 6 files changed, 259 insertions(+), 30 deletions(-) create mode 100644 partner_programs/program_dates.py create mode 100644 partner_programs/tests/test_program_dates.py diff --git a/partner_programs/admin.py b/partner_programs/admin.py index ff4bd8f8..58ca46c0 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -29,6 +29,11 @@ ) from partner_programs.services.case_fields import case_field_has_values from partner_programs.admin_registration import ProgramRegistrationAdminMixin +from partner_programs.program_dates import ( + DATE_FIELDS, + program_date_errors, + reopens_program_deadline, +) class PartnerProgramMaterialInline(admin.StackedInline): @@ -48,6 +53,38 @@ class PartnerProgramFieldInline(admin.TabularInline): @admin.register(PartnerProgram) class PartnerProgramAdmin(ProgramRegistrationAdminMixin, admin.ModelAdmin): class PartnerProgramAdminForm(forms.ModelForm): + confirm_reopen = forms.BooleanField( + required=False, + label="Подтверждаю повторное открытие приема или оценивания", + help_text="Нужно при переносе завершенного срока на будущую дату.", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if "city" in self.fields: + self.fields["city"].required = False + + def clean(self): + data = super().clean() + if not self.instance.pk or any( + key in self.changed_data for key in (*DATE_FIELDS, "draft") + ): + errors = program_date_errors( + data, require_complete=not data.get("draft", True) + ) + if self.instance.pk and not data.get("confirm_reopen"): + previous = PartnerProgram.objects.values(*DATE_FIELDS).get( + pk=self.instance.pk + ) + if reopens_program_deadline(previous, data, now=timezone.now()): + errors["confirm_reopen"] = ( + "Изменение срока снова откроет прием или оценивание. " + "Подтвердите сохранение." + ) + if errors: + raise forms.ValidationError(errors) + return data + class Meta: model = PartnerProgram fields = "__all__" @@ -102,6 +139,7 @@ class Meta: "datetime_evaluation_ends", "datetime_finished", ), + "confirm_reopen", ( "image_address", "cover_image_address", diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 1eef5ffb..dff4e9d1 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -318,26 +318,13 @@ def patch(self, request, pk, section): "Программа изменена другим организатором. Обновите данные перед сохранением.", ) confirm = values.pop("confirm_reopen", False) - before = [ - program.datetime_registration_ends, - program.get_project_submission_deadline(), - program.datetime_evaluation_ends, - ] + from partner_programs.program_dates import DATE_FIELDS, reopens_program_deadline + + before = {key: getattr(program, key) for key in DATE_FIELDS} for key, value in values.items(): setattr(program, key, value) - after = [ - program.datetime_registration_ends, - program.get_project_submission_deadline(), - program.datetime_evaluation_ends, - ] - now = timezone.now() - if ( - any( - old is not None and old < now and (new is None or new >= now) - for old, new in zip(before, after) - ) - and not confirm - ): + after = {key: getattr(program, key) for key in DATE_FIELDS} + if reopens_program_deadline(before, after, now=timezone.now()) and not confirm: return conflict( "reopen_confirmation_required", "Изменение срока снова откроет прием или оценивание. Подтвердите сохранение.", diff --git a/partner_programs/program_dates.py b/partner_programs/program_dates.py new file mode 100644 index 00000000..5d127b11 --- /dev/null +++ b/partner_programs/program_dates.py @@ -0,0 +1,46 @@ +"""Общие проверки календаря для публикации, API и Django-админки.""" + +DATE_FIELDS = ( + "datetime_started", + "datetime_registration_ends", + "datetime_project_submission_ends", + "datetime_evaluation_ends", + "datetime_finished", +) + + +def program_date_errors(values, *, require_complete=False): + errors = {} + if require_complete: + for field in ( + "datetime_started", + "datetime_finished", + "datetime_registration_ends", + ): + if values.get(field) is None: + errors[field] = "Укажите дату и время." + start = values.get("datetime_started") + finish = values.get("datetime_finished") + registration = values.get("datetime_registration_ends") + if start is not None and finish is not None and finish <= start: + errors["datetime_finished"] = "Окончание программы должно быть позже начала." + if registration is not None and finish is not None and registration > finish: + errors[ + "datetime_registration_ends" + ] = "Регистрация должна завершиться не позже окончания программы." + return errors + + +def reopens_program_deadline(before, after, *, now): + def deadlines(values): + return ( + values.get("datetime_registration_ends"), + values.get("datetime_project_submission_ends") + or values.get("datetime_registration_ends"), + values.get("datetime_evaluation_ends"), + ) + + return any( + old is not None and old < now and (new is None or new >= now) + for old, new in zip(deadlines(before), deadlines(after)) + ) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index 867fb304..fe37f818 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -7,6 +7,7 @@ from files.models import UserFile from partner_programs.models import PartnerProgram +from partner_programs.program_dates import DATE_FIELDS, program_date_errors class StrictFieldsMixin: @@ -198,13 +199,6 @@ class ManagementProjectActionApply(ManagementProjectActionWrite): "registration_link", "presentation_address", ) -DATE_FIELDS = ( - "datetime_started", - "datetime_registration_ends", - "datetime_project_submission_ends", - "datetime_evaluation_ends", - "datetime_finished", -) class ManagementSettingsSerializer(StrictFieldsMixin, serializers.ModelSerializer): @@ -240,6 +234,14 @@ def validate(self, attrs): editable = BASIC_FIELDS if self.context["section"] == "basic" else DATE_FIELDS if not any(key in attrs for key in editable): raise serializers.ValidationError("Нет измененных полей.") + if self.context["section"] == "dates": + values = { + key: attrs.get(key, getattr(self.instance, key, None)) + for key in DATE_FIELDS + } + errors = program_date_errors(values, require_complete=not self.instance.draft) + if errors: + raise serializers.ValidationError(errors) return attrs class Meta: diff --git a/partner_programs/services/program_launch.py b/partner_programs/services/program_launch.py index 09ef4fc4..05195f14 100644 --- a/partner_programs/services/program_launch.py +++ b/partner_programs/services/program_launch.py @@ -16,9 +16,10 @@ from partner_programs.models import PartnerProgram, ProgramRegistration from partner_programs.permissions import require_create_program from partner_programs.services.management import managed_program -from partner_programs.serializers.management import BASIC_FIELDS, DATE_FIELDS +from partner_programs.serializers.management import BASIC_FIELDS from partner_programs.services import native_registration as registration from project_rates.workspace import WorkspaceConflict +from partner_programs.program_dates import DATE_FIELDS, program_date_errors FORMATS = ("case_championship", "hackathon", "noncompetitive") @@ -169,12 +170,11 @@ def _date_problem(program): ) ): return "Укажите начало, окончание программы и окончание регистрации." - if program.datetime_finished <= program.datetime_started: - return "Окончание программы должно быть позже начала." + errors = program_date_errors({key: getattr(program, key) for key in DATE_FIELDS}) + if errors: + return next(iter(errors.values())) if program.datetime_registration_ends <= timezone.now(): return "Окончание регистрации должно быть в будущем, чтобы открыть прием." - if program.datetime_registration_ends > program.datetime_finished: - return "Регистрация должна завершиться не позже окончания программы." return None diff --git a/partner_programs/tests/test_program_dates.py b/partner_programs/tests/test_program_dates.py new file mode 100644 index 00000000..6e8305d0 --- /dev/null +++ b/partner_programs/tests/test_program_dates.py @@ -0,0 +1,156 @@ +from datetime import timedelta + +from django.forms import modelform_factory +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.admin import PartnerProgramAdmin +from partner_programs.models import PartnerProgram +from partner_programs.program_dates import DATE_FIELDS +from partner_programs.tests.helpers import create_partner_program, create_user + + +class ProgramDatesTests(TestCase): + def setUp(self): + self.now = timezone.now() + self.program = create_partner_program( + datetime_started=self.now + timedelta(days=1), + datetime_registration_ends=self.now + timedelta(days=10), + datetime_finished=self.now + timedelta(days=30), + ) + self.manager = create_user() + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def patch_dates(self, **changes): + self.program.refresh_from_db() + return self.client.patch( + f"/programs/{self.program.pk}/management/settings/dates/", + {"expected_updated_at": self.program.datetime_updated, **changes}, + format="json", + ) + + def admin_form(self, **changes): + self.program.refresh_from_db() + form_class = modelform_factory( + PartnerProgram, + form=PartnerProgramAdmin.PartnerProgramAdminForm, + fields=("name", "city", "draft", *DATE_FIELDS), + ) + values = { + key: getattr(self.program, key) + for key in ("name", "city", "draft", *DATE_FIELDS) + } + values.update(changes) + for key in DATE_FIELDS: + if values.get(key) is not None: + values[key] = values[key].isoformat(timespec="seconds") + return form_class(data=values, instance=self.program) + + def test_api_rejects_invalid_partial_dates_without_saving(self): + original = self.program.datetime_finished + for finish in (self.program.datetime_started, self.now): + with self.subTest(finish=finish): + response = self.patch_dates(datetime_finished=finish) + self.assertEqual(response.status_code, 400, response.data) + self.assertIn("datetime_finished", response.data) + self.program.refresh_from_db() + self.assertEqual(self.program.datetime_finished, original) + response = self.patch_dates( + datetime_registration_ends=original + timedelta(days=1) + ) + self.assertEqual(response.status_code, 400, response.data) + self.assertIn("datetime_registration_ends", response.data) + + def test_api_and_admin_allow_registration_after_start_until_program_end(self): + for registration in ( + self.program.datetime_started + timedelta(days=1), + self.program.datetime_finished, + ): + with self.subTest(registration=registration): + form = self.admin_form(datetime_registration_ends=registration) + self.assertTrue(form.is_valid(), form.errors) + response = self.patch_dates(datetime_registration_ends=registration) + self.assertEqual(response.status_code, 200, response.data) + + def test_draft_can_be_incomplete_but_known_dates_must_agree(self): + self.program.draft = True + self.program.save() + response = self.patch_dates(datetime_finished=None) + self.assertEqual(response.status_code, 200, response.data) + form = self.admin_form(datetime_started=None, datetime_registration_ends=None) + self.assertTrue(form.is_valid(), form.errors) + response = self.patch_dates(datetime_finished=self.now) + self.assertEqual(response.status_code, 400, response.data) + self.assertIn("datetime_finished", response.data) + + def test_published_dates_cannot_be_cleared_in_either_interface(self): + for field in ( + "datetime_started", + "datetime_finished", + "datetime_registration_ends", + ): + with self.subTest(field=field): + response = self.patch_dates(**{field: None}) + self.assertEqual(response.status_code, 400, response.data) + form = self.admin_form(**{field: None}) + self.assertFalse(form.is_valid()) + self.assertIn(field, form.errors) + + def test_admin_rejects_same_invalid_order_as_api(self): + form = self.admin_form(datetime_finished=self.now) + self.assertFalse(form.is_valid()) + self.assertIn("datetime_finished", form.errors) + self.assertIn("datetime_registration_ends", form.errors) + + def test_reopening_requires_confirmation_in_both_interfaces(self): + self.program.datetime_registration_ends = self.now - timedelta(days=1) + self.program.save() + registration = self.now + timedelta(days=2) + response = self.patch_dates(datetime_registration_ends=registration) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "reopen_confirmation_required") + form = self.admin_form(datetime_registration_ends=registration) + self.assertFalse(form.is_valid()) + self.assertIn("confirm_reopen", form.errors) + form = self.admin_form( + datetime_registration_ends=registration, confirm_reopen=True + ) + self.assertTrue(form.is_valid(), form.errors) + response = self.patch_dates( + datetime_registration_ends=registration, confirm_reopen=True + ) + self.assertEqual(response.status_code, 200, response.data) + + def test_admin_can_edit_a_frontend_program_without_city(self): + self.program.city = "" + self.program.save() + form = self.admin_form(datetime_finished=self.now + timedelta(days=35)) + self.assertTrue(form.is_valid(), form.errors) + form.save() + self.program.refresh_from_db() + self.assertEqual(self.program.city, "") + + def test_past_registration_is_valid_when_editing_existing_program(self): + self.program.datetime_started = self.now - timedelta(days=30) + self.program.save() + registration = self.now - timedelta(days=1) + response = self.patch_dates(datetime_registration_ends=registration) + self.assertEqual(response.status_code, 200, response.data) + form = self.admin_form(datetime_finished=self.now + timedelta(days=5)) + self.assertTrue(form.is_valid(), form.errors) + + def test_metadata_edit_does_not_block_legacy_inconsistent_calendar(self): + self.program.datetime_finished = self.now + self.program.save() + form = self.admin_form(name="Исправленное название") + self.assertTrue(form.is_valid(), form.errors) + + def test_dates_validation_does_not_grant_access(self): + for actor, status in ((create_user(), 403), (None, 401)): + with self.subTest(status=status): + self.client.force_authenticate(actor) + response = self.patch_dates(datetime_finished=self.now) + self.assertEqual(response.status_code, status, response.data) From 499ec8f54af226c6ef25410ccbedf4c698ed516b Mon Sep 17 00:00:00 2001 From: Peter <81633644+TThanos3000@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:47:36 +0300 Subject: [PATCH 2/2] fix(programs): allow an empty city in both editors --- partner_programs/serializers/management.py | 5 ++++- partner_programs/tests/test_program_dates.py | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index fe37f818..003e7d1d 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -247,7 +247,10 @@ def validate(self, attrs): class Meta: model = PartnerProgram fields = (*BASIC_FIELDS, *DATE_FIELDS, "expected_updated_at", "confirm_reopen") - extra_kwargs = {"name": {"allow_blank": False}} + extra_kwargs = { + "name": {"allow_blank": False}, + "city": {"allow_blank": True}, + } class ManagementMaterialWrite(StrictFieldsMixin, serializers.Serializer): diff --git a/partner_programs/tests/test_program_dates.py b/partner_programs/tests/test_program_dates.py index 6e8305d0..f37376ea 100644 --- a/partner_programs/tests/test_program_dates.py +++ b/partner_programs/tests/test_program_dates.py @@ -124,9 +124,14 @@ def test_reopening_requires_confirmation_in_both_interfaces(self): ) self.assertEqual(response.status_code, 200, response.data) - def test_admin_can_edit_a_frontend_program_without_city(self): - self.program.city = "" - self.program.save() + def test_city_can_be_cleared_in_both_interfaces(self): + response = self.client.patch( + f"/programs/{self.program.pk}/management/settings/basic/", + {"city": "", "expected_updated_at": self.program.datetime_updated}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["city"], "") form = self.admin_form(datetime_finished=self.now + timedelta(days=35)) self.assertTrue(form.is_valid(), form.errors) form.save()