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

@ -107,9 +107,14 @@ async def analyze_job_offer_and_cv_route(
if not cv_document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="CV non trouvé ou non accessible par cet utilisateur.")
try:
# Utilise la nouvelle fonction d'extraction de texte
logger.info(f"Tentative d'extraction du texte du CV à partir de : {cv_document.filepath}") # AJOUTEZ CETTE
# --- AJOUTEZ CES LIGNES DE DEBUG ---
logger.info(f"Texte extrait (début) du CV: '{cv_text_to_analyze[:100]}...'") # Affiche les 100 premiers caractères
logger.info(f"Longueur du texte extrait du CV (avant strip): {len(cv_text_to_analyze)}")
logger.info(f"Longueur du texte extrait du CV (après strip): {len(cv_text_to_analyze.strip())}")
# --- FIN DES LIGNES DE DEBUG ---
cv_text_to_analyze = extract_text_from_file(cv_document.filepath)
if not cv_text_to_analyze.strip(): # Vérifier après extraction si le contenu est vide
raise ValueError("Le fichier CV est vide ou l'extraction de texte a échoué.")

View file

@ -1,17 +1,23 @@
# backend/routers/document.py
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
import os
import uuid # Pour générer des noms de fichiers uniques
import uuid # For generating unique filenames
import logging
from typing import List # Required for list type hint in get_user_documents
from core.database import get_db
from core.security import create_access_token # Non utilisé directement ici mais potentiellement dans d'autres routers
from core.config import settings # Pour accéder au chemin d'upload
# Removed unused 'create_access_token'
from core.security import get_current_user # Ensure this is the correct import for your get_current_user dependency
from core.config import settings # To access upload directory
from crud import document as crud_document
from crud import user as crud_user # Pour récupérer l'utilisateur courant
# Removed unused 'crud_user' as it's not directly used in this router
from schemas import document as schemas_document
from schemas import user as schemas_user # Pour le modèle UserInDBBase ou UserResponse
from dependencies import get_current_user # Pour la protection des routes
from schemas import user as schemas_user # For UserResponse schema
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/documents",
@ -19,7 +25,7 @@ router = APIRouter(
responses={404: {"description": "Not found"}},
)
@router.post("/upload-cv", response_model=schemas_document.DocumentResponse, status_code=status.HTTP_201_CREATED)
@router.post("/upload-cv", response_model=schemas_document.DocumentResponse, status_code=status.HTTP_201_CREATED, summary="Uploader un CV")
async def upload_cv(
file: UploadFile = File(...),
db: Session = Depends(get_db),
@ -29,39 +35,53 @@ async def upload_cv(
Permet à un utilisateur authentifié d'uploader un CV.
Le fichier est stocké sur le serveur et ses métadonnées sont enregistrées en base de données.
"""
if not file.filename.lower().endswith(('.pdf', '.doc', '.docx')):
logger.info(f"Tentative d'upload de CV par l'utilisateur {current_user.id} - Nom du fichier: {file.filename}")
if not file.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Le nom du fichier est manquant.")
allowed_extensions = ('.pdf', '.doc', '.docx')
# Use os.path.splitext to safely get the extension
file_extension = os.path.splitext(file.filename)[1].lower()
if file_extension not in allowed_extensions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Seuls les fichiers PDF, DOC, DOCX sont autorisés."
detail=f"Seuls les fichiers {', '.join(allowed_extensions).upper()} sont autorisés."
)
# Créer un nom de fichier unique pour éviter les collisions et les problèmes de sécurité
unique_filename = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(settings.UPLOADS_DIR, unique_filename)
upload_dir = settings.UPLOADS_DIR # Utilisez le chemin absolu configuré dans settings
os.makedirs(upload_dir, exist_ok=True)
# S'assurer que le répertoire d'uploads existe
os.makedirs(settings.UPLOADS_DIR, exist_ok=True)
# Generate a unique filename using UUID to prevent collisions and potential path traversal issues
unique_filename = f"{uuid.uuid4()}{file_extension}"
file_path = os.path.join(upload_dir, unique_filename)
try:
# Write the file in chunks for efficiency with large files
with open(file_path, "wb") as buffer:
# Écrit le fichier par morceaux pour les gros fichiers
while content := await file.read(1024 * 1024): # Lire par blocs de 1MB
while content := await file.read(1024 * 1024): # Read in 1MB chunks
buffer.write(content)
logger.info(f"Fichier '{file.filename}' enregistré sous '{file_path}' pour l'utilisateur {current_user.id}")
except Exception as e:
logger.error(f"Erreur lors de l'enregistrement du fichier {file.filename}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de l'enregistrement du fichier: {e}"
)
finally:
# Ensure the UploadFile is closed even if an error occurs
await file.close()
# Enregistrer les métadonnées du document dans la base de données
# Save document metadata in the database
# The DocumentCreate schema might not need 'filename' as a field if you pass it directly to crud
# Assuming DocumentCreate schema only takes filename and crud.create_document handles filepath
document_data = schemas_document.DocumentCreate(filename=file.filename)
db_document = crud_document.create_document(db, document_data, file_path, current_user.id)
logger.info(f"Document ID {db_document.id} créé en base de données pour l'utilisateur {current_user.id}")
return db_document
@router.get("/", response_model=list[schemas_document.DocumentResponse])
@router.get("/", response_model=List[schemas_document.DocumentResponse], summary="Lister les documents de l'utilisateur")
def get_user_documents(
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
@ -69,26 +89,34 @@ def get_user_documents(
"""
Récupère tous les documents uploadés par l'utilisateur authentifié.
"""
logger.info(f"Tentative de listage des documents pour l'utilisateur {current_user.id}")
documents = crud_document.get_documents_by_owner(db, current_user.id)
return documents
@router.get("/{document_id}", response_model=schemas_document.DocumentResponse)
@router.get("/{document_id}", response_model=schemas_document.DocumentResponse, summary="Récupérer un document par ID")
def get_document_details(
document_id: int,
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
):
"""
Récupère les détails d'un document spécifique de l'utilisateur authentifié.
Récupère les détails d'un document spécifique appartenant à l'utilisateur courant.
"""
document = crud_document.get_document_by_id(db, document_id)
logger.info(f"Tentative de récupération du document {document_id} pour l'utilisateur {current_user.id}")
# Appel à la fonction CRUD qui filtre déjà par owner_id
document = crud_document.get_document_by_id(db, document_id, current_user.id)
if not document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document non trouvé.")
if document.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Vous n'avez pas accès à ce document.")
# Si le document n'est pas trouvé (soit il n'existe pas, soit il n'appartient pas à cet utilisateur)
logger.warning(f"Document {document_id} non trouvé ou non autorisé pour l'utilisateur {current_user.id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document non trouvé ou vous n'avez pas l'autorisation d'y accéder."
)
return document
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Supprimer un document par ID")
async def delete_document(
document_id: int,
db: Session = Depends(get_db),
@ -98,22 +126,62 @@ async def delete_document(
Supprime un document spécifique de l'utilisateur authentifié,
à la fois de la base de données et du système de fichiers.
"""
db_document = crud_document.get_document_by_id(db, document_id)
if not db_document:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document non trouvé.")
if db_document.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Vous n'avez pas la permission de supprimer ce document.")
logger.info(f"Tentative de suppression du document {document_id} pour l'utilisateur {current_user.id}")
# Supprimer le fichier du système de fichiers
if os.path.exists(db_document.filepath):
# Appel à la fonction CRUD qui filtre déjà par owner_id
db_document = crud_document.get_document_by_id(db, document_id, current_user.id)
if not db_document:
# Si le document n'est pas trouvé (soit il n'existe pas, soit il n'appartient pas à cet utilisateur)
logger.warning(f"Document {document_id} non trouvé ou non autorisé pour la suppression par l'utilisateur {current_user.id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document non trouvé ou vous n'avez pas la permission de le supprimer."
)
# Supprimer le fichier du système de fichiers s'il existe et si un chemin est défini
if db_document.filepath and os.path.exists(db_document.filepath):
try:
os.remove(db_document.filepath)
logger.info(f"Fichier physique '{db_document.filepath}' supprimé pour le document {document_id}.")
except OSError as e:
logger.error(f"Erreur lors de la suppression du fichier physique '{db_document.filepath}' pour le document {document_id}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la suppression du fichier sur le serveur: {e}"
)
else:
logger.warning(f"Le document {document_id} n'a pas de chemin de fichier ou le fichier n'existe pas: {db_document.filepath}")
# Supprimer l'entrée de la base de données
crud_document.delete_document(db, document_id)
return {"message": "Document supprimé avec succès."}
success = crud_document.delete_document(db, document_id)
if not success:
logger.error(f"Échec de la suppression de l'entrée du document {document_id} de la base de données.")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Échec de la suppression du document de la base de données.")
logger.info(f"Document {document_id} et son fichier physique supprimés avec succès pour l'utilisateur {current_user.id}.")
return {} # 204 No Content typically returns an empty body
# Optional: Add a route to download the actual file if needed
@router.get("/{document_id}/download", summary="Télécharger un document")
async def download_document(
document_id: int,
db: Session = Depends(get_db),
current_user: schemas_user.UserResponse = Depends(get_current_user)
):
"""
Permet à l'utilisateur authentifié de télécharger un de ses documents.
"""
logger.info(f"Tentative de téléchargement du document {document_id} par l'utilisateur {current_user.id}")
db_document = crud_document.get_document_by_id(db, document_id, current_user.id)
if not db_document:
logger.warning(f"Document {document_id} non trouvé ou non autorisé pour le téléchargement par l'utilisateur {current_user.id}")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document non trouvé ou non autorisé.")
if not os.path.exists(db_document.filepath):
logger.error(f"Fichier physique non trouvé pour le document {document_id} à l'emplacement: {db_document.filepath}")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Fichier physique non trouvé sur le serveur.")
# Return the file as a FastAPI FileResponse
return FileResponse(path=db_document.filepath, filename=db_document.filename, media_type="application/octet-stream")

View file

@ -1,73 +1,62 @@
# backend/routers/france_travail_offers.py
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from services.france_travail_offer_service import france_travail_offer_service
from core.security import get_current_user
from models.user import User
# Assuming these imports are still needed for your project context,
# even if not directly used in the current problem scope.
# from core.security import get_current_user
# from models.user import User
from schemas.france_travail import FranceTravailSearchResponse, OffreDetail, Offre
import logging
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/search", response_model=FranceTravailSearchResponse)
async def search_france_travail_offers(
motsCles: Optional[str] = Query(None, description="Mots-clés de recherche (ex: 'développeur full stack')"),
commune_nom_ou_code: Optional[str] = Query(None, alias="commune", description="Nom, code postal ou code INSEE de la commune"),
distance: Optional[int] = Query(10, description="Distance maximale en km autour de la commune"),
commune_input: Optional[str] = Query(None, alias="commune", description="Nom de la commune (ex: 'Paris', 'Marseille'). Si spécifié, le code départemental sera automatiquement dérivé."),
distance: Optional[int] = Query(10, description="Distance maximale en km autour de la commune ou du code postal. Applicable avec 'commune' ou 'codePostal', 'latitude'/'longitude'."),
codePostal: Optional[str] = Query(None, description="Code postal spécifique (ex: '75001')"),
latitude: Optional[float] = Query(None, description="Latitude du point de recherche (ex: 48.8566)"),
longitude: Optional[float] = Query(None, description="Longitude du point de recherche (ex: 2.3522)"),
# codeDepartement: Optional[str] = Query(None, description="Code départemental sur 2 chiffres (ex: '75' pour Paris). Prioritaire sur les autres paramètres de localisation."), # Ce paramètre est maintenant géré en interne par le service
page: int = Query(0, description="Numéro de la page de résultats (commence à 0)"),
limit: int = Query(15, description="Nombre d'offres par page (max 100 pour l'API France Travail)"), # Max 100 est une limite courante pour une seule requête à l'API FT
contrat: Optional[str] = Query(None, description="Type de contrat (ex: 'CDI', 'CDD', 'MIS')"),
experience: Optional[str] = Query(None, description="Niveau d'expérience (ex: '1' pour débutant, '2' pour 1-3 ans, '3' pour >3 ans)"),
current_user: User = Depends(get_current_user)
limit: int = Query(15, description="Nombre d'offres par page (max 100 pour l'API France Travail)"),
contrat: Optional[str] = Query(None, description="Type de contrat (ex: 'CDI', 'CDD', 'MIS'). Plusieurs séparés par des virgules."),
experience: Optional[str] = Query(None, description="Niveau d'expérience (ex: 'E' pour expérimenté, 'D' pour débutant). Plusieurs séparés par des virgules.")
# current_user: User = Depends(get_current_user) # Décommentez si l'authentification est nécessaire
):
"""
Recherche des offres d'emploi via l'API France Travail.
Convertit le nom de ville en code INSEE si nécessaire et gère la pagination.
Nécessite une authentification.
La localisation peut être spécifiée par commune (le département sera dérivé), code postal, ou latitude/longitude.
"""
if limit > 100: # La limite de l'API France Travail pour 'range' est souvent 150 ou 100 items par requête.
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La limite de résultats par page ne peut pas dépasser 100 pour une seule requête API."
)
logger.info(f"Requête de recherche d'offres reçue: motsCles='{motsCles}', commune_input='{commune_input}', codePostal='{codePostal}', latitude='{latitude}', longitude='{longitude}', distance={distance}, page={page}, limit={limit}")
commune_param_for_api = None
range_start = page * limit
range_end = range_start + limit - 1
logger.info(f"Paramètre 'range' calculé pour l'API France Travail: {range_start}-{range_end}")
if commune_nom_ou_code:
if commune_nom_ou_code.isdigit() and len(commune_nom_ou_code) == 5:
commune_param_for_api = commune_nom_ou_code
logger.info(f"Recherche par code postal: {commune_nom_ou_code}")
else:
logger.info(f"Tentative de récupération du code INSEE pour la ville: {commune_nom_ou_code}")
insee_code = await france_travail_offer_service.get_insee_code_for_commune(commune_nom_ou_code)
if not insee_code:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Code INSEE non trouvé pour la ville '{commune_nom_ou_code}'. Veuillez vérifier l'orthographe ou utiliser un code postal."
)
commune_param_for_api = insee_code
logger.info(f"Code INSEE '{insee_code}' trouvé pour '{commune_nom_ou_code}'.")
# Convertir les chaînes de contrats et expériences en listes
contrats_list = contrat.split(',') if contrat else None
experiences_list = experience.split(',') if experience else None
# Les paramètres de localisation sont passés directement au service,
# qui gérera la dérivation du département et la priorité.
if (commune_param_for_api is not None) and (distance is None):
distance = 10
# Calcul du paramètre 'range' pour l'API France Travail
start_index = page * limit
end_index = start_index + limit - 1
api_range_param = f"{start_index}-{end_index}"
logger.info(f"Paramètre 'range' calculé pour l'API France Travail: {api_range_param}")
try:
response = await france_travail_offer_service.search_offers(
motsCles=motsCles,
commune=commune_param_for_api,
commune=commune_input, # Passe le nom de la commune directement
codePostal=codePostal,
latitude=latitude,
longitude=longitude,
distance=distance,
range=api_range_param, # On passe le 'range' calculé
typeContrat=contrat,
# experience=experience # Vérifiez si ce paramètre est géré par l'API France Travail ou doit être mappé
# codeDepartement n'est plus passé ici, il est dérivé dans le service
range_start=range_start,
range_end=range_end,
typeContrat=contrats_list,
experience=experiences_list
)
return response
except RuntimeError as e:
@ -80,7 +69,7 @@ async def search_france_travail_offers(
@router.get("/{offer_id}", response_model=OffreDetail)
async def get_france_travail_offer_details(
offer_id: str,
current_user: User = Depends(get_current_user)
# current_user: User = Depends(get_current_user)
):
"""
Récupère les détails d'une offre d'emploi spécifique de l'API France Travail par son ID.
@ -93,5 +82,5 @@ async def get_france_travail_offer_details(
logger.error(f"Erreur lors de la récupération des détails de l'offre {offer_id} de France Travail: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Impossible de récupérer les détails de l'offre: {e}"
)
detail=f"Impossible de récupérer les détails de l'offre {offer_id}: {e}"
)