diff --git a/itou/job_applications/admin.py b/itou/job_applications/admin.py index 3c292ce8675..d0bf2e2b339 100644 --- a/itou/job_applications/admin.py +++ b/itou/job_applications/admin.py @@ -1,7 +1,9 @@ import uuid +import xworkflows from django.contrib import admin, messages from django.db.models import Q +from django.http import HttpResponseRedirect from django.urls import reverse from django.utils.safestring import mark_safe @@ -101,6 +103,7 @@ class JobApplicationAdmin(InconsistencyCheckMixin, ItouModelAdmin): "transferred_at", "transferred_from", "origin", + "state", ) inlines = (JobsInline, PriorActionInline, TransitionLogInline, UUIDSupportRemarkInline) @@ -269,11 +272,6 @@ def save_model(self, request, obj, form, change): obj.origin = Origin.ADMIN super().save_model(request, obj, form, change) - if form._job_application_to_accept: - if obj.state.is_new: - # The new -> accepted transition doesn't exist - obj.process(user=request.user) - obj.accept(user=request.user) def get_form(self, request, obj=None, **kwargs): """ @@ -287,6 +285,39 @@ def get_form(self, request, obj=None, **kwargs): kwargs.update({"help_texts": help_texts}) return super().get_form(request, obj, **kwargs) + def transition_error(self, request, error): + message = None + if error.args[0] == "Cannot create an approval without eligibility diagnosis here": + message = ( + "Un diagnostic d'éligibilité valide pour ce candidat " + "et cette SIAE est obligatoire pour pouvoir créer un PASS IAE." + ) + elif error.args[0] == "Cannot accept a job application with no hiring start date.": + message = "Le champ 'Date de début du contrat' est obligatoire pour accepter une candidature" + self.message_user(request, message or error, messages.ERROR) + return HttpResponseRedirect(request.get_full_path()) + + def response_change(self, request, obj): + """ + Override to add custom "actions" in `self.change_form_template` for: + * processing the job application + * accepting the job application + * refusing the job application + * reseting the job applciation + """ + for transition in ["accept", "cancel", "reset", "process"]: + if f"transition_{transition}" in request.POST: + try: + getattr(obj, transition)(user=request.user) + # Stay on same page + updated_request = request.POST.copy() + updated_request.update({"_continue": ["please"]}) + request.POST = updated_request + except xworkflows.AbortTransition as e: + return self.transition_error(request, e) + + return super().response_change(request, obj) + @admin.register(models.JobApplicationTransitionLog) class JobApplicationTransitionLogAdmin(ItouModelAdmin): diff --git a/itou/job_applications/admin_forms.py b/itou/job_applications/admin_forms.py index a2111969994..202eb9f85d4 100644 --- a/itou/job_applications/admin_forms.py +++ b/itou/job_applications/admin_forms.py @@ -1,8 +1,7 @@ from django import forms from django.core.exceptions import ValidationError -from itou.eligibility.models import EligibilityDiagnosis -from itou.job_applications.enums import JobApplicationState, SenderKind +from itou.job_applications.enums import SenderKind from itou.job_applications.models import JobApplication @@ -24,31 +23,6 @@ def __init__(self, *args, **kwargs): self._job_application_to_accept = False def clean(self): - target_state = self.cleaned_data.get("state") - if target_state == JobApplicationState.ACCEPTED and target_state != self._initial_job_application_state: - self._job_application_to_accept = True - self.cleaned_data["state"] = self._initial_job_application_state or JobApplicationState.NEW - - if self._job_application_to_accept and not self.cleaned_data.get("hiring_start_at"): - self.add_error("hiring_start_at", "Ce champ est obligatoire pour les candidatures acceptées.") - - if ( - self._job_application_to_accept - and (to_company := self.cleaned_data.get("to_company")) - and to_company.is_subject_to_eligibility_rules - and self.cleaned_data.get("hiring_without_approval") is not True - and (job_seeker := self.cleaned_data.get("job_seeker")) - and not job_seeker.has_valid_common_approval - and not EligibilityDiagnosis.objects.last_considered_valid(job_seeker, for_siae=to_company) - ): - self.add_error( - None, - ( - "Un diagnostic d'éligibilité valide pour ce candidat " - "et cette SIAE est obligatoire pour pouvoir créer un PASS IAE." - ), - ) - sender = self.cleaned_data["sender"] sender_kind = self.cleaned_data["sender_kind"] sender_company = self.cleaned_data.get("sender_company") diff --git a/itou/job_applications/models.py b/itou/job_applications/models.py index 52d684580ab..2c43f89c523 100644 --- a/itou/job_applications/models.py +++ b/itou/job_applications/models.py @@ -57,6 +57,7 @@ class JobApplicationWorkflow(xwf_models.Workflow): TRANSITION_CANCEL = "cancel" TRANSITION_RENDER_OBSOLETE = "render_obsolete" TRANSITION_TRANSFER = "transfer" + TRANSITION_RESET = "reset" TRANSITION_CHOICES = ( (TRANSITION_PROCESS, "Étudier la candidature"), @@ -68,6 +69,7 @@ class JobApplicationWorkflow(xwf_models.Workflow): (TRANSITION_CANCEL, "Annuler la candidature"), (TRANSITION_RENDER_OBSOLETE, "Rendre obsolete la candidature"), (TRANSITION_TRANSFER, "Transfert de la candidature vers une autre SIAE"), + (TRANSITION_RESET, "Réinitialiser la candidature"), ) CAN_BE_ACCEPTED_STATES = [ @@ -111,6 +113,7 @@ class JobApplicationWorkflow(xwf_models.Workflow): JobApplicationState.OBSOLETE, ), (TRANSITION_TRANSFER, CAN_BE_TRANSFERRED_STATES, JobApplicationState.NEW), + (TRANSITION_RESET, JobApplicationState.OBSOLETE, JobApplicationState.NEW), ) PENDING_STATES = [JobApplicationState.NEW, JobApplicationState.PROCESSING, JobApplicationState.POSTPONED] @@ -883,6 +886,14 @@ def get_sender_kind_display(self): def is_in_transferable_state(self): return self.state in JobApplicationWorkflow.CAN_BE_TRANSFERRED_STATES + @property + def is_in_acceptable_state(self): + return self.state in JobApplicationWorkflow.CAN_BE_ACCEPTED_STATES + + @property + def is_in_refusable_state(self): + return self.state in JobApplicationWorkflow.CAN_BE_REFUSED_STATES + def can_be_transferred(self, user, target_company): # User must be member of both origin and target companies to make a transfer if not (self.to_company.has_member(user) and target_company.has_member(user)): diff --git a/itou/templates/admin/job_applications/change_form.html b/itou/templates/admin/job_applications/change_form.html new file mode 100644 index 00000000000..c2579ba4da9 --- /dev/null +++ b/itou/templates/admin/job_applications/change_form.html @@ -0,0 +1,33 @@ +{% extends 'admin/change_form.html' %} + +{% block submit_buttons_top %} + + {{ block.super }} + +
+

