Skip to content

Commit dc4f4d4

Browse files
authored
Merge pull request #653 from PROCOLLAB-github/feature/application-model
Add application model
2 parents c7a6c24 + 1614343 commit dc4f4d4

5 files changed

Lines changed: 421 additions & 0 deletions

File tree

docs/application-project-submission-rfc.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ PROCOLLAB работает с кейс-чемпионатами, хакатон
125125
- `created_at`
126126
- `updated_at`
127127

128+
MVP implementation note: `team` is intentionally not added in the first
129+
`Application` model PR because the `Team` model does not exist yet. Until the
130+
Team PR lands, backend validation treats `Application` as an individual
131+
application and requires `user`.
132+
128133
Связи:
129134

130135
- принадлежит одному `Program`;

partner_programs/admin.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from core.utils import XlsxFileToExport, build_xlsx_download_response
1212
from mailing.views import MailingTemplateRender
1313
from partner_programs.models import (
14+
Application,
1415
PartnerProgram,
1516
PartnerProgramField,
1617
PartnerProgramFieldValue,
@@ -21,6 +22,44 @@
2122
from partner_programs.services import prepare_project_scores_export_data
2223

2324

25+
@admin.register(Application)
26+
class ApplicationAdmin(admin.ModelAdmin):
27+
list_display = (
28+
"id",
29+
"program",
30+
"user",
31+
"created_by",
32+
"status",
33+
"project",
34+
"submitted_at",
35+
"created_at",
36+
"updated_at",
37+
)
38+
list_filter = (
39+
"status",
40+
"program",
41+
"created_at",
42+
)
43+
search_fields = (
44+
"user__email",
45+
"created_by__email",
46+
"program__name",
47+
"program__tag",
48+
"project__name",
49+
)
50+
raw_id_fields = (
51+
"program",
52+
"user",
53+
"created_by",
54+
"project",
55+
)
56+
readonly_fields = (
57+
"created_at",
58+
"updated_at",
59+
)
60+
date_hierarchy = "created_at"
61+
62+
2463
class PartnerProgramMaterialInline(admin.StackedInline):
2564
model = PartnerProgramMaterial
2665
extra = 1
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Generated by Django 4.2.24 on 2026-07-20
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", "0017_alter_partnerprogramproject_options"),
13+
("projects", "0033_delete_projectnews"),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="Application",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
(
30+
"status",
31+
models.CharField(
32+
choices=[
33+
("draft", "Draft"),
34+
("submitted", "Submitted"),
35+
("approved", "Approved"),
36+
("rejected", "Rejected"),
37+
("withdrawn", "Withdrawn"),
38+
("cancelled", "Cancelled"),
39+
],
40+
default="draft",
41+
max_length=16,
42+
),
43+
),
44+
("form_data", models.JSONField(blank=True, default=dict)),
45+
("submitted_at", models.DateTimeField(blank=True, null=True)),
46+
("approved_at", models.DateTimeField(blank=True, null=True)),
47+
("rejected_at", models.DateTimeField(blank=True, null=True)),
48+
("withdrawn_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+
"created_by",
53+
models.ForeignKey(
54+
on_delete=django.db.models.deletion.PROTECT,
55+
related_name="created_program_applications",
56+
to=settings.AUTH_USER_MODEL,
57+
),
58+
),
59+
(
60+
"program",
61+
models.ForeignKey(
62+
on_delete=django.db.models.deletion.CASCADE,
63+
related_name="applications",
64+
to="partner_programs.partnerprogram",
65+
),
66+
),
67+
(
68+
"project",
69+
models.ForeignKey(
70+
blank=True,
71+
null=True,
72+
on_delete=django.db.models.deletion.SET_NULL,
73+
related_name="applications",
74+
to="projects.project",
75+
),
76+
),
77+
(
78+
"user",
79+
models.ForeignKey(
80+
blank=True,
81+
null=True,
82+
on_delete=django.db.models.deletion.SET_NULL,
83+
related_name="program_applications",
84+
to=settings.AUTH_USER_MODEL,
85+
),
86+
),
87+
],
88+
options={
89+
"verbose_name": "Application",
90+
"verbose_name_plural": "Applications",
91+
},
92+
),
93+
migrations.AddConstraint(
94+
model_name="application",
95+
constraint=models.UniqueConstraint(
96+
condition=models.Q(
97+
("status__in", ("draft", "submitted", "approved")),
98+
("user__isnull", False),
99+
),
100+
fields=("user", "program"),
101+
name="uniq_active_application_user_program",
102+
),
103+
),
104+
]

