Skip to content

Commit

Permalink
add batch refuse
Browse files Browse the repository at this point in the history
  • Loading branch information
xavfernandez committed Jan 14, 2025
1 parent 466ad1e commit d4ade3a
Show file tree
Hide file tree
Showing 5 changed files with 321 additions and 4 deletions.
25 changes: 25 additions & 0 deletions itou/templates/apply/includes/siae_actions.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@
<div id="mass-action-box" class="c-box c-box--action" {% if request.htmx %}hx-swap-oob="true"{% endif %}>
<h2 class="visually-hidden">Actions rapides</h2>
<div class="form-row align-items-center gx-3">
<div class="form-group col-12 col-lg-auto">
{% if can_refuse %}
<form method="post" action="{% url 'apply:batch_refuse' %}?next_url={{ list_url|urlencode }}">
{% csrf_token %}
{% for application_id in selected_application_ids %}
<input type="hidden" name="application_ids" value="{{ application_id }}" />
{% endfor %}
<button type="submit" class="btn btn-lg btn-link-white btn-block btn-ico justify-content-center">
<i class="ri-close-line" aria-hidden="true"></i>
<span>Décliner</span>
</button>
</form>
{% else %}
<button type="button"
class="btn btn-lg btn-link-white btn-block btn-ico justify-content-center"
disabled
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-custom-class="c-box--action-tooltip"
data-bs-title="Seules les candidatures au statut “Nouvelle”, “A l’étude” et “En attente” peuvent être déclinées.">
<i class="ri-close-line" aria-hidden="true"></i>
<span>Décliner</span>
</button>
{% endif %}
</div>
<div class="form-group col-12 col-lg-auto">
{% if can_postpone %}
<button type="button" class="btn btn-lg btn-link-white btn-block btn-ico" data-bs-toggle="modal" data-bs-target="#postpone_confirmation_modal">
Expand Down
7 changes: 7 additions & 0 deletions itou/templates/apply/process_refuse.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
{% load django_bootstrap5 %}
{% load theme_inclusion %}
{% load buttons_form %}
{% load str_filters %}

{% block title %}
{% if job_applications|length == 1 %}
Expand Down Expand Up @@ -171,6 +172,12 @@ <h2 class="mb-3 mb-md-4">Réponse {{ to_prescriber }}</h2>
$('input[name="reason-refusal_reason"]').change(function() {
manageWarningSection(this.value);
});

// Batch mode field names
manageWarningSection($('input[name="refusal_reason"]:checked').val());
$('input[name="refusal_reason"]').change(function() {
manageWarningSection(this.value);
});
});
</script>
{% endif %}
Expand Down
6 changes: 6 additions & 0 deletions itou/www/apply/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
path("siae/list/actions", list_views.list_for_siae_actions, name="list_for_siae_actions"),
path("company/batch/archive", batch_views.archive, name="batch_archive"),
path("company/batch/postpone", batch_views.postpone, name="batch_postpone"),
path("company/batch/refuse", batch_views.refuse, name="batch_refuse"),
path(
"company/batch/refuse/<uuid:session_uuid>/<slug:step>",
batch_views.RefuseWizardView.as_view(url_name="apply:batch_refuse_steps"),
name="batch_refuse_steps",
),
path("company/batch/transfer", batch_views.transfer, name="batch_transfer"),
# Process.
path(
Expand Down
285 changes: 281 additions & 4 deletions itou/www/apply/views/batch_views.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,45 @@
import logging
from collections import namedtuple

from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.template import loader
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from django.views.generic import TemplateView
from django_xworkflows import models as xwf_models

from itou.companies.models import Company
from itou.job_applications import enums as job_applications_enums
from itou.job_applications.models import JobApplication
from itou.utils.auth import check_user
from itou.utils.perms.company import get_current_company_or_404
from itou.utils.session import SessionNamespace
from itou.utils.templatetags.str_filters import pluralizefr
from itou.utils.urls import get_safe_url
from itou.www.apply.forms import BatchPostponeForm
from itou.www.apply.forms import (
BatchPostponeForm,
JobApplicationRefusalJobSeekerAnswerForm,
JobApplicationRefusalPrescriberAnswerForm,
JobApplicationRefusalReasonForm,
_get_orienter_and_prescriber_nb,
)
from itou.www.apply.views.process_views import RefuseViewStep


logger = logging.getLogger(__name__)


def _get_and_lock_employer_applications(company, request):
def _get_and_lock_employer_applications(company, request, lock=True):
application_ids = request.POST.getlist("application_ids")
applications = list(company.job_applications_received.filter(pk__in=application_ids).select_for_update())
qs = company.job_applications_received.filter(pk__in=application_ids)
if lock:
qs = qs.select_for_update()
applications = list(qs)
if mismatch_nb := len(application_ids) - len(applications):
if mismatch_nb > 1:
messages.error(
Expand Down Expand Up @@ -106,6 +123,8 @@ def postpone(request):
),
extra_tags="toast",
)
else:
postponed_ids.append(job_application.pk)

