Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions partner_programs/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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__"
Expand Down Expand Up @@ -102,6 +139,7 @@ class Meta:
"datetime_evaluation_ends",
"datetime_finished",
),
"confirm_reopen",
(
"image_address",
"cover_image_address",
Expand Down
23 changes: 5 additions & 18 deletions partner_programs/management_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"Изменение срока снова откроет прием или оценивание. Подтвердите сохранение.",
Expand Down
46 changes: 46 additions & 0 deletions partner_programs/program_dates.py
Original file line number Diff line number Diff line change
@@ -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))
)
21 changes: 13 additions & 8 deletions partner_programs/serializers/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -240,12 +234,23 @@ 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:
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):
Expand Down
10 changes: 5 additions & 5 deletions partner_programs/services/program_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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


Expand Down
161 changes: 161 additions & 0 deletions partner_programs/tests/test_program_dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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_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()
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)
Loading