Skip to content

Commit 00f6b78

Browse files
authored
Merge pull request #655 from PROCOLLAB-github/feature/submission-model
Add submission model
2 parents da0cfd1 + dc26e1c commit 00f6b78

5 files changed

Lines changed: 476 additions & 3 deletions

File tree

docs/application-project-submission-rfc.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,12 @@ Project не является заявкой. Он может быть:
383383
- `id`
384384
- `application`
385385
- `program`
386-
- `stage`, nullable
386+
- `stage_key`, строковый ключ этапа
387387
- `submitted_by`
388388
- `title`
389389
- `description`
390390
- `form_data`, JSON
391391
- `links`, JSON/list
392-
- `files` relation
393392
- `status`
394393
- `version`
395394
- `submitted_at`
@@ -409,10 +408,11 @@ Project не является заявкой. Он может быть:
409408
- `submitted` - отправлена;
410409
- `returned` - возвращена на доработку;
411410
- `final` - финальная версия зафиксирована.
411+
- `cancelled` - сдача отменена.
412412

413413
Ограничения от дублей:
414414

415-
- выбрать стратегию уникальности: `application + stage + version` или `application + stage + active_status`;
415+
- уникальность `application + stage_key + version`;
416416
- повторный POST должен быть идемпотентным или защищенным constraint-ами;
417417
- нельзя создавать сдачу для отозванной, отклоненной или отмененной заявки, если правила программы это запрещают.
418418

@@ -426,6 +426,11 @@ Project не является заявкой. Он может быть:
426426

427427
Submission не является заявкой и не является проектом. Это событие или версия сдачи решения. Эксперт должен оценивать конкретную сдачу, а не абстрактный проект, который может измениться после дедлайна.
428428

429+
MVP-заметка: отдельная модель `Stage` пока не создается, вместо нее используется
430+
`stage_key="main"`. Модели `SubmissionFile` и `SubmissionLink` также отложены:
431+
ссылки временно хранятся в `links` как JSON-массив, а полноценные этапы и файлы
432+
будут добавлены отдельными PR.
433+
429434
### SubmissionFile / SubmissionLink
430435

431436
Назначение: вложения и ссылки, прикрепленные к конкретной сдаче.

partner_programs/admin.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
PartnerProgramMaterial,
1919
PartnerProgramProject,
2020
PartnerProgramUserProfile,
21+
Submission,
2122
)
2223
from partner_programs.services import prepare_project_scores_export_data
2324

@@ -60,6 +61,48 @@ class ApplicationAdmin(admin.ModelAdmin):
6061
date_hierarchy = "created_at"
6162

6263

64+
@admin.register(Submission)
65+
class SubmissionAdmin(admin.ModelAdmin):
66+
list_display = (
67+
"id",
68+
"application",
69+
"program",
70+
"submitted_by",
71+
"title",
72+
"status",
73+
"stage_key",
74+
"version",
75+
"submitted_at",
76+
"created_at",
77+
"updated_at",
78+
)
79+
list_filter = (
80+
"status",
81+
"program",
82+
"stage_key",
83+
"created_at",
84+
)
85+
search_fields = (
86+
"title",
87+
"submitted_by__email",
88+
"submitted_by__first_name",
89+
"submitted_by__last_name",
90+
"program__name",
91+
"program__tag",
92+
"=application__id",
93+
)
94+
raw_id_fields = (
95+
"application",
96+
"program",
97+
"submitted_by",
98+
)
99+
readonly_fields = (
100+
"created_at",
101+
"updated_at",
102+
)
103+
date_hierarchy = "created_at"
104+
105+
63106
class PartnerProgramMaterialInline(admin.StackedInline):
64107
model = PartnerProgramMaterial
65108
extra = 1
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Generated by Django 4.2.11 on 2026-07-20 13:30
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
("partner_programs", "0018_application"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="Submission",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
("title", models.CharField(max_length=255)),
29+
("description", models.TextField(blank=True)),
30+
("form_data", models.JSONField(blank=True, default=dict)),
31+
("links", models.JSONField(blank=True, default=list)),
32+
(
33+
"status",
34+
models.CharField(
35+
choices=[
36+
("draft", "Draft"),
37+
("submitted", "Submitted"),
38+
("returned", "Returned"),
39+
("final", "Final"),
40+
("cancelled", "Cancelled"),
41+
],
42+
default="draft",
43+
max_length=16,
44+
),
45+
),
46+
("version", models.PositiveIntegerField(default=1)),
47+
("stage_key", models.CharField(default="main", max_length=128)),
48+
("submitted_at", models.DateTimeField(blank=True, null=True)),
49+
("created_at", models.DateTimeField(auto_now_add=True)),
50+
("updated_at", models.DateTimeField(auto_now=True)),
51+
(
52+
"application",
53+
models.ForeignKey(
54+
on_delete=django.db.models.deletion.CASCADE,
55+
related_name="submissions",
56+
to="partner_programs.application",
57+
),
58+
),
59+
(
60+
"program",
61+
models.ForeignKey(
62+
on_delete=django.db.models.deletion.CASCADE,
63+
related_name="submissions",
64+
to="partner_programs.partnerprogram",
65+
),
66+
),
67+
(
68+
"submitted_by",
69+
models.ForeignKey(
70+
on_delete=django.db.models.deletion.PROTECT,
71+
related_name="program_submissions",
72+
to=settings.AUTH_USER_MODEL,
73+
),
74+
),
75+
],
76+
options={
77+
"verbose_name": "Submission",
78+
"verbose_name_plural": "Submissions",
79+
},
80+
),
81+
migrations.AddConstraint(
82+
model_name="submission",
83+
constraint=models.UniqueConstraint(
84+
fields=("application", "stage_key", "version"),
85+
name="uniq_submission_application_stage_version",
86+
),
87+
),
88+
migrations.AddConstraint(
89+
model_name="submission",
90+
constraint=models.CheckConstraint(
91+
check=models.Q(("version__gte", 1)), name="submission_version_gte_1"
92+
),
93+
),
94+
]

