diff --git a/.env.example b/.env.example index 57c5616e..25da1b0f 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ DRF_THROTTLE_AUTH_RESEND_EMAIL=3/min DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min DRF_THROTTLE_TOKEN_OBTAIN=10/min DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min +DRF_THROTTLE_APPLICATION_CREATE=10/min SENTRY_DSN= diff --git a/partner_programs/application_urls.py b/partner_programs/application_urls.py new file mode 100644 index 00000000..d587596e --- /dev/null +++ b/partner_programs/application_urls.py @@ -0,0 +1,23 @@ +from django.urls import path + +from partner_programs.applications_views import ( + ApplicationDetailView, + ApplicationSubmitView, + ApplicationWithdrawView, +) + +app_name = "applications" + +urlpatterns = [ + path("/", ApplicationDetailView.as_view(), name="detail"), + path( + "/submit/", + ApplicationSubmitView.as_view(), + name="submit", + ), + path( + "/withdraw/", + ApplicationWithdrawView.as_view(), + name="withdraw", + ), +] diff --git a/partner_programs/applications_views.py b/partner_programs/applications_views.py new file mode 100644 index 00000000..040525cb --- /dev/null +++ b/partner_programs/applications_views.py @@ -0,0 +1,204 @@ +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import IntegrityError, transaction +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.throttling import PostOnlyScopedRateThrottle +from partner_programs.models import Application, PartnerProgram +from partner_programs.serializers import ApplicationSerializer + + +def _application_queryset_for(user): + queryset = Application.objects.select_related( + "program", + "user", + "created_by", + "project", + ) + if user.is_staff or user.is_superuser: + return queryset + return queryset.filter(user=user) + + +def _active_application(*, program, user): + return ( + Application.objects.select_related( + "program", + "user", + "created_by", + "project", + ) + .filter( + program=program, + user=user, + status__in=Application.ACTIVE_STATUSES, + ) + .order_by("-created_at") + .first() + ) + + +def _application_response(application, request, response_status=status.HTTP_200_OK): + serializer = ApplicationSerializer(application, context={"request": request}) + return Response(serializer.data, status=response_status) + + +class ProgramApplicationCreateView(APIView): + permission_classes = [IsAuthenticated] + throttle_classes = [PostOnlyScopedRateThrottle] + throttle_scope = "application_create" + + def post(self, request, program_id): + program = get_object_or_404(PartnerProgram, pk=program_id) + serializer = ApplicationSerializer( + data=request.data, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + + existing_application = _active_application( + program=program, + user=request.user, + ) + if existing_application: + return _application_response(existing_application, request) + + try: + with transaction.atomic(): + application = Application.objects.create( + program=program, + user=request.user, + created_by=request.user, + **serializer.validated_data, + ) + except DjangoValidationError as exc: + existing_application = _active_application( + program=program, + user=request.user, + ) + if existing_application: + return _application_response(existing_application, request) + raise ValidationError(exc.message_dict) from exc + except IntegrityError: + existing_application = _active_application( + program=program, + user=request.user, + ) + if existing_application: + return _application_response(existing_application, request) + raise + + return _application_response( + application, + request, + response_status=status.HTTP_201_CREATED, + ) + + +class MyProgramApplicationView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, program_id): + program = get_object_or_404(PartnerProgram, pk=program_id) + application = _active_application(program=program, user=request.user) + if application is None: + application = ( + Application.objects.select_related( + "program", + "user", + "created_by", + "project", + ) + .filter(program=program, user=request.user) + .order_by("-created_at") + .first() + ) + if application is None: + raise NotFound("Application not found.") + return _application_response(application, request) + + +class ApplicationDetailView(APIView): + permission_classes = [IsAuthenticated] + + def get_object(self, request, application_id): + return get_object_or_404( + _application_queryset_for(request.user), + pk=application_id, + ) + + def get(self, request, application_id): + application = self.get_object(request, application_id) + return _application_response(application, request) + + def patch(self, request, application_id): + application = self.get_object(request, application_id) + if application.user_id != request.user.pk: + raise PermissionDenied("Only the application owner can update it.") + + serializer = ApplicationSerializer( + application, + data=request.data, + partial=True, + context={"request": request}, + ) + serializer.is_valid(raise_exception=True) + try: + serializer.save() + except DjangoValidationError as exc: + raise ValidationError(exc.message_dict) from exc + return Response(serializer.data, status=status.HTTP_200_OK) + + +class ApplicationSubmitView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, application_id): + with transaction.atomic(): + application = get_object_or_404( + _application_queryset_for(request.user).select_for_update(), + pk=application_id, + ) + if application.status == Application.STATUS_SUBMITTED: + return _application_response(application, request) + if application.status != Application.STATUS_DRAFT: + raise ValidationError( + {"status": "Only draft applications can be submitted."} + ) + + application.status = Application.STATUS_SUBMITTED + application.submitted_at = timezone.now() + application.save(update_fields=["status", "submitted_at", "updated_at"]) + return _application_response(application, request) + + +class ApplicationWithdrawView(APIView): + permission_classes = [IsAuthenticated] + allowed_statuses = frozenset( + { + Application.STATUS_DRAFT, + Application.STATUS_SUBMITTED, + Application.STATUS_APPROVED, + } + ) + + def post(self, request, application_id): + with transaction.atomic(): + application = get_object_or_404( + _application_queryset_for(request.user).select_for_update(), + pk=application_id, + ) + if application.status == Application.STATUS_WITHDRAWN: + return _application_response(application, request) + if application.status not in self.allowed_statuses: + raise ValidationError({"status": "This application cannot be withdrawn."}) + + application.status = Application.STATUS_WITHDRAWN + application.withdrawn_at = timezone.now() + application.save(update_fields=["status", "withdrawn_at", "updated_at"]) + return _application_response(application, request) diff --git a/partner_programs/serializers/__init__.py b/partner_programs/serializers/__init__.py index 42b2f311..224782d2 100644 --- a/partner_programs/serializers/__init__.py +++ b/partner_programs/serializers/__init__.py @@ -1,3 +1,4 @@ +from .applications import ApplicationSerializer from .fields import PartnerProgramFieldValueUpdateSerializer from .programs import ( PartnerProgramBaseSerializerMixin, @@ -17,6 +18,7 @@ ) __all__ = [ + "ApplicationSerializer", "PartnerProgramBaseSerializerMixin", "PartnerProgramDataSchemaSerializer", "PartnerProgramFieldSerializer", diff --git a/partner_programs/serializers/applications.py b/partner_programs/serializers/applications.py new file mode 100644 index 00000000..6673319b --- /dev/null +++ b/partner_programs/serializers/applications.py @@ -0,0 +1,103 @@ +from rest_framework import serializers + +from partner_programs.models import Application +from projects.models import Project + + +class ApplicationSerializer(serializers.ModelSerializer): + project = serializers.PrimaryKeyRelatedField( + queryset=Project.objects.all(), + allow_null=True, + required=False, + ) + project_id = serializers.PrimaryKeyRelatedField( + source="project", + queryset=Project.objects.all(), + allow_null=True, + required=False, + write_only=True, + ) + + immutable_input_fields = frozenset( + { + "id", + "program", + "user", + "created_by", + "status", + "submitted_at", + "approved_at", + "rejected_at", + "withdrawn_at", + "created_at", + "updated_at", + } + ) + + class Meta: + model = Application + fields = ( + "id", + "program", + "user", + "created_by", + "status", + "form_data", + "project", + "project_id", + "submitted_at", + "approved_at", + "rejected_at", + "withdrawn_at", + "created_at", + "updated_at", + ) + read_only_fields = ( + "id", + "program", + "user", + "created_by", + "status", + "submitted_at", + "approved_at", + "rejected_at", + "withdrawn_at", + "created_at", + "updated_at", + ) + extra_kwargs = { + "form_data": {"required": False}, + } + + def validate(self, attrs): + supplied_immutable_fields = self.immutable_input_fields.intersection( + self.initial_data + ) + if supplied_immutable_fields: + raise serializers.ValidationError( + { + field: "This field is read-only." + for field in sorted(supplied_immutable_fields) + } + ) + + if "project" in self.initial_data and "project_id" in self.initial_data: + raise serializers.ValidationError( + {"project": "Use either project or project_id, not both."} + ) + + if self.instance and self.instance.status != Application.STATUS_DRAFT: + raise serializers.ValidationError( + {"status": "Only draft applications can be updated."} + ) + + project = attrs.get("project", serializers.empty) + if project is not serializers.empty and project is not None: + request = self.context.get("request") + user = getattr(request, "user", None) + if not user or not user.is_authenticated or project.leader_id != user.pk: + raise serializers.ValidationError( + {"project": "You can only use a project that you lead."} + ) + + return attrs diff --git a/partner_programs/tests/test_application_api.py b/partner_programs/tests/test_application_api.py new file mode 100644 index 00000000..932007ba --- /dev/null +++ b/partner_programs/tests/test_application_api.py @@ -0,0 +1,396 @@ +from django.conf import settings +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from partner_programs.models import ( + Application, + PartnerProgramProject, + PartnerProgramUserProfile, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_project, + create_user, +) +from projects.models import Project + + +def throttle_settings(**rates): + rest_framework = dict(settings.REST_FRAMEWORK) + rest_framework["DEFAULT_THROTTLE_RATES"] = { + **rest_framework.get("DEFAULT_THROTTLE_RATES", {}), + **rates, + } + return rest_framework + + +class ApplicationAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.user = create_user(prefix="application-api-owner") + self.other_user = create_user(prefix="application-api-other") + self.staff_user = create_user(prefix="application-api-staff", is_staff=True) + self.program = create_partner_program() + + def authenticate(self, user=None): + self.client.force_authenticate(user=user or self.user) + + def create_application(self, *, user=None, status=Application.STATUS_DRAFT, **data): + owner = user or self.user + return Application.objects.create( + program=self.program, + user=owner, + created_by=owner, + status=status, + **data, + ) + + def test_unauthenticated_user_cannot_create_application(self): + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 401) + self.assertFalse(Application.objects.exists()) + + def test_authenticated_user_can_create_draft_application(self): + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"form_data": {"motivation": "Build a useful project"}}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + application = Application.objects.get() + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.program, self.program) + self.assertEqual(application.user, self.user) + self.assertEqual(application.created_by, self.user) + self.assertEqual( + application.form_data, + {"motivation": "Build a useful project"}, + ) + self.assertIsNone(application.project) + self.assertEqual(response.data["id"], application.id) + self.assertEqual(response.data["project"], None) + self.assertFalse(PartnerProgramUserProfile.objects.exists()) + self.assertFalse(PartnerProgramProject.objects.exists()) + self.assertFalse(Project.objects.exists()) + + def test_my_application_returns_current_users_application(self): + application = self.create_application() + self.authenticate() + + response = self.client.get(f"/programs/{self.program.id}/applications/my/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + self.assertEqual(response.data["user"], self.user.id) + + def test_my_application_does_not_return_another_users_application(self): + self.create_application(user=self.other_user) + self.authenticate() + + response = self.client.get(f"/programs/{self.program.id}/applications/my/") + + self.assertEqual(response.status_code, 404) + + def test_owner_can_read_application_detail(self): + application = self.create_application() + self.authenticate() + + response = self.client.get(f"/applications/{application.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + + def test_repeated_create_returns_existing_active_application(self): + self.authenticate() + url = f"/programs/{self.program.id}/applications/" + + first_response = self.client.post( + url, + {"form_data": {"version": 1}}, + format="json", + ) + second_response = self.client.post( + url, + {"form_data": {"version": 2}}, + format="json", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 200) + self.assertEqual(first_response.data["id"], second_response.data["id"]) + self.assertEqual(Application.objects.count(), 1) + self.assertEqual(Application.objects.get().form_data, {"version": 1}) + + def test_patch_draft_updates_form_data(self): + application = self.create_application(form_data={"version": 1}) + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + {"form_data": {"version": 2}}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.form_data, {"version": 2}) + + def test_patch_rejects_immutable_application_fields(self): + application = self.create_application(form_data={"version": 1}) + other_program = create_partner_program() + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + { + "program": other_program.id, + "user": self.other_user.id, + "created_by": self.other_user.id, + "status": Application.STATUS_APPROVED, + "form_data": {"version": 2}, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.program, self.program) + self.assertEqual(application.user, self.user) + self.assertEqual(application.created_by, self.user) + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.form_data, {"version": 1}) + + def test_patch_submitted_application_is_rejected(self): + application = self.create_application( + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + + response = self.client.patch( + f"/applications/{application.id}/", + {"form_data": {"changed": True}}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.form_data, {}) + + def test_submit_transitions_draft_and_sets_submitted_at(self): + application = self.create_application() + self.authenticate() + + response = self.client.post( + f"/applications/{application.id}/submit/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_SUBMITTED) + self.assertIsNotNone(application.submitted_at) + self.assertEqual(response.data["status"], Application.STATUS_SUBMITTED) + + def test_repeated_submit_preserves_submitted_at(self): + application = self.create_application() + self.authenticate() + url = f"/applications/{application.id}/submit/" + + first_response = self.client.post(url, {}, format="json") + application.refresh_from_db() + first_submitted_at = application.submitted_at + second_response = self.client.post(url, {}, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.submitted_at, first_submitted_at) + + def test_withdraw_transitions_application_and_is_idempotent(self): + application = self.create_application( + status=Application.STATUS_SUBMITTED, + submitted_at=timezone.now(), + ) + self.authenticate() + url = f"/applications/{application.id}/withdraw/" + + first_response = self.client.post(url, {}, format="json") + application.refresh_from_db() + first_withdrawn_at = application.withdrawn_at + second_response = self.client.post(url, {}, format="json") + + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_WITHDRAWN) + self.assertIsNotNone(application.withdrawn_at) + self.assertEqual(application.withdrawn_at, first_withdrawn_at) + + def test_rejected_or_cancelled_application_cannot_be_withdrawn(self): + self.authenticate() + + for application_status in ( + Application.STATUS_REJECTED, + Application.STATUS_CANCELLED, + ): + application = self.create_application(status=application_status) + + response = self.client.post( + f"/applications/{application.id}/withdraw/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + application.refresh_from_db() + self.assertEqual(application.status, application_status) + + def test_user_can_create_new_application_after_withdrawal(self): + withdrawn_application = self.create_application( + status=Application.STATUS_WITHDRAWN, + withdrawn_at=timezone.now(), + ) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertNotEqual(response.data["id"], withdrawn_application.id) + self.assertEqual(Application.objects.count(), 2) + + def test_user_cannot_read_or_change_another_users_application(self): + application = self.create_application(user=self.other_user) + self.authenticate() + detail_url = f"/applications/{application.id}/" + + get_response = self.client.get(detail_url) + patch_response = self.client.patch( + detail_url, + {"form_data": {"changed": True}}, + format="json", + ) + submit_response = self.client.post( + f"/applications/{application.id}/submit/", + {}, + format="json", + ) + withdraw_response = self.client.post( + f"/applications/{application.id}/withdraw/", + {}, + format="json", + ) + + self.assertEqual(get_response.status_code, 404) + self.assertEqual(patch_response.status_code, 404) + self.assertEqual(submit_response.status_code, 404) + self.assertEqual(withdraw_response.status_code, 404) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_DRAFT) + self.assertEqual(application.form_data, {}) + + def test_staff_can_read_application(self): + application = self.create_application() + self.authenticate(self.staff_user) + + response = self.client.get(f"/applications/{application.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["id"], application.id) + + def test_staff_can_submit_and_withdraw_application(self): + application = self.create_application() + self.authenticate(self.staff_user) + + submit_response = self.client.post( + f"/applications/{application.id}/submit/", + {}, + format="json", + ) + withdraw_response = self.client.post( + f"/applications/{application.id}/withdraw/", + {}, + format="json", + ) + + self.assertEqual(submit_response.status_code, 200) + self.assertEqual(withdraw_response.status_code, 200) + application.refresh_from_db() + self.assertEqual(application.status, Application.STATUS_WITHDRAWN) + + def test_project_can_be_null(self): + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"project_id": None}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["project"], None) + self.assertIsNone(Application.objects.get().project) + + def test_user_can_attach_project_they_lead(self): + project = create_project(leader=self.user) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"project_id": project.id}, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["project"], project.id) + self.assertEqual(Application.objects.get().project, project) + + def test_user_cannot_attach_another_users_project(self): + project = create_project(leader=self.other_user) + self.authenticate() + + response = self.client.post( + f"/programs/{self.program.id}/applications/", + {"project_id": project.id}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertFalse(Application.objects.exists()) + + @override_settings(REST_FRAMEWORK=throttle_settings(application_create="1/min")) + def test_application_create_is_scoped_throttled(self): + self.authenticate() + url = f"/programs/{self.program.id}/applications/" + + first_response = self.client.post( + url, + {}, + format="json", + REMOTE_ADDR="203.0.113.30", + ) + second_response = self.client.post( + url, + {}, + format="json", + REMOTE_ADDR="203.0.113.30", + ) + + self.assertEqual(first_response.status_code, 201) + self.assertEqual(second_response.status_code, 429) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 41313a82..4238f812 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -1,6 +1,10 @@ from django.urls import path from news.views import NewsDetail, NewsDetailSetLiked, NewsDetailSetViewed, NewsList +from partner_programs.applications_views import ( + MyProgramApplicationView, + ProgramApplicationCreateView, +) from partner_programs.views import ( PartnerProgramCreateUserAndRegister, PartnerProgramDataSchema, @@ -22,6 +26,16 @@ urlpatterns = [ path("", PartnerProgramList.as_view()), + path( + "/applications/my/", + MyProgramApplicationView.as_view(), + name="my-application", + ), + path( + "/applications/", + ProgramApplicationCreateView.as_view(), + name="application-create", + ), path("/", PartnerProgramDetail.as_view()), path( "partner-program-projects//submit/", diff --git a/procollab/settings.py b/procollab/settings.py index 5e35c159..ec479fa6 100644 --- a/procollab/settings.py +++ b/procollab/settings.py @@ -179,6 +179,9 @@ "program_register_new": config( "DRF_THROTTLE_PROGRAM_REGISTER_NEW", default="10/min", cast=str ), + "application_create": config( + "DRF_THROTTLE_APPLICATION_CREATE", default="10/min", cast=str + ), }, } diff --git a/procollab/urls.py b/procollab/urls.py index c48adb8e..2daef614 100644 --- a/procollab/urls.py +++ b/procollab/urls.py @@ -55,6 +55,10 @@ # отключены. Для возврата модуля раскомментировать строку ниже. # path("events/", include("events.urls", namespace="events")), path("programs/", include("partner_programs.urls", namespace="partner_programs")), + path( + "applications/", + include("partner_programs.application_urls", namespace="applications"), + ), path("courses/", include("courses.urls", namespace="courses")), path("rate-project/", include(("project_rates.urls", "rate_projects"))), path("feed/", include("feed.urls", namespace="feed")),