diff --git a/api/pyproject.toml b/api/pyproject.toml index e729078e8..288b265a2 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -26,5 +26,6 @@ fastapi = ["fastapi", "fastapi_pagination"] markers = ''' with_token: inject token headers in test client with_admin_token: inject admin token headers in test client + feature_deprecated: the marked test is deprecated ''' testpaths = "tests" diff --git a/api/src/alembic/versions/20240527_130609_9f9a66546e3a_add_fk_structure_commune.py b/api/src/alembic/versions/20240527_130609_9f9a66546e3a_add_fk_structure_commune.py new file mode 100644 index 000000000..314cfb87d --- /dev/null +++ b/api/src/alembic/versions/20240527_130609_9f9a66546e3a_add_fk_structure_commune.py @@ -0,0 +1,58 @@ +"""add-fk-structure-commune + +Revision ID: 9f9a66546e3a +Revises: 170af30febde +Create Date: 2024-05-27 13:06:09.931428 + +""" + +import sqlalchemy as sa +from alembic import op + +from data_inclusion.api.code_officiel_geo import constants +from data_inclusion.api.code_officiel_geo.models import Commune +from data_inclusion.api.inclusion_data.models import Service, Structure + +# revision identifiers, used by Alembic. +revision = "9f9a66546e3a" +down_revision = "170af30febde" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + conn = op.get_bind() + + # must clean up the data before adding the foreign key + for model in [Structure, Service]: + # remove district codes + for k, v in constants._DISTRICTS_BY_CITY.items(): + conn.execute( + sa.update(model) + .where(model.code_insee.startswith(v[0][:3])) + .values({model.code_insee: k}) + .returning(1) + ) + + # remove invalid codes + conn.execute( + sa.update(model) + .where(model.code_insee.not_in(sa.select(Commune.code))) + .values({model.code_insee: None}) + ) + + op.create_foreign_key( + op.f("fk_api__structures__code_insee__api__communes"), + "api__structures", + "api__communes", + ["code_insee"], + ["code"], + ) + + +def downgrade() -> None: + op.drop_constraint( + op.f("fk_api__structures__code_insee__api__communes"), + "api__structures", + type_="foreignkey", + ) diff --git a/api/src/data_inclusion/api/api_description.md b/api/src/data_inclusion/api/api_description.md index 816138487..46c653b54 100644 --- a/api/src/data_inclusion/api/api_description.md +++ b/api/src/data_inclusion/api/api_description.md @@ -27,6 +27,16 @@ Les données data·inclusion sont issues d'un ensemble de sources (emplois de l' Le endpoint `/sources` permet de lister les sources disponibles. + +### Filtrer géographiquement les données + +Les données renvoyées par certains endpoints peuvent être filtrées géographiquement. + +Les codes communes, départements et régions utilisés sont issus du [code officiel géographique produit par l'INSEE](https://www.insee.fr/fr/information/2560452). + +L'[api de la base adresse nationale](https://adresse.data.gouv.fr/api-doc/adresse) peut être utilisée afin d'automatiser l'identification de codes insee associés à partir d'adresses ou de parties d'adresses (e.g. nom de commune, code postal). + + ### Nous contacter #### via notre [formulaire de contact](https://tally.so/r/w7N6Zz) diff --git a/api/src/data_inclusion/api/code_officiel_geo/constants.py b/api/src/data_inclusion/api/code_officiel_geo/constants.py index ff2a13001..c50f20785 100644 --- a/api/src/data_inclusion/api/code_officiel_geo/constants.py +++ b/api/src/data_inclusion/api/code_officiel_geo/constants.py @@ -1,127 +1,169 @@ +"""This module contains constants related to the official geographical codes. + +cf https://www.insee.fr/fr/information/2560452 +""" + from dataclasses import dataclass from enum import Enum @dataclass(frozen=True) -class _Departement: +class Departement: slug: str - cog: str - - -_departements_dict = { - "AIN": _Departement("ain", "01"), - "AISNE": _Departement("aisne", "02"), - "ALLIER": _Departement("allier", "03"), - "ALPES_DE_HAUTE_PROVENCE": _Departement("alpes-de-haute-provence", "04"), - "HAUTES_ALPES": _Departement("hautes-alpes", "05"), - "ALPES_MARITIMES": _Departement("alpes-maritimes", "06"), - "ARDECHE": _Departement("ardeche", "07"), - "ARDENNES": _Departement("ardennes", "08"), - "ARIEGE": _Departement("ariege", "09"), - "AUBE": _Departement("aube", "10"), - "AUDE": _Departement("aude", "11"), - "AVEYRON": _Departement("aveyron", "12"), - "BOUCHES_DU_RHONE": _Departement("bouches-du-rhone", "13"), - "CALVADOS": _Departement("calvados", "14"), - "CANTAL": _Departement("cantal", "15"), - "CHARENTE": _Departement("charente", "16"), - "CHARENTE_MARITIME": _Departement("charente-maritime", "17"), - "CHER": _Departement("cher", "18"), - "CORREZE": _Departement("correze", "19"), - "COTE_D_OR": _Departement("cote-d-or", "21"), - "COTES_D_ARMOR": _Departement("cotes-d-armor", "22"), - "CREUSE": _Departement("creuse", "23"), - "DORDOGNE": _Departement("dordogne", "24"), - "DOUBS": _Departement("doubs", "25"), - "DROME": _Departement("drome", "26"), - "EURE": _Departement("eure", "27"), - "EURE_ET_LOIR": _Departement("eure-et-loir", "28"), - "FINISTERE": _Departement("finistere", "29"), - "CORSE_DU_SUD": _Departement("corse-du-sud", "2A"), - "HAUTE_CORSE": _Departement("haute-corse", "2B"), - "GARD": _Departement("gard", "30"), - "HAUTE_GARONNE": _Departement("haute-garonne", "31"), - "GERS": _Departement("gers", "32"), - "GIRONDE": _Departement("gironde", "33"), - "HERAULT": _Departement("herault", "34"), - "ILLE_ET_VILAINE": _Departement("ille-et-vilaine", "35"), - "INDRE": _Departement("indre", "36"), - "INDRE_ET_LOIRE": _Departement("indre-et-loire", "37"), - "ISERE": _Departement("isere", "38"), - "JURA": _Departement("jura", "39"), - "LANDES": _Departement("landes", "40"), - "LOIR_ET_CHER": _Departement("loir-et-cher", "41"), - "LOIRE": _Departement("loire", "42"), - "HAUTE_LOIRE": _Departement("haute-loire", "43"), - "LOIRE_ATLANTIQUE": _Departement("loire-atlantique", "44"), - "LOIRET": _Departement("loiret", "45"), - "LOT": _Departement("lot", "46"), - "LOT_ET_GARONNE": _Departement("lot-et-garonne", "47"), - "LOZERE": _Departement("lozere", "48"), - "MAINE_ET_LOIRE": _Departement("maine-et-loire", "49"), - "MANCHE": _Departement("manche", "50"), - "MARNE": _Departement("marne", "51"), - "HAUTE_MARNE": _Departement("haute-marne", "52"), - "MAYENNE": _Departement("mayenne", "53"), - "MEURTHE_ET_MOSELLE": _Departement("meurthe-et-moselle", "54"), - "MEUSE": _Departement("meuse", "55"), - "MORBIHAN": _Departement("morbihan", "56"), - "MOSELLE": _Departement("moselle", "57"), - "NIEVRE": _Departement("nievre", "58"), - "NORD": _Departement("nord", "59"), - "OISE": _Departement("oise", "60"), - "ORNE": _Departement("orne", "61"), - "PAS_DE_CALAIS": _Departement("pas-de-calais", "62"), - "PUY_DE_DOME": _Departement("puy-de-dome", "63"), - "PYRENEES_ATLANTIQUES": _Departement("pyrenees-atlantiques", "64"), - "HAUTES_PYRENEES": _Departement("hautes-pyrenees", "65"), - "PYRENEES_ORIENTALES": _Departement("pyrenees-orientales", "66"), - "BAS_RHIN": _Departement("bas-rhin", "67"), - "HAUT_RHIN": _Departement("haut-rhin", "68"), - "RHONE": _Departement("rhone", "69"), - "HAUTE_SAONE": _Departement("haute-saone", "70"), - "SAONE_ET_LOIRE": _Departement("saone-et-loire", "71"), - "SARTHE": _Departement("sarthe", "72"), - "SAVOIE": _Departement("savoie", "73"), - "HAUTE_SAVOIE": _Departement("haute-savoie", "74"), - "PARIS": _Departement("paris", "75"), - "SEINE_MARITIME": _Departement("seine-maritime", "76"), - "SEINE_ET_MARNE": _Departement("seine-et-marne", "77"), - "YVELINES": _Departement("yvelines", "78"), - "DEUX_SEVRES": _Departement("deux-sevres", "79"), - "SOMME": _Departement("somme", "80"), - "TARN": _Departement("tarn", "81"), - "TARN_ET_GARONNE": _Departement("tarn-et-garonne", "82"), - "VAR": _Departement("var", "83"), - "VAUCLUSE": _Departement("vaucluse", "84"), - "VENDEE": _Departement("vendee", "85"), - "VIENNE": _Departement("vienne", "86"), - "HAUTE_VIENNE": _Departement("haute-vienne", "87"), - "VOSGES": _Departement("vosges", "88"), - "YONNE": _Departement("yonne", "89"), - "TERRITOIRE_DE_BELFORT": _Departement("territoire-de-belfort", "90"), - "ESSONNE": _Departement("essonne", "91"), - "HAUTS_DE_SEINE": _Departement("hauts-de-seine", "92"), - "SEINE_SAINT_DENIS": _Departement("seine-saint-denis", "93"), - "VAL_DE_MARNE": _Departement("val-de-marne", "94"), - "VAL_D_OISE": _Departement("val-d-oise", "95"), - "GUADELOUPE": _Departement("guadeloupe", "971"), - "MARTINIQUE": _Departement("martinique", "972"), - "GUYANE": _Departement("guyane", "973"), - "LA_REUNION": _Departement("la-reunion", "974"), - "MAYOTTE": _Departement("mayotte", "976"), -} + code: str + + +class DepartementEnum(Enum): + AIN = Departement("ain", "01") + AISNE = Departement("aisne", "02") + ALLIER = Departement("allier", "03") + ALPES_DE_HAUTE_PROVENCE = Departement("alpes-de-haute-provence", "04") + ALPES_MARITIMES = Departement("alpes-maritimes", "06") + ARDECHE = Departement("ardeche", "07") + ARDENNES = Departement("ardennes", "08") + ARIEGE = Departement("ariege", "09") + AUBE = Departement("aube", "10") + AUDE = Departement("aude", "11") + AVEYRON = Departement("aveyron", "12") + BAS_RHIN = Departement("bas-rhin", "67") + BOUCHES_DU_RHONE = Departement("bouches-du-rhone", "13") + CALVADOS = Departement("calvados", "14") + CANTAL = Departement("cantal", "15") + CHARENTE_MARITIME = Departement("charente-maritime", "17") + CHARENTE = Departement("charente", "16") + CHER = Departement("cher", "18") + CORREZE = Departement("correze", "19") + CORSE_DU_SUD = Departement("corse-du-sud", "2A") + COTE_D_OR = Departement("cote-d-or", "21") + COTES_D_ARMOR = Departement("cotes-d-armor", "22") + CREUSE = Departement("creuse", "23") + DEUX_SEVRES = Departement("deux-sevres", "79") + DORDOGNE = Departement("dordogne", "24") + DOUBS = Departement("doubs", "25") + DROME = Departement("drome", "26") + ESSONNE = Departement("essonne", "91") + EURE_ET_LOIR = Departement("eure-et-loir", "28") + EURE = Departement("eure", "27") + FINISTERE = Departement("finistere", "29") + GARD = Departement("gard", "30") + GERS = Departement("gers", "32") + GIRONDE = Departement("gironde", "33") + GUADELOUPE = Departement("guadeloupe", "971") + GUYANE = Departement("guyane", "973") + HAUT_RHIN = Departement("haut-rhin", "68") + HAUTE_CORSE = Departement("haute-corse", "2B") + HAUTE_GARONNE = Departement("haute-garonne", "31") + HAUTE_LOIRE = Departement("haute-loire", "43") + HAUTE_MARNE = Departement("haute-marne", "52") + HAUTE_SAONE = Departement("haute-saone", "70") + HAUTE_SAVOIE = Departement("haute-savoie", "74") + HAUTE_VIENNE = Departement("haute-vienne", "87") + HAUTES_ALPES = Departement("hautes-alpes", "05") + HAUTES_PYRENEES = Departement("hautes-pyrenees", "65") + HAUTS_DE_SEINE = Departement("hauts-de-seine", "92") + HERAULT = Departement("herault", "34") + ILLE_ET_VILAINE = Departement("ille-et-vilaine", "35") + INDRE_ET_LOIRE = Departement("indre-et-loire", "37") + INDRE = Departement("indre", "36") + ISERE = Departement("isere", "38") + JURA = Departement("jura", "39") + LA_REUNION = Departement("la-reunion", "974") + LANDES = Departement("landes", "40") + LOIR_ET_CHER = Departement("loir-et-cher", "41") + LOIRE_ATLANTIQUE = Departement("loire-atlantique", "44") + LOIRE = Departement("loire", "42") + LOIRET = Departement("loiret", "45") + LOT_ET_GARONNE = Departement("lot-et-garonne", "47") + LOT = Departement("lot", "46") + LOZERE = Departement("lozere", "48") + MAINE_ET_LOIRE = Departement("maine-et-loire", "49") + MANCHE = Departement("manche", "50") + MARNE = Departement("marne", "51") + MARTINIQUE = Departement("martinique", "972") + MAYENNE = Departement("mayenne", "53") + MAYOTTE = Departement("mayotte", "976") + MEURTHE_ET_MOSELLE = Departement("meurthe-et-moselle", "54") + MEUSE = Departement("meuse", "55") + MORBIHAN = Departement("morbihan", "56") + MOSELLE = Departement("moselle", "57") + NIEVRE = Departement("nievre", "58") + NORD = Departement("nord", "59") + OISE = Departement("oise", "60") + ORNE = Departement("orne", "61") + PARIS = Departement("paris", "75") + PAS_DE_CALAIS = Departement("pas-de-calais", "62") + PUY_DE_DOME = Departement("puy-de-dome", "63") + PYRENEES_ATLANTIQUES = Departement("pyrenees-atlantiques", "64") + PYRENEES_ORIENTALES = Departement("pyrenees-orientales", "66") + RHONE = Departement("rhone", "69") + SAONE_ET_LOIRE = Departement("saone-et-loire", "71") + SARTHE = Departement("sarthe", "72") + SAVOIE = Departement("savoie", "73") + SEINE_ET_MARNE = Departement("seine-et-marne", "77") + SEINE_MARITIME = Departement("seine-maritime", "76") + SEINE_SAINT_DENIS = Departement("seine-saint-denis", "93") + SOMME = Departement("somme", "80") + TARN_ET_GARONNE = Departement("tarn-et-garonne", "82") + TARN = Departement("tarn", "81") + TERRITOIRE_DE_BELFORT = Departement("territoire-de-belfort", "90") + VAL_D_OISE = Departement("val-d-oise", "95") + VAL_DE_MARNE = Departement("val-de-marne", "94") + VAR = Departement("var", "83") + VAUCLUSE = Departement("vaucluse", "84") + VENDEE = Departement("vendee", "85") + VIENNE = Departement("vienne", "86") + VOSGES = Departement("vosges", "88") + YONNE = Departement("yonne", "89") + YVELINES = Departement("yvelines", "78") + + +DepartementSlugEnum = Enum( + "DepartementSlugEnum", + {member.name: member.value.slug for member in DepartementEnum}, +) +DepartementCodeEnum = Enum( + "DepartementCodeEnum", + {member.name: member.value.code for member in DepartementEnum}, +) + + +@dataclass(frozen=True) +class Region: + slug: str + code: str + +class RegionEnum(Enum): + AUVERGNE_RHONE_ALPES = Region("auvergne-rhone-alpes", "84") + BOURGOGNE_FRANCHE_COMTE = Region("bourgogne-franche-comte", "27") + BRETAGNE = Region("bretagne", "53") + CENTRE_VAL_DE_LOIRE = Region("centre-val-de-loire", "24") + CORSE = Region("corse", "94") + GRAND_EST = Region("grand-est", "44") + GUADELOUPE = Region("guadeloupe", "01") + GUYANE = Region("guyane", "03") + HAUTS_DE_FRANCE = Region("hauts-de-france", "32") + ILE_DE_FRANCE = Region("ile-de-france", "11") + LA_REUNION = Region("la-reunion", "04") + MARTINIQUE = Region("martinique", "02") + MAYOTTE = Region("mayotte", "06") + NORMANDIE = Region("normandie", "28") + NOUVELLE_AQUITAINE = Region("nouvelle-aquitaine", "75") + OCCITANIE = Region("occitanie", "76") + PAYS_DE_LA_LOIRE = Region("pays-de-la-loire", "52") + PROVENCE_ALPES_COTE_D_AZUR = Region("provence-alpes-cote-d-azur", "93") -DepartementSlug = Enum( - "DepartementSlug", - {k: departement.slug for k, departement in _departements_dict.items()}, + +RegionSlugEnum = Enum( + "RegionSlugEnum", + {member.name: member.value.slug for member in RegionEnum}, ) -DepartementCOG = Enum( - "DepartementCOG", - {k: departement.cog for k, departement in _departements_dict.items()}, +RegionCodeEnum = Enum( + "RegionCodeEnum", + {member.name: member.value.code for member in RegionEnum}, ) + # based on # https://github.com/gip-inclusion/dora-back/blob/main/dora/admin_express/utils.py diff --git a/api/src/data_inclusion/api/code_officiel_geo/utils.py b/api/src/data_inclusion/api/code_officiel_geo/utils.py new file mode 100644 index 000000000..64ac2ff03 --- /dev/null +++ b/api/src/data_inclusion/api/code_officiel_geo/utils.py @@ -0,0 +1,21 @@ +from data_inclusion.api.code_officiel_geo import constants + + +def get_departement_by_code_or_slug( + code: constants.DepartementCodeEnum | None = None, + slug: constants.DepartementSlugEnum | None = None, +) -> constants.Departement | None: + if code is not None: + return constants.DepartementEnum[code.name].value + if slug is not None: + return constants.DepartementEnum[slug.name].value + + +def get_region_by_code_or_slug( + code: constants.RegionCodeEnum | None = None, + slug: constants.RegionSlugEnum | None = None, +) -> constants.Region | None: + if code is not None: + return constants.RegionEnum[code.name].value + if slug is not None: + return constants.RegionEnum[slug.name].value diff --git a/api/src/data_inclusion/api/inclusion_data/commands.py b/api/src/data_inclusion/api/inclusion_data/commands.py index 2efc6b268..9f4e1fd01 100644 --- a/api/src/data_inclusion/api/inclusion_data/commands.py +++ b/api/src/data_inclusion/api/inclusion_data/commands.py @@ -126,10 +126,38 @@ def load_inclusion_data(): # TODO: this must be fixed in the publication structures_df = structures_df.assign( - code_insee=structures_df.code_insee.apply(clean_up_code_insee) + code_insee=structures_df.code_insee.apply(clean_up_code_insee), + _di_geocodage_code_insee=structures_df._di_geocodage_code_insee.apply( + clean_up_code_insee + ), ) services_df = services_df.assign( - code_insee=services_df.code_insee.apply(clean_up_code_insee) + code_insee=services_df.code_insee.apply(clean_up_code_insee), + _di_geocodage_code_insee=services_df._di_geocodage_code_insee.apply( + clean_up_code_insee + ), + ) + + # fill missing codes with geocoding results + # and overwrite existing ones if the geocoder is confident enough + geocoder_validity_threshold = 0.7 + structures_df = structures_df.assign( + code_insee=structures_df._di_geocodage_code_insee.where( + structures_df._di_geocodage_score > geocoder_validity_threshold, + structures_df.code_insee, + ) + ) + services_df = services_df.assign( + code_insee=services_df._di_geocodage_code_insee.where( + services_df._di_geocodage_score > geocoder_validity_threshold, + services_df.code_insee, + ) + ) + structures_df = structures_df.drop( + columns=["_di_geocodage_code_insee", "_di_geocodage_score"] + ) + services_df = services_df.drop( + columns=["_di_geocodage_code_insee", "_di_geocodage_score"] ) structure_errors_df = validate_df(structures_df, model_schema=schema.Structure) diff --git a/api/src/data_inclusion/api/inclusion_data/models.py b/api/src/data_inclusion/api/inclusion_data/models.py index 58f94e32d..9d49fa362 100644 --- a/api/src/data_inclusion/api/inclusion_data/models.py +++ b/api/src/data_inclusion/api/inclusion_data/models.py @@ -13,14 +13,12 @@ class Structure(Base): # internal metadata _di_surrogate_id: Mapped[str] = mapped_column(primary_key=True) - _di_geocodage_code_insee: Mapped[str | None] - _di_geocodage_score: Mapped[float | None] # structure data accessibilite: Mapped[str | None] adresse: Mapped[str | None] antenne: Mapped[bool | None] = mapped_column(default=False) - code_insee: Mapped[str | None] + code_insee: Mapped[str | None] = mapped_column(sqla.ForeignKey(Commune.code)) code_postal: Mapped[str | None] commune: Mapped[str | None] complement_adresse: Mapped[str | None] @@ -45,6 +43,7 @@ class Structure(Base): typologie: Mapped[str | None] services: Mapped[list["Service"]] = relationship(back_populates="structure") + commune_: Mapped[Commune] = relationship(back_populates="structures") __table_args__ = (sqla.Index(None, "source"),) @@ -58,8 +57,6 @@ class Service(Base): _di_structure_surrogate_id: Mapped[str] = mapped_column( sqla.ForeignKey(Structure._di_surrogate_id) ) - _di_geocodage_code_insee: Mapped[str | None] - _di_geocodage_score: Mapped[float | None] structure: Mapped[Structure] = relationship(back_populates="services") # service data @@ -126,3 +123,4 @@ def __repr__(self) -> str: Commune.services = relationship(Service, back_populates="commune_") +Commune.structures = relationship(Structure, back_populates="commune_") diff --git a/api/src/data_inclusion/api/inclusion_data/routes.py b/api/src/data_inclusion/api/inclusion_data/routes.py index 1a7021781..d833944ae 100644 --- a/api/src/data_inclusion/api/inclusion_data/routes.py +++ b/api/src/data_inclusion/api/inclusion_data/routes.py @@ -8,10 +8,16 @@ from data_inclusion.api import auth from data_inclusion.api.code_officiel_geo.constants import ( CODE_COMMUNE_BY_CODE_ARRONDISSEMENT, - DepartementCOG, - DepartementSlug, + DepartementCodeEnum, + DepartementSlugEnum, + RegionCodeEnum, + RegionSlugEnum, ) from data_inclusion.api.code_officiel_geo.models import Commune +from data_inclusion.api.code_officiel_geo.utils import ( + get_departement_by_code_or_slug, + get_region_by_code_or_slug, +) from data_inclusion.api.config import settings from data_inclusion.api.core import db from data_inclusion.api.inclusion_data import schemas, services @@ -25,6 +31,21 @@ T = TypeVar("T") Optional = T | SkipJsonSchema[None] +CodeCommuneFilter = Annotated[ + Optional[di_schema.CodeCommune], + fastapi.Query(description="Code insee géographique d'une commune."), +] + +CodeDepartementFilter = Annotated[ + Optional[DepartementCodeEnum], + fastapi.Query(description="Code insee géographique d'un département."), +] + +CodeRegionFilter = Annotated[ + Optional[RegionCodeEnum], + fastapi.Query(description="Code insee géographique d'une région."), +] + @router.get( "/structures", @@ -34,29 +55,71 @@ ) def list_structures_endpoint( request: fastapi.Request, - source: Annotated[Optional[str], fastapi.Query()] = None, - id: Annotated[Optional[str], fastapi.Query()] = None, + source: Annotated[ + Optional[str], + fastapi.Query(include_in_schema=False), + ] = None, + sources: Annotated[ + Optional[list[str]], + fastapi.Query( + description="""Une liste d'identifiants de source. + La liste des identifiants de source est disponible sur le endpoint + dédié. Les résultats seront limités aux sources spécifiées. + """, + ), + ] = None, + id: Annotated[Optional[str], fastapi.Query(include_in_schema=False)] = None, typologie: Annotated[Optional[di_schema.Typologie], fastapi.Query()] = None, label_national: Annotated[ Optional[di_schema.LabelNational], fastapi.Query() ] = None, - thematique: Annotated[Optional[di_schema.Thematique], fastapi.Query()] = None, - departement: Annotated[Optional[DepartementCOG], fastapi.Query()] = None, - departement_slug: Annotated[Optional[DepartementSlug], fastapi.Query()] = None, - code_postal: Annotated[Optional[di_schema.CodePostal], fastapi.Query()] = None, + thematiques: Annotated[ + Optional[list[di_schema.Thematique]], + fastapi.Query( + description="""Une liste de thématique. + Chaque résultat renvoyé a (au moins) une thématique dans cette liste.""" + ), + ] = None, + code_region: CodeRegionFilter = None, + slug_region: Annotated[Optional[RegionSlugEnum], fastapi.Query()] = None, + departement: Annotated[ + Optional[DepartementCodeEnum], + fastapi.Query(include_in_schema=False), + ] = None, + code_departement: CodeDepartementFilter = None, + departement_slug: Annotated[ + Optional[DepartementSlugEnum], + fastapi.Query(include_in_schema=False), + ] = None, + slug_departement: Annotated[Optional[DepartementSlugEnum], fastapi.Query()] = None, + code_commune: CodeCommuneFilter = None, db_session=fastapi.Depends(db.get_session), ): + if sources is None and source is not None: + sources = [source] + + region = get_region_by_code_or_slug(code=code_region, slug=slug_region) + + if code_departement is None and departement is not None: + code_departement = departement + if slug_departement is None and departement_slug is not None: + slug_departement = departement_slug + + departement = get_departement_by_code_or_slug( + code=code_departement, slug=slug_departement + ) + return services.list_structures( request, db_session, - source=source, + sources=sources, id_=id, typologie=typologie, label_national=label_national, departement=departement, - departement_slug=departement_slug, - code_postal=code_postal, - thematique=thematique, + region=region, + commune_code=code_commune, + thematiques=thematiques, ) @@ -96,20 +159,118 @@ def list_sources_endpoint( def list_services_endpoint( request: fastapi.Request, db_session=fastapi.Depends(db.get_session), - source: Annotated[Optional[str], fastapi.Query()] = None, - thematique: Annotated[Optional[di_schema.Thematique], fastapi.Query()] = None, - departement: Annotated[Optional[DepartementCOG], fastapi.Query()] = None, - departement_slug: Annotated[Optional[DepartementSlug], fastapi.Query()] = None, - code_insee: Annotated[Optional[di_schema.CodeCommune], fastapi.Query()] = None, + source: Annotated[ + Optional[str], + fastapi.Query(include_in_schema=False), + ] = None, + sources: Annotated[ + Optional[list[str]], + fastapi.Query( + description="""Une liste d'identifiants de source. + La liste des identifiants de source est disponible sur le endpoint + dédié. Les résultats seront limités aux sources spécifiées. + """, + ), + ] = None, + thematique: Annotated[ + Optional[di_schema.Thematique], + fastapi.Query(include_in_schema=False), + ] = None, + thematiques: Annotated[ + Optional[list[di_schema.Thematique]], + fastapi.Query( + description="""Une liste de thématique. + Chaque résultat renvoyé a (au moins) une thématique dans cette liste.""" + ), + ] = None, + code_region: CodeRegionFilter = None, + slug_region: Annotated[Optional[RegionSlugEnum], fastapi.Query()] = None, + departement: Annotated[ + Optional[DepartementCodeEnum], + fastapi.Query(include_in_schema=False), + ] = None, + code_departement: CodeDepartementFilter = None, + departement_slug: Annotated[ + Optional[DepartementSlugEnum], + fastapi.Query(include_in_schema=False), + ] = None, + slug_departement: Annotated[Optional[DepartementSlugEnum], fastapi.Query()] = None, + code_insee: Annotated[ + Optional[di_schema.CodeCommune], + fastapi.Query(include_in_schema=False), + ] = None, + code_commune: CodeCommuneFilter = None, + frais: Annotated[ + Optional[list[di_schema.Frais]], + fastapi.Query( + description="""Une liste de frais. + Chaque résultat renvoyé a (au moins) un frais dans cette liste.""" + ), + ] = None, + profils: Annotated[ + Optional[list[di_schema.Profil]], + fastapi.Query( + description="""Une liste de profils. + Chaque résultat renvoyé a (au moins) un profil dans cette liste. + """ + ), + ] = None, + modes_accueil: Annotated[ + Optional[list[di_schema.ModeAccueil]], + fastapi.Query( + description="""Une liste de modes d'accueil. + Chaque résultat renvoyé a (au moins) un mode d'accueil dans cette liste. + """ + ), + ] = None, + types: Annotated[ + Optional[list[di_schema.TypologieService]], + fastapi.Query( + description="""Une liste de typologies de service. + Chaque résultat renvoyé a (au moins) une typologie dans cette liste.""" + ), + ] = None, + inclure_suspendus: Annotated[ + Optional[bool], + fastapi.Query( + description="""Inclure les services ayant une date de suspension dépassée. + Ils sont exclus par défaut. + """ + ), + ] = False, ): + if code_commune is None and code_insee is not None: + code_commune = code_insee + + if thematiques is None and thematique is not None: + thematiques = [thematique] + + if sources is None and source is not None: + sources = [source] + + if code_departement is None and departement is not None: + code_departement = departement + if slug_departement is None and departement_slug is not None: + slug_departement = departement_slug + + region = get_region_by_code_or_slug(code=code_region, slug=slug_region) + departement = get_departement_by_code_or_slug( + code=code_departement, slug=slug_departement + ) + return services.list_services( request, db_session, - source=source, - thematique=thematique, + sources=sources, + thematiques=thematiques, departement=departement, - departement_slug=departement_slug, - code_insee=code_insee, + region=region, + code_commune=code_commune, + frais=frais, + profils=profils, + modes_accueil=modes_accueil, + types=types, + include_outdated=inclure_suspendus, ) @@ -137,15 +298,6 @@ def retrieve_service_endpoint( def search_services_endpoint( request: fastapi.Request, db_session=fastapi.Depends(db.get_session), - source: Annotated[ - Optional[str], - fastapi.Query( - description="""Un identifiant de source. - Déprécié en faveur de `sources`. - """, - deprecated=True, - ), - ] = None, sources: Annotated[ Optional[list[str]], fastapi.Query( @@ -155,7 +307,7 @@ def search_services_endpoint( """, ), ] = None, - code_insee: Annotated[ + code_commune: Annotated[ Optional[di_schema.CodeCommune], fastapi.Query( description="""Code insee de la commune considérée. @@ -165,6 +317,10 @@ def search_services_endpoint( """ ), ] = None, + code_insee: Annotated[ + Optional[di_schema.CodeCommune], + fastapi.Query(include_in_schema=False), + ] = None, lat: Annotated[ Optional[float], fastapi.Query( @@ -197,6 +353,22 @@ def search_services_endpoint( Chaque résultat renvoyé a (au moins) un frais dans cette liste.""" ), ] = None, + modes_accueil: Annotated[ + Optional[list[di_schema.ModeAccueil]], + fastapi.Query( + description="""Une liste de modes d'accueil. + Chaque résultat renvoyé a (au moins) un mode d'accueil dans cette liste. + """ + ), + ] = None, + profils: Annotated[ + Optional[list[di_schema.Profil]], + fastapi.Query( + description="""Une liste de profils. + Chaque résultat renvoyé a (au moins) un profil dans cette liste. + """ + ), + ] = None, types: Annotated[ Optional[list[di_schema.TypologieService]], fastapi.Query( @@ -222,7 +394,7 @@ def search_services_endpoint( Les services peuvent être filtrés selon par thématiques, frais, typologies et code_insee de commune. - En particulier, lorsque le `code_insee` d'une commune est fourni : + En particulier, lorsqu'un `code_commune` est fourni : * les services sont filtrés par zone de diffusion lorsque celle-ci est définie. * de plus, les services en présentiel sont filtrés dans un rayon de 50km autour de @@ -234,15 +406,20 @@ def search_services_endpoint( * les résultats sont triés par distance croissante. """ + if code_commune is None and code_insee is not None: + code_commune = code_insee + commune_instance = None search_point = None - if code_insee is not None: - code_insee = CODE_COMMUNE_BY_CODE_ARRONDISSEMENT.get(code_insee, code_insee) - commune_instance = db_session.get(Commune, code_insee) + if code_commune is not None: + code_commune = CODE_COMMUNE_BY_CODE_ARRONDISSEMENT.get( + code_commune, code_commune + ) + commune_instance = db_session.get(Commune, code_commune) if commune_instance is None: raise fastapi.HTTPException( status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY, - detail="This `code_insee` does not exist.", + detail="This `code_commune` does not exist.", ) if lat and lon: search_point = f"POINT({lon} {lat})" @@ -252,9 +429,6 @@ def search_services_endpoint( detail="The `lat` and `lon` must be simultaneously filled.", ) - if sources is None and source is not None: - sources = [source] - return services.search_services( request, db_session, @@ -262,6 +436,8 @@ def search_services_endpoint( commune_instance=commune_instance, thematiques=thematiques, frais=frais, + modes_accueil=modes_accueil, + profils=profils, types=types, search_point=search_point, include_outdated=inclure_suspendus, diff --git a/api/src/data_inclusion/api/inclusion_data/schemas.py b/api/src/data_inclusion/api/inclusion_data/schemas.py index b4fae1478..27eed59d8 100644 --- a/api/src/data_inclusion/api/inclusion_data/schemas.py +++ b/api/src/data_inclusion/api/inclusion_data/schemas.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict from data_inclusion import schema @@ -17,14 +17,6 @@ class Service(schema.Service): formulaire_en_ligne: str | None = None lien_source: str | None = None - # TODO(vmttn): decide whether we should keep these extra fields - di_geocodage_code_insee: schema.CodeCommune | None = Field( - default=None, alias="_di_geocodage_code_insee" - ) - di_geocodage_score: float | None = Field( - default=None, ge=0, le=1, alias="_di_geocodage_score" - ) - class Structure(schema.Structure): model_config = ConfigDict(from_attributes=True, populate_by_name=True) @@ -34,14 +26,6 @@ class Structure(schema.Structure): lien_source: str | None = None accessibilite: str | None = None - # TODO(vmttn): decide whether we should keep these extra fields - di_geocodage_code_insee: schema.CodeCommune | None = Field( - default=None, alias="_di_geocodage_code_insee" - ) - di_geocodage_score: float | None = Field( - default=None, ge=0, le=1, alias="_di_geocodage_score" - ) - class DetailedService(Service): structure: Structure diff --git a/api/src/data_inclusion/api/inclusion_data/services.py b/api/src/data_inclusion/api/inclusion_data/services.py index 8ec1990db..fa5419d42 100644 --- a/api/src/data_inclusion/api/inclusion_data/services.py +++ b/api/src/data_inclusion/api/inclusion_data/services.py @@ -17,8 +17,8 @@ from data_inclusion import schema as di_schema from data_inclusion.api.code_officiel_geo.constants import ( CODE_COMMUNE_BY_CODE_ARRONDISSEMENT, - DepartementCOG, - DepartementSlug, + Departement, + Region, ) from data_inclusion.api.code_officiel_geo.models import Commune from data_inclusion.api.inclusion_data import models @@ -26,6 +26,13 @@ logger = logging.getLogger(__name__) +def filter_by_sources( + query: sqla.Select, + sources: list[str], +): + return query.filter(models.Structure.source == sqla.any_(sqla.literal(sources))) + + @functools.cache def get_thematiques_by_group(): thematiques = defaultdict(list) @@ -59,52 +66,151 @@ def get_sub_thematiques(thematiques: list[di_schema.Thematique]) -> list[str]: return list(all_thematiques) +def filter_services_by_thematiques( + query: sqla.Select, + thematiques: list[di_schema.Thematique], +): + return query.filter( + sqla.text("api__services.thematiques && :thematiques").bindparams( + thematiques=get_sub_thematiques(thematiques), + ) + ) + + +def filter_structures_by_thematiques( + query: sqla.Select, + thematiques: list[di_schema.Thematique], +): + return query.filter( + sqla.text("api__structures.thematiques && :thematiques").bindparams( + thematiques=get_sub_thematiques(thematiques), + ) + ) + + +def filter_services_by_frais( + query: sqla.Select, + frais: list[di_schema.Frais], +): + filter_stmt = """\ + EXISTS( + SELECT + FROM unnest(api__services.frais) frais + WHERE frais = ANY(:frais) + ) + """ + return query.filter( + sqla.text(filter_stmt).bindparams(frais=[f.value for f in frais]) + ) + + +def filter_services_by_modes_accueil( + query: sqla.Select, + modes_accueil: list[di_schema.ModeAccueil], +): + filter_stmt = """\ + EXISTS( + SELECT + FROM unnest(api__services.modes_accueil) modes_accueil + WHERE modes_accueil = ANY(:modes_accueil) + ) + """ + return query.filter( + sqla.text(filter_stmt).bindparams( + modes_accueil=[m.value for m in modes_accueil] + ) + ) + + +def filter_services_by_profils( + query: sqla.Select, + profils: list[di_schema.Profil], +): + filter_stmt = """\ + EXISTS( + SELECT + FROM unnest(api__services.profils) profils + WHERE profils = ANY(:profils) + ) + """ + return query.filter( + sqla.text(filter_stmt).bindparams(profils=[p.value for p in profils]) + ) + + +def filter_services_by_types( + query: sqla.Select, + types: list[di_schema.TypologieService], +): + filter_stmt = """\ + EXISTS( + SELECT + FROM unnest(api__services.types) types + WHERE types = ANY(:types) + ) + """ + return query.filter( + sqla.text(filter_stmt).bindparams(types=[t.value for t in types]) + ) + + +def filter_outdated_services( + query: sqla.Select, +): + return query.filter( + sqla.or_( + models.Service.date_suspension.is_(None), + models.Service.date_suspension >= date.today(), + ) + ) + + +def filter_restricted( + query: sqla.Select, + request: fastapi.Request, +) -> sqla.Select: + if not request.user.is_authenticated or "dora" not in request.user.username: + query = query.filter(models.Structure.source != "soliguide") + query = query.filter(models.Structure.source != "data-inclusion") + + return query + + def list_structures( request: fastapi.Request, db_session: orm.Session, - source: str | None = None, + sources: list[str] | None = None, id_: str | None = None, typologie: di_schema.Typologie | None = None, label_national: di_schema.LabelNational | None = None, - departement: DepartementCOG | None = None, - departement_slug: DepartementSlug | None = None, - code_postal: di_schema.CodePostal | None = None, - thematique: di_schema.Thematique | None = None, + departement: Departement | None = None, + region: Region | None = None, + commune_code: di_schema.CodeCommune | None = None, + thematiques: list[di_schema.Thematique] | None = None, ) -> list: query = sqla.select(models.Structure) + query = filter_restricted(query, request) - if source is not None: - query = query.filter_by(source=source) - - if not request.user.is_authenticated or "dora" not in request.user.username: - query = query.filter(models.Structure.source != "soliguide") - query = query.filter(models.Structure.source != "data-inclusion") + if sources is not None: + query = filter_by_sources(query, sources) if id_ is not None: query = query.filter_by(id=id_) - if departement is not None: - query = query.filter( - sqla.or_( - models.Structure.code_insee.startswith(departement.value), - models.Structure._di_geocodage_code_insee.startswith(departement.value), - ) + if commune_code is not None: + commune_code = CODE_COMMUNE_BY_CODE_ARRONDISSEMENT.get( + commune_code, commune_code ) + query = query.filter(models.Structure.code_insee == commune_code) - if departement_slug is not None: - query = query.filter( - sqla.or_( - models.Structure.code_insee.startswith( - DepartementCOG[departement_slug.name].value - ), - models.Structure._di_geocodage_code_insee.startswith( - DepartementCOG[departement_slug.name].value - ), - ) - ) + if departement is not None: + query = query.filter(models.Structure.code_insee.startswith(departement.code)) - if code_postal is not None: - query = query.filter_by(code_postal=code_postal) + if region is not None: + query = query.join(Commune).options( + orm.contains_eager(models.Structure.commune_) + ) + query = query.filter(Commune.region == region.code) if typologie is not None: query = query.filter_by(typologie=typologie.value) @@ -114,17 +220,8 @@ def list_structures( models.Structure.labels_nationaux.contains([label_national.value]) ) - if thematique is not None: - filter_stmt = """\ - EXISTS( - SELECT - FROM unnest(thematiques) thematique - WHERE thematique ~ ('^' || :thematique) - ) - """ - query = query.filter( - sqla.text(filter_stmt).bindparams(thematique=thematique.value) - ) + if thematiques is not None: + query = filter_structures_by_thematiques(query, thematiques) query = query.order_by( models.Structure.source, @@ -166,69 +263,87 @@ def list_sources(request: fastapi.Request) -> list[dict]: return sources +def filter_services( + query: sqla.Select, + sources: list[str] | None = None, + thematiques: list[di_schema.Thematique] | None = None, + frais: list[di_schema.Frais] | None = None, + profils: list[di_schema.Profil] | None = None, + modes_accueil: list[di_schema.ModeAccueil] | None = None, + types: list[di_schema.TypologieService] | None = None, + include_outdated: bool | None = False, +) -> sqla.Select: + """Common filters for services.""" + + if sources is not None: + query = filter_by_sources(query, sources) + + if thematiques is not None: + query = filter_services_by_thematiques(query, thematiques) + + if frais is not None: + query = filter_services_by_frais(query, frais) + + if profils is not None: + query = filter_services_by_profils(query, profils) + + if modes_accueil is not None: + query = filter_services_by_modes_accueil(query, modes_accueil) + + if types is not None: + query = filter_services_by_types(query, types) + + if not include_outdated: + query = filter_outdated_services(query) + + return query + + def list_services( request: fastapi.Request, db_session: orm.Session, - source: str | None = None, - thematique: di_schema.Thematique | None = None, - departement: DepartementCOG | None = None, - departement_slug: DepartementSlug | None = None, - code_insee: di_schema.CodeCommune | None = None, + sources: list[str] | None = None, + thematiques: list[di_schema.Thematique] | None = None, + departement: Departement | None = None, + region: Region | None = None, + code_commune: di_schema.CodeCommune | None = None, + frais: list[di_schema.Frais] | None = None, + profils: list[di_schema.Profil] | None = None, + modes_accueil: list[di_schema.ModeAccueil] | None = None, + types: list[di_schema.TypologieService] | None = None, + include_outdated: bool | None = False, ): query = ( sqla.select(models.Service) - .join(models.Service.structure) + .join(models.Structure) .options(orm.contains_eager(models.Service.structure)) ) - - if source is not None: - query = query.filter(models.Structure.source == source) - - if not request.user.is_authenticated or "dora" not in request.user.username: - query = query.filter(models.Structure.source != "soliguide") - query = query.filter(models.Structure.source != "data-inclusion") + query = filter_restricted(query, request) if departement is not None: - query = query.filter( - sqla.or_( - models.Service.code_insee.startswith(departement.value), - models.Service._di_geocodage_code_insee.startswith(departement.value), - ) - ) - - if departement_slug is not None: - query = query.filter( - sqla.or_( - models.Service.code_insee.startswith( - DepartementCOG[departement_slug.name].value - ), - models.Service._di_geocodage_code_insee.startswith( - DepartementCOG[departement_slug.name].value - ), - ) - ) + query = query.filter(models.Service.code_insee.startswith(departement.code)) - if code_insee is not None: - code_insee = CODE_COMMUNE_BY_CODE_ARRONDISSEMENT.get(code_insee, code_insee) + if region is not None: + query = query.join(Commune).options(orm.contains_eager(models.Service.commune_)) + query = query.filter(Commune.region == region.code) - query = query.filter( - sqla.or_( - models.Service.code_insee == code_insee, - models.Service._di_geocodage_code_insee == code_insee, - ) + if code_commune is not None: + code_commune = CODE_COMMUNE_BY_CODE_ARRONDISSEMENT.get( + code_commune, code_commune ) - if thematique is not None: - filter_stmt = """\ - EXISTS( - SELECT - FROM unnest(api__services.thematiques) thematique - WHERE thematique ~ ('^' || :thematique) - ) - """ - query = query.filter( - sqla.text(filter_stmt).bindparams(thematique=thematique.value) - ) + query = query.filter(models.Service.code_insee == code_commune) + + query = filter_services( + query=query, + sources=sources, + thematiques=thematiques, + frais=frais, + profils=profils, + modes_accueil=modes_accueil, + types=types, + include_outdated=include_outdated, + ) query = query.order_by( models.Service.source, @@ -245,22 +360,18 @@ def search_services( commune_instance: Commune | None = None, thematiques: list[di_schema.Thematique] | None = None, frais: list[di_schema.Frais] | None = None, + modes_accueil: list[di_schema.ModeAccueil] | None = None, + profils: list[di_schema.Profil] | None = None, types: list[di_schema.TypologieService] | None = None, search_point: str | None = None, include_outdated: bool | None = False, ): query = ( sqla.select(models.Service) - .join(models.Service.structure) + .join(models.Structure) .options(orm.contains_eager(models.Service.structure)) ) - - if sources is not None: - query = query.filter(models.Service.source == sqla.any_(sqla.literal(sources))) - - if not request.user.is_authenticated or "dora" not in request.user.username: - query = query.filter(models.Structure.source != "soliguide") - query = query.filter(models.Structure.source != "data-inclusion") + query = filter_restricted(query, request) if commune_instance is not None: # filter by zone de diffusion @@ -354,44 +465,16 @@ def search_services( else: query = query.add_columns(sqla.null().cast(sqla.Integer).label("distance")) - if thematiques is not None: - query = query.filter( - sqla.text("api__services.thematiques && :thematiques").bindparams( - thematiques=get_sub_thematiques(thematiques), - ) - ) - - if frais is not None: - filter_stmt = """\ - EXISTS( - SELECT - FROM unnest(api__services.frais) frais - WHERE frais = ANY(:frais) - ) - """ - query = query.filter( - sqla.text(filter_stmt).bindparams(frais=[f.value for f in frais]) - ) - - if types is not None: - filter_stmt = """\ - EXISTS( - SELECT - FROM unnest(api__services.types) types - WHERE types = ANY(:types) - ) - """ - query = query.filter( - sqla.text(filter_stmt).bindparams(types=[t.value for t in types]) - ) - - if not include_outdated: - query = query.filter( - sqla.or_( - models.Service.date_suspension.is_(None), - models.Service.date_suspension >= date.today(), - ) - ) + query = filter_services( + query=query, + sources=sources, + thematiques=thematiques, + frais=frais, + profils=profils, + modes_accueil=modes_accueil, + types=types, + include_outdated=include_outdated, + ) query = query.order_by(sqla.column("distance").nulls_last()) diff --git a/api/tests/e2e/api/__snapshots__/test_inclusion_data.ambr b/api/tests/e2e/api/__snapshots__/test_inclusion_data.ambr index 7c073e4e4..cc5070868 100644 --- a/api/tests/e2e/api/__snapshots__/test_inclusion_data.ambr +++ b/api/tests/e2e/api/__snapshots__/test_inclusion_data.ambr @@ -5,7 +5,7 @@ "openapi": "3.1.0", "info": { "title": "data·inclusion API", - "description": "### Token\n\nUn token est nécessaire pour accéder aux données.\n\nLes demandes de tokens s'effectuent via [ce formulaire](https://tally.so/r/mYjJ85). L'équipe data·inclusion prendra contact avec vous.\n\nLe token doit être renseigné dans chaque requête via un header:\n`Authorization: Bearer `.\n\n### Schéma des données\n\nLes données utilisent le schéma data·inclusion. Ce schéma comprend deux modèles principaux :\n\n* les structures proposant des services\n* les services proposés par ces structures\n\nCes deux modèles utilisent des référentiels faisant également partie du schéma data·inclusion : les types de structures et de services, les thématiques, etc.\n\nPlus d'informations sur le\n[dépôt](https://github.com/gip-inclusion/data-inclusion-schema) versionnant le schéma,\nsur la [documentation officielle](https://www.data.inclusion.beta.gouv.fr/schemas-de-donnees-de-loffre/schema-des-structures-dinsertion)\nou sur la page [schema.gouv](https://schema.data.gouv.fr/gip-inclusion/data-inclusion-schema/) du schéma.\n\n### Sources des données\n\nLes données data·inclusion sont issues d'un ensemble de sources (emplois de l'inclusion, France Travail, etc.).\n\nLe endpoint `/sources` permet de lister les sources disponibles.\n\n### Nous contacter\n\n#### via notre [formulaire de contact](https://tally.so/r/w7N6Zz)\n\n#### par mail à [data.inclusion@beta.gouv.fr](mailto:data.inclusion@beta.gouv.fr)", + "description": "### Token\n\nUn token est nécessaire pour accéder aux données.\n\nLes demandes de tokens s'effectuent via [ce formulaire](https://tally.so/r/mYjJ85). L'équipe data·inclusion prendra contact avec vous.\n\nLe token doit être renseigné dans chaque requête via un header:\n`Authorization: Bearer `.\n\n### Schéma des données\n\nLes données utilisent le schéma data·inclusion. Ce schéma comprend deux modèles principaux :\n\n* les structures proposant des services\n* les services proposés par ces structures\n\nCes deux modèles utilisent des référentiels faisant également partie du schéma data·inclusion : les types de structures et de services, les thématiques, etc.\n\nPlus d'informations sur le\n[dépôt](https://github.com/gip-inclusion/data-inclusion-schema) versionnant le schéma,\nsur la [documentation officielle](https://www.data.inclusion.beta.gouv.fr/schemas-de-donnees-de-loffre/schema-des-structures-dinsertion)\nou sur la page [schema.gouv](https://schema.data.gouv.fr/gip-inclusion/data-inclusion-schema/) du schéma.\n\n### Sources des données\n\nLes données data·inclusion sont issues d'un ensemble de sources (emplois de l'inclusion, France Travail, etc.).\n\nLe endpoint `/sources` permet de lister les sources disponibles.\n\n\n### Filtrer géographiquement les données\n\nLes données renvoyées par certains endpoints peuvent être filtrées géographiquement.\n\nLes codes communes, départements et régions utilisés sont issus du [code officiel géographique produit par l'INSEE](https://www.insee.fr/fr/information/2560452).\n\nL'[api de la base adresse nationale](https://adresse.data.gouv.fr/api-doc/adresse) peut être utilisée afin d'automatiser l'identification de codes insee associés à partir d'adresses ou de parties d'adresses (e.g. nom de commune, code postal).\n\n\n### Nous contacter\n\n#### via notre [formulaire de contact](https://tally.so/r/w7N6Zz)\n\n#### par mail à [data.inclusion@beta.gouv.fr](mailto:data.inclusion@beta.gouv.fr)", "contact": { "name": "data·inclusion", "url": "https://www.data.inclusion.beta.gouv.fr/", @@ -28,22 +28,18 @@ ], "parameters": [ { - "name": "source", - "in": "query", - "required": false, - "schema": { - "type": "string", - "title": "Source" - } - }, - { - "name": "id", + "name": "sources", "in": "query", "required": false, "schema": { - "type": "string", - "title": "Id" - } + "type": "array", + "items": { + "type": "string" + }, + "description": "Une liste d'identifiants de source.\n La liste des identifiants de source est disponible sur le endpoint\n dédié. Les résultats seront limités aux sources spécifiées.\n ", + "title": "Sources" + }, + "description": "Une liste d'identifiants de source.\n La liste des identifiants de source est disponible sur le endpoint\n dédié. Les résultats seront limités aux sources spécifiées.\n " }, { "name": "typologie", @@ -72,55 +68,88 @@ } }, { - "name": "thematique", + "name": "thematiques", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Thematique" + }, + "description": "Une liste de thématique.\n Chaque résultat renvoyé a (au moins) une thématique dans cette liste.", + "title": "Thematiques" + }, + "description": "Une liste de thématique.\n Chaque résultat renvoyé a (au moins) une thématique dans cette liste." + }, + { + "name": "code_region", "in": "query", "required": false, "schema": { "allOf": [ { - "$ref": "#/components/schemas/Thematique" + "$ref": "#/components/schemas/RegionCodeEnum" } ], - "title": "Thematique" - } + "description": "Code insee géographique d'une région.", + "title": "Code Region" + }, + "description": "Code insee géographique d'une région." }, { - "name": "departement", + "name": "slug_region", "in": "query", "required": false, "schema": { "allOf": [ { - "$ref": "#/components/schemas/DepartementCOG" + "$ref": "#/components/schemas/RegionSlugEnum" } ], - "title": "Departement" + "title": "Slug Region" } }, { - "name": "departement_slug", + "name": "code_departement", "in": "query", "required": false, "schema": { "allOf": [ { - "$ref": "#/components/schemas/DepartementSlug" + "$ref": "#/components/schemas/DepartementCodeEnum" } ], - "title": "Departement Slug" + "description": "Code insee géographique d'un département.", + "title": "Code Departement" + }, + "description": "Code insee géographique d'un département." + }, + { + "name": "slug_departement", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DepartementSlugEnum" + } + ], + "title": "Slug Departement" } }, { - "name": "code_postal", + "name": "code_commune", "in": "query", "required": false, "schema": { "type": "string", "minLength": 5, "maxLength": 5, - "pattern": "^\\d{5}$", - "title": "Code Postal" - } + "pattern": "^\\w{5}$", + "description": "Code insee géographique d'une commune.", + "title": "Code Commune" + }, + "description": "Code insee géographique d'une commune." }, { "name": "page", @@ -274,55 +303,91 @@ ], "parameters": [ { - "name": "source", + "name": "sources", "in": "query", "required": false, "schema": { - "type": "string", - "title": "Source" - } + "type": "array", + "items": { + "type": "string" + }, + "description": "Une liste d'identifiants de source.\n La liste des identifiants de source est disponible sur le endpoint\n dédié. Les résultats seront limités aux sources spécifiées.\n ", + "title": "Sources" + }, + "description": "Une liste d'identifiants de source.\n La liste des identifiants de source est disponible sur le endpoint\n dédié. Les résultats seront limités aux sources spécifiées.\n " + }, + { + "name": "thematiques", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Thematique" + }, + "description": "Une liste de thématique.\n Chaque résultat renvoyé a (au moins) une thématique dans cette liste.", + "title": "Thematiques" + }, + "description": "Une liste de thématique.\n Chaque résultat renvoyé a (au moins) une thématique dans cette liste." }, { - "name": "thematique", + "name": "code_region", "in": "query", "required": false, "schema": { "allOf": [ { - "$ref": "#/components/schemas/Thematique" + "$ref": "#/components/schemas/RegionCodeEnum" } ], - "title": "Thematique" - } + "description": "Code insee géographique d'une région.", + "title": "Code Region" + }, + "description": "Code insee géographique d'une région." }, { - "name": "departement", + "name": "slug_region", "in": "query", "required": false, "schema": { "allOf": [ { - "$ref": "#/components/schemas/DepartementCOG" + "$ref": "#/components/schemas/RegionSlugEnum" } ], - "title": "Departement" + "title": "Slug Region" } }, { - "name": "departement_slug", + "name": "code_departement", + "in": "query", + "required": false, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/DepartementCodeEnum" + } + ], + "description": "Code insee géographique d'un département.", + "title": "Code Departement" + }, + "description": "Code insee géographique d'un département." + }, + { + "name": "slug_departement", "in": "query", "required": false, "schema": { "allOf": [ { - "$ref": "#/components/schemas/DepartementSlug" + "$ref": "#/components/schemas/DepartementSlugEnum" } ], - "title": "Departement Slug" + "title": "Slug Departement" } }, { - "name": "code_insee", + "name": "code_commune", "in": "query", "required": false, "schema": { @@ -330,8 +395,78 @@ "minLength": 5, "maxLength": 5, "pattern": "^\\w{5}$", - "title": "Code Insee" - } + "description": "Code insee géographique d'une commune.", + "title": "Code Commune" + }, + "description": "Code insee géographique d'une commune." + }, + { + "name": "frais", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Frais" + }, + "description": "Une liste de frais.\n Chaque résultat renvoyé a (au moins) un frais dans cette liste.", + "title": "Frais" + }, + "description": "Une liste de frais.\n Chaque résultat renvoyé a (au moins) un frais dans cette liste." + }, + { + "name": "profils", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Profil" + }, + "description": "Une liste de profils.\n Chaque résultat renvoyé a (au moins) un profil dans cette liste.\n ", + "title": "Profils" + }, + "description": "Une liste de profils.\n Chaque résultat renvoyé a (au moins) un profil dans cette liste.\n " + }, + { + "name": "modes_accueil", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModeAccueil" + }, + "description": "Une liste de modes d'accueil.\n Chaque résultat renvoyé a (au moins) un mode d'accueil dans cette liste.\n ", + "title": "Modes Accueil" + }, + "description": "Une liste de modes d'accueil.\n Chaque résultat renvoyé a (au moins) un mode d'accueil dans cette liste.\n " + }, + { + "name": "types", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TypologieService" + }, + "description": "Une liste de typologies de service.\n Chaque résultat renvoyé a (au moins) une typologie dans cette liste.", + "title": "Types" + }, + "description": "Une liste de typologies de service.\n Chaque résultat renvoyé a (au moins) une typologie dans cette liste." + }, + { + "name": "inclure_suspendus", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Inclure les services ayant une date de suspension dépassée.\n Ils sont exclus par défaut.\n ", + "default": false, + "title": "Inclure Suspendus" + }, + "description": "Inclure les services ayant une date de suspension dépassée.\n Ils sont exclus par défaut.\n " }, { "name": "page", @@ -447,7 +582,7 @@ "Données" ], "summary": "Rechercher des services", - "description": "## Rechercher des services\n\nLa recherche de services permet de trouver des services dans une commune et à\nproximité.\n\nLes services peuvent être filtrés selon par thématiques, frais, typologies et\ncode_insee de commune.\n\nEn particulier, lorsque le `code_insee` d'une commune est fourni :\n\n* les services sont filtrés par zone de diffusion lorsque celle-ci est définie.\n* de plus, les services en présentiel sont filtrés dans un rayon de 50km autour de\nla commune ou du point de recherche fourni.\n* le champ `distance` est :\n * rempli pour les services (non exclusivement) en présentiel.\n * laissé vide pour les services à distance et par défaut si le mode d'accueil\n n'est pas définie.\n* les résultats sont triés par distance croissante.", + "description": "## Rechercher des services\n\nLa recherche de services permet de trouver des services dans une commune et à\nproximité.\n\nLes services peuvent être filtrés selon par thématiques, frais, typologies et\ncode_insee de commune.\n\nEn particulier, lorsqu'un `code_commune` est fourni :\n\n* les services sont filtrés par zone de diffusion lorsque celle-ci est définie.\n* de plus, les services en présentiel sont filtrés dans un rayon de 50km autour de\nla commune ou du point de recherche fourni.\n* le champ `distance` est :\n * rempli pour les services (non exclusivement) en présentiel.\n * laissé vide pour les services à distance et par défaut si le mode d'accueil\n n'est pas définie.\n* les résultats sont triés par distance croissante.", "operationId": "search_services_endpoint_api_v0_search_services_get", "security": [ { @@ -455,18 +590,6 @@ } ], "parameters": [ - { - "name": "source", - "in": "query", - "required": false, - "schema": { - "type": "string", - "description": "Un identifiant de source.\n Déprécié en faveur de `sources`.\n ", - "title": "Source" - }, - "description": "Un identifiant de source.\n Déprécié en faveur de `sources`.\n ", - "deprecated": true - }, { "name": "sources", "in": "query", @@ -482,7 +605,7 @@ "description": "Une liste d'identifiants de source.\n La liste des identifiants de source est disponible sur le endpoint\n dédié. Les résultats seront limités aux sources spécifiées.\n " }, { - "name": "code_insee", + "name": "code_commune", "in": "query", "required": false, "schema": { @@ -491,7 +614,7 @@ "maxLength": 5, "pattern": "^\\w{5}$", "description": "Code insee de la commune considérée.\n Si fourni, les résultats inclus également les services proches de\n cette commune. Les résultats sont triés par ordre de distance\n croissante.\n ", - "title": "Code Insee" + "title": "Code Commune" }, "description": "Code insee de la commune considérée.\n Si fourni, les résultats inclus également les services proches de\n cette commune. Les résultats sont triés par ordre de distance\n croissante.\n " }, @@ -545,6 +668,34 @@ }, "description": "Une liste de frais.\n Chaque résultat renvoyé a (au moins) un frais dans cette liste." }, + { + "name": "modes_accueil", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ModeAccueil" + }, + "description": "Une liste de modes d'accueil.\n Chaque résultat renvoyé a (au moins) un mode d'accueil dans cette liste.\n ", + "title": "Modes Accueil" + }, + "description": "Une liste de modes d'accueil.\n Chaque résultat renvoyé a (au moins) un mode d'accueil dans cette liste.\n " + }, + { + "name": "profils", + "in": "query", + "required": false, + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Profil" + }, + "description": "Une liste de profils.\n Chaque résultat renvoyé a (au moins) un profil dans cette liste.\n ", + "title": "Profils" + }, + "description": "Une liste de profils.\n Chaque résultat renvoyé a (au moins) un profil dans cette liste.\n " + }, { "name": "types", "in": "query", @@ -1052,14 +1203,13 @@ ], "title": "CustomizedPage[Structure]" }, - "DepartementCOG": { + "DepartementCodeEnum": { "type": "string", "enum": [ "01", "02", "03", "04", - "05", "06", "07", "08", @@ -1067,48 +1217,63 @@ "10", "11", "12", + "67", "13", "14", "15", - "16", "17", + "16", "18", "19", + "2A", "21", "22", "23", + "79", "24", "25", "26", - "27", + "91", "28", + "27", "29", - "2A", - "2B", "30", - "31", "32", "33", + "971", + "973", + "68", + "2B", + "31", + "43", + "52", + "70", + "74", + "87", + "05", + "65", + "92", "34", "35", - "36", "37", + "36", "38", "39", + "974", "40", "41", - "42", - "43", "44", + "42", "45", - "46", "47", + "46", "48", "49", "50", "51", - "52", + "972", "53", + "976", "54", "55", "56", @@ -1117,56 +1282,41 @@ "59", "60", "61", + "75", "62", "63", "64", - "65", "66", - "67", - "68", "69", - "70", "71", "72", "73", - "74", - "75", - "76", "77", - "78", - "79", + "76", + "93", "80", - "81", "82", + "81", + "90", + "95", + "94", "83", "84", "85", "86", - "87", "88", "89", - "90", - "91", - "92", - "93", - "94", - "95", - "971", - "972", - "973", - "974", - "976" + "78" ], - "title": "DepartementCOG" + "title": "DepartementCodeEnum" }, - "DepartementSlug": { + "DepartementSlugEnum": { "type": "string", "enum": [ "ain", "aisne", "allier", "alpes-de-haute-provence", - "hautes-alpes", "alpes-maritimes", "ardeche", "ardennes", @@ -1174,48 +1324,63 @@ "aube", "aude", "aveyron", + "bas-rhin", "bouches-du-rhone", "calvados", "cantal", - "charente", "charente-maritime", + "charente", "cher", "correze", + "corse-du-sud", "cote-d-or", "cotes-d-armor", "creuse", + "deux-sevres", "dordogne", "doubs", "drome", - "eure", + "essonne", "eure-et-loir", + "eure", "finistere", - "corse-du-sud", - "haute-corse", "gard", - "haute-garonne", "gers", "gironde", + "guadeloupe", + "guyane", + "haut-rhin", + "haute-corse", + "haute-garonne", + "haute-loire", + "haute-marne", + "haute-saone", + "haute-savoie", + "haute-vienne", + "hautes-alpes", + "hautes-pyrenees", + "hauts-de-seine", "herault", "ille-et-vilaine", - "indre", "indre-et-loire", + "indre", "isere", "jura", + "la-reunion", "landes", "loir-et-cher", - "loire", - "haute-loire", "loire-atlantique", + "loire", "loiret", - "lot", "lot-et-garonne", + "lot", "lozere", "maine-et-loire", "manche", "marne", - "haute-marne", + "martinique", "mayenne", + "mayotte", "meurthe-et-moselle", "meuse", "morbihan", @@ -1224,47 +1389,33 @@ "nord", "oise", "orne", + "paris", "pas-de-calais", "puy-de-dome", "pyrenees-atlantiques", - "hautes-pyrenees", "pyrenees-orientales", - "bas-rhin", - "haut-rhin", "rhone", - "haute-saone", "saone-et-loire", "sarthe", "savoie", - "haute-savoie", - "paris", - "seine-maritime", "seine-et-marne", - "yvelines", - "deux-sevres", + "seine-maritime", + "seine-saint-denis", "somme", - "tarn", "tarn-et-garonne", + "tarn", + "territoire-de-belfort", + "val-d-oise", + "val-de-marne", "var", "vaucluse", "vendee", "vienne", - "haute-vienne", "vosges", "yonne", - "territoire-de-belfort", - "essonne", - "hauts-de-seine", - "seine-saint-denis", - "val-de-marne", - "val-d-oise", - "guadeloupe", - "martinique", - "guyane", - "la-reunion", - "mayotte" + "yvelines" ], - "title": "DepartementSlug" + "title": "DepartementSlugEnum" }, "DetailedService": { "properties": { @@ -1769,33 +1920,6 @@ ], "title": "Modes Orientation Accompagnateur Autres" }, - "_di_geocodage_code_insee": { - "anyOf": [ - { - "type": "string", - "maxLength": 5, - "minLength": 5, - "pattern": "^\\w{5}$" - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Code Insee" - }, - "_di_geocodage_score": { - "anyOf": [ - { - "type": "number", - "maximum": 1.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Score" - }, "structure": { "$ref": "#/components/schemas/Structure" } @@ -2104,33 +2228,6 @@ ], "title": "Thematiques" }, - "_di_geocodage_code_insee": { - "anyOf": [ - { - "type": "string", - "maxLength": 5, - "minLength": 5, - "pattern": "^\\w{5}$" - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Code Insee" - }, - "_di_geocodage_score": { - "anyOf": [ - { - "type": "number", - "maximum": 1.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Score" - }, "services": { "items": { "$ref": "#/components/schemas/Service" @@ -2358,6 +2455,54 @@ ], "title": "Profil" }, + "RegionCodeEnum": { + "type": "string", + "enum": [ + "84", + "27", + "53", + "24", + "94", + "44", + "01", + "03", + "32", + "11", + "04", + "02", + "06", + "28", + "75", + "76", + "52", + "93" + ], + "title": "RegionCodeEnum" + }, + "RegionSlugEnum": { + "type": "string", + "enum": [ + "auvergne-rhone-alpes", + "bourgogne-franche-comte", + "bretagne", + "centre-val-de-loire", + "corse", + "grand-est", + "guadeloupe", + "guyane", + "hauts-de-france", + "ile-de-france", + "la-reunion", + "martinique", + "mayotte", + "normandie", + "nouvelle-aquitaine", + "occitanie", + "pays-de-la-loire", + "provence-alpes-cote-d-azur" + ], + "title": "RegionSlugEnum" + }, "Service": { "properties": { "id": { @@ -2860,33 +3005,6 @@ } ], "title": "Modes Orientation Accompagnateur Autres" - }, - "_di_geocodage_code_insee": { - "anyOf": [ - { - "type": "string", - "maxLength": 5, - "minLength": 5, - "pattern": "^\\w{5}$" - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Code Insee" - }, - "_di_geocodage_score": { - "anyOf": [ - { - "type": "number", - "maximum": 1.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Score" } }, "type": "object", @@ -3244,33 +3362,6 @@ } ], "title": "Thematiques" - }, - "_di_geocodage_code_insee": { - "anyOf": [ - { - "type": "string", - "maxLength": 5, - "minLength": 5, - "pattern": "^\\w{5}$" - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Code Insee" - }, - "_di_geocodage_score": { - "anyOf": [ - { - "type": "number", - "maximum": 1.0, - "minimum": 0.0 - }, - { - "type": "null" - } - ], - "title": " Di Geocodage Score" } }, "type": "object", diff --git a/api/tests/e2e/api/test_inclusion_data.py b/api/tests/e2e/api/test_inclusion_data.py index 2a4abd927..eb83ac022 100644 --- a/api/tests/e2e/api/test_inclusion_data.py +++ b/api/tests/e2e/api/test_inclusion_data.py @@ -5,6 +5,7 @@ import pytest from data_inclusion import schema +from data_inclusion.api.code_officiel_geo.constants import RegionEnum from data_inclusion.api.utils import soliguide from ... import factories @@ -17,6 +18,10 @@ ROUBAIX = {"code_insee": "59512"} +def list_resources_data(resp_data): + return [item.get("service", item) for item in resp_data["items"]] + + def test_openapi_spec(api_client, snapshot): url = "/api/openapi.json" response = api_client.get(url) @@ -44,34 +49,32 @@ def test_list_structures_all(api_client): assert resp_data == { "items": [ { - "_di_geocodage_code_insee": "55626", - "_di_geocodage_score": 0.33, - "id": "prince-point-monde", - "siret": "59382421200611", - "rna": "W948924115", - "nom": "Vaillant", - "commune": "Sainte Bernadetteboeuf", - "code_postal": "80571", - "code_insee": "84442", - "adresse": "977, rue Susan Lévy", + "accessibilite": "https://acceslibre.beta.gouv.fr/app/nom-asseoir/", + "adresse": "49, avenue de Pichon", + "antenne": False, + "code_insee": "59350", + "code_postal": "46873", + "commune": "Sainte CharlotteBourg", "complement_adresse": None, - "longitude": -172.461419, - "latitude": -64.9625245, - "typologie": "ACI", - "telephone": "0102030405", - "courriel": "ylacombe@example.net", - "site_web": "http://berger.fr/", - "presentation_resume": "Voie battre.", - "presentation_detail": "Or personne jambe.", - "source": "dora", + "courriel": "levyalexandre@example.org", "date_maj": "2023-01-01", - "antenne": False, - "lien_source": "https://dora.fr/prince-point-monde", "horaires_ouverture": 'Mo-Fr 10:00-20:00 "sur rendez-vous"; PH off', - "accessibilite": "https://acceslibre.beta.gouv.fr/app/prince-point-monde/", - "labels_nationaux": [], + "id": "nom-asseoir", "labels_autres": ["Nièvre médiation numérique"], + "labels_nationaux": [], + "latitude": -20.074628, + "lien_source": "https://dora.fr/nom-asseoir", + "longitude": 99.899603, + "nom": "Perrin", + "presentation_detail": "Or personne jambe.", + "presentation_resume": "Image voie battre.", + "rna": "W242194892", + "siret": "76475938700658", + "site_web": "https://www.le.net/", + "source": "dora", + "telephone": "0102030405", "thematiques": ["choisir-un-metier"], + "typologie": "ACI", } ], "total": 1, @@ -125,8 +128,6 @@ def assert_structure_data(structure, data): assert sorted(structure.labels_nationaux) == sorted(data["labels_nationaux"]) assert sorted(structure.labels_autres) == sorted(data["labels_autres"]) assert sorted(structure.thematiques) == sorted(data["thematiques"]) - assert structure._di_geocodage_code_insee == data["_di_geocodage_code_insee"] - assert structure._di_geocodage_score == data["_di_geocodage_score"] @pytest.mark.with_token @@ -175,6 +176,7 @@ def test_list_structures_filter_by_label( @pytest.mark.with_token +@pytest.mark.feature_deprecated def test_list_structures_filter_by_source(api_client): structure_1 = factories.StructureFactory(source="emplois-de-linclusion") factories.StructureFactory(source="dora") @@ -216,119 +218,7 @@ def test_list_sources(api_client): @pytest.mark.with_token -def test_list_structures_filter_by_departement_cog(api_client): - structure_1 = factories.StructureFactory(code_insee=PARIS["code_insee"]) - factories.StructureFactory(code_insee=LILLE["code_insee"]) - - url = "/api/v0/structures/" - response = api_client.get(url, params={"departement": "75"}) - - resp_data = response.json() - assert_paginated_response_data(response.json(), total=1) - assert_structure_data(structure_1, resp_data["items"][0]) - - response = api_client.get(url, params={"departement": "62"}) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_list_structures_filter_by_departement_slug( - api_client, -): - structure_1 = factories.StructureFactory(code_insee=PARIS["code_insee"]) - factories.StructureFactory(code_insee=LILLE["code_insee"]) - - url = "/api/v0/structures/" - response = api_client.get(url, params={"departement_slug": "paris"}) - - resp_data = response.json() - assert_paginated_response_data(response.json(), total=1) - assert_structure_data(structure_1, resp_data["items"][0]) - - response = api_client.get(url, params={"departement_slug": "pas-de-calais"}) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_list_structures_filter_by_code_postal( - api_client, -): - structure_1 = factories.StructureFactory( - code_postal="59100", code_insee=ROUBAIX["code_insee"], commune="roubaix" - ) - factories.StructureFactory( - code_postal="59178", code_insee="59100", commune="bousignies" - ) - - url = "/api/v0/structures/" - response = api_client.get(url, params={"code_postal": "59100"}) - - resp_data = response.json() - assert_paginated_response_data(response.json(), total=1) - assert_structure_data(structure_1, resp_data["items"][0]) - - response = api_client.get(url, params={"code_postal": ROUBAIX["code_insee"]}) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_list_structures_filter_by_thematique( - api_client, -): - structure_1 = factories.StructureFactory( - thematiques=[ - schema.Thematique.MOBILITE.value, - schema.Thematique.NUMERIQUE.value, - ] - ) - factories.StructureFactory( - thematiques=[ - schema.Thematique.TROUVER_UN_EMPLOI.value, - schema.Thematique.NUMERIQUE.value, - ] - ) - factories.StructureFactory(thematiques=[]) - - url = "/api/v0/structures/" - response = api_client.get( - url, params={"thematique": schema.Thematique.MOBILITE.value} - ) - - resp_data = response.json() - assert_paginated_response_data(response.json(), total=1) - assert_structure_data(structure_1, resp_data["items"][0]) - - response = api_client.get( - url, params={"thematique": schema.Thematique.PREPARER_SA_CANDIDATURE.value} - ) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_list_structures_filter_by_categorie_thematique( - api_client, -): - structure = factories.StructureFactory( - thematiques=[ - schema.Thematique.MOBILITE__ACHETER_UN_VEHICULE_MOTORISE.value, - ], - ) - factories.StructureFactory(thematiques=[]) - - url = "/api/v0/structures/" - - response = api_client.get( - url, params={"thematique": schema.Thematique.MOBILITE.value} - ) - - assert response.status_code == 200 - resp_data = response.json() - assert len(resp_data["items"]) == 1 - assert resp_data["items"][0]["source"] == structure.source - assert resp_data["items"][0]["id"] == structure.id - - -@pytest.mark.with_token +@pytest.mark.feature_deprecated def test_list_structures_filter_by_source_and_id( api_client, ): @@ -365,12 +255,10 @@ def test_list_services_all(api_client): assert resp_data == { "items": [ { - "_di_geocodage_code_insee": "59350", - "_di_geocodage_score": 0.89, - "adresse": "20, rue Lambert", + "adresse": "62, rue Eugène Rodrigues", "code_insee": "59350", - "code_postal": "22846", - "commune": "Guichard", + "code_postal": "92950", + "commune": "Sainte Gabriel", "complement_adresse": None, "contact_nom_prenom": "Thibaut de Michaud", "contact_public": False, @@ -380,28 +268,28 @@ def test_list_services_all(api_client): "date_maj": "2023-01-01", "date_suspension": "2054-01-01", "formulaire_en_ligne": None, - "frais_autres": "Point saint source.", + "frais_autres": "Camarade il.", "frais": ["gratuit"], - "id": "rassurer-vaincre", + "id": "cacher-violent", "justificatifs": [], - "latitude": 80.2434875, - "lien_source": "https://dora.fr/rassurer-vaincre", - "longitude": 128.091981, + "latitude": -77.857573, + "lien_source": "https://dora.fr/cacher-violent", + "longitude": -62.54684, "modes_accueil": ["a-distance"], "modes_orientation_accompagnateur_autres": None, "modes_orientation_accompagnateur": ["telephoner"], "modes_orientation_beneficiaire_autres": None, "modes_orientation_beneficiaire": ["telephoner"], - "nom": "Barbe", - "page_web": "http://www.foucher.com/", + "nom": "Munoz", + "page_web": "http://aubert.net/", "pre_requis": [], - "presentation_detail": "Noir roi fin parmi.", - "presentation_resume": "Épaule élever un.", - "prise_rdv": "https://www.raymond.com/", + "presentation_detail": "Épaule élever un.", + "presentation_resume": "Puissant fine.", + "prise_rdv": "https://teixeira.fr/", "profils": ["femmes"], "recurrence": None, "source": "dora", - "structure_id": "grace-plaindre", + "structure_id": "prince-point-monde", "telephone": "0102030405", "thematiques": ["choisir-un-metier"], "types": ["formation"], @@ -417,76 +305,6 @@ def test_list_services_all(api_client): } -@pytest.mark.with_token -def test_list_structures_null_siret( - api_client, -): - structure = factories.StructureFactory(siret=None) - - url = "/api/v0/structures/" - - response = api_client.get(url) - - assert response.status_code == 200 - - resp_data = response.json() - - assert resp_data["items"][0]["id"] == structure.id - assert resp_data["items"][0]["siret"] is None - - -@pytest.mark.with_token -def test_list_structures_null_code_insee( - api_client, -): - structure = factories.StructureFactory(code_insee=None) - - url = "/api/v0/structures/" - - response = api_client.get(url) - - assert response.status_code == 200 - - resp_data = response.json() - - assert resp_data["items"][0]["id"] == structure.id - assert resp_data["items"][0]["code_insee"] is None - - -@pytest.mark.with_token -def test_list_structures_null_code_insee_filter_by_departement_cog(api_client): - factories.StructureFactory(code_insee=None) - structure = factories.StructureFactory(code_insee=LILLE["code_insee"]) - - url = "/api/v0/structures/" - - response = api_client.get(url, params={"departement": "59"}) - - assert response.status_code == 200 - - resp_data = response.json() - - assert len(resp_data["items"]) == 1 - assert resp_data["items"][0]["id"] == structure.id - - -@pytest.mark.with_token -def test_list_structures_null_code_insee_filter_by_departement_slug(api_client): - factories.StructureFactory(code_insee=None) - structure = factories.StructureFactory(code_insee=LILLE["code_insee"]) - - url = "/api/v0/structures/" - - response = api_client.get(url, params={"departement_slug": "nord"}) - - assert response.status_code == 200 - - resp_data = response.json() - - assert len(resp_data["items"]) == 1 - assert resp_data["items"][0]["id"] == structure.id - - @pytest.mark.with_token def test_list_structures_order( api_client, @@ -509,9 +327,10 @@ def test_list_structures_order( @pytest.mark.with_token +@pytest.mark.feature_deprecated def test_list_services_filter_by_source(api_client): - service_1 = factories.ServiceFactory(structure__source="emplois-de-linclusion") - factories.ServiceFactory(structure__source="dora") + service_1 = factories.ServiceFactory(source="emplois-de-linclusion") + factories.ServiceFactory(source="dora") url = "/api/v0/services/" response = api_client.get(url, params={"source": "emplois-de-linclusion"}) @@ -526,9 +345,10 @@ def test_list_services_filter_by_source(api_client): @pytest.mark.with_token +@pytest.mark.feature_deprecated def test_list_services_filter_by_thematique(api_client): service_1 = factories.ServiceFactory( - structure__source="alpha", + source="alpha", id="1", thematiques=[ schema.Thematique.MOBILITE.value, @@ -536,7 +356,7 @@ def test_list_services_filter_by_thematique(api_client): ], ) service_2 = factories.ServiceFactory( - structure__source="alpha", + source="alpha", id="2", thematiques=[ schema.Thematique.TROUVER_UN_EMPLOI.value, @@ -570,94 +390,445 @@ def test_list_services_filter_by_thematique(api_client): assert resp_data["items"][1]["source"] == service_2.structure.source response = api_client.get( - url, params={"thematique": schema.Thematique.PREPARER_SA_CANDIDATURE.value} + url, params={"thematique": schema.Thematique.PREPARER_SA_CANDIDATURE.value} + ) + assert response.status_code == 200 + assert_paginated_response_data(response.json(), total=0) + + +@pytest.mark.with_token +@pytest.mark.feature_deprecated +def test_list_services_filter_by_categorie_thematique(api_client): + service = factories.ServiceFactory( + source="alpha", + id="1", + thematiques=[ + schema.Thematique.MOBILITE__ACHETER_UN_VEHICULE_MOTORISE.value, + ], + ) + factories.ServiceFactory(thematiques=[]) + + url = "/api/v0/services/" + + response = api_client.get( + url, params={"thematique": schema.Thematique.MOBILITE.value} + ) + + assert response.status_code == 200 + resp_data = response.json() + assert len(resp_data["items"]) == 1 + assert resp_data["items"][0]["source"] == service.structure.source + assert resp_data["items"][0]["id"] == service.id + + +@pytest.mark.parametrize( + "thematiques,input,found", + [ + ([], [schema.Thematique.FAMILLE.value], False), + ([schema.Thematique.FAMILLE.value], [schema.Thematique.FAMILLE.value], True), + ([schema.Thematique.NUMERIQUE.value], [schema.Thematique.FAMILLE.value], False), + ( + [schema.Thematique.NUMERIQUE.value, schema.Thematique.FAMILLE.value], + [schema.Thematique.FAMILLE.value], + True, + ), + ( + [schema.Thematique.SANTE.value, schema.Thematique.NUMERIQUE.value], + [schema.Thematique.FAMILLE.value, schema.Thematique.NUMERIQUE.value], + True, + ), + ( + [schema.Thematique.SANTE.value, schema.Thematique.NUMERIQUE.value], + [schema.Thematique.FAMILLE.value, schema.Thematique.NUMERIQUE.value], + True, + ), + ( + [schema.Thematique.FAMILLE__GARDE_DENFANTS.value], + [schema.Thematique.FAMILLE.value], + True, + ), + ], +) +@pytest.mark.with_token +@pytest.mark.parametrize( + ("url", "factory"), + [ + ("/api/v0/services", factories.ServiceFactory), + ("/api/v0/search/services", factories.ServiceFactory), + ("/api/v0/structures", factories.StructureFactory), + ], +) +def test_can_filter_resources_by_thematiques( + api_client, url, factory, thematiques, input, found +): + resource = factory(thematiques=thematiques) + factory(thematiques=[schema.Thematique.MOBILITE.value]) + + response = api_client.get(url, params={"thematiques": input}) + + assert response.status_code == 200 + resp_data = response.json() + if found: + assert_paginated_response_data(resp_data, total=1) + assert list_resources_data(resp_data)[0]["id"] in [resource.id] + else: + assert_paginated_response_data(resp_data, total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize( + ("url", "factory"), + [ + ("/api/v0/structures", factories.StructureFactory), + ("/api/v0/services", factories.ServiceFactory), + ], +) +@pytest.mark.parametrize( + "query_param", + [ + "code_departement", + pytest.param("departement", marks=pytest.mark.feature_deprecated), + ], +) +def test_can_filter_resources_by_departement_code( + api_client, url, factory, query_param +): + resource = factory(code_insee=PARIS["code_insee"]) + factory(code_insee=LILLE["code_insee"]) + factory(code_insee=None) + + response = api_client.get(url, params={query_param: "75"}) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=1) + assert resp_data["items"][0]["id"] == resource.id + + response = api_client.get(url, params={query_param: "62"}) + assert_paginated_response_data(response.json(), total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize( + ("url", "factory"), + [ + ("/api/v0/structures", factories.StructureFactory), + ("/api/v0/services", factories.ServiceFactory), + ], +) +@pytest.mark.parametrize( + "query_param", + [ + "slug_departement", + pytest.param("departement_slug", marks=pytest.mark.feature_deprecated), + ], +) +def test_can_filter_resources_by_departement_slug( + api_client, url, query_param, factory +): + resource = factory(code_insee=PARIS["code_insee"]) + factory(code_insee=LILLE["code_insee"]) + + response = api_client.get(url, params={query_param: "paris"}) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=1) + assert resp_data["items"][0]["id"] == resource.id + + response = api_client.get(url, params={query_param: "pas-de-calais"}) + assert_paginated_response_data(response.json(), total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize( + ("url", "factory"), + [ + ("/api/v0/structures", factories.StructureFactory), + ("/api/v0/services", factories.ServiceFactory), + ], +) +def test_can_filter_resources_by_code_region(api_client, url, factory): + resource = factory(code_insee=PARIS["code_insee"]) + factory(code_insee=LILLE["code_insee"]) + + response = api_client.get( + url, params={"code_region": RegionEnum.ILE_DE_FRANCE.value.code} + ) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=1) + assert resp_data["items"][0]["id"] == resource.id + + response = api_client.get( + url, params={"code_region": RegionEnum.LA_REUNION.value.code} + ) + assert_paginated_response_data(response.json(), total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize( + ("url", "factory"), + [ + ("/api/v0/structures", factories.StructureFactory), + ("/api/v0/services", factories.ServiceFactory), + ], +) +def test_can_filter_resources_by_slug_region(api_client, url, factory): + resource = factory(code_insee=PARIS["code_insee"]) + factory(code_insee=LILLE["code_insee"]) + + response = api_client.get( + url, params={"slug_region": RegionEnum.ILE_DE_FRANCE.value.slug} + ) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=1) + assert resp_data["items"][0]["id"] == resource.id + + response = api_client.get( + url, params={"slug_region": RegionEnum.LA_REUNION.value.slug} + ) + assert_paginated_response_data(response.json(), total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize( + ("url", "factory", "query_param"), + [ + ("/api/v0/structures", factories.StructureFactory, "code_commune"), + ("/api/v0/services", factories.ServiceFactory, "code_commune"), + pytest.param( + "/api/v0/services", + factories.ServiceFactory, + "code_insee", + marks=pytest.mark.feature_deprecated, + ), + ], +) +@pytest.mark.parametrize( + "code_commune, input, found", + [ + (None, DUNKERQUE["code_insee"], False), + (DUNKERQUE["code_insee"], DUNKERQUE["code_insee"], True), + (DUNKERQUE["code_insee"], "62041", False), + (PARIS["code_insee"], "75101", True), + ], +) +def test_can_filter_resources_by_code_commune( + api_client, url, factory, code_commune, input, found, query_param +): + resource = factory(code_insee=code_commune) + factory(code_insee=LILLE["code_insee"]) + + response = api_client.get(url, params={query_param: input}) + + assert response.status_code == 200 + resp_data = response.json() + if found: + assert_paginated_response_data(resp_data, total=1) + assert resp_data["items"][0]["id"] == resource.id + else: + assert_paginated_response_data(resp_data, total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize("url", ["/api/v0/services", "/api/v0/search/services"]) +def test_can_filter_services_by_profils(api_client, url): + service_1 = factories.ServiceFactory(profils=[schema.Profil.FEMMES.value]) + service_2 = factories.ServiceFactory(profils=[schema.Profil.JEUNES_16_26.value]) + factories.ServiceFactory(profils=[schema.Profil.ADULTES.value]) + factories.ServiceFactory(profils=[]) + factories.ServiceFactory(profils=None) + + url = "/api/v0/services" + response = api_client.get( + url, + params={ + "profils": [ + schema.Profil.FEMMES.value, + schema.Profil.JEUNES_16_26.value, + ], + }, + ) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=2) + assert {d["id"] for d in list_resources_data(resp_data)} == { + service_1.id, + service_2.id, + } + + response = api_client.get( + url, + params={ + "profils": schema.Profil.BENEFICIAIRES_RSA.value, + }, + ) + assert_paginated_response_data(response.json(), total=0) + + +@pytest.mark.with_token +@pytest.mark.parametrize("url", ["/api/v0/services", "/api/v0/search/services"]) +def test_list_services_by_types(api_client, url): + service_1 = factories.ServiceFactory(types=[schema.TypologieService.ACCUEIL.value]) + service_2 = factories.ServiceFactory( + types=[schema.TypologieService.ACCOMPAGNEMENT.value] + ) + factories.ServiceFactory(types=[schema.TypologieService.AIDE_FINANCIERE.value]) + + response = api_client.get( + url, + params={ + "types": [ + schema.TypologieService.ACCUEIL.value, + schema.TypologieService.ACCOMPAGNEMENT.value, + ], + }, + ) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=2) + assert {d["id"] for d in list_resources_data(resp_data)} == { + service_1.id, + service_2.id, + } + + response = api_client.get( + url, + params={ + "types": schema.TypologieService.ATELIER.value, + }, ) - assert response.status_code == 200 assert_paginated_response_data(response.json(), total=0) @pytest.mark.with_token -def test_list_services_filter_by_categorie_thematique(api_client): - service = factories.ServiceFactory( - structure__source="alpha", - id="1", - thematiques=[ - schema.Thematique.MOBILITE__ACHETER_UN_VEHICULE_MOTORISE.value, - ], - ) - factories.ServiceFactory(thematiques=[]) - - url = "/api/v0/services/" +@pytest.mark.parametrize("url", ["/api/v0/services", "/api/v0/search/services"]) +def test_can_filter_services_by_frais(api_client, url): + service_1 = factories.ServiceFactory(frais=[schema.Frais.GRATUIT.value]) + service_2 = factories.ServiceFactory(frais=[schema.Frais.ADHESION.value]) + factories.ServiceFactory(frais=[schema.Frais.PASS_NUMERIQUE.value]) response = api_client.get( - url, params={"thematique": schema.Thematique.MOBILITE.value} + url, + params={ + "frais": [ + schema.Frais.GRATUIT.value, + schema.Frais.ADHESION.value, + ], + }, ) assert response.status_code == 200 resp_data = response.json() - assert len(resp_data["items"]) == 1 - assert resp_data["items"][0]["source"] == service.structure.source - assert resp_data["items"][0]["id"] == service.id + assert_paginated_response_data(resp_data, total=2) + assert {d["id"] for d in list_resources_data(resp_data)} == { + service_1.id, + service_2.id, + } + + response = api_client.get( + url, + params={ + "frais": schema.Frais.PAYANT.value, + }, + ) + assert_paginated_response_data(response.json(), total=0) @pytest.mark.with_token -def test_list_services_filter_by_departement_cog(api_client): - service = factories.ServiceFactory(code_insee=PARIS["code_insee"]) - factories.ServiceFactory(code_insee=LILLE["code_insee"]) +@pytest.mark.parametrize("url", ["/api/v0/services", "/api/v0/search/services"]) +def test_can_filter_services_with_an_outdated_suspension_date(api_client, url): + service_1 = factories.ServiceFactory(date_suspension=None) + service_2 = factories.ServiceFactory(date_suspension=date.today()) + factories.ServiceFactory(date_suspension=date.today() - timedelta(days=1)) - url = "/api/v0/services/" - response = api_client.get(url, params={"departement": "75"}) + # exclude outdated services by default + response = api_client.get(url) assert response.status_code == 200 resp_data = response.json() - assert_paginated_response_data(resp_data, total=1) - assert resp_data["items"][0]["id"] == service.id + assert_paginated_response_data(resp_data, total=2) + assert {d["id"] for d in list_resources_data(resp_data)} == { + service_1.id, + service_2.id, + } - response = api_client.get(url, params={"departement": "62"}) - assert_paginated_response_data(response.json(), total=0) + # include outdated services with query parameter + response = api_client.get(url, params={"inclure_suspendus": True}) + + assert response.status_code == 200 + resp_data = response.json() + assert_paginated_response_data(resp_data, total=3) @pytest.mark.with_token -def test_list_services_filter_by_departement_slug(api_client): - service = factories.ServiceFactory(code_insee=PARIS["code_insee"]) - factories.ServiceFactory(code_insee=LILLE["code_insee"]) +@pytest.mark.parametrize("url", ["/api/v0/services", "/api/v0/search/services"]) +def test_can_filter_services_by_modes_accueil(api_client, url): + service_1 = factories.ServiceFactory( + modes_accueil=[schema.ModeAccueil.EN_PRESENTIEL.value] + ) + factories.ServiceFactory(modes_accueil=[schema.ModeAccueil.A_DISTANCE.value]) + factories.ServiceFactory(modes_accueil=[]) + factories.ServiceFactory(modes_accueil=None) - url = "/api/v0/services/" - response = api_client.get(url, params={"departement_slug": "paris"}) + response = api_client.get( + url, + params={ + "modes_accueil": [ + schema.ModeAccueil.EN_PRESENTIEL.value, + ], + }, + ) assert response.status_code == 200 resp_data = response.json() assert_paginated_response_data(resp_data, total=1) - assert resp_data["items"][0]["id"] == service.id - response = api_client.get(url, params={"departement_slug": "pas-de-calais"}) - assert_paginated_response_data(response.json(), total=0) + assert list_resources_data(resp_data)[0]["id"] == service_1.id +@pytest.mark.with_token @pytest.mark.parametrize( - "code_insee, input, found", + ("url", "factory"), [ - (None, DUNKERQUE["code_insee"], False), - (DUNKERQUE["code_insee"], DUNKERQUE["code_insee"], True), - (DUNKERQUE["code_insee"], "62041", False), - (PARIS["code_insee"], "75101", True), + ("/api/v0/services", factories.ServiceFactory), + ("/api/v0/search/services", factories.ServiceFactory), + ("/api/v0/structures", factories.StructureFactory), ], ) -@pytest.mark.with_token -def test_list_services_filter_by_code_insee(api_client, code_insee, input, found): - service = factories.ServiceFactory(code_insee=code_insee) - factories.ServiceFactory(code_insee=LILLE["code_insee"]) +def test_can_filter_resources_by_sources(api_client, url, factory): + service_1 = factory(source="dora") + service_2 = factory(source="emplois-de-linclusion") + factory(source="un-jeune-une-solution") - url = "/api/v0/services/" - response = api_client.get(url, params={"code_insee": input}) + response = api_client.get( + url, + params={ + "sources": ["dora", "emplois-de-linclusion"], + }, + ) assert response.status_code == 200 resp_data = response.json() - if found: - assert_paginated_response_data(resp_data, total=1) - assert resp_data["items"][0]["id"] == service.id - else: - assert_paginated_response_data(resp_data, total=0) + assert_paginated_response_data(resp_data, total=2) + assert {d["id"] for d in list_resources_data(resp_data)} == { + service_1.id, + service_2.id, + } + + response = api_client.get( + url, + params={ + "sources": ["foobar"], + }, + ) + assert_paginated_response_data(response.json(), total=0) @pytest.mark.parametrize( @@ -671,14 +842,23 @@ def test_list_services_filter_by_code_insee(api_client, code_insee, input, found ], ) @pytest.mark.with_token -def test_search_services_with_code_insee_foo(api_client, commune_data, input, found): +@pytest.mark.parametrize( + "query_param", + [ + "code_commune", + pytest.param("code_insee", marks=pytest.mark.feature_deprecated), + ], +) +def test_search_services_with_code_commune( + api_client, commune_data, input, found, query_param +): service = factories.ServiceFactory( modes_accueil=[schema.ModeAccueil.EN_PRESENTIEL.value], **(commune_data if commune_data is not None else {}), ) url = "/api/v0/search/services" - response = api_client.get(url, params={"code_insee": input}) + response = api_client.get(url, params={query_param: input}) assert response.status_code == 200 resp_data = response.json() @@ -690,7 +870,7 @@ def test_search_services_with_code_insee_foo(api_client, commune_data, input, fo @pytest.mark.with_token -def test_search_services_with_code_insee_too_far(api_client): +def test_search_services_with_code_commune_too_far(api_client): # Dunkerque to Hazebrouck: <50km # Hazebrouck to Lille: <50km # Dunkerque to Lille: >50km @@ -715,7 +895,7 @@ def test_search_services_with_code_insee_too_far(api_client): response = api_client.get( url, params={ - "code_insee": ROUBAIX["code_insee"], # Roubaix (only close to Lille) + "code_commune": ROUBAIX["code_insee"], # Roubaix (only close to Lille) }, ) @@ -729,9 +909,9 @@ def test_search_services_with_code_insee_too_far(api_client): response = api_client.get( url, params={ - "code_insee": ROUBAIX["code_insee"], # Roubaix + "code_commune": ROUBAIX["code_insee"], # Roubaix # Coordinates for Le Mans. We don't enforce lat/lon to be within - # the supplied 'code_insee' city limits. + # the supplied 'code_commune' city limits. "lat": 48.003954, "lon": 0.199134, }, @@ -745,7 +925,7 @@ def test_search_services_with_code_insee_too_far(api_client): response = api_client.get( url, params={ - "code_insee": ROUBAIX["code_insee"], # Roubaix + "code_commune": ROUBAIX["code_insee"], # Roubaix # Coordinates for Hazebrouck, between Dunkirk & Lille "lat": 50.7262, "lon": 2.5387, @@ -759,12 +939,12 @@ def test_search_services_with_code_insee_too_far(api_client): assert resp_data["items"][0]["service"]["id"] == service_2.id assert 0 < resp_data["items"][0]["distance"] < 50 - # What about a request without code_insee but with lat/lon? + # What about a request without code_commune but with lat/lon? response = api_client.get( url, params={ # Coordinates for Le Mans, should be ignored. - # the supplied 'code_insee' city limits. + # the supplied 'code_commune' city limits. "lat": 48.003954, "lon": 0.199134, }, @@ -778,7 +958,7 @@ def test_search_services_with_code_insee_too_far(api_client): response = api_client.get( url, params={ - "code_insee": MAUBEUGE["code_insee"], + "code_commune": MAUBEUGE["code_insee"], "lat": 48.003954, }, ) @@ -790,7 +970,7 @@ def test_search_services_with_code_insee_too_far(api_client): response = api_client.get( url, params={ - "code_insee": MAUBEUGE["code_insee"], + "code_commune": MAUBEUGE["code_insee"], "lon": 1.2563, }, ) @@ -817,7 +997,7 @@ def test_search_services_with_zone_diffusion_pays(api_client): response = api_client.get( url, params={ - "code_insee": MAUBEUGE["code_insee"], # Maubeuge + "code_commune": MAUBEUGE["code_insee"], # Maubeuge }, ) @@ -854,7 +1034,7 @@ def test_search_services_with_zone_diffusion_commune(api_client): response = api_client.get( url, params={ - "code_insee": DUNKERQUE["code_insee"], # Dunkerque + "code_commune": DUNKERQUE["code_insee"], # Dunkerque }, ) @@ -891,7 +1071,7 @@ def test_search_services_with_zone_diffusion_epci(api_client): response = api_client.get( url, params={ - "code_insee": DUNKERQUE["code_insee"], # Dunkerque + "code_commune": DUNKERQUE["code_insee"], # Dunkerque }, ) @@ -928,7 +1108,7 @@ def test_search_services_with_zone_diffusion_departement(api_client): response = api_client.get( url, params={ - "code_insee": DUNKERQUE["code_insee"], # Dunkerque + "code_commune": DUNKERQUE["code_insee"], # Dunkerque }, ) @@ -965,7 +1145,7 @@ def test_search_services_with_zone_diffusion_region(api_client): response = api_client.get( url, params={ - "code_insee": DUNKERQUE["code_insee"], # Dunkerque + "code_commune": DUNKERQUE["code_insee"], # Dunkerque }, ) @@ -976,7 +1156,7 @@ def test_search_services_with_zone_diffusion_region(api_client): @pytest.mark.with_token -def test_search_services_with_bad_code_insee(api_client): +def test_search_services_with_bad_code_commune(api_client): factories.ServiceFactory( commune="Lille", code_insee=LILLE["code_insee"], @@ -989,7 +1169,7 @@ def test_search_services_with_bad_code_insee(api_client): response = api_client.get( url, params={ - "code_insee": "59999", # Does not exist + "code_commune": "59999", # Does not exist }, ) @@ -997,7 +1177,7 @@ def test_search_services_with_bad_code_insee(api_client): @pytest.mark.with_token -def test_search_services_with_code_insee_ordering(api_client): +def test_search_services_with_code_commune_ordering(api_client): service_1 = factories.ServiceFactory( commune="Hazebrouck", **HAZEBROUCK, @@ -1010,12 +1190,11 @@ def test_search_services_with_code_insee_ordering(api_client): ) factories.ServiceFactory( code_insee=None, - _di_geocodage_code_insee=None, modes_accueil=[schema.ModeAccueil.EN_PRESENTIEL.value], ) url = "/api/v0/search/services" - response = api_client.get(url, params={"code_insee": ROUBAIX["code_insee"]}) + response = api_client.get(url, params={"code_commune": ROUBAIX["code_insee"]}) assert response.status_code == 200 resp_data = response.json() @@ -1027,21 +1206,19 @@ def test_search_services_with_code_insee_ordering(api_client): @pytest.mark.with_token -def test_search_services_with_code_insee_sample_distance(api_client): +def test_search_services_with_code_commune_sample_distance(api_client): service_1 = factories.ServiceFactory( commune="Lille", - _di_geocodage_code_insee=None, **LILLE, modes_accueil=[schema.ModeAccueil.EN_PRESENTIEL.value], ) factories.ServiceFactory( code_insee=None, - _di_geocodage_code_insee=None, modes_accueil=[schema.ModeAccueil.EN_PRESENTIEL.value], ) url = "/api/v0/search/services" - response = api_client.get(url, params={"code_insee": HAZEBROUCK["code_insee"]}) + response = api_client.get(url, params={"code_commune": HAZEBROUCK["code_insee"]}) assert response.status_code == 200 resp_data = response.json() @@ -1051,22 +1228,20 @@ def test_search_services_with_code_insee_sample_distance(api_client): @pytest.mark.with_token -def test_search_services_with_code_insee_a_distance(api_client): +def test_search_services_with_code_commune_a_distance(api_client): service_1 = factories.ServiceFactory( commune="Dunkerque", code_insee=DUNKERQUE["code_insee"], - _di_geocodage_code_insee=None, modes_accueil=[schema.ModeAccueil.A_DISTANCE.value], ) service_2 = factories.ServiceFactory( commune="Maubeuge", code_insee=MAUBEUGE["code_insee"], - _di_geocodage_code_insee=None, modes_accueil=[schema.ModeAccueil.A_DISTANCE.value], ) url = "/api/v0/search/services" - response = api_client.get(url, params={"code_insee": DUNKERQUE["code_insee"]}) + response = api_client.get(url, params={"code_commune": DUNKERQUE["code_insee"]}) assert response.status_code == 200 resp_data = response.json() @@ -1077,173 +1252,6 @@ def test_search_services_with_code_insee_a_distance(api_client): assert resp_data["items"][1]["distance"] is None -@pytest.mark.parametrize( - "thematiques,input,found", - [ - ([], [schema.Thematique.FAMILLE.value], False), - ([schema.Thematique.FAMILLE.value], [schema.Thematique.FAMILLE.value], True), - ([schema.Thematique.NUMERIQUE.value], [schema.Thematique.FAMILLE.value], False), - ( - [schema.Thematique.NUMERIQUE.value, schema.Thematique.FAMILLE.value], - [schema.Thematique.FAMILLE.value], - True, - ), - ( - [schema.Thematique.SANTE.value, schema.Thematique.NUMERIQUE.value], - [schema.Thematique.FAMILLE.value, schema.Thematique.NUMERIQUE.value], - True, - ), - ( - [schema.Thematique.SANTE.value, schema.Thematique.NUMERIQUE.value], - [schema.Thematique.FAMILLE.value, schema.Thematique.NUMERIQUE.value], - True, - ), - ( - [schema.Thematique.FAMILLE__GARDE_DENFANTS.value], - [schema.Thematique.FAMILLE.value], - True, - ), - ], -) -@pytest.mark.with_token -def test_search_services_with_thematique(api_client, thematiques, input, found): - service = factories.ServiceFactory(thematiques=thematiques) - factories.ServiceFactory(thematiques=[schema.Thematique.MOBILITE.value]) - - url = "/api/v0/search/services" - response = api_client.get(url, params={"thematiques": input}) - - assert response.status_code == 200 - resp_data = response.json() - if found: - assert_paginated_response_data(resp_data, total=1) - assert resp_data["items"][0]["service"]["id"] in [service.id] - else: - assert_paginated_response_data(resp_data, total=0) - - -@pytest.mark.with_token -def test_search_services_with_frais(api_client): - service_1 = factories.ServiceFactory(frais=[schema.Frais.GRATUIT.value]) - service_2 = factories.ServiceFactory(frais=[schema.Frais.ADHESION.value]) - factories.ServiceFactory(frais=[schema.Frais.PASS_NUMERIQUE.value]) - - url = "/api/v0/search/services" - response = api_client.get( - url, - params={ - "frais": [ - schema.Frais.GRATUIT.value, - schema.Frais.ADHESION.value, - ], - }, - ) - - assert response.status_code == 200 - resp_data = response.json() - assert_paginated_response_data(resp_data, total=2) - assert resp_data["items"][0]["service"]["id"] in [service_1.id, service_2.id] - assert resp_data["items"][1]["service"]["id"] in [service_1.id, service_2.id] - - response = api_client.get( - url, - params={ - "frais": schema.Frais.PAYANT.value, - }, - ) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_search_services_with_types(api_client): - service_1 = factories.ServiceFactory(types=[schema.TypologieService.ACCUEIL.value]) - service_2 = factories.ServiceFactory( - types=[schema.TypologieService.ACCOMPAGNEMENT.value] - ) - factories.ServiceFactory(types=[schema.TypologieService.AIDE_FINANCIERE.value]) - - url = "/api/v0/search/services" - response = api_client.get( - url, - params={ - "types": [ - schema.TypologieService.ACCUEIL.value, - schema.TypologieService.ACCOMPAGNEMENT.value, - ], - }, - ) - - assert response.status_code == 200 - resp_data = response.json() - assert_paginated_response_data(resp_data, total=2) - assert resp_data["items"][0]["service"]["id"] in [service_1.id, service_2.id] - assert resp_data["items"][1]["service"]["id"] in [service_1.id, service_2.id] - - response = api_client.get( - url, - params={ - "types": schema.TypologieService.ATELIER.value, - }, - ) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_search_services_with_sources(api_client): - service_1 = factories.ServiceFactory(source="dora") - service_2 = factories.ServiceFactory(source="emplois-de-linclusion") - factories.ServiceFactory(source="un-jeune-une-solution") - - url = "/api/v0/search/services" - response = api_client.get( - url, - params={ - "sources": ["dora", "emplois-de-linclusion"], - }, - ) - - assert response.status_code == 200 - resp_data = response.json() - assert_paginated_response_data(resp_data, total=2) - assert resp_data["items"][0]["service"]["id"] in [service_1.id, service_2.id] - assert resp_data["items"][1]["service"]["id"] in [service_1.id, service_2.id] - - response = api_client.get( - url, - params={ - "sources": ["foobar"], - }, - ) - assert_paginated_response_data(response.json(), total=0) - - -@pytest.mark.with_token -def test_search_services_outdated(api_client): - service_1 = factories.ServiceFactory(date_suspension=None) - service_2 = factories.ServiceFactory(date_suspension=date.today()) - factories.ServiceFactory(date_suspension=date.today() - timedelta(days=1)) - - url = "/api/v0/search/services" - - # exclude outdated services by default - response = api_client.get(url) - - assert response.status_code == 200 - resp_data = response.json() - assert_paginated_response_data(resp_data, total=2) - assert {d["service"]["id"] for d in resp_data["items"]} == { - service_1.id, - service_2.id, - } - - # include outdated services with query parameter - response = api_client.get(url, params={"inclure_suspendus": True}) - - assert response.status_code == 200 - resp_data = response.json() - assert_paginated_response_data(resp_data, total=3) - - @pytest.mark.with_token def test_retrieve_service(api_client): service_1 = factories.ServiceFactory(source="foo", id="1") diff --git a/api/tests/factories.py b/api/tests/factories.py index 0d124c677..2f5cf1bd2 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -24,10 +24,6 @@ class Meta: sqlalchemy_session_persistence = "commit" _di_surrogate_id = factory.Faker("uuid4") - _di_geocodage_code_insee = factory.Faker("postcode") - _di_geocodage_score = factory.Faker( - "pyfloat", right_digits=2, positive=True, max_value=1 - ) id = factory.Faker("slug", locale="fr_FR") siret = factory.LazyFunction(lambda: fake.siret().replace(" ", "")) @@ -35,7 +31,7 @@ class Meta: nom = factory.Faker("company", locale="fr_FR") commune = factory.Faker("city", locale="fr_FR") code_postal = factory.Faker("postcode") - code_insee = factory.Faker("postcode") + code_insee = "59350" adresse = factory.Faker("street_address", locale="fr_FR") longitude = factory.Faker("longitude") latitude = factory.Faker("latitude") @@ -77,16 +73,15 @@ class Meta: sqlalchemy_session_persistence = "commit" _di_surrogate_id = factory.Faker("uuid4") - _di_structure_surrogate_id = factory.SelfAttribute("structure._di_surrogate_id") - _di_geocodage_code_insee = factory.SelfAttribute("code_insee") - _di_geocodage_score = factory.Faker( - "pyfloat", right_digits=2, positive=True, max_value=1 - ) - structure = factory.SubFactory(StructureFactory) + structure = factory.SubFactory( + StructureFactory, + source=factory.SelfAttribute("..source"), + code_insee=factory.SelfAttribute("..code_insee"), + ) id = factory.Faker("slug", locale="fr_FR") structure_id = factory.SelfAttribute("structure.id") - source = factory.SelfAttribute("structure.source") + source = factory.Iterator(["dora", "emplois-de-linclusion"]) nom = factory.Faker("company", locale="fr_FR") presentation_resume = factory.Faker("text", max_nb_chars=20, locale="fr_FR") presentation_detail = factory.Faker("text", max_nb_chars=20, locale="fr_FR")