Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PASS IAE: La date de début du contrat doit être avant la date de fin du PASS [GEN-2225] #5248

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions itou/approvals/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,15 @@ class CommonApprovalQuerySet(models.QuerySet):
A QuerySet shared by both `Approval` and `PoleEmploiApproval` models.
"""

@property
def valid_lookup(self):
return Q(end_at__gte=timezone.localdate())
def valid_lookup(self, on_date=None):
on_date = on_date or timezone.localdate()
return Q(end_at__gte=on_date)

def valid(self):
return self.filter(self.valid_lookup)
def valid(self, on_date=None):
return self.filter(self.valid_lookup(on_date))

def invalid(self):
return self.exclude(self.valid_lookup)
def invalid(self, on_date=None):
return self.exclude(self.valid_lookup(on_date))

def starts_in_the_past(self):
return self.filter(Q(start_at__lt=timezone.localdate()))
Expand Down
6 changes: 4 additions & 2 deletions itou/eligibility/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from itou.eligibility.tasks import async_certify_criteria, certify_criteria
from itou.job_applications.enums import SenderKind
from itou.utils.date import make_date_timezone_aware
from itou.utils.models import InclusiveDateRangeField
from itou.utils.types import InclusiveDateRange

Expand All @@ -22,8 +23,9 @@


class CommonEligibilityDiagnosisQuerySet(models.QuerySet):
def valid(self):
return self.filter(expires_at__gt=timezone.now())
def valid(self, on_date=None):
on_date = on_date or timezone.now()
return self.filter(expires_at__gt=make_date_timezone_aware(on_date))

def expired(self):
return self.filter(expires_at__lte=timezone.now())
Expand Down
11 changes: 8 additions & 3 deletions itou/eligibility/models/iae.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from itou.approvals.models import Approval
from itou.eligibility.enums import AdministrativeCriteriaLevel, AuthorKind
from itou.utils.date import make_date_timezone_aware

from .common import (
AbstractAdministrativeCriteria,
Expand Down Expand Up @@ -39,10 +40,10 @@ def has_approval(self):


class EligibilityDiagnosisManager(models.Manager):
def has_considered_valid(self, job_seeker, for_siae=None):
def has_considered_valid(self, job_seeker, for_siae=None, on_date=None):
"""
Returns True if the given job seeker has a considered valid diagnosis,
False otherwise.
False otherwise. If on_date is given, the validity is considered for this date.

We consider the eligibility as valid when a PASS IAE is valid but
the eligibility diagnosis is missing.
Expand All @@ -57,7 +58,11 @@ def has_considered_valid(self, job_seeker, for_siae=None):
Hence the Trello #2604 decision: if a PASS IAE is valid, we do not
check the presence of an eligibility diagnosis.
"""
return job_seeker.has_valid_approval or bool(self.last_considered_valid(job_seeker, for_siae=for_siae))
on_date = on_date or timezone.now()
last_valid_diagnosis = self.last_considered_valid(job_seeker, for_siae=for_siae)
return job_seeker.has_valid_approval_for_date(on_date) or (
last_valid_diagnosis and last_valid_diagnosis.expires_at > make_date_timezone_aware(on_date)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A confirmer avec le métier mais pour les diagnostiques il me semble qu'on veux qu'il soit valide au moment où on accepte la candidature et on s'en fiche qu'il le soit encore le jour de l'embauche car il aura un PASS IAE à ce moment là.

)
Comment on lines +62 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C'est dommage : avec ton changement on fait les requêtes de last_considered_valid même si job_seeker.has_valid_approval_for_date(on_date) est True

Suggested change
last_valid_diagnosis = self.last_considered_valid(job_seeker, for_siae=for_siae)
return job_seeker.has_valid_approval_for_date(on_date) or (
last_valid_diagnosis and last_valid_diagnosis.expires_at > make_date_timezone_aware(on_date)
)
if job_seeker.has_valid_approval_for_date(on_date):
return True
last_valid_diagnosis = self.last_considered_valid(job_seeker, for_siae=for_siae)
return last_valide_diagnosis and last_valid_diagnosis.expires_at > make_date_timezone_aware(on_date)
)


def last_considered_valid(self, job_seeker, for_siae=None, prefetch=None):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last_considered_valid m'a posé une colle. Avec la description du méthode, il me semble que c'est la dernière diagnostique aujourd'hui pour le candidat, et que je devrais ignorer le fonction dans ce PR.

Par contre, c'est utilisé dans le modèle JobApplication, dans get_eligibility_diagnosis :

return EligibilityDiagnosis.objects.last_considered_valid(self.job_seeker, for_siae=self.to_company)

Donc c'est possible que ce fonction devrait produire une éligibilité qui est valide pour la date de début d'embauche

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pour moi, tu devrais ajouter un on_date= dans last_considered_valid() car c'est utilisé dans hire_confirmation()

"""
Expand Down
2 changes: 1 addition & 1 deletion itou/job_applications/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def _get_selected_jobs(job_application):
def _get_eligibility_status(job_application):
eligibility = "non"
# Eligibility diagnoses made by SIAE are ignored.
if job_application.job_seeker.has_valid_diagnosis():
if job_application.job_seeker_has_valid_diagnosis():
eligibility = "oui"

