diff --git a/docs/case-championship-domain.md b/docs/case-championship-domain.md index 371c0818..5987a8e1 100644 --- a/docs/case-championship-domain.md +++ b/docs/case-championship-domain.md @@ -48,16 +48,18 @@ Program **Связи.** Имеет регистрации, заявки и сдачи; связывается с менеджерами, экспертами, курсами, материалами и legacy-проектами. -**Что хранит.** Описание и изображения, даты начала и завершения, дедлайны -регистрации, legacy-дедлайн подачи проектов и дедлайн оценки, схему -регистрационных данных, настройки конкурсности и доступности проектов. +**Что хранит.** Описание и изображения, даты начала и завершения, дедлайн +регистрации, отдельный дедлайн Application, legacy-дедлайн подачи проектов и +дедлайн оценки, схему регистрационных данных, настройки конкурсности, +доступности проектов, формата участия и размера команды. **Что не должна хранить.** Ответы конкретного участника, состав команды, решение или оценку конкретной работы. **Текущее состояние.** Реализована как `partner_programs.PartnerProgram`. -Специальных настроек формата участия, минимального/максимального размера -команды, дедлайна заявки и отдельного дедлайна решения пока нет. +Формат участия, минимальный/максимальный размер команды и отдельный дедлайн +Application хранятся в модели и защищены validation/constraints. Enforcement +в Application/team service и отдельный дедлайн решения пока отсутствуют. ### Registration @@ -615,6 +617,7 @@ Registration. | Область | Уже реализовано | Частично | Отсутствует | Комментарий | |---|---|---|---|---| | Registration | `PartnerProgramUserProfile`, register endpoints, `participating=1`, deadline и uniqueness | Статус Registration и связь с новым flow | Отдельная модель/явный contract Registration | Application create не требует Registration | +| Program participation policy | Форматы `individual_only/team_only/individual_or_team`, размеры Team, application deadline, model/DB validation и admin | Helpers пока вызываются только явно | Enforcement в Application/team service и публичный API | Default `individual_only`, размеры и deadline существующих программ остаются `null` | | Application model | Program, user, created_by, participation_mode, form_data, nullable Project, timestamps, partial unique constraint | Ownership по-прежнему опирается на Application.user | returned, snapshot, review reason | Default participation_mode временно individual | | Application API | create/my/detail/patch/submit/withdraw, idempotency create/submit/withdraw, scoped throttle create | Owner/staff access | review endpoints, manager access, deadline/form/team checks | 404 my application корректно означает отсутствие записи | | Application statuses | Шесть статусов и timestamps submit/approve/reject/withdraw | Переходы draft/submit/withdraw | `returned`, return/approve/reject/cancel actions | approved/rejected могут появиться только вне нового API, например через admin | @@ -628,7 +631,7 @@ Registration. | Notification | Email и chat WebSocket infrastructure | Mailing logs не являются inbox | Доменная Notification и пользовательский центр | Не входит в первый Team PR | | Evaluation | `Criteria`, `ProjectScore`, `ProjectExpertAssignment` для legacy Project | Эксперты и распределенное оценивание проекта | Evaluation по Submission | Нельзя смешивать с ProjectScore без миграции | | Result | Legacy scores и пользовательские достижения существуют отдельно | Нет единого результата заявки | Result/ranking/publication contract | Требует решения об источнике итогов | -| Deadlines | Registration и legacy project submission/evaluation dates | Поля Program можно переиспользовать только после решения semantics | Проверки в Application/Submission API, отдельные application/solution deadlines | Сейчас новый flow позволяет действия после дедлайнов | +| Deadlines | Registration, отдельный Application deadline и legacy project submission/evaluation dates | Application helper не подключен к endpoints | Проверки в Application/Submission API и отдельный solution deadline | Application deadline не имеет fallback на legacy-поля | | Permissions | Owner/staff для новых API; manager/expert permissions есть для legacy flow | Staff имеет расширенный доступ | Captain/member, manager и expert permissions для новых сущностей | Нужен общий domain permission layer | | Constraints | Registration uniqueness; active individual Application; Team one-to-one/member/captain constraints; Submission version; Project collaborators | Captain/member validation есть только внутри одной Team | Cross-team active participation и team size | Cross-table race требует транзакционного service | | Admin | PartnerProgram, Registration, Application, Team, TeamMember, Submission, Project, Invite и legacy evaluation зарегистрированы | Admin позволяет ручную диагностику | TeamInvite/Evaluation admin | Admin не заменяет transition services | @@ -683,12 +686,12 @@ Registration. | Team и TeamMember | Модели, admin, constraints и tests реализованы | Добавить транзакционный service и публичный API | P0 | Application/team invariants | | Только зарегистрированный создает Application | Не проверяется | Domain service должен проверять PartnerProgramUserProfile | P0 | Application eligibility and lifecycle | | Одна активная заявка на пользователя с учетом Team | Constraint покрывает только `Application.user` | Транзакционная проверка индивидуальных и командных участий | P0 | Application/team invariants | -| Валидация команды перед submit | Есть модель Team, но нет program policy/service | Настройки формата/размера Program и submit validation | P0 | Program participation policy | +| Валидация команды перед submit | Team и Program policy реализованы | Транзакционная проверка accepted-состава и формата перед submit | P0 | Application/team invariants | | Captain-only actions | Captain хранится, endpoints Team не учитывают | Team-aware permissions для Application и Submission | P0 | Team permissions | | Team API | Отсутствует | CRUD ограниченного MVP, members, leave/transfer captain | P1 | Team API | | Полный lifecycle Application | Нет returned/review actions | Добавить return/approve/reject/cancel с reason/audit | P1 | Application review API | | TeamInvite | Есть только Project Invite | Отдельные token/email/user invites и идемпотентный accept | P1 | TeamInvite model and API | -| Application deadlines | Новый API их не проверяет | Зафиксировать поля Program и проверять create/edit/submit/withdraw | P0 | Application eligibility and lifecycle | +| Application deadlines | Отдельное поле и helper Program реализованы, API их не проверяет | Проверять create/edit/submit/withdraw без fallback на legacy deadlines | P0 | Application eligibility and lifecycle | | Submission deadlines и роли | Новый API не проверяет сроки, manager/expert/team | Добавить policy и единый permission layer | P1 | Submission lifecycle hardening | | Return/finalize Submission | Статусы есть, действий нет | Организаторские endpoints и audit fields/reasons | P1 | Submission review API | | Semantics версий Submission | Несколько записей и auto-next уже есть | Определить current/final/evaluated version и поведение after return | P1 | Submission version workflow | @@ -704,8 +707,9 @@ Registration. 1. **`feature/application-team-model` — реализовано.** Добавлены `participation_mode`, Team и TeamMember с миграцией, admin, модельными ограничениями и тестами; без API. -2. **Program participation policy.** Зафиксировать разрешенные форматы, - минимальный/максимальный размер команды и реальные дедлайны Application. +2. **`feature/program-participation-policy` — реализовано.** Добавлены + разрешенные форматы, минимальный/максимальный размер команды и отдельный + дедлайн Application; enforcement в API намеренно не включен. 3. **Application/team invariants.** Ввести транзакционный service для проверки Registration, конфликтов активного участия, капитана и accepted-состава. 4. **Team permissions and API.** Добавить owner/captain/member/manager access, diff --git a/docs/program-participation-policy.md b/docs/program-participation-policy.md new file mode 100644 index 00000000..6b8eac6a --- /dev/null +++ b/docs/program-participation-policy.md @@ -0,0 +1,88 @@ +# Program Participation Policy + +## Назначение + +Policy хранится в `PartnerProgram` и задает допустимый формат участия, +границы размера команды и отдельный дедлайн подачи `Application`. Эти данные +станут источником истины для будущего транзакционного сервиса создания и +отправки заявок. + +В текущем изменении policy валидируется на уровне модели и базы данных, но еще +не применяется существующими Application endpoints. Публичные Program +serializers также намеренно не расширены. + +## Формат участия + +Поле `participation_format` поддерживает три значения: + +| Значение | Разрешенные финальные `Application.participation_mode` | +|---|---| +| `individual_only` | Только `individual` | +| `team_only` | Только `team` | +| `individual_or_team` | `individual` и `team` | + +`undecided` остается допустимым состоянием черновика Application, но не +считается разрешенным финальным форматом. Метод +`allows_participation_mode(mode)` возвращает `False` и для `undecided`, и для +неизвестного значения. + +Runtime default — `individual_only`. Он сохраняет поведение существующих +программ и не включает командные заявки без явной настройки организатора. +После миграции все существующие строки получают это значение. + +## Размер команды + +`team_min_size` и `team_max_size` применяются только к `team_only` и +`individual_or_team`. Для этих форматов оба поля обязательны, минимальный +размер не может быть меньше двух, а максимальный — меньше минимального. + +Для `individual_only` оба значения должны быть `null`. Существующим программам +произвольные границы не назначаются. + +Размер команды в будущем рассчитывается только по `TeamMember` со статусом +`accepted`. Капитан имеет accepted-запись `TeamMember` и входит в размер +команды. Фактическое число участников нельзя надежно проверить constraint-ом +одной таблицы: оно зависит от связанных строк и конкурентных изменений. +Поэтому эту проверку должен выполнять будущий транзакционный domain service +непосредственно перед submit Application. + +## Дедлайн Application + +`datetime_application_ends` — nullable datetime отдельного процесса заявки. +Он не переиспользует и не меняет semantics следующих legacy-полей: + +- `datetime_registration_ends`; +- `datetime_project_submission_ends`; +- `datetime_evaluation_ends`. + +Значение `null` означает, что отдельный дедлайн Application не настроен. Метод +`is_application_deadline_passed(at=None)` в таком случае возвращает `False`. +Fallback на регистрацию или сдачу проекта намеренно отсутствует: будущий +domain service должен явно определить поведение при `null`. + +## Validation и database constraints + +`PartnerProgram.clean()` привязывает русскоязычные ошибки к +`team_min_size`/`team_max_size` и проверяет соответствие размеров выбранному +формату. + +Database constraints дополнительно гарантируют: + +- каждый заданный размер не меньше двух; +- `team_max_size >= team_min_size`, когда заданы оба значения; +- у `individual_only` оба размера равны `null`; +- у форматов с командами оба размера заданы. + +Constraints защищают данные при обходе model validation, но не проверяют +фактический состав `Team`. + +## Текущие ограничения и следующий шаг + +Существующие Application API, Team API и legacy registration/project flow не +изменены. Policy пока не блокирует create/edit/submit Application, не проверяет +Registration и не разрешает конфликты участия между командами. + +Следующий PR должен добавить транзакционный Application/team service. Перед +submit он использует policy для проверки Registration, финального +`participation_mode`, accepted-состава Team и дедлайна без изменения legacy +дедлайнов. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index ed9831bf..352c9213 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -229,12 +229,20 @@ class Meta: inlines = [PartnerProgramMaterialInline, PartnerProgramFieldInline] form = PartnerProgramAdminForm - list_display = ("id", "name", "tag", "city", "datetime_created") + list_display = ( + "id", + "name", + "tag", + "city", + "participation_format", + "datetime_created", + ) list_display_links = ( "id", "name", "tag", "city", + "participation_format", "datetime_created", ) search_fields = ( @@ -242,7 +250,7 @@ class Meta: "city", "tag", ) - list_filter = ("city",) + list_filter = ("city", "participation_format") autocomplete_fields = ("managers",) date_hierarchy = "datetime_started" @@ -279,6 +287,16 @@ class Meta: ) }, ), + ( + "Участие и заявки", + { + "fields": ( + "participation_format", + ("team_min_size", "team_max_size"), + "datetime_application_ends", + ) + }, + ), ("Менеджеры программы", {"fields": ("managers",)}), ("Служебная информация", {"fields": ("datetime_created", "datetime_updated")}), ) diff --git a/partner_programs/migrations/0021_partnerprogram_datetime_application_ends_and_more.py b/partner_programs/migrations/0021_partnerprogram_datetime_application_ends_and_more.py new file mode 100644 index 00000000..1db1b5d3 --- /dev/null +++ b/partner_programs/migrations/0021_partnerprogram_datetime_application_ends_and_more.py @@ -0,0 +1,113 @@ +# Generated by Django 4.2.11 on 2026-07-21 12:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "partner_programs", + "0020_team_application_participation_mode_teammember_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="partnerprogram", + name="datetime_application_ends", + field=models.DateTimeField( + blank=True, + help_text="Отдельный дедлайн Application; другие дедлайны не используются как fallback", + null=True, + verbose_name="Дата окончания подачи заявок", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="participation_format", + field=models.CharField( + choices=[ + ("individual_only", "Только индивидуально"), + ("team_only", "Только в команде"), + ("individual_or_team", "Индивидуально или в команде"), + ], + default="individual_only", + max_length=24, + verbose_name="Разрешенный формат участия", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="team_max_size", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Количество принятых участников с учетом капитана", + null=True, + verbose_name="Максимальный размер команды", + ), + ), + migrations.AddField( + model_name="partnerprogram", + name="team_min_size", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Количество принятых участников с учетом капитана", + null=True, + verbose_name="Минимальный размер команды", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("team_min_size__isnull", True), + ("team_min_size__gte", 2), + _connector="OR", + ), + name="program_team_min_gte_2_or_null", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("team_max_size__isnull", True), + ("team_max_size__gte", 2), + _connector="OR", + ), + name="program_team_max_gte_2_or_null", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + ("team_min_size__isnull", True), + ("team_max_size__isnull", True), + ("team_max_size__gte", models.F("team_min_size")), + _connector="OR", + ), + name="program_team_max_gte_min", + ), + ), + migrations.AddConstraint( + model_name="partnerprogram", + constraint=models.CheckConstraint( + check=models.Q( + models.Q( + ("participation_format", "individual_only"), + ("team_max_size__isnull", True), + ("team_min_size__isnull", True), + ), + models.Q( + ("participation_format__in", ("team_only", "individual_or_team")), + ("team_max_size__isnull", False), + ("team_min_size__isnull", False), + ), + _connector="OR", + ), + name="program_team_sizes_match_format", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index e0631586..f470c46d 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -35,6 +35,16 @@ class PartnerProgram(models.Model): ("experts_only", "Только экспертам"), ] + PARTICIPATION_FORMAT_INDIVIDUAL_ONLY = "individual_only" + PARTICIPATION_FORMAT_TEAM_ONLY = "team_only" + PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM = "individual_or_team" + + PARTICIPATION_FORMAT_CHOICES = ( + (PARTICIPATION_FORMAT_INDIVIDUAL_ONLY, "Только индивидуально"), + (PARTICIPATION_FORMAT_TEAM_ONLY, "Только в команде"), + (PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, "Индивидуально или в команде"), + ) + name = models.TextField( verbose_name="Название", ) @@ -125,9 +135,35 @@ class PartnerProgram(models.Model): verbose_name="Публиковать проекты после окончания программы", help_text="Если включено, проекты участников станут публичными после завершения программы", ) + # До появления выбора формата весь существующий flow остается индивидуальным. + participation_format = models.CharField( + max_length=24, + choices=PARTICIPATION_FORMAT_CHOICES, + default=PARTICIPATION_FORMAT_INDIVIDUAL_ONLY, + verbose_name="Разрешенный формат участия", + ) + # Будущий service считает accepted-участников вместе с капитаном. + team_min_size = models.PositiveSmallIntegerField( + null=True, + blank=True, + verbose_name="Минимальный размер команды", + help_text="Количество принятых участников с учетом капитана", + ) + team_max_size = models.PositiveSmallIntegerField( + null=True, + blank=True, + verbose_name="Максимальный размер команды", + help_text="Количество принятых участников с учетом капитана", + ) datetime_registration_ends = models.DateTimeField( verbose_name="Дата окончания регистрации", ) + datetime_application_ends = models.DateTimeField( + null=True, + blank=True, + verbose_name="Дата окончания подачи заявок", + help_text="Отдельный дедлайн Application; другие дедлайны не используются как fallback", + ) datetime_project_submission_ends = models.DateTimeField( null=True, blank=True, @@ -160,9 +196,122 @@ def is_manager(self, user: User) -> bool: return False return self.managers.filter(pk=user.pk).exists() + def clean(self): + """Проверяет согласованность формата участия и размеров команды.""" + super().clean() + errors = {} + + if self.participation_format == self.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY: + if self.team_min_size is not None: + errors["team_min_size"] = ( + "Для индивидуального формата минимальный размер команды не задается." + ) + if self.team_max_size is not None: + errors["team_max_size"] = ( + "Для индивидуального формата максимальный размер команды не задается." + ) + elif self.participation_format in { + self.PARTICIPATION_FORMAT_TEAM_ONLY, + self.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM, + }: + if self.team_min_size is None: + errors["team_min_size"] = ( + "Для командного формата укажите минимальный размер команды." + ) + elif self.team_min_size < 2: + errors["team_min_size"] = ( + "Минимальный размер команды должен быть не меньше двух." + ) + + if self.team_max_size is None: + errors["team_max_size"] = ( + "Для командного формата укажите максимальный размер команды." + ) + elif self.team_max_size < 2: + errors["team_max_size"] = ( + "Максимальный размер команды должен быть не меньше двух." + ) + elif ( + self.team_min_size is not None + and self.team_max_size < self.team_min_size + ): + errors["team_max_size"] = ( + "Максимальный размер команды не может быть меньше минимального." + ) + + if errors: + raise ValidationError(errors) + + def allows_participation_mode(self, mode: str) -> bool: + """Возвращает, разрешен ли выбранный финальный формат заявки.""" + allowed_modes = { + self.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY: { + Application.PARTICIPATION_MODE_INDIVIDUAL + }, + self.PARTICIPATION_FORMAT_TEAM_ONLY: { + Application.PARTICIPATION_MODE_TEAM + }, + self.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM: { + Application.PARTICIPATION_MODE_INDIVIDUAL, + Application.PARTICIPATION_MODE_TEAM, + }, + } + return mode in allowed_modes.get(self.participation_format, set()) + + def is_application_deadline_passed(self, at=None) -> bool: + """Проверяет отдельный дедлайн Application без fallback на legacy-поля.""" + if self.datetime_application_ends is None: + return False + moment = at if at is not None else timezone.now() + return moment > self.datetime_application_ends + class Meta: verbose_name = "Программа" verbose_name_plural = "Программы" + # Constraints защищают policy одной программы. Фактический состав Team + # требует проверки связанных строк в будущем транзакционном service. + constraints = [ + models.CheckConstraint( + check=( + models.Q(team_min_size__isnull=True) + | models.Q(team_min_size__gte=2) + ), + name="program_team_min_gte_2_or_null", + ), + models.CheckConstraint( + check=( + models.Q(team_max_size__isnull=True) + | models.Q(team_max_size__gte=2) + ), + name="program_team_max_gte_2_or_null", + ), + models.CheckConstraint( + check=( + models.Q(team_min_size__isnull=True) + | models.Q(team_max_size__isnull=True) + | models.Q(team_max_size__gte=models.F("team_min_size")) + ), + name="program_team_max_gte_min", + ), + models.CheckConstraint( + check=( + models.Q( + participation_format="individual_only", + team_min_size__isnull=True, + team_max_size__isnull=True, + ) + | models.Q( + participation_format__in=( + "team_only", + "individual_or_team", + ), + team_min_size__isnull=False, + team_max_size__isnull=False, + ) + ), + name="program_team_sizes_match_format", + ), + ] def __str__(self): return f"PartnerProgram<{self.pk}> - {self.name}" diff --git a/partner_programs/tests/test_program_participation_policy.py b/partner_programs/tests/test_program_participation_policy.py new file mode 100644 index 00000000..09ff28f3 --- /dev/null +++ b/partner_programs/tests/test_program_participation_policy.py @@ -0,0 +1,272 @@ +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase, TransactionTestCase +from django.utils import timezone + +from partner_programs.models import Application, PartnerProgram +from partner_programs.serializers.programs import ( + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + PartnerProgramListSerializer, +) +from partner_programs.tests.helpers import create_partner_program + + +class PartnerProgramParticipationPolicyTests(TestCase): + def assert_policy_invalid(self, expected_field, **overrides): + program = create_partner_program(data_schema={"field": "value"}) + for field, value in overrides.items(): + setattr(program, field, value) + + with self.assertRaises(ValidationError) as error: + program.full_clean() + + self.assertIn(expected_field, error.exception.message_dict) + + def test_participation_format_defaults_to_individual_only(self): + program = create_partner_program() + + self.assertEqual( + program.participation_format, + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY, + ) + + def test_team_min_size_defaults_to_null(self): + program = create_partner_program() + + self.assertIsNone(program.team_min_size) + + def test_team_max_size_defaults_to_null(self): + program = create_partner_program() + + self.assertIsNone(program.team_max_size) + + def test_application_deadline_defaults_to_null(self): + program = create_partner_program() + + self.assertIsNone(program.datetime_application_ends) + + def test_participation_format_choices_contain_expected_values(self): + self.assertEqual( + {value for value, _label in PartnerProgram.PARTICIPATION_FORMAT_CHOICES}, + {"individual_only", "team_only", "individual_or_team"}, + ) + + def test_individual_only_without_team_sizes_is_valid(self): + program = create_partner_program(data_schema={"field": "value"}) + + program.full_clean() + + def test_individual_only_with_team_min_size_is_invalid(self): + self.assert_policy_invalid("team_min_size", team_min_size=2) + + def test_individual_only_with_team_max_size_is_invalid(self): + self.assert_policy_invalid("team_max_size", team_max_size=5) + + def test_team_only_without_team_sizes_is_invalid(self): + self.assert_policy_invalid( + "team_min_size", + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + ) + + def test_individual_or_team_without_team_sizes_is_invalid(self): + self.assert_policy_invalid( + "team_max_size", + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + ) + + def test_team_min_size_less_than_two_is_invalid(self): + self.assert_policy_invalid( + "team_min_size", + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=1, + team_max_size=5, + ) + + def test_team_max_size_less_than_minimum_is_invalid(self): + self.assert_policy_invalid( + "team_max_size", + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=5, + team_max_size=4, + ) + + def test_team_only_with_valid_sizes_is_valid(self): + program = create_partner_program(data_schema={"field": "value"}) + program.participation_format = PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY + program.team_min_size = 2 + program.team_max_size = 5 + + program.full_clean() + + def test_individual_or_team_with_valid_sizes_is_valid(self): + program = create_partner_program(data_schema={"field": "value"}) + program.participation_format = ( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM + ) + program.team_min_size = 2 + program.team_max_size = 5 + + program.full_clean() + + def test_individual_only_allows_individual_mode(self): + program = create_partner_program() + + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_INDIVIDUAL) + ) + + def test_individual_only_rejects_team_mode(self): + program = create_partner_program() + + self.assertFalse( + program.allows_participation_mode(Application.PARTICIPATION_MODE_TEAM) + ) + + def test_team_only_allows_team_mode(self): + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_TEAM) + ) + + def test_team_only_rejects_individual_mode(self): + program = create_partner_program( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=2, + team_max_size=5, + ) + + self.assertFalse( + program.allows_participation_mode(Application.PARTICIPATION_MODE_INDIVIDUAL) + ) + + def test_individual_or_team_allows_both_final_modes(self): + program = create_partner_program( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=5, + ) + + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_INDIVIDUAL) + ) + self.assertTrue( + program.allows_participation_mode(Application.PARTICIPATION_MODE_TEAM) + ) + + def test_undecided_is_not_an_allowed_final_mode(self): + program = create_partner_program( + participation_format=(PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_OR_TEAM), + team_min_size=2, + team_max_size=5, + ) + + self.assertFalse( + program.allows_participation_mode(Application.PARTICIPATION_MODE_UNDECIDED) + ) + + def test_unknown_participation_mode_is_not_allowed(self): + program = create_partner_program() + + self.assertFalse(program.allows_participation_mode("unknown")) + + def test_null_application_deadline_is_not_passed(self): + program = create_partner_program(datetime_application_ends=None) + + self.assertFalse(program.is_application_deadline_passed()) + + def test_future_application_deadline_is_not_passed(self): + program = create_partner_program( + datetime_application_ends=timezone.now() + timezone.timedelta(days=1) + ) + + self.assertFalse(program.is_application_deadline_passed()) + + def test_past_application_deadline_is_passed(self): + program = create_partner_program( + datetime_application_ends=timezone.now() - timezone.timedelta(days=1) + ) + + self.assertTrue(program.is_application_deadline_passed()) + + def test_application_deadline_accepts_explicit_moment(self): + deadline = timezone.now() + program = create_partner_program(datetime_application_ends=deadline) + + self.assertFalse( + program.is_application_deadline_passed( + at=deadline - timezone.timedelta(seconds=1) + ) + ) + self.assertTrue( + program.is_application_deadline_passed( + at=deadline + timezone.timedelta(seconds=1) + ) + ) + + def test_public_program_serializers_do_not_expose_policy_fields(self): + policy_fields = { + "participation_format", + "team_min_size", + "team_max_size", + "datetime_application_ends", + } + + for serializer_class in ( + PartnerProgramListSerializer, + PartnerProgramForMemberSerializer, + PartnerProgramForUnregisteredUserSerializer, + ): + with self.subTest(serializer=serializer_class.__name__): + self.assertTrue( + policy_fields.isdisjoint(serializer_class().fields.keys()) + ) + + +class PartnerProgramParticipationPolicyConstraintTests(TransactionTestCase): + def test_database_rejects_team_min_size_less_than_two(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=1, + team_max_size=5, + ) + + def test_database_rejects_team_max_size_less_than_minimum(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=5, + team_max_size=4, + ) + + def test_database_rejects_team_format_without_sizes(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=PartnerProgram.PARTICIPATION_FORMAT_TEAM_ONLY, + team_min_size=None, + team_max_size=None, + ) + + def test_database_rejects_individual_format_with_team_sizes(self): + program = create_partner_program() + + with self.assertRaises(IntegrityError), transaction.atomic(): + PartnerProgram.objects.filter(pk=program.pk).update( + participation_format=( + PartnerProgram.PARTICIPATION_FORMAT_INDIVIDUAL_ONLY + ), + team_min_size=2, + team_max_size=5, + )