departements
This commit is contained in:
parent
6b53a419c9
commit
4c180fe1f8
19 changed files with 21999 additions and 431 deletions
|
@ -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()
|
Loading…
Add table
Add a link
Reference in a new issue