diff --git a/docs/application-project-submission-rfc.md b/docs/application-project-submission-rfc.md index 0a6c6dac..b7acf62d 100644 --- a/docs/application-project-submission-rfc.md +++ b/docs/application-project-submission-rfc.md @@ -125,6 +125,11 @@ PROCOLLAB работает с кейс-чемпионатами, хакатон - `created_at` - `updated_at` +MVP implementation note: `team` is intentionally not added in the first +`Application` model PR because the `Team` model does not exist yet. Until the +Team PR lands, backend validation treats `Application` as an individual +application and requires `user`. + Связи: - принадлежит одному `Program`; diff --git a/partner_programs/admin.py b/partner_programs/admin.py index 17709716..feb29d70 100644 --- a/partner_programs/admin.py +++ b/partner_programs/admin.py @@ -11,6 +11,7 @@ from core.utils import XlsxFileToExport, build_xlsx_download_response from mailing.views import MailingTemplateRender from partner_programs.models import ( + Application, PartnerProgram, PartnerProgramField, PartnerProgramFieldValue, @@ -21,6 +22,44 @@ from partner_programs.services import prepare_project_scores_export_data +@admin.register(Application) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ( + "id", + "program", + "user", + "created_by", + "status", + "project", + "submitted_at", + "created_at", + "updated_at", + ) + list_filter = ( + "status", + "program", + "created_at", + ) + search_fields = ( + "user__email", + "created_by__email", + "program__name", + "program__tag", + "project__name", + ) + raw_id_fields = ( + "program", + "user", + "created_by", + "project", + ) + readonly_fields = ( + "created_at", + "updated_at", + ) + date_hierarchy = "created_at" + + class PartnerProgramMaterialInline(admin.StackedInline): model = PartnerProgramMaterial extra = 1 diff --git a/partner_programs/migrations/0018_application.py b/partner_programs/migrations/0018_application.py new file mode 100644 index 00000000..e3f6bee8 --- /dev/null +++ b/partner_programs/migrations/0018_application.py @@ -0,0 +1,104 @@ +# Generated by Django 4.2.24 on 2026-07-20 + +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", "0017_alter_partnerprogramproject_options"), + ("projects", "0033_delete_projectnews"), + ] + + operations = [ + migrations.CreateModel( + name="Application", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("draft", "Draft"), + ("submitted", "Submitted"), + ("approved", "Approved"), + ("rejected", "Rejected"), + ("withdrawn", "Withdrawn"), + ("cancelled", "Cancelled"), + ], + default="draft", + max_length=16, + ), + ), + ("form_data", models.JSONField(blank=True, default=dict)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("approved_at", models.DateTimeField(blank=True, null=True)), + ("rejected_at", models.DateTimeField(blank=True, null=True)), + ("withdrawn_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="created_program_applications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "program", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="applications", + to="partner_programs.partnerprogram", + ), + ), + ( + "project", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="applications", + to="projects.project", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="program_applications", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Application", + "verbose_name_plural": "Applications", + }, + ), + migrations.AddConstraint( + model_name="application", + constraint=models.UniqueConstraint( + condition=models.Q( + ("status__in", ("draft", "submitted", "approved")), + ("user__isnull", False), + ), + fields=("user", "program"), + name="uniq_active_application_user_program", + ), + ), + ] diff --git a/partner_programs/models.py b/partner_programs/models.py index 33c6abe6..9967c7f9 100644 --- a/partner_programs/models.py +++ b/partner_programs/models.py @@ -176,6 +176,124 @@ def is_project_submission_open(self) -> bool: return deadline is None or deadline >= timezone.now() +class Application(models.Model): + """ + MVP application model for participation in a partner program. + + Team applications are intentionally deferred until the Team model exists. + For now, an application is individual and must have `user`. + """ + + STATUS_DRAFT = "draft" + STATUS_SUBMITTED = "submitted" + STATUS_APPROVED = "approved" + STATUS_REJECTED = "rejected" + STATUS_WITHDRAWN = "withdrawn" + STATUS_CANCELLED = "cancelled" + + ACTIVE_STATUSES = ( + STATUS_DRAFT, + STATUS_SUBMITTED, + STATUS_APPROVED, + ) + + STATUS_CHOICES = ( + (STATUS_DRAFT, "Draft"), + (STATUS_SUBMITTED, "Submitted"), + (STATUS_APPROVED, "Approved"), + (STATUS_REJECTED, "Rejected"), + (STATUS_WITHDRAWN, "Withdrawn"), + (STATUS_CANCELLED, "Cancelled"), + ) + + program = models.ForeignKey( + PartnerProgram, + on_delete=models.CASCADE, + related_name="applications", + ) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="program_applications", + null=True, + blank=True, + ) + created_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + related_name="created_program_applications", + ) + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + ) + form_data = models.JSONField(default=dict, blank=True) + project = models.ForeignKey( + Project, + on_delete=models.SET_NULL, + related_name="applications", + null=True, + blank=True, + ) + submitted_at = models.DateTimeField(null=True, blank=True) + approved_at = models.DateTimeField(null=True, blank=True) + rejected_at = models.DateTimeField(null=True, blank=True) + withdrawn_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 not self.user_id: + errors["user"] = ( + "User is required for MVP applications until Team is implemented." + ) + + if self.user_id and self.program_id and self.status in self.ACTIVE_STATUSES: + duplicate_qs = Application.objects.filter( + user_id=self.user_id, + program_id=self.program_id, + status__in=self.ACTIVE_STATUSES, + ) + if self.pk: + duplicate_qs = duplicate_qs.exclude(pk=self.pk) + if duplicate_qs.exists(): + errors["user"] = ( + "User already has an active application for this program." + ) + + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + class Meta: + verbose_name = "Application" + verbose_name_plural = "Applications" + constraints = [ + models.UniqueConstraint( + fields=["user", "program"], + condition=models.Q( + user__isnull=False, + status__in=("draft", "submitted", "approved"), + ), + name="uniq_active_application_user_program", + ), + ] + + def __str__(self): + return ( + f"Application<{self.pk}> " + f"user={self.user_id} program={self.program_id} status={self.status}" + ) + + class PartnerProgramUserProfile(models.Model): """ PartnerProgramUserProfile model diff --git a/partner_programs/tests/test_application_model.py b/partner_programs/tests/test_application_model.py new file mode 100644 index 00000000..46547d52 --- /dev/null +++ b/partner_programs/tests/test_application_model.py @@ -0,0 +1,155 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from partner_programs.models import Application +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, +) + + +class ApplicationModelTests(TestCase): + def test_can_create_draft_application_for_user_and_program(self): + user = create_user(prefix="application-user") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + ) + + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.program, program) + self.assertEqual(application.user, user) + self.assertEqual(application.created_by, user) + + def test_cannot_create_second_active_application_for_same_user_and_program(self): + user = create_user(prefix="application-active") + program = create_partner_program() + Application.objects.create( + program=program, + user=user, + created_by=user, + status=Application.STATUS_DRAFT, + ) + + with self.assertRaises(ValidationError) as error: + Application.objects.create( + program=program, + user=user, + created_by=user, + status=Application.STATUS_SUBMITTED, + ) + + self.assertIn("user", error.exception.message_dict) + + def test_can_create_new_application_after_inactive_status(self): + for inactive_status in ( + Application.STATUS_WITHDRAWN, + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + user = create_user(prefix=f"application-{inactive_status}") + program = create_partner_program() + Application.objects.create( + program=program, + user=user, + created_by=user, + status=inactive_status, + ) + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + status=Application.STATUS_DRAFT, + ) + + self.assertEqual(application.status, Application.STATUS_DRAFT) + + def test_created_by_is_required(self): + user = create_user(prefix="application-created-by") + program = create_partner_program() + application = Application( + program=program, + user=user, + ) + + with self.assertRaises(ValidationError) as error: + application.full_clean() + + self.assertIn("created_by", error.exception.message_dict) + + def test_form_data_defaults_to_dict(self): + user = create_user(prefix="application-form-data") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + ) + + self.assertEqual(application.form_data, {}) + self.assertIsInstance(application.form_data, dict) + + def test_project_and_status_timestamps_can_be_null(self): + user = create_user(prefix="application-nullable") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + ) + + self.assertIsNone(application.project) + self.assertIsNone(application.submitted_at) + self.assertIsNone(application.approved_at) + self.assertIsNone(application.rejected_at) + self.assertIsNone(application.withdrawn_at) + + def test_project_can_be_attached(self): + user = create_user(prefix="application-project") + program = create_partner_program() + project = create_project(leader=user) + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + project=project, + ) + + self.assertEqual(application.project, project) + + def test_status_choices_accept_expected_values(self): + for status, _label in Application.STATUS_CHOICES: + user = create_user(prefix=f"application-{status}") + program = create_partner_program() + + application = Application.objects.create( + program=program, + user=user, + created_by=user, + status=status, + ) + + self.assertEqual(application.status, status) + + def test_status_choices_reject_unknown_value(self): + user = create_user(prefix="application-invalid-status") + program = create_partner_program() + application = Application( + program=program, + user=user, + created_by=user, + status="unknown", + ) + + with self.assertRaises(ValidationError) as error: + application.full_clean() + + self.assertIn("status", error.exception.message_dict)