This commit is contained in:
el 2025-06-24 18:17:53 +02:00
commit d7666f7b2c
44 changed files with 2246 additions and 0 deletions

View file

@ -0,0 +1,197 @@
# backend/services/france_travail_offer_service.py
import httpx
import logging
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any, Union
from core.config import settings
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail, Offre, TypeContrat
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
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
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
}
async with httpx.AsyncClient() as client:
try:
response = await client.post(self.token_url, headers=headers, data=data)
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)
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}")
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.
"""
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
params = {
"q": commune_name
}
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,
}
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()
if codePostal:
params["codePostal"] = codePostal
if distance is not None:
params["distance"] = distance
else:
params["distance"] = 10
elif commune:
params["commune"] = commune
if distance is not None:
params["distance"] = distance
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}")
async def get_offer_details(self, offer_id: str) -> OffreDetail:
token = await self._get_access_token()
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}"
}
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}")
france_travail_offer_service = FranceTravailOfferService()