departements

This commit is contained in:
el 2025-07-01 18:25:10 +02:00
parent 6b53a419c9
commit 4c180fe1f8
19 changed files with 21999 additions and 431 deletions

View file

@ -1,197 +1,225 @@
# backend/services/france_travail_offer_service.py
import httpx
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any, Union
import time
from typing import Optional, Dict, Any, List, Tuple
import asyncio
from core.config import settings
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail, Offre, TypeContrat
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail
logger = logging.getLogger(__name__)
class FranceTravailOfferService:
def __init__(self):
self.client_id = settings.FRANCE_TRAVAIL_CLIENT_ID
self.client_secret = settings.FRANCE_TRAVAIL_CLIENT_SECRET
self.token_url = settings.FRANCE_TRAVAIL_TOKEN_URL
self.api_base_url = settings.FRANCE_TRAVAIL_API_BASE_URL
self.api_scope = settings.FRANCE_TRAVAIL_API_SCOPE
self.access_token = None
self.token_expires_at = None
self.client = httpx.AsyncClient(base_url=settings.FRANCE_TRAVAIL_API_BASE_URL)
self.auth_client = httpx.AsyncClient(base_url=settings.FRANCE_TRAVAIL_TOKEN_URL.split('?')[0])
self.token_info = {"token": None, "expires_at": 0}
self.geo_api_client = httpx.AsyncClient() # Client for geo.api.gouv.fr
async def _get_access_token(self):
if self.access_token and self.token_expires_at and datetime.now() < self.token_expires_at:
logger.info("Réutilisation du token France Travail existant.")
return self.access_token
if self.token_info["token"] and self.token_info["expires_at"] > time.time() + 60: # Refresh 1 min before expiry
logger.info("Utilisation du token France Travail depuis le cache.")
return self.token_info["token"]
logger.info("Obtention d'un nouveau token d'accès France Travail...")
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": self.api_scope
}
logger.info("Obtention d'un nouveau token France Travail...")
try:
token_url_with_realm = settings.FRANCE_TRAVAIL_TOKEN_URL
response = await self.auth_client.post(
token_url_with_realm,
data={
"grant_type": "client_credentials",
"client_id": settings.FRANCE_TRAVAIL_CLIENT_ID,
"client_secret": settings.FRANCE_TRAVAIL_CLIENT_SECRET,
"scope": "o2dsoffre api_offresdemploiv2"
}
)
response.raise_for_status()
token_data = response.json()
self.token_info["token"] = token_data["access_token"]
self.token_info["expires_at"] = time.time() + token_data["expires_in"]
logger.info("Nouveau token France Travail obtenu et mis en cache.")
return self.token_info["token"]
except httpx.HTTPStatusError as e:
logger.error(f"Échec de l'obtention du token France Travail: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de l'obtention du token France Travail: {e.response.text}")
except httpx.RequestError as e:
logger.error(f"Erreur réseau lors de l'obtention du token France Travail: {e}")
raise RuntimeError(f"Erreur réseau lors de l'obtention du token France Travail: {e}")
async with httpx.AsyncClient() as client:
try:
response = await client.post(self.token_url, headers=headers, data=data)
async def get_insee_and_postal_code_for_commune(self, commune_name: str) -> Optional[Tuple[str, str, float, float]]:
logger.info(f"Début de la recherche Geo API pour: '{commune_name}'")
try:
# First, try exact match by name
geo_url_by_name = f"https://geo.api.gouv.fr/communes?nom={commune_name}&fields=codesPostaux,code,nom,centre&format=json&limit=5"
logger.info(f"Recherche par nom via API Geo.gouv.fr: {geo_url_by_name}")
response = await self.geo_api_client.get(geo_url_by_name)
response.raise_for_status()
communes = response.json()
for commune in communes:
# Prioritize exact name match
if commune['nom'].lower() == commune_name.lower():
insee_code = commune['code']
postal_code = commune['codesPostaux'][0] if commune['codesPostaux'] else None
latitude = commune['centre']['coordinates'][1]
longitude = commune['centre']['coordinates'][0]
logger.info(f"Correspondance exacte par nom trouvée: INSEE='{insee_code}', CP='{postal_code}', Lat='{latitude}', Long='{longitude}' pour '{commune_name}'.")
return insee_code, postal_code, latitude, longitude
# If no exact name match, try the first result if available
if communes:
commune = communes[0]
insee_code = commune['code']
postal_code = commune['codesPostaux'][0] if commune['codesPostaux'] else None
latitude = commune['centre']['coordinates'][1]
longitude = commune['centre']['coordinates'][0]
logger.info(f"Aucune correspondance exacte par nom, première commune trouvée: INSEE='{insee_code}', CP='{postal_code}', Lat='{latitude}', Long='{longitude}' pour '{commune_name}'.")
return insee_code, postal_code, latitude, longitude
# If not found by name, try by postal code if commune_name looks like a postal code
if commune_name.isdigit() and len(commune_name) == 5:
geo_url_by_cp = f"https://geo.api.gouv.fr/communes?codePostal={commune_name}&fields=codesPostaux,code,nom,centre&format=json&limit=1"
logger.info(f"Recherche par code postal via API Geo.gouv.fr: {geo_url_by_cp}")
response = await self.geo_api_client.get(geo_url_by_cp)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 1500)
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in - 60)
communes_by_cp = response.json()
if communes_by_cp:
commune = communes_by_cp[0]
insee_code = commune['code']
postal_code = commune['codesPostaux'][0] if commune['codesPostaux'] else None
latitude = commune['centre']['coordinates'][1]
longitude = commune['centre']['coordinates'][0]
logger.info(f"Correspondance par code postal trouvée: INSEE='{insee_code}', CP='{postal_code}', Lat='{latitude}', Long='{longitude}' pour code postal '{commune_name}'.")
return insee_code, postal_code, latitude, longitude
logger.info("Token France Travail obtenu avec succès.")
return self.access_token
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de l'obtention du token France Travail: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de l'obtention du token France Travail: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de l'obtention du token France Travail: {e}")
raise RuntimeError(f"Échec inattendu lors de l'obtention du token France Travail: {e}")
logger.warning(f"Aucune correspondance trouvée pour la commune/code postal: '{commune_name}' dans l'API Geo.gouv.fr.")
return None
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de l'appel à l'API Geo.gouv.fr pour '{commune_name}': {e.response.status_code} - {e.response.text}")
return None
except httpx.RequestError as e:
logger.error(f"Erreur réseau lors de l'appel à l'API Geo.gouv.fr pour '{commune_name}': {e}")
return None
async def get_insee_code_for_commune(self, commune_name: str) -> Optional[str]:
"""
Récupère le code INSEE d'une commune à partir de son nom.
Recherche une correspondance exacte du libellé, ou un code spécifique pour Paris.
"""
async def search_offers(
self,
motsCles: Optional[str] = None,
commune: Optional[str] = None, # Reste le nom de la commune
codePostal: Optional[str] = None,
latitude: Optional[float] = None,
longitude: Optional[float] = None,
distance: Optional[int] = None,
# codeDepartement: Optional[str] = None, # Ce paramètre sera maintenant dérivé en interne
range_start: int = 0,
range_end: int = 14,
typeContrat: Optional[List[str]] = None,
experience: Optional[List[str]] = None
) -> FranceTravailSearchResponse:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
params = {
"q": commune_name
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{self.api_base_url}/v2/referentiel/communes",
headers=headers,
params=params
)
response.raise_for_status()
communes_data = response.json()
found_code = None
normalized_input_name = commune_name.upper().strip()
if communes_data and isinstance(communes_data, list):
for commune_info in communes_data:
if commune_info and "code" in commune_info and "libelle" in commune_info:
normalized_libelle = commune_info["libelle"].upper().strip()
# Priorité 1: Recherche spécifique pour "PARIS" avec son code INSEE connu
if normalized_input_name == "PARIS" and commune_info["code"] == "75056":
found_code = commune_info["code"]
break
# Priorité 2: Correspondance exacte du libellé
elif normalized_libelle == normalized_input_name:
found_code = commune_info["code"]
break
# Priorité 3: Si c'est Paris, mais le libellé renvoyé n'est pas "PARIS" exactement,
# mais le code est le bon, on le prend quand même.
# Ceci peut arriver si l'API renvoie "Paris 01" par exemple.
elif normalized_input_name == "PARIS" and commune_info["code"] in ["75056", "75101", "75102", "75103", "75104", "75105", "75106", "75107", "75108", "75109", "75110", "75111", "75112", "75113", "75114", "75115", "75116", "75117", "75118", "75119", "75120"]:
# Note: Les codes 75101 à 75120 sont pour les arrondissements, mais l'API
# France Travail utilise souvent le 75056 pour "Paris" globalement.
# Cette condition est plus une sécurité, mais 75056 est la cible principale.
if commune_info["code"] == "75056": # On préfère le code global de Paris
found_code = commune_info["code"]
break
elif found_code is None: # Si on n'a pas encore trouvé 75056, on prend un arrondissement
found_code = commune_info["code"] # Conserver le code d'arrondissement si c'est le seul "Paris" trouvé
# Note: La logique ici est à affiner selon si vous voulez les arrondissements ou seulement le code global.
# Pour la plupart des cas, "75056" est suffisant.
if found_code:
logger.info(f"Code INSEE pour '{commune_name}' trouvé : {found_code}")
return found_code
logger.warning(f"Aucun code INSEE exact trouvé pour la commune '{commune_name}' parmi les résultats de l'API. Vérifiez l'orthographe.")
return None
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de la récupération du code INSEE pour '{commune_name}': {e.response.status_code} - {e.response.text}")
return None
except Exception as e:
logger.error(f"Erreur inattendue lors de la récupération du code INSEE pour '{commune_name}': {e}")
return None
async def search_offers(self,
motsCles: Optional[str] = None,
typeContrat: Optional[str] = None,
codePostal: Optional[str] = None,
commune: Optional[str] = None,
distance: Optional[int] = None,
alternance: Optional[bool] = None,
offresManagerees: Optional[bool] = None,
range: str = "0-14") -> FranceTravailSearchResponse:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
params = {
"range": range,
params: Dict[str, Any] = {
"range": f"{range_start}-{range_end}"
}
if motsCles:
params["motsCles"] = motsCles
if typeContrat:
params["typeContrat"] = typeContrat
if alternance is not None:
params["alternance"] = str(alternance).lower()
if offresManagerees is not None:
params["offresManagerees"] = str(offresManagerees).lower()
params["typeContrat"] = ','.join(typeContrat)
if experience:
params["experience"] = ','.join(experience)
if codePostal:
# Logique de localisation améliorée
# insee_code = None # Non utilisé directement comme paramètre pour l'API France Travail
# postal_code_for_api = None # Non utilisé directement comme paramètre pour l'API France Travail
# latitude_for_api = None # Non utilisé directement comme paramètre pour l'API France Travail
# longitude_for_api = None # Non utilisé directement comme paramètre pour l'API France Travail
# Le codeDepartement sera déterminé ici si une commune est fournie
derived_departement_code = None
if commune:
logger.info(f"Traitement de la commune spécifiée: '{commune}' pour dériver le département.")
geo_data = await self.get_insee_and_postal_code_for_commune(commune)
if geo_data:
insee_code, postal_code_from_geo, latitude_from_geo, longitude_from_geo = geo_data
# Dériver le code départemental du code postal ou INSEE
if postal_code_from_geo and len(postal_code_from_geo) >= 2:
derived_departement_code = postal_code_from_geo[:2]
# Cas spécifiques pour la Corse
if postal_code_from_geo.startswith('2A'):
derived_departement_code = '2A'
elif postal_code_from_geo.startswith('2B'):
derived_departement_code = '2B'
logger.info(f"Département dérivé de '{commune}': {derived_departement_code}")
# Si un département est dérivé, l'utiliser prioritairement
if derived_departement_code:
params["departement"] = derived_departement_code
logger.info(f"Paramètre 'departement' utilisé (dérivé de la commune): {derived_departement_code}")
elif latitude_from_geo and longitude_from_geo and distance is not None:
# Fallback sur latitude/longitude si la dérivation du département échoue
params["latitude"] = latitude_from_geo
params["longitude"] = longitude_from_geo
params["distance"] = distance
logger.info(f"Paramètres de localisation utilisés (dérivés de la commune): Latitude/Longitude et Distance: Lat={latitude_from_geo}, Long={longitude_from_geo}, Dist={distance}")
else:
logger.warning(f"Impossible de dériver le département ou d'obtenir des coordonnées valides pour la commune '{commune}'. Recherche sans localisation précise.")
else:
logger.warning(f"Impossible d'obtenir les données géographiques pour la commune '{commune}'. Recherche sans localisation précise.")
elif codePostal:
params["codePostal"] = codePostal
if distance is not None:
params["distance"] = distance
else:
params["distance"] = 10
elif commune:
params["commune"] = commune
logger.info(f"Paramètres de localisation utilisés: Code Postal={codePostal}, Distance={distance}")
elif latitude is not None and longitude is not None:
params["latitude"] = latitude
params["longitude"] = longitude
if distance is not None:
params["distance"] = distance
logger.info(f"Paramètres de localisation utilisés: Latitude/Longitude: Lat={latitude}, Long={longitude}, Dist={distance}")
else:
logger.warning("Aucun paramètre de localisation valide (commune, code postal, lat/long) n'a été spécifié. La recherche sera nationale.")
logger.info(f"Appel à l'API France Travail pour search_offers avec paramètres FINAUX: {params}")
try:
response = await self.client.get("/v2/offres/search", headers=headers, params=params)
response.raise_for_status()
logger.info(f"Réponse brute de l'API France Travail (search_offers): {response.json()}")
return FranceTravailSearchResponse(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Échec de la recherche d'offres France Travail: {e.response.status_code} - {e.response.text}")
if e.response.status_code == 400 and "incorrect value" in e.response.text.lower() and "commune" in e.response.text.lower():
raise RuntimeError(f"L'API France Travail a renvoyé une erreur 400: La valeur du paramètre 'commune' est incorrecte. Veuillez vérifier le code INSEE ou envisager une recherche par département ou latitude/longitude.")
else:
params["distance"] = 10
logger.info(f"Paramètres de recherche France Travail: {params}")
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{self.api_base_url}/v2/offres/search", headers=headers, params=params)
response.raise_for_status()
return FranceTravailSearchResponse(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de la recherche d'offres France Travail: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de la recherche d'offres France Travail: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de la recherche d'offres France Travail: {e}")
raise RuntimeError(f"Échec inattendu lors de la recherche d'offres France Travail: {e}")
except httpx.RequestError as e:
logger.error(f"Erreur réseau lors de la recherche d'offres France Travail: {e}")
raise RuntimeError(f"Erreur réseau lors de la recherche d'offres France Travail: {e}")
async def get_offer_details(self, offer_id: str) -> OffreDetail:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
async with httpx.AsyncClient() as client:
try:
response = await client.get(f"{self.api_base_url}/v2/offres/{offer_id}", headers=headers)
response.raise_for_status()
return OffreDetail(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Erreur HTTP lors de la récupération des détails de l'offre {offer_id}: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de la récupération des détails de l'offre {offer_id}: {e.response.text}")
except Exception as e:
logger.error(f"Erreur inattendue lors de la récupération des détails de l'offre {offer_id}: {e}")
raise RuntimeError(f"Échec inattendu lors de la récupération des détails de l'offre {offer_id}: {e}")
logger.info(f"Appel à l'API France Travail pour get_offer_details avec id: {offer_id}")
try:
response = await self.client.get(f"/v2/offres/{offer_id}", headers=headers)
response.raise_for_status()
logger.info(f"Réponse brute de l'API France Travail (get_offer_details): {response.json()}")
return OffreDetail(**response.json())
except httpx.HTTPStatusError as e:
logger.error(f"Échec de la récupération des détails de l'offre {offer_id}: {e.response.status_code} - {e.response.text}")
raise RuntimeError(f"Échec de la récupération des détails de l'offre {offer_id}: {e.response.text}")
except httpx.RequestError as e:
logger.error(f"Erreur réseau lors de la récupération des détails de l'offre {offer_id}: {e}")
raise RuntimeError(f"Erreur réseau lors de la récupération des détails de l'offre {offer_id}: {e}")
france_travail_offer_service = FranceTravailOfferService()