Changer l'état de la candidature :

+ {% if original.state.is_new %} + + {% endif %} + {% if original.is_in_acceptable_state %} + + {% endif %} + {% if original.state.is_accepted and original.can_be_cancelled %} + + {% endif %} + {% if original.state.is_obsolete %} + + {% endif %} +
+ + +{% endblock %} diff --git a/tests/job_applications/__snapshots__/test_admin.ambr b/tests/job_applications/__snapshots__/test_admin.ambr new file mode 100644 index 00000000000..7573207db9c --- /dev/null +++ b/tests/job_applications/__snapshots__/test_admin.ambr @@ -0,0 +1,107 @@ +# serializer version: 1 +# name: test_available_transitions[accepted] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- +# name: test_available_transitions[cancelled] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- +# name: test_available_transitions[new] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- +# name: test_available_transitions[obsolete] + ''' +
+

Changer l'état de la candidature :

+ + + + + + + + +
+ ''' +# --- +# name: test_available_transitions[postponed] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- +# name: test_available_transitions[prior_to_hire] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- +# name: test_available_transitions[processing] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- +# name: test_available_transitions[refused] + ''' +
+

Changer l'état de la candidature :

+ + + + + + +
+ ''' +# --- diff --git a/tests/job_applications/test_admin.py b/tests/job_applications/test_admin.py index 812197de189..3380d6a3e88 100644 --- a/tests/job_applications/test_admin.py +++ b/tests/job_applications/test_admin.py @@ -1,17 +1,20 @@ +import pytest from django.contrib import messages from django.contrib.admin import helpers from django.urls import reverse from django.utils import timezone -from pytest_django.asserts import assertContains, assertFormError, assertMessages +from pytest_django.asserts import assertContains, assertMessages, assertRedirects from itou.employee_record import models as employee_record_models from itou.job_applications import models +from itou.job_applications.enums import JobApplicationState from tests.approvals.factories import ApprovalFactory from tests.companies.factories import CompanyFactory from tests.eligibility.factories import EligibilityDiagnosisFactory, EligibilityDiagnosisMadeBySiaeFactory from tests.employee_record import factories as employee_record_factories from tests.job_applications import factories from tests.users.factories import JobSeekerFactory +from tests.utils.test import parse_response_to_soup def test_create_employee_record(admin_client): @@ -159,12 +162,12 @@ def test_check_inconsistency_check(admin_client): } -def test_create_accepted_job_application(admin_client): +def test_create_then_accept_job_application(admin_client): job_seeker = JobSeekerFactory() company = CompanyFactory(subject_to_eligibility=True, with_membership=True) employer = company.members.first() post_data = { - "state": "accepted", + "state": "accepted", # not taken into account since the field is readonly "job_seeker": job_seeker.pk, "to_company": company.pk, "sender_kind": "employer", @@ -174,141 +177,160 @@ def test_create_accepted_job_application(admin_client): **JOB_APPLICATION_FORMSETS_PAYLOAD, } response = admin_client.post(reverse("admin:job_applications_jobapplication_add"), post_data) - assert response.status_code == 200 - assertFormError( - response.context["adminform"].form, - "hiring_start_at", - "Ce champ est obligatoire pour les candidatures acceptées.", + assertRedirects(response, reverse("admin:job_applications_jobapplication_changelist")) + job_application = models.JobApplication.objects.get() + assert job_application.state == JobApplicationState.NEW + url = reverse("admin:job_applications_jobapplication_change", args=(job_application.pk,)) + assertMessages( + response, + [ + messages.Message( + messages.SUCCESS, + f'L\'objet candidature «\xa0{job_application.pk}\xa0» a été ajouté avec succès.', + ) + ], ) - assertFormError( - response.context["adminform"].form, - None, - ( - "Un diagnostic d'éligibilité valide pour ce candidat " - "et cette SIAE est obligatoire pour pouvoir créer un PASS IAE." - ), + + response = admin_client.get(url) + assertContains(response, 'value="Passer à l\'étude"') + + response = admin_client.post(url, {**post_data, "transition_process": True}) + assertRedirects(response, url) + job_application.refresh_from_db() + assert job_application.state == JobApplicationState.PROCESSING + + response = admin_client.get(url) + assertContains(response, 'value="Accepter"') + + response = admin_client.post(url, {**post_data, "transition_accept": True}) + assertRedirects(response, url, fetch_redirect_response=False) # don't flush the messages + job_application.refresh_from_db() + assert job_application.state == JobApplicationState.PROCESSING + + response = admin_client.get(url) + assertMessages( + response, + [ + messages.Message( + messages.ERROR, + "Le champ 'Date de début du contrat' est obligatoire pour accepter une candidature", + ) + ], ) + assertContains(response, 'value="Accepter"') # Retry with the mandatory date post_data["hiring_start_at"] = timezone.localdate() + response = admin_client.post(url, {**post_data, "transition_accept": True}) + assertRedirects(response, url, fetch_redirect_response=False) # don't flush the messages + job_application.refresh_from_db() + assert job_application.state == JobApplicationState.PROCESSING + + response = admin_client.get(url) + assertMessages( + response, + [ + messages.Message( + messages.ERROR, + "Un diagnostic d'éligibilité valide pour ce candidat " + "et cette SIAE est obligatoire pour pouvoir créer un PASS IAE.", + ) + ], + ) + # and make sure a diagnosis exists EligibilityDiagnosisFactory(job_seeker=job_seeker) - response = admin_client.post(reverse("admin:job_applications_jobapplication_add"), post_data) - assert response.status_code == 302 - job_app = models.JobApplication.objects.get() - assert job_app.state.is_accepted - assert job_app.logs.count() == 2 # new->processing & processing->accepted - assert job_app.approval + response = admin_client.post(url, {**post_data, "transition_accept": True}) + assertRedirects(response, url) + job_application.refresh_from_db() + assert job_application.state == JobApplicationState.ACCEPTED + assert job_application.logs.count() == 2 + assert job_application.approval -def test_accept_existing_job_application(admin_client): +def test_accepte_job_application_not_subject_to_eligibility(admin_client): job_application = factories.JobApplicationFactory( - eligibility_diagnosis=None, - to_company__subject_to_eligibility=True, - state=models.JobApplicationState.REFUSED, + to_company__not_subject_to_eligibility=True, + state=JobApplicationState.PROCESSING, ) - change_url = reverse("admin:job_applications_jobapplication_change", args=[job_application.pk]) + + url = reverse("admin:job_applications_jobapplication_change", args=(job_application.pk,)) + response = admin_client.get(url) + assertContains(response, 'value="Accepter"') + post_data = { "state": "accepted", - "job_seeker": job_application.job_seeker.pk, - "to_company": job_application.to_company.pk, + "job_seeker": job_application.job_seeker_id, + "to_company": job_application.to_company_id, "sender_kind": job_application.sender_kind, - "sender": job_application.sender.pk, + "sender": job_application.sender_id, # Formsets to please django admin **JOB_APPLICATION_FORMSETS_PAYLOAD, } - response = admin_client.post(change_url, post_data) - assert response.status_code == 200 - assertFormError( - response.context["adminform"].form, - "hiring_start_at", - "Ce champ est obligatoire pour les candidatures acceptées.", - ) - assertFormError( - response.context["adminform"].form, - None, - ( - "Un diagnostic d'éligibilité valide pour ce candidat " - "et cette SIAE est obligatoire pour pouvoir créer un PASS IAE." - ), - ) - - # Retry with the mandatory date - post_data["hiring_start_at"] = timezone.localdate() - # and make sure a diagnosis exists - EligibilityDiagnosisFactory(job_seeker=job_application.job_seeker) - response = admin_client.post(change_url, post_data) - assert response.status_code == 302 - job_app = models.JobApplication.objects.get() - assert job_app.state.is_accepted - assert job_app.logs.count() == 1 # refused->accepted - assert job_app.approval + response = admin_client.post(url, {**post_data, "transition_accept": True}) + assertRedirects(response, url, fetch_redirect_response=False) # don't flush the messages + job_application.refresh_from_db() + assert job_application.state == JobApplicationState.PROCESSING - -def test_create_accepted_job_application_not_subject_to_eligibility(admin_client): - job_seeker = JobSeekerFactory() - company = CompanyFactory(not_subject_to_eligibility=True, with_membership=True) - post_data = { - "state": "accepted", - "job_seeker": job_seeker.pk, - "to_company": company.pk, - "sender_kind": "employer", - "sender_company": company.pk, - "sender": company.members.first().pk, - # Formsets to please django admin - **JOB_APPLICATION_FORMSETS_PAYLOAD, - } - response = admin_client.post(reverse("admin:job_applications_jobapplication_add"), post_data) - assert response.status_code == 200 - assertFormError( - response.context["adminform"].form, - "hiring_start_at", - "Ce champ est obligatoire pour les candidatures acceptées.", + response = admin_client.get(url) + assertMessages( + response, + [ + messages.Message( + messages.ERROR, + "Le champ 'Date de début du contrat' est obligatoire pour accepter une candidature", + ) + ], ) + assertContains(response, 'value="Accepter"') # Retry with the mandatory date post_data["hiring_start_at"] = timezone.localdate() - response = admin_client.post(reverse("admin:job_applications_jobapplication_add"), post_data) - assert response.status_code == 302 + response = admin_client.post(url, {**post_data, "transition_accept": True}) + assertRedirects(response, url, fetch_redirect_response=False) # don't flush the messages + job_application.refresh_from_db() + assert job_application.state.is_accepted + assert job_application.logs.count() == 1 # processing->accepted + assert job_application.approval is None + - job_app = models.JobApplication.objects.get() - assert job_app.state.is_accepted - assert job_app.logs.count() == 2 # new->processing & processing->accepted - assert job_app.approval is None +@pytest.mark.parametrize("state", JobApplicationState) +def test_available_transitions(admin_client, state, snapshot): + job_application = factories.JobApplicationFactory(state=state) + response = admin_client.get(reverse("admin:job_applications_jobapplication_change", args=(job_application.pk,))) + assert str(parse_response_to_soup(response, "#job-app-transitions")) == snapshot -def test_create_accepted_job_application_for_job_seeker_with_approval(admin_client): +def test_accept_job_application_for_job_seeker_with_approval(admin_client): # Create an approval with a diagnosis that would not be valid for the other company # (if the approval didn't exist) existing_approval = ApprovalFactory(eligibility_diagnosis=EligibilityDiagnosisMadeBySiaeFactory()) job_seeker = existing_approval.user - company = CompanyFactory(subject_to_eligibility=True, with_membership=True) + job_application = factories.JobApplicationFactory( + job_seeker=job_seeker, + state=JobApplicationState.PROCESSING, + ) + + url = reverse("admin:job_applications_jobapplication_change", args=(job_application.pk,)) + response = admin_client.get(url) + assertContains(response, 'value="Accepter"') + post_data = { "state": "accepted", "job_seeker": job_seeker.pk, - "to_company": company.pk, - "sender_kind": "employer", - "sender_company": company.pk, - "sender": company.members.first().pk, + "to_company": job_application.to_company_id, + "sender_kind": job_application.sender_kind, + "sender": job_application.sender_id, + "hiring_start_at": timezone.localdate(), # Formsets to please django admin **JOB_APPLICATION_FORMSETS_PAYLOAD, } - response = admin_client.post(reverse("admin:job_applications_jobapplication_add"), post_data) - assert response.status_code == 200 - assertFormError( - response.context["adminform"].form, - "hiring_start_at", - "Ce champ est obligatoire pour les candidatures acceptées.", - ) - - # Retry with the mandatory date - post_data["hiring_start_at"] = timezone.localdate() - response = admin_client.post(reverse("admin:job_applications_jobapplication_add"), post_data) - assert response.status_code == 302 - job_app = models.JobApplication.objects.get() - assert job_app.state.is_accepted - assert job_app.logs.count() == 2 # new->processing & processing->accepted - assert job_app.approval == existing_approval + response = admin_client.post(url, {**post_data, "transition_accept": True}) + assertRedirects(response, url, fetch_redirect_response=False) # don't flush the messages + job_application.refresh_from_db() + assert job_application.state.is_accepted + assert job_application.logs.count() == 1 # processing->accepted + assert job_application.approval == existing_approval