partner_programs/models.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,124 @@ def is_project_submission_open(self) -> bool:
176176
return deadline is None or deadline >= timezone.now()
177177

178178

179+
class Application(models.Model):
180+
"""
181+
MVP application model for participation in a partner program.
182+
183+
Team applications are intentionally deferred until the Team model exists.
184+
For now, an application is individual and must have `user`.
185+
"""
186+
187+
STATUS_DRAFT = "draft"
188+
STATUS_SUBMITTED = "submitted"
189+
STATUS_APPROVED = "approved"
190+
STATUS_REJECTED = "rejected"
191+
STATUS_WITHDRAWN = "withdrawn"
192+
STATUS_CANCELLED = "cancelled"
193+
194+
ACTIVE_STATUSES = (
195+
STATUS_DRAFT,
196+
STATUS_SUBMITTED,
197+
STATUS_APPROVED,
198+
)
199+
200+
STATUS_CHOICES = (
201+
(STATUS_DRAFT, "Draft"),
202+
(STATUS_SUBMITTED, "Submitted"),
203+
(STATUS_APPROVED, "Approved"),
204+
(STATUS_REJECTED, "Rejected"),
205+
(STATUS_WITHDRAWN, "Withdrawn"),
206+
(STATUS_CANCELLED, "Cancelled"),
207+
)
208+
209+
program = models.ForeignKey(
210+
PartnerProgram,
211+
on_delete=models.CASCADE,
212+
related_name="applications",
213+
)
214+
user = models.ForeignKey(
215+
User,
216+
on_delete=models.SET_NULL,
217+
related_name="program_applications",
218+
null=True,
219+
blank=True,
220+
)
221+
created_by = models.ForeignKey(
222+
User,
223+
on_delete=models.PROTECT,
224+
related_name="created_program_applications",
225+
)
226+
status = models.CharField(
227+
max_length=16,
228+
choices=STATUS_CHOICES,
229+
default=STATUS_DRAFT,
230+
)
231+
form_data = models.JSONField(default=dict, blank=True)
232+
project = models.ForeignKey(
233+
Project,
234+
on_delete=models.SET_NULL,
235+
related_name="applications",
236+
null=True,
237+
blank=True,
238+
)
239+
submitted_at = models.DateTimeField(null=True, blank=True)
240+
approved_at = models.DateTimeField(null=True, blank=True)
241+
rejected_at = models.DateTimeField(null=True, blank=True)
242+
withdrawn_at = models.DateTimeField(null=True, blank=True)
243+
created_at = models.DateTimeField(auto_now_add=True)
244+
updated_at = models.DateTimeField(auto_now=True)
245+
246+
def clean(self):
247+
super().clean()
248+
249+
errors = {}
250+
251+
if not self.user_id:
252+
errors["user"] = (
253+
"User is required for MVP applications until Team is implemented."
254+
)
255+
256+
if self.user_id and self.program_id and self.status in self.ACTIVE_STATUSES:
257+
duplicate_qs = Application.objects.filter(
258+
user_id=self.user_id,
259+
program_id=self.program_id,
260+
status__in=self.ACTIVE_STATUSES,
261+
)
262+
if self.pk:
263+
duplicate_qs = duplicate_qs.exclude(pk=self.pk)
264+
if duplicate_qs.exists():
265+
errors["user"] = (
266+
"User already has an active application for this program."
267+
)
268+
269+
if errors:
270+
raise ValidationError(errors)
271+
272+
def save(self, *args, **kwargs):
273+
self.full_clean()
274+
return super().save(*args, **kwargs)
275+
276+
class Meta:
277+
verbose_name = "Application"
278+
verbose_name_plural = "Applications"
279+
constraints = [
280+
models.UniqueConstraint(
281+
fields=["user", "program"],
282+
condition=models.Q(
283+
user__isnull=False,
284+
status__in=("draft", "submitted", "approved"),
285+
),
286+
name="uniq_active_application_user_program",
287+
),
288+
]
289+
290+
def __str__(self):
291+
return (
292+
f"Application<{self.pk}> "
293+
f"user={self.user_id} program={self.program_id} status={self.status}"
294+
)
295+
296+
179297
class PartnerProgramUserProfile(models.Model):
180298
"""
181299
PartnerProgramUserProfile model

0 commit comments

Comments
 (0)