partner_programs/models.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,138 @@ def __str__(self):
294294
)
295295

296296

297+
class Submission(models.Model):
298+
"""Versioned solution submitted for an application and program stage."""
299+
300+
STATUS_DRAFT = "draft"
301+
STATUS_SUBMITTED = "submitted"
302+
STATUS_RETURNED = "returned"
303+
STATUS_FINAL = "final"
304+
STATUS_CANCELLED = "cancelled"
305+
306+
STATUS_CHOICES = (
307+
(STATUS_DRAFT, "Draft"),
308+
(STATUS_SUBMITTED, "Submitted"),
309+
(STATUS_RETURNED, "Returned"),
310+
(STATUS_FINAL, "Final"),
311+
(STATUS_CANCELLED, "Cancelled"),
312+
)
313+
314+
EDITABLE_STATUSES = (
315+
STATUS_DRAFT,
316+
STATUS_RETURNED,
317+
)
318+
319+
application = models.ForeignKey(
320+
Application,
321+
on_delete=models.CASCADE,
322+
related_name="submissions",
323+
)
324+
program = models.ForeignKey(
325+
PartnerProgram,
326+
on_delete=models.CASCADE,
327+
related_name="submissions",
328+
)
329+
submitted_by = models.ForeignKey(
330+
User,
331+
on_delete=models.PROTECT,
332+
related_name="program_submissions",
333+
)
334+
title = models.CharField(max_length=255)
335+
description = models.TextField(blank=True)
336+
form_data = models.JSONField(default=dict, blank=True)
337+
links = models.JSONField(default=list, blank=True)
338+
status = models.CharField(
339+
max_length=16,
340+
choices=STATUS_CHOICES,
341+
default=STATUS_DRAFT,
342+
)
343+
version = models.PositiveIntegerField(default=1)
344+
stage_key = models.CharField(max_length=128, default="main")
345+
submitted_at = models.DateTimeField(null=True, blank=True)
346+
created_at = models.DateTimeField(auto_now_add=True)
347+
updated_at = models.DateTimeField(auto_now=True)
348+
349+
def clean(self):
350+
super().clean()
351+
352+
errors = {}
353+
if self.application_id:
354+
application = self.application
355+
356+
if self.program_id and self.program_id != application.program_id:
357+
errors["program"] = "Program must match the application's program."
358+
359+
allowed_submitter_ids = {
360+
application.user_id,
361+
application.created_by_id,
362+
}
363+
allowed_submitter_ids.discard(None)
364+
if (
365+
self.submitted_by_id
366+
and self.submitted_by_id not in allowed_submitter_ids
367+
):
368+
errors["submitted_by"] = (
369+
"Submitted by must match the application user or creator."
370+
)
371+
372+
if self._state.adding and application.status in (
373+
Application.STATUS_WITHDRAWN,
374+
Application.STATUS_REJECTED,
375+
Application.STATUS_CANCELLED,
376+
):
377+
errors["application"] = (
378+
"Submissions cannot be created for inactive applications."
379+
)
380+
381+
if errors:
382+
raise ValidationError(errors)
383+
384+
def save(self, *args, **kwargs):
385+
self.full_clean()
386+
return super().save(*args, **kwargs)
387+
388+
@property
389+
def is_draft(self):
390+
return self.status == self.STATUS_DRAFT
391+
392+
@property
393+
def is_submitted(self):
394+
return self.status == self.STATUS_SUBMITTED
395+
396+
@property
397+
def is_final(self):
398+
return self.status == self.STATUS_FINAL
399+
400+
@property
401+
def can_edit(self):
402+
return self.status in self.EDITABLE_STATUSES
403+
404+
@property
405+
def can_submit(self):
406+
return self.status in self.EDITABLE_STATUSES
407+
408+
class Meta:
409+
verbose_name = "Submission"
410+
verbose_name_plural = "Submissions"
411+
constraints = [
412+
models.UniqueConstraint(
413+
fields=["application", "stage_key", "version"],
414+
name="uniq_submission_application_stage_version",
415+
),
416+
models.CheckConstraint(
417+
check=models.Q(version__gte=1),
418+
name="submission_version_gte_1",
419+
),
420+
]
421+
422+
def __str__(self):
423+
return (
424+
f"Submission<{self.pk}> application={self.application_id} "
425+
f"stage={self.stage_key} version={self.version} status={self.status}"
426+
)
427+
428+
297429
class PartnerProgramUserProfile(models.Model):
298430
"""
299431
PartnerProgramUserProfile model

0 commit comments

Comments
 (0)