Skip to content

Api reference

components.occupational_health.public.actions

account

move_occupational_health_account_models_from_source_to_target_account

move_occupational_health_account_models_from_source_to_target_account(
    source_account_id, target_account_id
)

Function to move all occupational health models linked to an account from a source account to a target account in case of an account being deleted/merged

Source code in components/occupational_health/public/actions/account.py
def move_occupational_health_account_models_from_source_to_target_account(
    source_account_id: UUID,
    target_account_id: UUID,
) -> None:
    """
    Function to move all occupational health models linked to an account from a source account to a target account
    in case of an account being deleted/merged
    """
    error = None
    messages: list[str] = []
    source_account_id_typed = AccountId(source_account_id)
    target_account_id_typed = AccountId(target_account_id)

    with transaction(propagation=Propagation.REQUIRES_NEW) as session:
        try:
            messages += _move_subscription_model(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_affiliations(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_affiliation_strategy_rules(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_affiliation_movements(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_dashboard_admins(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_mass_affiliation_logs(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_visits(source_account_id_typed, target_account_id_typed)
            messages += _move_workspace_actions(
                source_account_id_typed, target_account_id_typed
            )
            messages += _move_profile_employment_data(
                source_account_id_typed, target_account_id_typed
            )

            # TODO: occupational_health_billed_entity => check logic?
        except Exception as e:
            error = e
            current_logger.exception(
                "Failed to move occupational health models from source account to target account, rollbacking changes",
                source_account_id=source_account_id,
                target_account_id=target_account_id,
            )
            messages.append(
                " ⚠️ Exception occurred while moving occupational health models from source account to target account"
            )
            messages.append(str(e))
            session.rollback()

    if len(messages) > 0:
        _post_slack_notification(source_account_id, target_account_id, messages)

    if error is not None:
        raise error

billing

Public actions for occupational health billing invoice emails.

send_billing_invoices_email

send_billing_invoices_email(account_id)

Send billing invoice emails to all HR contacts of an account.

Source code in components/occupational_health/public/actions/billing.py
def send_billing_invoices_email(account_id: AccountId) -> None:
    """Send billing invoice emails to all HR contacts of an account."""
    from components.occupational_health.internal.business_logic.billing.send_invoice_email import (
        send_billing_invoices_email as send_billing_invoices_email_internal,
    )

    send_billing_invoices_email_internal(account_id=account_id)

billing_entity

Public actions for managing occupational health billed entities.

create_billed_entity

create_billed_entity(
    entity_name,
    siren,
    postal_street,
    postal_code,
    postal_city,
    postal_country_code,
    account_id,
    emails_to_notify,
    siret=None,
    has_installment_plan_per_year=None,
    save=True,
)

Create a new billed entity for occupational health billing.

Parameters:

Name Type Description Default
entity_name str

Legal name of the entity

required
siren str

French company identifier (9 digits)

required
postal_street str

Street address

required
postal_code str

Postal code

required
postal_city str

City name

required
postal_country_code str

ISO 3166-1 alpha-2 country code (e.g. 'FR', 'BE')

required
account_id AccountId

Account ID this entity belongs to

required
emails_to_notify list[str]

List of HR email addresses to notify

required
siret str | None

French establishment identifier (14 digits, optional)

None
has_installment_plan_per_year dict[str, bool] | None

Year-specific boolean for installment plan

None
save bool

Whether to commit the transaction

True

Returns:

Type Description
OccupationalHealthBilledEntity

The created billed entity

Source code in components/occupational_health/public/actions/billing_entity.py
def create_billed_entity(
    entity_name: str,
    siren: str,
    postal_street: str,
    postal_code: str,
    postal_city: str,
    postal_country_code: str,
    account_id: AccountId,
    emails_to_notify: list[str],
    siret: str | None = None,
    has_installment_plan_per_year: dict[str, bool] | None = None,
    save: bool = True,
) -> OccupationalHealthBilledEntity:
    """
    Create a new billed entity for occupational health billing.

    Args:
        entity_name: Legal name of the entity
        siren: French company identifier (9 digits)
        postal_street: Street address
        postal_code: Postal code
        postal_city: City name
        postal_country_code: ISO 3166-1 alpha-2 country code (e.g. 'FR', 'BE')
        account_id: Account ID this entity belongs to
        emails_to_notify: List of HR email addresses to notify
        siret: French establishment identifier (14 digits, optional)
        has_installment_plan_per_year: Year-specific boolean for installment plan
        save: Whether to commit the transaction

    Returns:
        The created billed entity
    """
    return create_billed_entity_internal(
        entity_name=entity_name,
        siren=siren,
        postal_street=postal_street,
        postal_code=postal_code,
        postal_city=postal_city,
        postal_country_code=postal_country_code,
        account_id=account_id,
        emails_to_notify=emails_to_notify,
        siret=siret,
        has_installment_plan_per_year=has_installment_plan_per_year,
        save=save,
    )

update_billed_entity

update_billed_entity(
    billed_entity_id,
    entity_name=NOT_SET,
    siren=NOT_SET,
    siret=NOT_SET,
    postal_street=NOT_SET,
    postal_code=NOT_SET,
    postal_city=NOT_SET,
    postal_country_code=NOT_SET,
    subscription_ref=NOT_SET,
    emails_to_notify=NOT_SET,
    has_installment_plan_per_year=NOT_SET,
    save=True,
)

Update an existing billed entity.

Parameters:

Name Type Description Default
billed_entity_id UUID

ID of the billed entity to update

required
entity_name NotSet[str]

Legal name of the entity

NOT_SET
siren NotSet[str]

French company identifier (9 digits)

NOT_SET
siret NotSet[str | None]

French establishment identifier (14 digits, optional)

NOT_SET
postal_street NotSet[str]

Street address

NOT_SET
postal_code NotSet[str]

Postal code

NOT_SET
postal_city NotSet[str]

City name

NOT_SET
postal_country_code NotSet[str]

ISO 3166-1 alpha-2 country code (e.g. 'FR', 'BE')

NOT_SET
subscription_ref NotSet[UUID]

Reference of the subscription

NOT_SET
emails_to_notify NotSet[list[str]]

List of HR email addresses to notify

NOT_SET
has_installment_plan_per_year NotSet[dict[str, bool] | None]

Year-specific boolean for installment plan

NOT_SET
save bool

Whether to commit the transaction

True

Returns:

Type Description
OccupationalHealthBilledEntity

The updated billed entity

Source code in components/occupational_health/public/actions/billing_entity.py
def update_billed_entity(
    billed_entity_id: UUID,
    entity_name: NotSet[str] = NOT_SET,
    siren: NotSet[str] = NOT_SET,
    siret: NotSet[str | None] = NOT_SET,
    postal_street: NotSet[str] = NOT_SET,
    postal_code: NotSet[str] = NOT_SET,
    postal_city: NotSet[str] = NOT_SET,
    postal_country_code: NotSet[str] = NOT_SET,
    subscription_ref: NotSet[UUID] = NOT_SET,
    emails_to_notify: NotSet[list[str]] = NOT_SET,
    has_installment_plan_per_year: NotSet[dict[str, bool] | None] = NOT_SET,
    save: bool = True,
) -> OccupationalHealthBilledEntity:
    """
    Update an existing billed entity.

    Args:
        billed_entity_id: ID of the billed entity to update
        entity_name: Legal name of the entity
        siren: French company identifier (9 digits)
        siret: French establishment identifier (14 digits, optional)
        postal_street: Street address
        postal_code: Postal code
        postal_city: City name
        postal_country_code: ISO 3166-1 alpha-2 country code (e.g. 'FR', 'BE')
        subscription_ref: Reference of the subscription
        emails_to_notify: List of HR email addresses to notify
        has_installment_plan_per_year: Year-specific boolean for installment plan
        save: Whether to commit the transaction

    Returns:
        The updated billed entity
    """
    return update_billed_entity_internal(
        billed_entity_id=billed_entity_id,
        entity_name=entity_name,
        siren=siren,
        siret=siret,
        postal_street=postal_street,
        postal_code=postal_code,
        postal_city=postal_city,
        postal_country_code=postal_country_code,
        subscription_ref=subscription_ref,
        emails_to_notify=emails_to_notify,
        has_installment_plan_per_year=has_installment_plan_per_year,
        save=save,
    )

cancellation_email

on_cancellation_email_sent

on_cancellation_email_sent(visit_id, visit_model)

Success callback: stamp post_visit_email_sent_at for idempotency.

Source code in components/occupational_health/public/actions/cancellation_email.py
def on_cancellation_email_sent(visit_id: UUID, visit_model: str) -> None:
    """Success callback: stamp `post_visit_email_sent_at` for idempotency."""
    try:
        visit, _ = get_visit_and_sheet_type_from_visit_model(visit_id, visit_model)
    except BaseErrorCode:
        current_logger.error(
            "[Occupational Health] Could not find visit with id after sending cancellation email",
            visit_id=visit_id,
            visit_model=visit_model,
        )
        return

    visit.post_visit_email_sent_at = datetime.now(UTC)
    current_session.commit()

doctolib_db_matching

Public facade for applying a reviewed Doctolib reconciliation plan (v3).

DoctolibReconciliationApplier

DoctolibReconciliationApplier()

Applies a reviewed DoctolibDbMatchingPlan to the gsheet + DB.

Source code in components/occupational_health/internal/business_logic/doctolib_matching/applier.py
def __init__(self) -> None:
    from shared.services.google.spreadsheets import SpreadsheetsService

    self._gsheet = OccupationalHealthGSheetService(
        SpreadsheetsService.get(
            credentials_config_key="GOOGLE_GSPREAD_SERVICE_ACCOUNT_SECRET_NAME"
        ),
        SheetType.PREDICTABLE,
    )
apply_booked
apply_booked(items)

Fill a pending predictable invite in the gsheet from the CSV booking.

Source code in components/occupational_health/internal/business_logic/doctolib_matching/applier.py
def apply_booked(self, items: list[BookedVisit]) -> ApplyResultSummary:
    """Fill a pending predictable invite in the gsheet from the CSV booking."""
    gsheet_enabled = self._is_gsheet_write_enabled()
    return self._run(
        items, lambda item: self._apply_one_booked(item, gsheet_enabled)
    )
apply_cancellations
apply_cancellations(items)

Cancel a visit (predictable -> gsheet status, on-demand -> DB status).

Source code in components/occupational_health/internal/business_logic/doctolib_matching/applier.py
def apply_cancellations(self, items: list[CancelledVisit]) -> ApplyResultSummary:
    """Cancel a visit (predictable -> gsheet status, on-demand -> DB status)."""
    gsheet_enabled = self._is_gsheet_write_enabled()
    return self._run(
        items, lambda item: self._apply_one_cancellation(item, gsheet_enabled)
    )
apply_profile_id_sets
apply_profile_id_sets(items)

Persist the learned doctolib_patient_id on the OH profile (DB).

Source code in components/occupational_health/internal/business_logic/doctolib_matching/applier.py
def apply_profile_id_sets(
    self, items: list[ProfileDoctolibIdSet]
) -> ApplyResultSummary:
    """Persist the learned ``doctolib_patient_id`` on the OH profile (DB)."""
    return self._run(items, self._apply_one_profile_id_set)
apply_reschedulings
apply_reschedulings(items)

Update a matched visit's date/hour/HP (predictable -> gsheet, on-demand -> DB).

Source code in components/occupational_health/internal/business_logic/doctolib_matching/applier.py
def apply_reschedulings(self, items: list[RescheduledVisit]) -> ApplyResultSummary:
    """Update a matched visit's date/hour/HP (predictable -> gsheet, on-demand -> DB)."""
    gsheet_enabled = self._is_gsheet_write_enabled()
    return self._run(
        items, lambda item: self._apply_one_rescheduling(item, gsheet_enabled)
    )
apply_visit_id_sets
apply_visit_id_sets(items)

Persist the learned doctolib_visit_id on the matched visit.

Source code in components/occupational_health/internal/business_logic/doctolib_matching/applier.py
def apply_visit_id_sets(
    self, items: list[VisitDoctolibIdSet]
) -> ApplyResultSummary:
    """Persist the learned ``doctolib_visit_id`` on the matched visit."""
    gsheet_enabled = self._is_gsheet_write_enabled()
    return self._run(
        items, lambda item: self._apply_one_visit_id_set(item, gsheet_enabled)
    )

employee_export

AffiliationStatus

Bases: Enum

Affiliation status of a member.

AFFILIATED class-attribute instance-attribute
AFFILIATED = 'Affilié'
TERMINATED class-attribute instance-attribute
TERMINATED = 'Résilié'
UPCOMING class-attribute instance-attribute
UPCOMING = 'À venir'

HEADERS module-attribute

HEADERS = [
    "Prénom",
    "Nom",
    "SIREN/SIRET",
    "Entité",
    "Matricule",
    "Email d'invitation",
    "Numéro de sécurité sociale",
    "NTT",
    "Catégorie de risque",
    "Date de dernière visite périodique",
    "Date de prochaine visite périodique",
    "Date de début de contrat",
    "Statut de visite",
    "Statut d'affiliation",
    "Observation",
]

VisitStatus

Bases: Enum

Visit status of a member.

LATE class-attribute instance-attribute
LATE = 'En retard'
ON_TIME class-attribute instance-attribute
ON_TIME = 'À jour'
UPCOMING class-attribute instance-attribute
UPCOMING = 'Planifiée'

generate_employees_export

generate_employees_export(account_id, file_format)

Generate an export of occupational health employees for the given account.

Source code in components/occupational_health/public/actions/employee_export.py
def generate_employees_export(
    account_id: uuid.UUID,
    file_format: ExportExtension,
) -> GeneratedExportData:
    """Generate an export of occupational health employees for the given account."""
    if file_format not in _SERIALIZER_BY_FORMAT:
        raise ValueError(f"Unsupported file format: {file_format}")

    members: list[AffiliatedMemberForAdminDashboard] = (
        get_current_or_upcoming_affiliated_members_for_admin_dashboard(
            AccountId(account_id),
            with_ssn_and_ntt=True,
            with_siren_or_siret=True,
        )
    )
    members.sort(key=lambda m: (m.last_name.lower(), m.first_name.lower()))
    items_count = len(members)

    return GeneratedExportData(
        file_content=_SERIALIZER_BY_FORMAT[file_format](_generate_rows(members)),
        mimetype=_MIMETYPE_BY_FORMAT[file_format],
        items_count=items_count,
    )

ins

InsiServiceErrorDetails dataclass

InsiServiceErrorDetails(
    soap_reason=None,
    soap_subcode=None,
    detail_code=None,
    detail_error=None,
)

Upstream TLSi error fields exposed to the caller (operators / HPs).

Pulled from the icanopee 409 JSON payload — see s_apiErrorTlsiError* in the DMPConnect ES REST response.

detail_code class-attribute instance-attribute
detail_code = None

e.g. insi_102 — TLSi detail code.

detail_error class-attribute instance-attribute
detail_error = None

e.g. L'appel au service de recherche ... renvoie une erreur technique.

soap_reason class-attribute instance-attribute
soap_reason = None

Human-readable reason from the TLSi SOAP fault (long, French).

soap_subcode class-attribute instance-attribute
soap_subcode = None

e.g. siram_40 — TLSi SOAP subcode.

QualifyInsIdentityErrorCode

Bases: Enum

Why a qualify-via-INSi call did not move the identity to QUALIFIED.

IDENTITY_INCOMPLETE class-attribute instance-attribute
IDENTITY_INCOMPLETE = 'identity_incomplete'
INSI_SERVICE_ERROR class-attribute instance-attribute
INSI_SERVICE_ERROR = 'insi_service_error'
MULTIPLE_IDENTITIES_FOUND class-attribute instance-attribute
MULTIPLE_IDENTITIES_FOUND = 'multiple_identities_found'
NO_IDENTITY_FOUND class-attribute instance-attribute
NO_IDENTITY_FOUND = 'no_identity_found'

QualifyInsIdentityResult dataclass

QualifyInsIdentityResult(
    success,
    error_code=None,
    insi_service_error_details=None,
)

Outcome of a qualify-via-INSi call. error_code is None on success.

error_code class-attribute instance-attribute
error_code = None
insi_service_error_details class-attribute instance-attribute
insi_service_error_details = None

Populated only when error_code == INSI_SERVICE_ERROR.

success instance-attribute
success

create_or_update_ins_identity

create_or_update_ins_identity(
    profile_id,
    *,
    birth_last_name,
    birth_first_name,
    birth_date,
    sex,
    birth_place_insee_code=None,
    given_first_name=None,
    given_last_name=None,
    matricule_value=None,
    matricule_key=None,
    commit=True
)

Create or update INS identity for a profile. Creates if none exists, updates otherwise.

Source code in components/occupational_health/internal/business_logic/actions/ins/actions.py
def create_or_update_ins_identity(
    profile_id: ProfileId,
    *,
    birth_last_name: str,
    birth_first_name: str,
    birth_date: date,
    sex: Sex,
    birth_place_insee_code: str | None = None,
    given_first_name: str | None = None,
    given_last_name: str | None = None,
    matricule_value: str | None = None,
    matricule_key: str | None = None,
    commit: bool = True,
) -> None:
    """Create or update INS identity for a profile. Creates if none exists, updates otherwise."""
    existing = current_session.execute(
        select(OccupationalHealthInsIdentity).where(
            OccupationalHealthInsIdentity.profile_id == profile_id
        )
    ).scalar_one_or_none()

    if existing:
        update_ins_identity(
            profile_id,
            birth_last_name=birth_last_name,
            birth_first_name=birth_first_name,
            birth_date=birth_date,
            sex=sex,
            birth_place_insee_code=birth_place_insee_code,
            given_first_name=given_first_name,
            given_last_name=given_last_name,
            matricule_value=matricule_value,
            matricule_key=matricule_key,
            commit=commit,
        )
    else:
        create_ins_identity(
            profile_id,
            birth_last_name=birth_last_name,
            birth_first_name=birth_first_name,
            birth_date=birth_date,
            sex=sex,
            birth_place_insee_code=birth_place_insee_code,
            given_first_name=given_first_name,
            given_last_name=given_last_name,
            matricule_value=matricule_value,
            matricule_key=matricule_key,
            commit=commit,
        )

qualify_ins_identity_via_insi

qualify_ins_identity_via_insi(
    profile_id, *, actor_user_id, commit=True
)

Call INSi and upgrade the identity status on a unique-match success.

Status transition on success: PROVISIONAL → RETRIEVED, VALIDATED → QUALIFIED. Already-RETRIEVED/QUALIFIED identities keep their status.

On any non-success path the identity is left untouched and the failure is persisted via :class:PrevenirInsiCallTrace in a separate transaction so we keep an audit trail even when the outer one rolls back.

Source code in components/occupational_health/internal/business_logic/actions/ins/qualify.py
def qualify_ins_identity_via_insi(
    profile_id: ProfileId,
    *,
    actor_user_id: str,
    commit: bool = True,
) -> QualifyInsIdentityResult:
    """Call INSi and upgrade the identity status on a unique-match success.

    Status transition on success: PROVISIONAL → RETRIEVED, VALIDATED → QUALIFIED.
    Already-RETRIEVED/QUALIFIED identities keep their status.

    On any non-success path the identity is left untouched and the failure is
    persisted via :class:`PrevenirInsiCallTrace` in a separate transaction so
    we keep an audit trail even when the outer one rolls back.
    """
    identity = current_session.execute(
        select(OccupationalHealthInsIdentity).where(
            OccupationalHealthInsIdentity.profile_id == profile_id
        )
    ).scalar_one_or_none()

    if not identity:
        raise ValueError(
            f"No INS identity found for occupational health profile {profile_id}"
        )

    input_payload = {
        "birth_name": identity.birth_last_name,
        "given_names": identity.birth_first_names,
        "sex": identity.sex.value if identity.sex else None,
        "birth_date": identity.birth_date.isoformat() if identity.birth_date else None,
        "birth_place_insee_code": identity.birth_place_insee_code,
    }

    if not _has_required_traits_for_insi_call(identity):
        # No INSi call happens here, so no call-trace row to write — just log.
        current_logger.info(
            "Skipping INSi qualify: identity is missing one of the required traits",
            profile_id=str(profile_id),
            identity_id=str(identity.id),
        )
        return QualifyInsIdentityResult(
            success=False,
            error_code=QualifyInsIdentityErrorCode.IDENTITY_INCOMPLETE,
        )

    from components.occupational_health.internal.business_logic.identity.prevenir_insi_client import (
        get_prevenir_insi_client,
    )

    # `_has_required_traits_for_insi_call` above guarantees these are set;
    # `mandatory()` makes that explicit to the type checker.
    member_information = MemberInformation(
        birth_date=mandatory(identity.birth_date),
        birth_place=identity.birth_place_insee_code,
        birth_last_name=mandatory(identity.birth_last_name),
        first_names=mandatory(identity.birth_first_names),
        sex=mandatory(identity.sex).to_insi_sex(),
    )

    try:
        result = get_ins_from_member_information(
            member_information=member_information,
            insi_client=get_prevenir_insi_client(),
        )
    except (DmpConnectESRestNetworkError, INSiServiceError) as exc:
        current_logger.warning(
            "INSi service call failed",
            profile_id=str(profile_id),
            error=str(exc),
        )
        _write_prevenir_insi_call_trace(
            related_ins_identity_id=identity.id,
            actor_app_name=get_current_app_name(),
            actor_app_user_id=actor_user_id,
            call_type=InsiCallType.GET_INS_FROM_MEMBER_INFORMATION,
            call_timestamp=datetime.now(UTC),
            input_payload=input_payload,
            response_payload=None,
            error=traceback.format_exc(),
        )
        return QualifyInsIdentityResult(
            success=False,
            error_code=QualifyInsIdentityErrorCode.INSI_SERVICE_ERROR,
            insi_service_error_details=_extract_insi_service_error_details(exc),
        )

    response_payload = result.to_dict()

    if result.i_insIdentityResult == IdentityResultString.MULTIPLE_IDENTITIES_FOUND:
        _write_prevenir_insi_call_trace(
            related_ins_identity_id=identity.id,
            actor_app_name=get_current_app_name(),
            actor_app_user_id=actor_user_id,
            call_type=InsiCallType.GET_INS_FROM_MEMBER_INFORMATION,
            call_timestamp=datetime.now(UTC),
            input_payload=input_payload,
            response_payload=response_payload,
            error="multiple_identities_found",
        )
        return QualifyInsIdentityResult(
            success=False,
            error_code=QualifyInsIdentityErrorCode.MULTIPLE_IDENTITIES_FOUND,
        )

    if (
        result.s_status != ResponseStatus.OK
        or result.i_insIdentityResult == IdentityResultString.NO_IDENTITY_FOUND
        or result.Identity is None
    ):
        _write_prevenir_insi_call_trace(
            related_ins_identity_id=identity.id,
            actor_app_name=get_current_app_name(),
            actor_app_user_id=actor_user_id,
            call_type=InsiCallType.GET_INS_FROM_MEMBER_INFORMATION,
            call_timestamp=datetime.now(UTC),
            input_payload=input_payload,
            response_payload=response_payload,
            error="no_identity_found",
        )
        return QualifyInsIdentityResult(
            success=False,
            error_code=QualifyInsIdentityErrorCode.NO_IDENTITY_FOUND,
        )

    ins = result.Identity.Ins
    identity.matricule_value = ins.s_value
    identity.matricule_key = ins.s_key
    identity.matricule_oid = InsiMatriculeOid(ins.s_oid)
    # PROVISIONAL → RETRIEVED (INS found, no ID check yet); VALIDATED → QUALIFIED
    # (ID check already done, now INS found too). The DB constraint
    # `validation_method_required_if_validated_or_qualified` forbids QUALIFIED
    # without a validation_method, so we can't unconditionally jump there.
    if identity.status == IdentityStatus.PROVISIONAL:
        identity.status = IdentityStatus.RETRIEVED
    elif identity.status == IdentityStatus.VALIDATED:
        identity.status = IdentityStatus.QUALIFIED

    _write_prevenir_insi_call_trace(
        related_ins_identity_id=identity.id,
        actor_app_name=get_current_app_name(),
        actor_app_user_id=actor_user_id,
        call_type=InsiCallType.GET_INS_FROM_MEMBER_INFORMATION,
        call_timestamp=datetime.now(UTC),
        input_payload=input_payload,
        response_payload=response_payload,
        error=None,
    )

    if commit:
        current_session.commit()
    else:
        current_session.flush()

    return QualifyInsIdentityResult(success=True)

update_ins_administrative_profile

update_ins_administrative_profile(
    profile_id,
    *,
    birth_last_name=None,
    birth_first_name=None,
    birth_date=None,
    sex=None,
    birth_place_insee_code=None,
    given_first_name=None,
    given_last_name=None,
    matricule_value=None,
    matricule_key=None,
    health_statuses=NOT_SET,
    risk_category=NOT_SET,
    notes=NOT_SET,
    medical_record_sharing_consent=NOT_SET,
    medical_record_access_consent=NOT_SET,
    health_data_sharing_consent=NOT_SET,
    video_consultation_consent=NOT_SET,
    orient_prevention_of_work_consent=NOT_SET,
    transfer_dmst_to_spsti_consent=NOT_SET,
    personal_email=NOT_SET,
    phone_number=NOT_SET,
    commit=True
)

Orchestrate all INS administrative profile saves in a single transaction.

Source code in components/occupational_health/internal/business_logic/actions/ins/actions.py
def update_ins_administrative_profile(
    profile_id: ProfileId,
    *,
    # INS identity (optional — skipped when no identity fields provided)
    birth_last_name: str | None = None,
    birth_first_name: str | None = None,
    birth_date: date | None = None,
    sex: Sex | None = None,
    birth_place_insee_code: str | None = None,
    given_first_name: str | None = None,
    given_last_name: str | None = None,
    matricule_value: str | None = None,
    matricule_key: str | None = None,
    # Monitoring
    health_statuses: NotSet[list[HealthStatus]] = NOT_SET,
    risk_category: NotSet[RiskCategory] = NOT_SET,
    notes: NotSet[str | None] = NOT_SET,
    # Consents
    medical_record_sharing_consent: NotSet[bool | None] = NOT_SET,
    medical_record_access_consent: NotSet[bool | None] = NOT_SET,
    health_data_sharing_consent: NotSet[bool | None] = NOT_SET,
    video_consultation_consent: NotSet[bool | None] = NOT_SET,
    orient_prevention_of_work_consent: NotSet[bool | None] = NOT_SET,
    transfer_dmst_to_spsti_consent: NotSet[bool | None] = NOT_SET,
    # Contact
    personal_email: NotSet[str | None] = NOT_SET,
    phone_number: NotSet[str | None] = NOT_SET,
    commit: bool = True,
) -> None:
    """Orchestrate all INS administrative profile saves in a single transaction."""
    from components.occupational_health.internal.business_logic.actions.members.update_administrative_profile import (
        update_administrative_profile,
    )
    from components.occupational_health.internal.business_logic.actions.occupational_health_profile import (
        update_personal_email,
        update_phone_number,
    )

    # The four fields required to call the INSi service
    has_mandatory_identity_fields = all(
        [
            birth_last_name,
            birth_first_name,
            birth_date,
            sex,
        ]
    )

    if has_mandatory_identity_fields:
        create_or_update_ins_identity(
            profile_id,
            birth_last_name=mandatory(birth_last_name),
            birth_first_name=mandatory(birth_first_name),
            birth_date=mandatory(birth_date),
            sex=mandatory(sex),
            birth_place_insee_code=birth_place_insee_code,
            given_first_name=given_first_name,
            given_last_name=given_last_name,
            matricule_value=matricule_value,
            matricule_key=matricule_key,
            commit=False,
        )
    else:
        current_logger.warning(
            "Skipping INS identity create/update: missing mandatory fields for INSi call",
            profile_id=str(profile_id),
        )

    update_administrative_profile(
        profile_id,
        health_statuses=health_statuses,
        risk_category=risk_category,
        notes=notes,
        medical_record_sharing_consent=medical_record_sharing_consent,
        medical_record_access_consent=medical_record_access_consent,
        health_data_sharing_consent=health_data_sharing_consent,
        video_consultation_consent=video_consultation_consent,
        orient_prevention_of_work_consent=orient_prevention_of_work_consent,
        transfer_dmst_to_spsti_consent=transfer_dmst_to_spsti_consent,
        commit=False,
    )

    if is_set(personal_email):
        update_personal_email(profile_id, personal_email, commit=False)
    if is_set(phone_number):
        update_phone_number(profile_id, phone_number, commit=False)

    if commit:
        current_session.commit()
    else:
        current_session.flush()

validate_ins_identity

validate_ins_identity(
    profile_id, *, validation_method_id=None, commit=True
)

Upgrade INS identity status after ID check and store the validation method.

Source code in components/occupational_health/internal/business_logic/actions/ins/actions.py
def validate_ins_identity(
    profile_id: ProfileId,
    *,
    validation_method_id: UUID | None = None,
    commit: bool = True,
) -> None:
    """Upgrade INS identity status after ID check and store the validation method."""
    identity = current_session.execute(
        select(OccupationalHealthInsIdentity).where(
            OccupationalHealthInsIdentity.profile_id == profile_id
        )
    ).scalar_one_or_none()

    if not identity:
        raise ValueError(
            f"No INS identity found for occupational health profile {profile_id}"
        )

    if validation_method_id is None:
        raise ValueError(
            "Validation method must be provided to validate an INS identity."
        )

    # Upgrades status from PROVISIONAL → VALIDATED (transition logic shared with clinic)
    identity.status = identity.status.upgraded_status_after_successful_id_check
    identity.validation_method_id = validation_method_id

    if commit:
        current_session.commit()
    else:
        current_session.flush()

members

update_dmst

update_dmst(
    occupational_health_profile_id, dmst, commit=True
)

Updates the DMST data for a given occupational health profile. We also create the medical secrecy worker record ID, which will be created by the caller on the next request using this ID

See update_curriculum_laboris_job: also creates or updates jobs for the profile

Source code in components/occupational_health/internal/business_logic/actions/members/update_dmst.py
def update_dmst(
    occupational_health_profile_id: UUID,
    dmst: Dmst,
    commit: bool = True,
) -> None:
    """
    Updates the DMST data for a given occupational health profile. We also create the
    medical secrecy worker record ID, which will be created by the caller on the next
    request using this ID

    See update_curriculum_laboris_job: also creates or updates jobs for the profile
    """

    occupational_health_profile = current_session.get_one(
        OccupationalHealthProfile, occupational_health_profile_id
    )

    if not dmst.medical_secrecy_worker_record_id:
        # Note: we're pre-creating the ID, the caller needs to create the medical secrecy data
        # Dirty hack: we modify the dmst param so that the caller can get the new ID
        occupational_health_profile.medical_secrecy_worker_record_id = uuid.uuid4()
        dmst.medical_secrecy_worker_record_id = (
            occupational_health_profile.medical_secrecy_worker_record_id
        )

    existing_job_ids = {
        job.id
        for job in OccupationalHealthJobBroker.get_existing_jobs(
            occupational_health_profile_id,
        ).all()
    }
    updated_job_ids = {
        job.id for job in dmst.curriculum_laboris.jobs if job.id or set()
    }
    deleted_ids = set(existing_job_ids) - set(updated_job_ids)

    for deleted_id in deleted_ids:
        delete_curriculum_laboris_job(
            occupational_health_profile_id=occupational_health_profile.id,
            job_id=deleted_id,
            commit=False,
        )

    if dmst.curriculum_laboris.jobs:
        for job in dmst.curriculum_laboris.jobs:
            update_curriculum_laboris_job(
                occupational_health_profile_id=occupational_health_profile.id,
                job=job,
                commit=False,
            )

    if dmst.administrative_profile:
        update_administrative_profile(
            occupational_health_profile_id=occupational_health_profile.id,  # pyrefly: ignore [bad-argument-type]
            health_statuses=dmst.administrative_profile.health_statuses,
            risk_category=dmst.administrative_profile.risk_category,
            notes=dmst.administrative_profile.notes,
            medical_record_sharing_consent=dmst.administrative_profile.medical_record_sharing_consent,
            medical_record_access_consent=dmst.administrative_profile.medical_record_access_consent,
            health_data_sharing_consent=dmst.administrative_profile.health_data_sharing_consent,
            video_consultation_consent=dmst.administrative_profile.video_consultation_consent,
            orient_prevention_of_work_consent=dmst.administrative_profile.orient_prevention_of_work_consent,
            transfer_dmst_to_spsti_consent=dmst.administrative_profile.transfer_dmst_to_spsti_consent,
            commit=False,
        )

        update_personal_email(
            profile_id=ProfileId(occupational_health_profile.id),
            personal_email=dmst.administrative_profile.personal_email,
            commit=False,
        )

        update_phone_number(
            profile_id=ProfileId(occupational_health_profile.id),
            phone_number=dmst.administrative_profile.phone_number,
            commit=False,
        )

    if commit:
        current_session.commit()
    else:
        current_session.flush()

no_show_email

on_no_show_email_sent

on_no_show_email_sent(visit_id, visit_model)

Reuses post_visit_email_sent_at — the column the prior post-visit emails relied on for idempotency; no dedicated no_show_email_sent_at.

Source code in components/occupational_health/public/actions/no_show_email.py
def on_no_show_email_sent(visit_id: UUID, visit_model: str) -> None:
    """Reuses `post_visit_email_sent_at` — the column the prior post-visit
    emails relied on for idempotency; no dedicated `no_show_email_sent_at`.
    """
    try:
        visit, _ = get_visit_and_sheet_type_from_visit_model(visit_id, visit_model)
    except BaseErrorCode:
        current_logger.error(
            "[Occupational Health] Could not find visit with id after sending no-show email",
            visit_id=visit_id,
            visit_model=visit_model,
        )
        return

    visit.post_visit_email_sent_at = datetime.now(UTC)
    current_session.commit()

on_demand_visit

Public facade for on-demand visit actions.

CreateVisitResult dataclass

CreateVisitResult(
    state, state_reason=None, details=None, warning=None
)

Outcome of a Marmot create-visit call, decoupled from internal enums.

details class-attribute instance-attribute
details = None
state instance-attribute
state
state_reason class-attribute instance-attribute
state_reason = None
warning class-attribute instance-attribute
warning = None

CreateVisitState module-attribute

CreateVisitState = Literal['done', 'to_review', 'failed']

CreateVisitWarning dataclass

CreateVisitWarning(
    type,
    message,
    existing_visit_id=None,
    existing_visit_admin_url=None,
)

A non-blocking warning surfaced to the FE on visit creation.

existing_visit_admin_url class-attribute instance-attribute
existing_visit_admin_url = None
existing_visit_id class-attribute instance-attribute
existing_visit_id = None
message instance-attribute
message
type instance-attribute
type

OnDemandVisitInput dataclass

OnDemandVisitInput(
    first_name=None,
    last_name=None,
    email=None,
    account_name=None,
    phone_number=None,
    account_id=None,
    user_id=None,
    date_planned=None,
    hour_booked=None,
    hour_end=None,
    hp_owner_id=None,
    date_fixed=None,
    visit_setup=None,
    send_booking_email=None,
    refer_to_doctor=None,
    visit_format=None,
    visit_type=None,
    if_return_visit_specify_motive=None,
    if_on_demand_visit_specify_motive=None,
    if_pre_return_visit_specify_request_initiator=None,
    work_stoppage_start_date=None,
    work_stoppage_end_date=None,
    return_to_work_date=None,
    utm_source=None,
    utm_content=None,
    is_hr_informed=None,
    hr_informed=None,
    is_employee_submitting_the_request=None,
    person_submitting_request=None,
    additional_comments=None,
    submitted_at=None,
    ops_comment=None,
    occupational_health_profile_id=None,
)

Input data for creating an on-demand visit request.

account_id class-attribute instance-attribute
account_id = None
account_name class-attribute instance-attribute
account_name = None
additional_comments class-attribute instance-attribute
additional_comments = None
date_fixed class-attribute instance-attribute
date_fixed = None
date_planned class-attribute instance-attribute
date_planned = None
email class-attribute instance-attribute
email = None
first_name class-attribute instance-attribute
first_name = None
from_dict classmethod
from_dict(payload)

Build from a JSON payload, converting ISO date / UUID / enum strings.

Source code in components/occupational_health/internal/business_logic/actions/on_demand_visit.py
@classmethod
def from_dict(cls, payload: dict[str, Any]) -> "OnDemandVisitInput":
    """Build from a JSON payload, converting ISO date / UUID / enum strings."""
    date_fields = {
        "work_stoppage_start_date",
        "work_stoppage_end_date",
        "return_to_work_date",
        "date_planned",
    }
    uuid_fields = {"account_id", "hp_owner_id"}
    parsed: dict[str, Any] = {}
    for key, value in payload.items():
        if key in date_fields and isinstance(value, str) and value:
            parsed[key] = date.fromisoformat(value)
        elif key in uuid_fields and isinstance(value, str) and value:
            parsed[key] = UUID(value)
        elif key == "visit_setup" and isinstance(value, str) and value:
            parsed[key] = VisitSetup(value)
        else:
            parsed[key] = value
    return cls(**parsed)
hour_booked class-attribute instance-attribute
hour_booked = None
hour_end class-attribute instance-attribute
hour_end = None
hp_owner_id class-attribute instance-attribute
hp_owner_id = None
hr_informed class-attribute instance-attribute
hr_informed = None
if_on_demand_visit_specify_motive class-attribute instance-attribute
if_on_demand_visit_specify_motive = None
if_pre_return_visit_specify_request_initiator class-attribute instance-attribute
if_pre_return_visit_specify_request_initiator = None
if_return_visit_specify_motive class-attribute instance-attribute
if_return_visit_specify_motive = None
is_employee_submitting_the_request class-attribute instance-attribute
is_employee_submitting_the_request = None
is_hr_informed class-attribute instance-attribute
is_hr_informed = None
last_name class-attribute instance-attribute
last_name = None
occupational_health_profile_id class-attribute instance-attribute
occupational_health_profile_id = None
ops_comment class-attribute instance-attribute
ops_comment = None
person_submitting_request class-attribute instance-attribute
person_submitting_request = None
phone_number class-attribute instance-attribute
phone_number = None
refer_to_doctor class-attribute instance-attribute
refer_to_doctor = None
return_to_work_date class-attribute instance-attribute
return_to_work_date = None
send_booking_email class-attribute instance-attribute
send_booking_email = None
submitted_at class-attribute instance-attribute
submitted_at = None
user_id class-attribute instance-attribute
user_id = None
utm_content class-attribute instance-attribute
utm_content = None
utm_source class-attribute instance-attribute
utm_source = None
visit_format class-attribute instance-attribute
visit_format = None
visit_setup class-attribute instance-attribute
visit_setup = None
visit_type class-attribute instance-attribute
visit_type = None
work_stoppage_end_date class-attribute instance-attribute
work_stoppage_end_date = None
work_stoppage_start_date class-attribute instance-attribute
work_stoppage_start_date = None

create_on_demand_visit

create_on_demand_visit(visit_input)

Create an OccupationalHealthVisitRequest and persist it.

Parameters:

Name Type Description Default
visit_input OnDemandVisitInput

The visit request data from the automation/typeform.

required

Returns:

Type Description
OccupationalHealthVisitRequest

The created OccupationalHealthVisitRequest (state=TODO).

Source code in components/occupational_health/internal/business_logic/actions/on_demand_visit.py
def create_on_demand_visit(
    visit_input: OnDemandVisitInput,
) -> OccupationalHealthVisitRequest:
    """Create an OccupationalHealthVisitRequest and persist it.

    Args:
        visit_input: The visit request data from the automation/typeform.

    Returns:
        The created OccupationalHealthVisitRequest (state=TODO).
    """
    from dataclasses import asdict

    from components.occupational_health.internal.enums.visit_request_type import (
        VisitRequestType,
    )
    from components.occupational_health.internal.enums.visit_request_visit_type import (
        VisitRequestVisitType,
    )

    visit_request = OccupationalHealthVisitRequest(
        payload=asdict(visit_input),
        request_type=VisitRequestType.CREATION,
        visit_type=VisitRequestVisitType.ON_DEMAND,
    )
    current_session.add(visit_request)
    current_session.commit()
    return visit_request

create_on_demand_visit_from_marmot

create_on_demand_visit_from_marmot(args, actor_id)

Create an on-demand visit from the Marmot v2 admin tool.

Resolves identity fields (first/last name, email, phone) from the user profile so the gsheet row stays in sync with the typeform-fed flow while downstream automations still read from the gsheet. Also resolves the occupational_health_profile_id from the user_id so it lands on both the Turing visit and the gsheet row.

Slot duplication is detected but non-blocking since the gsheet is not synced in real time so DB-level checks produce false positives. We log a warning and let ops resolve in the gsheet.

Source code in components/occupational_health/public/actions/on_demand_visit.py
def create_on_demand_visit_from_marmot(
    args: dict[str, Any],
    actor_id: Optional[int],
) -> CreateVisitResult:
    """Create an on-demand visit from the Marmot v2 admin tool.

    Resolves identity fields (first/last name, email, phone) from the user
    profile so the gsheet row stays in sync with the typeform-fed flow while
    downstream automations still read from the gsheet. Also resolves the
    occupational_health_profile_id from the user_id so it lands on both the
    Turing visit and the gsheet row.

    Slot duplication is detected but non-blocking since the gsheet is not synced in real time so DB-level checks produce
    false positives. We log a warning and let ops resolve in the gsheet.
    """
    user_id = args["user_id"]
    account_id = AccountId(args["account_id"])

    visit_input = OnDemandVisitInput.from_dict(
        {
            **args,
            "account_name": get_app_dependency().get_account_name(
                account_id=account_id
            ),
        }
    )

    warning: Optional[CreateVisitWarning] = None
    existing_visit = find_visit_booked_for_slot(
        hp_owner_id=visit_input.hp_owner_id,
        date_planned=visit_input.date_planned,
        hour_booked=visit_input.hour_booked,
    )
    if existing_visit is not None:
        warning = CreateVisitWarning(
            type="slot_already_booked",
            message=(
                "A visit is already planned at this date and hour for the HP. "
                "Please check at the gsheet to see if the data is up-to-date (because data are not imported in real time)."
            ),
            existing_visit_id=str(existing_visit.visit_id),
            existing_visit_admin_url=_build_visit_admin_url(
                visit_id=str(existing_visit.visit_id),
                visit_kind=existing_visit.kind,
            ),
        )

    visit_request = create_on_demand_visit(visit_input)
    visit_request = process_on_demand_visit_request(
        visit_request,
        user_id=user_id,
        account_id=account_id,
        actor_id=actor_id,
    )

    state_reason = (
        str(visit_request.state_reason) if visit_request.state_reason else None
    )
    if visit_request.state == VisitRequestState.DONE:
        return CreateVisitResult(state="done", warning=warning)
    if visit_request.state == VisitRequestState.TO_REVIEW:
        return CreateVisitResult(
            state="to_review",
            state_reason=state_reason,
            details=visit_request.state_reason_details,
            warning=warning,
        )
    return CreateVisitResult(state="failed", state_reason=state_reason, warning=warning)

process_on_demand_visit_request

process_on_demand_visit_request(
    visit_request,
    user_id=None,
    account_id=None,
    actor_id=None,
)

Process an on-demand visit request: match, enrich, create the Turing visit.

Idempotent: skips if the request is already in DONE state.

When user_id is provided (manual review), skip the matching step. Otherwise, auto-match via email/name + account affiliation.

  • Unique match + complete enrichment -> Turing visit -> DONE
  • No match or incomplete enrichment -> TO_REVIEW
  • Error -> FAILED

Returns the visit_request so callers can route on its final state.

Source code in components/occupational_health/internal/business_logic/actions/on_demand_visit.py
def process_on_demand_visit_request(
    visit_request: OccupationalHealthVisitRequest,
    user_id: Optional[str] = None,
    account_id: Optional[UUID] = None,
    actor_id: Optional[int] = None,
) -> OccupationalHealthVisitRequest:
    """Process an on-demand visit request: match, enrich, create the Turing visit.

    Idempotent: skips if the request is already in DONE state.

    When user_id is provided (manual review), skip the matching step.
    Otherwise, auto-match via email/name + account affiliation.

    - Unique match + complete enrichment -> Turing visit -> DONE
    - No match or incomplete enrichment -> TO_REVIEW
    - Error -> FAILED

    Returns the visit_request so callers can route on its final state.
    """
    from components.occupational_health.internal.business_logic.queries.visits.matching.user_enrichment import (
        get_user_enrichment_data,
    )
    from components.occupational_health.internal.business_logic.queries.visits.visits import (
        find_visit_booked_for_slot,
    )
    from components.occupational_health.internal.enums.visit_request_state import (
        VisitRequestState,
    )
    from shared.helpers.typing import mandatory

    if visit_request.state == VisitRequestState.DONE:
        current_logger.info(
            "on_demand_visit: skipping, request already processed",
            visit_request_id=str(visit_request.id),
            state=visit_request.state.value,
        )
        return visit_request

    visit_input = OnDemandVisitInput.from_dict(visit_request.payload)
    used_account_id = account_id or visit_input.account_id
    used_user_id = user_id or visit_input.user_id

    if not used_account_id:
        return _mark_to_review(
            visit_request, reason=VisitRequestStateReason.MISSING_ACCOUNT_ID
        )

    turing_visit_id: Optional[UUID] = None
    try:
        used_account_id = AccountId(used_account_id)

        # Step 1: resolve user
        match_result = _resolve_user_id(visit_input, used_account_id, used_user_id)
        if not match_result.matched or not match_result.user_id:
            if match_result.candidate_user_ids:
                return _mark_to_review(
                    visit_request,
                    reason=VisitRequestStateReason.MULTIPLE_USERS_MATCHED,
                    details={
                        "candidate_user_ids": [
                            str(uid) for uid in match_result.candidate_user_ids
                        ]
                    },
                )
            return _mark_to_review(
                visit_request, reason=VisitRequestStateReason.NO_MATCH_FOUND
            )

        matched_user_id = match_result.user_id
        visit_request.user_id = matched_user_id

        # Step 2: validate profile + affiliation
        failure_reason = _validate_user(
            matched_user_id, used_account_id, str(visit_request.id)
        )
        if failure_reason:
            return _mark_to_review(
                visit_request, reason=failure_reason, user_id=matched_user_id
            )

        # Step 3: check enrichment
        enrichment = get_user_enrichment_data(user_id=matched_user_id)
        if not _has_complete_enrichment(enrichment):
            return _mark_to_review(
                visit_request,
                reason=VisitRequestStateReason.INCOMPLETE_ENRICHMENT,
                details={"missing_fields": _get_missing_enrichment_fields(enrichment)},
                user_id=matched_user_id,
            )

        # Step 4: backfill hp_owner_id from the account setting if missing,
        # then check that all planning info is present.
        if visit_input.hp_owner_id is None:
            visit_input.hp_owner_id = _get_default_hp_owner_for_account(used_account_id)

        occupational_health_profile = get_occupational_health_profile_or_none(
            user_id=matched_user_id
        )
        visit_input.occupational_health_profile_id = (
            occupational_health_profile.id if occupational_health_profile else None
        )

        missing_planning_fields = _get_missing_planning_fields(visit_input)
        if missing_planning_fields:
            return _mark_to_review(
                visit_request,
                reason=VisitRequestStateReason.MISSING_PLANNING_INFO,
                details={"missing_fields": missing_planning_fields},
                user_id=matched_user_id,
            )

        # Step 4b: detect (hp_owner_id, date_planned, hour_booked) slot collisions.
        # A conflicting TO_FILL visit on the same slot sends the request back to
        # review so ops can resolve the overlap before the visit is created.
        existing_visit = find_visit_booked_for_slot(
            hp_owner_id=mandatory(visit_input.hp_owner_id),
            date_planned=mandatory(visit_input.date_planned),
            hour_booked=mandatory(visit_input.hour_booked),
        )
        if existing_visit is not None:
            current_logger.warning(
                f"{str(visit_request.visit_type)}: slot already booked",
                visit_request_id=str(visit_request.id),
                user_id=matched_user_id,
                hp_owner_id=str(visit_input.hp_owner_id),
                date_planned=str(visit_input.date_planned),
                hour_booked=visit_input.hour_booked,
                existing_visit_id=str(existing_visit.visit_id),
                existing_visit_kind=existing_visit.kind,
            )
            return _mark_to_review(
                visit_request,
                reason=VisitRequestStateReason.SLOT_CONFLICT,
                details={
                    "existing_visit_kind": existing_visit.kind,
                    "existing_visit_id": str(existing_visit.visit_id),
                },
                user_id=matched_user_id,
            )

        visit_input.date_fixed = True
        # Step 5: create the Turing visit (the DB is the source of truth).
        turing_visit = _create_turing_visit(
            visit_input, user_id=matched_user_id, account_id=used_account_id
        )
        current_session.flush()
        visit_request.visit_id = turing_visit.id
        turing_visit_id = turing_visit.id
        _mark_done(visit_request, user_id=matched_user_id, actor_id=actor_id)

    except Exception:
        current_logger.exception(
            "on_demand_visit: failed to process visit request",
            visit_request_id=str(visit_request.id),
        )
        return _mark_failed(visit_request)

    # Best-effort, post-DONE. Failures here MUST NOT corrupt the request state:
    # _mark_done already committed and the Turing visit row is in place, so
    # surfacing an exception would only mis-flip DONE→FAILED and break the
    # deterministic-UUID retry path.
    if turing_visit_id is not None and visit_input.send_booking_email:
        try:
            trigger_visit_booking_confirmation_email(turing_visit_id)
        except Exception:
            current_logger.exception(
                "Failed to trigger booking confirmation email",
                visit_id=str(turing_visit_id),
                runbook=_BOOKING_EMAIL_ON_CALL_DOC,
            )
    return visit_request

reject_visit_request

reject_visit_request(
    visit_request_id, message, actor_id=None
)

Mark a visit request as rejected by ops, storing an optional message.

Source code in components/occupational_health/internal/business_logic/actions/on_demand_visit.py
def reject_visit_request(
    visit_request_id: UUID,
    message: Optional[str],
    actor_id: Optional[int] = None,
) -> None:
    """Mark a visit request as rejected by ops, storing an optional message."""
    from components.occupational_health.internal.enums.visit_request_state import (
        VisitRequestState,
    )
    from components.occupational_health.internal.models.occupational_health_visit_request import (
        OccupationalHealthVisitRequest,
    )
    from shared.helpers.get_or_else import get_or_raise_missing_resource

    visit_request = get_or_raise_missing_resource(
        OccupationalHealthVisitRequest, visit_request_id
    )

    if visit_request.state == VisitRequestState.REJECTED:
        current_logger.info(
            "on_demand_visit: visit request already rejected",
            visit_request_id=str(visit_request_id),
            actor_id=actor_id,
            has_message=bool(message),
        )
        return

    current_logger.info(
        "on_demand_visit: rejecting visit request",
        visit_request_id=str(visit_request_id),
        actor_id=actor_id,
        has_message=bool(message),
    )

    visit_request.state = VisitRequestState.REJECTED
    visit_request.state_reason = VisitRequestStateReason.REJECTED_BY_OPS
    if message:
        visit_request.state_reason_details = (
            {"delete_by_ops_message": message} if message else None
        )
    visit_request.actor_id = actor_id
    current_session.commit()

update_visit_after_post_document_generation

update_visit_after_post_document_generation(
    visit_id,
    visit_model,
    doc_generated=None,
    individual_measure_doc_generated=None,
    save=True,
)

Update doc generation flags on an on-demand visit (DB + GSheet).

Parameters:

Name Type Description Default
visit_id UUID

UUID of the TuringOccupationalHealthOnDemandVisit.

required
visit_model str

Type of the visit

required
doc_generated Optional[bool]

New value, or None to leave unchanged.

None
individual_measure_doc_generated Optional[bool]

New value, or None to leave unchanged.

None
save bool

Whether to commit the DB transaction.

True
Source code in components/occupational_health/internal/business_logic/actions/post_visit/post_document_on_demand_visit.py
def update_visit_after_post_document_generation(
    visit_id: UUID,
    visit_model: str,
    doc_generated: Optional[bool] = None,
    individual_measure_doc_generated: Optional[bool] = None,
    save: bool = True,
) -> None:
    """Update doc generation flags on an on-demand visit (DB + GSheet).

    Args:
        visit_id: UUID of the TuringOccupationalHealthOnDemandVisit.
        visit_model: Type of the visit
        doc_generated: New value, or None to leave unchanged.
        individual_measure_doc_generated: New value, or None to leave unchanged.
        save: Whether to commit the DB transaction.
    """

    visit, sheet_type = get_visit_and_sheet_type_from_visit_model(visit_id, visit_model)
    visit_type = VISIT_TYPE_MAP[visit_model]

    if doc_generated is None and individual_measure_doc_generated is None:
        raise RuntimeError(
            f"update_visit_after_post_document_generation: Doc generated and individual measure doc generated are both None for {visit_type} visit {visit.id}"
        )
    if doc_generated is not None:
        visit.doc_generated = doc_generated
    if individual_measure_doc_generated is not None:
        visit.individual_measure_doc_generated = individual_measure_doc_generated

    _sync_docs_to_gsheet(
        visit,
        visit_type=visit_type,
        doc_generated=doc_generated,
        individual_measure_doc_generated=individual_measure_doc_generated,
    )
    if save:
        current_session.commit()

    trigger_post_visit_email(
        visit_id=visit_id,
        visit_type=visit_type,
    )

update_visit_request

update_visit_request(
    visit_request_id, payload_updates, actor_id=None
)

Single entry point used by the Visit request tool. The controller passes the Ops-supplied fields (user_id, account_id, date_planned, hour_booked, hour_end, hp_owner_id) as a payload patch; the planning info is part of OnDemandVisitInput and process_on_demand_visit_request validates it along with user matching and enrichment.

Source code in components/occupational_health/internal/business_logic/actions/on_demand_visit.py
def update_visit_request(
    visit_request_id: UUID,
    payload_updates: dict[str, Any],
    actor_id: Optional[int] = None,
) -> None:
    """
    Single entry point used by the Visit request tool. The controller passes the
    Ops-supplied fields (user_id, account_id, date_planned, hour_booked,
    hour_end, hp_owner_id) as a payload patch; the planning info is part of
    `OnDemandVisitInput` and `process_on_demand_visit_request` validates it
    along with user matching and enrichment.
    """
    from sqlalchemy.orm.attributes import flag_modified

    from components.occupational_health.internal.models.occupational_health_visit_request import (
        OccupationalHealthVisitRequest,
    )
    from shared.helpers.get_or_else import get_or_raise_missing_resource

    visit_request = get_or_raise_missing_resource(
        OccupationalHealthVisitRequest, visit_request_id
    )

    current_logger.info(
        "on_demand_visit: updating payload before re-processing",
        visit_request_id=str(visit_request_id),
        payload_updates_keys=sorted(payload_updates.keys()),
        actor_id=actor_id,
    )

    visit_request.payload = {**(visit_request.payload or {}), **payload_updates}
    # Sql Alchemy method to mark an attribute dirty (to force a correct flush). Especially useful for JSONB or Array field
    flag_modified(visit_request, "payload")
    current_session.flush()

    process_on_demand_visit_request(visit_request, actor_id=actor_id)

post_visit

archive_visit_after_post_visit_email

archive_visit_after_post_visit_email(
    visit_id, visit_model, save=True
)

Archive a visit and stamp the post-visit email timestamp.

Called at the end of the post-visit flow, once the post-visit email has been sent to the member.

Parameters:

Name Type Description Default
visit_id UUID

UUID of the on-demand or predictable visit.

required
visit_model PostVisitDocumentVisitModel

"on_demand" or "predictable".

required
save bool

Whether to commit the DB transaction.

True
Source code in components/occupational_health/internal/business_logic/actions/post_visit/post_visit_email_sent.py
def archive_visit_after_post_visit_email(
    visit_id: UUID,
    visit_model: PostVisitDocumentVisitModel,
    save: bool = True,
) -> None:
    """Archive a visit and stamp the post-visit email timestamp.

    Called at the end of the post-visit flow, once the post-visit email has
    been sent to the member.

    Args:
        visit_id: UUID of the on-demand or predictable visit.
        visit_model: "on_demand" or "predictable".
        save: Whether to commit the DB transaction.
    """

    used_visit_model = (
        PREDICTABLE_VISIT_MODEL
        if visit_model == PostVisitDocumentVisitModel.PREDICTABLE
        else ON_DEMAND_VISIT_MODEL
    )
    visit, _sheet_type = get_visit_and_sheet_type_from_visit_model(
        visit_id, used_visit_model
    )

    visit.is_archived = True
    visit.post_visit_email_sent_at = datetime.now(UTC)
    current_session.add(visit)

    # TODO(customer.io): emit a "post_visit_email_sent" event via
    # shared.services.segment.client.TrackingClient.track() so member-facing
    # nurturing journeys can react to it.

    if save:
        current_session.commit()

create_post_visit_document

create_post_visit_document(
    visit_id,
    visit_model,
    document_type,
    filename,
    gdrive_folder_id,
    gdrive_file_id,
    save=True,
)

Download from gdrive, upload to S3, persist a post-visit document row.

Parameters:

Name Type Description Default
visit_id UUID

UUID of the visit (predictable or on-demand).

required
visit_model PostVisitDocumentVisitModel

Which visit table visit_id refers to.

required
document_type PostVisitDocumentType

The document kind (drives the S3 directory + signature).

required
filename str

Human-readable filename

required
gdrive_folder_id str

Drive folder id holding the file.

required
gdrive_file_id str

Drive file id of the file to fetch.

required
save bool

Whether to commit the DB transaction.

True

Returns:

Type Description
OccupationalHealthPostVisitDocument

The persisted OccupationalHealthPostVisitDocument (with s3_uri set).

Source code in components/occupational_health/internal/business_logic/actions/post_visit/create_post_visit_document.py
def create_post_visit_document(
    visit_id: UUID,
    visit_model: PostVisitDocumentVisitModel,
    document_type: PostVisitDocumentType,
    filename: str,
    gdrive_folder_id: str,
    gdrive_file_id: str,
    save: bool = True,
) -> OccupationalHealthPostVisitDocument:
    """Download from gdrive, upload to S3, persist a post-visit document row.

    Args:
        visit_id: UUID of the visit (predictable or on-demand).
        visit_model: Which visit table `visit_id` refers to.
        document_type: The document kind (drives the S3 directory + signature).
        filename: Human-readable filename
        gdrive_folder_id: Drive folder id holding the file.
        gdrive_file_id: Drive file id of the file to fetch.
        save: Whether to commit the DB transaction.

    Returns:
        The persisted OccupationalHealthPostVisitDocument (with `s3_uri` set).
    """
    drive_service = DriveService.get(
        credentials_config_key="GOOGLE_GSPREAD_SERVICE_ACCOUNT_SECRET_NAME"
    )
    file_content = get_file_content(drive_service, gdrive_file_id)
    s3_filename = f"{visit_id}_{filename}"
    try:
        visit_cls = (
            TuringOccupationalHealthPredictableVisit
            if visit_model == PostVisitDocumentVisitModel.PREDICTABLE
            else TuringOccupationalHealthOnDemandVisit
        )
        visit = current_session.scalar(
            select(visit_cls).where(visit_cls.id == visit_id)
        )
        if visit and visit.date_planned:
            s3_filename = f"{visit.user_id}_{visit.date_planned.isoformat()}_{filename}"
    except Exception as e:
        current_logger.error(
            f"Failed to get visit for post-visit document, use fallback filename {s3_filename}",
            exc_info=e,
        )

    s3_uri = RemoteFileClient.upload(
        upload_dir=_S3_DIRECTORY_BY_DOCUMENT_TYPE[document_type],
        file_path_or_file_obj=io.BytesIO(file_content),
        s3_bucket=current_config["OCCUPATIONAL_HEALTH_DOCUMENTS_S3_BUCKET"],
        destination_filename=s3_filename,
    )
    document = OccupationalHealthPostVisitDocument(
        visit_id=visit_id,
        visit_model=visit_model,
        document_type=document_type,
        filename=filename,
        gdrive_folder_id=gdrive_folder_id,
        gdrive_file_id=gdrive_file_id,
        s3_uri=s3_uri,
        required_signature=_REQUIRED_SIGNATURE_BY_DOCUMENT_TYPE[document_type],
    )
    current_session.add(document)
    if save:
        current_session.commit()
    return document

update_visit_after_post_document_generation

update_visit_after_post_document_generation(
    visit_id,
    visit_model,
    doc_generated=None,
    individual_measure_doc_generated=None,
    save=True,
)

Update doc generation flags on an on-demand visit (DB + GSheet).

Parameters:

Name Type Description Default
visit_id UUID

UUID of the TuringOccupationalHealthOnDemandVisit.

required
visit_model str

Type of the visit

required
doc_generated Optional[bool]

New value, or None to leave unchanged.

None
individual_measure_doc_generated Optional[bool]

New value, or None to leave unchanged.

None
save bool

Whether to commit the DB transaction.

True
Source code in components/occupational_health/internal/business_logic/actions/post_visit/post_document_on_demand_visit.py
def update_visit_after_post_document_generation(
    visit_id: UUID,
    visit_model: str,
    doc_generated: Optional[bool] = None,
    individual_measure_doc_generated: Optional[bool] = None,
    save: bool = True,
) -> None:
    """Update doc generation flags on an on-demand visit (DB + GSheet).

    Args:
        visit_id: UUID of the TuringOccupationalHealthOnDemandVisit.
        visit_model: Type of the visit
        doc_generated: New value, or None to leave unchanged.
        individual_measure_doc_generated: New value, or None to leave unchanged.
        save: Whether to commit the DB transaction.
    """

    visit, sheet_type = get_visit_and_sheet_type_from_visit_model(visit_id, visit_model)
    visit_type = VISIT_TYPE_MAP[visit_model]

    if doc_generated is None and individual_measure_doc_generated is None:
        raise RuntimeError(
            f"update_visit_after_post_document_generation: Doc generated and individual measure doc generated are both None for {visit_type} visit {visit.id}"
        )
    if doc_generated is not None:
        visit.doc_generated = doc_generated
    if individual_measure_doc_generated is not None:
        visit.individual_measure_doc_generated = individual_measure_doc_generated

    _sync_docs_to_gsheet(
        visit,
        visit_type=visit_type,
        doc_generated=doc_generated,
        individual_measure_doc_generated=individual_measure_doc_generated,
    )
    if save:
        current_session.commit()

    trigger_post_visit_email(
        visit_id=visit_id,
        visit_type=visit_type,
    )

update_visit_from_post_visit

update_visit_from_post_visit(
    visit_id,
    occupational_health_profile_id,
    payload,
    save=True,
)

Update visit fields from the post-visit form (DB + GSheet).

Parameters:

Name Type Description Default
visit_id UUID

UUID of the visit.

required
occupational_health_profile_id UUID

UUID of the Occupational Health profile (for admin profile update).

required
payload dict[str, Any]

Dict of all post-visit form fields.

required
save bool

Whether to commit the transaction.

True
Source code in components/occupational_health/internal/business_logic/actions/post_visit/action.py
def update_visit_from_post_visit(
    visit_id: UUID,
    occupational_health_profile_id: UUID,
    payload: dict[str, Any],
    save: bool = True,
) -> None:
    """Update visit fields from the post-visit form (DB + GSheet).

    Args:
        visit_id: UUID of the visit.
        occupational_health_profile_id: UUID of the Occupational Health profile (for admin profile update).
        payload: Dict of all post-visit form fields.
        save: Whether to commit the transaction.
    """
    visit_model = payload["visit_model"]
    visit, sheet_type = get_visit_and_sheet_type_from_visit_model(visit_id, visit_model)

    visit_request = OccupationalHealthVisitRequest(
        payload=payload,
        request_type=VisitRequestType.UPDATE,
        visit_type=VISIT_TYPE_MAP[visit_model],
        state=VisitRequestState.TODO,
        user_id=visit.user_id,
    )
    current_session.add(visit_request)
    current_session.flush()  # to get the id

    _enrich_payload(payload)

    try:
        sync_visit_to_gsheet(visit=visit, sheet_type=sheet_type, payload=payload)

        update_visit_db(visit, payload)

        risk_category = payload.get("risk_category")
        if risk_category:
            update_administrative_profile(
                ProfileId(occupational_health_profile_id),
                risk_category=risk_category,
                commit=False,
            )

        visit_request.state = VisitRequestState.DONE

        if save:
            current_session.commit()

        trigger_post_visit_document_automation(
            visit_id=visit_id,
            visit_type=VISIT_TYPE_MAP[visit_model],
        )

        visit_status_raw = payload.get("visit_status")
        if visit_status_raw is not None:
            dispatch_post_visit_actions(
                visit_id=visit_id,
                visit_status=VisitStatus(visit_status_raw),
                visit_type=VISIT_TYPE_MAP[visit_model],
            )

    except Exception as exc:
        current_logger.exception(
            "Post-visit update failed",
            visit_id=str(visit_id),
            visit_request_id=str(visit_request.id),
        )
        visit_request.state = VisitRequestState.FAILED
        visit_request.state_reason = VisitRequestStateReason.TECHNICAL_ERROR
        visit_request.state_reason_details = {"error": str(exc)}

        if save:
            current_session.commit()

        raise exc

subscriber_documents

soft_delete_subscriber_document

soft_delete_subscriber_document(
    account_id, document_id, commit=False
)
Source code in components/occupational_health/internal/business_logic/actions/subscriber_documents.py
@tracer.wrap()
def soft_delete_subscriber_document(
    account_id: uuid.UUID,
    document_id: uuid.UUID,
    commit: bool = False,
) -> None:
    existing_doc = current_session.scalars(
        select(OccupationalHealthSubscriberDocument).where(
            OccupationalHealthSubscriberDocument.account_id == account_id,
            OccupationalHealthSubscriberDocument.id == document_id,
            OccupationalHealthSubscriberDocument.deleted_at.is_(None),
        )
    ).one_or_none()

    if not existing_doc:
        raise BaseErrorCode.missing_resource(
            f"No document found with account_id {account_id} and document_id {document_id}"
        )

    existing_doc.deleted_at = utcnow()

    if commit:
        current_session.commit()
    else:
        current_session.flush()

update_subscriber_document

update_subscriber_document(document, commit=False)
Source code in components/occupational_health/internal/business_logic/actions/subscriber_documents.py
@tracer.wrap()
def update_subscriber_document(
    document: UpdateSubscriberDocument,
    commit: bool = False,
) -> None:
    existing_doc = current_session.scalars(
        select(OccupationalHealthSubscriberDocument).where(
            OccupationalHealthSubscriberDocument.account_id == document.account_id,
            OccupationalHealthSubscriberDocument.id == document.id,
            OccupationalHealthSubscriberDocument.deleted_at.is_(None),
        )
    ).one_or_none()

    if not existing_doc:
        raise BaseErrorCode.missing_resource(
            f"No document found with account_id {document.account_id} and document_id {document.id}"
        )

    existing_doc.name = document.name
    existing_doc.document_type = document.document_type
    existing_doc.siret = document.siret

    if commit:
        current_session.commit()
    else:
        current_session.flush()

upload_subscriber_documents

upload_subscriber_documents(
    documents, skip_duplication_check=False
)
Source code in components/occupational_health/internal/business_logic/actions/subscriber_documents.py
@tracer.wrap()
def upload_subscriber_documents(
    documents: list[CreateSubscriberDocument],
    skip_duplication_check: bool = False,
) -> list[uuid.UUID]:
    if documents:
        # Check duplicates for the first document's account_id (assuming all have the same account)
        check_duplicate_subscriber_files(
            account_id=documents[0].account_id,
            files=[doc.file for doc in documents],
            skip_duplication_check=skip_duplication_check,
        )

    created_ids: list[uuid.UUID] = []
    for document in documents:
        with converted_file_mimetype_and_hash(document.name, document.file) as (
            converted_file,
            mime_type,
            content_hash,
        ):
            new_document = OccupationalHealthSubscriberDocument(
                document_type=document.type,
                account_id=AccountId(document.account_id),
                siret=document.siret,
                mime_type=mime_type,
                content_hash=content_hash,
                name=document.name,
            )
            current_session.add(new_document)
            new_document.upload_file(converted_file)
            current_session.flush()
            created_ids.append(new_document.id)

    current_session.commit()
    return created_ids

visit_booking_confirmation

Public facade for the visit booking-confirmation email lifecycle.

on_booking_confirmation_sent

on_booking_confirmation_sent(visit_id)

Stamp the visit row after the backend mailer confirmed the booking confirmation was sent.

Source code in components/occupational_health/public/actions/visit_booking_confirmation.py
def on_booking_confirmation_sent(visit_id: UUID) -> None:
    """Stamp the visit row after the backend mailer confirmed the booking confirmation was sent."""
    visit = current_session.execute(
        select(TuringOccupationalHealthOnDemandVisit).where(
            TuringOccupationalHealthOnDemandVisit.id == visit_id
        )
    ).scalar_one_or_none()

    if visit is None:
        current_logger.error(
            "[Occupational Health] Could not find on-demand visit with id after sending booking email",
            visit_id=visit_id,
        )
        return

    visit.booking_email_sent_at = datetime.now(UTC)
    current_session.commit()

welcome_desk

update_visit_from_welcome_desk

update_visit_from_welcome_desk(
    visit_id, payload, save=True
)

Update visit-day fields from the welcome-desk (ASST) form.

Parameters:

Name Type Description Default
visit_id UUID

UUID of the visit to update.

required
payload dict[str, Any]

Schema-validated dict. Must contain visit_model; may contain any subset of :data:ALLOWED_WELCOME_DESK_FIELDS.

required
save bool

Whether to commit the transaction.

True
Source code in components/occupational_health/internal/business_logic/actions/welcome_desk/action.py
def update_visit_from_welcome_desk(
    visit_id: UUID,
    payload: dict[str, Any],
    save: bool = True,
) -> None:
    """Update visit-day fields from the welcome-desk (ASST) form.

    Args:
        visit_id: UUID of the visit to update.
        payload: Schema-validated dict. Must contain ``visit_model``; may contain
            any subset of :data:`ALLOWED_WELCOME_DESK_FIELDS`.
        save: Whether to commit the transaction.
    """
    extraneous = set(payload) - ALLOWED_WELCOME_DESK_FIELDS - {"visit_model"}
    if extraneous:
        raise BaseErrorCode.invalid_arguments(
            message=f"Welcome-desk payload contains non-ASST fields: {sorted(extraneous)}",
        )

    visit_model = payload["visit_model"]
    visit, sheet_type = get_visit_and_sheet_type_from_visit_model(visit_id, visit_model)

    visit_request = OccupationalHealthVisitRequest(
        payload=payload,
        request_type=VisitRequestType.UPDATE,
        visit_type=VISIT_TYPE_MAP[visit_model],
        state=VisitRequestState.TODO,
        user_id=visit.user_id,
        visit_id=visit_id,
    )
    current_session.add(visit_request)
    current_session.flush()

    health_professional_id = payload.get("health_professional_id")
    if health_professional_id:
        get_or_raise_health_professional(health_professional_id)

    try:
        # `arrived_in_waiting_room_at` is not in FIELD_TO_GSHEET_COLUMN, so it
        # is naturally filtered out of the GSheet update; other fields (HP,
        # status, time, modality, type) still sync so downstream consumers
        # stay current.
        sync_visit_to_gsheet(visit=visit, sheet_type=sheet_type, payload=payload)

        update_visit_db(visit, payload)

        visit_request.state = VisitRequestState.DONE

        if save:
            current_session.commit()

        visit_status_raw = payload.get("visit_status")
        if visit_status_raw is not None:
            dispatch_post_visit_actions(
                visit_id=visit_id,
                visit_status=VisitStatus(visit_status_raw),
                visit_type=VISIT_TYPE_MAP[visit_model],
            )

    except Exception as exc:
        current_logger.exception(
            "Welcome-desk update failed",
            visit_id=str(visit_id),
            visit_request_id=str(visit_request.id),
        )
        visit_request.state = VisitRequestState.FAILED
        visit_request.state_reason = VisitRequestStateReason.TECHNICAL_ERROR
        visit_request.state_reason_details = {"error": str(exc)}

        if save:
            current_session.commit()

        raise exc

workspace_actions

create_workspace_action

create_workspace_action(
    thesaurus_mean_id,
    target_type,
    target_id,
    status,
    creator_full_name,
    prevention_type,
    eta_date=None,
    note=None,
    siret=None,
    document_id=None,
    company_workplace_planned_visit_date=None,
    completed_date=None,
    company_workplace_visit_status=None,
    time_spent_in_minutes=None,
    commit=True,
)

Create a new WorkspaceAction for a company or a member

Returns:

Type Description
UUID

The ID of the newly created AMT

Source code in components/occupational_health/internal/business_logic/actions/workspace_actions.py
def create_workspace_action(
    thesaurus_mean_id: uuid.UUID,
    target_type: WorkspaceActionType,
    target_id: uuid.UUID,
    status: WorkspaceActionStatus,
    creator_full_name: str,
    prevention_type: WorkspaceActionPreventionType,
    eta_date: Optional[date] = None,
    note: Optional[str] = None,
    siret: Optional[str] = None,
    document_id: Optional[uuid.UUID] = None,
    company_workplace_planned_visit_date: Optional[date] = None,
    completed_date: Optional[date] = None,
    company_workplace_visit_status: Optional[
        WorkspaceActionCompanyWorkplaceVisitStatus
    ] = None,
    time_spent_in_minutes: Optional[int] = None,
    commit: bool = True,
) -> uuid.UUID:
    """
    Create a new WorkspaceAction for a company or a member

    Returns:
        The ID of the newly created AMT
    """

    # TODO david.barthelemy: the creator_full_name must be replace as soon as you have a reference to the global profile. For now it's ok to live like this
    thesaurus_mean = get_or_raise_missing_resource(ThesaurusMean, thesaurus_mean_id)
    _raise_if_status_not_allowed_for_thesaurus_mean(status, thesaurus_mean)
    if target_type == WorkspaceActionType.MEMBER:
        account_id = None
        profile = get_or_raise_missing_resource(OccupationalHealthProfile, target_id)
    elif target_type == WorkspaceActionType.COMPANY:
        account_id = target_id
        profile = None
    else:
        raise NotImplementedError(f"unsupported target type: {target_type}")

    document = (
        _resolve_attachable_document(document_id, account_id, siret, thesaurus_mean)
        if document_id is not None
        else None
    )

    workspace_action = OccupationalHealthWorkspaceAction(
        title=thesaurus_mean.label,
        thesaurus_mean=thesaurus_mean,
        type=target_type,
        profile=profile,
        account_id=account_id,
        # actions for at the entity level (SIRET) must have an account
        siret=siret if account_id else None,
        prevention_type=prevention_type,
        eta_date=eta_date,
        status=status,
        note=note,
        actor_full_name=creator_full_name,
        company_workplace_planned_visit_date=company_workplace_planned_visit_date,
        completed_date=completed_date,
        company_workplace_visit_status=company_workplace_visit_status,
        time_spent_in_minutes=time_spent_in_minutes,
    )

    current_session.add(workspace_action)
    current_session.flush()

    if document is not None:
        document.workspace_action = workspace_action

    if commit:
        current_session.commit()
    else:
        current_session.flush()

    current_logger.info(
        "Created new WorkspaceAction",
        workspace_action_d=str(workspace_action.id),
        target_type=str(target_type),
        target_id=str(target_id),
        status=str(status),
    )

    return workspace_action.id

delete_workspace_action

delete_workspace_action(workspace_action_id, commit=True)

Delete an existing WorkspaceAction

Source code in components/occupational_health/internal/business_logic/actions/workspace_actions.py
def delete_workspace_action(
    workspace_action_id: uuid.UUID,
    commit: bool = True,
) -> None:
    """
    Delete an existing WorkspaceAction
    """
    workspace_action = get_or_raise_missing_resource(
        OccupationalHealthWorkspaceAction, workspace_action_id
    )

    # Soft-delete the linked doc (if any)
    _replace_linked_document(workspace_action, None)

    # Drop references to the action in the subscriber document table
    current_session.execute(
        update(OccupationalHealthSubscriberDocument)
        .where(
            OccupationalHealthSubscriberDocument.workspace_action_id
            == workspace_action.id
        )
        .values(workspace_action_id=None)
    )
    current_session.flush()

    # TODO: migrate to a soft delete (@wearp)
    current_session.delete(workspace_action)

    if commit:
        current_session.commit()
    else:
        current_session.flush()

    current_logger.info(
        "Deleted WorkspaceAction",
        workspace_action_id=str(workspace_action_id),
    )

update_workspace_action

update_workspace_action(
    workspace_action_id,
    status,
    prevention_type=None,
    eta_date=None,
    note=None,
    document_id=NOT_SET,
    company_workplace_planned_visit_date=None,
    completed_date=None,
    company_workplace_visit_status=None,
    time_spent_in_minutes=None,
    commit=True,
)

Update an existing WorkspaceAction

Only updates the fields: status, prevention_type, eta_date, note, company_workplace_planned_visit_date, completed_date, company_workplace_visit_status, time_spent_in_minutes, and the linked subscriber document.

The target and action (thesaurus_mean) cannot be changed

Source code in components/occupational_health/internal/business_logic/actions/workspace_actions.py
def update_workspace_action(
    workspace_action_id: uuid.UUID,
    status: WorkspaceActionStatus,
    prevention_type: Optional[WorkspaceActionPreventionType] = None,
    eta_date: Optional[date] = None,
    note: Optional[str] = None,
    document_id: NotSet[Optional[uuid.UUID]] = NOT_SET,
    company_workplace_planned_visit_date: Optional[date] = None,
    completed_date: Optional[date] = None,
    company_workplace_visit_status: Optional[
        WorkspaceActionCompanyWorkplaceVisitStatus
    ] = None,
    time_spent_in_minutes: Optional[int] = None,
    commit: bool = True,
) -> None:
    """
    Update an existing WorkspaceAction

    Only updates the fields: status, prevention_type, eta_date, note,
    company_workplace_planned_visit_date, completed_date,
    company_workplace_visit_status, time_spent_in_minutes,
    and the linked subscriber document.

    The target and action (thesaurus_mean) cannot be changed
    """
    workspace_action = get_or_raise_missing_resource(
        OccupationalHealthWorkspaceAction, workspace_action_id
    )

    _raise_if_status_not_allowed_for_thesaurus_mean(
        status,
        workspace_action.thesaurus_mean,
        current_status=workspace_action.status,
    )

    workspace_action.status = status
    workspace_action.prevention_type = prevention_type
    workspace_action.eta_date = eta_date
    workspace_action.note = note
    workspace_action.company_workplace_planned_visit_date = (
        company_workplace_planned_visit_date
    )
    workspace_action.completed_date = completed_date
    workspace_action.company_workplace_visit_status = company_workplace_visit_status
    workspace_action.time_spent_in_minutes = time_spent_in_minutes

    if is_set(document_id):
        _replace_linked_document(workspace_action, document_id)

    if commit:
        current_session.commit()
    else:
        current_session.flush()

    current_logger.info(
        "Updated WorkspaceAction",
        workspace_action_id=str(workspace_action.id),
        status=str(status),
    )

components.occupational_health.public.business_logic

actions

merge_users

merge_occupational_health_users
merge_occupational_health_users(
    source_user_id,
    target_user_id,
    commit=False,
    event_bus=None,
)

Merge all occupational health data from source user to target user.

This function handles merging of all occupational health entities that reference user_id: - Dashboard admins - Notifies oncall to update spreadsheets if visits exist for the user

Parameters:

Name Type Description Default
source_user_id UserId | str

User ID to merge from

required
target_user_id UserId | str

User ID to merge into

required
commit bool

Whether to commit the transaction (default: False)

False
event_bus EventBus[CommittableFunction] | None

If provided, notifications are deferred until event_bus.apply(commit=True)

None

Returns:

Type Description
list[str]

List of log messages describing actions taken

Source code in components/occupational_health/public/business_logic/actions/merge_users.py
def merge_occupational_health_users(
    source_user_id: UserId | str,
    target_user_id: UserId | str,
    commit: bool = False,
    event_bus: EventBus[CommittableFunction] | None = None,
) -> list[str]:
    """
    Merge all occupational health data from source user to target user.

    This function handles merging of all occupational health entities that reference user_id:
    - Dashboard admins
    - Notifies oncall to update spreadsheets if visits exist for the user

    Args:
        source_user_id: User ID to merge from
        target_user_id: User ID to merge into
        commit: Whether to commit the transaction (default: False)
        event_bus: If provided, notifications are deferred until event_bus.apply(commit=True)

    Returns:
        List of log messages describing actions taken
    """
    logs: list[str] = []

    # Create internal event bus if none provided
    internal_event_bus: BasicEventBus | None = None
    if event_bus is None:
        internal_event_bus = BasicEventBus()

    effective_event_bus = mandatory(event_bus or internal_event_bus)

    # Merge dashboard admins
    logs += merge_occupational_health_dashboard_admins(
        source_user_id=UserId(source_user_id),
        target_user_id=UserId(target_user_id),
        commit=False,
    )

    # Check visits and notify oncall to update spreadsheets if needed
    logs += notify_about_user_merge_in_occupational_health_visits(
        source_user_id=UserId(source_user_id),
        target_user_id=UserId(target_user_id),
        event_bus=effective_event_bus,
    )

    if commit:
        from shared.helpers.db import current_session

        current_session.commit()

    # Only apply if we created an internal event bus (caller is responsible otherwise)
    if internal_event_bus is not None:
        internal_event_bus.apply(commit=commit)

    return logs

components.occupational_health.public.dependencies

AccountSearchResult dataclass

AccountSearchResult(id, name)

Represent a basic Account object retrieved from the Search

id instance-attribute

id

name instance-attribute

name

OCCUPATIONAL_HEALTH_COMPONENT_NAME module-attribute

OCCUPATIONAL_HEALTH_COMPONENT_NAME = 'occupational_health'

OccupationalHealthDependency

Bases: ABC

Dependency interface for occupational_health component to access country-specific functionality.

This interface abstracts away direct dependencies on country-specific components, allowing the occupational_health component to work with different country implementations.

create_profile_with_user abstractmethod

create_profile_with_user(profile_data)

Create user profile with user data.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def create_profile_with_user(
    self, profile_data: ProfileData
) -> tuple[UserId, GlobalProfileId]:
    """Create user profile with user data."""
    raise NotImplementedError()

find_member_candidates abstractmethod

find_member_candidates(last_names, first_names, sirens)

Find member candidates by name + company SIREN for DPAE matching.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def find_member_candidates(
    self, last_names: set[str], first_names: set[str], sirens: set[str]
) -> list[MemberCandidate]:
    """Find member candidates by name + company SIREN for DPAE matching."""
    raise NotImplementedError()

get_account_admins_profile_ids abstractmethod

get_account_admins_profile_ids(account_id)

Get account admin profile IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_account_admins_profile_ids(
    self, account_id: AccountId
) -> set[GlobalProfileId]:
    """Get account admin profile IDs."""
    raise NotImplementedError()

get_account_id abstractmethod

get_account_id(company_id)

Get account ID for a company.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_account_id(self, company_id: CompanyId) -> AccountId:
    """Get account ID for a company."""
    raise NotImplementedError()

get_account_id_and_siret abstractmethod

get_account_id_and_siret(company_id)

Get account ID and SIRET for a company.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_account_id_and_siret(
    self, company_id: CompanyId
) -> tuple[AccountId, str | None]:
    """Get account ID and SIRET for a company."""
    raise NotImplementedError()

get_account_name abstractmethod

get_account_name(account_id)

Get account name.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_account_name(self, account_id: AccountId) -> str:
    """Get account name."""
    raise NotImplementedError()

get_account_names_by_ids abstractmethod

get_account_names_by_ids(account_ids)

Get account names by IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_account_names_by_ids(
    self, account_ids: set[AccountId]
) -> dict[AccountId, str]:
    """Get account names by IDs."""
    raise NotImplementedError()

get_company_addresses_by_ids abstractmethod

get_company_addresses_by_ids(company_ids)

Get company addresses by IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_addresses_by_ids(
    self, company_ids: set[CompanyId]
) -> dict[CompanyId, CompanyAddress | None]:
    """Get company addresses by IDs."""
    raise NotImplementedError()

get_company_admins_profile_ids abstractmethod

get_company_admins_profile_ids(company_id)

Get company admin profile IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_admins_profile_ids(
    self, company_id: CompanyId
) -> set[GlobalProfileId]:
    """Get company admin profile IDs."""
    raise NotImplementedError()

get_company_display_name_and_account_id abstractmethod

get_company_display_name_and_account_id(company_id)

Get company display name and associated account ID.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_display_name_and_account_id(
    self, company_id: CompanyId
) -> tuple[str, AccountId]:
    """Get company display name and associated account ID."""
    raise NotImplementedError()

get_company_from_siret abstractmethod

get_company_from_siret(siret)

Get company data from SIRET via DSN.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_from_siret(self, siret: str) -> Optional["DSNCompanyData"]:
    """Get company data from SIRET via DSN."""
    raise NotImplementedError()

get_company_ids_in_account abstractmethod

get_company_ids_in_account(account_id)

Get all company IDs in an account.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_ids_in_account(self, account_id: AccountId) -> list[CompanyId]:
    """Get all company IDs in an account."""
    raise NotImplementedError()

get_company_names_by_ids abstractmethod

get_company_names_by_ids(company_ids)

Get company names by IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_names_by_ids(
    self, company_ids: set[CompanyId]
) -> dict[CompanyId, str]:
    """Get company names by IDs."""
    raise NotImplementedError()

get_company_siren abstractmethod

get_company_siren(company_id)

Get company SIREN.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_siren(self, company_id: CompanyId) -> str:
    """Get company SIREN."""
    raise NotImplementedError()

get_company_sirens_by_ids abstractmethod

get_company_sirens_by_ids(company_ids)

Get company SIRENs by IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_sirens_by_ids(
    self, company_ids: set[CompanyId]
) -> dict[CompanyId, str]:
    """Get company SIRENs by IDs."""
    raise NotImplementedError()

get_company_siret abstractmethod

get_company_siret(company_id, force_nic=None)

Get company SIRET, optionally forcing a specific NIC.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_company_siret(
    self, company_id: CompanyId, force_nic: str | None = None
) -> str | None:
    """Get company SIRET, optionally forcing a specific NIC."""
    raise NotImplementedError()

get_dpae_list abstractmethod

get_dpae_list()

List all past DPAE uploads, most recent first.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_dpae_list(self) -> list[DpaeListItem]:
    """List all past DPAE uploads, most recent first."""
    raise NotImplementedError()

get_global_profile_id abstractmethod

get_global_profile_id(user_id)

Get global profile ID for a user.

Raises:

Type Description
UserIdNotFound

If the user ID does not exist.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_global_profile_id(self, user_id: UserId) -> GlobalProfileId | None:
    """Get global profile ID for a user.

    Raises:
        UserIdNotFound: If the user ID does not exist.
    """
    raise NotImplementedError()

get_known_sirens_for_account abstractmethod

get_known_sirens_for_account(account_id)

Return the distinct SIRENs of all Company rows attached to the Account.

Empty set when the account has no companies. Used by the Affiliator core-stack flow to pre-populate the SIREN input with the operator's likely picks.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_known_sirens_for_account(self, account_id: AccountId) -> set[str]:
    """Return the distinct SIRENs of all Company rows attached to the Account.

    Empty set when the account has no companies. Used by the Affiliator
    core-stack flow to pre-populate the SIREN input with the operator's
    likely picks.
    """
    raise NotImplementedError()

get_nic_mapping_by_sirens abstractmethod

get_nic_mapping_by_sirens(sirens)

Get companies by SIREN. Returns siren → {nic → account_id}.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_nic_mapping_by_sirens(
    self, sirens: set[str]
) -> dict[str, dict[str | None, UUID]]:
    """Get companies by SIREN. Returns siren → {nic → account_id}."""
    raise NotImplementedError()

get_prevoyance_covered_user_company_pairs abstractmethod

get_prevoyance_covered_user_company_pairs(user_ids)

Return the (user, company) pairs covered by an active prévoyance contract today.

Inputs accept UserId, plain str, or UUID — callers from other components don't need to know the internal NewType aliases; the implementation normalizes them.

Per-employment granularity so callers can decide whether ALL the member's companies in a given scope (e.g. an account) are covered, instead of just knowing the user is covered "somewhere".

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_prevoyance_covered_user_company_pairs(
    self, user_ids: Iterable[UserId | str | UUID]
) -> set[tuple[UserId, CompanyId]]:
    """Return the `(user, company)` pairs covered by an active prévoyance
    contract today.

    Inputs accept `UserId`, plain `str`, or `UUID` — callers from other
    components don't need to know the internal `NewType` aliases; the
    implementation normalizes them.

    Per-employment granularity so callers can decide whether ALL the
    member's companies in a given scope (e.g. an account) are covered,
    instead of just knowing the user is covered "somewhere".
    """
    raise NotImplementedError()

get_sirene_establishment_from_siret abstractmethod

get_sirene_establishment_from_siret(siret)

Get establishment data from SIRET via INSEE SIRENE (DSN fallback).

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_sirene_establishment_from_siret(
    self, siret: str
) -> Optional["SireneEstablishmentData"]:
    """Get establishment data from SIRET via INSEE SIRENE (DSN fallback)."""
    raise NotImplementedError()

get_ssn_and_ntt_for_user abstractmethod

get_ssn_and_ntt_for_user(user_id)

Get SSN and NTT of a user.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_ssn_and_ntt_for_user(
    self, user_id: UserId
) -> tuple[str | None, str | None]:
    """Get SSN and NTT of a user."""
    raise NotImplementedError()

get_ssn_and_ntt_for_users abstractmethod

get_ssn_and_ntt_for_users(user_ids)

Get SSN and NTT for multiple users.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_ssn_and_ntt_for_users(
    self, user_ids: Iterable[UserId]
) -> dict[UserId, tuple[str | None, str | None]]:
    """Get SSN and NTT for multiple users."""
    raise NotImplementedError()

get_user_id_by_global_profile_id_mapping_from_global_profile_ids abstractmethod

get_user_id_by_global_profile_id_mapping_from_global_profile_ids(
    global_profile_ids,
)

Get global profile ID -> user ID mapping from global health profile IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_user_id_by_global_profile_id_mapping_from_global_profile_ids(
    self, global_profile_ids: Iterable[GlobalProfileId]
) -> dict[GlobalProfileId, UserId]:
    """Get global profile ID -> user ID mapping from global health profile IDs."""
    raise NotImplementedError()

get_user_id_by_global_profile_id_mapping_from_user_ids abstractmethod

get_user_id_by_global_profile_id_mapping_from_user_ids(
    user_ids,
)

Get global profile ID -> user ID mapping from user IDs.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_user_id_by_global_profile_id_mapping_from_user_ids(
    self, user_ids: Iterable[UserId]
) -> dict[GlobalProfileId, UserId]:
    """Get global profile ID -> user ID mapping from user IDs."""
    raise NotImplementedError()

get_user_id_from_global_profile_id abstractmethod

get_user_id_from_global_profile_id(global_profile_id)

Get user ID from global profile ID.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_user_id_from_global_profile_id(
    self, global_profile_id: GlobalProfileId
) -> UserId:
    """Get user ID from global profile ID."""
    raise NotImplementedError()

get_user_slack_id abstractmethod

get_user_slack_id(user_id)

Get Slack handle for an Alan user.

Returns Slack handle (e.g., "john.doe") or None if not found. Used for pinging users in Slack notifications.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_user_slack_id(self, user_id: UserId) -> str | None:
    """
    Get Slack handle for an Alan user.

    Returns Slack handle (e.g., "john.doe") or None if not found.
    Used for pinging users in Slack notifications.
    """
    raise NotImplementedError()

get_work_stoppages_for_users abstractmethod

get_work_stoppages_for_users(
    user_ids, ever_active_during_period=None
)

Get work stoppages for users.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def get_work_stoppages_for_users(
    self,
    user_ids: Iterable[UserId],
    ever_active_during_period: tuple[date, date] | None = None,
) -> dict[UserId, list["WorkStoppage"]]:
    """Get work stoppages for users."""
    raise NotImplementedError()

prepare_mailer_params_for_billing_invoice_email abstractmethod

prepare_mailer_params_for_billing_invoice_email(
    email_address,
    template_name,
    template_args,
    attachments,
    invoice_ids,
)

Return a MailerParam instance to be used for sending billing invoice emails.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def prepare_mailer_params_for_billing_invoice_email(
    self,
    email_address: str,
    template_name: str,
    template_args: dict[str, Any],
    attachments: list[dict[str, str]] | None,
    invoice_ids: list[int],
) -> None | BaseMailerParams:
    """
    Return a MailerParam instance to be used for sending billing invoice emails.
    """
    raise NotImplementedError()

prepare_mailer_params_for_cancellation_email abstractmethod

prepare_mailer_params_for_cancellation_email(
    recipient,
    visit_id,
    visit_model,
    template_args,
    hr_emails,
)

Return MailerParams for the cancellation email (predictable + on-demand).

Implementations MUST wire a success_callback that writes post_visit_email_sent_at on the visit, so the flag is only set after delivery is confirmed.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def prepare_mailer_params_for_cancellation_email(
    self,
    recipient: str,
    visit_id: UUID,
    visit_model: str,
    template_args: dict[str, Any],
    hr_emails: list[str],
) -> BaseMailerParams:
    """Return MailerParams for the cancellation email (predictable + on-demand).

    Implementations MUST wire a `success_callback` that writes
    `post_visit_email_sent_at` on the visit, so the flag is only set
    after delivery is confirmed.
    """
    raise NotImplementedError()

prepare_mailer_params_for_no_show_email abstractmethod

prepare_mailer_params_for_no_show_email(
    recipient,
    visit_id,
    visit_model,
    template_args,
    hr_emails,
)

Return MailerParams for the no-show email (predictable + on-demand).

Implementations MUST wire a success_callback that writes post_visit_email_sent_at on the visit, so the flag is only set after delivery is confirmed.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def prepare_mailer_params_for_no_show_email(
    self,
    recipient: str,
    visit_id: UUID,
    visit_model: str,
    template_args: dict[str, Any],
    hr_emails: list[str],
) -> BaseMailerParams:
    """Return MailerParams for the no-show email (predictable + on-demand).

    Implementations MUST wire a `success_callback` that writes
    `post_visit_email_sent_at` on the visit, so the flag is only set
    after delivery is confirmed.
    """
    raise NotImplementedError()

prepare_mailer_params_for_visit_booking_confirmation abstractmethod

prepare_mailer_params_for_visit_booking_confirmation(
    email_address, visit_id, template_args, hr_emails
)

Return MailerParams for the visit booking confirmation email.

Implementations MUST wire a success_callback that writes booking_email_sent_at on the visit, so the flag is only set after delivery is confirmed.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def prepare_mailer_params_for_visit_booking_confirmation(
    self,
    email_address: str,
    visit_id: str,
    template_args: dict[str, Any],
    hr_emails: list[str],
) -> BaseMailerParams:
    """Return MailerParams for the visit booking confirmation email.

    Implementations MUST wire a `success_callback` that writes
    `booking_email_sent_at` on the visit, so the flag is only set
    after delivery is confirmed.
    """
    raise NotImplementedError()

search_accounts_by_name abstractmethod

search_accounts_by_name(query, account_ids)

Search accounts by name.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def search_accounts_by_name(
    self, query: str, account_ids: Iterable[UUID]
) -> list[AccountSearchResult]:
    """Search accounts by name."""
    raise NotImplementedError()

search_users abstractmethod

search_users(search_term)

Search users by term (email, name, etc.).

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def search_users(self, search_term: str) -> list[UserSearchResult]:
    """Search users by term (email, name, etc.)."""
    raise NotImplementedError()

set_ssn_ntt_on_user abstractmethod

set_ssn_ntt_on_user(user_id, ssn, ntt)

Set SSN and NTT on user.

Source code in components/occupational_health/public/dependencies.py
@abstractmethod
def set_ssn_ntt_on_user(
    self, user_id: UserId, ssn: str | None, ntt: str | None
) -> None:
    """Set SSN and NTT on user."""
    raise NotImplementedError()

ProfileData dataclass

ProfileData(
    email=None,
    first_name=None,
    last_name=None,
    birth_date=None,
    language=None,
    phone_number=None,
    gender=None,
)

The profile data used to create a user and profile.

birth_date class-attribute instance-attribute

birth_date = None

email class-attribute instance-attribute

email = None

first_name class-attribute instance-attribute

first_name = None

gender class-attribute instance-attribute

gender = None

language class-attribute instance-attribute

language = None

last_name class-attribute instance-attribute

last_name = None

phone_number class-attribute instance-attribute

phone_number = None

UserSearchResult dataclass

UserSearchResult(
    id, first_name, last_name, email, birth_date
)

A user returned by search.

birth_date instance-attribute

birth_date

email instance-attribute

email

first_name instance-attribute

first_name

id instance-attribute

id

last_name instance-attribute

last_name

get_app_dependency

get_app_dependency()

Retrieves the occupational_health dependency

Source code in components/occupational_health/public/dependencies.py
def get_app_dependency() -> OccupationalHealthDependency:
    """Retrieves the occupational_health dependency"""
    from flask import current_app

    app = cast("CustomFlask", current_app)
    return cast(
        "OccupationalHealthDependency",
        app.get_component_dependency(OCCUPATIONAL_HEALTH_COMPONENT_NAME),
    )

set_app_dependency

set_app_dependency(dependency)

Sets the occupational_health dependency to the app

Source code in components/occupational_health/public/dependencies.py
def set_app_dependency(dependency: OccupationalHealthDependency) -> None:
    """Sets the occupational_health dependency to the app"""
    from flask import current_app

    cast("CustomFlask", current_app).add_component_dependency(
        OCCUPATIONAL_HEALTH_COMPONENT_NAME, dependency
    )

components.occupational_health.public.employment

employment_consumer

Note: Do not import local country code here, do it in the internal component after checking the country code.

occupational_health_employment_change_consumer

occupational_health_employment_change_consumer(
    employment_change, event_bus_orchestrator
)
Source code in components/occupational_health/public/employment/employment_consumer.py
def occupational_health_employment_change_consumer(  # noqa: D103
    employment_change: "EmploymentChange[FrExtendedValues]",
    event_bus_orchestrator: "EventBusOrchestrator",
) -> None:
    from components.employment.public.enums import CountryCode

    if employment_change.country_code != CountryCode.fr:
        return

    from components.occupational_health.internal.business_logic.actions.employment.on_employment_change import (
        on_employment_change,
    )

    on_employment_change(
        employment_change=employment_change,
        event_bus_orchestrator=event_bus_orchestrator,
    )

requires_siret_information

requires_siret_information

requires_siret_information(account_id)

Return whether the given account ID requires SIRET information to be provided when tracking employment movements.

SIRET is required iff at least one active (non-cancelled) affiliation strategy rule targets SIRET specifically (siret set, or is_missing_siret=True). Pure company_id rules and the wildcard rule are resolved without SIRET by the eligibility engine, so they don't trigger SIRET collection.

Source code in components/occupational_health/public/employment/requires_siret_information.py
def requires_siret_information(account_id: UUID) -> bool:
    """
    Return whether the given account ID requires SIRET information to be provided when tracking employment movements.

    SIRET is required iff at least one active (non-cancelled) affiliation strategy rule
    targets SIRET specifically (``siret`` set, or ``is_missing_siret=True``).
    Pure ``company_id`` rules and the wildcard rule are resolved without SIRET by the
    eligibility engine, so they don't trigger SIRET collection.
    """
    from components.occupational_health.public.queries.has_active_occupational_health_contract import (
        has_active_or_upcoming_occupational_health_contract,
    )

    account_id = AccountId(account_id)

    if not has_active_or_upcoming_occupational_health_contract(account_id):
        # No need to collect the SIRET if there is no active contract
        return False

    # SIRET is required as soon as a rule depends on it to be matched:
    # - `siret IS NOT NULL`: the rule targets a specific SIRET, we need it to compare.
    # - `is_missing_siret = True`: the rule checks SIRET presence (fires when SIRET is absent),
    #   so we still need to know whether SIRET was provided.
    # Pure `company_id` and wildcard rules are matched without reading SIRET.
    siret_dependent_rule = select(AffiliationStrategyRule.id).where(
        AffiliationStrategyRule.account_id == account_id,
        AffiliationStrategyRule.cancelled_at.is_(None),
        or_(
            AffiliationStrategyRule.siret.is_not(None),
            AffiliationStrategyRule.is_missing_siret.is_(True),
        ),
    )
    return current_session.scalar(siret_dependent_rule) is not None

components.occupational_health.public.entities

admin_workspace_action

AdminWorkspaceAction dataclass

AdminWorkspaceAction(
    id, label, category, status, siret=None
)

Bases: DataClassJsonMixin

An account-level AMT (action en milieu de travail) projected for the employer admin read-only view.

Carries only the fields an admin may see: identity, the canonical action label and its category, the simplified status, and the establishment SIRET. Medical notes, author, member-level data and prevention type are intentionally absent.

category instance-attribute
category
id instance-attribute
id
label instance-attribute
label
siret class-attribute instance-attribute
siret = None
status instance-attribute
status

AdminWorkspaceActionStatus

Bases: AlanBaseEnum

Simplified status of an AMT shown to admins, hiding the internal medical states.

DOING class-attribute instance-attribute
DOING = 'doing'
DONE class-attribute instance-attribute
DONE = 'done'
TODO class-attribute instance-attribute
TODO = 'todo'

billing

BillingStrategy

Bases: AlanBaseEnum

If the customer is getting an invoice per SIREN or per SIRET.

SIREN_BASED class-attribute instance-attribute
SIREN_BASED = 'siren_based'
SIRET_BASED class-attribute instance-attribute
SIRET_BASED = 'siret_based'

ContractInvoicesData dataclass

ContractInvoicesData(*, contract_ref, balance, invoices)

Bases: DataClassJsonMixin

Invoices grouped by contract ref, with a live contract-level balance.

balance instance-attribute
balance
contract_ref instance-attribute
contract_ref
invoices instance-attribute
invoices

CustomerAddress dataclass

CustomerAddress(street, postal_code, city, country_code)

Bases: DataClassJsonMixin

Postal address of a customer.

city instance-attribute
city
country_code instance-attribute
country_code
postal_code instance-attribute
postal_code
street instance-attribute
street

CustomerData dataclass

CustomerData(
    *, entity_name, siren, siret=None, postal_address
)

Bases: DataClassJsonMixin

Customer data needed for billing purposes.

billing_strategy property
billing_strategy

Return the billing strategy for this customer.

entity_name instance-attribute
entity_name
postal_address instance-attribute
postal_address
siren instance-attribute
siren
siret class-attribute instance-attribute
siret = None

InvoiceFileData dataclass

InvoiceFileData(*, file_name, file)

Data representing an invoice file.

file instance-attribute
file
file_name instance-attribute
file_name

InvoiceToGenerateData dataclass

InvoiceToGenerateData(
    *,
    billing_period,
    billing_year,
    contract_type,
    contract_ref,
    entity_name,
    siren,
    siret,
    contract_yearly_price_per_employee_in_cents,
    contract_has_installment_plan=None,
    references_of_employees_affiliated_over_the_year
)

Bases: DataClassJsonMixin

Data needed to generate an invoice for a customer.

billing_period instance-attribute
billing_period
billing_year instance-attribute
billing_year

The year of the billing period. END_OF_YEAR_REGUL invoices are issued on 01/01 of the year Y+1 but will hold the original year Y in billing_year.

contract_has_installment_plan class-attribute instance-attribute
contract_has_installment_plan = None
contract_ref instance-attribute
contract_ref
contract_type instance-attribute
contract_type
contract_yearly_price_per_employee_in_cents instance-attribute
contract_yearly_price_per_employee_in_cents
entity_name instance-attribute
entity_name
references_of_employees_affiliated_over_the_year instance-attribute
references_of_employees_affiliated_over_the_year

List of opaque references of all employees affiliated on this subscription/contract, during the entire year of the billing period (not just the specified billing period).

siren instance-attribute
siren
siret instance-attribute
siret

IssuedInvoiceData dataclass

IssuedInvoiceData(
    *,
    invoice_id,
    invoice_number,
    event_date,
    issued_date,
    due_date,
    total_invoice_amount,
    remaining_balance,
    previous_balance,
    amount_to_charge,
    contract_ref,
    entity_name,
    siren,
    siret,
    has_appendix,
    billing_reason,
    email_sent_at
)

Bases: DataClassJsonMixin

Data representing an actual issued invoice from the Invoice model. Used to display invoices in the company admin dashboard.

amount_to_charge instance-attribute
amount_to_charge
billing_reason instance-attribute
billing_reason
contract_ref instance-attribute
contract_ref
due_date instance-attribute
due_date
email_sent_at instance-attribute
email_sent_at
entity_name instance-attribute
entity_name
event_date instance-attribute
event_date
has_appendix instance-attribute
has_appendix
invoice_id instance-attribute
invoice_id
invoice_number instance-attribute
invoice_number
issued_date instance-attribute
issued_date
previous_balance instance-attribute
previous_balance
remaining_balance instance-attribute
remaining_balance
siren instance-attribute
siren
siret instance-attribute
siret
total_invoice_amount instance-attribute
total_invoice_amount

OccupationalHealthBillingPeriod

Bases: AlanBaseEnum

Billing period for occupational health services.

END_OF_YEAR_REGUL class-attribute instance-attribute
END_OF_YEAR_REGUL = 'END_OF_YEAR_REGUL'
Q1 class-attribute instance-attribute
Q1 = 'Q1'
Q2 class-attribute instance-attribute
Q2 = 'Q2'
Q3 class-attribute instance-attribute
Q3 = 'Q3'
Q4 class-attribute instance-attribute
Q4 = 'Q4'
from_date classmethod
from_date(d)

Return the billing period (Q1-Q4) for the quarter containing the given date.

Never returns END_OF_YEAR_REGUL — that period is not tied to a calendar quarter and must be handled separately.

Source code in components/occupational_health/public/entities/billing.py
@classmethod
def from_date(cls, d: date) -> "OccupationalHealthBillingPeriod":
    """Return the billing period (Q1-Q4) for the quarter containing the given date.

    Never returns END_OF_YEAR_REGUL — that period is not tied to a calendar
    quarter and must be handled separately.
    """
    quarter = (d.month - 1) // 3 + 1
    return {
        1: cls.Q1,
        2: cls.Q2,
        3: cls.Q3,
        4: cls.Q4,
    }[quarter]
to_validity_period
to_validity_period(year)

The period during which we'd consider affiliations for billing purposes.

Source code in components/occupational_health/public/entities/billing.py
def to_validity_period(self, year: int) -> ValidityPeriod:
    """The period during which we'd consider affiliations for billing purposes."""
    start_date = date(year, 1, 1)
    match self:
        case OccupationalHealthBillingPeriod.Q1:
            # Only consider the affiliations active on 01/01
            end_date = date(year, 1, 1)
        case OccupationalHealthBillingPeriod.Q2:
            # Bill for all affiliations active between 01/01 and 31/03
            end_date = date(year, 3, 31)
        case OccupationalHealthBillingPeriod.Q3:
            # In Q3 we bill for affiliations until the end of Q2
            end_date = date(year, 6, 30)
        case OccupationalHealthBillingPeriod.Q4:
            # In Q4 we bill for affiliations until the end of Q3
            end_date = date(year, 9, 30)
        case OccupationalHealthBillingPeriod.END_OF_YEAR_REGUL:
            # Then at the end of the year, we bill for all affiliations during the year
            end_date = date(year, 12, 31)
        case _:
            raise NotImplementedError(f"Unsupported billing period: {self}")

    return ValidityPeriod(
        start_date=start_date,
        end_date=end_date,
    )

company

CompanyAddress dataclass

CompanyAddress(street, postal_code, city)

Bases: DataClassJsonMixin

Address of a company.

city instance-attribute
city
postal_code instance-attribute
postal_code
street instance-attribute
street

dmst

AdministrativeProfile dataclass

AdministrativeProfile(
    *,
    ssn,
    health_statuses,
    risk_category,
    notes,
    medical_record_sharing_consent,
    medical_record_access_consent,
    health_data_sharing_consent,
    video_consultation_consent,
    orient_prevention_of_work_consent,
    transfer_dmst_to_spsti_consent,
    medical_record_sharing_consent_collected_at=None,
    medical_record_access_consent_collected_at=None,
    health_data_sharing_consent_collected_at=None,
    video_consultation_consent_collected_at=None,
    orient_prevention_of_work_consent_collected_at=None,
    transfer_dmst_to_spsti_consent_collected_at=None,
    personal_email=None,
    professional_email=None,
    phone_number=None
)

Bases: DataClassJsonMixin

Administrative information about a member's occupational health status.

Keep in sync with: frontend/apps/medical-software/app/hooks/useProfileDmstQuery.ts

Used in the HP medical app (DMST) for the "Administrative side modal"

health_data_sharing_consent
health_data_sharing_consent_collected_at = None
health_statuses instance-attribute
health_statuses
medical_record_access_consent
medical_record_access_consent_collected_at = None
medical_record_sharing_consent
medical_record_sharing_consent_collected_at = None
notes instance-attribute
notes
orient_prevention_of_work_consent
orient_prevention_of_work_consent_collected_at = None
personal_email class-attribute instance-attribute
personal_email = None
phone_number class-attribute instance-attribute
phone_number = None
professional_email class-attribute instance-attribute
professional_email = None
risk_category instance-attribute
risk_category
ssn instance-attribute
ssn
transfer_dmst_to_spsti_consent
transfer_dmst_to_spsti_consent_collected_at = None
video_consultation_consent
video_consultation_consent_collected_at = None

ContractType

Bases: AlanBaseEnum

Contract types from Présanse thesaurus level 1.

cdd class-attribute instance-attribute
cdd = 'cdd'
cdi class-attribute instance-attribute
cdi = 'cdi'
contrat_accompagnement_emploi class-attribute instance-attribute
contrat_accompagnement_emploi = (
    "contrat_accompagnement_emploi"
)
contrat_apprentissage class-attribute instance-attribute
contrat_apprentissage = 'contrat_apprentissage'
contrat_groupements_employeurs class-attribute instance-attribute
contrat_groupements_employeurs = (
    "contrat_groupements_employeurs"
)
contrat_initiative_emploi class-attribute instance-attribute
contrat_initiative_emploi = 'contrat_initiative_emploi'
contrat_intermittent class-attribute instance-attribute
contrat_intermittent = 'contrat_intermittent'
contrat_professionnalisation class-attribute instance-attribute
contrat_professionnalisation = (
    "contrat_professionnalisation"
)
contrat_saisonnier class-attribute instance-attribute
contrat_saisonnier = 'contrat_saisonnier'
contrat_temporaire class-attribute instance-attribute
contrat_temporaire = 'contrat_temporaire'
contrat_unique_insertion class-attribute instance-attribute
contrat_unique_insertion = 'contrat_unique_insertion'

CurriculumLaboris dataclass

CurriculumLaboris(*, occupational_medical_history, jobs)

Bases: DataClassJsonMixin

Member curriculum laboris (work information): previous job, description of work, tasks, etc.

Keep in sync with: frontend/apps/medical-software/app/hooks/useProfileDmstQuery.ts

Used in the HP medical app (DMST) for the "Professional" tab

jobs instance-attribute
jobs
occupational_medical_history instance-attribute
occupational_medical_history

Dmst dataclass

Dmst(
    *,
    occupational_health_profile_id,
    first_name,
    last_name,
    gender,
    birthdate,
    medical_secrecy_worker_record_id,
    curriculum_laboris,
    administrative_profile,
    health_history=None,
    google_drive_folder_id=None,
    active_affiliations=list()
)

Bases: DataClassJsonMixin

Information about a member, such as their name, gender, birthdate, etc.

Keep in sync with: frontend/apps/medical-software/app/hooks/useProfileDmstQuery.ts

Used in the HP medical app (DMST)

active_affiliations class-attribute instance-attribute
active_affiliations = field(default_factory=list)
administrative_profile instance-attribute
administrative_profile
birthdate instance-attribute
birthdate
curriculum_laboris instance-attribute
curriculum_laboris
first_name instance-attribute
first_name
gender instance-attribute
gender
google_drive_folder_id class-attribute instance-attribute
google_drive_folder_id = None
health_history class-attribute instance-attribute
health_history = None
last_name instance-attribute
last_name
medical_secrecy_worker_record_id instance-attribute
medical_secrecy_worker_record_id
occupational_health_profile_id instance-attribute
occupational_health_profile_id

MemberJob dataclass

MemberJob(
    *,
    id,
    title,
    start_date,
    end_date,
    employer,
    is_past_job,
    medical_secrecy_worker_job_id,
    description,
    working_hours,
    missions,
    physical_conditions,
    organizational_conditions,
    mental_conditions,
    tools,
    worn_equipment,
    risks_and_advice,
    contract_type,
    exposition_notations
)

Bases: DataClassJsonMixin

Represents a single job held by a member, including employment details and duration.

Keep in sync with: frontend/apps/medical-software/app/hooks/useProfileDmstQuery.ts

Used in the HP medical app (DMST) for the "Professional" tab

contract_type instance-attribute
contract_type
description instance-attribute
description
employer instance-attribute
employer
end_date instance-attribute
end_date
exposition_notations instance-attribute
exposition_notations
id instance-attribute
id
is_past_job instance-attribute
is_past_job
medical_secrecy_worker_job_id instance-attribute
medical_secrecy_worker_job_id
mental_conditions instance-attribute
mental_conditions
missions instance-attribute
missions
organizational_conditions instance-attribute
organizational_conditions
physical_conditions instance-attribute
physical_conditions
risks_and_advice instance-attribute
risks_and_advice
start_date instance-attribute
start_date
title instance-attribute
title
tools instance-attribute
tools
working_hours instance-attribute
working_hours
worn_equipment instance-attribute
worn_equipment

OccupationalMedicalHistory dataclass

OccupationalMedicalHistory(*, types, description)

Bases: DataClassJsonMixin

Represents the medical history pertaining to occupational health.

Note: this is different from the general HealthHistory, which is not tied to occupational (work) history

description instance-attribute
description
types instance-attribute
types

OccupationalMedicalHistoryType

Bases: AlanBaseEnum

Representing types of occupational medical history, can be multiple

occupational_accident class-attribute instance-attribute
occupational_accident = 'occupational_accident'
occupational_disease class-attribute instance-attribute
occupational_disease = 'occupational_disease'

ProfileAffiliation dataclass

ProfileAffiliation(*, account_id, name)

Bases: DataClassJsonMixin

Subscriber (billing) account linked to the member via active affiliations.

Keep in sync with: frontend/apps/medical-software/app/hooks/useProfileDmstQuery.ts

account_id instance-attribute
account_id
name instance-attribute
name

doctolib_db_matching

Entity dataclasses for the DB-based Doctolib export matching (reframed v3).

The matching produces a dry-run plan of updates for Ops to review. Nothing is applied here -- each bucket describes a change that would be made on apply.

Every plan item shares a similar shape so Ops (and the UI) can reason about them uniformly:

  • key -- stable, unique id for the item (drives selection + React keys)
  • csv_row -- the full Doctolib row we observed (None when the visit is absent from the export, e.g. missing in Doctolib)
  • matched_profile-- the OH profile we matched to (DB side) + how we matched
  • visit -- the affected/candidate DB visit (None when there is no slot)

plus a few bucket-specific extras. csv_row (Doctolib) next to matched_profile (our DB) is what lets Ops confirm a match makes sense.

A visit is keyed by visit_model plus exactly one of visit_id (on-demand, DB row) or gsheet_row_index (predictable, gsheet row).

AmbiguousVisitMatch dataclass

AmbiguousVisitMatch(
    key,
    csv_row=None,
    matched_profile=None,
    candidates=list(),
)

Bases: DataClassJsonMixin

Matched a member but the row could fit more than one of their visits.

candidates class-attribute instance-attribute
candidates = field(default_factory=list)
csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None

ApplyResultSummary dataclass

ApplyResultSummary(total, succeeded, failed, errors)

Bases: DataClassJsonMixin

Summary of a batch apply operation (per bucket).

errors instance-attribute
errors
failed instance-attribute
failed
succeeded instance-attribute
succeeded
total instance-attribute
total

BookedVisit dataclass

BookedVisit(
    key, csv_row=None, matched_profile=None, visit=None
)

Bases: DataClassJsonMixin

A member booked a pending invite -> fill the slot from the CSV row.

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None
visit class-attribute instance-attribute
visit = None

CancelledVisit dataclass

CancelledVisit(
    key,
    cancelled_status="",
    csv_row=None,
    matched_profile=None,
    visit=None,
)

Bases: DataClassJsonMixin

A Statut=Supprimé row linked to a visit. Status depends on supprime_par.

cancelled_status class-attribute instance-attribute
cancelled_status = ''
csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None
visit class-attribute instance-attribute
visit = None

ConsultationType

Bases: str, Enum

Type of medical consultation derived from the Doctolib appointment motif.

ONSITE class-attribute instance-attribute
ONSITE = 'onsite'
TELECONSULTATION class-attribute instance-attribute
TELECONSULTATION = 'teleconsultation'

DoctolibCsvRow dataclass

DoctolibCsvRow(
    csv_row_index,
    id,
    doctolib_patient_id,
    appointment_date,
    appointment_start,
    appointment_end,
    duration,
    agenda,
    reason,
    consultation_type,
    notes,
    created_at,
    updated_at,
    created_by,
    deleted_at,
    deleted_by,
    status,
    online_booking,
    civility,
    first_name,
    last_name,
    birth_name,
    birth_date,
    phone,
    email,
    address,
    postal_code,
    city,
)

Bases: DataClassJsonMixin

A single normalized row from the Doctolib CSV export.

address instance-attribute
address
agenda instance-attribute
agenda
appointment_date instance-attribute
appointment_date
appointment_end instance-attribute
appointment_end
appointment_start instance-attribute
appointment_start
birth_date instance-attribute
birth_date
birth_name instance-attribute
birth_name
city instance-attribute
city
civility instance-attribute
civility
consultation_type class-attribute instance-attribute
consultation_type = field(
    metadata={
        "marshmallow_field": Enum(
            ConsultationType, by_value=True
        )
    }
)
created_at instance-attribute
created_at
created_by instance-attribute
created_by
csv_row_index instance-attribute
csv_row_index
deleted_at instance-attribute
deleted_at
deleted_by instance-attribute
deleted_by
doctolib_patient_id instance-attribute
doctolib_patient_id
duration instance-attribute
duration
email instance-attribute
email
first_name instance-attribute
first_name
id instance-attribute
id
last_name instance-attribute
last_name
notes instance-attribute
notes
online_booking instance-attribute
online_booking
phone instance-attribute
phone
postal_code instance-attribute
postal_code
reason instance-attribute
reason
status instance-attribute
status
updated_at instance-attribute
updated_at

DoctolibDbMatchingPlan dataclass

DoctolibDbMatchingPlan(
    profile_doctolib_id_sets,
    profile_doctolib_id_conflicts,
    visit_doctolib_id_sets,
    visit_doctolib_id_conflicts,
    booked_visits,
    reschedulings,
    cancellations,
    missing_in_doctolib_visits,
    unsolicited_visits,
    unmatched_rows,
    ambiguous_visit_matches,
    stats,
)

Bases: DataClassJsonMixin

The full dry-run plan returned to the frontend. Nothing is applied.

ambiguous_visit_matches instance-attribute
ambiguous_visit_matches
booked_visits instance-attribute
booked_visits
cancellations instance-attribute
cancellations
missing_in_doctolib_visits instance-attribute
missing_in_doctolib_visits
profile_doctolib_id_conflicts instance-attribute
profile_doctolib_id_conflicts
profile_doctolib_id_sets instance-attribute
profile_doctolib_id_sets
reschedulings instance-attribute
reschedulings
stats instance-attribute
stats
unmatched_rows instance-attribute
unmatched_rows
unsolicited_visits instance-attribute
unsolicited_visits
visit_doctolib_id_conflicts instance-attribute
visit_doctolib_id_conflicts
visit_doctolib_id_sets instance-attribute
visit_doctolib_id_sets

MatchedProfile dataclass

MatchedProfile(
    profile_id,
    user_id,
    first_name,
    last_name,
    email,
    birth_date,
    doctolib_patient_id,
    strategy,
    priority,
)

Bases: DataClassJsonMixin

The OH profile a Doctolib row matched to (DB side), and how it matched.

Shown next to the item's csv_row so Ops can confirm the match. strategy is the cascade strategy (email_exact, name_birth_exact, ...), doctolib_visit_id when linked by appointment id, or db_only when the member comes from the DB with no row this run (missing in Doctolib).

birth_date instance-attribute
birth_date
doctolib_patient_id instance-attribute
doctolib_patient_id
email instance-attribute
email
first_name instance-attribute
first_name
last_name instance-attribute
last_name
priority instance-attribute
priority
profile_id instance-attribute
profile_id
strategy instance-attribute
strategy
user_id instance-attribute
user_id

MissingInDoctolibVisit dataclass

MissingInDoctolibVisit(
    key, matched_profile=None, visit=None
)

Bases: DataClassJsonMixin

A still-pending booked visit the export doesn't account for. DB-only (no csv_row).

Either previously synced but its doctolib_visit_id is absent (likely postponed beyond the window or cancelled in Doctolib), or never linked there (no doctolib_visit_id and no matching row -- likely never created in Doctolib).

key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None
visit class-attribute instance-attribute
visit = None

ProfileDoctolibIdConflict dataclass

ProfileDoctolibIdConflict(
    key, csv_row=None, matched_profile=None
)

Bases: DataClassJsonMixin

CSV patient id differs from the one already on the profile (review-only).

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None

ProfileDoctolibIdSet dataclass

ProfileDoctolibIdSet(
    key, csv_row=None, matched_profile=None
)

Bases: DataClassJsonMixin

The OH profile has no doctolib_patient_id yet -- set it from the CSV row.

Apply target: matched_profile.profile_id; new value: csv_row.doctolib_patient_id.

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None

RescheduledVisit dataclass

RescheduledVisit(
    key, csv_row=None, matched_profile=None, visit=None
)

Bases: DataClassJsonMixin

A matched visit whose date/hour/HP changed (old = visit, new = csv_row).

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None
visit class-attribute instance-attribute
visit = None

UnmatchedRow dataclass

UnmatchedRow(key, reason, csv_row=None)

Bases: DataClassJsonMixin

A Doctolib row that matched no OH profile (no_match) or matched ambiguously.

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
reason instance-attribute
reason

UnsolicitedVisit dataclass

UnsolicitedVisit(key, csv_row=None, matched_profile=None)

Bases: DataClassJsonMixin

Matched a member but no invite/visit slot (booked without invite / unlogged on-demand).

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None

VisitDoctolibIdConflict dataclass

VisitDoctolibIdConflict(
    key, csv_row=None, matched_profile=None, visit=None
)

Bases: DataClassJsonMixin

CSV visit id differs from the one already on the matched visit (review-only).

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None
visit class-attribute instance-attribute
visit = None

VisitDoctolibIdSet dataclass

VisitDoctolibIdSet(
    key, csv_row=None, matched_profile=None, visit=None
)

Bases: DataClassJsonMixin

The matched visit has no doctolib_visit_id yet -- set it from csv_row.id.

csv_row class-attribute instance-attribute
csv_row = None
key instance-attribute
key
matched_profile class-attribute instance-attribute
matched_profile = None
visit class-attribute instance-attribute
visit = None

VisitRef dataclass

VisitRef(
    visit_model,
    visit_id,
    gsheet_row_index,
    doctolib_visit_id,
    date_planned,
    hour_booked,
    hp,
    visit_type,
    visit_setup,
)

Bases: DataClassJsonMixin

A DB visit (affected or candidate).

Keyed by visit_id (on-demand) or gsheet_row_index (predictable). The schedule fields are the current DB/gsheet values (the "before" side).

date_planned instance-attribute
date_planned
doctolib_visit_id instance-attribute
doctolib_visit_id
gsheet_row_index instance-attribute
gsheet_row_index
hour_booked instance-attribute
hour_booked
hp instance-attribute
hp
visit_id instance-attribute
visit_id
visit_model instance-attribute
visit_model
visit_setup instance-attribute
visit_setup
visit_type instance-attribute
visit_type

dpae

DPAE-specific public entities.

DpaeListItem dataclass

DpaeListItem(id, file_name, row_count, created_at)

Bases: DataClassJsonMixin

Summary of a past DPAE upload.

created_at instance-attribute
created_at
file_name instance-attribute
file_name
id instance-attribute
id
row_count instance-attribute
row_count

MemberCandidate dataclass

MemberCandidate(
    user_id,
    last_name,
    first_name,
    birth_date,
    gender,
    siren,
    start_date,
)

A potential member match from the DB for DPAE matching.

birth_date instance-attribute
birth_date
first_name instance-attribute
first_name
gender instance-attribute
gender
last_name instance-attribute
last_name
siren instance-attribute
siren
start_date instance-attribute
start_date
user_id instance-attribute
user_id

health_history

Health data entities for occupational health worker medical records.

These entities correspond to the TypeScript interfaces in: frontend/apps/medical-software/app/hooks/types.ts

NOTE: This is a duplicate of components.medical_secrecy.internal.entities.occupational_health_worker_medical_record_health_history to avoid cross-component dependencies. Keep in sync manually. TODO: @david.barthelemy: clean this after talking with Alexandre and Mickael

AlcoholStatus

Bases: AlanBaseEnum

Alcohol consumption status.

addicted class-attribute instance-attribute
addicted = 'addicted'
casual class-attribute instance-attribute
casual = 'casual'
deprived class-attribute instance-attribute
deprived = 'deprived'
no class-attribute instance-attribute
no = 'no'

AllergyItem dataclass

AllergyItem(
    *,
    clinical_thesaurus_entry_id=None,
    label=None,
    clinical_thesaurus_entry=None,
    status=None,
    start_date_year=None,
    observation=None
)

Bases: ClinicalThesaurusReference

Allergy item.

Corresponds to AllergyItem TypeScript interface.

observation class-attribute instance-attribute
observation = None
start_date_year class-attribute instance-attribute
start_date_year = None
status class-attribute instance-attribute
status = None

AllergyStatus

Bases: AlanBaseEnum

Status of an allergy.

cured class-attribute instance-attribute
cured = 'cured'
in_treatment class-attribute instance-attribute
in_treatment = 'in_treatment'
untreated class-attribute instance-attribute
untreated = 'untreated'

ClinicalThesaurusReference dataclass

ClinicalThesaurusReference(
    *,
    clinical_thesaurus_entry_id=None,
    label=None,
    clinical_thesaurus_entry=None
)

Bases: DataClassJsonMixin

Mixin for an item that may reference a clinical thesaurus entry.

Note: clinical_thesaurus_entry_id wins on write over label. In such cases, the label is not written.

clinical_thesaurus_entry class-attribute instance-attribute
clinical_thesaurus_entry = field(
    default=None,
    metadata=config(exclude=lambda val: val is None),
)
clinical_thesaurus_entry_id class-attribute instance-attribute
clinical_thesaurus_entry_id = None
label class-attribute instance-attribute
label = None
normalize_for_write
normalize_for_write()

Drop read-only enrichment and clear label when an entry id is set.

Source code in components/occupational_health/public/entities/health_history.py
def normalize_for_write(self) -> None:
    """Drop read-only enrichment and clear label when an entry id is set."""
    self.clinical_thesaurus_entry = None
    if self.clinical_thesaurus_entry_id is not None:
        self.label = None

EyeHealthItem dataclass

EyeHealthItem(
    *,
    optical_correction=None,
    ophthalmologic_follow_up=None,
    observation=None
)

Bases: DataClassJsonMixin

Corresponds to EyeHealthItem TypeScript interface.

observation class-attribute instance-attribute
observation = None
ophthalmologic_follow_up class-attribute instance-attribute
ophthalmologic_follow_up = None
optical_correction class-attribute instance-attribute
optical_correction = None

FamilyHistoryItem dataclass

FamilyHistoryItem(
    *,
    clinical_thesaurus_entry_id=None,
    label=None,
    clinical_thesaurus_entry=None,
    family_members=list(),
    observation=None
)

Bases: ClinicalThesaurusReference

Family history item.

Corresponds to FamilyHistoryItem TypeScript interface.

family_members class-attribute instance-attribute
family_members = field(default_factory=list)
observation class-attribute instance-attribute
observation = None

FamilyHistoryMember dataclass

FamilyHistoryMember(*, age=None, status=None)

Bases: DataClassJsonMixin

Family history member information.

Corresponds to FamilyHistoryMember TypeScript interface.

age class-attribute instance-attribute
age = None
status class-attribute instance-attribute
status = None

FamilyStatus

Bases: AlanBaseEnum

Family member status.

aunt class-attribute instance-attribute
aunt = 'aunt'
brother class-attribute instance-attribute
brother = 'brother'
father class-attribute instance-attribute
father = 'father'
grand_father class-attribute instance-attribute
grand_father = 'grand_father'
grand_mother class-attribute instance-attribute
grand_mother = 'grand_mother'
great_grand_father class-attribute instance-attribute
great_grand_father = 'great_grand_father'
great_grand_mother class-attribute instance-attribute
great_grand_mother = 'great_grand_mother'
mother class-attribute instance-attribute
mother = 'mother'
other class-attribute instance-attribute
other = 'other'
sister class-attribute instance-attribute
sister = 'sister'
uncle class-attribute instance-attribute
uncle = 'uncle'

HealthHistory dataclass

HealthHistory(
    *,
    medical_surgical_history=list(),
    allergies=list(),
    treatment=None,
    vaccine=None,
    family_history=list(),
    lifestyle=None,
    transport_mode=None,
    personal_situation=None,
    gynecological_follow_up=None,
    gynecological_observation=None,
    contraceptives=None,
    eye_health=None,
    medical_observation=None,
    health_measures=list()
)

Bases: DataClassJsonMixin

Health history for occupational health worker medical record.

This dataclass contains all health-related information that needs to be encrypted and stored in the OccupationalHealthWorkerMedicalRecord.health_history field.

All fields correspond to the forms defined in: frontend/apps/medical-software/app/pages/MemberHealthTab.tsx

allergies class-attribute instance-attribute
allergies = field(default_factory=list)
contraceptives class-attribute instance-attribute
contraceptives = None
eye_health class-attribute instance-attribute
eye_health = None
family_history class-attribute instance-attribute
family_history = field(default_factory=list)
gynecological_follow_up class-attribute instance-attribute
gynecological_follow_up = None
gynecological_observation class-attribute instance-attribute
gynecological_observation = None
health_measures class-attribute instance-attribute
health_measures = field(default_factory=list)
lifestyle class-attribute instance-attribute
lifestyle = None
medical_observation class-attribute instance-attribute
medical_observation = None
medical_surgical_history class-attribute instance-attribute
medical_surgical_history = field(default_factory=list)
personal_situation class-attribute instance-attribute
personal_situation = None
transport_mode class-attribute instance-attribute
transport_mode = None
treatment class-attribute instance-attribute
treatment = None
vaccine class-attribute instance-attribute
vaccine = None

LifestyleItem dataclass

LifestyleItem(
    *,
    alcohol_status=None,
    alcohol_observation=None,
    smoking_status=None,
    smoking_observation=None,
    other_addictive_behaviors=None,
    physical_activity_status=None,
    physical_activity_observations=None,
    food=None,
    sleep=None,
    lifestyle_observations=None
)

Bases: DataClassJsonMixin

Lifestyle information.

Corresponds to LifestyleItem TypeScript interface.

alcohol_observation class-attribute instance-attribute
alcohol_observation = None
alcohol_status class-attribute instance-attribute
alcohol_status = None
food class-attribute instance-attribute
food = None
lifestyle_observations class-attribute instance-attribute
lifestyle_observations = None
other_addictive_behaviors class-attribute instance-attribute
other_addictive_behaviors = None
physical_activity_observations class-attribute instance-attribute
physical_activity_observations = None
physical_activity_status class-attribute instance-attribute
physical_activity_status = None
sleep class-attribute instance-attribute
sleep = None
smoking_observation class-attribute instance-attribute
smoking_observation = None
smoking_status class-attribute instance-attribute
smoking_status = None

LivingStatus

Bases: AlanBaseEnum

Living status.

alone class-attribute instance-attribute
alone = 'alone'
couple class-attribute instance-attribute
couple = 'couple'
family class-attribute instance-attribute
family = 'family'
house_share class-attribute instance-attribute
house_share = 'house_share'
other class-attribute instance-attribute
other = 'other'
single_parent_family class-attribute instance-attribute
single_parent_family = 'single_parent_family'

MedicalSurgicalHistoryItem dataclass

MedicalSurgicalHistoryItem(
    *,
    clinical_thesaurus_entry_id=None,
    label=None,
    clinical_thesaurus_entry=None,
    status=None,
    start_date_year=None,
    observation=None
)

Bases: ClinicalThesaurusReference

Medical and surgical history item.

Corresponds to MedicalSurgicalHistoryItem TypeScript interface.

observation class-attribute instance-attribute
observation = None
start_date_year class-attribute instance-attribute
start_date_year = None
status class-attribute instance-attribute
status = None

MedicalSurgicalHistoryStatus

Bases: AlanBaseEnum

Status of a medical or surgical history item.

cured class-attribute instance-attribute
cured = 'cured'
in_treatment class-attribute instance-attribute
in_treatment = 'in_treatment'
relapse class-attribute instance-attribute
relapse = 'relapse'
remission class-attribute instance-attribute
remission = 'remission'

PersonalSituationItem dataclass

PersonalSituationItem(
    *, living_status=None, number_of_children=None
)

Bases: DataClassJsonMixin

Corresponds to PersonalSituationItem TypeScript interface.

living_status class-attribute instance-attribute
living_status = None
number_of_children class-attribute instance-attribute
number_of_children = None

PhysicalActivityStatus

Bases: AlanBaseEnum

Physical activity status.

less_than_1 class-attribute instance-attribute
less_than_1 = 'less_than_1'
less_than_2 class-attribute instance-attribute
less_than_2 = 'less_than_2'
more_than_2 class-attribute instance-attribute
more_than_2 = 'more_than_2'
no class-attribute instance-attribute
no = 'no'

SmokingStatus

Bases: AlanBaseEnum

Smoking status.

deprived class-attribute instance-attribute
deprived = 'deprived'
less_than_5 class-attribute instance-attribute
less_than_5 = 'less_than_5'
less_than_9 class-attribute instance-attribute
less_than_9 = 'less_than_9'
more_than_9 class-attribute instance-attribute
more_than_9 = 'more_than_9'
no class-attribute instance-attribute
no = 'no'
passive class-attribute instance-attribute
passive = 'passive'

TransportModeItem dataclass

TransportModeItem(
    *,
    travel_time=None,
    transport_mode=None,
    is_full_remote=None
)

Bases: DataClassJsonMixin

Corresponds to TransportModeItem TypeScript interface.

is_full_remote class-attribute instance-attribute
is_full_remote = None
transport_mode class-attribute instance-attribute
transport_mode = None
travel_time class-attribute instance-attribute
travel_time = None

TreatmentItem dataclass

TreatmentItem(*, description=None)

Bases: DataClassJsonMixin

Treatment item.

Corresponds to TreatmentItem TypeScript interface.

description class-attribute instance-attribute
description = None

VaccineItem dataclass

VaccineItem(*, status=None, description=None)

Bases: DataClassJsonMixin

Vaccination item.

Corresponds to VaccineItem TypeScript interface.

description class-attribute instance-attribute
description = None
status class-attribute instance-attribute
status = None

VaccineStatus

Bases: AlanBaseEnum

Status of vaccination.

need_update class-attribute instance-attribute
need_update = 'need_update'
unknown class-attribute instance-attribute
unknown = 'unknown'
up_to_date class-attribute instance-attribute
up_to_date = 'up_to_date'

health_measure

Health measure entity for occupational health.

NOTE: This is a duplicate of components.medical_secrecy.internal.entities.health_measure.HealthMeasureItem to avoid cross-component dependencies. Keep in sync manually.

HealthMeasureItem dataclass

HealthMeasureItem(
    *, id=None, measure_type, measure_value, measure_date
)

Bases: DataClassJsonMixin

Single health measure stored as individual encrypted columns in DB.

Corresponds to HealthMeasureItem TypeScript interface.

id class-attribute instance-attribute
id = None
measure_date instance-attribute
measure_date
measure_type instance-attribute
measure_type
measure_value instance-attribute
measure_value

health_professional

HealthProfessional dataclass

HealthProfessional(
    id, first_name, last_name, role, gender=None
)

Bases: DataClassJsonMixin

A health professional entity.

first_name instance-attribute
first_name
gender class-attribute instance-attribute
gender = field(default=None)
id instance-attribute
id
last_name instance-attribute
last_name
role instance-attribute
role

member_info

MemberInfo dataclass

MemberInfo(
    *,
    occupational_health_profile_id,
    user_id,
    member_first_name,
    member_last_name,
    member_gender,
    member_birthdate
)

Bases: DataClassJsonMixin

Information about a member, such as their name, gender, birthdate, etc.

Note: we'll be using other structures for the rest of the data e.g. MemberActivity, etc.

Used in the HP medical app.

member_birthdate instance-attribute
member_birthdate
member_first_name instance-attribute
member_first_name
member_gender instance-attribute
member_gender
member_last_name instance-attribute
member_last_name
occupational_health_profile_id instance-attribute
occupational_health_profile_id
user_id instance-attribute
user_id

search

SearchResult dataclass

SearchResult(*, members, accounts, companies)

Bases: DataClassJsonMixin

Represents the result of a search operation for members, accounts, companies

accounts instance-attribute
accounts
companies instance-attribute
companies
members instance-attribute
members

subscribers

ContractDocument dataclass

ContractDocument(
    *, id, name, company_id, company_name, uploaded_at
)

Bases: DataClassJsonMixin

Contract document signed between an Occupational Health subscriber and Prévenir.

company_id instance-attribute
company_id
company_name instance-attribute
company_name
id instance-attribute
id
name instance-attribute
name
uploaded_at instance-attribute
uploaded_at

CreateSubscriberDocument dataclass

CreateSubscriberDocument(
    *, name, type, file, account_id, siret
)

Bases: DataClassJsonMixin

Create a new subscriber document

account_id instance-attribute
account_id
file instance-attribute
file
name instance-attribute
name
siret instance-attribute
siret
type instance-attribute
type

SubscriberDocument dataclass

SubscriberDocument(
    *,
    id,
    public_uri,
    filename,
    uploaded_at,
    account_id,
    siret,
    company_name,
    company_address,
    document_type,
    mime_type
)

Bases: DataClassJsonMixin

Subscriber document

account_id instance-attribute
account_id
company_address instance-attribute
company_address
company_name instance-attribute
company_name
document_type instance-attribute
document_type
filename instance-attribute
filename
id instance-attribute
id
mime_type instance-attribute
mime_type
public_uri instance-attribute
public_uri
siret instance-attribute
siret
uploaded_at instance-attribute
uploaded_at

SubscriberEstablishment dataclass

SubscriberEstablishment(
    *,
    siret,
    name,
    city,
    postal_code,
    street_number=None,
    street_name=None
)

Bases: DataClassJsonMixin

city instance-attribute
city
name instance-attribute
name
postal_code instance-attribute
postal_code
siret instance-attribute
siret
street_name class-attribute instance-attribute
street_name = None
street_number class-attribute instance-attribute
street_number = None

UpdateSubscriberDocument dataclass

UpdateSubscriberDocument(
    *, id, account_id, name, document_type, siret
)

Bases: DataClassJsonMixin

Update an existing subscriber document

account_id instance-attribute
account_id
document_type instance-attribute
document_type
id instance-attribute
id
name instance-attribute
name
siret instance-attribute
siret

thesaurus

ThesaurusMean dataclass

ThesaurusMean(
    *, id, label, key=None, is_displayed=None, category=None
)

Bases: DataClassJsonMixin

The public dataclass of a ThesaurusMean from Presanse

category class-attribute instance-attribute
category = None
id instance-attribute
id
is_displayed class-attribute instance-attribute
is_displayed = None
key class-attribute instance-attribute
key = None
label instance-attribute
label

ThesaurusOccupationalExposure dataclass

ThesaurusOccupationalExposure(
    *,
    notation,
    label,
    dc_type,
    status,
    created_at,
    version,
    line_number,
    risk_category,
    parent_notation,
    class_label=None,
    subclass_label=None,
    class_notation=None,
    subclass_notation=None
)

Bases: DataClassJsonMixin

A TEP (Thésaurus des Expositions Professionnelles) exposition entry.

class_label class-attribute instance-attribute
class_label = None
class_notation class-attribute instance-attribute
class_notation = None
created_at instance-attribute
created_at
dc_type instance-attribute
dc_type
label instance-attribute
label
line_number instance-attribute
line_number
notation instance-attribute
notation
parent_notation instance-attribute
parent_notation
risk_category instance-attribute
risk_category
status instance-attribute
status
subclass_label class-attribute instance-attribute
subclass_label = None
subclass_notation class-attribute instance-attribute
subclass_notation = None
version instance-attribute
version

visit

ON_DEMAND_VISIT_MODEL module-attribute

ON_DEMAND_VISIT_MODEL = 'OnDemandVisit'

PREDICTABLE_VISIT_MODEL module-attribute

PREDICTABLE_VISIT_MODEL = 'PredictableVisit'

PlannedVisit dataclass

PlannedVisit(profile_id, planned_at, minutes_difference)

Bases: DataClassJsonMixin

Planned visit information

minutes_difference instance-attribute
minutes_difference
planned_at instance-attribute
planned_at
profile_id instance-attribute
profile_id

PlannedVisitWithDetails dataclass

PlannedVisitWithDetails(
    *,
    visit_id,
    profile_id,
    planned_at,
    visit_table,
    visit_type,
    visit_setup,
    health_professional_name
)

Bases: DataClassJsonMixin

Planned visit with extra details for monitoring exports.

health_professional_name instance-attribute
health_professional_name
planned_at instance-attribute
planned_at
profile_id instance-attribute
profile_id
visit_id instance-attribute
visit_id
visit_setup instance-attribute
visit_setup
visit_table instance-attribute
visit_table
visit_type instance-attribute
visit_type

VisitInfo dataclass

VisitInfo(
    *,
    visit_id,
    member_first_name,
    member_last_name,
    member_gender,
    member_birthdate,
    visit_date,
    visit_hour_booked,
    visit_hour_end,
    visit_type,
    health_professional_name,
    health_professional_id,
    visit_status,
    occupational_health_profile_id,
    visit_setup,
    account_id=None,
    visit_model,
    has_individual_measures=None,
    should_generate_individual_measures_doc=None,
    individual_measures_comment=None,
    individual_measures_start_date=None,
    individual_measures_end_date=None,
    are_individual_measures_reasonable_accommodation=None,
    sir_monitoring_date=None,
    declare_unfit=None,
    orient_prevenir=None,
    orient_prevenir_hp_id=None,
    orient_prevenir_comment=None,
    refer_to_doctor=None,
    refer_to_doctor_name=None,
    refer_to_doctor_comment=None,
    recommend_rps=None,
    recommend_tms=None,
    recommend_cmb=None,
    next_visit_date=None,
    if_avis_comments=None,
    ins_status=None,
    is_archived=False,
    avis_aptitude_to_fill=False,
    attestation_de_suivi_to_fill=False,
    doc_generated=False,
    individual_measure_doc_generated=False,
    arrived_in_waiting_room_at=None,
    job_title=None,
    affiliation_names=list(),
    risk_category=None
)

Bases: DataClassJsonMixin

Information about a visit scheduled for a specific date.

Used in the HP medical app.

account_id class-attribute instance-attribute
account_id = None
affiliation_names class-attribute instance-attribute
affiliation_names = field(default_factory=list)
are_individual_measures_reasonable_accommodation class-attribute instance-attribute
are_individual_measures_reasonable_accommodation = None
arrived_in_waiting_room_at class-attribute instance-attribute
arrived_in_waiting_room_at = None
attestation_de_suivi_to_fill class-attribute instance-attribute
attestation_de_suivi_to_fill = False
avis_aptitude_to_fill class-attribute instance-attribute
avis_aptitude_to_fill = False
declare_unfit class-attribute instance-attribute
declare_unfit = None
doc_generated class-attribute instance-attribute
doc_generated = False
has_individual_measures class-attribute instance-attribute
has_individual_measures = None
health_professional_id instance-attribute
health_professional_id
health_professional_name instance-attribute
health_professional_name
if_avis_comments class-attribute instance-attribute
if_avis_comments = None
individual_measure_doc_generated class-attribute instance-attribute
individual_measure_doc_generated = False
individual_measures_comment class-attribute instance-attribute
individual_measures_comment = None
individual_measures_end_date class-attribute instance-attribute
individual_measures_end_date = None
individual_measures_start_date class-attribute instance-attribute
individual_measures_start_date = None
ins_status class-attribute instance-attribute
ins_status = None
is_archived class-attribute instance-attribute
is_archived = False
job_title class-attribute instance-attribute
job_title = None
member_birthdate instance-attribute
member_birthdate
member_first_name instance-attribute
member_first_name
member_gender instance-attribute
member_gender
member_last_name instance-attribute
member_last_name
next_visit_date class-attribute instance-attribute
next_visit_date = None
occupational_health_profile_id instance-attribute
occupational_health_profile_id
orient_prevenir class-attribute instance-attribute
orient_prevenir = None
orient_prevenir_comment class-attribute instance-attribute
orient_prevenir_comment = None
orient_prevenir_hp_id class-attribute instance-attribute
orient_prevenir_hp_id = None
recommend_cmb class-attribute instance-attribute
recommend_cmb = None
recommend_rps class-attribute instance-attribute
recommend_rps = None
recommend_tms class-attribute instance-attribute
recommend_tms = None
refer_to_doctor class-attribute instance-attribute
refer_to_doctor = None
refer_to_doctor_comment class-attribute instance-attribute
refer_to_doctor_comment = None
refer_to_doctor_name class-attribute instance-attribute
refer_to_doctor_name = None
risk_category class-attribute instance-attribute
risk_category = None
should_generate_individual_measures_doc class-attribute instance-attribute
should_generate_individual_measures_doc = None
sir_monitoring_date class-attribute instance-attribute
sir_monitoring_date = None
visit_date instance-attribute
visit_date
visit_hour_booked instance-attribute
visit_hour_booked
visit_hour_end instance-attribute
visit_hour_end
visit_id instance-attribute
visit_id
visit_model instance-attribute
visit_model
visit_setup instance-attribute
visit_setup
visit_status instance-attribute
visit_status
visit_type instance-attribute
visit_type

workspace_action

WorkspaceAction dataclass

WorkspaceAction(
    id,
    title,
    target_type,
    status,
    created_at,
    siret=None,
    company_name=None,
    company_address=None,
    members_count=None,
    member_full_name=None,
    job_title=None,
    health_professional_fullname=None,
    profile_id=None,
    account_id=None,
    account_name=None,
    eta_date=None,
    prevention_type=None,
    note=None,
    thesaurus_mean_key=None,
    subscriber_document=None,
    company_workplace_planned_visit_date=None,
    completed_date=None,
    company_workplace_visit_status=None,
    time_spent_in_minutes=None,
)

Bases: DataClassJsonMixin

Represents a WorkspaceAction (an AMT in France) in the context of occupational health.

account_id class-attribute instance-attribute
account_id = None
account_name class-attribute instance-attribute
account_name = None
company_address class-attribute instance-attribute
company_address = None
company_name class-attribute instance-attribute
company_name = None
company_workplace_planned_visit_date class-attribute instance-attribute
company_workplace_planned_visit_date = None
company_workplace_visit_status class-attribute instance-attribute
company_workplace_visit_status = None
completed_date class-attribute instance-attribute
completed_date = None
created_at instance-attribute
created_at
eta_date class-attribute instance-attribute
eta_date = None
health_professional_fullname class-attribute instance-attribute
health_professional_fullname = None
id instance-attribute
id
job_title class-attribute instance-attribute
job_title = None
member_full_name class-attribute instance-attribute
member_full_name = None
members_count class-attribute instance-attribute
members_count = None
note class-attribute instance-attribute
note = None
prevention_type class-attribute instance-attribute
prevention_type = None
profile_id class-attribute instance-attribute
profile_id = None
siret class-attribute instance-attribute
siret = None
status instance-attribute
status
subscriber_document class-attribute instance-attribute
subscriber_document = None
target_type instance-attribute
target_type
thesaurus_mean_key class-attribute instance-attribute
thesaurus_mean_key = None
time_spent_in_minutes class-attribute instance-attribute
time_spent_in_minutes = None
title instance-attribute
title

WorkspaceActionCompanyWorkplaceVisitStatus

Bases: AlanBaseEnum

AWAITING_RESPONSE class-attribute instance-attribute
AWAITING_RESPONSE = 'awaiting_response'
NO_RESPONSE class-attribute instance-attribute
NO_RESPONSE = 'no_response'
TO_CONTACT class-attribute instance-attribute
TO_CONTACT = 'to_contact'
VISIT_COMPLETED class-attribute instance-attribute
VISIT_COMPLETED = 'visit_completed'
VISIT_PLANNED class-attribute instance-attribute
VISIT_PLANNED = 'visit_planned'

WorkspaceActionPreventionType

Bases: AlanBaseEnum

PRIMARY class-attribute instance-attribute
PRIMARY = 'primary'
SECONDARY class-attribute instance-attribute
SECONDARY = 'secondary'
TERTIARY class-attribute instance-attribute
TERTIARY = 'tertiary'

WorkspaceActionStatus

Bases: AlanBaseEnum

CREATED_BY_PREVIOUS_SPSTI class-attribute instance-attribute
CREATED_BY_PREVIOUS_SPSTI = 'created_by_previous_spsti'
DOCTOR_VALIDATING class-attribute instance-attribute
DOCTOR_VALIDATING = 'doctor_validating'
IN_DRAFT class-attribute instance-attribute
IN_DRAFT = 'in_draft'
IN_PROGRESS class-attribute instance-attribute
IN_PROGRESS = 'in_progress'
SENT_TO_CLIENT class-attribute instance-attribute
SENT_TO_CLIENT = 'sent_to_client'
TODO class-attribute instance-attribute
TODO = 'todo'
VALIDATED class-attribute instance-attribute
VALIDATED = 'validated'
VALIDATED_BY_DOCTOR class-attribute instance-attribute
VALIDATED_BY_DOCTOR = 'validated_by_doctor'

WorkspaceActionSubscriberDocument dataclass

WorkspaceActionSubscriberDocument(
    id, name, uploaded_at, public_uri, mime_type=None
)

Bases: DataClassJsonMixin

The live subscriber document currently linked to a WorkspaceAction.

id instance-attribute
id
mime_type class-attribute instance-attribute
mime_type = None
name instance-attribute
name
public_uri instance-attribute
public_uri
uploaded_at instance-attribute
uploaded_at

WorkspaceActionType

Bases: AlanBaseEnum

COMPANY class-attribute instance-attribute
COMPANY = 'company'
MEMBER class-attribute instance-attribute
MEMBER = 'member'

components.occupational_health.public.enums

AffiliationDecision

Bases: AlanBaseEnum

Possible decisions for occupational health affiliation.

AFFILIATED class-attribute instance-attribute

AFFILIATED = 'affiliated'

NOT_AFFILIATED class-attribute instance-attribute

NOT_AFFILIATED = 'not_affiliated'

REQUIRE_MANUAL_REVIEW class-attribute instance-attribute

REQUIRE_MANUAL_REVIEW = 'require_manual_review'

HealthStatus

Bases: AlanBaseEnum

Corresponds to "type de suivi" in the DMST administrative profile

Keep in sync with frontend/apps/medical-software/app/hooks/types.ts

INV1 class-attribute instance-attribute

INV1 = 'INV1'

INV2 class-attribute instance-attribute

INV2 = 'INV2'

INV3 class-attribute instance-attribute

INV3 = 'INV3'

Inaptitude class-attribute instance-attribute

Inaptitude = 'Inaptitude'

PDP class-attribute instance-attribute

PDP = 'PDP'

RQTH class-attribute instance-attribute

RQTH = 'RQTH'

PREDICTABLE_MARMOT_EDITABLE_STATUSES module-attribute

PREDICTABLE_MARMOT_EDITABLE_STATUSES = (
    NO_SHOW,
    CANCELLED_BY_PATIENT,
    CANCELLED_BY_PREVENIR,
)

PostVisitDocumentType

Bases: AlanBaseEnum

The type of document generated after a visit.

The set is expected to grow (e.g. when new regulatory documents are added), so new values can be appended without a data migration. Current values map one-to-one with the templates produced by the post-visit automation flows.

ATTESTATION_DE_SUIVI class-attribute instance-attribute

ATTESTATION_DE_SUIVI = 'attestation_de_suivi'

AVIS_D_APTITUDE class-attribute instance-attribute

AVIS_D_APTITUDE = 'avis_d_aptitude'

DOCUMENT_MESURES_INDIVIDUELLES class-attribute instance-attribute

DOCUMENT_MESURES_INDIVIDUELLES = (
    "document_mesures_individuelles"
)

PostVisitDocumentVisitModel

Bases: AlanBaseEnum

Which "visit model" the document is attached to.

A post-visit document is always linked to a single visit, but visits are currently stored in two distinct tables (the "turing" migration layer): - turing_occupational_health_predictable_visit - turing_occupational_health_on_demand_visit

ON_DEMAND class-attribute instance-attribute

ON_DEMAND = 'on_demand'

PREDICTABLE class-attribute instance-attribute

PREDICTABLE = 'predictable'

RiskCategory

Bases: AlanBaseEnum

Risk categories / "type de suivi" for occupational health visits.

These determine the frequency and type of required medical visits.

SI class-attribute instance-attribute

SI = 'SI'

SIA class-attribute instance-attribute

SIA = 'SIA'

SIR class-attribute instance-attribute

SIR = 'SIR'

SubscriberDocumentType

Bases: AlanBaseEnum

Type of subscriber document.

See https://www.notion.so/alaninsurance/Occupational-Health-Glossary-2d81426e8be7801cba9ed5dacdc24c60 ⧉

ACTIVITY_REPORT class-attribute instance-attribute

ACTIVITY_REPORT = 'activity_report'

COLLECTIVE_WORKSTATION_ASSESSMENT class-attribute instance-attribute

COLLECTIVE_WORKSTATION_ASSESSMENT = (
    "collective_workstation_assessment"
)

CSE class-attribute instance-attribute

CSE = 'CSE'

OTHER class-attribute instance-attribute

OTHER = 'other'

PAPRIPACT class-attribute instance-attribute

PAPRIPACT = 'PAPRIPACT'

SINGLE_OCCUPATIONAL_RISK_ASSESSMENT class-attribute instance-attribute

SINGLE_OCCUPATIONAL_RISK_ASSESSMENT = (
    "single_occupational_risk_assessment"
)

STAFF_INTERNAL_REGULATIONS class-attribute instance-attribute

STAFF_INTERNAL_REGULATIONS = 'staff_internal_regulations'

STOPPAGES_ACCIDENTS_ILLNESSES_SUMMARY class-attribute instance-attribute

STOPPAGES_ACCIDENTS_ILLNESSES_SUMMARY = (
    "stoppages_accidents_illnesses_summary"
)

WORKPLACE_RISK_FILE class-attribute instance-attribute

WORKPLACE_RISK_FILE = 'workplace_risk_file'

VisitRequestVisitType

Bases: AlanBaseEnum

The type of an visit request.

ON_DEMAND class-attribute instance-attribute

ON_DEMAND = 'on_demand'

PREDICTABLE class-attribute instance-attribute

PREDICTABLE = 'predictable'

VisitSetup

Bases: AlanBaseEnum

legacy_backfilled_no_data class-attribute instance-attribute

legacy_backfilled_no_data = 'LEGACY_BACKFILLED_NO_DATA'

online class-attribute instance-attribute

online = 'ONLINE'

onsite class-attribute instance-attribute

onsite = 'ONSITE'

VisitStatus

Bases: AlanBaseEnum

The operational status of a visit.

CANCELLED_BY_PATIENT class-attribute instance-attribute

CANCELLED_BY_PATIENT = 'cancelled_by_patient'

CANCELLED_BY_PREVENIR class-attribute instance-attribute

CANCELLED_BY_PREVENIR = 'cancelled_by_prevenir'

HAPPENED class-attribute instance-attribute

HAPPENED = 'happened'

NO_SHOW class-attribute instance-attribute

NO_SHOW = 'no_show'

TECHNICAL_ISSUE class-attribute instance-attribute

TECHNICAL_ISSUE = 'technical_issue'

TO_FILL class-attribute instance-attribute

TO_FILL = 'to_fill'

The visit is to be scheduled, or scheduled but not yet cancelled nor happened.

VisitType

Bases: AlanBaseEnum

EXAMEN_MEDICAL_APTITUDE_EMBAUCHE class-attribute instance-attribute

EXAMEN_MEDICAL_APTITUDE_EMBAUCHE = (
    "3.examen_medical_aptitude_embauche"
)

EXAMEN_MEDICAL_APTITUDE_PERIODIQUE class-attribute instance-attribute

EXAMEN_MEDICAL_APTITUDE_PERIODIQUE = (
    "4.examen_medical_aptitude_periodique"
)

VISITE_CESSATION_EXPOSITION class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION = (
    "30.visite_cessation_exposition"
)

VISITE_CESSATION_EXPOSITION_COURS_CARRIERE class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION_COURS_CARRIERE = (
    "31.visite_cessation_exposition_cours_carriere"
)

VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_EMPLOYEUR = "32.visite_cessation_exposition_cours_carriere_initiative_employeur"

VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_TRAVAILLEUR = "33.visite_cessation_exposition_cours_carriere_initiative_travailleur"

VISITE_CESSATION_EXPOSITION_FIN_CARRIERE class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION_FIN_CARRIERE = (
    "34.visite_cessation_exposition_fin_carriere"
)

VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_EMPLOYEUR = "35.visite_cessation_exposition_fin_carriere_initiative_employeur"

VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_TRAVAILLEUR = "36.visite_cessation_exposition_fin_carriere_initiative_travailleur"

VISITE_DEMANDE class-attribute instance-attribute

VISITE_DEMANDE = '11.visite_demande'

VISITE_DEMANDE_EMPLOYEUR class-attribute instance-attribute

VISITE_DEMANDE_EMPLOYEUR = '13.visite_demande_employeur'

VISITE_DEMANDE_MEDECIN_TRAVAIL class-attribute instance-attribute

VISITE_DEMANDE_MEDECIN_TRAVAIL = (
    "14.visite_demande_medecin_travail"
)

VISITE_DEMANDE_MEDECIN_TRAVAIL_ORIENTATION_INFIRMIER class-attribute instance-attribute

VISITE_DEMANDE_MEDECIN_TRAVAIL_ORIENTATION_INFIRMIER = "15.visite_demande_medecin_travail_orientation_infirmier"

VISITE_DEMANDE_TRAVAILLEUR class-attribute instance-attribute

VISITE_DEMANDE_TRAVAILLEUR = "12.visite_demande_travailleur"

VISITE_INFORMATION_PREVENTION_INITIALE class-attribute instance-attribute

VISITE_INFORMATION_PREVENTION_INITIALE = (
    "1.visite_information_prevention_initiale"
)

VISITE_INFORMATION_PREVENTION_PERIODIQUE class-attribute instance-attribute

VISITE_INFORMATION_PREVENTION_PERIODIQUE = (
    "2.visite_information_prevention_periodique"
)

VISITE_INTERMEDIAIRE_ENTRE_DEUX_EXAMENS class-attribute instance-attribute

VISITE_INTERMEDIAIRE_ENTRE_DEUX_EXAMENS = (
    "5.visite_intermediaire_entre_deux_examens"
)

VISITE_MI_CARRIERE class-attribute instance-attribute

VISITE_MI_CARRIERE = '29.visite_mi_carriere'

VISITE_PREREPRISE class-attribute instance-attribute

VISITE_PREREPRISE = '6.visite_prereprise'

VISITE_PREREPRISE_INITIATIVE_MEDECIN_CONSEIL class-attribute instance-attribute

VISITE_PREREPRISE_INITIATIVE_MEDECIN_CONSEIL = (
    "7.visite_prereprise_initiative_medecin_conseil"
)

VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAITANT class-attribute instance-attribute

VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAITANT = (
    "8.visite_prereprise_initiative_medecin_traitant"
)

VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAVAIL class-attribute instance-attribute

VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAVAIL = (
    "10.visite_prereprise_initiative_medecin_travail"
)

VISITE_PREREPRISE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_PREREPRISE_INITIATIVE_TRAVAILLEUR = (
    "9.visite_prereprise_initiative_travailleur"
)

VISITE_REPRISE class-attribute instance-attribute

VISITE_REPRISE = '16.visite_reprise'

VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL class-attribute instance-attribute

VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL = (
    "20.visite_reprise_absence_30_jours_accident_travail"
)

VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_EMPLOYEUR class-attribute instance-attribute

VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_EMPLOYEUR = "21.visite_reprise_absence_30_jours_accident_travail_initiative_employeur"

VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_TRAVAILLEUR = "22.visite_reprise_absence_30_jours_accident_travail_initiative_travailleur"

VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE class-attribute instance-attribute

VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE = (
    "23.visite_reprise_absence_maladie_professionnelle"
)

VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute

VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_EMPLOYEUR = "24.visite_reprise_absence_maladie_professionnelle_initiative_employeur"

VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_TRAVAILLEUR = "25.visite_reprise_absence_maladie_professionnelle_initiative_travailleur"

VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO class-attribute instance-attribute

VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO = (
    "17.visite_reprise_cause_maladie_accident_non_pro"
)

VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_EMPLOYEUR class-attribute instance-attribute

VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_EMPLOYEUR = "18.visite_reprise_cause_maladie_accident_non_pro_initiative_employeur"

VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_TRAVAILLEUR = "19.visite_reprise_cause_maladie_accident_non_pro_initiative_travailleur"

VISITE_REPRISE_CONGE_MATERNITE class-attribute instance-attribute

VISITE_REPRISE_CONGE_MATERNITE = (
    "26.visite_reprise_conge_maternite"
)

VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute

VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_EMPLOYEUR = (
    "27.visite_reprise_conge_maternite_initiative_employeur"
)

VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute

VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_TRAVAILLEUR = "28.visite_reprise_conge_maternite_initiative_travailleur"

detect_from_string classmethod

detect_from_string(visit_type_str)

Detect the visit type from a string.

Source code in components/occupational_health/internal/enums/visit_type.py
@classmethod
def detect_from_string(cls, visit_type_str: str) -> "VisitType":
    """
    Detect the visit type from a string.
    """
    try:
        return VisitType(visit_type_str)
    except ValueError:
        pass

    mapping = {
        "Visite d'embauche": VisitType.VISITE_INFORMATION_PREVENTION_INITIALE,
        "VIP": VisitType.VISITE_INFORMATION_PREVENTION_INITIALE,
        "Visite périodique": VisitType.VISITE_INFORMATION_PREVENTION_PERIODIQUE,
        "Visite de reprise": VisitType.VISITE_REPRISE,
        "Visite à la demande": VisitType.VISITE_DEMANDE,
        # not 100% sure about the original meaning of this one, but limited number of cases
        "Visite de suivi": VisitType.VISITE_DEMANDE_MEDECIN_TRAVAIL,
    }

    if visit_type_str not in mapping:
        raise ValueError(f"Visit type not found in mapping: {visit_type_str}")

    return mapping[visit_type_str]

level_1_category property

level_1_category

components.occupational_health.public.events

subscription

subscribe_to_events

subscribe_to_events()

All event subscriptions for the occupational health component should be done here.

Source code in components/occupational_health/public/events/subscription.py
def subscribe_to_events() -> None:
    """
    All event subscriptions for the occupational health component should be done here.
    """
    from components.occupational_health.internal.profile.actions.update_occupational_health_profile_merge import (
        update_occupational_health_profile_merge,
    )
    from shared.messaging.broker import get_message_broker

    message_broker = get_message_broker()

    # This is on top of the existing global profile profile merge event
    # See: the components.global_profile.internal.events.subscribers.update_links_to_profile_when_merging
    message_broker.subscribe_async(
        ProfilesMerged,
        update_occupational_health_profile_merge,
        queue_name=LOW_PRIORITY_QUEUE,
    )

components.occupational_health.public.exceptions

CompanyIdNotFound

CompanyIdNotFound(company_id)

Bases: ConsumerError

The company ID mentioned in the employment change could not be found.

Should only happen within unit tests, or if a blocked movement has its company deleted (very rare)

Source code in components/occupational_health/public/exceptions.py
def __init__(self, company_id: CompanyId) -> None:
    super().__init__(
        message=f"Company ID '{company_id}' not found",
        consumer=occupational_health_employment_change_consumer.__name__,
    )

error_code class-attribute instance-attribute

error_code = 'company_id_not_found'

UserIdNotFound

UserIdNotFound(message)

Bases: UserNotFoundError

A user ID cannot be found.

This happens generally after a user merge event, when the employment change still refers the old user ID.

Source code in components/occupational_health/public/exceptions.py
def __init__(self, message: str) -> None:
    super().__init__(
        message=message,
        consumer=occupational_health_employment_change_consumer.__name__,
    )

components.occupational_health.public.fixtures

OccupationalHealthFixtureFactory

Bootstrap Occupational Health for a company: subscription, dashboard admins, profiles + affiliations for every employee. With with_documents (default True): also one contract document, four subscriber documents, a billed entity, and one invoice per past month of the current year.

build classmethod

build(company, with_documents=True)
Source code in components/occupational_health/internal/admin_tools/fixtures/occupational_health.py
@classmethod
def build(
    cls,
    company: Company,
    with_documents: bool = True,
) -> Company:
    # First day of the current year so the admin dashboard's
    # "depuis le <contract start date>" window covers the whole year.
    start_date = utctoday().replace(month=1, day=1)
    account_id = AccountId(company.account_id)

    subscription_id = _create_account_contract(
        company=company,
        account_id=account_id,
        start_date=start_date,
    )

    with factory_build_add_to_session():
        affiliated_count = _affiliate_employees(
            company=company,
            account_id=account_id,
            start_date=start_date,
        )

        if with_documents:
            _create_account_documents(
                company=company,
                account_id=account_id,
                subscription_id=subscription_id,
                affiliated_employee_count=affiliated_count,
            )

    return company

build_batch classmethod

build_batch(size, **kwargs)
Source code in components/occupational_health/internal/admin_tools/fixtures/occupational_health.py
@classmethod
def build_batch(cls, size: int, **kwargs: Any) -> list[Company]:
    return [cls.build(**kwargs) for _ in range(size)]

OccupationalHealthVisitFixtureFactory

Build one TuringOccupationalHealthPredictableVisit for a user. Status is HAPPENED for a past/today visit_date, TO_FILL for a future one. Optional documents attaches one OccupationalHealthPostVisitDocument per entry (past visits only).

build classmethod

build(
    user,
    visit_type=VisitType.VISITE_INFORMATION_PREVENTION_INITIALE,
    visit_date=None,
    documents=None,
    date_sent=None,
    booked=None,
)
Source code in components/occupational_health/internal/admin_tools/fixtures/occupational_health_visit.py
@classmethod
def build(
    cls,
    user: User,
    visit_type: VisitType = VisitType.VISITE_INFORMATION_PREVENTION_INITIALE,
    visit_date: date | None = None,
    documents: list[PostVisitDocumentType] | None = None,
    date_sent: date | None = None,
    booked: bool | None = None,
) -> TuringOccupationalHealthPredictableVisit:
    documents = [
        doc
        if isinstance(doc, PostVisitDocumentType)
        else PostVisitDocumentType(doc)
        for doc in (documents or [])
    ]
    if isinstance(visit_date, str):
        visit_date = date.fromisoformat(visit_date)
    effective_date = visit_date or utctoday()
    is_upcoming = effective_date > utctoday()
    if is_upcoming and documents:
        raise ValueError(
            "Cannot attach post-visit documents to a future visit date"
        )

    profile = current_session.scalar(
        select(OccupationalHealthProfile).where(
            OccupationalHealthProfile.global_profile_id
            == GlobalProfileId(user.profile_id)
        )
    )
    if profile is None:
        raise ValueError(
            f"No OccupationalHealthProfile for user {user.id}. "
            "Run OccupationalHealthFixtureFactory first."
        )

    account_id = _resolve_account_id_from_employments(user)
    status = VisitStatus.TO_FILL if is_upcoming else VisitStatus.HAPPENED

    with factory_build_add_to_session():
        visit = TuringOccupationalHealthPredictableVisitFactory.build(
            user_id=str(user.id),
            occupational_health_profile_id=profile.id,
            account_id=account_id,
            visit_type=visit_type,
            date_planned=effective_date,
            status=status,
            date_sent=date_sent,
            booked=booked,
        )
        current_session.add(visit)
        current_session.flush()

        for document_type in documents:
            current_session.add(
                OccupationalHealthPostVisitDocument(
                    visit_id=visit.id,
                    visit_model=PostVisitDocumentVisitModel.PREDICTABLE,
                    document_type=document_type,
                    filename=f"demo_{document_type.value}.pdf",
                    gdrive_folder_id="demo_folder",
                    gdrive_file_id=f"demo_{uuid4()}",
                    required_signature=_REQUIRED_SIGNATURE_BY_DOCUMENT_TYPE[
                        document_type
                    ],
                )
            )
        return visit

build_batch classmethod

build_batch(size, **kwargs)
Source code in components/occupational_health/internal/admin_tools/fixtures/occupational_health_visit.py
@classmethod
def build_batch(
    cls, size: int, **kwargs: Any
) -> list[TuringOccupationalHealthPredictableVisit]:
    return [cls.build(**kwargs) for _ in range(size)]

components.occupational_health.public.marmot

actions

Actions for use in Marmot controllers.

AffiliationRequest dataclass

AffiliationRequest(
    user_id,
    start_date,
    end_date,
    risk_category,
    extra_data,
    last_visit=None,
    personal_email=None,
    professional_email=None,
)

Bases: DataClassJsonMixin

end_date instance-attribute
end_date
extra_data instance-attribute
extra_data
last_visit class-attribute instance-attribute
last_visit = None
personal_email class-attribute instance-attribute
personal_email = None
professional_email class-attribute instance-attribute
professional_email = None
risk_category instance-attribute
risk_category
start_date instance-attribute
start_date
user_id instance-attribute
user_id

DuplicateAdminError

Bases: Exception

Raised when trying to add a duplicate occupational health dashboard admin.

RuleAction

Bases: AlanBaseEnum

Which action to take when affiliating employees matching the rule.

AFFILIATE class-attribute instance-attribute
AFFILIATE = 'affiliate'

Affiliate the employee automatically

IGNORE class-attribute instance-attribute
IGNORE = 'ignore'

Do not affiliate the employee

REQUIRE_MANUAL_REVIEW class-attribute instance-attribute
REQUIRE_MANUAL_REVIEW = 'require_manual_review'

Ask the admin to decide whether to affiliate the employee or not

add_discrepancy_note_for_marmot

add_discrepancy_note_for_marmot(
    account_id, user_id, comment, noted_by_user_id
)

Add a note on a discrepancy.

Upserts: if a note already exists for (account_id, user_id), update comment and noted_by; otherwise create a new record.

Source code in components/occupational_health/public/marmot/actions.py
def add_discrepancy_note_for_marmot(
    account_id: AccountId,
    user_id: UserId,
    comment: str,
    noted_by_user_id: UserId,
) -> None:
    """
    Add a note on a discrepancy.

    Upserts: if a note already exists for (account_id, user_id), update
    comment and noted_by; otherwise create a new record.
    """
    from components.occupational_health.internal.models.occupational_health_discrepancy_note import (
        OccupationalHealthDiscrepancyNote,
    )

    existing = current_session.scalars(
        select(OccupationalHealthDiscrepancyNote).where(
            OccupationalHealthDiscrepancyNote.account_id == account_id,
            OccupationalHealthDiscrepancyNote.user_id == user_id,
        )
    ).one_or_none()

    if existing:
        existing.comment = comment
        existing.noted_by_user_id = noted_by_user_id
        current_session.add(existing)
    else:
        current_session.add(
            OccupationalHealthDiscrepancyNote(
                account_id=account_id,
                user_id=user_id,
                comment=comment,
                noted_by_user_id=noted_by_user_id,
            )
        )

    current_session.commit()

    current_logger.info(
        "Added discrepancy note",
        account_id=account_id,
        user_id=user_id,
        noted_by_user_id=noted_by_user_id,
    )

add_occupational_health_dashboard_admin_to_account

add_occupational_health_dashboard_admin_to_account(
    account_id, user_id
)

Add an occupational health dashboard admin to an account.

Raises:

Type Description
DuplicateAdminError

If the user is already an admin for this account.

Source code in components/occupational_health/internal/business_logic/actions/dashboard_admin.py
def add_occupational_health_dashboard_admin_to_account(
    account_id: AccountId, user_id: UserId
) -> UUID:
    """
    Add an occupational health dashboard admin to an account.

    Raises:
        DuplicateAdminError: If the user is already an admin for this account.
    """

    # Check if admin already exists for this user and account
    existing_admin = current_session.scalar(
        select(OccupationalHealthDashboardAdmin).where(
            OccupationalHealthDashboardAdmin.user_id == user_id,
            OccupationalHealthDashboardAdmin.account_id == account_id,
        )
    )

    if existing_admin:
        raise DuplicateAdminError(
            f"User {user_id} is already an admin for account {account_id}. "
            "Cannot add duplicate admin."
        )

    current_logger.info(
        f"Adding occupational health dashboard admin user {user_id} to account {account_id}"
    )
    occupational_health_dashboard_admin = OccupationalHealthDashboardAdmin(
        account_id=account_id,
        user_id=user_id,
        scope=OccupationalHealthDashboardAdminScope.ACCOUNT,
    )
    current_session.add(occupational_health_dashboard_admin)
    current_session.commit()

    return occupational_health_dashboard_admin.id

affiliate_multiple_members

affiliate_multiple_members(account_id, affiliations)

Affiliate multiple members to Prévenir in a single transaction.

Parameters:

Name Type Description Default
account_id AccountId

The account ID to affiliate members to

required
affiliations list[AffiliationRequest]

List of affiliation requests with user_id, start_date, optional last_visit, and optional risk_category

required

Returns:

Type Description
list[UUID]

List of affiliation IDs

Source code in components/occupational_health/internal/business_logic/actions/affiliation_tool.py
def affiliate_multiple_members(
    account_id: AccountId,
    affiliations: list[AffiliationRequest],
) -> list[UUID]:
    """
    Affiliate multiple members to Prévenir in a single transaction.

    Args:
        account_id: The account ID to affiliate members to
        affiliations: List of affiliation requests with user_id, start_date, optional last_visit, and optional risk_category

    Returns:
        List of affiliation IDs
    """
    contract_start_date = get_contract_start_date(account_id)

    current_logger.info(
        f"Affiliating {len(affiliations)} members to account {account_id}..."
    )
    affiliation_ids: list[UUID] = []

    for affiliation_request in affiliations:
        # Get or create occupational health profile ID from user_id
        profile_id = get_or_create_profile_id(affiliation_request.user_id, commit=False)
        affiliation_id = affiliate_member(
            occupational_health_profile_id=profile_id,
            start_date=affiliation_request.start_date,
            end_date=affiliation_request.end_date,
            account_id=account_id,
            commit=False,
        )
        affiliation_ids.append(affiliation_id)

        # Update personal email if provided
        if affiliation_request.personal_email:
            update_personal_email(
                profile_id=profile_id,
                personal_email=affiliation_request.personal_email,
                commit=False,
            )

        # Update professional email if provided
        if affiliation_request.professional_email:
            update_professional_email(
                profile_id=profile_id,
                account_id=account_id,
                professional_email=affiliation_request.professional_email,
                commit=False,
            )

        # Store risk category in administrative profile if provided
        if affiliation_request.risk_category:
            update_administrative_profile(
                occupational_health_profile_id=profile_id,
                risk_category=affiliation_request.risk_category,
                commit=False,
            )

        affiliation_log = OccupationalHealthMassAffiliationLog(
            profile_id=profile_id,
            account_id=account_id,
            start_date=affiliation_request.start_date,
            extra_data=affiliation_request.extra_data,
            affiliation_id=affiliation_id,
        )
        current_session.add(affiliation_log)

        # Create visit record if last_visit data is provided
        if affiliation_request.last_visit:
            visit_type = affiliation_request.last_visit.visit_type
            visit_date = affiliation_request.last_visit.date

            create_backfilled_visit_if_not_exists(
                profile_id=profile_id,
                visit_date=visit_date,
                visit_type=visit_type,
                account_id=account_id,
                contract_start_date=contract_start_date,
            )

    current_session.commit()

    return affiliation_ids

affiliate_new_member_for_marmot

affiliate_new_member_for_marmot(
    *,
    user_id,
    account_id,
    start_date,
    end_date=None,
    personal_email=None,
    professional_email=None,
    last_visit=None,
    risk_category=None,
    actor_user_id=None
)

Affiliate a new member to occupational health.

Source code in components/occupational_health/public/marmot/actions.py
def affiliate_new_member_for_marmot(
    *,
    user_id: UserId,
    account_id: AccountId,
    start_date: date,
    end_date: date | None = None,
    personal_email: str | None = None,
    professional_email: str | None = None,
    last_visit: "LastVisit | None" = None,
    risk_category: "RiskCategory | None" = None,
    actor_user_id: UserId | str | None = None,
) -> None:
    """
    Affiliate a new member to occupational health.
    """
    from components.occupational_health.internal.business_logic.actions.members.update_administrative_profile import (
        update_administrative_profile,
    )

    current_logger.info(
        "Affiliating new member from Marmot",
        extra={
            "user_id": user_id,
            "account_id": account_id,
            "start_date": start_date,
            "end_date": end_date,
        },
    )

    profile_id = get_or_create_profile_id(user_id)

    current_logger.info(
        "Affiliating new member from Marmot",
        extra={
            "profile_id": profile_id,
            "account_id": account_id,
            "start_date": start_date,
            "end_date": end_date,
        },
    )

    account_name = get_account_name(account_id)
    ts_thread = log_and_post_message(
        f"Affiliating new member {user_id} from Marmot (account {account_name})",
        username="Alaner activity in Marmot",
    )
    slack_id = get_actor_slack_handle(actor_user_id=actor_user_id)
    log_and_post_message(
        f"<{slack_id}> can you share context on why this was required?",
        username="Alaner activity in Marmot",
        ts_thread=ts_thread,
    )

    affiliate_member(
        occupational_health_profile_id=profile_id,
        start_date=start_date,
        end_date=end_date,
        account_id=account_id,
    )

    # Update personal email if provided
    if personal_email:
        update_personal_email(
            profile_id=profile_id,
            personal_email=personal_email,
        )

    # Update professional email if provided
    if professional_email:
        update_professional_email(
            profile_id=profile_id,
            account_id=account_id,
            professional_email=professional_email,
        )

    # Store risk category in administrative profile if provided
    if risk_category:
        update_administrative_profile(
            occupational_health_profile_id=profile_id,
            risk_category=risk_category,
        )

    # Create visit record if last_visit data is provided
    if last_visit:
        create_backfilled_visit_if_not_exists(
            profile_id=profile_id,
            visit_date=last_visit.date,
            visit_type=last_visit.visit_type,
            account_id=account_id,
            commit=True,
            contract_start_date=get_contract_start_date(account_id),
        )

cancel_affiliation_for_marmot

cancel_affiliation_for_marmot(
    affiliation_id, actor_user_id
)

Cancel an affiliation for a profile.

This is a wrapper around cancel_affiliation that adds logging.

Source code in components/occupational_health/public/marmot/actions.py
def cancel_affiliation_for_marmot(
    affiliation_id: UUID,
    actor_user_id: UserId | str | None,
) -> None:
    """
    Cancel an affiliation for a profile.

    This is a wrapper around cancel_affiliation that adds logging.
    """
    from components.occupational_health.internal.business_logic.actions.affiliation import (
        cancel_affiliation,
    )

    current_logger.info(
        f"Cancelling affiliation {affiliation_id} from Marmot",
        extra={
            "actor_user_id": actor_user_id,
        },
    )

    ts_thread = log_and_post_message(
        f"Cancelling affiliation `{affiliation_id}` from Marmot",
        username="Alaner activity in Marmot",
    )
    slack_id = get_actor_slack_handle(actor_user_id=actor_user_id)
    log_and_post_message(
        f"<{slack_id}> can you share context on why this was required?",
        username="Alaner activity in Marmot",
        ts_thread=ts_thread,
    )

    cancel_affiliation(affiliation_id=affiliation_id)

cancel_affiliation_strategy_rule_for_marmot

cancel_affiliation_strategy_rule_for_marmot(rule_id)

Cancel (soft-delete) an affiliation strategy rule.

Source code in components/occupational_health/public/marmot/actions.py
def cancel_affiliation_strategy_rule_for_marmot(rule_id: UUID) -> None:
    """Cancel (soft-delete) an affiliation strategy rule."""
    from datetime import datetime

    from components.occupational_health.internal.models.affiliation_strategy_rule import (
        AffiliationStrategyRule,
    )

    rule = get_or_raise_missing_resource(AffiliationStrategyRule, rule_id)

    if rule.is_cancelled:
        raise BaseErrorCode.invalid_arguments(message="Rule is already cancelled")

    rule.cancelled_at = datetime.now(UTC)
    current_session.add(rule)
    current_session.commit()

    current_logger.info(
        f"Cancelled affiliation strategy rule: {rule_id}",
        rule_id=rule_id,
        account_id=rule.account_id,
    )

create_affiliation_strategy_rule_for_marmot

create_affiliation_strategy_rule_for_marmot(
    *,
    account_id,
    company_id=None,
    siret=None,
    name,
    action,
    force_if_duplicate=False
)

Create a new affiliation strategy rule for a company or SIRET.

Either company_id or siret must be provided, but not both.

If a cancelled rule exists with the same criteria and force_if_duplicate is False, raises a conflict error so the frontend can prompt for confirmation.

Source code in components/occupational_health/public/marmot/actions.py
def create_affiliation_strategy_rule_for_marmot(
    *,
    account_id: AccountId,
    company_id: CompanyId | None = None,
    siret: str | None = None,
    name: str | None,
    action: RuleAction,
    force_if_duplicate: bool = False,
) -> UUID:
    """
    Create a new affiliation strategy rule for a company or SIRET.

    Either company_id or siret must be provided, but not both.

    If a cancelled rule exists with the same criteria and force_if_duplicate is False,
    raises a conflict error so the frontend can prompt for confirmation.
    """
    from components.occupational_health.internal.models.affiliation_strategy_rule import (
        AffiliationStrategyRule,
    )

    if not company_id and not siret:
        raise BaseErrorCode.invalid_arguments(
            message="Either company_id or siret must be provided"
        )
    if company_id and siret:
        raise BaseErrorCode.invalid_arguments(
            message="Cannot provide both company_id and siret"
        )

    current_logger.info(
        "Creating affiliation strategy rule from Marmot",
        extra={
            "account_id": account_id,
            "company_id": company_id,
            "siret": siret,
            "name": name,
            "action": action,
        },
    )

    # Check if an active (non-cancelled) rule already exists
    query = select(AffiliationStrategyRule).where(
        AffiliationStrategyRule.account_id == account_id,
        ~AffiliationStrategyRule.is_cancelled,
    )
    if company_id:
        query = query.where(AffiliationStrategyRule.company_id == company_id)
    if siret:
        query = query.where(AffiliationStrategyRule.siret == siret)

    existing_active_rule = current_session.scalars(query).first()
    if existing_active_rule:
        rule_type = f"company {company_id}" if company_id else f"SIRET {siret}"
        raise BaseErrorCode.invalid_arguments(
            message=f"Rule already exists for {rule_type} in account {account_id}"
        )

    # Check if a cancelled rule exists with the same criteria
    if not force_if_duplicate:
        cancelled_query = select(AffiliationStrategyRule).where(
            AffiliationStrategyRule.account_id == account_id,
            AffiliationStrategyRule.is_cancelled,
            AffiliationStrategyRule.company_id == company_id,
            AffiliationStrategyRule.siret == siret,
            # we're not filtering on is_missing_siret, etc because these cannot be created from Marmot at this point
        )

        cancelled_rule = current_session.scalars(cancelled_query).first()
        if cancelled_rule:
            rule_type = f"company {company_id}" if company_id else f"SIRET {siret}"
            raise BaseErrorCode.invalid_arguments(
                message=f"A cancelled rule already exists for {rule_type}. Use force_if_duplicate to create anyway.",
                http_code=409,
            )

    # Create new rule
    new_rule = AffiliationStrategyRule(
        account_id=account_id,
        company_id=company_id,
        siret=siret,
        name=name,
        action=action,
        is_missing_siret=False,
    )

    current_session.add(new_rule)
    current_session.commit()

    current_logger.info(
        f"Created new affiliation strategy rule: {new_rule.id}",
        extra={
            "rule_id": new_rule.id,
            "account_id": account_id,
            "company_id": company_id,
            "siret": siret,
            "name": name,
            "action": action,
        },
    )

    return new_rule.id

create_contract

create_contract(session, account_id, start_date)
Source code in components/occupational_health/internal/business_logic/contracting/actions.py
@transactional(propagation=Propagation.REQUIRES_NEW)
def create_contract(
    session: Session,  # noqa:ARG001
    account_id: AccountId,
    start_date: date,
) -> UUID:
    from components.contracting.public.events import (
        CompanyContractSigned,
        ContractSigned,
    )
    from components.occupational_health.external.company import (
        get_company_ids_in_account,
    )
    from shared.helpers.app_name import AppName
    from shared.messaging.broker import get_message_broker

    subscription_id = _initialize_subscription_and_record_versions(
        account_id=account_id,
        start_date=start_date,
    )

    message_broker = get_message_broker()
    for company_id in get_company_ids_in_account(account_id):
        message_broker.publish(
            CompanyContractSigned(
                company_id=str(company_id),
                contract_id=str(subscription_id),
                app_name=AppName.ALAN_FR,
            )
        )
        message_broker.publish(
            ContractSigned(
                company_id=str(company_id),
                individual_id=None,
                contract_id=str(subscription_id),
                app_name=AppName.ALAN_FR,
                contract_type="occupational_health",
            )
        )

    return subscription_id

reactivate_affiliation_strategy_rule_for_marmot

reactivate_affiliation_strategy_rule_for_marmot(rule_id)

Reactivate a previously cancelled affiliation strategy rule.

Source code in components/occupational_health/public/marmot/actions.py
def reactivate_affiliation_strategy_rule_for_marmot(rule_id: UUID) -> None:
    """Reactivate a previously cancelled affiliation strategy rule."""
    from components.occupational_health.internal.models.affiliation_strategy_rule import (
        AffiliationStrategyRule,
    )

    rule = get_or_raise_missing_resource(AffiliationStrategyRule, rule_id)

    if not rule.is_cancelled:
        raise BaseErrorCode.invalid_arguments(message="Rule is not cancelled")

    rule.cancelled_at = None
    current_session.add(rule)
    current_session.commit()

    current_logger.info(
        f"Reactivated affiliation strategy rule: {rule_id}",
        rule_id=rule_id,
        account_id=rule.account_id,
    )

update_employment_nic

update_employment_nic(employment_id, nic, commit=True)

Update the NIC of an employment.

Source code in components/occupational_health/public/marmot/actions.py
def update_employment_nic(
    employment_id: UUID,
    nic: str | None,
    commit: bool = True,
) -> None:
    """
    Update the NIC of an employment.
    """
    from components.employment.public.business_logic.queries.core_employment_version import (
        get_latest_version_for_employment_or_raise,
    )
    from components.fr.public.employment.fr_extended_values import (
        FrExtendedValues,
    )
    from shared.temporal.validity_period import ValidityPeriod

    employment_version = get_latest_version_for_employment_or_raise(employment_id)
    validity_period = ValidityPeriod(
        start_date=employment_version.start_date,
        end_date=employment_version.end_date,
    )

    set_extended_employment_values(
        employment_id=employment_id,
        values=FrExtendedValues({"nic": nic}),
        source_type=SourceType.fr_marmot_occupational_health,
        source_information=SourceInformation(
            raw_data={},
            metadata={"endpoint": "occupational_health.update_employment"},
        ),
        validity_period=validity_period,
        commit=commit,
    )

    current_logger.info(
        "Updated NIC of employment",
        employment_id=employment_id,
        nic=nic,
    )

update_on_demand_visit_for_marmot

update_on_demand_visit_for_marmot(
    visit_id, payload, actor_id=None
)

Apply a Marmot edit to an on-demand visit (DB + GSheet, no automations).

Source code in components/occupational_health/public/marmot/visit_management/actions/visit_scheduling.py
def update_on_demand_visit_for_marmot(
    visit_id: UUID,
    payload: dict[str, Any],
    actor_id: Optional[int] = None,
) -> None:
    """Apply a Marmot edit to an on-demand visit (DB + GSheet, no automations)."""
    from components.occupational_health.internal.business_logic.actions.visit_scheduling import (
        update_on_demand_visit,
    )

    update_on_demand_visit(visit_id=visit_id, payload=payload, actor_id=actor_id)

update_predictable_visit_status_for_marmot

update_predictable_visit_status_for_marmot(
    visit_id, new_status, actor_id=None
)

Apply a Marmot status-only edit to a predictable visit (GSheet + DB write).

Source code in components/occupational_health/public/marmot/visit_management/actions/visit_scheduling.py
def update_predictable_visit_status_for_marmot(
    visit_id: UUID,
    new_status: "VisitStatus",
    actor_id: Optional[int] = None,
) -> None:
    """Apply a Marmot status-only edit to a predictable visit (GSheet + DB write)."""
    from components.occupational_health.internal.business_logic.actions.visit_scheduling import (
        update_predictable_visit_status,
    )

    update_predictable_visit_status(
        visit_id=visit_id, new_status=new_status, actor_id=actor_id
    )

dpae

Public interface for DPAE marmot operations.

DpaeClassificationResult dataclass

DpaeClassificationResult(
    prevenir_subscribers=list(),
    alan_customers=list(),
    unknown=list(),
)

Result of parsing, matching and classifying DPAE rows.

alan_customers class-attribute instance-attribute
alan_customers = field(default_factory=list)
prevenir_subscribers class-attribute instance-attribute
prevenir_subscribers = field(default_factory=list)
unknown class-attribute instance-attribute
unknown = field(default_factory=list)

DpaeListItem dataclass

DpaeListItem(id, file_name, row_count, created_at)

Bases: DataClassJsonMixin

Summary of a past DPAE upload.

created_at instance-attribute
created_at
file_name instance-attribute
file_name
id instance-attribute
id
row_count instance-attribute
row_count

DpaeParsingError

DpaeParsingError(line_number)

Bases: Exception

Raised when a DPAE CSV row is invalid.

Source code in components/occupational_health/internal/business_logic/queries/dpae/parse_csv.py
def __init__(self, line_number: int) -> None:
    super().__init__(f"Invalid DPAE row at line {line_number}")
    self.line_number = line_number
line_number instance-attribute
line_number = line_number

DpaeRow dataclass

DpaeRow(
    created_at,
    siret,
    company_name,
    address,
    zip_code,
    city,
    last_name,
    first_name,
    married_name,
    gender,
    birth_date,
    contract_start_date,
    siren="",
    matched_user_id=None,
    company_match=None,
    account_id=None,
)

Bases: DataClassJsonMixin

A parsed DPAE employee declaration.

__post_init__
__post_init__()
Source code in components/occupational_health/internal/business_logic/queries/dpae/parse_csv.py
def __post_init__(self) -> None:
    if not self.siren:
        self.siren = self.siret[:SIREN_LENGTH]
account_id class-attribute instance-attribute
account_id = None
address instance-attribute
address
birth_date instance-attribute
birth_date
city instance-attribute
city
company_match class-attribute instance-attribute
company_match = None
company_name instance-attribute
company_name
contract_start_date instance-attribute
contract_start_date
created_at instance-attribute
created_at
first_name instance-attribute
first_name
gender instance-attribute
gender
last_name instance-attribute
last_name
married_name instance-attribute
married_name
matched_user_id class-attribute instance-attribute
matched_user_id = None
siren class-attribute instance-attribute
siren = ''
siret instance-attribute
siret
zip_code instance-attribute
zip_code

entities

AccountForMarmot dataclass

AccountForMarmot(id, name)

Bases: DataClassJsonMixin

Represent an account with an occupational health contract for display in Marmot.

id instance-attribute
id
name instance-attribute
name

AffiliatedMemberForMarmot dataclass

AffiliatedMemberForMarmot(
    affiliation_id,
    start_date,
    end_date,
    is_cancelled,
    user_id,
    occupational_health_profile_id,
    global_profile_id,
    first_name,
    last_name,
    birth_date,
    professional_email,
    personal_email,
    is_vip,
    employments,
    account_id,
)

Bases: DataClassJsonMixin

Represent a member affiliated to Prévenir, to be displayed in Marmot

account_id instance-attribute
account_id
affiliation_id instance-attribute
affiliation_id
birth_date instance-attribute
birth_date
employments instance-attribute
employments
end_date instance-attribute
end_date
first_name instance-attribute
first_name
global_profile_id instance-attribute
global_profile_id
is_cancelled instance-attribute
is_cancelled
is_vip instance-attribute
is_vip
last_name instance-attribute
last_name
occupational_health_profile_id instance-attribute
occupational_health_profile_id
personal_email instance-attribute
personal_email
professional_email instance-attribute
professional_email
start_date instance-attribute
start_date
user_id instance-attribute
user_id

AffiliationDecisionInfo dataclass

AffiliationDecisionInfo(
    decision_id,
    decision,
    manual_decision,
    rule_name,
    created_at,
)

Bases: DataClassJsonMixin

Info about an affiliation decision for a user.

created_at instance-attribute
created_at
decision instance-attribute
decision
decision_id instance-attribute
decision_id
manual_decision instance-attribute
manual_decision
rule_name instance-attribute
rule_name

AffiliationMovementForMarmot dataclass

AffiliationMovementForMarmot(
    movement_id,
    account_id,
    profile_id,
    movement_type,
    date_of_movement,
    first_name,
    last_name,
    birth_date,
    created_at,
    decision_id,
    company_id,
    company_name,
    company_name_enriched_from_dsn,
)

Bases: DataClassJsonMixin

Represent an affiliation movement for display in Marmot

account_id instance-attribute
account_id
birth_date instance-attribute
birth_date
company_id instance-attribute
company_id
company_name instance-attribute
company_name
company_name_enriched_from_dsn instance-attribute
company_name_enriched_from_dsn
created_at instance-attribute
created_at
date_of_movement instance-attribute
date_of_movement
decision_id instance-attribute
decision_id
first_name instance-attribute
first_name
last_name instance-attribute
last_name
movement_id instance-attribute
movement_id
movement_type instance-attribute
movement_type
profile_id instance-attribute
profile_id

CancelledAffiliationInfo dataclass

CancelledAffiliationInfo(
    affiliation_id, start_date, end_date
)

Bases: DataClassJsonMixin

Info about a cancelled affiliation for a user.

affiliation_id instance-attribute
affiliation_id
end_date instance-attribute
end_date
start_date instance-attribute
start_date

CompanyWithAffiliatedCount dataclass

CompanyWithAffiliatedCount(
    company_id,
    company_display_name,
    ever_affiliated_members_count,
    sirets,
)

Bases: DataClassJsonMixin

Represent a company with its count of affiliated members for display in Marmot.

company_display_name instance-attribute
company_display_name
company_id instance-attribute
company_id
ever_affiliated_members_count instance-attribute
ever_affiliated_members_count
sirets instance-attribute
sirets

ContractInfo dataclass

ContractInfo(account_id, start_date, end_date)

Bases: DataClassJsonMixin

Information about a contract.

account_id instance-attribute
account_id
end_date instance-attribute
end_date
start_date instance-attribute
start_date

DiscrepancyFixProposition dataclass

DiscrepancyFixProposition(fix_type, reason, data)

Bases: DataClassJsonMixin

A proposed fix for a discrepancy.

data instance-attribute
data
fix_type instance-attribute
fix_type
reason instance-attribute
reason

DiscrepancyForMarmot dataclass

DiscrepancyForMarmot(
    account_id,
    profile_id,
    user_id,
    category,
    message,
    employments_timeline=None,
    affiliations_timeline=None,
    *,
    first_name,
    last_name,
    birth_date,
    employments,
    discrepancy_note=None,
    cancelled_affiliations=list(),
    affiliation_decisions=list()
)

Bases: Discrepancy

Represent a discrepancy found in an affiliation for display in Marmot.

affiliation_decisions class-attribute instance-attribute
affiliation_decisions = field(default_factory=list)
birth_date instance-attribute
birth_date
cancelled_affiliations class-attribute instance-attribute
cancelled_affiliations = field(default_factory=list)
discrepancy_note class-attribute instance-attribute
discrepancy_note = None
employments instance-attribute
employments
first_name instance-attribute
first_name
last_name instance-attribute
last_name

DiscrepancyNoteInfo dataclass

DiscrepancyNoteInfo(comment, noted_by_full_name, noted_at)

Bases: DataClassJsonMixin

Info about a note added to a discrepancy.

comment instance-attribute
comment
noted_at instance-attribute
noted_at
noted_by_full_name instance-attribute
noted_by_full_name

LastVisit dataclass

LastVisit(date, visit_type)

Bases: DataClassJsonMixin

Information about the last occupational health visit.

date instance-attribute
date
visit_type instance-attribute
visit_type

MemberToAffiliate dataclass

MemberToAffiliate(
    row_index,
    first_name,
    last_name,
    birthdate,
    ssn,
    professional_email,
    personal_email,
    proposed_user_id,
    proposed_user_name,
    proposed_birthdate,
    matched_name,
    matched_birthdate,
    matching_result,
    affiliations,
    employments,
    target_start_date,
    target_end_date,
    last_visit,
    risk_category,
    extra_data,
)

Bases: DataClassJsonMixin

Result of matching a spreadsheet row.

affiliations instance-attribute
affiliations
birthdate instance-attribute
birthdate
employments instance-attribute
employments
extra_data instance-attribute
extra_data
first_name instance-attribute
first_name
last_name instance-attribute
last_name
last_visit instance-attribute
last_visit
matched_birthdate instance-attribute
matched_birthdate
matched_name instance-attribute
matched_name
matching_result instance-attribute
matching_result
personal_email instance-attribute
personal_email
professional_email instance-attribute
professional_email
proposed_birthdate instance-attribute
proposed_birthdate
proposed_user_id instance-attribute
proposed_user_id
proposed_user_name instance-attribute
proposed_user_name
risk_category instance-attribute
risk_category
row_index instance-attribute
row_index
ssn instance-attribute
ssn
target_end_date instance-attribute
target_end_date
target_start_date instance-attribute
target_start_date

NicTimelineEntry dataclass

NicTimelineEntry(nic, start_date, end_date)

Bases: DataClassJsonMixin

A single NIC value with its validity period.

end_date instance-attribute
end_date
nic instance-attribute
nic
start_date instance-attribute
start_date

OccupationalHealthAffiliationDecisionForMarmot dataclass

OccupationalHealthAffiliationDecisionForMarmot(
    id,
    created_at,
    decision,
    employment_change_payload,
    core_employment_id,
    rule_id,
    rule_name,
)

Bases: DataClassJsonMixin

Affiliation decision data for display in Marmot.

core_employment_id instance-attribute
core_employment_id
created_at instance-attribute
created_at
decision instance-attribute
decision
employment_change_payload instance-attribute
employment_change_payload
id instance-attribute
id
rule_id instance-attribute
rule_id
rule_name instance-attribute
rule_name

OccupationalHealthAffiliationDecisionWithProfileForMarmot dataclass

OccupationalHealthAffiliationDecisionWithProfileForMarmot(
    id,
    created_at,
    decision,
    manual_decision,
    employment_change_payload,
    core_employment_id,
    rule_id,
    rule_name,
    first_name,
    last_name,
    birth_date,
    company_id,
    company_name,
    company_name_enriched_from_dsn,
)

Bases: DataClassJsonMixin

Affiliation decision data with profile information for display in Marmot.

birth_date instance-attribute
birth_date
company_id instance-attribute
company_id
company_name instance-attribute
company_name
company_name_enriched_from_dsn instance-attribute
company_name_enriched_from_dsn
core_employment_id instance-attribute
core_employment_id
created_at instance-attribute
created_at
decision instance-attribute
decision
employment_change_payload instance-attribute
employment_change_payload
first_name instance-attribute
first_name
id instance-attribute
id
last_name instance-attribute
last_name
manual_decision instance-attribute
manual_decision
rule_id instance-attribute
rule_id
rule_name instance-attribute
rule_name

OccupationalHealthProfileData dataclass

OccupationalHealthProfileData(
    profile_id,
    past_visits,
    affiliations,
    affiliation_decisions,
    next_visit_result=None,
)

Bases: DataClassJsonMixin

All occupational health data for a profile

affiliation_decisions instance-attribute
affiliation_decisions
affiliations instance-attribute
affiliations
next_visit_result class-attribute instance-attribute
next_visit_result = None
past_visits instance-attribute
past_visits
profile_id instance-attribute
profile_id

RiskCategory

Bases: AlanBaseEnum

Risk categories / "type de suivi" for occupational health visits.

These determine the frequency and type of required medical visits.

SI class-attribute instance-attribute
SI = 'SI'
SIA class-attribute instance-attribute
SIA = 'SIA'
SIR class-attribute instance-attribute
SIR = 'SIR'

SiretWithMemberCount dataclass

SiretWithMemberCount(siret, member_count, pretty_name)

Bases: DataClassJsonMixin

Represent a SIRET with its count of members.

member_count instance-attribute
member_count
pretty_name instance-attribute
pretty_name

Company name from DSN, if available. Format: 'Name (City)' or None if not found.

siret instance-attribute
siret

VisitRequestForMarmot dataclass

VisitRequestForMarmot(
    id,
    visit_request_type,
    visit_type,
    first_name,
    last_name,
    email,
    account_id,
    account_name,
    state_reason,
    state_reason_details,
    user_id,
    default_hp_owner_id,
    visit_format,
    date_planned,
    hour_booked,
    hour_end,
    from_refer_to_doctor,
    hr_informed,
)

Bases: DataClassJsonMixin

A visit request to review in Marmot.

account_id instance-attribute
account_id
account_name instance-attribute
account_name
date_planned instance-attribute
date_planned
default_hp_owner_id instance-attribute
default_hp_owner_id
email instance-attribute
email
first_name instance-attribute
first_name
from_refer_to_doctor instance-attribute
from_refer_to_doctor
hour_booked instance-attribute
hour_booked
hour_end instance-attribute
hour_end
hr_informed instance-attribute
hr_informed
id instance-attribute
id
last_name instance-attribute
last_name
state_reason instance-attribute
state_reason
state_reason_details instance-attribute
state_reason_details
user_id instance-attribute
user_id
visit_format instance-attribute
visit_format
visit_request_type instance-attribute
visit_request_type
visit_type instance-attribute
visit_type

VisitType

Bases: AlanBaseEnum

EXAMEN_MEDICAL_APTITUDE_EMBAUCHE class-attribute instance-attribute
EXAMEN_MEDICAL_APTITUDE_EMBAUCHE = (
    "3.examen_medical_aptitude_embauche"
)
EXAMEN_MEDICAL_APTITUDE_PERIODIQUE class-attribute instance-attribute
EXAMEN_MEDICAL_APTITUDE_PERIODIQUE = (
    "4.examen_medical_aptitude_periodique"
)
VISITE_CESSATION_EXPOSITION class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION = (
    "30.visite_cessation_exposition"
)
VISITE_CESSATION_EXPOSITION_COURS_CARRIERE class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION_COURS_CARRIERE = (
    "31.visite_cessation_exposition_cours_carriere"
)
VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_EMPLOYEUR = "32.visite_cessation_exposition_cours_carriere_initiative_employeur"
VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION_COURS_CARRIERE_INITIATIVE_TRAVAILLEUR = "33.visite_cessation_exposition_cours_carriere_initiative_travailleur"
VISITE_CESSATION_EXPOSITION_FIN_CARRIERE class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION_FIN_CARRIERE = (
    "34.visite_cessation_exposition_fin_carriere"
)
VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_EMPLOYEUR = "35.visite_cessation_exposition_fin_carriere_initiative_employeur"
VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_CESSATION_EXPOSITION_FIN_CARRIERE_INITIATIVE_TRAVAILLEUR = "36.visite_cessation_exposition_fin_carriere_initiative_travailleur"
VISITE_DEMANDE class-attribute instance-attribute
VISITE_DEMANDE = '11.visite_demande'
VISITE_DEMANDE_EMPLOYEUR class-attribute instance-attribute
VISITE_DEMANDE_EMPLOYEUR = '13.visite_demande_employeur'
VISITE_DEMANDE_MEDECIN_TRAVAIL class-attribute instance-attribute
VISITE_DEMANDE_MEDECIN_TRAVAIL = (
    "14.visite_demande_medecin_travail"
)
VISITE_DEMANDE_MEDECIN_TRAVAIL_ORIENTATION_INFIRMIER class-attribute instance-attribute
VISITE_DEMANDE_MEDECIN_TRAVAIL_ORIENTATION_INFIRMIER = "15.visite_demande_medecin_travail_orientation_infirmier"
VISITE_DEMANDE_TRAVAILLEUR class-attribute instance-attribute
VISITE_DEMANDE_TRAVAILLEUR = "12.visite_demande_travailleur"
VISITE_INFORMATION_PREVENTION_INITIALE class-attribute instance-attribute
VISITE_INFORMATION_PREVENTION_INITIALE = (
    "1.visite_information_prevention_initiale"
)
VISITE_INFORMATION_PREVENTION_PERIODIQUE class-attribute instance-attribute
VISITE_INFORMATION_PREVENTION_PERIODIQUE = (
    "2.visite_information_prevention_periodique"
)
VISITE_INTERMEDIAIRE_ENTRE_DEUX_EXAMENS class-attribute instance-attribute
VISITE_INTERMEDIAIRE_ENTRE_DEUX_EXAMENS = (
    "5.visite_intermediaire_entre_deux_examens"
)
VISITE_MI_CARRIERE class-attribute instance-attribute
VISITE_MI_CARRIERE = '29.visite_mi_carriere'
VISITE_PREREPRISE class-attribute instance-attribute
VISITE_PREREPRISE = '6.visite_prereprise'
VISITE_PREREPRISE_INITIATIVE_MEDECIN_CONSEIL class-attribute instance-attribute
VISITE_PREREPRISE_INITIATIVE_MEDECIN_CONSEIL = (
    "7.visite_prereprise_initiative_medecin_conseil"
)
VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAITANT class-attribute instance-attribute
VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAITANT = (
    "8.visite_prereprise_initiative_medecin_traitant"
)
VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAVAIL class-attribute instance-attribute
VISITE_PREREPRISE_INITIATIVE_MEDECIN_TRAVAIL = (
    "10.visite_prereprise_initiative_medecin_travail"
)
VISITE_PREREPRISE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_PREREPRISE_INITIATIVE_TRAVAILLEUR = (
    "9.visite_prereprise_initiative_travailleur"
)
VISITE_REPRISE class-attribute instance-attribute
VISITE_REPRISE = '16.visite_reprise'
VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL class-attribute instance-attribute
VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL = (
    "20.visite_reprise_absence_30_jours_accident_travail"
)
VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_EMPLOYEUR class-attribute instance-attribute
VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_EMPLOYEUR = "21.visite_reprise_absence_30_jours_accident_travail_initiative_employeur"
VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_REPRISE_ABSENCE_30_JOURS_ACCIDENT_TRAVAIL_INITIATIVE_TRAVAILLEUR = "22.visite_reprise_absence_30_jours_accident_travail_initiative_travailleur"
VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE class-attribute instance-attribute
VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE = (
    "23.visite_reprise_absence_maladie_professionnelle"
)
VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute
VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_EMPLOYEUR = "24.visite_reprise_absence_maladie_professionnelle_initiative_employeur"
VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_REPRISE_ABSENCE_MALADIE_PROFESSIONNELLE_INITIATIVE_TRAVAILLEUR = "25.visite_reprise_absence_maladie_professionnelle_initiative_travailleur"
VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO class-attribute instance-attribute
VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO = (
    "17.visite_reprise_cause_maladie_accident_non_pro"
)
VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_EMPLOYEUR class-attribute instance-attribute
VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_EMPLOYEUR = "18.visite_reprise_cause_maladie_accident_non_pro_initiative_employeur"
VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_REPRISE_CAUSE_MALADIE_ACCIDENT_NON_PRO_INITIATIVE_TRAVAILLEUR = "19.visite_reprise_cause_maladie_accident_non_pro_initiative_travailleur"
VISITE_REPRISE_CONGE_MATERNITE class-attribute instance-attribute
VISITE_REPRISE_CONGE_MATERNITE = (
    "26.visite_reprise_conge_maternite"
)
VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_EMPLOYEUR class-attribute instance-attribute
VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_EMPLOYEUR = (
    "27.visite_reprise_conge_maternite_initiative_employeur"
)
VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_TRAVAILLEUR class-attribute instance-attribute
VISITE_REPRISE_CONGE_MATERNITE_INITIATIVE_TRAVAILLEUR = "28.visite_reprise_conge_maternite_initiative_travailleur"
detect_from_string classmethod
detect_from_string(visit_type_str)

Detect the visit type from a string.

Source code in components/occupational_health/internal/enums/visit_type.py
@classmethod
def detect_from_string(cls, visit_type_str: str) -> "VisitType":
    """
    Detect the visit type from a string.
    """
    try:
        return VisitType(visit_type_str)
    except ValueError:
        pass

    mapping = {
        "Visite d'embauche": VisitType.VISITE_INFORMATION_PREVENTION_INITIALE,
        "VIP": VisitType.VISITE_INFORMATION_PREVENTION_INITIALE,
        "Visite périodique": VisitType.VISITE_INFORMATION_PREVENTION_PERIODIQUE,
        "Visite de reprise": VisitType.VISITE_REPRISE,
        "Visite à la demande": VisitType.VISITE_DEMANDE,
        # not 100% sure about the original meaning of this one, but limited number of cases
        "Visite de suivi": VisitType.VISITE_DEMANDE_MEDECIN_TRAVAIL,
    }

    if visit_type_str not in mapping:
        raise ValueError(f"Visit type not found in mapping: {visit_type_str}")

    return mapping[visit_type_str]
level_1_category property
level_1_category

enums

MovementType

Bases: AlanBaseEnum

The recorded movement: either an affiliation, or a termination, or a cancellation.

Transfers are composed by a termination and an affiliation, if they moved between two distinct accounts.

AFFILIATION class-attribute instance-attribute
AFFILIATION = 'affiliation'
CANCELLATION class-attribute instance-attribute
CANCELLATION = 'cancellation'
TERMINATION class-attribute instance-attribute
TERMINATION = 'termination'

queries

Queries for use in Marmot controllers.

InvalidInputDataError

Bases: ValueError

The input data is not valid.

analyze_discrepancy_fixes_for_marmot

analyze_discrepancy_fixes_for_marmot(discrepancies)

Analyze multiple discrepancies and suggest fixes.

Returns a mapping of user_id to fix proposition (or None if no fix found).

Source code in components/occupational_health/public/marmot/queries.py
@tracer_wrap()
def analyze_discrepancy_fixes_for_marmot(
    discrepancies: list[DiscrepancyForMarmot],
) -> dict[UserId, DiscrepancyFixProposition | None]:
    """
    Analyze multiple discrepancies and suggest fixes.

    Returns a mapping of user_id to fix proposition (or None if no fix found).
    """
    from sqlalchemy import select

    from components.occupational_health.internal.business_logic.contracting.queries.contracting import (
        get_contract_timeline_for_account,
    )
    from components.occupational_health.internal.business_logic.queries.affiliation.affiliation_tool import (
        detect_date_format,
    )
    from components.occupational_health.internal.business_logic.queries.affiliation.affiliations import (
        get_all_affiliations,
    )
    from components.occupational_health.internal.business_logic.queries.affiliation.discrepancy_fix_analysis import (
        analyze_discrepancy_fix,
    )
    from components.occupational_health.internal.models.occupational_health_mass_affiliation_log import (
        OccupationalHealthMassAffiliationLog,
    )
    from shared.helpers.db import current_session

    if not discrepancies:
        return {}

    # All discrepancies belong to the same account
    account_id = discrepancies[0].account_id

    # Fetch contract start date once
    timeline = get_contract_timeline_for_account(account_id)
    contract_start_date = (
        timeline.periods[0].validity_period.start_date
        if timeline and timeline.periods
        else None
    )

    # Fetch all mass affiliation logs for the account in one query, indexed by affiliation_id
    all_logs = current_session.scalars(
        select(OccupationalHealthMassAffiliationLog).where(
            OccupationalHealthMassAffiliationLog.account_id == account_id,
        )
    ).all()
    logs_by_affiliation_id = {log.affiliation_id: log for log in all_logs}

    # Detect a consistent date format across all log date strings
    all_date_strings: set[str] = set()
    for log in all_logs:
        for key in ("date_debut_de_contrat", "date_fin_de_contrat_optionnel"):
            value = log.extra_data.get(key)
            if isinstance(value, str) and value.strip():
                all_date_strings.add(value.strip())
    try:
        date_format = (
            detect_date_format(all_date_strings, ignore_non_date=True)
            if all_date_strings
            else "%Y-%m-%d"
        )
    except InvalidInputDataError as e:
        raise BaseErrorCode.invalid_arguments(description=str(e)) from e

    fixes: dict[UserId, DiscrepancyFixProposition | None] = {}

    for discrepancy in discrepancies:
        # Skip if missing data or ambiguous (multiple employments)
        if not discrepancy.profile_id or len(discrepancy.employments) != 1:
            fixes[discrepancy.user_id] = None
            continue

        affiliations = get_all_affiliations(
            discrepancy.profile_id, discrepancy.account_id
        )
        # Skip if no affiliation or ambiguous (multiple affiliations)
        if not affiliations or len(affiliations) > 1:
            fixes[discrepancy.user_id] = None
            continue

        mass_log = logs_by_affiliation_id.get(affiliations[0].affiliation_id)
        if not mass_log:
            fixes[discrepancy.user_id] = None
            continue

        fixes[discrepancy.user_id] = analyze_discrepancy_fix(
            discrepancy=discrepancy,
            affiliation_id=affiliations[0].affiliation_id,
            contract_start_date=contract_start_date,
            mass_log=mass_log,
            date_format=date_format,
        )

    return fixes

get_accounts_list_for_marmot

get_accounts_list_for_marmot()

Return the list of accounts with occupational health contracts for Marmot.

Source code in components/occupational_health/public/marmot/queries.py
@tracer_wrap()
def get_accounts_list_for_marmot() -> list[AccountForMarmot]:
    """
    Return the list of accounts with occupational health contracts for Marmot.
    """
    from components.occupational_health.internal.business_logic.contracting.queries.contracting import (
        get_all_account_ids_with_contract,
    )
    from components.occupational_health.public.dependencies import (
        get_app_dependency,
    )

    dependency = get_app_dependency()

    accounts = [
        AccountForMarmot(
            id=account_id,
            name=dependency.get_account_name(account_id=account_id),
        )
        for account_id in get_all_account_ids_with_contract()
    ]

    # Sort by name for consistent ordering
    return sorted(accounts, key=lambda a: a.name)

get_admin_accounts_from_user_for_marmot

get_admin_accounts_from_user_for_marmot(user_id)

Returns the list of accounts for the current admin user

Source code in components/occupational_health/public/marmot/queries.py
def get_admin_accounts_from_user_for_marmot(
    user_id: str,
) -> list[OccupationalHealthDashboardAdmin]:
    """
    Returns the list of accounts for the current admin user
    """
    return get_accounts_for_admin(UserId(user_id))

get_affiliated_members_for_marmot

get_affiliated_members_for_marmot(
    filter_by_account_id=None,
)

Return the list of affiliated members (including past ones) for display in Marmot.

Source code in components/occupational_health/public/marmot/queries.py
@tracer_wrap()
def get_affiliated_members_for_marmot(
    filter_by_account_id: AccountId | UUID | None = None,
) -> list[AffiliatedMemberForMarmot]:
    """
    Return the list of affiliated members (including past ones) for display in Marmot.
    """
    from components.affiliation_occ_health.public.api import (
        get_affiliation_list,
    )
    from components.occupational_health.internal.business_logic.queries.marmot import (
        get_affiliated_members_from_affiliations_for_marmot,
    )
    from components.occupational_health.internal.helpers.affiliation import (
        build_subscription_ref,
    )

    if filter_by_account_id:
        affiliations = get_affiliation_list(
            AffiliationService.OCCUPATIONAL_HEALTH,
            subscription_ref=build_subscription_ref(AccountId(filter_by_account_id)),
        )
    else:
        affiliations = get_affiliation_list(AffiliationService.OCCUPATIONAL_HEALTH)

    return get_affiliated_members_from_affiliations_for_marmot(
        affiliations=affiliations
    )

get_affiliation_decisions_for_marmot

get_affiliation_decisions_for_marmot(
    profile_service,
    account_id,
    decision=None,
    not_older_than=None,
)

Return the list of affiliation decisions, optionally filtered by decision type and date.

Source code in components/occupational_health/public/marmot/queries.py
@tracer_wrap()
@inject_profile_service
def get_affiliation_decisions_for_marmot(
    profile_service: ProfileService,
    account_id: AccountId,
    decision: AffiliationDecision | None = None,
    not_older_than: date | None = None,
) -> list[OccupationalHealthAffiliationDecisionWithProfileForMarmot]:
    """
    Return the list of affiliation decisions, optionally filtered by decision type and date.
    """
    company_ids = get_company_ids_in_account(account_id)
    query = (
        current_session.query(OccupationalHealthAffiliationDecision)  # noqa: ALN085
        .join(OccupationalHealthAffiliationDecision.rule, isouter=True)
        .join(OccupationalHealthAffiliationDecision.profile)
        .options(
            joinedload(OccupationalHealthAffiliationDecision.rule),
            joinedload(OccupationalHealthAffiliationDecision.profile),
            raiseload("*"),
        )
        .filter(
            cast(
                OccupationalHealthAffiliationDecision.employment_change_payload[
                    "core_employment_version"
                ]["company_id"].astext,
                Integer,
            ).in_(company_ids),
        )
        .order_by(OccupationalHealthAffiliationDecision.created_at.desc())
    )

    if decision:
        query = query.filter(OccupationalHealthAffiliationDecision.decision == decision)

    if not_older_than:
        query = query.filter(
            OccupationalHealthAffiliationDecision.created_at >= not_older_than
        )

    decisions = query.all()

    # Get all global profile IDs
    global_profile_ids = {decision.profile.global_profile_id for decision in decisions}  # type: ignore[union-attr]
    profiles = profile_service.get_profiles(profile_ids=global_profile_ids)
    profiles_per_global_id = {profile.id: profile for profile in profiles}

    # Get company names
    company_names = get_company_names_by_ids(set(company_ids))
    company_names_from_company_id_and_nic = _build_company_names_by_company_id_and_nic(
        [decision.employment_change_payload for decision in decisions]
    )

    return [
        _build_decision(
            decision,
            profiles_per_global_id.get(decision.profile.global_profile_id),  # type: ignore[union-attr]
            company_names,
            company_names_from_company_id_and_nic,
        )
        for decision in decisions
    ]

get_all_admins_for_account

get_all_admins_for_account(account_id, profile_service)

Get all admins for a given account.

Source code in components/occupational_health/internal/business_logic/queries/admin_dashboard/admin_authorization.py
@inject_profile_service
def get_all_admins_for_account(
    account_id: AccountId,
    profile_service: ProfileService,
) -> list[DashboardAdmin]:
    """
    Get all admins for a given account.
    """
    stmt = select(OccupationalHealthDashboardAdmin).filter(
        OccupationalHealthDashboardAdmin.account_id == account_id,
        OccupationalHealthDashboardAdmin.scope
        == OccupationalHealthDashboardAdminScope.ACCOUNT,
    )
    result = current_session.execute(stmt).scalars().all()

    user_ids = {admin.user_id for admin in result}
    profiles = profile_service.user_compat.get_user_profile_mapping(user_ids)
    profile_by_user_id = {
        str(user_id): profile for user_id, profile in profiles.user_to_profile.items()
    }

    return [
        DashboardAdmin(
            admin_id=admin.id,
            user_id=admin.user_id,
            # Will break if a user_id was merged into another one and no longer exists
            first_name=profile_by_user_id[admin.user_id].first_name,
            last_name=profile_by_user_id[admin.user_id].last_name,
            account_id=admin.account_id,
        )
        for admin in result
    ]

get_companies_with_affiliated_count_for_marmot

get_companies_with_affiliated_count_for_marmot(account_id)

Return the list of all companies in an account with their count of affiliated members.

Source code in components/occupational_health/public/marmot/queries.py
@tracer_wrap()
def get_companies_with_affiliated_count_for_marmot(
    account_id: AccountId | UUID,
) -> list[CompanyWithAffiliatedCount]:
    """
    Return the list of all companies in an account with their count of affiliated members.
    """
    from components.occupational_health.internal.business_logic.queries.employments import (
        get_employments_by_profile_id,
    )

    account_id = AccountId(account_id)

    # Get all companies in the account
    company_ids = get_company_ids_in_account(account_id)
    company_names = get_company_names_by_ids(company_ids=set(company_ids))

    # Get all affiliations for this account (including terminated, upcoming, but not cancelled ones).
    affiliations = get_all_affiliations_for_account(account_id=account_id)

    # Fetch employments
    profile_ids = {affiliation.profile_id for affiliation in affiliations}
    employments_by_profile_id = get_employments_by_profile_id(
        account_id=account_id,
        profile_ids=profile_ids,
        should_overlap_prevenir_contract=False,
    )

    # Count unique affiliated members per company and collect SIRETs
    company_affiliated_member_ids: dict[CompanyId, set[ProfileId]] = {}
    # Map: company_id -> siret -> set of profile_ids
    profile_ids_by_siret_by_company_id: dict[CompanyId, dict[str, set[ProfileId]]] = {}
    dependency = get_app_dependency()

    siren_per_company = {
        company_id: dependency.get_company_siren(company_id)
        for company_id in company_ids
    }
    siret_per_company = {
        company_id: dependency.get_company_siret(company_id)
        for company_id in company_ids
    }

    nic_per_employment_id = get_nic_for_employments(
        on_date_per_employment_id={
            employment.employment_id: clamp(
                utctoday(), employment.start_date, employment.end_date
            )
            for employments in employments_by_profile_id.values()
            for employment in employments
        }
    )

    for profile_id, employments in employments_by_profile_id.items():
        for employment in employments:
            company_id = CompanyId(employment.company_id)
            if company_id not in company_ids:
                raise RuntimeError(
                    f"Encountered employment from out-of-account company:"
                    f" {employment.employment_id} has company ID {company_id} which is not in account {company_ids}."
                )

            # Count members per company
            if company_id not in company_affiliated_member_ids:
                company_affiliated_member_ids[company_id] = set()
            company_affiliated_member_ids[company_id].add(profile_id)

            nic = nic_per_employment_id[employment.employment_id]
            if not nic:
                # Trust first the Employment Component, then the Company configuration
                siret = siret_per_company[company_id]
                if siret:
                    nic = siret[-5:]

            if nic:
                siren = siren_per_company[company_id]
                if siren:
                    siret = f"{siren}{nic}"

                    if company_id not in profile_ids_by_siret_by_company_id:
                        profile_ids_by_siret_by_company_id[company_id] = defaultdict(
                            set
                        )
                    profile_ids_by_siret_by_company_id[company_id][siret].add(
                        profile_id
                    )

    # Build the result list with all companies (even those with 0 affiliated members)
    result = []
    for company_id in company_ids:
        count = len(company_affiliated_member_ids.get(company_id, set()))
        profile_ids_by_siret = profile_ids_by_siret_by_company_id.get(company_id, {})

        company_data_per_siret = {
            siret: dependency.get_company_from_siret(siret=siret)
            for siret in profile_ids_by_siret.keys()
            if siret
        }

        # Fetch company names from DSN for each SIRET
        sirets = []
        for siret, profile_ids in profile_ids_by_siret.items():
            pretty_name = None
            company_data = company_data_per_siret.get(siret)
            if company_data and company_data.name:
                pretty_name = (
                    f"{company_data.name} ({company_data.city})"
                    if company_data.city
                    else company_data.name
                )

            sirets.append(
                SiretWithMemberCount(
                    siret=siret,
                    member_count=len(profile_ids),
                    pretty_name=pretty_name,
                )
            )

        # Sort SIRETs by member count descending, then by SIRET
        sirets.sort(key=lambda s: (-s.member_count, s.siret))

        result.append(
            CompanyWithAffiliatedCount(
                company_id=company_id,
                company_display_name=company_names.get(
                    company_id, f"Company {company_id}"
                ),
                ever_affiliated_members_count=count,
                sirets=sirets,
            )
        )

    # Sort by company display name for consistent ordering
    return sorted(result, key=lambda c: c.company_display_name)

get_contract_info_for_account

get_contract_info_for_account(account_id)

Get contract information for an account.

Source code in components/occupational_health/internal/business_logic/queries/affiliation/affiliation_tool.py
def get_contract_info_for_account(account_id: AccountId) -> ContractInfoResult:
    """Get contract information for an account."""
    try:
        account_name = get_account_name(account_id)
    except NoResultFound:
        raise BaseErrorCode.missing_resource(f"Account {account_id} not found")
    timeline = get_contract_timeline_for_account(account_id)

    contracts: list[ContractInfo] = []
    if timeline:
        for version in timeline:
            contracts.append(
                ContractInfo(
                    account_id=account_id,
                    start_date=version.validity_period.start_date,
                    end_date=version.validity_period.end_date,
                )
            )

    return ContractInfoResult(
        account_name=account_name,
        contracts=contracts,
    )

get_occupational_health_profile_data_for_marmot

get_occupational_health_profile_data_for_marmot(
    profile_id, account_id=None
)

Return all occupational health data for a profile, for display in Marmot.

Source code in components/occupational_health/public/marmot/queries.py
@tracer_wrap()
def get_occupational_health_profile_data_for_marmot(
    profile_id: ProfileId | UUID,
    account_id: AccountId | None = None,
) -> OccupationalHealthProfileData:
    """
    Return all occupational health data for a profile, for display in Marmot.
    """
    from components.occupational_health.external.global_profile import (
        get_user_id_from_occupational_health_profile_id,
    )
    from components.occupational_health.internal.business_logic.queries.marmot import (
        get_affiliated_members_from_affiliations_for_marmot,
    )
    from components.occupational_health.internal.business_logic.queries.visits.exceptions import (
        NoAffiliationsFoundError,
    )
    from components.occupational_health.internal.business_logic.queries.visits.visits import (
        get_all_past_visits_from_all_sources_by_profile,
        get_computed_next_visit_result,
    )
    from components.occupational_health.internal.models.occupational_health_affiliation_decision import (
        OccupationalHealthAffiliationDecision,
    )
    from shared.helpers.db import current_session

    profile_id = ProfileId(profile_id)

    affiliations = get_all_affiliations(profile_id, account_id)
    affiliated_members = get_affiliated_members_from_affiliations_for_marmot(
        affiliations=affiliations
    )

    # Get past visits
    user_id = get_user_id_from_occupational_health_profile_id(profile_id=profile_id)
    past_visits_by_profile_id = get_all_past_visits_from_all_sources_by_profile(
        profile_ids=[profile_id], user_id_mapping={user_id: profile_id}
    )
    past_visits = past_visits_by_profile_id[profile_id]

    # Get all affiliation decisions
    affiliation_decisions = (
        current_session.query(OccupationalHealthAffiliationDecision)  # noqa: ALN085
        .filter(OccupationalHealthAffiliationDecision.profile_id == profile_id)
        .options(
            joinedload(OccupationalHealthAffiliationDecision.rule),
            raiseload("*"),
        )
    )

    # Serialize affiliation decisions
    serialized_decisions = []
    for decision in affiliation_decisions:
        serialized_decisions.append(
            OccupationalHealthAffiliationDecisionForMarmot(
                id=decision.id,
                created_at=decision.created_at.date(),
                decision=decision.decision,
                employment_change_payload=decision.employment_change_payload,
                core_employment_id=decision.core_employment_id,
                rule_id=decision.rule_id,
                rule_name=decision.rule.name if decision.rule else None,
            )
        )

    try:
        next_visit_result = get_computed_next_visit_result(
            profile_id=profile_id,
            affiliations=affiliations,
        )
    except NoAffiliationsFoundError:
        next_visit_result = None

    return OccupationalHealthProfileData(
        profile_id=profile_id,
        past_visits=past_visits,
        affiliations=affiliated_members,
        affiliation_decisions=serialized_decisions,
        next_visit_result=next_visit_result,
    )

get_recent_affiliation_movements

get_recent_affiliation_movements(
    profile_service, account_id, not_older_than
)

Return the list of affiliation decisions made since the given date for the given account.

Source code in components/occupational_health/public/marmot/queries.py
@inject_profile_service
def get_recent_affiliation_movements(
    profile_service: ProfileService,
    account_id: AccountId | UUID,
    not_older_than: date,
) -> list[AffiliationMovementForMarmot]:
    """
    Return the list of affiliation decisions made since the given date for the given account.
    """
    account_id = AccountId(account_id)

    movements = (
        current_session.query(  # noqa: ALN085
            OccupationalHealthProfile.global_profile_id,
            OccupationalHealthAffiliationMovement.id,
            OccupationalHealthAffiliationMovement.account_id,
            OccupationalHealthAffiliationMovement.created_at,
            OccupationalHealthAffiliationMovement.movement_type,
            OccupationalHealthAffiliationMovement.affiliation_decision_id,
            OccupationalHealthAffiliationMovement.profile_id,
            OccupationalHealthAffiliationMovement.employment_change_payload,
        )
        .select_from(OccupationalHealthAffiliationMovement)
        .join(OccupationalHealthAffiliationMovement.profile)
        .filter(
            OccupationalHealthAffiliationMovement.account_id == account_id,
            OccupationalHealthAffiliationMovement.created_at >= not_older_than,
        )
        .options(raiseload("*"))
        .all()
    )

    global_profile_ids = {movement.global_profile_id for movement in movements}
    profiles = profile_service.get_profiles(
        profile_ids=global_profile_ids,
    )
    profiles_per_global_id = {profile.id: profile for profile in profiles}

    company_ids = {
        movement.employment_change_payload["core_employment_version"]["company_id"]
        for movement in movements
        if movement.employment_change_payload
    }
    company_names = get_company_names_by_ids(company_ids)

    company_names_from_company_id_and_nic = _build_company_names_by_company_id_and_nic(
        [movement.employment_change_payload for movement in movements]
    )

    affiliation_movements_to_return: list[AffiliationMovementForMarmot] = []

    for movement in movements:
        if not movement.employment_change_payload:
            movement_company_id = None
            movement_company_name = None
            company_name_enriched_from_dsn = False
        else:
            movement_company_id = movement.employment_change_payload[
                "core_employment_version"
            ]["company_id"]
            nic = extract_nic_from_employment_change(
                EmploymentChange.from_dict(movement.employment_change_payload)
            )
            company_name_from_dsn = (
                company_names_from_company_id_and_nic.get((movement_company_id, nic))
                if nic
                else None
            )
            movement_company_name = (
                company_name_from_dsn
                # Fall back on the usual Company.name data
                or company_names[movement_company_id]
            )
            company_name_enriched_from_dsn = company_name_from_dsn is not None

        affiliation_movements_to_return.append(
            AffiliationMovementForMarmot(
                movement_id=movement.id,
                account_id=account_id,
                birth_date=profiles_per_global_id[
                    movement.global_profile_id
                ].birth_date,
                first_name=profiles_per_global_id[movement.global_profile_id].first_name
                or "",
                last_name=profiles_per_global_id[movement.global_profile_id].last_name
                or "",
                created_at=movement.created_at,
                profile_id=movement.profile_id,
                movement_type=movement.movement_type,
                date_of_movement=_get_date_of_movement(movement),
                decision_id=movement.affiliation_decision_id,
                company_id=movement_company_id,
                company_name=movement_company_name,
                company_name_enriched_from_dsn=company_name_enriched_from_dsn,
            )
        )

    return affiliation_movements_to_return

get_visits_for_marmot

get_visits_for_marmot(
    user_id=None,
    include_on_demand_visits=True,
    include_predictable_visits=False,
)

Fetch on-demand visits (Marmot visit scheduling tool) but will also fetch predictable visits in the future.

Source code in components/occupational_health/public/marmot/visit_management/queries/visit_scheduling.py
def get_visits_for_marmot(
    user_id: Optional[str] = None,
    include_on_demand_visits: bool = True,
    include_predictable_visits: bool = False,
) -> list["VisitForMarmot"]:
    """Fetch on-demand visits (Marmot visit scheduling tool) but will also fetch predictable visits in the future."""
    from components.occupational_health.internal.business_logic.queries.visits.visit_scheduling import (
        get_all_visits_for_marmot,
    )

    return get_all_visits_for_marmot(
        user_id=user_id,
        include_predictable_visits=include_predictable_visits,
        include_on_demand_visits=include_on_demand_visits,
    )

parse_and_match_spreadsheet

parse_and_match_spreadsheet(
    profile_service,
    account_id,
    spreadsheet_data,
    raise_on_invalid_data=False,
)

Parse spreadsheet data and return matching results (members that will could affiliate)

Source code in components/occupational_health/internal/business_logic/queries/affiliation/affiliation_tool.py
@inject_profile_service
@tracer_wrap()
def parse_and_match_spreadsheet(
    profile_service: ProfileService,
    account_id: AccountId,
    spreadsheet_data: str,
    raise_on_invalid_data: bool = False,
) -> ParsingAndMatchingResult:
    """
    Parse spreadsheet data and return matching results (members that will could affiliate)
    """
    from components.fr.internal.fr_employment_data_sources.business_logic.queries.user import (  # noqa: ALN043 - function should be made public (or existing global one made compatible)
        get_user_from_personal_informations,
    )
    from components.fr.internal.fr_employment_data_sources.entities.employee_personal_informations import (  # noqa: ALN043 - function should be made public (or existing global one made compatible)
        EmployeePersonalInformations,
    )

    parsing_warnings = []

    rows = parse_spreadsheet_data(spreadsheet_data)

    new_warnings = _sanitize_ssns(
        rows,
        raise_on_invalid_or_duplicate_ssn=raise_on_invalid_data,
    )
    parsing_warnings.extend(new_warnings)

    # Raise on duplicate proposed user IDs
    _sanitize_data(rows)

    # Get all affiliations for the account to check if users are already affiliated
    all_affiliations = get_all_affiliations_for_account(account_id)

    # Build a set of affiliated user IDs
    affiliations_by_user_ids: dict[UserId, Affiliation] = {}
    for affiliation in all_affiliations:
        # TODO: improve performance (N+1 query)
        user_id = get_user_id_from_occupational_health_profile_id(
            affiliation.profile_id
        )
        affiliations_by_user_ids[user_id] = affiliation

    # Find the contract start date
    contract_timeline = get_contract_timeline_for_account(account_id)
    if not contract_timeline:
        raise ValueError(f"Couldn't find any contract for account {account_id}")
    default_start_date = contract_timeline.start_date

    # Collect user IDs for employment lookup
    user_ids_for_employment: set[UserId] = set()

    # Detect the date format for the entire file
    all_birthdates: set[str] = set()
    all_visit_dates: set[str] = set()
    all_contract_start_dates: set[str] = set()
    all_contract_end_dates: set[str] = set()
    for row in rows:
        # Collect birthdates
        if row.get("date_naissance_gsheet"):
            all_birthdates.add(row["date_naissance_gsheet"])
        # Collect last visit dates
        if row.get("date_de_derniere_visite"):
            all_visit_dates.add(row["date_de_derniere_visite"].strip())
        # Collect contract start dates
        if row.get("date_debut_de_contrat"):
            all_contract_start_dates.add(row["date_debut_de_contrat"].strip())
        # Collect contract end dates
        if row.get("date_fin_de_contrat_optionnel"):
            all_contract_end_dates.add(row["date_fin_de_contrat_optionnel"].strip())

    birthdate_format = detect_date_format(all_birthdates)
    visit_date_format = detect_date_format(all_visit_dates)
    contract_start_date_format = detect_date_format(all_contract_start_dates)
    contract_end_date_format = detect_date_format(all_contract_end_dates)

    matches: list[MemberToAffiliate] = []

    for row_index, row in enumerate(rows):
        # TODO: support aliases for column names
        first_name = row.get("prenom_gsheet", "").strip()
        last_name = row.get("nom_gsheet", "").strip()
        birthdate_str = row.get("date_naissance_gsheet")
        birthdate = (
            datetime.strptime(birthdate_str, birthdate_format).date()
            if birthdate_str
            else None
        )
        ssn = row.get("ssn_original", "").strip()
        email_pro_gsheet_raw = row.get("email_pro_gsheet", "").strip()
        email_perso_gsheet_raw = row.get("email_perso_gsheet", "").strip()

        # Validate and normalize professional email
        email_pro_gsheet, email_pro_warning = _validate_email(
            row_index=row_index,
            email=email_pro_gsheet_raw,
            field_name="email_pro_gsheet",
        )
        if email_pro_warning is not None:
            parsing_warnings.append(email_pro_warning)

        # Validate and normalize personal email
        email_perso_gsheet, email_perso_warning = _validate_email(
            row_index=row_index,
            email=email_perso_gsheet_raw,
            field_name="email_perso_gsheet",
        )
        if email_perso_warning is not None:
            parsing_warnings.append(email_perso_warning)

        # Parse risk category from 'suivi_si_sia_sir' column
        risk_category, risk_category_warning = parse_risk_category(
            row_index=row_index,
            risk_category_str=row.get("suivi_si_sia_sir"),
        )
        if risk_category_warning is not None:
            parsing_warnings.append(risk_category_warning)

        # Parse last visit information
        last_visit, warning = _parse_last_visit(
            row_index=row_index,
            last_visit_date_str=row.get("date_de_derniere_visite", "").strip(),
            last_visit_type_str=row.get("type_de_visite", "").strip(),
            visit_date_format=visit_date_format,
        )
        if warning is not None:
            parsing_warnings.append(warning)

        # Parse contract start date (per-member override)
        contract_start_date_str = row.get("date_debut_de_contrat", "").strip()
        if contract_start_date_str:
            try:
                affiliation_start_date = datetime.strptime(
                    contract_start_date_str, contract_start_date_format
                ).date()
                # clamp on contract start_date
                affiliation_start_date = max(affiliation_start_date, default_start_date)
            except ValueError:
                parsing_warnings.append(
                    ParsingWarning(
                        row_index=row_index,
                        value=contract_start_date_str,
                        message=f"Invalid date format for contract start date: {contract_start_date_str} - expected format: {contract_start_date_format}",
                        category=ParsingWarningCategory.INVALID_CONTRACT_START_DATE,
                    )
                )
                affiliation_start_date = default_start_date
        else:
            affiliation_start_date = default_start_date
        # Parse optional contract end date
        contract_end_date_str = row.get("date_fin_de_contrat_optionnel", "").strip()
        affiliation_end_date: date | None = None
        if contract_end_date_str:
            try:
                affiliation_end_date = datetime.strptime(
                    contract_end_date_str, contract_end_date_format
                ).date()
            except ValueError:
                parsing_warnings.append(
                    ParsingWarning(
                        row_index=row_index,
                        value=contract_end_date_str,
                        message=f"Invalid date format for contract end date: {contract_end_date_str} - expected format: {contract_end_date_format}",
                        category=ParsingWarningCategory.INVALID_CONTRACT_END_DATE,
                    )
                )

        # Skip row if end date is before start date
        if affiliation_end_date and affiliation_end_date < affiliation_start_date:
            parsing_warnings.append(
                ParsingWarning(
                    row_index=row_index,
                    value=contract_end_date_str,
                    message=f"End date {affiliation_end_date} is before contract or affiliation start date {affiliation_start_date}, skipping row",
                    category=ParsingWarningCategory.END_DATE_BEFORE_START_DATE,
                )
            )
            continue

        # Create EmployeePersonalInformations for matching
        personal_informations = EmployeePersonalInformations(
            first_name=first_name,
            known_last_names={
                # we could inject the birth last name if available
                last_name
            },
            email=email_pro_gsheet,
            ssn=ssn.strip(),
            birth_date=birthdate,
            auto_compute_ssn_key=True,  # Auto-compute SSN key if needed
            # TODO: consider using siren / company external IDs when available
        )

        # Match user (company is optional)
        # NOTE: might need to move this back into the fr component / component injection until it gets globalized
        matching_result = get_user_from_personal_informations(
            personal_informations=personal_informations,
            company=None,
        )

        # Get matched user data if found
        if matching_result.matched_user_id is not None:
            matched_global_profile_id = (
                profile_service.user_compat.get_profile_id_by_user_id(
                    user_id=matching_result.matched_user_id
                )
            )
            if matched_global_profile_id is None:
                raise BaseErrorCode.missing_resource(
                    f"User ID {matching_result.matched_user_id} has no global profile"
                )

            matched_profile = profile_service.get_profile(
                profile_id=matched_global_profile_id
            )
        else:
            matched_profile = None

        proposed_user_id = row.get("alan_user_id", "").strip()
        proposed_user_name = None
        proposed_birthdate = None
        if proposed_user_id:
            if (
                matching_result.matched_user_id
                and matched_profile
                and str(matching_result.matched_user_id) == proposed_user_id
            ):
                # Everybody agrees, let's reuse the same name
                proposed_user_name = matched_profile.full_name
                proposed_birthdate = matched_profile.birth_date
            else:
                proposed_global_profile_id = (
                    profile_service.user_compat.get_profile_id_by_user_id(
                        user_id=UserId(proposed_user_id)
                    )
                )
                if proposed_global_profile_id is None:
                    raise BaseErrorCode.missing_resource(
                        f"User ID {proposed_user_id} has no global profile"
                    )

                proposed_profile = profile_service.get_profile(
                    profile_id=proposed_global_profile_id
                )
                if proposed_profile:
                    proposed_user_name = proposed_profile.full_name
                    proposed_birthdate = proposed_profile.birth_date

        relevant_user_ids = {
            UserId(str(matching_result.matched_user_id))
            if matching_result.matched_user_id
            else None,
            UserId(proposed_user_id),
        }
        relevant_user_ids -= {None}
        relevant_affiliations = []
        for relevant_user_id in relevant_user_ids:
            if relevant_user_id in affiliations_by_user_ids:
                affiliation = affiliations_by_user_ids[relevant_user_id]
                relevant_affiliations.append(
                    RelevantAffiliation(
                        user_id=relevant_user_id,
                        start_date=affiliation.start_date,
                        end_date=affiliation.end_date,
                    )
                )

        user_ids_for_employment.update(relevant_user_ids)  # type: ignore[arg-type]

        extra_data_from_row = row.copy()
        # No need to store some of the columns
        extra_data_from_row.pop("marmot_user_link", None)
        extra_data_from_row.pop("marmot_account_link", None)
        extra_data_from_row.pop("marmot_company_link", None)

        matches.append(
            MemberToAffiliate(
                row_index=row_index,
                first_name=first_name,
                last_name=last_name,
                birthdate=birthdate,
                ssn=ssn,
                professional_email=email_pro_gsheet or None,
                personal_email=email_perso_gsheet or None,
                # return None instead of an empty string
                proposed_user_id=proposed_user_id or None,
                proposed_user_name=proposed_user_name,
                proposed_birthdate=proposed_birthdate,
                matching_result=matching_result,
                matched_name=matched_profile.full_name if matched_profile else None,
                matched_birthdate=(
                    matched_profile.birth_date if matched_profile else None
                ),
                affiliations=relevant_affiliations,
                employments=[],  # Will be filled below
                target_start_date=affiliation_start_date,
                target_end_date=affiliation_end_date,
                last_visit=last_visit,
                risk_category=risk_category,
                extra_data=extra_data_from_row,
            )
        )

    # Get employments for all matched users and inject them into matches
    if user_ids_for_employment:
        employments_by_user_id = get_employments_by_user_id(
            account_ids={account_id},
            user_ids=user_ids_for_employment,
            should_overlap_prevenir_contract=False,
        )

        # Build company names
        company_ids = set()
        for employments in employments_by_user_id.values():
            for employment in employments:
                company_ids.add(CompanyId(employment.company_id))
        company_names = get_company_names_by_ids(company_ids)

        # Fill employments in matches
        for match in matches:
            match_user_id: str | int | None = (
                match.matching_result.matched_user_id or match.proposed_user_id
            )
            if match_user_id is None:
                continue
            user_employments = employments_by_user_id.get(
                UserId(str(match_user_id)), []
            )
            match.employments = [
                EmploymentInfo(
                    company_id=employment.company_id,
                    company_name=company_names.get(
                        CompanyId(employment.company_id),
                        "Unknown!",
                    ),
                    start_date=employment.start_date,
                    end_date=employment.end_date,
                )
                for employment in user_employments
            ]

            # Clamp target_start_date to earliest employment start date
            if match.employments:
                earliest_employment_start = min(e.start_date for e in match.employments)
                match.target_start_date = max(
                    match.target_start_date, earliest_employment_start
                )

    # Add warning if we suggest the same user ID for multiple users
    parsing_warnings.extend(_check_for_duplicate_matching_user_ids(matches))

    return ParsingAndMatchingResult(
        matches=matches,
        warnings=parsing_warnings,
    )

visit_management

actions

visit_scheduling
update_on_demand_visit_for_marmot
update_on_demand_visit_for_marmot(
    visit_id, payload, actor_id=None
)

Apply a Marmot edit to an on-demand visit (DB + GSheet, no automations).

Source code in components/occupational_health/public/marmot/visit_management/actions/visit_scheduling.py
def update_on_demand_visit_for_marmot(
    visit_id: UUID,
    payload: dict[str, Any],
    actor_id: Optional[int] = None,
) -> None:
    """Apply a Marmot edit to an on-demand visit (DB + GSheet, no automations)."""
    from components.occupational_health.internal.business_logic.actions.visit_scheduling import (
        update_on_demand_visit,
    )

    update_on_demand_visit(visit_id=visit_id, payload=payload, actor_id=actor_id)
update_predictable_visit_status_for_marmot
update_predictable_visit_status_for_marmot(
    visit_id, new_status, actor_id=None
)

Apply a Marmot status-only edit to a predictable visit (GSheet + DB write).

Source code in components/occupational_health/public/marmot/visit_management/actions/visit_scheduling.py
def update_predictable_visit_status_for_marmot(
    visit_id: UUID,
    new_status: "VisitStatus",
    actor_id: Optional[int] = None,
) -> None:
    """Apply a Marmot status-only edit to a predictable visit (GSheet + DB write)."""
    from components.occupational_health.internal.business_logic.actions.visit_scheduling import (
        update_predictable_visit_status,
    )

    update_predictable_visit_status(
        visit_id=visit_id, new_status=new_status, actor_id=actor_id
    )

queries

visit_scheduling
get_on_demand_visit_for_marmot_by_id
get_on_demand_visit_for_marmot_by_id(visit_id)

Fetch a single on-demand visit by id (Marmot v2 — used by the clash modal).

Source code in components/occupational_health/public/marmot/visit_management/queries/visit_scheduling.py
def get_on_demand_visit_for_marmot_by_id(visit_id: UUID) -> Optional["VisitForMarmot"]:
    """Fetch a single on-demand visit by id (Marmot v2 — used by the clash modal)."""
    from components.occupational_health.internal.business_logic.queries.visits.visit_scheduling import (
        get_on_demand_visit_for_marmot,
    )

    return get_on_demand_visit_for_marmot(visit_id=visit_id)
get_paginated_visits_for_marmot
get_paginated_visits_for_marmot(
    visit_model,
    page=1,
    per_page=100,
    user_ids=None,
    account_id=None,
    health_professional_id=None,
    planned=None,
    statuses=None,
    setup=None,
)

Paginated visits of one model for the Marmot visit scheduling tool.

Source code in components/occupational_health/public/marmot/visit_management/queries/visit_scheduling.py
def get_paginated_visits_for_marmot(
    visit_model: "VisitRequestVisitType",
    page: int = 1,
    per_page: int = 100,
    user_ids: Optional[list[str]] = None,
    account_id: Optional[UUID] = None,
    health_professional_id: Optional[UUID] = None,
    planned: Optional["PlannedFilter"] = None,
    statuses: Optional[list["VisitStatus"]] = None,
    setup: Optional["VisitSetup"] = None,
) -> "PaginatedVisitsForMarmot":
    """Paginated visits of one model for the Marmot visit scheduling tool."""
    from components.occupational_health.internal.business_logic.queries.visits.visit_scheduling import (
        get_paginated_visits_for_marmot_by_model,
    )

    return get_paginated_visits_for_marmot_by_model(
        visit_model=visit_model,
        page=page,
        per_page=per_page,
        user_ids=user_ids,
        account_id=account_id,
        health_professional_id=health_professional_id,
        planned=planned,
        statuses=statuses,
        setup=setup,
    )
get_visits_for_marmot
get_visits_for_marmot(
    user_id=None,
    include_on_demand_visits=True,
    include_predictable_visits=False,
)

Fetch on-demand visits (Marmot visit scheduling tool) but will also fetch predictable visits in the future.

Source code in components/occupational_health/public/marmot/visit_management/queries/visit_scheduling.py
def get_visits_for_marmot(
    user_id: Optional[str] = None,
    include_on_demand_visits: bool = True,
    include_predictable_visits: bool = False,
) -> list["VisitForMarmot"]:
    """Fetch on-demand visits (Marmot visit scheduling tool) but will also fetch predictable visits in the future."""
    from components.occupational_health.internal.business_logic.queries.visits.visit_scheduling import (
        get_all_visits_for_marmot,
    )

    return get_all_visits_for_marmot(
        user_id=user_id,
        include_predictable_visits=include_predictable_visits,
        include_on_demand_visits=include_on_demand_visits,
    )
list_visit_slot_clashes_for_marmot
list_visit_slot_clashes_for_marmot()

List TO_FILL slots booked by more than one visit (Marmot v2 clash banner).

Source code in components/occupational_health/public/marmot/visit_management/queries/visit_scheduling.py
def list_visit_slot_clashes_for_marmot() -> list["VisitSlotClash"]:
    """List TO_FILL slots booked by more than one visit (Marmot v2 clash banner)."""
    from components.occupational_health.internal.business_logic.queries.visits.visit_scheduling import (
        list_visit_slot_clashes,
    )

    return list_visit_slot_clashes()

components.occupational_health.public.queries

admin_dashboard

ConfigForAdminDashboard dataclass

ConfigForAdminDashboard(
    visit_booking_actions,
    account_name,
    google_drive_folder_id=None,
    health_professional_contact_name=None,
    health_professional_notion_url=None,
)

Bases: DataClassJsonMixin

account_name instance-attribute
account_name
google_drive_folder_id class-attribute instance-attribute
google_drive_folder_id = None
health_professional_contact_name class-attribute instance-attribute
health_professional_contact_name = None
health_professional_notion_url class-attribute instance-attribute
health_professional_notion_url = None
visit_booking_actions instance-attribute
visit_booking_actions

get_config_for_admin_dashboard

get_config_for_admin_dashboard(account_id)

Return the config for the admin dashboard, including available visit booking actions, account name and Google Drive folder ID.

Source code in components/occupational_health/internal/business_logic/queries/admin_dashboard/admin_dashboard.py
def get_config_for_admin_dashboard(account_id: AccountId) -> ConfigForAdminDashboard:
    """Return the config for the admin dashboard, including available visit booking actions, account name and Google Drive folder ID."""

    visit_booking_actions = get_available_actions(account_id)

    account_setting = OccupationalHealthAccountSettingBroker.get_by_account_id(
        account_id
    )
    google_drive_folder_id = (
        account_setting.post_visit_google_drive_link if account_setting else None
    )
    if google_drive_folder_id is None:
        current_logger.error(
            "Missing Google Drive folder mapping", account_id=account_id
        )

    config = ConfigForAdminDashboard(
        visit_booking_actions=visit_booking_actions,
        account_name=get_account_name(account_id),
        google_drive_folder_id=google_drive_folder_id,
        health_professional_contact_name=None,
        health_professional_notion_url=None,
    )

    health_professional_id = MAPPING_ACCOUNT_HEALTH_PROFESSIONAL.get(account_id)
    if health_professional_id is None:
        current_logger.error(
            "Missing health professional mapping", account_id=account_id
        )
        return config

    health_professional = get_or_raise_health_professional(health_professional_id)
    health_professional_contact_name = (
        f"{health_professional.first_name} {health_professional.last_name}"
    )
    if health_professional.role == HealthProfessionalRole.DOCTOR:
        health_professional_contact_name = f"Dr. {health_professional_contact_name}"
    config.health_professional_contact_name = health_professional_contact_name

    info = HEALTH_PROFESSIONALS_INFO.get(health_professional_id)
    if info is not None:
        config.health_professional_notion_url = info["notion_url"]

    return config

billed_entity

get_flask_admin_url_from_contract_ref

get_flask_admin_url_from_contract_ref(contract_ref)

Return the flask admin url linking to a billed entity from the contract reference (used for invoice)

Source code in components/occupational_health/public/queries/billed_entity.py
def get_flask_admin_url_from_contract_ref(contract_ref: str) -> str:
    """
    Return the flask admin url linking to a billed entity from the contract reference (used for invoice)
    """
    account_id = contract_ref.split(":")[-2]
    siret_or_siren = contract_ref.split(":")[-1]
    if len(siret_or_siren) == SIREN_LENGTH:
        return f"{current_config['BASE_URL']}/admin/occupationalhealthbilledentity/?flt0_account_id_equals={account_id}&flt1_siren_equals={siret_or_siren}"
    elif len(siret_or_siren) == SIRET_LENGTH:
        return f"{current_config['BASE_URL']}/admin/occupationalhealthbilledentity/?flt0_account_id_equals={account_id}&flt1_siret_equals={siret_or_siren}"
    else:
        raise ValueError(f"Incorrect siret or siren in contract_ref {contract_ref}")

billing

Set of queries used by the Billing stack.

See https://github.com/alan-eu/Topics/discussions/30344?sort=old#discussioncomment-14080373 ⧉

ContractRefNotFound

Bases: Exception

A contract reference was not found.

UnableToDetermineCustomerToBill

Bases: Exception

Unable to determine which customer to bill.

build_invoices_data_for_period

build_invoices_data_for_period(
    billing_period, billing_year
)

Build the data required to generate all invoices for the given billing period and invoice year.

Parameters:

Name Type Description Default
billing_period OccupationalHealthBillingPeriod

The billing period to build data for.

required
billing_year int

The year of the billing period to build data for.

required

The data is not guaranteed to be stable over time for past billing periods, as retroactive affiliation changes can occur.

Returns:

Name Type Description
tuple tuple[list[InvoiceToGenerateData], dict[UUID, dict[str, int | str]]]

A tuple containing: - list[InvoiceToGenerateData]: The data about all invoices to generate for the given period. - dict[UUID, dict[str, int | str]]: Statistics per account with keys "account_name", "invoices_count" and "errors_count".

Source code in components/occupational_health/public/queries/billing.py
def build_invoices_data_for_period(
    billing_period: OccupationalHealthBillingPeriod,
    billing_year: int,
) -> tuple[list[InvoiceToGenerateData], dict[UUID, dict[str, int | str]]]:
    """
    Build the data required to generate all invoices for the given billing period and invoice year.

    Args:
        billing_period: The billing period to build data for.
        billing_year: The year of the billing period to build data for.

    The data is not guaranteed to be stable over time for past billing periods, as
    retroactive affiliation changes can occur.

    Returns:
        tuple: A tuple containing:
            - list[InvoiceToGenerateData]: The data about all invoices to generate for the given period.
            - dict[UUID, dict[str, int | str]]: Statistics per account with keys "account_name", "invoices_count" and "errors_count".
    """
    from components.occupational_health.internal.business_logic.contracting.queries.contracting import (
        get_all_account_ids_with_contract,
    )

    with replica_db_context():
        # Get all customers with an active contract during the given billing period
        account_ids = get_all_account_ids_with_contract(
            active_during_period=billing_period.to_validity_period(year=billing_year),
        )

        dependency = get_app_dependency()
        invoice_to_generate_data_list: list[InvoiceToGenerateData] = []
        stats_per_account: dict[UUID, dict[str, int | str]] = {}

        for account_id in account_ids:
            invoices, errors = build_invoices_data_for_period_and_account(
                billing_period=billing_period,
                billing_year=billing_year,
                account_id=account_id,
            )
            invoice_to_generate_data_list.extend(invoices)
            stats_per_account[account_id] = {
                "account_name": dependency.get_account_name(account_id=account_id),
                "invoices_count": len(invoices),
                "errors_count": len(errors),
            }

        return invoice_to_generate_data_list, stats_per_account

build_invoices_data_for_period_and_account

build_invoices_data_for_period_and_account(
    billing_period, billing_year, account_id
)

Build the data required to generate all invoices for the given billing period, invoice year and account.

Parameters:

Name Type Description Default
billing_period OccupationalHealthBillingPeriod

The billing period to build data for.

required
billing_year int

The year of the billing period to build data for.

required
account_id UUID | AccountId

The account ID to build invoices for.

required

The data is not guaranteed to be stable over time for past billing periods, as retroactive affiliation changes can occur.

Returns:

Name Type Description
tuple tuple[list[InvoiceToGenerateData], dict[UUID, str]]

A tuple containing: - list[InvoiceToGenerateData]: The data about all invoices to generate for the given period. - dict[UUID, str]: Mapping of affiliation IDs to rejection reasons for affiliations that couldn't be matched.

Source code in components/occupational_health/public/queries/billing.py
def build_invoices_data_for_period_and_account(
    billing_period: OccupationalHealthBillingPeriod,
    billing_year: int,
    account_id: UUID | AccountId,
) -> tuple[list[InvoiceToGenerateData], dict[UUID, str]]:
    """
    Build the data required to generate all invoices for the given billing period, invoice year and account.

    Args:
        billing_period: The billing period to build data for.
        billing_year: The year of the billing period to build data for.
        account_id: The account ID to build invoices for.

    The data is not guaranteed to be stable over time for past billing periods, as
    retroactive affiliation changes can occur.

    Returns:
        tuple: A tuple containing:
            - list[InvoiceToGenerateData]: The data about all invoices to generate for the given period.
            - dict[UUID, str]: Mapping of affiliation IDs to rejection reasons for affiliations that couldn't be matched.
    """
    from components.occupational_health.internal.business_logic.billing.pricing import (
        YEARLY_PRICE_PER_EMPLOYEE_IN_CENTS,
    )
    from components.occupational_health.internal.business_logic.queries.affiliation.affiliations import (
        get_all_affiliations_for_account,
    )
    from components.occupational_health.internal.helpers.affiliation import (
        build_profile_id_from_affiliable_ref,
    )

    account_id = AccountId(account_id)

    # 1. Get the customer data, including the billable entities.
    customers = get_billed_entities_for_account(account_id=account_id)

    # 2. Get all affiliated members overlapping with billing period for the given account, spread them over the billable entities depending on their SIRET.
    affiliations_for_whole_account = get_all_affiliations_for_account(
        account_id=account_id
    )

    # Sort affiliations by start_date to process them in chronological order
    affiliations_sorted = sorted(
        affiliations_for_whole_account, key=lambda aff: aff.start_date
    )

    # Pre-fetch billing affiliation overrides for all affiliations in this account
    # Assumption: the number of such overrides is small (a handful per account max),
    # making it more efficient to fetch them all at once rather than querying one by
    # one during the loop below.
    all_affiliation_ids = {aff.affiliation_id for aff in affiliations_sorted}
    billing_overrides = _get_billing_overrides(all_affiliation_ids)

    # Compute the start and end dates, between which we'll consider period to bill
    to_bill_period = billing_period.to_validity_period(year=billing_year)

    customer_by_contract_ref = {
        customer.contract_ref: customer for customer in customers
    }
    # Index customers by profile_id to detect duplicates
    customers_per_profile: defaultdict[ProfileId, list[BilledEntityData]] = defaultdict(
        list
    )
    errors = 0
    error_affiliation_ids: dict[UUID, str] = {}
    for affiliation in affiliations_sorted:
        if affiliation.is_ever_active_between(
            period_start=to_bill_period.start_date,
            period_end=to_bill_period.end_date,
        ):
            # Calculate the number of days the affiliation is active during the billing period
            affiliation_duration = _find_duration_of_affiliation_on_period(
                affiliation, to_bill_period, billing_period
            )

            # Skip affiliations that are active for 3 days or less
            if affiliation_duration and affiliation_duration <= 3:
                current_logger.info(
                    f"Skipping affiliation {affiliation.affiliation_id} - only active for {affiliation_duration} days"
                )
                continue

            # This affiliation is active at some point during the billing period:
            # take it into account for billing.
            profile_id = build_profile_id_from_affiliable_ref(
                affiliation.affiliable_ref
            )
            try:
                # Find the right customer for this affiliation
                matching_customer = _find_customer_to_bill_for_affiliation(
                    account_id=account_id,
                    affiliation=affiliation,
                    profile_id=profile_id,
                    customers=customers,
                    to_bill_period=to_bill_period,
                )
            except UnableToDetermineCustomerToBill as e:
                current_logger.warning(
                    f"Unable to determine customer to bill for {affiliation} based on employment data."
                )

                # Check if there's a manual billing override for this affiliation
                override_billed_entity_id = billing_overrides.get(
                    affiliation.affiliation_id
                )
                if override_billed_entity_id is not None:
                    override_customer = one(
                        customers,
                        predicate=lambda c: c.id == override_billed_entity_id,  # noqa: B023
                        message=f"Unable to find BilledEntity {override_billed_entity_id} for BillingAffiliationOverride of affiliation {affiliation.affiliation_id}",
                    )
                    current_logger.info(
                        f"Found a BillingAffiliationOverride to determine customer to bill for affiliation {affiliation.affiliation_id} -> billing to {override_customer.entity_name}.",
                        affiliation_id=affiliation.affiliation_id,
                        billed_entity_id=override_billed_entity_id,
                        original_error=str(e),
                    )
                    customers_per_profile[profile_id].append(override_customer)
                else:
                    errors += 1
                    error_affiliation_ids[affiliation.affiliation_id] = str(e)
            else:
                # Collect customer for each profile_id if no error
                customers_per_profile[profile_id].append(matching_customer)

    # TODO: ensure transfers are properly handled (eg unit test?)

    if errors:
        current_logger.warning(
            f"Unable to find the customer to bill for {errors} affiliations in account {account_id}"
        )

    # 3. Process customers per profile and select the final customer to bill
    profile_ids_per_customer_ref: defaultdict[str, set[str]] = defaultdict(set)

    for profile_id, customer_list in customers_per_profile.items():
        uniq_contract_ref = {customer.contract_ref for customer in customer_list}
        if len(uniq_contract_ref) > 1:
            current_logger.info(
                f"Multiple customers found for profile<{profile_id}> : {', '.join(uniq_contract_ref)}, taking the first one"
            )

        # Take the first customer (from earliest affiliation due to sorting)
        selected_customer = customer_list[0]
        profile_ids_per_customer_ref[selected_customer.contract_ref].add(
            str(profile_id)
        )

    # 4. Package the data
    # Iterate over all customers, not just those with profiles
    invoice_data = []
    for customer_contract_ref, customer in customer_by_contract_ref.items():
        profile_ids = profile_ids_per_customer_ref.get(customer_contract_ref, set())
        invoice_data.append(
            InvoiceToGenerateData(
                billing_period=billing_period,
                billing_year=billing_year,
                contract_type="occupational_health",
                contract_ref=customer_contract_ref,
                entity_name=customer.entity_name,
                siren=customer.siren,
                siret=customer.siret,
                contract_yearly_price_per_employee_in_cents=YEARLY_PRICE_PER_EMPLOYEE_IN_CENTS,
                contract_has_installment_plan=(
                    customer.has_installment_plan_per_year or {}
                ).get(str(billing_year)),
                references_of_employees_affiliated_over_the_year=profile_ids,
            )
        )

    return invoice_data, error_affiliation_ids

get_active_employee_count_for_account

get_active_employee_count_for_account(account_id)

Return the number of currently active affiliations for the given account.

Note: billing is the first external consumer of this function which is why it was defined in billing.py - don't hesitate to move it

Source code in components/occupational_health/public/queries/billing.py
def get_active_employee_count_for_account(account_id: AccountId | UUID) -> int:
    """
    Return the number of currently active affiliations for the given account.

    Note: billing is the first external consumer of this function which is why it was
    defined in billing.py - don't hesitate to move it
    """
    from components.occupational_health.internal.business_logic.queries.affiliation.affiliations import (
        get_all_affiliations_for_account,
    )

    return len(
        get_all_affiliations_for_account(
            account_id=AccountId(account_id),
            only_active_on=utctoday(),
        )
    )

get_billed_entities_for_account

get_billed_entities_for_account(account_id)

Get all billed entities for a given account.

Source code in components/occupational_health/public/queries/billing.py
def get_billed_entities_for_account(
    account_id: UUID,
) -> list["BilledEntityData"]:
    """
    Get all billed entities for a given account.
    """
    query = select(OccupationalHealthBilledEntity).where(
        OccupationalHealthBilledEntity.account_id == AccountId(account_id)
    )
    billed_entities: Sequence[OccupationalHealthBilledEntity] = (
        current_session.execute(query).scalars().all()
    )

    serialized_billed_entities: list[BilledEntityData] = []

    for billed_entity in billed_entities:
        serialized_billed_entities.append(
            BilledEntityData(
                id=billed_entity.id,
                entity_name=billed_entity.entity_name,
                siren=billed_entity.siren,
                siret=billed_entity.siret,
                postal_address=CustomerAddress(
                    street=billed_entity.postal_street,
                    postal_code=billed_entity.postal_code,
                    city=billed_entity.postal_city,
                    country_code=billed_entity.postal_country_code,
                ),
                account_id=billed_entity.account_id,
                has_installment_plan_per_year=billed_entity.has_installment_plan_per_year,
                contract_ref=billed_entity.contract_ref,
                subscription_ref=billed_entity.subscription_ref,
                emails_to_notify=billed_entity.emails_to_notify,
            )
        )

    return serialized_billed_entities

get_customer_data

get_customer_data(contract_ref)

Fetch customer data for the billing system.

Until we decide how to store this customer data (on the Billing side?), here is a query to serve it.

Parameters:

Name Type Description Default
contract_ref str

The contract reference to look up customer data for

required

Returns:

Name Type Description
CustomerData CustomerData

The customer data for the given contract reference

Raises:

Type Description
ContractRefNotFound

If the contract reference is not found in the customer data mapping

Source code in components/occupational_health/public/queries/billing.py
def get_customer_data(contract_ref: str) -> CustomerData:
    """
    Fetch customer data for the billing system.

    Until we decide how to store this customer data (on the Billing side?), here is a query to serve it.

    Args:
        contract_ref: The contract reference to look up customer data for

    Returns:
        CustomerData: The customer data for the given contract reference

    Raises:
        ContractRefNotFound: If the contract reference is not found in the customer data mapping
    """
    query = select(OccupationalHealthBilledEntity).where(
        OccupationalHealthBilledEntity.contract_ref == contract_ref
    )

    with replica_db_context():
        billed_entity = current_session.scalars(query).one_or_none()

    if billed_entity is None:
        raise ContractRefNotFound(
            f"Customer data not found for contract reference: {contract_ref}"
        )

    return CustomerData(
        entity_name=billed_entity.entity_name,
        siren=billed_entity.siren,
        siret=billed_entity.siret,
        postal_address=CustomerAddress(
            street=billed_entity.postal_street,
            postal_code=billed_entity.postal_code,
            city=billed_entity.postal_city,
            country_code=billed_entity.postal_country_code,
        ),
    )

get_profile_list_from_invoice_data

get_profile_list_from_invoice_data(invoices)

Build a list of profiles with customer names from invoice data.

Parameters:

Name Type Description Default
invoices list[InvoiceToGenerateData]

List of invoice objects, each with contract_ref and references_of_employees_affiliated_over_the_year attributes.

required

Returns:

Type Description
list[dict[str, str | ProfileId]]

List of dicts with profile_id, first_name, last_name, and customer_name.

Source code in components/occupational_health/public/queries/billing.py
def get_profile_list_from_invoice_data(
    invoices: list[InvoiceToGenerateData],
) -> list[dict[str, str | ProfileId]]:
    """
    Build a list of profiles with customer names from invoice data.

    Args:
        invoices: List of invoice objects, each with contract_ref and references_of_employees_affiliated_over_the_year attributes.

    Returns:
        List of dicts with profile_id, first_name, last_name, and customer_name.
    """
    from uuid import UUID

    from components.global_profile.public.api import ProfileService
    from components.occupational_health.external.global_profile import (
        get_global_profile_ids_by_occupational_health_profile_id_mapping,
    )

    # Build profile list with customer names
    profile_to_customer_name: dict[ProfileId, str] = {}
    all_profile_ids: set[ProfileId] = set()
    for invoice in invoices:
        customer_data = get_customer_data(invoice.contract_ref)
        for profile_id_str in invoice.references_of_employees_affiliated_over_the_year:
            profile_id = ProfileId(UUID(profile_id_str))
            all_profile_ids.add(profile_id)
            profile_to_customer_name[profile_id] = customer_data.entity_name

    # Get global_profile_ids from occupational health profile_ids
    profile_id_to_global_profile_id = (
        get_global_profile_ids_by_occupational_health_profile_id_mapping(
            profile_ids=all_profile_ids
        )
    )

    # Get first_name/last_name from global profiles using global_profile_ids directly
    global_profile_ids = list(profile_id_to_global_profile_id.values())
    profile_service = ProfileService.create()
    global_profiles = profile_service.get_profiles(profile_ids=global_profile_ids)
    # Build lookup by global_profile_id for easier access
    global_profile_by_id = {profile.id: profile for profile in global_profiles}

    profiles_with_customer_name: list[dict[str, str | ProfileId]] = []
    for profile_id in all_profile_ids:
        global_profile_id = profile_id_to_global_profile_id[profile_id]
        profile = global_profile_by_id.get(global_profile_id)
        first_name = (profile.first_name or "⁉️") if profile else "⁉️"
        last_name = (profile.last_name or "⁉️") if profile else "⁉️"
        profiles_with_customer_name.append(
            {
                "profile_id": profile_id,
                "first_name": first_name,
                "last_name": last_name,
                "customer_name": profile_to_customer_name[profile_id],
            }
        )
    return profiles_with_customer_name

doctolib_db_matching

Public facade for the DB-based Doctolib export matching (reframed v3).

Thin re-export so the Marmot controller (in components/fr/) can import the reconciliation entry point from a single public module.

build_doctolib_db_matching_plan

build_doctolib_db_matching_plan(csv_content)

Run the full pipeline and return the dry-run plan.

Source code in components/occupational_health/internal/business_logic/doctolib_matching/orchestrator.py
def build_doctolib_db_matching_plan(csv_content: str) -> DoctolibDbMatchingPlan:
    """Run the full pipeline and return the dry-run plan."""
    # Step 1: parse + normalize; split active vs deleted rows; compute start_date.
    all_rows = normalize_doctolib_csv_rows(parse_doctolib_csv(csv_content))
    active_rows = [row for row in all_rows if row.status == ACTIVE_STATUS]
    deleted_rows = [row for row in all_rows if row.status == DELETED_STATUS]
    start_date = _compute_start_date([*active_rows, *deleted_rows])

    # Step 2: candidate visits -- predictable (gsheet) + on-demand (DB) >= start_date.
    candidate_visits = load_candidate_visits(start_date)
    predictable_count = sum(
        1 for visit in candidate_visits if visit.visit_model == "predictable"
    )
    on_demand_count = len(candidate_visits) - predictable_count

    # Step 3: identity from OH profile -> global profile.
    identity = load_identity()

    # Step 4: match active + deleted rows to OH profiles. Deleted rows are
    # profile-matched too, so a cancellation can fall back to identity when the
    # row's doctolib_visit_id never linked to a visit.
    profile_matches = match_rows_to_profiles(active_rows, identity)
    matched_profile_rows = [m for m in profile_matches if m.profile_id is not None]
    deleted_profile_matches = match_rows_to_profiles(deleted_rows, identity)

    # Step 5: match rows to candidate visits (active -> outcomes, deleted -> cancel).
    all_csv_doctolib_ids = {row.id for row in (*active_rows, *deleted_rows) if row.id}
    visit_result = match_rows_to_visits(
        matched_profile_rows,
        candidate_visits,
        identity.profile_id_to_user_id,
        all_csv_doctolib_ids,
        deleted_profile_matches,
    )

    # Step 6: build the dry-run plan.
    return build_plan(
        profile_matches,
        visit_result,
        identity,
        total_csv_rows=len(all_rows),
        active_rows=len(active_rows),
        deleted_rows=len(deleted_rows),
        start_date=start_date,
        predictable_count=predictable_count,
        on_demand_count=on_demand_count,
    )

has_active_occupational_health_contract

has_active_occupational_health_contract

has_active_occupational_health_contract(account_id)

Return whether the given account ID has an active occupational health contract at the moment.

Parameters:

Name Type Description Default
account_id UUID

The ID of the account to check.

required

Returns:

Name Type Description
bool bool

True if the account has an active occupational health contract, False otherwise.

Source code in components/occupational_health/public/queries/has_active_occupational_health_contract.py
def has_active_occupational_health_contract(account_id: UUID) -> bool:
    """
    Return whether the given account ID has an active occupational health contract at the moment.

    Args:
        account_id: The ID of the account to check.

    Returns:
        bool: True if the account has an active occupational health contract, False otherwise.
    """
    from components.occupational_health.internal.business_logic.contracting.queries.helpers import (
        has_currently_active_contract as _has_active_contract,
    )

    return _has_active_contract(AccountId(account_id))

has_active_or_upcoming_occupational_health_contract

has_active_or_upcoming_occupational_health_contract(
    account_id,
)

Return whether the given account ID has an active or upcoming occupational health contract.

Parameters:

Name Type Description Default
account_id UUID

The ID of the account to check.

required

Returns: bool: True if the account has an active or upcoming occupational health contract, False otherwise.

Source code in components/occupational_health/public/queries/has_active_occupational_health_contract.py
def has_active_or_upcoming_occupational_health_contract(account_id: UUID) -> bool:
    """
    Return whether the given account ID has an active or upcoming occupational health contract.

    Args:
        account_id: The ID of the account to check.
    Returns:
        bool: True if the account has an active or upcoming occupational health contract, False otherwise.
    """
    from components.occupational_health.internal.business_logic.contracting.queries.helpers import (
        has_currently_active_or_upcoming_contract as _has_active_or_upcoming_contract,
    )

    return _has_active_or_upcoming_contract(AccountId(account_id))

health_professional

get_all_active_health_professionals

get_all_active_health_professionals()

Return all health professionals ordered by last name, first name.

Source code in components/occupational_health/internal/queries/health_professional.py
def get_all_active_health_professionals() -> list[HealthProfessional]:
    """Return all health professionals ordered by last name, first name."""
    hps = OccupationalHealthHealthProfessionalBroker.get_all_active()
    return [_to_entity(hp) for hp in hps]

get_or_raise_health_professional

get_or_raise_health_professional(health_professional_id)

Get or raise health professional by ID.

Source code in components/occupational_health/internal/queries/health_professional.py
def get_or_raise_health_professional(
    health_professional_id: UUID,
) -> HealthProfessional:
    """Get or raise health professional by ID."""
    health_professional = OccupationalHealthHealthProfessionalBroker.get_by_id(
        health_professional_id
    )
    return _to_entity(health_professional)

ins

get_ins_administrative_profile

get_ins_administrative_profile(profile_id)

Get INS administrative profile combining INS identity + monitoring/consent/contact data.

Source code in components/occupational_health/internal/business_logic/queries/ins/queries.py
def get_ins_administrative_profile(
    profile_id: ProfileId,
) -> InsAdministrativeProfile:
    """Get INS administrative profile combining INS identity + monitoring/consent/contact data."""
    ins_identity = get_ins_identity(profile_id)

    administrative_profile = (
        OccupationalHealthAdministrativeProfileBroker.get_by_profile(
            profile_id
        ).one_or_none()
    )

    occupational_health_profile = current_session.get_one(
        OccupationalHealthProfile, profile_id
    )
    personal_email = occupational_health_profile.personal_email
    phone_number = occupational_health_profile.phone_number

    latest_employment_data = (
        occupational_health_profile.employment_data[-1]
        if occupational_health_profile.employment_data
        else None
    )
    professional_email = (
        latest_employment_data.professional_email if latest_employment_data else None
    )

    matricule_ins = get_ins_number(profile_id)

    dependency = get_app_dependency()
    user_id = dependency.get_user_id_from_global_profile_id(
        GlobalProfileId(occupational_health_profile.global_profile_id)
    )
    ssn, _ = dependency.get_ssn_and_ntt_for_user(user_id)

    return InsAdministrativeProfile(
        # INS identity (nullable when no identity exists yet)
        ins_status=ins_identity.status if ins_identity else None,
        validation_method_id=(
            ins_identity.validation_method_id if ins_identity else None
        ),
        matricule_ins=matricule_ins,
        sex=ins_identity.sex if ins_identity else None,
        birth_last_name=ins_identity.birth_last_name if ins_identity else None,
        display_first_birth_first_name=(
            ins_identity.display_first_birth_first_name if ins_identity else None
        ),
        birth_date=ins_identity.birth_date if ins_identity else None,
        birth_place_insee_code=(
            ins_identity.birth_place_insee_code if ins_identity else None
        ),
        given_first_name=(ins_identity.given_first_name if ins_identity else None),
        given_last_name=(ins_identity.given_last_name if ins_identity else None),
        ssn=ssn,
        # Monitoring
        health_statuses=(
            administrative_profile.health_statuses if administrative_profile else []
        ),
        risk_category=(
            administrative_profile.risk_category
            if administrative_profile
            else RiskCategory.SI
        ),
        notes=administrative_profile.notes if administrative_profile else None,
        # Consents
        medical_record_sharing_consent=(
            administrative_profile.medical_record_sharing_consent
            if administrative_profile
            else None
        ),
        medical_record_sharing_consent_collected_at=(
            administrative_profile.medical_record_sharing_consent_collected_at
            if administrative_profile
            else None
        ),
        medical_record_access_consent=(
            administrative_profile.medical_record_access_consent
            if administrative_profile
            else None
        ),
        medical_record_access_consent_collected_at=(
            administrative_profile.medical_record_access_consent_collected_at
            if administrative_profile
            else None
        ),
        health_data_sharing_consent=(
            administrative_profile.health_data_sharing_consent
            if administrative_profile
            else None
        ),
        health_data_sharing_consent_collected_at=(
            administrative_profile.health_data_sharing_consent_collected_at
            if administrative_profile
            else None
        ),
        video_consultation_consent=(
            administrative_profile.video_consultation_consent
            if administrative_profile
            else None
        ),
        video_consultation_consent_collected_at=(
            administrative_profile.video_consultation_consent_collected_at
            if administrative_profile
            else None
        ),
        orient_prevention_of_work_consent=(
            administrative_profile.orient_prevention_of_work_consent
            if administrative_profile
            else None
        ),
        orient_prevention_of_work_consent_collected_at=(
            administrative_profile.orient_prevention_of_work_consent_collected_at
            if administrative_profile
            else None
        ),
        transfer_dmst_to_spsti_consent=(
            administrative_profile.transfer_dmst_to_spsti_consent
            if administrative_profile
            else None
        ),
        transfer_dmst_to_spsti_consent_collected_at=(
            administrative_profile.transfer_dmst_to_spsti_consent_collected_at
            if administrative_profile
            else None
        ),
        # Contact
        personal_email=personal_email,
        professional_email=professional_email,
        phone_number=phone_number,
    )

invoices

Public interface for invoice queries.

Re-exports internal query functions for use by external components.

get_all_invoice_pdf_files_for_period

get_all_invoice_pdf_files_for_period(
    year, billing_period, account_id=None
)

Get all issued occupational_health invoice PDFs for a given year and billing period.

Parameters:

Name Type Description Default
year int

The billing year

required
billing_period OccupationalHealthBillingPeriod

The billing period to filter by issued_date range

required
account_id AccountId | None

If provided, restrict to invoices for this account's contract_refs

None

Returns:

Type Description
list[InvoicePdfFileData]

List of InvoicePdfFileData with invoice metadata and downloaded PDF file.

list[InvoicePdfFileData]

Invoices without an attached PDF (uri is None) are skipped.

Source code in components/occupational_health/internal/queries/invoices.py
def get_all_invoice_pdf_files_for_period(
    year: int,
    billing_period: OccupationalHealthBillingPeriod,
    account_id: AccountId | None = None,
) -> list[InvoicePdfFileData]:
    """
    Get all issued occupational_health invoice PDFs for a given year and billing period.

    Args:
        year: The billing year
        billing_period: The billing period to filter by issued_date range
        account_id: If provided, restrict to invoices for this account's contract_refs

    Returns:
        List of InvoicePdfFileData with invoice metadata and downloaded PDF file.
        Invoices without an attached PDF (uri is None) are skipped.
    """
    from components.fr.internal.billing.models.invoice import Invoice  # noqa: ALN069

    filters = [
        Invoice.contract_type == ContractType.occupational_health,
        Invoice.event_date == Year(year),
    ]

    if account_id is not None:
        billed_entities = get_billed_entities_for_account(account_id=account_id)
        contract_refs = [entity.contract_ref for entity in billed_entities]
        if not contract_refs:
            return []
        filters.append(Invoice.contract_ref.in_(contract_refs))

    start_date, end_date = _get_issued_date_range(year, billing_period)
    filters.append(Invoice.issued_date >= start_date)
    filters.append(Invoice.issued_date <= end_date)

    query = select(Invoice).where(*filters).order_by(Invoice.issued_date.desc())
    invoices = current_session.execute(query).scalars().all()

    return [
        InvoicePdfFileData(
            invoice_number=invoice.invoice_number,
            event_date=invoice.event_date,
            file=invoice.get_or_download_file(),
        )
        for invoice in invoices
        if invoice.uri is not None
    ]

get_invoice_appendix_employee_data_for_profile_ids

get_invoice_appendix_employee_data_for_profile_ids(
    profile_ids,
)

Returns the data needed to build the invoice appendix for each employee per profile id.

:param profile_ids: iterable of profile ids :return: dict of InvoiceAppendixEmployeeData per profile id

Source code in components/occupational_health/public/queries/invoices.py
def get_invoice_appendix_employee_data_for_profile_ids(
    profile_ids: Iterable[ProfileId],
) -> dict[ProfileId, InvoiceAppendixEmployeeData]:
    """
    Returns the data needed to build the invoice appendix for each employee per profile id.

    :param profile_ids: iterable of profile ids
    :return: dict of InvoiceAppendixEmployeeData per profile id
    """
    # get global profiles
    global_profile_id_per_profile_id = (
        get_global_profile_ids_by_occupational_health_profile_id_mapping(
            profile_ids=profile_ids
        )
    )
    global_profiles = ProfileService.create().get_profiles(
        profile_ids=global_profile_id_per_profile_id.values()
    )
    global_profile_by_global_profile_id = {p.id: p for p in global_profiles}

    # get ssn and ntt
    user_id_by_profile = get_user_ids_by_occupational_health_profile_ids_mapping(
        profile_ids
    )
    dependency = get_app_dependency()
    ssn_and_ntt_by_user_id = dependency.get_ssn_and_ntt_for_users(
        user_id_by_profile.values()
    )

    result: dict[ProfileId, InvoiceAppendixEmployeeData] = {}
    for profile_id in profile_ids:
        global_profile_id = global_profile_id_per_profile_id.get(profile_id)
        if global_profile_id is None:
            current_logger.warning(
                "No global profile id found for occupational health profile id",
                occupational_health_profile_id=str(profile_id),
            )
            result[profile_id] = InvoiceAppendixEmployeeData()
            continue
        global_profile = global_profile_by_global_profile_id.get(global_profile_id)
        if global_profile is None:
            current_logger.warning(
                "No global profile found for global profile id",
                profile_id=str(global_profile_id),
                occupational_health_profile_id=str(profile_id),
            )
            result[profile_id] = InvoiceAppendixEmployeeData()
            continue

        user_id = user_id_by_profile.get(profile_id)
        if user_id is None:
            current_logger.warning(
                "No user id found for occupational health profile id",
                occupational_health_profile_id=str(profile_id),
            )
            result[profile_id] = InvoiceAppendixEmployeeData()
            continue

        (ssn, ntt) = ssn_and_ntt_by_user_id.get(user_id, (None, None))

        result[profile_id] = InvoiceAppendixEmployeeData(
            first_name=global_profile.first_name,
            last_name=global_profile.last_name,
            ssn=ssn,
            ntt=ntt,
        )

    return result

get_invoice_file_for_account

get_invoice_file_for_account(
    account_id, invoice_id, file_type="invoice"
)

Get file data for a specific invoice that belongs to the given account.

Parameters:

Name Type Description Default
account_id AccountId

The account ID to verify ownership

required
invoice_id int

The invoice ID to fetch

required
file_type Literal['invoice', 'appendix']

Which file to return — "invoice" for the PDF, "appendix" for the CSV

'invoice'

Returns:

Type Description
InvoiceFileData

InvoiceFileData with the filename and raw file

Raises:

Type Description
missing_resource

If invoice not found or doesn't belong to account

remote_file_retrieval

If the requested file is not attached

Source code in components/occupational_health/internal/queries/invoices.py
def get_invoice_file_for_account(
    account_id: AccountId,
    invoice_id: int,
    file_type: Literal["invoice", "appendix"] = "invoice",
) -> InvoiceFileData:
    """
    Get file data for a specific invoice that belongs to the given account.

    Args:
        account_id: The account ID to verify ownership
        invoice_id: The invoice ID to fetch
        file_type: Which file to return — "invoice" for the PDF, "appendix" for the CSV

    Returns:
        InvoiceFileData with the filename and raw file

    Raises:
        BaseErrorCode.missing_resource: If invoice not found or doesn't belong to account
        BaseErrorCode.remote_file_retrieval: If the requested file is not attached
    """
    from components.fr.internal.billing.models.invoice import Invoice  # noqa: ALN069

    billed_entities = get_billed_entities_for_account(account_id=account_id)
    if len(billed_entities) == 0:
        raise BaseErrorCode.missing_resource(
            message="No billed entities found for account"
        )

    # Get all contract refs for this account
    contract_refs = [billed_entity.contract_ref for billed_entity in billed_entities]
    # Query the invoice, ensuring it belongs to the account
    invoice: Invoice | None = current_session.execute(
        select(Invoice).where(
            Invoice.id == invoice_id,
            Invoice.contract_type == ContractType.occupational_health,
            Invoice.contract_ref.in_(contract_refs),
        )
    ).scalar_one_or_none()

    if invoice is None:
        raise BaseErrorCode.missing_resource(message="No invoice found for account")

    full_date = invoice.event_date.strftime("%Y%m%d")

    if file_type == "appendix":
        if invoice.appendix_uri is None:
            raise BaseErrorCode.remote_file_retrieval(
                message="Invoice has no attached appendix"
            )
        filename = f"Prévenir {full_date} - Annexe Médecine du travail - {invoice.invoice_number}.csv"
        return InvoiceFileData(
            filename=filename,
            file=invoice.get_or_download_file(
                file_field="appendix_file", uri_field="appendix_uri"
            ),
        )

    if invoice.uri is None:
        raise BaseErrorCode.remote_file_retrieval(message="Invoice has no attached pdf")

    return InvoiceFileData(
        filename=_build_invoice_pdf_filename(
            invoice.event_date, invoice.invoice_number
        ),
        file=invoice.get_or_download_file(),
    )

get_issued_invoices_for_account

get_issued_invoices_for_account(account_id)

Get all actual issued invoices for all billed entities of an account.

Parameters:

Name Type Description Default
account_id UUID

The account ID to fetch invoices for

required

Returns:

Type Description
list[IssuedInvoiceData]

List of IssuedInvoiceData for all invoices linked to this account's billed entities,

list[IssuedInvoiceData]

sorted by event_date descending.

Source code in components/occupational_health/internal/queries/invoices.py
def get_issued_invoices_for_account(account_id: UUID) -> list[IssuedInvoiceData]:
    """
    Get all actual issued invoices for all billed entities of an account.

    Args:
        account_id: The account ID to fetch invoices for

    Returns:
        List of IssuedInvoiceData for all invoices linked to this account's billed entities,
        sorted by event_date descending.
    """
    billed_entities = get_billed_entities_for_account(account_id=account_id)

    if not billed_entities:
        return []

    # Build a mapping of contract_ref -> billed entity info
    entity_info_by_contract_ref: dict[str, dict[str, str | None]] = {}
    contract_refs: list[str] = []
    for entity in billed_entities:
        contract_ref = entity.contract_ref
        contract_refs.append(contract_ref)
        entity_info_by_contract_ref[contract_ref] = {
            "entity_name": entity.entity_name,
            "siren": entity.siren,
            "siret": entity.siret,
        }

    # Query invoices for these contract refs
    from components.fr.internal.billing.models.invoice import Invoice  # noqa: ALN069

    invoices_query = (
        select(Invoice)
        .where(
            Invoice.contract_type == ContractType.occupational_health,
            Invoice.contract_ref.in_(contract_refs),
        )
        .order_by(Invoice.event_date.desc())
    )
    invoices = current_session.execute(invoices_query).scalars().all()

    # Build response dataclass list
    result: list[IssuedInvoiceData] = []
    for invoice in invoices:
        entity_info = entity_info_by_contract_ref.get(invoice.contract_ref, {})
        result.append(
            IssuedInvoiceData(
                invoice_id=invoice.id,
                invoice_number=invoice.invoice_number,
                event_date=invoice.event_date,
                issued_date=invoice.issued_date,
                due_date=invoice.due_date,
                total_invoice_amount=invoice.total_invoice_amount,
                remaining_balance=invoice.remaining_balance,
                previous_balance=invoice.previous_balance,
                amount_to_charge=invoice.amount_to_charge,
                contract_ref=invoice.contract_ref,
                entity_name=entity_info.get("entity_name") or "",
                siren=entity_info.get("siren") or "",
                siret=entity_info.get("siret"),
                has_appendix=invoice.appendix_uri is not None,
                billing_reason=invoice.billing_reason,
                email_sent_at=invoice.email_sent_at,
            )
        )

    return result

is_currently_affiliated_to_prevenir

is_currently_affiliated_to_occupational_health

is_currently_affiliated_to_occupational_health(
    global_profile_id, account_id, commit=True
)

Check if the user is currently affiliated to Prevenir.

Will create a OccupationalHealthProfile if it doesn't exist, hence the commit parameter.

Returns True if the user has active affiliations, otherwise False.

Source code in components/occupational_health/public/queries/is_currently_affiliated_to_prevenir.py
def is_currently_affiliated_to_occupational_health(
    global_profile_id: GlobalProfileId | UUID,
    account_id: AccountId | UUID,
    commit: bool = True,
) -> bool:
    """
    Check if the user is currently affiliated to Prevenir.

    Will create a OccupationalHealthProfile if it doesn't exist, hence the commit parameter.

    Returns True if the user has active affiliations, otherwise False.
    """
    global_profile_id = GlobalProfileId(global_profile_id)
    account_id = AccountId(account_id)

    occupational_health_profile_id = get_or_create_profile_id_from_global_id(
        global_profile_id,
        commit=commit,
    )

    # If you pass an user (a global profile ID) and an account ID, you can get at most one affiliation.
    active_affiliations = get_active_affiliations(
        occupational_health_profile_id=occupational_health_profile_id,
        for_account_id=account_id,
    )

    if active_affiliations:
        return True

    return False

is_dashboard_admin

is_occupational_health_dashboard_admin

is_occupational_health_dashboard_admin(user_id, account_id)

Check if a user is an occupational health dashboard admin for the given account.

Source code in components/occupational_health/public/queries/is_dashboard_admin.py
def is_occupational_health_dashboard_admin(user_id: str, account_id: UUID) -> bool:
    """Check if a user is an occupational health dashboard admin for the given account."""
    from components.occupational_health.internal.business_logic.queries.admin_dashboard.admin_authorization import (
        get_accessible_account_ids,
    )
    from components.occupational_health.internal.types import AccountId, UserId

    accessible = get_accessible_account_ids(for_user_id=UserId(user_id))
    return AccountId(account_id) in accessible

jobs

get_referenced_medical_secrecy_worker_job_ids

get_referenced_medical_secrecy_worker_job_ids()

Return all medical_secrecy_worker_job_id values referenced by OccupationalHealthJob.

Source code in components/occupational_health/internal/business_logic/queries/jobs.py
def get_referenced_medical_secrecy_worker_job_ids() -> set[UUID]:
    """Return all medical_secrecy_worker_job_id values referenced by OccupationalHealthJob."""
    medical_secrecy_worker_jobs_ids = (
        current_session.execute(
            select(OccupationalHealthJob.medical_secrecy_worker_job_id).where(
                OccupationalHealthJob.medical_secrecy_worker_job_id.isnot(None)
            )
        )
        .scalars()
        .all()
    )

    # not needed as query checks is not None but mypy is complaining
    return {
        medical_secrecy_worker_jobs_id
        for medical_secrecy_worker_jobs_id in medical_secrecy_worker_jobs_ids
        if medical_secrecy_worker_jobs_id is not None
    }

profiles

get_profile_dmst

get_profile_dmst(
    occupational_health_profile_id, profile_service
)

Returns the member information for a given occupational health profile ID.

Source code in components/occupational_health/internal/business_logic/queries/profiles/profiles.py
@inject_profile_service
def get_profile_dmst(
    occupational_health_profile_id: UUID, profile_service: ProfileService
) -> Dmst:
    """
    Returns the member information for a given occupational health profile ID.
    """

    dependency = get_app_dependency()

    occupational_health_profile = current_session.get_one(
        OccupationalHealthProfile,
        occupational_health_profile_id,
        options=[joinedload(OccupationalHealthProfile.employment_data)],
    )
    profile = profile_service.get_or_raise_profile(
        occupational_health_profile.global_profile_id
    )
    jobs = OccupationalHealthJobBroker.get_existing_jobs(
        occupational_health_profile_id
    ).all()
    administrative_profile = (
        OccupationalHealthAdministrativeProfileBroker.get_by_profile(
            ProfileId(occupational_health_profile_id)
        ).one_or_none()
    )

    ins_identity = get_ins_identity(ProfileId(occupational_health_profile_id))
    resolved = resolve_identity(
        ins_identity,
        fallback_first_name=profile.first_name,
        fallback_last_name=profile.last_name,
        fallback_gender=profile.gender,
        fallback_birth_date=profile.birth_date,
    )
    first_name = resolved.first_name or "⁉️"
    last_name = resolved.last_name or "⁉️"
    gender = resolved.gender
    birthdate = resolved.birth_date

    user_id = dependency.get_user_id_from_global_profile_id(GlobalProfileId(profile.id))
    ssn, _ = dependency.get_ssn_and_ntt_for_user(user_id)

    personal_email = occupational_health_profile.personal_email
    phone_number = occupational_health_profile.phone_number

    latest_employment_data = (
        occupational_health_profile.employment_data[-1]
        if occupational_health_profile.employment_data
        else None
    )
    professional_email = (
        latest_employment_data.professional_email if latest_employment_data else None
    )

    google_drive_folder_id = get_post_visit_google_drive_folder_id(
        occupational_health_profile,
    )

    # If we do have an account ID, but we can't find a Google drive folder for it, log it
    # Don't log if we don't have the latest employment data or if no account ID is linked to it
    if (
        latest_employment_data and latest_employment_data.account_id
    ) and not google_drive_folder_id:
        current_logger.error(
            "Could not find Google Drive folder ID for account",
            account_id=latest_employment_data.account_id,
        )

    active_affiliations: list[ProfileAffiliation] = []
    for affiliation in get_active_affiliations(
        ProfileId(occupational_health_profile_id)
    ):
        subscription_ref = affiliation.subscription_ref

        if not subscription_ref:
            current_logger.error(
                "No subscription_ref for affiliation",
                affiliation_id=affiliation.affiliation_id,
                profile_id=occupational_health_profile_id,
            )
            continue
        try:
            account_id = build_account_id_from_subscription_ref(subscription_ref)
        except ValueError:
            current_logger.error(
                "Invalid subscription_ref for affiliation",
                subscription_ref=subscription_ref,
                occupational_health_profile_id=occupational_health_profile_id,
            )
            continue
        try:
            name = dependency.get_account_name(account_id)
        except NoResultFound:
            current_logger.error(
                "No Account row for affiliation subscription_ref",
                account_id=account_id,
                occupational_health_profile_id=occupational_health_profile_id,
            )
            continue

        active_affiliations.append(
            ProfileAffiliation(
                account_id=account_id,
                name=name,
            )
        )

    return Dmst(
        occupational_health_profile_id=occupational_health_profile.id,
        first_name=first_name,
        last_name=last_name,
        gender=gender,
        birthdate=birthdate,
        medical_secrecy_worker_record_id=occupational_health_profile.medical_secrecy_worker_record_id,
        curriculum_laboris=CurriculumLaboris(
            occupational_medical_history=OccupationalMedicalHistory(
                types=[],  # In medical secrecy record (see get_medical_record)
                description=None,  # In medical secrecy record (see get_medical_record)
            ),
            jobs=(
                [
                    MemberJob(
                        id=job.id,
                        title=job.title,
                        start_date=job.start_date,
                        end_date=job.end_date,
                        employer=job.employer,
                        # The last job (oldest) is a generic "past job" job
                        is_past_job=job.is_past_job,
                        medical_secrecy_worker_job_id=job.medical_secrecy_worker_job_id,
                        description=None,  # In medical secrecy worker job (see get_medical_record)
                        working_hours=None,  # In medical secrecy worker job (see get_medical_record)
                        missions=None,  # In medical secrecy worker job (see get_medical_record)
                        physical_conditions=None,  # In medical secrecy worker job (see get_medical_record)
                        organizational_conditions=None,  # In medical secrecy worker job (see get_medical_record)
                        mental_conditions=None,  # In medical secrecy worker job (see get_medical_record)
                        tools=None,  # In medical secrecy worker job (see get_medical_record)
                        worn_equipment=None,  # In medical secrecy worker job (see get_medical_record)
                        risks_and_advice=None,  # In medical secrecy worker job (see get_medical_record)
                        contract_type=None,  # In medical secrecy worker job (see get_medical_record)
                        exposition_notations=[],  # In medical secrecy worker job (see get_medical_record)
                    )
                    for (index, job) in enumerate(jobs)
                ]
            ),
        ),
        administrative_profile=(
            AdministrativeProfile(
                ssn=ssn,
                health_statuses=administrative_profile.health_statuses,
                risk_category=administrative_profile.risk_category,
                notes=administrative_profile.notes,
                medical_record_sharing_consent=administrative_profile.medical_record_sharing_consent,
                medical_record_access_consent=administrative_profile.medical_record_access_consent,
                health_data_sharing_consent=administrative_profile.health_data_sharing_consent,
                video_consultation_consent=administrative_profile.video_consultation_consent,
                orient_prevention_of_work_consent=administrative_profile.orient_prevention_of_work_consent,
                transfer_dmst_to_spsti_consent=administrative_profile.transfer_dmst_to_spsti_consent,
                medical_record_sharing_consent_collected_at=administrative_profile.medical_record_sharing_consent_collected_at,
                medical_record_access_consent_collected_at=administrative_profile.medical_record_access_consent_collected_at,
                health_data_sharing_consent_collected_at=administrative_profile.health_data_sharing_consent_collected_at,
                video_consultation_consent_collected_at=administrative_profile.video_consultation_consent_collected_at,
                orient_prevention_of_work_consent_collected_at=administrative_profile.orient_prevention_of_work_consent_collected_at,
                transfer_dmst_to_spsti_consent_collected_at=administrative_profile.transfer_dmst_to_spsti_consent_collected_at,
                personal_email=personal_email,
                professional_email=professional_email,
                phone_number=phone_number,
            )
            if administrative_profile
            else AdministrativeProfile(
                ssn=ssn,
                health_statuses=[],
                risk_category=RiskCategory.SI,
                notes=None,
                medical_record_sharing_consent=None,
                medical_record_access_consent=None,
                health_data_sharing_consent=None,
                video_consultation_consent=None,
                orient_prevention_of_work_consent=None,
                transfer_dmst_to_spsti_consent=None,
                personal_email=personal_email,
                professional_email=professional_email,
                phone_number=phone_number,
            )
        ),
        health_history=HealthHistory(),  # In Medical secrecy Medical record (see get_medical_record)
        google_drive_folder_id=google_drive_folder_id,
        active_affiliations=active_affiliations,
    )

search

get_search_results

get_search_results(profile_service, search_input)

Search for members and accounts based on provided search input.

Note

The function currently only searches for members by full name

Source code in components/occupational_health/internal/business_logic/queries/search/search.py
@inject_profile_service
def get_search_results(
    profile_service: ProfileService, search_input: str
) -> SearchResult:
    """
    Search for members and accounts based on provided search input.

    Note:
        The function currently only searches for members by full name
    """
    from components.occupational_health.public.dependencies import get_app_dependency

    dependency = get_app_dependency()
    # Searching for the global profiles and associated occupational health profiles
    global_profiles = profile_service.search_profiles_by_fullname(search_input)
    global_profiles_by_id: dict[GlobalProfileId, list[Profile]] = group_by(
        global_profiles, lambda profile: GlobalProfileId(profile.id)
    )
    global_profiles_ids = list(global_profiles_by_id.keys())
    profile_with_jobs = (
        get_occupational_health_profile_by_global_profile_ids_with_latest_job(
            global_profiles_ids, load_administrative_profile=True
        )
    )
    occupational_health_profiles = [profile for profile, jobs in profile_with_jobs]
    occupational_health_profiles_to_latest_health_job_mapping = {
        profile.id: job
        for (
            profile,
            job,
        ) in profile_with_jobs
    }
    occupational_health_profile_ids_by_administrative_profiles = {
        occupational_health_profile.id: occupational_health_profile.administrative_profile
        for occupational_health_profile in occupational_health_profiles
    }

    occupational_health_profile_ids_by_global_profile_id = (
        get_occupational_health_profile_ids_by_global_profile_id_mapping(
            global_profiles_ids
        )
    )
    company_names_by_occupational_health_profile_ids = (
        get_company_names_by_occupational_health_profile_id_mapping(
            occupational_health_profiles
        )
    )
    global_profile_ids_by_occupational_health_profile_ids = {
        value: key
        for key, value in occupational_health_profile_ids_by_global_profile_id.items()
    }

    affiliated_account_ids = get_all_account_ids_with_active_contract()
    matched_accounts = dependency.search_accounts_by_name(
        search_input, affiliated_account_ids
    )

    ins_identities = get_ins_identities_batch(
        set(occupational_health_profile_ids_by_global_profile_id.values())
    )

    def get_latest_job_name_by_occupational_health_profile_id(
        occupational_health_profile_id: ProfileId,
    ) -> Optional[str]:
        latest_job = occupational_health_profiles_to_latest_health_job_mapping[
            occupational_health_profile_id
        ]
        return latest_job.title if latest_job else None

    def get_profile_by_occupational_health_profile_id(
        occupational_health_profile_id: ProfileId,
    ) -> Profile:
        global_profile_id = global_profile_ids_by_occupational_health_profile_ids[
            occupational_health_profile_id
        ]
        return one(global_profiles_by_id[global_profile_id])

    def get_risk_category_by_occupational_health_profile_id(
        occupational_health_profile_id: ProfileId,
    ) -> Optional[RiskCategory]:
        administrative_profile = (
            occupational_health_profile_ids_by_administrative_profiles[
                occupational_health_profile_id
            ]
        )
        return administrative_profile.risk_category if administrative_profile else None

    def get_company_names_by_occupational_health_profile_id(
        occupational_health_profile_id: ProfileId,
    ) -> Optional[str]:
        names = company_names_by_occupational_health_profile_ids.get(
            occupational_health_profile_id, None
        )
        return ",".join(names) if names else None

    members = []
    for (
        occupational_health_profile_id
    ) in occupational_health_profile_ids_by_global_profile_id.values():
        global_profile = get_profile_by_occupational_health_profile_id(
            occupational_health_profile_id
        )
        resolved = resolve_identity(
            ins_identities.get(occupational_health_profile_id),
            fallback_first_name=global_profile.first_name,
            fallback_last_name=global_profile.last_name,
            fallback_gender=None,
            fallback_birth_date=None,
        )
        members.append(
            Member(
                occupational_health_profile_id=str(occupational_health_profile_id),
                first_name=resolved.first_name,
                last_name=resolved.last_name,
                job_title=get_latest_job_name_by_occupational_health_profile_id(
                    occupational_health_profile_id
                ),
                company_name=get_company_names_by_occupational_health_profile_id(
                    occupational_health_profile_id
                ),
                risk_category=get_risk_category_by_occupational_health_profile_id(
                    occupational_health_profile_id
                ),
            )
        )

    return SearchResult(
        members=members,
        accounts=[
            Account(id=str(account.id), name=account.name)
            for account in matched_accounts
        ],
        companies=[],  # TODO @david.barthelemy Remove this when frontend is deployed
    )

strategy_rules

AffiliationStrategyRuleForMarmot dataclass

AffiliationStrategyRuleForMarmot(
    rule_id,
    account_id,
    name,
    company_id,
    company_name,
    siret,
    company_name_from_siret,
    is_missing_siret,
    action,
    decisions_count,
    created_at,
    updated_at,
    cancelled_at,
)

Bases: DataClassJsonMixin

Affiliation strategy rules displayed in Marmot.

account_id instance-attribute
account_id
action instance-attribute
action
cancelled_at instance-attribute
cancelled_at
company_id instance-attribute
company_id
company_name instance-attribute
company_name
company_name_from_siret instance-attribute
company_name_from_siret
created_at instance-attribute
created_at
decisions_count instance-attribute
decisions_count
is_missing_siret instance-attribute
is_missing_siret
name instance-attribute
name
rule_id instance-attribute
rule_id
siret instance-attribute
siret
updated_at instance-attribute
updated_at

get_affiliation_strategy_rules_for_account

get_affiliation_strategy_rules_for_account(account_id)

Get all affiliation strategy rules for a given account.

Source code in components/occupational_health/public/queries/strategy_rules.py
def get_affiliation_strategy_rules_for_account(
    account_id: AccountId | UUID,
) -> list[AffiliationStrategyRuleForMarmot]:
    """Get all affiliation strategy rules for a given account."""
    account_id = AccountId(account_id)

    rules = current_session.scalars(
        select(AffiliationStrategyRule)
        .where(AffiliationStrategyRule.account_id == account_id)
        # Maybe we should order them by priority instead
        .order_by(AffiliationStrategyRule.created_at.desc())
    ).all()

    rule_ids = [rule.id for rule in rules]
    decisions_count_by_rule_id: dict[UUID, int] = {}
    if rule_ids:
        counts = current_session.execute(
            select(
                OccupationalHealthAffiliationDecision.rule_id,
                func.count().label("decisions_count"),
            )
            .where(OccupationalHealthAffiliationDecision.rule_id.in_(rule_ids))
            .group_by(OccupationalHealthAffiliationDecision.rule_id)
        ).all()
        decisions_count_by_rule_id = {
            row.rule_id: row.decisions_count for row in counts
        }

    all_sirets = set(rule.siret for rule in rules if rule.siret)
    company_name_by_siret = {}
    for siret in all_sirets:
        company_data = get_app_dependency().get_company_from_siret(siret)
        if company_data:
            company_name = f"{company_data.name} ({company_data.city})"
            company_name_by_siret[siret] = company_name

    all_company_ids = set(rule.company_id for rule in rules if rule.company_id)
    company_name_by_company_id = get_app_dependency().get_company_names_by_ids(
        all_company_ids
    )

    return [
        AffiliationStrategyRuleForMarmot(
            rule_id=rule.id,
            account_id=rule.account_id,
            name=rule.name,
            company_name=company_name_by_company_id.get(rule.company_id)
            if rule.company_id
            else None,
            company_id=rule.company_id,
            siret=rule.siret,
            company_name_from_siret=company_name_by_siret.get(rule.siret)
            if rule.siret
            else None,
            is_missing_siret=rule.is_missing_siret,
            action=rule.action,
            decisions_count=decisions_count_by_rule_id.get(rule.id, 0),
            created_at=rule.created_at,
            updated_at=rule.updated_at,
            cancelled_at=rule.cancelled_at,
        )
        for rule in rules
    ]

subscriber_documents

get_documents_for_subscriber

get_documents_for_subscriber(account_id)

Returns all non-deleted documents for a given subscriber account ID.

Source code in components/occupational_health/internal/business_logic/queries/subscriber_documents.py
def get_documents_for_subscriber(account_id: UUID) -> list[SubscriberDocument]:
    """Returns all non-deleted documents for a given subscriber account ID."""
    documents = (
        current_session.execute(
            select(OccupationalHealthSubscriberDocument)
            .where(
                and_(
                    OccupationalHealthSubscriberDocument.account_id == account_id,
                    OccupationalHealthSubscriberDocument.deleted_at.is_(None),
                )
            )
            # Most recent first and add the ID in case of same date: always same order
            .order_by(
                OccupationalHealthSubscriberDocument.uploaded_at.desc(),
                OccupationalHealthSubscriberDocument.id,
            )
        )
        .scalars()
        .all()
    )

    # Resolve SIRENE only for the SIRETs actually referenced by documents.
    # INSEE responses are cached for 1 day (keyed on SIREN) at the client layer,
    # so repeated calls within a request — or across recent requests — are cheap.
    dependency = get_app_dependency()
    document_sirets = {doc.siret for doc in documents if doc.siret}
    sirene_by_siret = {
        siret: dependency.get_sirene_establishment_from_siret(siret)
        for siret in document_sirets
    }

    result = []
    for document in documents:
        sirene = sirene_by_siret.get(document.siret) if document.siret else None
        result.append(
            SubscriberDocument(
                id=document.id,
                public_uri=document.public_uri,
                filename=document.filename,
                account_id=document.account_id,
                siret=document.siret,
                company_name=sirene.name if sirene else None,
                company_address=_format_sirene_address(sirene),
                uploaded_at=document.uploaded_at,
                document_type=document.document_type,
                mime_type=document.mime_type,
            )
        )
    return result

subscribers

get_affiliated_members_for_account

get_affiliated_members_for_account(
    account_id, profile_service
)

Return the list of currently affiliated members for a subscriber profile.

Source code in components/occupational_health/internal/business_logic/queries/subscribers/members.py
@inject_profile_service
@tracer_wrap()
def get_affiliated_members_for_account(
    account_id: AccountId,
    profile_service: ProfileService,
) -> list[SubscriberMember]:
    """
    Return the list of currently affiliated members for a subscriber profile.
    """
    affiliations = get_all_affiliations_for_account(
        account_id=account_id,
        only_active_on=utctoday(),
    )

    if not affiliations:
        return []

    occupational_health_profile_ids = {
        build_profile_id_from_affiliable_ref(affiliation.affiliable_ref)
        for affiliation in affiliations
    }

    profiles = list(
        current_session.execute(
            select(OccupationalHealthProfile).where(
                OccupationalHealthProfile.id.in_(occupational_health_profile_ids)
            )
        ).scalars()
    )
    profile_by_id = {profile.id: profile for profile in profiles}
    global_profile_ids = [profile.global_profile_id for profile in profiles]

    profiles_with_jobs = (
        get_occupational_health_profile_by_global_profile_ids_with_latest_job(
            global_profile_ids=global_profile_ids
        )
    )
    job_by_profile_id = {profile.id: job for profile, job in profiles_with_jobs}

    global_profiles = profile_service.get_profiles(global_profile_ids)
    global_profiles_by_id = {profile.id: profile for profile in global_profiles}

    ins_identities = get_ins_identities_batch(occupational_health_profile_ids)

    company_names_by_profile_id = (
        get_company_names_by_occupational_health_profile_id_mapping(profiles)
    )

    risk_category_by_profile_id = get_current_risk_category_for_profiles(
        profile_ids=occupational_health_profile_ids,
    )

    employments_by_global_profile_id = get_employments_by_global_profile_id(
        account_id=account_id,
        global_profile_ids=global_profile_ids,
    )

    user_id_to_global_profile_id = get_user_id_to_global_profile_id_mapping(
        global_profile_ids=global_profile_ids
    )
    global_profile_id_to_user_id = {
        gp_id: user_id for user_id, gp_id in user_id_to_global_profile_id.items()
    }
    user_id_by_profile_id: dict[UserId, ProfileId] = {  # pyrefly: ignore [bad-assignment]
        global_profile_id_to_user_id[profile.global_profile_id]: profile.id
        for profile in profiles
        if profile.global_profile_id in global_profile_id_to_user_id
    }

    # Fetch visits
    last_visit_by_profile_id = get_last_visit_for_profiles(
        profile_ids=occupational_health_profile_ids,
        user_id_mapping=user_id_by_profile_id,
    )
    next_visit_by_profile_id = get_future_visit_for_profiles(
        profile_ids=occupational_health_profile_ids,
        user_id_mapping=user_id_by_profile_id,
        employments_by_global_profile_id=employments_by_global_profile_id,
        affiliations=affiliations,
        risk_category_by_profile_id=risk_category_by_profile_id,
    )

    members = []
    for affiliation in affiliations:
        profile_id = build_profile_id_from_affiliable_ref(affiliation.affiliable_ref)

        if profile_id not in profile_by_id:
            current_logger.error(
                f"Profile {profile_id} not found for affiliation {affiliation.affiliation_id}",
            )
            continue

        profile = profile_by_id[profile_id]
        global_profile = global_profiles_by_id.get(profile.global_profile_id)

        if not global_profile:
            current_logger.error(
                f"Global profile {profile.global_profile_id} not found",
            )
            continue

        next_visit = next_visit_by_profile_id[profile_id]

        job = job_by_profile_id.get(profile_id)
        job_title = job.title if job else None

        company_names = list(company_names_by_profile_id.get(profile_id, set()))

        resolved = resolve_identity(
            ins_identities.get(profile_id),
            fallback_first_name=global_profile.first_name,
            fallback_last_name=global_profile.last_name,
            fallback_gender=None,
            fallback_birth_date=None,
        )

        members.append(
            SubscriberMember(
                occupational_health_profile_id=str(profile_id),
                first_name=resolved.first_name or "",
                last_name=resolved.last_name or "",
                job_title=job_title,
                company_names=company_names,
                risk_category=risk_category_by_profile_id.get(profile_id),
                last_visit=last_visit_by_profile_id.get(profile_id),
                next_visit=next_visit,
                status=get_member_status(
                    next_visit=next_visit,
                ),
            )
        )

    return members

get_subscriber_establishments

get_subscriber_establishments(account_id)

Return establishments for a subscriber account, ordered by siret, postal_code, name.

DB-only — no address enrichment. Street fields are left as None. Use get_subscriber_establishments_with_addresses when callers need the full SIRENE-enriched address (e.g. the SIRET-correction modal).

Source code in components/occupational_health/internal/business_logic/queries/subscribers/establishments.py
def get_subscriber_establishments(
    account_id: UUID,
) -> list[SubscriberEstablishment]:
    """Return establishments for a subscriber account, ordered by siret, postal_code, name.

    DB-only — no address enrichment. Street fields are left as ``None``. Use
    ``get_subscriber_establishments_with_addresses`` when callers need the full
    SIRENE-enriched address (e.g. the SIRET-correction modal).
    """
    rows = _query_rows(AccountId(account_id))
    return [
        SubscriberEstablishment(
            siret=row.siret,
            name=row.name,
            city=row.city,
            postal_code=row.postal_code,
        )
        for row in rows
    ]

get_subscriber_establishments_with_addresses

get_subscriber_establishments_with_addresses(account_id)

Return establishments enriched with INSEE SIRENE street fields.

Used by callers that need full postal addresses (e.g. the SIRET-correction modal). Street fields fall back to None when SIRENE has no data for the SIRET.

Source code in components/occupational_health/internal/business_logic/queries/subscribers/establishments.py
def get_subscriber_establishments_with_addresses(
    account_id: UUID,
) -> list[SubscriberEstablishment]:
    """Return establishments enriched with INSEE SIRENE street fields.

    Used by callers that need full postal addresses (e.g. the SIRET-correction
    modal). Street fields fall back to ``None`` when SIRENE has no data for the
    SIRET.
    """
    rows = _query_rows(AccountId(account_id))
    dependency = get_app_dependency()
    sirene_by_siret = {
        row.siret: dependency.get_sirene_establishment_from_siret(row.siret)
        for row in rows
    }

    result: list[SubscriberEstablishment] = []
    for row in rows:
        sirene = sirene_by_siret.get(row.siret)
        result.append(
            SubscriberEstablishment(
                siret=row.siret,
                name=row.name,
                city=row.city,
                postal_code=row.postal_code,
                street_number=sirene.street_number if sirene else None,
                street_name=sirene.street_name if sirene else None,
            )
        )
    return result

get_subscriber_for_account

get_subscriber_for_account(account_id)

Get a subscriber for an account.

Source code in components/occupational_health/internal/business_logic/queries/subscribers/subscribers.py
def get_subscriber_for_account(account_id: UUID) -> Subscriber | None:
    """Get a subscriber for an account."""
    dependency = get_app_dependency()

    try:
        account_name = dependency.get_account_name(AccountId(account_id))
    except NoResultFound:
        return None

    companies = _get_companies_for_subscriber_by_account_id(account_id)

    admin_dashboard_config = get_config_for_admin_dashboard(AccountId(account_id))

    return Subscriber(
        id=account_id,
        name=account_name,
        companies=companies,
        health_professional_contact_name=admin_dashboard_config.health_professional_contact_name,
    )

visit_requests

get_visit_requests_to_review

get_visit_requests_to_review()

Get visit requests pending review.

Source code in components/occupational_health/public/queries/visit_requests.py
def get_visit_requests_to_review() -> list[VisitRequestForMarmot]:
    """Get visit requests pending review."""
    visit_requests = OccupationalHealthVisitRequestBroker.get_to_review().all()
    default_hp_owners = _get_default_hp_owners_by_account(visit_requests)
    return [
        _build_visit_request_for_marmot(vr, default_hp_owners) for vr in visit_requests
    ]

visits

get_all_visits_for_member

get_all_visits_for_member(
    profile_service, occupational_health_profile_id
)

Get all visits for a specific member

Parameters:

Name Type Description Default
profile_service ProfileService

The profile service for fetching member information

required
occupational_health_profile_id ProfileId | UUID

Optional filter by occupational health profile ID

required

Returns:

Type Description
list[VisitInfo]

List[VisitInfo]: List of visits scheduled for the specified date

Source code in components/occupational_health/internal/business_logic/queries/visits/medical_app.py
@inject_profile_service
def get_all_visits_for_member(
    profile_service: ProfileService,
    occupational_health_profile_id: ProfileId | UUID,
) -> list[VisitInfo]:
    """
    Get all visits for a specific member

    Args:
        profile_service: The profile service for fetching member information
        occupational_health_profile_id: Optional filter by occupational health profile ID

    Returns:
        List[VisitInfo]: List of visits scheduled for the specified date
    """

    if isinstance(occupational_health_profile_id, UUID):
        occupational_health_profile_id = ProfileId(occupational_health_profile_id)

    occupational_health_profile = (
        current_session.query(OccupationalHealthProfile)  # noqa: ALN085
        .filter(OccupationalHealthProfile.id == occupational_health_profile_id)
        .one_or_none()
    )
    if not occupational_health_profile:
        raise Exception("Occupational Health Profile not found")

    global_profile_id = occupational_health_profile.global_profile_id
    user_id = get_app_dependency().get_user_id_from_global_profile_id(global_profile_id)

    all_visits = _retrieve_all_visits(
        None,
        profile_id_and_user_id={
            "profile_id": occupational_health_profile_id,
            "user_id": user_id,
        },
    )

    # Fetch HPs
    health_professional_mapping = {
        hp.id: hp
        for hp in current_session.query(  # noqa: ALN085
            OccupationalHealthHealthProfessional.id,
            OccupationalHealthHealthProfessional.first_name,
            OccupationalHealthHealthProfessional.last_name,
        )
    }

    # Convert to dataclass instances
    result = []
    profile = profile_service.get_or_raise_profile(
        occupational_health_profile.global_profile_id
    )
    ins_identity = get_ins_identity(occupational_health_profile_id)
    resolved = resolve_identity(
        ins_identity,
        fallback_first_name=profile.first_name,
        fallback_last_name=profile.last_name,
        fallback_gender=profile.gender,
        fallback_birth_date=profile.birth_date,
    )
    for visit in all_visits:
        # Get health professional name
        health_professional = health_professional_mapping.get(
            visit.health_professional_id
        )
        health_professional_name = (
            f"{health_professional.first_name} {health_professional.last_name}".strip()
            if health_professional
            else None
        )

        result.append(
            VisitInfo(
                visit_id=visit.visit_id,
                member_first_name=resolved.first_name or "⁉️",
                member_last_name=resolved.last_name or "⁉️",
                member_gender=resolved.gender,
                member_birthdate=resolved.birth_date,
                visit_date=visit.visit_date,
                visit_hour_booked=_format_visit_hour(visit.visit_hour_booked),
                visit_hour_end=_format_visit_hour(visit.visit_hour_end),
                visit_type=visit.visit_type,
                health_professional_name=health_professional_name,
                health_professional_id=visit.health_professional_id,
                visit_status=visit.status,
                occupational_health_profile_id=occupational_health_profile_id,
                visit_setup=visit.visit_setup,
                visit_model=visit.visit_model,
                account_id=visit.account_id,
                has_individual_measures=visit.individual_measures_start_date
                is not None,
                should_generate_individual_measures_doc=visit.should_generate_individual_measures_doc,
                individual_measures_comment=visit.individual_measures_comment,
                individual_measures_start_date=visit.individual_measures_start_date,
                individual_measures_end_date=visit.individual_measures_end_date,
                are_individual_measures_reasonable_accommodation=visit.are_individual_measures_reasonable_accommodation,
                sir_monitoring_date=visit.sir_monitoring_date,
                declare_unfit=visit.declare_unfit,
                orient_prevenir=visit.reoriented_to_health_professional_id is not None,
                orient_prevenir_hp_id=visit.reoriented_to_health_professional_id,
                orient_prevenir_comment=visit.reoriented_to_health_professional_comment,
                refer_to_doctor=visit.refer_to_doctor,
                refer_to_doctor_name=visit.refer_to_doctor_name,
                refer_to_doctor_comment=visit.refer_to_doctor_comment,
                recommend_rps=visit.recommend_rps,
                recommend_tms=visit.recommend_tms,
                recommend_cmb=visit.recommend_cmb,
                next_visit_date=visit.next_visit_date,
                if_avis_comments=visit.if_avis_comments,
                is_archived=visit.is_archived or False,
                avis_aptitude_to_fill=bool(visit.avis_aptitude_to_fill),
                attestation_de_suivi_to_fill=bool(visit.attestation_de_suivi_to_fill),
                doc_generated=bool(visit.doc_generated),
                individual_measure_doc_generated=bool(
                    visit.individual_measure_doc_generated
                ),
                arrived_in_waiting_room_at=visit.arrived_in_waiting_room_at,
            )
        )

    return result

get_closest_visit_by_health_professional_and_timestamp

get_closest_visit_by_health_professional_and_timestamp(
    health_professional_id, on_datetime
)

Retrieves the closest occupational health visit by a health professional for a given day.

Matches visits with status HAPPENED or TO_FILL (HP may not have updated status yet when audio recording is processed).

Gets all visits for the day and returns the one closest to on_datetime within allowed time window:

  • max 10 minutes before planned time (recording started early)
  • max 20 minutes after planned time (recording started late)

Warning: only querying turing tables for now, could refactor with code above

Source code in components/occupational_health/internal/business_logic/queries/visits/visits.py
def get_closest_visit_by_health_professional_and_timestamp(
    health_professional_id: UUID | None,
    on_datetime: datetime | None,
) -> Optional[PlannedVisit]:
    """
    Retrieves the closest occupational health visit by a health professional for a given day.

    Matches visits with status HAPPENED or TO_FILL (HP may not have updated status yet when
    audio recording is processed).

    Gets all visits for the day and returns the one closest to on_datetime within allowed time window:

    - max 10 minutes before planned time (recording started early)
    - max 20 minutes after planned time (recording started late)

    Warning: only querying turing tables for now, could refactor with code above
    """
    from sqlalchemy import select

    from components.occupational_health.external.global_profile import (
        get_occupational_health_profile_id,
    )
    from components.occupational_health.internal.enums.visit_status import VisitStatus
    from components.occupational_health.internal.models.turing.turing_occupational_health_on_demand_visit import (
        TuringOccupationalHealthOnDemandVisit,
    )
    from components.occupational_health.internal.models.turing.turing_occupational_health_predictable_visit import (
        TuringOccupationalHealthPredictableVisit,
    )
    from components.occupational_health.internal.types import UserId
    from shared.helpers.db import current_session

    if not health_professional_id or not on_datetime:
        raise RuntimeError(
            "Either or both health_professional_id / on_datetime must be provided."
        )

    on_demand_stmt = select(
        TuringOccupationalHealthOnDemandVisit.user_id,
        TuringOccupationalHealthOnDemandVisit.planned_at,
    ).filter(
        TuringOccupationalHealthOnDemandVisit.hp_visit_owner_id
        == health_professional_id,
        TuringOccupationalHealthOnDemandVisit.date_planned == on_datetime.date(),
        TuringOccupationalHealthOnDemandVisit.status.in_(
            [VisitStatus.HAPPENED, VisitStatus.TO_FILL]
        ),
    )

    predictable_stmt = select(
        TuringOccupationalHealthPredictableVisit.user_id,
        TuringOccupationalHealthPredictableVisit.planned_at,
    ).filter(
        TuringOccupationalHealthPredictableVisit.hp_visit_owner_id
        == health_professional_id,
        TuringOccupationalHealthPredictableVisit.date_planned == on_datetime.date(),
        TuringOccupationalHealthPredictableVisit.status.in_(
            [VisitStatus.HAPPENED, VisitStatus.TO_FILL]
        ),
    )

    unioned = on_demand_stmt.union_all(predictable_stmt).subquery()
    stmt = select(
        unioned.c.user_id.label("user_id"),
        unioned.c.planned_at.label("planned_at"),
    )
    visits = current_session.execute(stmt).fetchall()

    if not visits:
        current_logger.info(
            f"No visit found for HP {health_professional_id} on {on_datetime.date()}."
        )
        return None

    # Find the visit closest to on_datetime
    # Use naive datetimes for comparison (planned_at is naive, on_datetime may be aware)
    on_datetime_naive = on_datetime.replace(tzinfo=None)

    # Filter visits within allowed time window:
    # - max 10 minutes before planned time (recording started early)
    # - max 20 minutes after planned time (recording started late)
    max_before_seconds = 10 * 60  # 10 minutes
    max_after_seconds = 20 * 60  # 20 minutes

    valid_visits = []
    for visit in visits:
        diff_seconds = (on_datetime_naive - visit.planned_at).total_seconds()
        # diff_seconds > 0 means on_datetime is after planned (recording late)
        # diff_seconds < 0 means on_datetime is before planned (recording early)
        if -max_before_seconds <= diff_seconds <= max_after_seconds:
            valid_visits.append(visit)

    if not valid_visits:
        current_logger.info(
            f"No visit within time window for HP {health_professional_id} "
            f"on {on_datetime.date()} (max 10min before, 20min after planned time)."
        )
        return None

    closest_visit = min(
        valid_visits,
        key=lambda v: abs((v.planned_at - on_datetime_naive).total_seconds()),
    )

    if not closest_visit.user_id:
        current_logger.error(
            f"Visit for HP {health_professional_id} on {on_datetime.date()} has no user_id."
        )
        return None

    user_id = UserId(closest_visit.user_id)
    profile_id = get_occupational_health_profile_id(user_id)

    # Calculate minutes difference between on_datetime and the found visit
    time_diff_seconds = abs(
        (closest_visit.planned_at - on_datetime_naive).total_seconds()
    )
    minutes_difference = int(time_diff_seconds / 60)

    return PlannedVisit(
        profile_id=profile_id,
        planned_at=closest_visit.planned_at,
        minutes_difference=minutes_difference,
    )

get_current_health_professional_waiting_room_visits

get_current_health_professional_waiting_room_visits(
    health_professional_id, now=None
)

Today's visits for the given HP where the member has just checked in.

Returns every visit that: - is assigned to health_professional_id - is still TO_FILL - has arrived_in_waiting_room_at within ±45 min of now

Sorted by visit_hour_booked ascending (earliest first). The caller is expected to surface the first one and decide what to do if several match.

Source code in components/occupational_health/internal/business_logic/queries/visits/medical_app.py
def get_current_health_professional_waiting_room_visits(
    health_professional_id: UUID,
    now: datetime | None = None,
) -> list[VisitInfo]:
    """Today's visits for the given HP where the member has just checked in.

    Returns every visit that:
    - is assigned to ``health_professional_id``
    - is still ``TO_FILL``
    - has ``arrived_in_waiting_room_at`` within ±45 min of ``now``

    Sorted by ``visit_hour_booked`` ascending (earliest first). The caller is
    expected to surface the first one and decide what to do if several match.
    """
    resolved_now = now or datetime.now(UTC)
    todays_visits = get_visits(on_date=utctoday())

    matches = [
        visit
        for visit in todays_visits
        if visit.health_professional_id == health_professional_id
        and visit.visit_status == VisitStatus.TO_FILL
        and _is_fresh_waiting_room_arrival(
            visit.arrived_in_waiting_room_at, resolved_now
        )
    ]

    matches.sort(key=lambda visit: visit.visit_hour_booked or "")

    if len(matches) > 1:
        # An HP shouldn't normally have several members in the waiting room at
        # once — surface it so we notice if the picker has to choose.
        current_logger.warning(
            "Multiple waiting-room visits for HP",
            health_professional_id=str(health_professional_id),
            visit_ids=[str(visit.visit_id) for visit in matches if visit.visit_id],
        )

    return matches

get_visit_details_by_hp_and_date

get_visit_details_by_hp_and_date(
    health_professional_id,
    visit_date,
    occupational_health_profile_id,
)

Find a turing visit by HP ID, date, and occupational health profile ID.

Source code in components/occupational_health/internal/business_logic/queries/visits/visits.py
def get_visit_details_by_hp_and_date(
    health_professional_id: UUID,
    visit_date: date,
    occupational_health_profile_id: UUID,
) -> Optional[PlannedVisitWithDetails]:
    """Find a turing visit by HP ID, date, and occupational health profile ID."""
    from sqlalchemy import literal_column, select

    from components.occupational_health.external.global_profile import (
        get_user_id_from_occupational_health_profile_id,
    )
    from components.occupational_health.internal.queries.health_professional import (
        get_or_raise_health_professional,
    )

    user_id = get_user_id_from_occupational_health_profile_id(
        ProfileId(occupational_health_profile_id)
    )
    hp = get_or_raise_health_professional(health_professional_id)
    hp_name = f"{hp.first_name} {hp.last_name}"

    user_id_str = str(user_id)

    on_demand_stmt = select(  # type: ignore[var-annotated]
        TuringOccupationalHealthOnDemandVisit.id,
        TuringOccupationalHealthOnDemandVisit.planned_at.label("planned_at"),
        TuringOccupationalHealthOnDemandVisit.visit_type,
        TuringOccupationalHealthOnDemandVisit.visit_setup,
        literal_column("'on_demand'").label("visit_table"),
    ).filter(
        TuringOccupationalHealthOnDemandVisit.hp_visit_owner_id
        == health_professional_id,
        TuringOccupationalHealthOnDemandVisit.date_planned == visit_date,
        TuringOccupationalHealthOnDemandVisit.status == VisitStatus.HAPPENED,
        TuringOccupationalHealthOnDemandVisit.user_id == user_id_str,
    )

    predictable_stmt = select(  # type: ignore[var-annotated]
        TuringOccupationalHealthPredictableVisit.id,
        TuringOccupationalHealthPredictableVisit.planned_at.label("planned_at"),
        TuringOccupationalHealthPredictableVisit.visit_type,
        TuringOccupationalHealthPredictableVisit.visit_setup,
        literal_column("'predictable'").label("visit_table"),
    ).filter(
        TuringOccupationalHealthPredictableVisit.hp_visit_owner_id
        == health_professional_id,
        TuringOccupationalHealthPredictableVisit.date_planned == visit_date,
        TuringOccupationalHealthPredictableVisit.status == VisitStatus.HAPPENED,
        TuringOccupationalHealthPredictableVisit.user_id == user_id_str,
    )

    unioned = on_demand_stmt.union_all(predictable_stmt).subquery()
    visit = current_session.execute(select(unioned)).one_or_none()

    if not visit:
        return None

    return PlannedVisitWithDetails(
        visit_id=visit.id,
        profile_id=occupational_health_profile_id,
        planned_at=visit.planned_at,
        visit_table=visit.visit_table,
        visit_type=visit.visit_type,
        visit_setup=visit.visit_setup,
        health_professional_name=hp_name,
    )

get_visits

get_visits(profile_service, on_date)

Get all visits scheduled for a specific date. If no date is provided, return visits for today.

Parameters:

Name Type Description Default
profile_service ProfileService

The profile service for fetching member information

required
on_date date | None

The date to fetch visits for. If None, uses today's date.

required

Returns:

Type Description
list[VisitInfo]

List[VisitInfo]: List of visits scheduled for the specified date

Source code in components/occupational_health/internal/business_logic/queries/visits/medical_app.py
@inject_profile_service
def get_visits(
    profile_service: ProfileService,
    on_date: date | None,
) -> list[VisitInfo]:
    """
    Get all visits scheduled for a specific date.
    If no date is provided, return visits for today.

    Args:
        profile_service: The profile service for fetching member information
        on_date: The date to fetch visits for. If None, uses today's date.

    Returns:
        List[VisitInfo]: List of visits scheduled for the specified date
    """
    if not on_date:
        on_date = utctoday()

    # Query visits scheduled for the specified date
    all_visits = _retrieve_all_visits(on_date, None)

    # Now, the mapping mess: we either have the user ID, or the (Occupational Health) profile ID
    # Get the global profile ID for the user IDs
    user_ids_found = {
        visit.user_id for visit in all_visits if visit.user_id is not None
    }
    user_id_mapping = (
        get_app_dependency().get_user_id_by_global_profile_id_mapping_from_user_ids(
            user_ids_found
        )
    )
    global_profile_id_by_user_id = {
        user_id: global_profile_id
        for global_profile_id, user_id in user_id_mapping.items()
    }

    # Get the list of found profile IDs
    profile_ids_found = {
        visit.profile_id for visit in all_visits if visit.profile_id is not None
    }

    # Based on those two lists, get the final mapping of (Occupational Health) profile IDs indexed by the Global Profile ID
    profile_id_by_global_profile_id: dict[GlobalProfileId, ProfileId] = {
        profile.global_profile_id: profile.id
        for profile in (
            current_session.query(  # noqa: ALN085
                OccupationalHealthProfile.id,
                OccupationalHealthProfile.global_profile_id,
            )
            .filter(
                or_(
                    OccupationalHealthProfile.global_profile_id.in_(
                        user_id_mapping.keys()
                    ),
                    OccupationalHealthProfile.id.in_(profile_ids_found),
                )
            )
            .all()
        )
    }

    # Get profile information from profile service
    profiles = profile_service.get_profiles(
        profile_ids=list(profile_id_by_global_profile_id.keys())
    )
    profiles_by_occupational_health_profile_id = {
        profile_id_by_global_profile_id[
            GlobalProfileId(global_profile.id)
        ]: global_profile
        for global_profile in profiles
    }

    ins_identities = get_ins_identities_batch(
        set(profiles_by_occupational_health_profile_id.keys())
    )

    # Current occupational-health job (Poste) per member, keyed by occupational health profile id.
    # Excludes past jobs to match the member-profile header ("current job" = not is_past_job).
    latest_job_by_profile_id = {
        occupational_health_profile.id: job
        for occupational_health_profile, job in (
            get_occupational_health_profile_by_global_profile_ids_with_latest_job(
                list(profile_id_by_global_profile_id.keys()),
                exclude_past_jobs=True,
            )
        )
    }

    # Active affiliation (subscriber account) names per member, keyed by occupational health profile id.
    affiliation_names_by_profile_id = get_active_affiliations_by_profile_ids(
        list(profile_id_by_global_profile_id.values())
    )

    # Current risk category (e.g. SI / SIR / SIA) per member, keyed by occupational health profile id.
    risk_category_by_profile_id = get_current_risk_category_for_profiles(
        list(profile_id_by_global_profile_id.values())
    )

    # Fetch HPs
    health_professional_mapping = {
        hp.id: hp
        for hp in current_session.query(  # noqa: ALN085
            OccupationalHealthHealthProfessional.id,
            OccupationalHealthHealthProfessional.first_name,
            OccupationalHealthHealthProfessional.last_name,
        )
    }

    # Convert to dataclass instances
    result = []
    for visit in all_visits:
        # Get health professional name
        health_professional = health_professional_mapping.get(
            visit.health_professional_id
        )
        health_professional_name = (
            f"{health_professional.first_name} {health_professional.last_name}".strip()
            if health_professional
            else None
        )

        # Get member information from global profile
        occupational_health_profile_id = visit.profile_id
        if not occupational_health_profile_id:
            # Visit imported from GSheets, we only know the User ID
            global_profile_id = global_profile_id_by_user_id.get(visit.user_id)
            if not global_profile_id:
                current_logger.error(
                    f"Found spreadsheet visit with unknown user ID {visit.user_id} ({visit.visit_type} on date {visit.visit_date})."
                    " The user ID from the GSheet probably doesn't exist.",
                    stack_info=False,
                )
                # Do not crash but return "empty" info about member. HPs will not be
                # able to open the DMST.
                result.append(_make_empty_visit_info(visit, health_professional_name))
                continue

            if global_profile_id not in profile_id_by_global_profile_id:
                current_logger.error(
                    f"Global Profile ID {global_profile_id} ({visit.user_id=}) not found for visit ({visit.visit_type} on date {visit.visit_date})."
                    " The user is probably not affiliated to Prévenir.",
                    stack_info=False,
                )
                # Do not crash but return "empty" info about member. HPs will not be
                # able to open the DMST.
                result.append(_make_empty_visit_info(visit, health_professional_name))
                continue

            occupational_health_profile_id = profile_id_by_global_profile_id[
                global_profile_id
            ]

        if (
            occupational_health_profile_id
            not in profiles_by_occupational_health_profile_id
        ):
            current_logger.error(
                f"Occupational Health Profile ID {occupational_health_profile_id} ({visit.user_id=}) not found for visit ({visit.visit_type} on date {visit.visit_date})."
                "The profile is probably not affiliated to Prévenir.",
                stack_info=False,
            )
            # Do not crash but return "empty" info about member. HPs will not be
            # able to open the DMST.
            result.append(_make_empty_visit_info(visit, health_professional_name))
            continue

        global_profile = profiles_by_occupational_health_profile_id[
            occupational_health_profile_id
        ]
        ins_identity = ins_identities.get(occupational_health_profile_id)
        resolved = resolve_identity(
            ins_identity,
            fallback_first_name=global_profile.first_name,
            fallback_last_name=global_profile.last_name,
            fallback_gender=global_profile.gender,
            fallback_birth_date=global_profile.birth_date,
        )

        latest_job = latest_job_by_profile_id.get(occupational_health_profile_id)
        job_title = latest_job.title if latest_job else None
        affiliation_names = affiliation_names_by_profile_id.get(
            occupational_health_profile_id, []
        )

        result.append(
            VisitInfo(
                member_first_name=resolved.first_name or "⁉️",
                member_last_name=resolved.last_name or "⁉️",
                member_gender=resolved.gender,
                member_birthdate=resolved.birth_date,
                visit_date=visit.visit_date,
                visit_hour_booked=_format_visit_hour(visit.visit_hour_booked),
                visit_hour_end=_format_visit_hour(visit.visit_hour_end),
                visit_type=visit.visit_type,
                health_professional_name=health_professional_name,
                health_professional_id=visit.health_professional_id,
                visit_status=visit.status,
                visit_id=visit.visit_id,
                occupational_health_profile_id=profile_id_by_global_profile_id[
                    GlobalProfileId(global_profile.id)
                ],
                visit_setup=visit.visit_setup,
                visit_model=visit.visit_model,
                account_id=visit.account_id,
                has_individual_measures=visit.individual_measures_start_date
                is not None,
                should_generate_individual_measures_doc=visit.should_generate_individual_measures_doc,
                individual_measures_comment=visit.individual_measures_comment,
                individual_measures_start_date=visit.individual_measures_start_date,
                individual_measures_end_date=visit.individual_measures_end_date,
                are_individual_measures_reasonable_accommodation=visit.are_individual_measures_reasonable_accommodation,
                sir_monitoring_date=visit.sir_monitoring_date,
                declare_unfit=visit.declare_unfit,
                orient_prevenir=visit.reoriented_to_health_professional_id is not None,
                orient_prevenir_hp_id=visit.reoriented_to_health_professional_id,
                orient_prevenir_comment=visit.reoriented_to_health_professional_comment,
                refer_to_doctor=visit.refer_to_doctor,
                refer_to_doctor_name=visit.refer_to_doctor_name,
                refer_to_doctor_comment=visit.refer_to_doctor_comment,
                recommend_rps=visit.recommend_rps,
                recommend_tms=visit.recommend_tms,
                recommend_cmb=visit.recommend_cmb,
                next_visit_date=visit.next_visit_date,
                if_avis_comments=visit.if_avis_comments,
                ins_status=ins_identity.status if ins_identity else None,
                is_archived=visit.is_archived or False,
                avis_aptitude_to_fill=bool(visit.avis_aptitude_to_fill),
                attestation_de_suivi_to_fill=bool(visit.attestation_de_suivi_to_fill),
                doc_generated=bool(visit.doc_generated),
                individual_measure_doc_generated=bool(
                    visit.individual_measure_doc_generated
                ),
                arrived_in_waiting_room_at=visit.arrived_in_waiting_room_at,
                job_title=job_title,
                affiliation_names=affiliation_names,
                risk_category=risk_category_by_profile_id.get(
                    occupational_health_profile_id
                ),
            )
        )

    # Sort visits by visit hour booked (if available) and put None value in front
    result.sort(
        key=lambda v: (v.visit_hour_booked not in (None, ""), v.visit_hour_booked or "")
    )
    return result

workspace_actions

get_all_workspace_actions

get_all_workspace_actions(
    occupational_health_profile_id=None, account_id=None
)

Get all workspace actions, optionally filtered by occupational health profile ID or account ID (XOR).

Parameters:

Name Type Description Default
occupational_health_profile_id Optional[UUID]

Optional profile ID to filter member-type actions

None
account_id Optional[UUID]

Optional account ID to filter company-type actions

None

Returns:

Type Description
list[WorkspaceAction]

List of WorkspaceAction entities

Source code in components/occupational_health/public/queries/workspace_actions.py
def get_all_workspace_actions(
    occupational_health_profile_id: Optional[UUID] = None,
    account_id: Optional[UUID] = None,
) -> list[WorkspaceAction]:
    """
    Get all workspace actions, optionally filtered by occupational health profile ID or account ID (XOR).

    Args:
        occupational_health_profile_id: Optional profile ID to filter member-type actions
        account_id: Optional account ID to filter company-type actions

    Returns:
        List of WorkspaceAction entities
    """
    return _get_all_workspace_actions(
        occupational_health_profile_id=occupational_health_profile_id,
        account_id=account_id,
    )

components.occupational_health.public.testing

OccupationalHealthHealthProfessionalFactory

Bases: AlanBaseFactory['OccupationalHealthHealthProfessional']

Meta

model class-attribute instance-attribute
model = OccupationalHealthHealthProfessional

first_name class-attribute instance-attribute

first_name = LazyFunction(lambda: first_name())

gender class-attribute instance-attribute

gender = None

last_name class-attribute instance-attribute

last_name = LazyFunction(lambda: last_name())

offboarded_at class-attribute instance-attribute

offboarded_at = None

role class-attribute instance-attribute

role = DOCTOR

components.occupational_health.public.thesaurus

expositions

actions

parse_and_store_tep_rdf
parse_and_store_tep_rdf(file_path, commit=True)

Parse a TEP RDF file (compressed or not) and store expositions in the database.

Parameters:

Name Type Description Default
file_path str | Path

Path to the RDF file to parse (can be gzip compressed with .gz extension)

required

Raises:

Type Description
TepParsingError

If there is an error parsing the file or if the file is invalid

FileNotFoundError

If the file does not exist

Source code in components/occupational_health/internal/thesaurus/expositions/parser.py
def parse_and_store_tep_rdf(file_path: str | Path, commit: bool = True) -> None:
    """
    Parse a TEP RDF file (compressed or not) and store expositions in the database.

    Args:
        file_path: Path to the RDF file to parse (can be gzip compressed with .gz extension)

    Raises:
        TepParsingError: If there is an error parsing the file or if the file is invalid
        FileNotFoundError: If the file does not exist
    """
    from defusedxml import ElementTree as ET  # lazy: heavy import

    # Convert to Path object if string
    file_path = Path(file_path)

    try:
        # Parse the file based on whether it's compressed or not
        if file_path.suffix == ".gz":
            with GzipFile(file_path) as gz_file:
                tree = ET.parse(gz_file)
        else:
            tree = ET.parse(file_path)

        root = tree.getroot()

        # Define XML namespaces used in the RDF file
        namespaces = {
            "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
            "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
            "skos": "http://www.w3.org/2004/02/skos/core#",
            "dct": "http://purl.org/dc/terms/",
            "dc": "http://purl.org/dc/elements/1.1/",
            "adms": "http://www.w3.org/ns/adms#",
            "tep": "http://data.esante.gouv.fr/tep/",
        }

        expositions: list[ParsedThesaurusOccupationalExposure] = []

        # Find all Description elements that have a rdfs:label
        descriptions = root.findall(".//rdf:Description", namespaces)  # pyrefly: ignore [missing-attribute]

        for desc in descriptions:
            # Extract required fields using XML paths
            label_elem = desc.find("rdfs:label", namespaces)
            notation_elem = desc.find("skos:notation", namespaces)
            type_elem = desc.find("dc:type", namespaces)
            status_elem = desc.find("adms:status", namespaces)
            created_elem = desc.find("dct:created", namespaces)
            version_elem = desc.find("tep:versionTep", namespaces)
            line_num_elem = desc.find("tep:numLigne", namespaces)

            # Skip if any required field is missing
            if (
                label_elem is None
                or notation_elem is None
                or type_elem is None
                or status_elem is None
                or created_elem is None
                or version_elem is None
                or line_num_elem is None
            ):
                continue

            # Get parent notation: it always exists
            parent_notation = None
            parent_elem = desc.find("rdfs:subClassOf", namespaces)
            if parent_elem is not None:
                parent_ref = parent_elem.get(
                    "{http://www.w3.org/1999/02/22-rdf-syntax-ns#}resource"
                )
                if parent_ref:
                    # Find the parent Description element
                    parent_desc = root.find(  # pyrefly: ignore [missing-attribute]
                        f".//rdf:Description[@rdf:about='{parent_ref}']", namespaces
                    )
                    if parent_desc is not None:
                        parent_notation_elem = parent_desc.find(
                            "skos:notation", namespaces
                        )
                        if parent_notation_elem is not None:
                            parent_notation = parent_notation_elem.text

            # Get risk category from list of risks
            risk_category = RiskCategory.SI
            if label_elem.text.strip().lower() in SIR_LABELS:
                risk_category = RiskCategory.SIR
            if (
                label_elem.text.strip().lower() in SIA_LABELS
                or notation_elem.text in SIA_NOTATIONS
            ):
                risk_category = RiskCategory.SIA

            try:
                # Skip if type is not in TepDcType enum
                if type_elem.text not in [e.value for e in DcType]:
                    current_logger.error(
                        f"Skipping exposition {notation_elem.text} due to invalid type: {type_elem.text}"
                    )
                    continue

                exposition = ParsedThesaurusOccupationalExposure(
                    notation=notation_elem.text,
                    label=label_elem.text.strip(),
                    dc_type=DcType.validate(type_elem.text),  # pyrefly: ignore [bad-argument-type]
                    status=AdmsStatus.validate(status_elem.text),  # pyrefly: ignore [bad-argument-type]
                    created_at=datetime.fromisoformat(created_elem.text),
                    version=version_elem.text,
                    line_number=int(line_num_elem.text),
                    risk_category=risk_category,
                    parent_notation=mandatory(parent_notation),
                )
                expositions.append(exposition)
            except Exception as exception:
                raise TepParsingError(
                    f"Error parsing exposition with notation {notation_elem.text}: {str(exception)[:250]}"
                ) from exception

        if not expositions:
            raise TepParsingError("No valid expositions found in the file")

        current_logger.info(f"Found {len(expositions)} TEP expositions to upsert")

        # Add information that is not present in the initial file
        add_class_subclass_notations(expositions)

        # Bulk insert with upsert on conflict (PostgreSQL specific)
        statement = insert(ThesaurusOccupationalExposure).values(
            [exposition.to_dict() for exposition in expositions]
        )
        statement = statement.on_conflict_do_update(
            index_elements=["notation"],
            set_={
                "label": statement.excluded.label,
                "dc_type": statement.excluded.dc_type,
                "status": statement.excluded.status,
                "created_at": statement.excluded.created_at,
                "version": statement.excluded.version,
                "line_number": statement.excluded.line_number,
                "risk_category": statement.excluded.risk_category,
                "parent_notation": statement.excluded.parent_notation,
                "class_notation": statement.excluded.class_notation,
                "subclass_notation": statement.excluded.subclass_notation,
            },
        )
        current_session.execute(statement)
        if commit:
            current_session.commit()
        else:
            current_session.rollback()

        current_logger.info(f"Successfully stored {len(expositions)} TEP expositions")

    except FileNotFoundError as exception:
        raise TepParsingError(f"File {file_path} not found") from exception

    except Exception as exception:
        current_session.rollback()
        raise TepParsingError(
            f"Error parsing TEP RDF file {file_path}: {str(exception)[:250]}"
        ) from exception

queries

get_expositions
get_expositions(exposition_notations)

Return the exposition data matching the given notations

Source code in components/occupational_health/internal/thesaurus/expositions/queries.py
@tracer_wrap()
def get_expositions(
    exposition_notations: list[str],
) -> list[ThesaurusOccupationalExposure]:
    """
    Return the exposition data matching the given notations
    """
    expositions = ThesaurusOccupationalExposureBroker.get_by_notations(
        exposition_notations
    ).all()
    class_and_subclass_labels_by_notation = get_class_subclass_labels(list(expositions))
    return [
        ThesaurusOccupationalExposureMapper.to_entity(
            model, class_and_subclass_labels_by_notation
        )
        for model in expositions
    ]
search_expositions
search_expositions(search_input)

Return the exposition data matching the given search input (case-insensitive and accent-insensitive)

Note: adds the class and subclass labels to the exposition objects

Source code in components/occupational_health/internal/thesaurus/expositions/queries.py
@tracer_wrap()
def search_expositions(search_input: str) -> list[ThesaurusOccupationalExposure]:
    """
    Return the exposition data matching the given search input (case-insensitive and accent-insensitive)

    Note: adds the class and subclass labels to the exposition objects
    """
    expositions = ThesaurusOccupationalExposureBroker.search_in_label(
        search_input
    ).all()
    class_and_subclass_labels_by_notation = get_class_subclass_labels(list(expositions))
    return [
        ThesaurusOccupationalExposureMapper.to_entity(
            model, class_and_subclass_labels_by_notation
        )
        for model in expositions
    ]

means

actions

sync_thesaurus_means_from_registry
sync_thesaurus_means_from_registry(dry_run)

Sync the ThesaurusMean table from the static THESAURUS_MEANS registry.

For each entry in the registry: - If a row with that key exists: update label/presanse_label/is_displayed/category if changed - If no row with that key but a row with matching label exists: assign the key and update fields - Otherwise: create a new row

Also warns about orphan rows (in DB but not in registry).

Source code in components/occupational_health/internal/thesaurus/means/sync_thesaurus_means.py
def sync_thesaurus_means_from_registry(dry_run: bool) -> None:
    """Sync the ThesaurusMean table from the static THESAURUS_MEANS registry.

    For each entry in the registry:
    - If a row with that key exists: update label/presanse_label/is_displayed/category if changed
    - If no row with that key but a row with matching label exists: assign the key and update fields
    - Otherwise: create a new row

    Also warns about orphan rows (in DB but not in registry).
    """
    existing_by_key: dict[str, ThesaurusMean] = {
        mean.key: mean
        for mean in current_session.execute(
            select(ThesaurusMean).where(ThesaurusMean.key.isnot(None))
        ).scalars()
        if mean.key is not None  # required to make mypy happy
    }
    existing_by_label: dict[str, ThesaurusMean] = {
        mean.label: mean
        for mean in current_session.execute(
            select(ThesaurusMean).where(ThesaurusMean.key.is_(None))
        ).scalars()
    }

    registry_keys = {definition.key for definition in THESAURUS_MEANS}
    created_count = 0
    updated_count = 0
    matched_count = 0

    for definition in THESAURUS_MEANS:
        if definition.key in existing_by_key:
            mean = existing_by_key[definition.key]
            if _update_mean_from_definition(mean, definition):
                updated_count += 1
                current_logger.info(
                    "Updated thesaurus mean",
                    key=definition.key,
                )
        elif definition.label in existing_by_label:
            mean = existing_by_label.pop(definition.label)
            mean.key = definition.key
            _update_mean_from_definition(mean, definition)
            matched_count += 1
            current_logger.info(
                "Matched existing mean by label, assigned key",
                key=definition.key,
                label=definition.label,
            )
        else:
            mean = ThesaurusMean(
                key=definition.key,
                label=definition.label,
                presanse_label=definition.presanse_label,
                is_displayed=definition.is_displayed,
                category=definition.category,
            )
            current_session.add(mean)
            created_count += 1
            current_logger.info(
                "Created thesaurus mean",
                key=definition.key,
            )

    orphan_keys = set(existing_by_key.keys()) - registry_keys
    for orphan_key in orphan_keys:
        current_logger.warning(
            "Orphan thesaurus mean in DB (not in registry)",
            key=orphan_key,
        )

    for label in existing_by_label:
        current_logger.warning(
            "Unmatched thesaurus mean in DB (no key, label not in registry)",
            label=label,
        )

    if dry_run:
        current_session.flush()
        current_logger.info(
            f"DRY RUN: {created_count} created, {updated_count} updated, "
            f"{matched_count} matched by label, {len(orphan_keys)} orphans, "
            f"{len(existing_by_label)} unmatched",
        )
    else:
        current_session.commit()
        current_logger.info(
            f"{created_count} created, {updated_count} updated, "
            f"{matched_count} matched by label, {len(orphan_keys)} orphans, "
            f"{len(existing_by_label)} unmatched",
        )

queries

get_all_thesaurus_means
get_all_thesaurus_means()

Return only displayed means (used by the frontend dropdown).

Source code in components/occupational_health/internal/thesaurus/means/queries.py
@tracer_wrap()
def get_all_thesaurus_means() -> list[ThesaurusMean]:
    """Return only displayed means (used by the frontend dropdown)."""
    means = ThesaurusMeanBroker.get_all_displayed().all()
    return [ThesaurusMeanMapper.to_entity(model) for model in means]

components.occupational_health.public.types

AccountId module-attribute

AccountId = NewType('AccountId', UUID)

The Account ID type.

CompanyId module-attribute

CompanyId = NewType('CompanyId', str)

The Company ID type. We use 'str' to not make assumption on the country's representation of the ID.

GlobalProfileId module-attribute

GlobalProfileId = NewType('GlobalProfileId', UUID)

The Global Profile ID type.

ProfileId module-attribute

ProfileId = NewType('ProfileId', UUID)

The Occupational Health Profile ID type.

UserId module-attribute

UserId = NewType('UserId', str)

The country-specific User ID type. We use 'str' to not make assumption on the country's representation of the ID.