From dc26e1c5cbbeb5549d87e9a8bb0f07e3bd31fa46 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 20 Jul 2026 16:34:23 +0300 Subject: [PATCH] Add submission model --- docs/application-project-submission-rfc.md | 11 +- partner_programs/admin.py | 43 ++++ .../migrations/0019_submission.py | 94 +++++++++ partner_programs/models.py | 132 ++++++++++++ .../tests/test_submission_model.py | 199 ++++++++++++++++++ 5 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 partner_programs/migrations/0019_submission.py create mode 100644 partner_programs/tests/test_submission_model.py diff --git a/docs/application-project-submission-rfc.md b/docs/application-project-submission-rfc.md index b7acf62d..3a82867d 100644 --- a/docs/application-project-submission-rfc.md +++ b/docs/application-project-submission-rfc.md @@ -383,13 +383,12 @@ Project не является заявкой. Он может быть: - `id` - `application` - `program` -- `stage`, nullable +- `stage_key`, строковый ключ этапа - `submitted_by` - `title` - `description` - `form_data`, JSON - `links`, JSON/list -- `files` relation - `status` - `version` - `submitted_at` @@ -409,10 +408,11 @@ Project не является заявкой. Он может быть: - `submitted` - отправлена; - `returned` - возвращена на доработку; - `final` - финальная версия зафиксирована. +- `cancelled` - сдача отменена. Ограничения от дублей: -- выбрать стратегию уникальности: `application + stage + version` или `application + stage + active_status`; +- уникальность `application + stage_key + version`; - повторный POST должен быть идемпотентным или защищенным constraint-ами; - нельзя создавать сдачу для отозванной, отклоненной или отмененной заявки, если правила программы это запрещают. @@ -426,6 +426,11 @@ Project не является заявкой. Он может быть: Submission не является заявкой и не является проектом. Это событие или версия сдачи решения. Эксперт должен оценивать конкретную сдачу, а не абстрактный проект, который может измениться после дедлайна. +MVP-заметка: отдельная модель `Stage` пока не создается, вместо нее используется +`stage_key="main"`. Модели `SubmissionFile` и `SubmissionLink` также отложены: +ссылки временно хранятся в `links` как JSON-массив, а полноценные этапы и файлы +будут добавлены отдельными PR. + ### SubmissionFile / SubmissionLink Назначение: вложения и ссылки, прикрепленные к конкретной сдаче. diff --git a/partner_programs/admin.py b/partner_programs/admin.py index feb29d70..8251afa6 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -18,6 +18,7 @@ PartnerProgramMaterial, PartnerProgramProject, PartnerProgramUserProfile, + Submission, ) from partner_programs.services import prepare_project_scores_export_data @@ -60,6 +61,48 @@ class ApplicationAdmin(admin.ModelAdmin): date_hierarchy = "created_at" +@admin.register(Submission) +class SubmissionAdmin(admin.ModelAdmin): + list_display = ( + "id", + "application", + "program", + "submitted_by", + "title", + "status", + "stage_key", + "version", + "submitted_at", + "created_at", + "updated_at", + ) + list_filter = ( + "status", + "program", + "stage_key", + "created_at", + ) + search_fields = ( + "title", + "submitted_by__email", + "submitted_by__first_name", + "submitted_by__last_name", + "program__name", + "program__tag", + "=application__id", + ) + raw_id_fields = ( + "application", + "program", + "submitted_by", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + date_hierarchy = "created_at" + + class PartnerProgramMaterialInline(admin.StackedInline): model = PartnerProgramMaterial extra = 1 diff --git a/partner_programs/migrations/0019_submission.py b/partner_programs/migrations/0019_submission.py new file mode 100644 index 00000000..84af4b43 --- /dev/null +++ b/partner_programs/migrations/0019_submission.py @@ -0,0 +1,94 @@ +# Generated by Django 4.2.11 on 2026-07-20 13:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("partner_programs", "0018_application"), + ] + + operations = [ + migrations.CreateModel( + name="Submission", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("form_data", models.JSONField(blank=True, default=dict)), + ("links", models.JSONField(blank=True, default=list)), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitted", "Submitted"), + ("returned", "Returned"), + ("final", "Final"), + ("cancelled", "Cancelled"), + ], + default="draft", + max_length=16, + ), + ), + ("version", models.PositiveIntegerField(default=1)), + ("stage_key", models.CharField(default="main", max_length=128)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="partner_programs.application", + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="partner_programs.partnerprogram", + ), + ), + ( + "submitted_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="program_submissions", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Submission", + "verbose_name_plural": "Submissions", + }, + ), + migrations.AddConstraint( + model_name="submission", + constraint=models.UniqueConstraint( + fields=("application", "stage_key", "version"), + name="uniq_submission_application_stage_version", + ), + ), + migrations.AddConstraint( + model_name="submission", + constraint=models.CheckConstraint( + check=models.Q(("version__gte", 1)), name="submission_version_gte_1" + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 9967c7f9..6672ae1b 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -294,6 +294,138 @@ def __str__(self): ) +class Submission(models.Model): + """Versioned solution submitted for an application and program stage.""" + + STATUS_DRAFT = "draft" + STATUS_SUBMITTED = "submitted" + STATUS_RETURNED = "returned" + STATUS_FINAL = "final" + STATUS_CANCELLED = "cancelled" + + STATUS_CHOICES = ( + (STATUS_DRAFT, "Draft"), + (STATUS_SUBMITTED, "Submitted"), + (STATUS_RETURNED, "Returned"), + (STATUS_FINAL, "Final"), + (STATUS_CANCELLED, "Cancelled"), + ) + + EDITABLE_STATUSES = ( + STATUS_DRAFT, + STATUS_RETURNED, + ) + + application = models.ForeignKey( + Application, + on_delete=models.CASCADE, + related_name="submissions", + ) + program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="submissions", + ) + submitted_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="program_submissions", + ) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + form_data = models.JSONField(default=dict, blank=True) + links = models.JSONField(default=list, blank=True) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + ) + version = models.PositiveIntegerField(default=1) + stage_key = models.CharField(max_length=128, default="main") + submitted_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def clean(self): + super().clean() + + errors = {} + if self.application_id: + application = self.application + + if self.program_id and self.program_id != application.program_id: + errors["program"] = "Program must match the application's program." + + allowed_submitter_ids = { + application.user_id, + application.created_by_id, + } + allowed_submitter_ids.discard(None) + if ( + self.submitted_by_id + and self.submitted_by_id not in allowed_submitter_ids + ): + errors["submitted_by"] = ( + "Submitted by must match the application user or creator." + ) + + if self._state.adding and application.status in ( + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + errors["application"] = ( + "Submissions cannot be created for inactive applications." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + @property + def is_draft(self): + return self.status == self.STATUS_DRAFT + + @property + def is_submitted(self): + return self.status == self.STATUS_SUBMITTED + + @property + def is_final(self): + return self.status == self.STATUS_FINAL + + @property + def can_edit(self): + return self.status in self.EDITABLE_STATUSES + + @property + def can_submit(self): + return self.status in self.EDITABLE_STATUSES + + class Meta: + verbose_name = "Submission" + verbose_name_plural = "Submissions" + constraints = [ + models.UniqueConstraint( + fields=["application", "stage_key", "version"], + name="uniq_submission_application_stage_version", + ), + models.CheckConstraint( + check=models.Q(version__gte=1), + name="submission_version_gte_1", + ), + ] + + def __str__(self): + return ( + f"Submission<{self.pk}> application={self.application_id} " + f"stage={self.stage_key} version={self.version} status={self.status}" + ) + + class PartnerProgramUserProfile(models.Model): """ PartnerProgramUserProfile model diff --git a/partner_programs/tests/test_submission_model.py b/partner_programs/tests/test_submission_model.py new file mode 100644 index 00000000..805f1380 --- /dev/null +++ b/partner_programs/tests/test_submission_model.py @@ -0,0 +1,199 @@ +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.test import TestCase + +from partner_programs.models import Application, Submission +from partner_programs.tests.helpers import create_partner_program, create_user + + +class SubmissionModelTests(TestCase): + def setUp(self): + self.user = create_user(prefix="submission-user") + self.program = create_partner_program() + self.application = Application.objects.create( + program=self.program, + user=self.user, + created_by=self.user, + ) + + def create_submission(self, **overrides): + values = { + "application": self.application, + "program": self.program, + "submitted_by": self.user, + "title": "MVP solution", + } + values.update(overrides) + return Submission.objects.create(**values) + + def test_can_create_draft_submission_for_application(self): + submission = self.create_submission() + + self.assertEqual(submission.application, self.application) + self.assertEqual(submission.program, self.program) + self.assertEqual(submission.submitted_by, self.user) + self.assertEqual(submission.status, Submission.STATUS_DRAFT) + + def test_form_data_defaults_to_dict(self): + submission = self.create_submission() + + self.assertEqual(submission.form_data, {}) + self.assertIsInstance(submission.form_data, dict) + + def test_links_default_to_list(self): + submission = self.create_submission() + + self.assertEqual(submission.links, []) + self.assertIsInstance(submission.links, list) + + def test_status_defaults_to_draft(self): + submission = self.create_submission() + + self.assertEqual(submission.status, Submission.STATUS_DRAFT) + + def test_version_defaults_to_one(self): + submission = self.create_submission() + + self.assertEqual(submission.version, 1) + + def test_stage_key_defaults_to_main(self): + submission = self.create_submission() + + self.assertEqual(submission.stage_key, "main") + + def test_project_is_not_required_or_present_on_submission(self): + submission = self.create_submission() + + with self.assertRaises(FieldDoesNotExist): + Submission._meta.get_field("project") + self.assertIsNotNone(submission.pk) + + def test_application_is_required(self): + submission = Submission( + program=self.program, + submitted_by=self.user, + title="Missing application", + ) + + with self.assertRaises(ValidationError) as error: + submission.full_clean() + + self.assertIn("application", error.exception.message_dict) + + def test_program_is_required(self): + submission = Submission( + application=self.application, + submitted_by=self.user, + title="Missing program", + ) + + with self.assertRaises(ValidationError) as error: + submission.full_clean() + + self.assertIn("program", error.exception.message_dict) + + def test_submitted_by_is_required(self): + submission = Submission( + application=self.application, + program=self.program, + title="Missing submitter", + ) + + with self.assertRaises(ValidationError) as error: + submission.full_clean() + + self.assertIn("submitted_by", error.exception.message_dict) + + def test_program_must_match_application_program(self): + other_program = create_partner_program() + + with self.assertRaises(ValidationError) as error: + self.create_submission(program=other_program) + + self.assertIn("program", error.exception.message_dict) + + def test_submitted_by_must_match_application_owner_or_creator(self): + other_user = create_user(prefix="submission-other-user") + + with self.assertRaises(ValidationError) as error: + self.create_submission(submitted_by=other_user) + + self.assertIn("submitted_by", error.exception.message_dict) + + def test_inactive_application_cannot_have_submission(self): + for status in ( + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + with self.subTest(status=status): + self.application.status = status + self.application.save() + + with self.assertRaises(ValidationError) as error: + self.create_submission() + + self.assertIn("application", error.exception.message_dict) + + def test_duplicate_application_stage_and_version_is_rejected(self): + self.create_submission() + + with self.assertRaises(ValidationError): + self.create_submission(title="Duplicate solution") + + def test_version_two_is_allowed_for_same_application_and_stage(self): + self.create_submission() + + version_two = self.create_submission(title="Version two", version=2) + + self.assertEqual(version_two.version, 2) + + def test_other_stage_is_allowed_for_same_application_and_version(self): + self.create_submission() + + other_stage = self.create_submission(title="Final stage", stage_key="final") + + self.assertEqual(other_stage.stage_key, "final") + + def test_version_must_be_at_least_one(self): + with self.assertRaises(ValidationError): + self.create_submission(version=0) + + def test_can_edit_for_draft_and_returned_only(self): + for status in (Submission.STATUS_DRAFT, Submission.STATUS_RETURNED): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertTrue(submission.can_edit) + + for status in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertFalse(submission.can_edit) + + def test_can_submit_for_draft_and_returned_only(self): + for status in (Submission.STATUS_DRAFT, Submission.STATUS_RETURNED): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertTrue(submission.can_submit) + + for status in ( + Submission.STATUS_SUBMITTED, + Submission.STATUS_FINAL, + Submission.STATUS_CANCELLED, + ): + with self.subTest(status=status): + submission = Submission(status=status) + self.assertFalse(submission.can_submit) + + def test_status_helpers(self): + self.assertTrue(Submission(status=Submission.STATUS_DRAFT).is_draft) + self.assertTrue(Submission(status=Submission.STATUS_SUBMITTED).is_submitted) + self.assertTrue(Submission(status=Submission.STATUS_FINAL).is_final) + + def test_submitted_at_can_be_null(self): + submission = self.create_submission() + + self.assertIsNone(submission.submitted_at)