return eligibility
Expand Down
24 changes: 16 additions & 8 deletions itou/job_applications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,11 @@ def with_jobseeker_valid_eligibility_diagnosis(self):
"""
sub_query = Subquery(
(
EligibilityDiagnosis.objects.valid()
.for_job_seeker_and_siae(job_seeker=OuterRef("job_seeker"), siae=OuterRef("to_company"))
EligibilityDiagnosis.objects.for_job_seeker_and_siae(
job_seeker=OuterRef("job_seeker"), siae=OuterRef("to_company")
)
.filter(expires_at__gt=OuterRef("hiring_start_at"))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hiring_start_at n'est renseignée qu'une fois que la candidature est acceptée par l'employeur donc la comparaison avec NULL sera toujours fausse donc aucun diagnostique ne sera renvoyé tant que le champ n'est pas remplis.

.valid()
.values("id")[:1]
),
output_field=models.IntegerField(),
Expand All @@ -251,14 +254,14 @@ def with_jobseeker_eligibility_diagnosis(self):
def eligibility_validated(self):
return self.filter(
Exists(
Approval.objects.filter(
user=OuterRef("job_seeker"),
).valid()
Approval.objects.filter(user=OuterRef("job_seeker"), end_at__gte=OuterRef("hiring_start_at")).valid()
)
| Exists(
EligibilityDiagnosis.objects.for_job_seeker_and_siae(
OuterRef("job_seeker"), siae=OuterRef("to_company")
).valid()
)
.filter(expires_at__gt=OuterRef("hiring_start_at"))
.valid()
)
)

Expand Down Expand Up @@ -729,13 +732,18 @@ def is_sent_by_authorized_prescriber(self):
def is_spontaneous(self):
return not self.selected_jobs.exists()

def job_seeker_has_valid_diagnosis(self):
return self.job_seeker.has_valid_diagnosis(for_siae=self.to_company, on_date=self.hiring_start_at)

def eligibility_diagnosis_by_siae_required(self):
"""
Returns True if an eligibility diagnosis must be made by an SIAE
when processing an application, False otherwise.
"""
return self.to_company.is_subject_to_eligibility_rules and not self.job_seeker.has_valid_diagnosis(
for_siae=self.to_company
return (
not self.hiring_without_approval
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bien vu pour cet ajout

and self.to_company.is_subject_to_eligibility_rules
and not self.job_seeker_has_valid_diagnosis()
)

@property
Expand Down
13 changes: 9 additions & 4 deletions itou/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from itou.common_apps.address.format import compute_hexa_address
from itou.common_apps.address.models import AddressMixin
from itou.companies.enums import CompanyKind
from itou.utils.date import make_date_timezone_aware
from itou.utils.db import or_queries
from itou.utils.models import UniqueConstraintWithErrorCode
from itou.utils.templatetags.str_filters import mask_unless
Expand Down Expand Up @@ -511,6 +512,10 @@ def latest_common_approval(self):

return self.latest_approval or self.latest_pe_approval

def has_valid_approval_for_date(self, on_date):
on_date = make_date_timezone_aware(on_date)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quel intérêt de rendre on_date et self.latest_approval.end_at aware pour les comparer.
Ce ne sont pas tous les deux des datetime.date ?

return self.has_valid_approval and make_date_timezone_aware(self.latest_approval.end_at) > on_date

@property
def has_valid_approval(self):
return self.latest_approval and self.latest_approval.is_valid()
Expand All @@ -533,11 +538,10 @@ def new_approval_blocked_by_waiting_period(self, siae, sender_prescriber_organiz
)

# Only diagnoses made by authorized prescribers are taken into account.
has_valid_diagnosis = self.has_valid_diagnosis()
return (
self.has_latest_common_approval_in_waiting_period
and siae.is_subject_to_eligibility_rules
and not (is_sent_by_authorized_prescriber or has_valid_diagnosis)
and not (is_sent_by_authorized_prescriber or self.has_valid_diagnosis())
)

@property
Expand All @@ -563,8 +567,9 @@ def has_external_data(self):
def has_jobseeker_profile(self):
return self.is_job_seeker and hasattr(self, "jobseeker_profile")

def has_valid_diagnosis(self, for_siae=None):
return self.eligibility_diagnoses.has_considered_valid(job_seeker=self, for_siae=for_siae)
def has_valid_diagnosis(self, for_siae=None, on_date=None):
on_date = on_date or timezone.now()
return self.eligibility_diagnoses.has_considered_valid(job_seeker=self, for_siae=for_siae, on_date=on_date)

def joined_recently(self):
time_since_date_joined = timezone.now() - self.date_joined
Expand Down
12 changes: 12 additions & 0 deletions itou/utils/date.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,15 @@
def monday_of_the_week(value=None):
the_day = timezone.localdate(value)
return the_day - datetime.timedelta(days=the_day.weekday())


def make_date_timezone_aware(value, tz=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je ne suis pas certain de saisir l'utilité de cette fonction, on ne devrait avoir que des datetime avec timezone dans notre base de code et notre base de données.
Reste la conversion de date vers datetime: le mieux serait sûrement de ne pas avoir à les comparer et pour cela #5210 me semble une meilleure solution et devrait faciliter cette PR.

"""
Makes datetime.date or datetime.datetime timezone aware.
Useful when providing a DateField to a query on the DateTimeField of another field/model.
"""
if not hasattr(value, "hour"):
value = datetime.datetime.combine(value, datetime.datetime.min.time())
if timezone.is_aware(value):
return value
return timezone.make_aware(value, tz)
16 changes: 12 additions & 4 deletions itou/www/apply/views/process_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,19 +536,27 @@ def accept(request, job_application_id, template_name="apply/process_accept.html
Trigger the `accept` transition.
"""
queryset = JobApplication.objects.is_active_company_member(request.user).select_related(
"job_seeker", "job_seeker__jobseeker_profile"
"job_seeker", "job_seeker__jobseeker_profile", "to_company"
)
job_application = get_object_or_404(queryset, id=job_application_id)
job_seeker = job_application.job_seeker
check_waiting_period(job_application)
next_url = reverse("apply:details_for_company", kwargs={"job_application_id": job_application.pk})
if not job_application.hiring_without_approval and job_application.eligibility_diagnosis_by_siae_required():
messages.error(request, "Cette candidature requiert un diagnostic d'éligibilité pour être acceptée.")