postponed_nb = len(postponed_ids)
messages.success(
Expand All @@ -127,6 +146,264 @@ def postpone(request):
return HttpResponseRedirect(next_url)

Check warning

Code scanning / CodeQL

URL redirection from remote source Medium

Untrusted URL redirection depends on a
user-provided value
.


BATCH_REFUSE_SESSION_KIND = "job-applications-batch-refuse"


@check_user(lambda user: user.is_employer)
@require_POST
def refuse(request):
company = get_current_company_or_404(request)
applications = _get_and_lock_employer_applications(company, request, lock=False)
refuse_session = SessionNamespace.create_uuid_namespace(
request.session,
data={
"kind": BATCH_REFUSE_SESSION_KIND,
"config": {
"reset_url": get_safe_url(request, "next_url", fallback_url=reverse("apply:list_for_siae")),
},
"application_ids": [application.pk for application in applications],
},
)
return HttpResponseRedirect(
reverse(
"apply:batch_refuse_steps", kwargs={"session_uuid": refuse_session.name, "step": RefuseViewStep.REASON}
)
)


class RefuseWizardView(UserPassesTestMixin, TemplateView):
url_name = None # Set by `RefuseWizardView.as_view` call in urls.py
expected_session_kind = BATCH_REFUSE_SESSION_KIND
STEPS = [
RefuseViewStep.REASON,
RefuseViewStep.JOB_SEEKER_ANSWER,
RefuseViewStep.PRESCRIBER_ANSWER,
]

template_name = "apply/process_refuse.html"

def test_func(self):
return self.request.user.is_authenticated and self.request.user.is_employer

def setup(self, request, *args, session_uuid, step, **kwargs):
super().setup(request, *args, **kwargs)
# Load session data
wizard_session = SessionNamespace(request.session, session_uuid)
if not wizard_session.exists():
raise Http404
if (session_kind := wizard_session.get("kind")) != self.expected_session_kind:
logger.warning(f"Trying to reuse invalid session with kind={session_kind}")
raise Http404
self.wizard_session = wizard_session
self.reset_url = wizard_session.get("config", {}).get("reset_url")

# Batch refuse specific logic
self.applications = get_current_company_or_404(request).job_applications_received.filter(
pk__in=wizard_session.get("application_ids", [])
)
if not self.applications:
raise Http404

# Check step consistency
self.steps = self.get_steps()
try:
step = RefuseViewStep(step)
except ValueError:
raise Http404
if step not in self.steps:
raise Http404

self.step = step
self.next_step = self.get_next_step()

self.form = self.get_form(self.step, data=self.request.POST if self.request.method == "POST" else None)

def get_steps(self):
if any(
job_application.sender_kind == job_applications_enums.SenderKind.PRESCRIBER
for job_application in self.applications
):
return self.STEPS
return [
RefuseViewStep.REASON,
RefuseViewStep.JOB_SEEKER_ANSWER,
]

def get_next_step(self):
next_step_index = self.steps.index(self.step) + 1
if next_step_index >= len(self.steps):
return None
return self.steps[next_step_index]

def get_previous_step(self):
prev_step_index = self.steps.index(self.step) - 1
if prev_step_index < 0:
return None
return self.steps[prev_step_index]

def get_step_url(self, step):
return reverse(self.url_name, kwargs={"session_uuid": self.wizard_session.name, "step": step})

def get_form_initial(self, step):
initial_data = self.wizard_session.get("data", {}).get(step, {})
# XXX: check if we want to override job_seeker_answer even if one is present ?
if step == RefuseViewStep.JOB_SEEKER_ANSWER and not initial_data.get("job_seeker_answer"):
refusal_reason = self.wizard_session.get("data", {}).get(RefuseViewStep.REASON, {}).get("refusal_reason")

if refusal_reason:
to_companies = {job_application.to_company for job_application in self.applications}
if len(to_companies) != 1:
# This shouldn't happen
logger.warning("Handling a batch of applications from different companies: %s", to_companies)

initial_data["job_seeker_answer"] = loader.render_to_string(
f"apply/refusal_messages/{refusal_reason}.txt",
context={
"to_company": tuple(to_companies)[0],
}
if refusal_reason == job_applications_enums.RefusalReason.NON_ELIGIBLE
else {},
request=self.request,
)
return initial_data

def get_form_kwargs(self):
return {"job_applications": self.applications}

def get_form_class(self, step):
return {
RefuseViewStep.REASON: JobApplicationRefusalReasonForm,
RefuseViewStep.JOB_SEEKER_ANSWER: JobApplicationRefusalJobSeekerAnswerForm,
RefuseViewStep.PRESCRIBER_ANSWER: JobApplicationRefusalPrescriberAnswerForm,
}[step]

def get_form(self, step, data):
return self.get_form_class(step)(initial=self.get_form_initial(step), data=data, **self.get_form_kwargs())

def get_context_data(self, **kwargs):
orienter_nb, prescriber_nb = _get_orienter_and_prescriber_nb(self.applications)
if orienter_nb and not prescriber_nb:
to_prescriber = pluralizefr(orienter_nb, "à l’orienteur,aux orienteurs")
the_prescriber = pluralizefr(orienter_nb, "l’orienteur,les orienteurs")
elif prescriber_nb and not orienter_nb:
to_prescriber = pluralizefr(orienter_nb, "au prescripteur,aux prescripteurs")
the_prescriber = pluralizefr(orienter_nb, "le prescripteur,les prescripteurs")
else:
# orienter_nb & prescriber_nb might both be equal to 0 here
to_prescriber = "aux prescripteurs/orienteurs"
the_prescriber = "les prescripteurs/orienteurs"

Steps = namedtuple("Steps", ["current", "step1", "count", "next", "prev"])
context = super().get_context_data(**kwargs) | {
"job_applications": self.applications,
"can_view_personal_information": True, # SIAE members have access to personal info
"matomo_custom_title": "Candidatures refusées",
"matomo_event_name": f"batch-refuse-applications-{self.step}-submit",
# Compatibility with current process_refuse.html
"wizard": {
"steps": Steps(
current=self.step,
step1=self.steps.index(self.step) + 1,
count=len(self.steps),
next=self.next_step,
prev=self.get_step_url(self.get_previous_step()),
),
"form": self.form,
},
"form": self.form,
"reset_url": self.reset_url,
"RefuseViewStep": RefuseViewStep,
"to_prescriber": to_prescriber,
"the_prescriber": the_prescriber,
"with_prescriber": orienter_nb or prescriber_nb,
"job_seeker_nb": len(set(job_application.job_seeker_id for job_application in self.applications)),
}
if self.step != RefuseViewStep.REASON:
reason_data = self.wizard_session.get("data", {}).get(RefuseViewStep.REASON, {})

if refusal_reason := reason_data.get("refusal_reason"):
context["refusal_reason_label"] = job_applications_enums.RefusalReason(refusal_reason).label
context["refusal_reason_shared_with_job_seeker"] = reason_data.get("refusal_reason_shared_with_job_seeker")

return context

def check_data_until_step(self, step):
"""Return the step with invalid data or None if everything is fine"""
for previous_step in self.steps:
if previous_step == step:
return None
form = self.get_form(previous_step, data=self.wizard_session.get("data", {}).get(previous_step, {}))
if not form.is_valid():
return previous_step
return None

def get(self, request, *args, **kwargs):
if invalid_step := self.check_data_until_step(self.step):
return HttpResponseRedirect(self.get_step_url(invalid_step))
return super().get(request, *args, **kwargs)

def post(self, request, *args, **kwargs):
if self.form.is_valid():
refuse_session_data = self.wizard_session.get("data", {})
refuse_session_data[self.step] = self.form.cleaned_data
self.wizard_session.set("data", refuse_session_data)
if self.next_step:
return HttpResponseRedirect(self.get_step_url(self.next_step))
else:
if invalid_step := self.check_data_until_step(self.step):
messages.warning(request, "Certaines informations sont absentes ou invalides")
return HttpResponseRedirect(self.get_step_url(invalid_step))
self.done()
return HttpResponseRedirect(self.reset_url)
context = self.get_context_data(**kwargs)
return self.render_to_response(context)

def done(self):
refuse_session_data = self.wizard_session.get("data", {})
# We're done, refuse all applications !
refused_ids = []
for job_application in self.applications:
job_application.refusal_reason = refuse_session_data[RefuseViewStep.REASON]["refusal_reason"]
job_application.refusal_reason_shared_with_job_seeker = refuse_session_data[RefuseViewStep.REASON][
"refusal_reason_shared_with_job_seeker"
]
job_application.answer = refuse_session_data[RefuseViewStep.JOB_SEEKER_ANSWER]["job_seeker_answer"]
job_application.answer_to_prescriber = refuse_session_data[RefuseViewStep.PRESCRIBER_ANSWER][
"prescriber_answer"
]
try:
job_application.refuse(user=self.request.user)
except xwf_models.InvalidTransitionError:
messages.error(
self.request,
(
f"La candidature de {job_application.job_seeker.get_full_name()} n’a pas pu être refusée "
f"car elle est au statut “{job_application.get_state_display()}“."
),
extra_tags="toast",
)
else:
refused_ids.append(job_application.pk)
refused_nb = len(refused_ids)
if refused_nb:
messages.success(
self.request,
(
f"{refused_nb} candidatures ont été refusées."
if refused_nb > 1
else "La candidature de {self.application[0].job_seeker.get_full_name()} a bien été refusée."
),
extra_tags="toast",
)
logger.info(
"user=%s batch refused %s applications: %s",
self.request.user.pk,
refused_nb,
",".join(str(app_uid) for app_uid in refused_ids),
)
self.wizard_session.delete()


@check_user(lambda user: user.is_employer)
@require_POST
def transfer(request):
Expand Down
Loading

0 comments on commit d4ade3a

Please sign in to comment.