diff --git a/itou/eligibility/models/geiq.py b/itou/eligibility/models/geiq.py index 8d0cbc1b2f..8876d2dded 100644 --- a/itou/eligibility/models/geiq.py +++ b/itou/eligibility/models/geiq.py @@ -15,6 +15,7 @@ CommonEligibilityDiagnosisQuerySet, ) from itou.eligibility.utils import geiq_allowance_amount +from itou.gps.models import FollowUpGroup from itou.prescribers.models import PrescriberOrganization from itou.users.models import User @@ -207,6 +208,9 @@ def create_eligibility_diagnosis( if administrative_criteria: result.administrative_criteria.set(administrative_criteria) + # Sync GPS groups + FollowUpGroup.objects.follow_beneficiary(job_seeker, author) + return result @classmethod diff --git a/itou/eligibility/models/iae.py b/itou/eligibility/models/iae.py index 04d695cd6e..a231ea714f 100644 --- a/itou/eligibility/models/iae.py +++ b/itou/eligibility/models/iae.py @@ -17,6 +17,7 @@ AdministrativeCriteriaQuerySet, CommonEligibilityDiagnosisQuerySet, ) +from itou.gps.models import FollowUpGroup logger = logging.getLogger(__name__) @@ -214,6 +215,10 @@ def create_diagnosis(cls, job_seeker, *, author, author_organization, administra ) if administrative_criteria: diagnosis.administrative_criteria.add(*administrative_criteria) + + # Sync GPS groups + FollowUpGroup.objects.follow_beneficiary(job_seeker, author) + return diagnosis @classmethod diff --git a/itou/gps/admin.py b/itou/gps/admin.py index 7f32979609..901061721d 100644 --- a/itou/gps/admin.py +++ b/itou/gps/admin.py @@ -37,7 +37,14 @@ class FollowUpGroupMembershipAdmin(ItouModelAdmin): "created_in_bulk", ) raw_id_fields = ("follow_up_group", "member") - readonly_fields = ("creator", "created_at", "updated_at", "ended_at", "created_in_bulk") + readonly_fields = ( + "creator", + "created_at", + "last_contact_at", + "updated_at", + "ended_at", + "created_in_bulk", + ) ordering = ["-created_at"] def get_readonly_fields(self, request, obj=None): diff --git a/itou/gps/migrations/0006_followupgroupmembership_last_contact_at_and_more.py b/itou/gps/migrations/0006_followupgroupmembership_last_contact_at_and_more.py new file mode 100644 index 0000000000..2e259838a3 --- /dev/null +++ b/itou/gps/migrations/0006_followupgroupmembership_last_contact_at_and_more.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.5 on 2025-01-27 10:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("gps", "0005_francetravailcontact"), + ] + + operations = [ + migrations.AddField( + model_name="followupgroupmembership", + name="last_contact_at", + field=models.DateTimeField(null=True, verbose_name="date de dernier contact"), + ), + ] diff --git a/itou/gps/models.py b/itou/gps/models.py index 2fe6f269da..51dd3369ef 100644 --- a/itou/gps/models.py +++ b/itou/gps/models.py @@ -14,20 +14,29 @@ def not_bulk_created(self): class FollowUpGroupManager(models.Manager): - def follow_beneficiary(self, beneficiary, user, is_referent=False): + def follow_beneficiary(self, beneficiary, user, is_referent=None): + now = timezone.now() with transaction.atomic(): group, _ = FollowUpGroup.objects.get_or_create(beneficiary=beneficiary) - updated = FollowUpGroupMembership.objects.filter(member=user, follow_up_group=group).update( - is_active=True, is_referent=is_referent + update_args = { + "is_active": True, + "last_contact_at": now, + } + if is_referent is not None: + update_args["is_referent"] = is_referent + + create_args = update_args | { + "creator": user, + "created_at": now, + } + + FollowUpGroupMembership.objects.update_or_create( + follow_up_group=group, + member=user, + defaults=update_args, + create_defaults=create_args, ) - if not updated: - FollowUpGroupMembership.objects.create( - follow_up_group=group, - member=user, - creator=user, - is_referent=is_referent, - ) class FollowUpGroupQueryset(BulkCreatedAtQuerysetProxy, models.QuerySet): @@ -100,6 +109,8 @@ class Meta: created_at = models.DateTimeField(verbose_name="date de création", default=timezone.now) created_in_bulk = models.BooleanField(verbose_name="créé massivement", default=False, db_index=True) + last_contact_at = models.DateTimeField(verbose_name="date de dernier contact", null=True) + # Keep track of when the membership was ended ended_at = models.DateTimeField(verbose_name="date de désactivation", null=True) diff --git a/itou/job_applications/models.py b/itou/job_applications/models.py index 0d8640dbc6..f4671a3b89 100644 --- a/itou/job_applications/models.py +++ b/itou/job_applications/models.py @@ -17,6 +17,7 @@ from itou.companies.models import CompanyMembership from itou.eligibility.enums import AuthorKind from itou.eligibility.models import EligibilityDiagnosis, SelectedAdministrativeCriteria +from itou.gps.models import FollowUpGroup from itou.job_applications import notifications as job_application_notifications from itou.job_applications.enums import ( ARCHIVABLE_JOB_APPLICATION_STATES_MANUAL, @@ -1011,6 +1012,9 @@ def accept(self, *, user): self.approval_delivery_mode = self.APPROVAL_DELIVERY_MODE_AUTOMATIC self.approval.unsuspend(self.hiring_start_at) + # Sync GPS groups + FollowUpGroup.objects.follow_beneficiary(self.job_seeker, user) + @xwf_models.transition() def postpone(self, *, user): # Send notification. diff --git a/itou/www/apply/views/submit_views.py b/itou/www/apply/views/submit_views.py index 57fb6be7b8..de5284db5d 100644 --- a/itou/www/apply/views/submit_views.py +++ b/itou/www/apply/views/submit_views.py @@ -17,6 +17,7 @@ from itou.eligibility.models import EligibilityDiagnosis from itou.eligibility.models.geiq import GEIQEligibilityDiagnosis from itou.files.models import File +from itou.gps.models import FollowUpGroup from itou.job_applications.models import JobApplication from itou.users.enums import UserKind from itou.users.models import User @@ -663,6 +664,10 @@ def form_valid(self): # The job application is now saved in DB, delete the session early to avoid any problems self.apply_session.delete() + if self.request.user.kind in [UserKind.EMPLOYER, UserKind.PRESCRIBER]: + # New job application -> sync GPS groups if the sender is not a jobseeker + FollowUpGroup.objects.follow_beneficiary(self.job_seeker, self.request.user) + try: # Send notifications company_recipients = User.objects.filter( diff --git a/itou/www/job_seekers_views/views.py b/itou/www/job_seekers_views/views.py index c5343cc450..f82c243c5f 100644 --- a/itou/www/job_seekers_views/views.py +++ b/itou/www/job_seekers_views/views.py @@ -432,9 +432,7 @@ def post(self, request, *args, **kwargs): # The NIR we found is correct if self.form.data.get("confirm"): if self.is_gps: - FollowUpGroup.objects.follow_beneficiary( - beneficiary=job_seeker, user=request.user, is_referent=True - ) + FollowUpGroup.objects.follow_beneficiary(job_seeker, request.user, is_referent=True) return HttpResponseRedirect(self.get_exit_url(job_seeker.public_id)) context = { @@ -522,9 +520,7 @@ def post(self, request, *args, **kwargs): logger.exception("step_job_seeker: error when saving job_seeker=%s nir=%s", job_seeker, nir) else: if self.is_gps: - FollowUpGroup.objects.follow_beneficiary( - beneficiary=job_seeker, user=request.user, is_referent=True - ) + FollowUpGroup.objects.follow_beneficiary(job_seeker, request.user, is_referent=True) return HttpResponseRedirect(self.get_exit_url(job_seeker.public_id)) return self.render_to_response( @@ -796,7 +792,7 @@ def post(self, request, *args, **kwargs): url = self.get_exit_url(self.profile.user.public_id, created=True) if self.is_gps: - FollowUpGroup.objects.follow_beneficiary(beneficiary=user, user=request.user, is_referent=True) + FollowUpGroup.objects.follow_beneficiary(user, request.user, is_referent=True) return HttpResponseRedirect(url) diff --git a/tests/eligibility/test_geiq.py b/tests/eligibility/test_geiq.py index 47c69f0eb6..e6b0aec98a 100644 --- a/tests/eligibility/test_geiq.py +++ b/tests/eligibility/test_geiq.py @@ -7,6 +7,7 @@ from itou.companies.enums import CompanyKind from itou.eligibility.enums import AdministrativeCriteriaAnnex, AdministrativeCriteriaLevel from itou.eligibility.models import GEIQAdministrativeCriteria, GEIQEligibilityDiagnosis +from itou.gps.models import FollowUpGroup, FollowUpGroupMembership from tests.companies.factories import CompanyWithMembershipAndJobsFactory from tests.eligibility.factories import GEIQEligibilityDiagnosisFactory from tests.job_applications.factories import JobApplicationFactory @@ -53,6 +54,11 @@ def test_create_geiq_eligibility_diagnosis(administrative_criteria_annex_1): ) assert diagnosis.pk + group = FollowUpGroup.objects.get() + assert group.beneficiary == diagnosis.job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == diagnosis.author + assert membership.creator == diagnosis.author diagnosis = GEIQEligibilityDiagnosis.create_eligibility_diagnosis( job_seeker=JobSeekerFactory(), @@ -62,6 +68,11 @@ def test_create_geiq_eligibility_diagnosis(administrative_criteria_annex_1): ) assert diagnosis.pk + group = FollowUpGroup.objects.exclude(pk=group.pk).get() # Get the newer group + assert group.beneficiary == diagnosis.job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == diagnosis.author + assert membership.creator == diagnosis.author # bad cops: diff --git a/tests/eligibility/test_iae.py b/tests/eligibility/test_iae.py index 4db20b98d8..2c9821fc6a 100644 --- a/tests/eligibility/test_iae.py +++ b/tests/eligibility/test_iae.py @@ -19,6 +19,7 @@ AdministrativeCriteriaQuerySet, ) from itou.eligibility.models.geiq import GEIQAdministrativeCriteria +from itou.gps.models import FollowUpGroup, FollowUpGroupMembership from itou.utils.mocks.api_particulier import ( rsa_certified_mocker, rsa_not_certified_mocker, @@ -276,6 +277,14 @@ def test_create_diagnosis_employer(self): assert diagnosis.administrative_criteria.count() == 0 assert diagnosis.expires_at == datetime.date(2025, 3, 5) + # Check GPS group + # ---------------------------------------------------------------------- + group = FollowUpGroup.objects.get() + assert group.beneficiary == job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == user + assert membership.creator == user + @freeze_time("2024-12-03") def test_create_diagnosis_prescriber(self): job_seeker = JobSeekerFactory() @@ -296,6 +305,14 @@ def test_create_diagnosis_prescriber(self): assert diagnosis.administrative_criteria.count() == 0 assert diagnosis.expires_at == datetime.date(2025, 6, 3) + # Check GPS group + # ---------------------------------------------------------------------- + group = FollowUpGroup.objects.get() + assert group.beneficiary == job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == prescriber + assert membership.creator == prescriber + def test_create_diagnosis_with_administrative_criteria(self): job_seeker = JobSeekerFactory() prescriber_organization = PrescriberOrganizationWithMembershipFactory(authorized=True) diff --git a/tests/gps/__snapshots__/test_views.ambr b/tests/gps/__snapshots__/test_views.ambr index 7f4b0b5470..ed8168a4c2 100644 --- a/tests/gps/__snapshots__/test_views.ambr +++ b/tests/gps/__snapshots__/test_views.ambr @@ -659,6 +659,7 @@ "gps_followupgroupmembership"."is_active", "gps_followupgroupmembership"."created_at", "gps_followupgroupmembership"."created_in_bulk", + "gps_followupgroupmembership"."last_contact_at", "gps_followupgroupmembership"."ended_at", "gps_followupgroupmembership"."updated_at", "gps_followupgroupmembership"."follow_up_group_id", @@ -770,7 +771,8 @@ "col7", "col8", "col9", - "col10" + "col10", + "col11" FROM (SELECT * FROM @@ -779,11 +781,12 @@ "gps_followupgroupmembership"."is_active" AS "col3", "gps_followupgroupmembership"."created_at" AS "col4", "gps_followupgroupmembership"."created_in_bulk" AS "col5", - "gps_followupgroupmembership"."ended_at" AS "col6", - "gps_followupgroupmembership"."updated_at" AS "col7", - "gps_followupgroupmembership"."follow_up_group_id" AS "col8", - "gps_followupgroupmembership"."member_id" AS "col9", - "gps_followupgroupmembership"."creator_id" AS "col10", + "gps_followupgroupmembership"."last_contact_at" AS "col6", + "gps_followupgroupmembership"."ended_at" AS "col7", + "gps_followupgroupmembership"."updated_at" AS "col8", + "gps_followupgroupmembership"."follow_up_group_id" AS "col9", + "gps_followupgroupmembership"."member_id" AS "col10", + "gps_followupgroupmembership"."creator_id" AS "col11", ROW_NUMBER() OVER (PARTITION BY "gps_followupgroupmembership"."follow_up_group_id" ORDER BY RANDOM() ASC) AS "qual0", RANDOM() AS "qual1" diff --git a/tests/gps/test_management_commands.py b/tests/gps/test_management_commands.py index 288a102c83..fb3ff55fff 100644 --- a/tests/gps/test_management_commands.py +++ b/tests/gps/test_management_commands.py @@ -14,6 +14,7 @@ from itou.companies.enums import CompanyKind from itou.gps.models import FollowUpGroup, FollowUpGroupMembership, FranceTravailContact from itou.job_applications.enums import JobApplicationState +from itou.job_applications.models import JobApplicationTransitionLog from itou.users.models import JobSeekerProfile from tests.companies.factories import CompanyWith4MembershipsFactory from tests.eligibility.factories import GEIQEligibilityDiagnosisFactory, IAEEligibilityDiagnosisFactory @@ -86,7 +87,12 @@ def test_group_creation(self): ) # Needed to create the transition log entries user_who_accepted = job_application_with_approval.to_company.members.last() - job_application_with_approval.accept(user=user_who_accepted) + # Don't call accept that now creates a group and membership automatically + JobApplicationTransitionLog.objects.create( + job_application=job_application_with_approval, + to_state=JobApplicationState.ACCEPTED, + user=user_who_accepted, + ) should_be_created_groups_counter += 1 should_be_created_memberships += 3 # employer who accepted, employer who made the diagnosis and prescriber @@ -172,7 +178,12 @@ def test_job_application_accepted(self): state=JobApplicationState.PROCESSING, ) user = job_application_accepted.to_company.members.first() - job_application_accepted.accept(user=user) + # Don't call accept that now creates a group and membership automatically + JobApplicationTransitionLog.objects.create( + job_application=job_application_accepted, + to_state=JobApplicationState.ACCEPTED, + user=user, + ) assert FollowUpGroup.objects.count() == 0 assert FollowUpGroupMembership.objects.count() == 0 @@ -341,7 +352,12 @@ def test_group_create_at_update(self): # >>> Employer membership creation date employer_membership_date = timezone.now() # the employer accepts the last job app - accepted_job_app.accept(user=employer) + # Don't call accept that now creates a group and membership automatically + JobApplicationTransitionLog.objects.create( + job_application=accepted_job_app, + to_state=JobApplicationState.ACCEPTED, + user=employer, + ) with freeze_time("2022-06-01 00:00:08"): # Another iae diag from the employer IAEEligibilityDiagnosisFactory(job_seeker=job_seeker, from_employer=True, author=employer) diff --git a/tests/gps/test_models.py b/tests/gps/test_models.py index 9af01c05f9..1c03627473 100644 --- a/tests/gps/test_models.py +++ b/tests/gps/test_models.py @@ -1,4 +1,6 @@ import pytest +from django.utils import timezone +from freezegun import freeze_time from pytest_django.asserts import assertNumQueries from itou.gps.models import FollowUpGroup, FollowUpGroupMembership @@ -26,37 +28,54 @@ def test_follow_beneficiary(): beneficiary = JobSeekerFactory() prescriber = PrescriberFactory(membership=True) - FollowUpGroup.objects.follow_beneficiary(beneficiary=beneficiary, user=prescriber, is_referent=True) - group = FollowUpGroup.objects.get() - membership = group.memberships.get() - assert membership.is_active is True - assert membership.is_referent is True - assert membership.creator == prescriber - - membership.is_active = False - membership.is_referent = False - membership.save() - - FollowUpGroup.objects.follow_beneficiary(beneficiary=beneficiary, user=prescriber, is_referent=True) - group = FollowUpGroup.objects.get() - membership = group.memberships.get() - assert membership.is_active is True - assert membership.is_referent is True - - membership.is_active = False - membership.save() - - FollowUpGroup.objects.follow_beneficiary(beneficiary=beneficiary, user=prescriber, is_referent=False) - group = FollowUpGroup.objects.get() - membership = group.memberships.get() - assert membership.is_active is True - assert membership.is_referent is False - - other_member = EmployerFactory() - FollowUpGroup.objects.follow_beneficiary(beneficiary=beneficiary, user=other_member, is_referent=True) - assert group.memberships.count() == 2 - other_membership = group.memberships.get(member=other_member) - assert other_membership.is_referent is True # No limit to the number of referent + with freeze_time() as frozen_time: + created_at = timezone.now() + FollowUpGroup.objects.follow_beneficiary(beneficiary, prescriber, is_referent=True) + group = FollowUpGroup.objects.get() + membership = group.memberships.get() + assert membership.is_active is True + assert membership.is_referent is True + assert membership.created_at == created_at + assert membership.last_contact_at == created_at + assert membership.creator == prescriber + + membership.is_active = False + membership.is_referent = False + membership.save() + frozen_time.tick() + updated_at = timezone.now() + + FollowUpGroup.objects.follow_beneficiary(beneficiary, prescriber, is_referent=True) + membership.refresh_from_db() + assert membership.is_active is True + assert membership.is_referent is True + assert membership.created_at == created_at + assert membership.last_contact_at == updated_at + + membership.is_active = False + membership.save() + + FollowUpGroup.objects.follow_beneficiary(beneficiary, prescriber, is_referent=False) + membership.refresh_from_db() + assert membership.is_active is True + assert membership.is_referent is False + + FollowUpGroup.objects.follow_beneficiary(beneficiary, prescriber) + membership.refresh_from_db() + assert membership.is_referent is False # does not change + + membership.is_referent = True + membership.save() + + FollowUpGroup.objects.follow_beneficiary(beneficiary, prescriber) + membership.refresh_from_db() + assert membership.is_referent is True # does not change + + other_member = EmployerFactory() + FollowUpGroup.objects.follow_beneficiary(beneficiary, other_member, is_referent=True) + assert group.memberships.count() == 2 + other_membership = group.memberships.get(member=other_member) + assert other_membership.is_referent is True # No limit to the number of referent @pytest.mark.parametrize( diff --git a/tests/gps/test_views.py b/tests/gps/test_views.py index bc7d0b82aa..6362d270e0 100644 --- a/tests/gps/test_views.py +++ b/tests/gps/test_views.py @@ -86,7 +86,7 @@ def test_my_groups(snapshot, client): member__first_name="John", member__last_name="Doe", ) - FollowUpGroup.objects.follow_beneficiary(beneficiary=group.beneficiary, user=user) + FollowUpGroup.objects.follow_beneficiary(group.beneficiary, user) with assertSnapshotQueries(snapshot): response = client.get(reverse("gps:my_groups")) @@ -101,7 +101,7 @@ def test_my_groups(snapshot, client): # Test `is_referent` display. group = FollowUpGroupFactory(memberships=1, beneficiary__first_name="Janis", beneficiary__last_name="Joplin") - FollowUpGroup.objects.follow_beneficiary(beneficiary=group.beneficiary, user=user, is_referent=True) + FollowUpGroup.objects.follow_beneficiary(group.beneficiary, user, is_referent=True) response = client.get(reverse("gps:my_groups")) assertContains(response, "vous êtes référent") diff --git a/tests/job_applications/tests.py b/tests/job_applications/tests.py index 513d0a4f65..6703262ab7 100644 --- a/tests/job_applications/tests.py +++ b/tests/job_applications/tests.py @@ -17,6 +17,7 @@ from itou.eligibility.enums import AdministrativeCriteriaLevel from itou.eligibility.models import AdministrativeCriteria, EligibilityDiagnosis from itou.employee_record.enums import Status +from itou.gps.models import FollowUpGroup, FollowUpGroupMembership from itou.job_applications.admin_forms import JobApplicationAdminForm from itou.job_applications.enums import ( JobApplicationState, @@ -165,6 +166,21 @@ def test_inverted_vae_contract(self): JobApplicationFactory(to_company__kind=CompanyKind.AI, inverted_vae_contract=True).clean() assert "Un contrat associé à une VAE inversée n'est possible que pour les GEIQ" in str(excinfo.value) + def test_accept_follow_up_group(self): + job_application = JobApplicationFactory( + sent_by_authorized_prescriber_organisation=True, + state=JobApplicationState.PROCESSING, + ) + user = job_application.to_company.members.first() + assert not FollowUpGroup.objects.exists() + + job_application.accept(user=user) + group = FollowUpGroup.objects.get() + assert group.beneficiary == job_application.job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == user + assert membership.creator == user + class TestJobApplicationQueryset: def test_is_active_company_member(self): diff --git a/tests/www/apply/test_submit.py b/tests/www/apply/test_submit.py index d621ff15bf..f7333730fe 100644 --- a/tests/www/apply/test_submit.py +++ b/tests/www/apply/test_submit.py @@ -29,6 +29,7 @@ GEIQAdministrativeCriteria, GEIQEligibilityDiagnosis, ) +from itou.gps.models import FollowUpGroup, FollowUpGroupMembership from itou.job_applications.enums import JobApplicationState, QualificationLevel, QualificationType, SenderKind from itou.job_applications.models import JobApplication from itou.siae_evaluations.models import Sanctions @@ -503,6 +504,9 @@ def test_apply_as_jobseeker(self, client, pdf_file): # + 1 in the page content assertContains(response, reverse("dashboard:edit_user_info"), count=3) + # GPS : a job seeker must not follow himself + assert not FollowUpGroup.objects.exists() + def test_apply_as_job_seeker_temporary_nir(self, client): """ Full path is tested above. See test_apply_as_job_seeker. @@ -1374,6 +1378,14 @@ def test_apply_as_authorized_prescriber(self, client, pdf_file): response = client.get(next_url) assert response.status_code == 200 + # Check GPS group + # ---------------------------------------------------------------------- + group = FollowUpGroup.objects.get() + assert group.beneficiary == new_job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == user + assert membership.creator == user + def test_cannot_create_job_seeker_with_pole_emploi_email(self, client): company = CompanyMembershipFactory().company @@ -1845,6 +1857,14 @@ def test_apply_as_prescriber(self, client, pdf_file): response = client.get(next_url) assert response.status_code == 200 + # Check GPS group + # ---------------------------------------------------------------------- + group = FollowUpGroup.objects.get() + assert group.beneficiary == new_job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == user + assert membership.creator == user + def test_check_info_as_prescriber_for_job_seeker_with_incomplete_info(self, client): company = CompanyFactory(with_membership=True, with_jobs=True, romes=("N1101", "N1105")) user = PrescriberFactory() @@ -2415,6 +2435,14 @@ def _test_apply_as_company(self, client, user, company, dummy_job_seeker, pdf_fi response = client.get(next_url) assert response.status_code == 200 + # Check GPS group + # ---------------------------------------------------------------------- + group = FollowUpGroup.objects.get() + assert group.beneficiary == new_job_seeker + membership = FollowUpGroupMembership.objects.get(follow_up_group=group) + assert membership.member == user + assert membership.creator == user + @pytest.mark.ignore_unknown_variable_template_error("confirmation_needed", "job_seeker") def test_apply_as_employer(self, client, pdf_file): company = CompanyWithMembershipAndJobsFactory(romes=("N1101", "N1105"))