# Verify PASS IAE approval.
if job_application.eligibility_diagnosis_by_siae_required():
# Either the candidate has a valid diagnosis that will be out of date...
if job_seeker.has_valid_diagnosis(for_siae=job_application.to_company, on_date=timezone.now()):
messages.error(request, "Le contrat doit débuter sur la période couverte par le PASS IAE.")
Comment on lines +549 to +550
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timezone.now() ? Ça devrais pas être self.hiring_start_at justement ?

Et le lien de causalité entre has_valid_diagnosis() et le message d'erreur n'est absolument pas clair, je dirais même qu'il n'y a aucun rapport car dans l'un on parle explicitement de "PASS IAE" et pas de "Diagnostique d'élégibilité".

# Or they have no valid diagnosis.
else:
messages.error(request, "Cette candidature requiert un diagnostic d'éligibilité pour être acceptée.")
return HttpResponseRedirect(next_url)

return common_views._accept(
request,
job_application.to_company,
job_application.job_seeker,
job_seeker,
error_url=next_url,
back_url=next_url,
template_name=template_name,
Expand Down
16 changes: 16 additions & 0 deletions tests/eligibility/test_iae.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,22 @@ def test_expired_itou_diagnosis(self):
assert last_considered_valid is None
assert last_expired is not None

def test_diagnosis_expiring(self):
expires_at = timezone.now() + datetime.timedelta(days=1)
diagnosis = IAEEligibilityDiagnosisFactory(
from_prescriber=True, job_seeker=self.job_seeker, expires_at=expires_at
)
has_considered_valid = EligibilityDiagnosis.objects.has_considered_valid(job_seeker=diagnosis.job_seeker)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pourquoi ne pas mettre les assert sans variable intermédiaire ?
assert EligibilityDiagnosis.objects.has_considered_valid(job_seeker=diagnosis.job_seeker)

last_considered_valid = EligibilityDiagnosis.objects.last_considered_valid(job_seeker=diagnosis.job_seeker)
last_expired = EligibilityDiagnosis.objects.last_expired(job_seeker=diagnosis.job_seeker)
assert last_considered_valid == diagnosis
assert last_expired is None
assert has_considered_valid

assert not EligibilityDiagnosis.objects.has_considered_valid(
job_seeker=diagnosis.job_seeker, on_date=(expires_at + datetime.timedelta(hours=1))
)

def test_expired_itou_diagnosis_with_ongoing_approval(self):
expired_diagnosis = IAEEligibilityDiagnosisFactory(
from_prescriber=True, job_seeker=self.job_seeker, expired=True
Expand Down
22 changes: 22 additions & 0 deletions tests/job_applications/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ def test_eligibility_diagnosis_by_siae_required(self):
assert not has_considered_valid_diagnoses
assert job_application.eligibility_diagnosis_by_siae_required()

def test_eligibility_diagnosis_by_siae_required_hiring_without_approval(self):
job_application = JobApplicationFactory(
state=JobApplicationState.PROCESSING,
to_company__kind=CompanyKind.EI,
eligibility_diagnosis=None,
hiring_without_approval=True,
)
has_considered_valid_diagnoses = EligibilityDiagnosis.objects.has_considered_valid(
job_application.job_seeker, for_siae=job_application.to_company
)
assert not has_considered_valid_diagnoses
assert not job_application.eligibility_diagnosis_by_siae_required()

def test_accepted_by(self):
job_application = JobApplicationFactory(
sent_by_authorized_prescriber_organisation=True,
Expand Down Expand Up @@ -383,6 +396,15 @@ def test_with_jobseeker_eligibility_diagnosis(self):
qs = JobApplication.objects.with_jobseeker_eligibility_diagnosis().get(pk=job_app.pk)
assert qs.jobseeker_eligibility_diagnosis == diagnosis.pk

def test_with_jobseeker_eligibility_diagnosis_expires_before_job_start(self):
tomorrow = timezone.localdate() + datetime.timedelta(days=1)
job_app = JobApplicationFactory(
with_approval=True, eligibility_diagnosis__expires_at=timezone.now(), hiring_start_at=tomorrow
)
qs = JobApplication.objects.with_jobseeker_eligibility_diagnosis().get(pk=job_app.pk)
# Not considered valid.
assert qs.jobseeker_valid_eligibility_diagnosis is None

def test_with_jobseeker_eligibility_diagnosis_with_a_denormalized_diagnosis_from_prescriber(self):
job_application = JobApplicationFactory(
sent_by_authorized_prescriber_organisation=True,
Expand Down
14 changes: 13 additions & 1 deletion tests/utils/test_date.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import datetime

import freezegun
from django.utils import timezone

from itou.utils.date import monday_of_the_week
from itou.utils.date import make_date_timezone_aware, monday_of_the_week


def test_monday_of_the_week_with_arguments():
Expand All @@ -21,3 +22,14 @@ def test_monday_of_the_week_with_arguments():
def test_monday_of_the_week_without_arguments():
with freezegun.freeze_time(datetime.date(2024, 8, 1)):
assert monday_of_the_week() == datetime.date(2024, 7, 29)


@freezegun.freeze_time("2024-12-04 15:00:00")
def test_make_date_timezone_aware():
assert timezone.is_aware(make_date_timezone_aware(datetime.date.today()))

assert make_date_timezone_aware(datetime.date.today()).strftime("%Y-%m-%d %H:%M") == "2024-12-04 00:00"
assert make_date_timezone_aware(timezone.localdate()).strftime("%Y-%m-%d %H:%M") == "2024-12-04 00:00"

assert make_date_timezone_aware(datetime.datetime.now()).strftime("%Y-%m-%d %H") == "2024-12-04 15"
assert make_date_timezone_aware(timezone.now()).strftime("%Y-%m-%d %H") == "2024-12-04 15"
36 changes: 20 additions & 16 deletions tests/www/apply/__snapshots__/test_list_for_siae.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -716,19 +716,21 @@
COALESCE("job_applications_jobapplication"."eligibility_diagnosis_id",
(SELECT U0."id"
FROM "eligibility_eligibilitydiagnosis" U0
WHERE (U0."expires_at" > %s
AND (U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id"))
WHERE ((U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id")
AND U0."expires_at" > ("job_applications_jobapplication"."hiring_start_at")
AND U0."expires_at" > %s)
ORDER BY U0."created_at" DESC
LIMIT 1)) AS "jobseeker_eligibility_diagnosis",

(SELECT U0."id"
FROM "eligibility_eligibilitydiagnosis" U0
WHERE (U0."expires_at" > %s
AND (U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id"))
WHERE ((U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id")
AND U0."expires_at" > ("job_applications_jobapplication"."hiring_start_at")
AND U0."expires_at" > %s)
ORDER BY U0."created_at" DESC
LIMIT 1) AS "jobseeker_valid_eligibility_diagnosis",
GREATEST("job_applications_jobapplication"."created_at", MAX("job_applications_jobapplicationtransitionlog"."timestamp")) AS "last_change",
Expand Down Expand Up @@ -809,19 +811,21 @@

(SELECT U0."id"
FROM "eligibility_eligibilitydiagnosis" U0
WHERE (U0."expires_at" > %s
AND (U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id"))
WHERE ((U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id")
AND U0."expires_at" > ("job_applications_jobapplication"."hiring_start_at")
AND U0."expires_at" > %s)
ORDER BY U0."created_at" DESC
LIMIT 1) AS "jobseeker_valid_eligibility_diagnosis",
COALESCE("job_applications_jobapplication"."eligibility_diagnosis_id",
(SELECT U0."id"
FROM "eligibility_eligibilitydiagnosis" U0
WHERE (U0."expires_at" > %s
AND (U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id"))
WHERE ((U0."author_kind" = %s
OR U0."author_siae_id" = ("job_applications_jobapplication"."to_company_id"))
AND U0."job_seeker_id" = ("job_applications_jobapplication"."job_seeker_id")
AND U0."expires_at" > ("job_applications_jobapplication"."hiring_start_at")
AND U0."expires_at" > %s)
ORDER BY U0."created_at" DESC
LIMIT 1)) AS "jobseeker_eligibility_diagnosis",
EXISTS
Expand Down
Loading
Loading