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 }}
+
+
+
+
+{% 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]
+ '''
+
+ '''
+# ---
+# 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