Spaces:
Sleeping
Sleeping
Commit ·
8629ddc
1
Parent(s): d57f194
ajout du module generation de fiche de renseignement
Browse files- app/models/atre_models.py +79 -0
- app/routers/admission.py +67 -1
- app/routers/atre_routes.py +167 -0
- app/services/atre_pdf_service.py +433 -0
- app/services/pdf_generator_service.py +432 -0
- app/templates/CD_ENTRETIEN_GUIDE.md +88 -0
- app/templates/GUIDE_TEMPLATE.md +166 -0
- app/templates/entretien.docx +0 -0
- app/templates/template.docx +0 -0
- requirements.txt +3 -2
app/models/atre_models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Modèles Pydantic pour les données de la Fiche de Détection ATRE
|
| 3 |
+
(Atelier Technique de Recherche d'Emploi)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from typing import List, Optional
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PersonalInfo(BaseModel):
|
| 11 |
+
"""Informations personnelles du candidat"""
|
| 12 |
+
nom: str
|
| 13 |
+
prenom: str
|
| 14 |
+
date_naissance: str
|
| 15 |
+
niveau_etudes: str # Bac, Bac+1, Bac+2, Bac+3, Bac+4, Bac+5
|
| 16 |
+
formation_souhaitee: str
|
| 17 |
+
email: str
|
| 18 |
+
telephone: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MotivationObjectifs(BaseModel):
|
| 22 |
+
"""Motivation et objectifs du candidat"""
|
| 23 |
+
motivation_apprentissage: str # Pourquoi souhaitez-vous poursuivre un apprentissage ? (200 mots max)
|
| 24 |
+
objectifs_professionnels: str # Objectifs à court et long termes (200 mots max)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ExperienceCompetences(BaseModel):
|
| 28 |
+
"""Compétences et expériences"""
|
| 29 |
+
a_experience: bool # Oui/Non
|
| 30 |
+
description_experience: Optional[str] = None # Si oui, description
|
| 31 |
+
competences_particulieres: Optional[str] = None # Langues, informatique, etc.
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class BesoinsAccompagnement(BaseModel):
|
| 35 |
+
"""Besoins en accompagnement"""
|
| 36 |
+
# Compétences à développer (checkboxes)
|
| 37 |
+
techniques_cv: bool = False
|
| 38 |
+
techniques_entretien: bool = False
|
| 39 |
+
strategies_recherche_emploi: bool = False
|
| 40 |
+
competences_specifiques: bool = False
|
| 41 |
+
competences_specifiques_detail: Optional[str] = None
|
| 42 |
+
competences_interpersonnelles: bool = False
|
| 43 |
+
|
| 44 |
+
# Défis anticipés
|
| 45 |
+
defis_anticipes: Optional[str] = None
|
| 46 |
+
|
| 47 |
+
# Méthodes de soutien préférées
|
| 48 |
+
ateliers_groupe: bool = False
|
| 49 |
+
ressources_en_ligne: bool = False
|
| 50 |
+
seances_individuelles: bool = False
|
| 51 |
+
autres_methodes: bool = False
|
| 52 |
+
autres_methodes_detail: Optional[str] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class Consentement(BaseModel):
|
| 56 |
+
"""Consentement et validation"""
|
| 57 |
+
accepte_conseils: bool # Oui/Non pour recevoir des conseils par mail
|
| 58 |
+
date_signature: str
|
| 59 |
+
# La signature sera laissée vide pour signature manuelle
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ATREData(BaseModel):
|
| 63 |
+
"""Modèle complet de la Fiche de Détection ATRE"""
|
| 64 |
+
personal_info: PersonalInfo
|
| 65 |
+
motivation_objectifs: MotivationObjectifs
|
| 66 |
+
experience_competences: ExperienceCompetences
|
| 67 |
+
besoins_accompagnement: BesoinsAccompagnement
|
| 68 |
+
consentement: Consentement
|
| 69 |
+
candidate_id: Optional[str] = None # ID Airtable du candidat (optionnel)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class ATREResponse(BaseModel):
|
| 73 |
+
"""Réponse après génération de la fiche"""
|
| 74 |
+
success: bool
|
| 75 |
+
message: str
|
| 76 |
+
file_path: Optional[str] = None
|
| 77 |
+
docx_path: Optional[str] = None
|
| 78 |
+
pdf_path: Optional[str] = None
|
| 79 |
+
|
app/routers/admission.py
CHANGED
|
@@ -288,4 +288,70 @@ async def delete_entreprise(
|
|
| 288 |
service.delete_fiche_entreprise(EtudiantID)
|
| 289 |
return {"message": "Fiche entreprise supprimée avec succès"}
|
| 290 |
except Exception as e:
|
| 291 |
-
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
service.delete_fiche_entreprise(EtudiantID)
|
| 289 |
return {"message": "Fiche entreprise supprimée avec succès"}
|
| 290 |
except Exception as e:
|
| 291 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# =====================================================
|
| 295 |
+
# GÉNÉRATION PDF - FICHE DE RENSEIGNEMENTS
|
| 296 |
+
# =====================================================
|
| 297 |
+
|
| 298 |
+
@router.post("/generate-fiche/{etudiant_id}", response_model=Dict)
|
| 299 |
+
async def generate_fiche_renseignements(
|
| 300 |
+
etudiant_id: str,
|
| 301 |
+
service: AdmissionService = Depends(get_admission_service)
|
| 302 |
+
):
|
| 303 |
+
"""
|
| 304 |
+
Génère une fiche de renseignements PDF pour un candidat.
|
| 305 |
+
|
| 306 |
+
Cette API:
|
| 307 |
+
1. Récupère les données du candidat depuis la table "Liste des candidats"
|
| 308 |
+
2. Récupère les données de l'entreprise depuis la table "Fiche de Renseignement Entreprise"
|
| 309 |
+
(en cherchant par recordIdetudiant)
|
| 310 |
+
3. Remplit le template PDF avec toutes les données
|
| 311 |
+
4. Upload le PDF généré vers la colonne "Fiche entreprise" de la table "Liste des candidats"
|
| 312 |
+
|
| 313 |
+
Args:
|
| 314 |
+
etudiant_id: L'ID du candidat (record ID Airtable de la table "Liste des candidats")
|
| 315 |
+
|
| 316 |
+
Returns:
|
| 317 |
+
Dict avec le statut de l'opération:
|
| 318 |
+
- status: "success", "partial" ou "error"
|
| 319 |
+
- record_id: L'ID du candidat
|
| 320 |
+
- candidate_name: Nom complet du candidat
|
| 321 |
+
- message: Description du résultat
|
| 322 |
+
|
| 323 |
+
Raises:
|
| 324 |
+
HTTPException 404: Si le candidat n'est pas trouvé
|
| 325 |
+
HTTPException 500: En cas d'erreur serveur
|
| 326 |
+
"""
|
| 327 |
+
try:
|
| 328 |
+
# Import du service PDF
|
| 329 |
+
from app.services.pdf_generator_service import PDFGeneratorService
|
| 330 |
+
from app.repositories.airtable_repository import EntrepriseRepository
|
| 331 |
+
|
| 332 |
+
# Initialiser le service de génération PDF
|
| 333 |
+
entreprise_repo = EntrepriseRepository()
|
| 334 |
+
pdf_service = PDFGeneratorService(
|
| 335 |
+
airtable_repo=service.airtable_repo,
|
| 336 |
+
entreprise_repo=entreprise_repo
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Générer et uploader le PDF
|
| 340 |
+
result = pdf_service.generate_and_upload(etudiant_id)
|
| 341 |
+
|
| 342 |
+
if result["status"] == "error":
|
| 343 |
+
if "non trouvé" in result.get("message", "").lower():
|
| 344 |
+
raise HTTPException(status_code=404, detail=result["message"])
|
| 345 |
+
raise HTTPException(status_code=500, detail=result["message"])
|
| 346 |
+
|
| 347 |
+
return result
|
| 348 |
+
|
| 349 |
+
except HTTPException:
|
| 350 |
+
raise
|
| 351 |
+
except Exception as e:
|
| 352 |
+
import traceback
|
| 353 |
+
traceback.print_exc()
|
| 354 |
+
raise HTTPException(
|
| 355 |
+
status_code=500,
|
| 356 |
+
detail=f"Erreur lors de la génération de la fiche: {str(e)}"
|
| 357 |
+
)
|
app/routers/atre_routes.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Routes pour la génération de Fiches de Détection ATRE
|
| 3 |
+
Version hybride utilisant la génération PDF directe (plus rapide)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import uuid
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 9 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 10 |
+
from ..models.atre_models import ATREData, ATREResponse
|
| 11 |
+
from ..services.atre_pdf_service import ATREHybridService, ATREPDFGenerator
|
| 12 |
+
from ..services.airtable_service import AirtableService
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/atre", tags=["ATRE"])
|
| 15 |
+
|
| 16 |
+
# Configuration - utiliser /tmp pour déploiement cloud
|
| 17 |
+
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/tmp/generated_fiches")
|
| 18 |
+
|
| 19 |
+
# Créer le dossier s'il n'existe pas
|
| 20 |
+
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
| 21 |
+
|
| 22 |
+
# Services - Utilisation du nouveau service PDF direct
|
| 23 |
+
atre_service = ATREHybridService()
|
| 24 |
+
airtable_service = AirtableService()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.get("/candidates")
|
| 28 |
+
async def get_candidates():
|
| 29 |
+
"""
|
| 30 |
+
Récupère la liste de tous les candidats depuis Airtable
|
| 31 |
+
"""
|
| 32 |
+
try:
|
| 33 |
+
candidates = airtable_service.get_all_candidates()
|
| 34 |
+
return {
|
| 35 |
+
"success": True,
|
| 36 |
+
"candidates": candidates,
|
| 37 |
+
"count": len(candidates)
|
| 38 |
+
}
|
| 39 |
+
except Exception as e:
|
| 40 |
+
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération des candidats: {str(e)}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.get("/candidates/{candidate_id}")
|
| 44 |
+
async def get_candidate(candidate_id: str):
|
| 45 |
+
"""
|
| 46 |
+
Récupère les informations d'un candidat spécifique depuis Airtable
|
| 47 |
+
"""
|
| 48 |
+
try:
|
| 49 |
+
candidate = airtable_service.get_candidate_by_id(candidate_id)
|
| 50 |
+
if not candidate:
|
| 51 |
+
raise HTTPException(status_code=404, detail="Candidat non trouvé")
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"success": True,
|
| 55 |
+
"candidate": candidate
|
| 56 |
+
}
|
| 57 |
+
except HTTPException:
|
| 58 |
+
raise
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération du candidat: {str(e)}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@router.post("/generate", response_model=ATREResponse)
|
| 64 |
+
async def generate_fiche(data: ATREData):
|
| 65 |
+
"""
|
| 66 |
+
Génère une fiche de détection ATRE à partir des données fournies.
|
| 67 |
+
Utilise la génération PDF directe (plus rapide que DOCX → PDF).
|
| 68 |
+
"""
|
| 69 |
+
try:
|
| 70 |
+
# Générer un ID unique pour le fichier
|
| 71 |
+
file_id = str(uuid.uuid4())[:8]
|
| 72 |
+
nom_clean = data.personal_info.nom.replace(" ", "_")
|
| 73 |
+
prenom_clean = data.personal_info.prenom.replace(" ", "_")
|
| 74 |
+
filename = f"fiche_atre_{nom_clean}_{prenom_clean}_{file_id}"
|
| 75 |
+
|
| 76 |
+
# Générer la fiche (PDF direct)
|
| 77 |
+
docx_path, pdf_path = atre_service.generate_fiche(data, OUTPUT_DIR, filename)
|
| 78 |
+
|
| 79 |
+
# Vérifier le résultat
|
| 80 |
+
if pdf_path and os.path.exists(pdf_path):
|
| 81 |
+
file_path = pdf_path
|
| 82 |
+
message = "Fiche ATRE générée avec succès (PDF direct)"
|
| 83 |
+
else:
|
| 84 |
+
raise HTTPException(status_code=500, detail="Erreur lors de la génération de la fiche")
|
| 85 |
+
|
| 86 |
+
return ATREResponse(
|
| 87 |
+
success=True,
|
| 88 |
+
message=message,
|
| 89 |
+
file_path=os.path.basename(file_path),
|
| 90 |
+
docx_path=docx_path, # None avec la méthode PDF direct
|
| 91 |
+
pdf_path=pdf_path
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
import traceback
|
| 96 |
+
traceback.print_exc()
|
| 97 |
+
raise HTTPException(status_code=500, detail=f"Erreur: {str(e)}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.post("/upload-to-airtable/{candidate_id}")
|
| 101 |
+
async def upload_to_airtable(candidate_id: str, file_path: str = Query(...)):
|
| 102 |
+
"""
|
| 103 |
+
Upload le fichier PDF généré vers Airtable dans la colonne ATR
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
candidate_id: L'ID du record Airtable
|
| 107 |
+
file_path: Le nom du fichier PDF généré
|
| 108 |
+
"""
|
| 109 |
+
try:
|
| 110 |
+
# Construire le chemin complet du fichier
|
| 111 |
+
full_path = os.path.join(OUTPUT_DIR, file_path)
|
| 112 |
+
|
| 113 |
+
print(f"📁 Tentative d'upload du fichier: {full_path}")
|
| 114 |
+
print(f"👤 Pour le candidat ID: {candidate_id}")
|
| 115 |
+
|
| 116 |
+
# Vérifier que le fichier existe
|
| 117 |
+
if not os.path.exists(full_path):
|
| 118 |
+
raise HTTPException(status_code=404, detail=f"Fichier non trouvé: {file_path}")
|
| 119 |
+
|
| 120 |
+
# Uploader vers Airtable
|
| 121 |
+
success = airtable_service.upload_atr_file(candidate_id, full_path)
|
| 122 |
+
|
| 123 |
+
if success:
|
| 124 |
+
return {
|
| 125 |
+
"success": True,
|
| 126 |
+
"message": "✅ Fiche ATRE attachée avec succès dans Airtable"
|
| 127 |
+
}
|
| 128 |
+
else:
|
| 129 |
+
raise HTTPException(status_code=500, detail="Échec de l'upload vers Airtable")
|
| 130 |
+
|
| 131 |
+
except HTTPException:
|
| 132 |
+
raise
|
| 133 |
+
except Exception as e:
|
| 134 |
+
print(f"❌ Erreur lors de l'upload: {e}")
|
| 135 |
+
raise HTTPException(status_code=500, detail=f"Erreur lors de l'upload: {str(e)}")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/download/{filename}")
|
| 139 |
+
async def download_fiche(filename: str):
|
| 140 |
+
"""
|
| 141 |
+
Télécharge la fiche ATRE générée
|
| 142 |
+
"""
|
| 143 |
+
# Sécurité: vérifier que le fichier est dans le répertoire autorisé
|
| 144 |
+
file_path = os.path.join(OUTPUT_DIR, filename)
|
| 145 |
+
|
| 146 |
+
if not os.path.exists(file_path):
|
| 147 |
+
raise HTTPException(status_code=404, detail="Fichier non trouvé")
|
| 148 |
+
|
| 149 |
+
# Vérifier l'extension
|
| 150 |
+
if filename.endswith('.pdf'):
|
| 151 |
+
media_type = "application/pdf"
|
| 152 |
+
elif filename.endswith('.docx'):
|
| 153 |
+
media_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
| 154 |
+
else:
|
| 155 |
+
raise HTTPException(status_code=400, detail="Format de fichier non supporté")
|
| 156 |
+
|
| 157 |
+
return FileResponse(
|
| 158 |
+
path=file_path,
|
| 159 |
+
filename=filename,
|
| 160 |
+
media_type=media_type
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.get("/health")
|
| 165 |
+
async def health_check():
|
| 166 |
+
"""Vérification de santé de l'API"""
|
| 167 |
+
return {"status": "ok", "message": "Fiche ATRE Generator API is running"}
|
app/services/atre_pdf_service.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service de génération de Fiche ATRE - Version PDF Direct
|
| 3 |
+
Remplit directement le PDF template sans conversion DOCX → PDF
|
| 4 |
+
Plus rapide et plus fiable que la conversion LibreOffice
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Optional, Tuple
|
| 11 |
+
from pdfrw import PdfReader, PdfWriter, PageMerge
|
| 12 |
+
from reportlab.pdfgen import canvas
|
| 13 |
+
from reportlab.lib.pagesizes import A4
|
| 14 |
+
from reportlab.pdfbase import pdfmetrics
|
| 15 |
+
from reportlab.pdfbase.ttfonts import TTFont
|
| 16 |
+
|
| 17 |
+
from ..models.atre_models import ATREData
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ATREPDFGenerator:
|
| 21 |
+
"""
|
| 22 |
+
Générateur de Fiche ATRE utilisant le remplissage PDF direct.
|
| 23 |
+
Plus rapide que la conversion DOCX → PDF via LibreOffice.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self):
|
| 27 |
+
template_path = os.path.join(
|
| 28 |
+
os.path.dirname(__file__),
|
| 29 |
+
'..', 'templates', "Fiche de detection pour l'ATRE.pdf"
|
| 30 |
+
)
|
| 31 |
+
self.template_path = os.path.abspath(template_path)
|
| 32 |
+
print(f"📄 Template PDF ATRE: {self.template_path}")
|
| 33 |
+
|
| 34 |
+
# Mapping des champs texte
|
| 35 |
+
self.text_fields = {
|
| 36 |
+
# Page 1 - Informations personnelles
|
| 37 |
+
'nom': 'Champ de texte 178',
|
| 38 |
+
'prenom': 'Champ de texte 179',
|
| 39 |
+
'date_naissance': 'Champ de texte 180',
|
| 40 |
+
'formation_souhaitee': 'Champ de texte 181',
|
| 41 |
+
'email': 'Champ de texte 182',
|
| 42 |
+
'telephone': 'Champ de texte 183',
|
| 43 |
+
'motivation_apprentissage': 'Champ de texte 184',
|
| 44 |
+
'objectifs_professionnels': 'Champ de texte 185',
|
| 45 |
+
|
| 46 |
+
# Page 2
|
| 47 |
+
'description_experience': 'Champ de texte 187',
|
| 48 |
+
'competences_particulieres': 'Champ de texte 186',
|
| 49 |
+
'competences_specifiques_detail': 'Champ de texte 188',
|
| 50 |
+
'defis_anticipes': 'Champ de texte 192',
|
| 51 |
+
|
| 52 |
+
# Page 3
|
| 53 |
+
'autres_methodes_detail': 'Champ de texte 193',
|
| 54 |
+
'date_signature': 'Champ de texte 194',
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
# Mapping des cases à cocher - Niveau d'études
|
| 58 |
+
self.checkbox_niveau = {
|
| 59 |
+
'Bac': 'Case à cocher 34',
|
| 60 |
+
'Bac+1': 'Case à cocher 35',
|
| 61 |
+
'Bac+2': 'Case à cocher 36',
|
| 62 |
+
'Bac+3': 'Case à cocher 37',
|
| 63 |
+
'Bac+4': 'Case à cocher 38',
|
| 64 |
+
'Bac+5': 'Case à cocher 39',
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# Mapping des cases à cocher - Expérience (Page 2)
|
| 68 |
+
self.checkbox_experience = {
|
| 69 |
+
'oui': 'Case à cocher 40',
|
| 70 |
+
'non': 'Case à cocher 41',
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
# Mapping des cases à cocher - Compétences à développer (Page 2)
|
| 74 |
+
self.checkbox_competences = {
|
| 75 |
+
'techniques_cv': 'Case à cocher 42',
|
| 76 |
+
'techniques_entretien': 'Case à cocher 43',
|
| 77 |
+
'strategies_recherche': 'Case à cocher 44',
|
| 78 |
+
'competences_specifiques': 'Case à cocher 45',
|
| 79 |
+
'competences_interpersonnelles': 'Case à cocher 46',
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
# Mapping des cases à cocher - Méthodes de soutien (Page 3)
|
| 83 |
+
self.checkbox_methodes = {
|
| 84 |
+
'ateliers_groupe': 'Case à cocher 54',
|
| 85 |
+
'ressources_en_ligne': 'Case à cocher 55',
|
| 86 |
+
'seances_individuelles': 'Case à cocher 56',
|
| 87 |
+
'autres_methodes': 'Case à cocher 57',
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# Mapping des cases à cocher - Accepte conseils (Page 3)
|
| 91 |
+
self.checkbox_conseils = {
|
| 92 |
+
'oui': 'Case à cocher 58',
|
| 93 |
+
'non': 'Case à cocher 59',
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def _format_date(self, date_str: str) -> str:
|
| 97 |
+
"""Formate une date YYYY-MM-DD en DD/MM/YYYY"""
|
| 98 |
+
if not date_str:
|
| 99 |
+
return ""
|
| 100 |
+
try:
|
| 101 |
+
dt = datetime.strptime(date_str, "%Y-%m-%d")
|
| 102 |
+
return dt.strftime("%d/%m/%Y")
|
| 103 |
+
except ValueError:
|
| 104 |
+
return date_str
|
| 105 |
+
|
| 106 |
+
def _prepare_field_values(self, data: ATREData) -> dict:
|
| 107 |
+
"""Prépare les valeurs des champs texte"""
|
| 108 |
+
info = data.personal_info
|
| 109 |
+
motivation = data.motivation_objectifs
|
| 110 |
+
experience = data.experience_competences
|
| 111 |
+
besoins = data.besoins_accompagnement
|
| 112 |
+
consentement = data.consentement
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
# Page 1
|
| 116 |
+
'nom': info.nom,
|
| 117 |
+
'prenom': info.prenom,
|
| 118 |
+
'date_naissance': self._format_date(info.date_naissance),
|
| 119 |
+
'formation_souhaitee': info.formation_souhaitee,
|
| 120 |
+
'email': info.email,
|
| 121 |
+
'telephone': info.telephone,
|
| 122 |
+
'motivation_apprentissage': motivation.motivation_apprentissage,
|
| 123 |
+
'objectifs_professionnels': motivation.objectifs_professionnels,
|
| 124 |
+
|
| 125 |
+
# Page 2
|
| 126 |
+
'description_experience': experience.description_experience or '',
|
| 127 |
+
'competences_particulieres': experience.competences_particulieres or '',
|
| 128 |
+
'competences_specifiques_detail': besoins.competences_specifiques_detail or '',
|
| 129 |
+
'defis_anticipes': besoins.defis_anticipes or '',
|
| 130 |
+
|
| 131 |
+
# Page 3
|
| 132 |
+
'autres_methodes_detail': besoins.autres_methodes_detail or '',
|
| 133 |
+
'date_signature': self._format_date(consentement.date_signature),
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
def _prepare_checkbox_values(self, data: ATREData) -> dict:
|
| 137 |
+
"""Prépare les cases à cocher à marquer"""
|
| 138 |
+
info = data.personal_info
|
| 139 |
+
experience = data.experience_competences
|
| 140 |
+
besoins = data.besoins_accompagnement
|
| 141 |
+
consentement = data.consentement
|
| 142 |
+
|
| 143 |
+
checkboxes_to_mark = []
|
| 144 |
+
|
| 145 |
+
# Niveau d'études
|
| 146 |
+
niveau = info.niveau_etudes
|
| 147 |
+
if niveau in self.checkbox_niveau:
|
| 148 |
+
checkboxes_to_mark.append(self.checkbox_niveau[niveau])
|
| 149 |
+
|
| 150 |
+
# Expérience
|
| 151 |
+
if experience.a_experience:
|
| 152 |
+
checkboxes_to_mark.append(self.checkbox_experience['oui'])
|
| 153 |
+
else:
|
| 154 |
+
checkboxes_to_mark.append(self.checkbox_experience['non'])
|
| 155 |
+
|
| 156 |
+
# Compétences à développer
|
| 157 |
+
if besoins.techniques_cv:
|
| 158 |
+
checkboxes_to_mark.append(self.checkbox_competences['techniques_cv'])
|
| 159 |
+
if besoins.techniques_entretien:
|
| 160 |
+
checkboxes_to_mark.append(self.checkbox_competences['techniques_entretien'])
|
| 161 |
+
if besoins.strategies_recherche_emploi:
|
| 162 |
+
checkboxes_to_mark.append(self.checkbox_competences['strategies_recherche'])
|
| 163 |
+
if besoins.competences_specifiques:
|
| 164 |
+
checkboxes_to_mark.append(self.checkbox_competences['competences_specifiques'])
|
| 165 |
+
if besoins.competences_interpersonnelles:
|
| 166 |
+
checkboxes_to_mark.append(self.checkbox_competences['competences_interpersonnelles'])
|
| 167 |
+
|
| 168 |
+
# Méthodes de soutien
|
| 169 |
+
if besoins.ateliers_groupe:
|
| 170 |
+
checkboxes_to_mark.append(self.checkbox_methodes['ateliers_groupe'])
|
| 171 |
+
if besoins.ressources_en_ligne:
|
| 172 |
+
checkboxes_to_mark.append(self.checkbox_methodes['ressources_en_ligne'])
|
| 173 |
+
if besoins.seances_individuelles:
|
| 174 |
+
checkboxes_to_mark.append(self.checkbox_methodes['seances_individuelles'])
|
| 175 |
+
if besoins.autres_methodes:
|
| 176 |
+
checkboxes_to_mark.append(self.checkbox_methodes['autres_methodes'])
|
| 177 |
+
|
| 178 |
+
# Accepte conseils
|
| 179 |
+
if consentement.accepte_conseils:
|
| 180 |
+
checkboxes_to_mark.append(self.checkbox_conseils['oui'])
|
| 181 |
+
else:
|
| 182 |
+
checkboxes_to_mark.append(self.checkbox_conseils['non'])
|
| 183 |
+
|
| 184 |
+
return checkboxes_to_mark
|
| 185 |
+
|
| 186 |
+
def _get_field_coords(self, pdf: PdfReader) -> dict:
|
| 187 |
+
"""Extrait les coordonnées de tous les champs du PDF"""
|
| 188 |
+
field_coords = {}
|
| 189 |
+
|
| 190 |
+
for page_num, page in enumerate(pdf.pages):
|
| 191 |
+
if page.Annots:
|
| 192 |
+
for annot in page.Annots:
|
| 193 |
+
if annot.T:
|
| 194 |
+
name = annot.T.to_unicode() if hasattr(annot.T, 'to_unicode') else str(annot.T)
|
| 195 |
+
if annot.Rect:
|
| 196 |
+
rect = [float(x) for x in annot.Rect]
|
| 197 |
+
field_coords[name] = {
|
| 198 |
+
'page': page_num,
|
| 199 |
+
'rect': rect,
|
| 200 |
+
'type': str(annot.FT) if annot.FT else 'Unknown'
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return field_coords
|
| 204 |
+
|
| 205 |
+
def _wrap_text(self, text: str, max_width: int, canvas_obj, font_size: int = 9) -> list:
|
| 206 |
+
"""Découpe le texte en lignes pour tenir dans une largeur donnée"""
|
| 207 |
+
if not text:
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
words = text.split()
|
| 211 |
+
lines = []
|
| 212 |
+
current_line = ""
|
| 213 |
+
|
| 214 |
+
for word in words:
|
| 215 |
+
test_line = f"{current_line} {word}".strip()
|
| 216 |
+
# Estimer la largeur (approximation)
|
| 217 |
+
if len(test_line) * (font_size * 0.5) < max_width:
|
| 218 |
+
current_line = test_line
|
| 219 |
+
else:
|
| 220 |
+
if current_line:
|
| 221 |
+
lines.append(current_line)
|
| 222 |
+
current_line = word
|
| 223 |
+
|
| 224 |
+
if current_line:
|
| 225 |
+
lines.append(current_line)
|
| 226 |
+
|
| 227 |
+
return lines
|
| 228 |
+
|
| 229 |
+
def generate(self, data: ATREData, output_dir: str, filename: str) -> Tuple[str, Optional[str]]:
|
| 230 |
+
"""
|
| 231 |
+
Génère la fiche ATRE en remplissant directement le PDF template.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
data: Données du formulaire ATRE
|
| 235 |
+
output_dir: Répertoire de sortie
|
| 236 |
+
filename: Nom du fichier (sans extension)
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
Tuple (pdf_path, None) - Le PDF est généré directement
|
| 240 |
+
"""
|
| 241 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 242 |
+
|
| 243 |
+
# Lire le template
|
| 244 |
+
base_pdf = PdfReader(self.template_path)
|
| 245 |
+
num_pages = len(base_pdf.pages)
|
| 246 |
+
|
| 247 |
+
# Récupérer les coordonnées des champs
|
| 248 |
+
field_coords = self._get_field_coords(base_pdf)
|
| 249 |
+
|
| 250 |
+
# Préparer les valeurs
|
| 251 |
+
field_values = self._prepare_field_values(data)
|
| 252 |
+
checkboxes_to_mark = self._prepare_checkbox_values(data)
|
| 253 |
+
|
| 254 |
+
# Créer l'overlay pour chaque page
|
| 255 |
+
overlay_stream = BytesIO()
|
| 256 |
+
c = canvas.Canvas(overlay_stream, pagesize=A4)
|
| 257 |
+
|
| 258 |
+
# Utiliser une police standard
|
| 259 |
+
c.setFont("Helvetica", 9)
|
| 260 |
+
|
| 261 |
+
for page_num in range(num_pages):
|
| 262 |
+
# Remplir les champs texte de cette page
|
| 263 |
+
for field_key, field_name in self.text_fields.items():
|
| 264 |
+
if field_name in field_coords:
|
| 265 |
+
field_info = field_coords[field_name]
|
| 266 |
+
if field_info['page'] == page_num:
|
| 267 |
+
x0, y0, x1, y1 = field_info['rect']
|
| 268 |
+
value = field_values.get(field_key, '')
|
| 269 |
+
|
| 270 |
+
if value:
|
| 271 |
+
# Calculer la largeur disponible
|
| 272 |
+
width = x1 - x0 - 4
|
| 273 |
+
height = y1 - y0
|
| 274 |
+
|
| 275 |
+
# Pour les grands champs texte (multilignes)
|
| 276 |
+
if height > 50:
|
| 277 |
+
c.setFont("Helvetica", 8)
|
| 278 |
+
lines = self._wrap_text(value, width, c, 8)
|
| 279 |
+
line_height = 10
|
| 280 |
+
current_y = y1 - 12
|
| 281 |
+
|
| 282 |
+
for line in lines:
|
| 283 |
+
if current_y > y0:
|
| 284 |
+
c.drawString(x0 + 2, current_y, line)
|
| 285 |
+
current_y -= line_height
|
| 286 |
+
|
| 287 |
+
c.setFont("Helvetica", 9)
|
| 288 |
+
else:
|
| 289 |
+
# Champ simple sur une ligne
|
| 290 |
+
c.drawString(x0 + 2, y0 + 4, str(value)[:80])
|
| 291 |
+
|
| 292 |
+
# Marquer les cases à cocher de cette page
|
| 293 |
+
for checkbox_name in checkboxes_to_mark:
|
| 294 |
+
if checkbox_name in field_coords:
|
| 295 |
+
field_info = field_coords[checkbox_name]
|
| 296 |
+
if field_info['page'] == page_num:
|
| 297 |
+
x0, y0, x1, y1 = field_info['rect']
|
| 298 |
+
# Dessiner un X dans la case
|
| 299 |
+
c.setFont("Helvetica-Bold", 10)
|
| 300 |
+
c.drawString(x0 + 2, y0 + 1, "X")
|
| 301 |
+
c.setFont("Helvetica", 9)
|
| 302 |
+
|
| 303 |
+
# Passer à la page suivante
|
| 304 |
+
c.showPage()
|
| 305 |
+
|
| 306 |
+
c.save()
|
| 307 |
+
overlay_stream.seek(0)
|
| 308 |
+
|
| 309 |
+
# Fusionner l'overlay avec le template
|
| 310 |
+
overlay_pdf = PdfReader(overlay_stream)
|
| 311 |
+
writer = PdfWriter()
|
| 312 |
+
|
| 313 |
+
for i in range(num_pages):
|
| 314 |
+
PageMerge(base_pdf.pages[i]).add(overlay_pdf.pages[i]).render()
|
| 315 |
+
writer.addpage(base_pdf.pages[i])
|
| 316 |
+
|
| 317 |
+
# Sauvegarder le PDF final
|
| 318 |
+
pdf_path = os.path.join(output_dir, f"{filename}.pdf")
|
| 319 |
+
writer.write(pdf_path)
|
| 320 |
+
|
| 321 |
+
print(f"✅ Fiche ATRE PDF générée: {pdf_path}")
|
| 322 |
+
|
| 323 |
+
# Retourner (None, pdf_path) pour indiquer qu'il n'y a pas de DOCX
|
| 324 |
+
return None, pdf_path
|
| 325 |
+
|
| 326 |
+
def generate_streaming(self, data: ATREData) -> BytesIO:
|
| 327 |
+
"""
|
| 328 |
+
Génère la fiche ATRE en mémoire pour streaming direct.
|
| 329 |
+
Utile pour retourner directement le PDF sans le sauvegarder.
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
data: Données du formulaire ATRE
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
BytesIO contenant le PDF
|
| 336 |
+
"""
|
| 337 |
+
# Lire le template
|
| 338 |
+
base_pdf = PdfReader(self.template_path)
|
| 339 |
+
num_pages = len(base_pdf.pages)
|
| 340 |
+
|
| 341 |
+
# Récupérer les coordonnées des champs
|
| 342 |
+
field_coords = self._get_field_coords(base_pdf)
|
| 343 |
+
|
| 344 |
+
# Préparer les valeurs
|
| 345 |
+
field_values = self._prepare_field_values(data)
|
| 346 |
+
checkboxes_to_mark = self._prepare_checkbox_values(data)
|
| 347 |
+
|
| 348 |
+
# Créer l'overlay
|
| 349 |
+
overlay_stream = BytesIO()
|
| 350 |
+
c = canvas.Canvas(overlay_stream, pagesize=A4)
|
| 351 |
+
c.setFont("Helvetica", 9)
|
| 352 |
+
|
| 353 |
+
for page_num in range(num_pages):
|
| 354 |
+
# Remplir les champs texte
|
| 355 |
+
for field_key, field_name in self.text_fields.items():
|
| 356 |
+
if field_name in field_coords:
|
| 357 |
+
field_info = field_coords[field_name]
|
| 358 |
+
if field_info['page'] == page_num:
|
| 359 |
+
x0, y0, x1, y1 = field_info['rect']
|
| 360 |
+
value = field_values.get(field_key, '')
|
| 361 |
+
|
| 362 |
+
if value:
|
| 363 |
+
width = x1 - x0 - 4
|
| 364 |
+
height = y1 - y0
|
| 365 |
+
|
| 366 |
+
if height > 50:
|
| 367 |
+
c.setFont("Helvetica", 8)
|
| 368 |
+
lines = self._wrap_text(value, width, c, 8)
|
| 369 |
+
line_height = 10
|
| 370 |
+
current_y = y1 - 12
|
| 371 |
+
|
| 372 |
+
for line in lines:
|
| 373 |
+
if current_y > y0:
|
| 374 |
+
c.drawString(x0 + 2, current_y, line)
|
| 375 |
+
current_y -= line_height
|
| 376 |
+
|
| 377 |
+
c.setFont("Helvetica", 9)
|
| 378 |
+
else:
|
| 379 |
+
c.drawString(x0 + 2, y0 + 4, str(value)[:80])
|
| 380 |
+
|
| 381 |
+
# Marquer les cases à cocher
|
| 382 |
+
for checkbox_name in checkboxes_to_mark:
|
| 383 |
+
if checkbox_name in field_coords:
|
| 384 |
+
field_info = field_coords[checkbox_name]
|
| 385 |
+
if field_info['page'] == page_num:
|
| 386 |
+
x0, y0, x1, y1 = field_info['rect']
|
| 387 |
+
c.setFont("Helvetica-Bold", 10)
|
| 388 |
+
c.drawString(x0 + 2, y0 + 1, "X")
|
| 389 |
+
c.setFont("Helvetica", 9)
|
| 390 |
+
|
| 391 |
+
c.showPage()
|
| 392 |
+
|
| 393 |
+
c.save()
|
| 394 |
+
overlay_stream.seek(0)
|
| 395 |
+
|
| 396 |
+
# Fusionner
|
| 397 |
+
overlay_pdf = PdfReader(overlay_stream)
|
| 398 |
+
writer = PdfWriter()
|
| 399 |
+
|
| 400 |
+
for i in range(num_pages):
|
| 401 |
+
PageMerge(base_pdf.pages[i]).add(overlay_pdf.pages[i]).render()
|
| 402 |
+
writer.addpage(base_pdf.pages[i])
|
| 403 |
+
|
| 404 |
+
# Écrire dans un BytesIO
|
| 405 |
+
output_stream = BytesIO()
|
| 406 |
+
writer.write(output_stream)
|
| 407 |
+
output_stream.seek(0)
|
| 408 |
+
|
| 409 |
+
return output_stream
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
class ATREHybridService:
|
| 413 |
+
"""
|
| 414 |
+
Service hybride qui utilise la génération PDF directe.
|
| 415 |
+
Compatible avec l'interface existante.
|
| 416 |
+
"""
|
| 417 |
+
|
| 418 |
+
def __init__(self):
|
| 419 |
+
self.pdf_generator = ATREPDFGenerator()
|
| 420 |
+
|
| 421 |
+
def generate_fiche(self, data: ATREData, output_dir: str, filename: str) -> Tuple[Optional[str], Optional[str]]:
|
| 422 |
+
"""
|
| 423 |
+
Génère la fiche ATRE.
|
| 424 |
+
|
| 425 |
+
Args:
|
| 426 |
+
data: Données du formulaire
|
| 427 |
+
output_dir: Répertoire de sortie
|
| 428 |
+
filename: Nom du fichier (sans extension)
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
Tuple (docx_path, pdf_path) - docx_path sera None avec cette méthode
|
| 432 |
+
"""
|
| 433 |
+
return self.pdf_generator.generate(data, output_dir, filename)
|
app/services/pdf_generator_service.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service de génération de PDF - Fiche de Renseignements
|
| 3 |
+
Génère un PDF à partir des données Airtable (candidat + entreprise)
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import tempfile
|
| 8 |
+
import logging
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Dict, Optional, Tuple
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
|
| 13 |
+
from pdfrw import PdfReader, PdfWriter, PageMerge
|
| 14 |
+
from reportlab.pdfgen import canvas
|
| 15 |
+
from reportlab.lib.pagesizes import A4
|
| 16 |
+
from reportlab.pdfbase import pdfmetrics
|
| 17 |
+
from reportlab.pdfbase.ttfonts import TTFont
|
| 18 |
+
|
| 19 |
+
from app.core.config import settings
|
| 20 |
+
|
| 21 |
+
# =====================================================
|
| 22 |
+
# LOGGING
|
| 23 |
+
# =====================================================
|
| 24 |
+
logging.basicConfig(level=logging.INFO)
|
| 25 |
+
logger = logging.getLogger(__name__)
|
| 26 |
+
|
| 27 |
+
# =====================================================
|
| 28 |
+
# PATHS
|
| 29 |
+
# =====================================================
|
| 30 |
+
# Chemin du fichier actuel: backend/api/app/services/pdf_generator_service.py
|
| 31 |
+
# parents[0] = services/, parents[1] = app/, parents[2] = api/, parents[3] = backend/
|
| 32 |
+
BASE_DIR = Path(__file__).resolve().parents[2] # backend/api
|
| 33 |
+
PDF_TEMPLATE = BASE_DIR / "assets" / "templates_pdf" / "Fiche de renseignements.pdf"
|
| 34 |
+
|
| 35 |
+
# Alternative path si le premier n'existe pas (dans generateur-pdf-main)
|
| 36 |
+
ALT_PDF_TEMPLATE = Path(__file__).resolve().parents[3] / "generateur-pdf-main" / "assets" / "templates_pdf" / "Fiche de renseignements.pdf"
|
| 37 |
+
|
| 38 |
+
# Troisième option: dans app/templates
|
| 39 |
+
THIRD_PDF_TEMPLATE = BASE_DIR / "app" / "templates" / "Fiche de renseignements.pdf"
|
| 40 |
+
|
| 41 |
+
# =====================================================
|
| 42 |
+
# PDF FIELD MAPPING
|
| 43 |
+
# Correspondance entre les champs du formulaire PDF et les colonnes Airtable
|
| 44 |
+
# COLONNES VÉRIFIÉES SUR AIRTABLE LE 20/01/2026
|
| 45 |
+
# =====================================================
|
| 46 |
+
|
| 47 |
+
PDF_MAPPING = {
|
| 48 |
+
# =====================================================
|
| 49 |
+
# EMPLOYEUR / IDENTIFICATION (Table: Fiche de Renseignement Entreprise)
|
| 50 |
+
# =====================================================
|
| 51 |
+
"Champ de texte 2": ("entreprise", "Raison sociale"),
|
| 52 |
+
# Adresse complète: Numéro + Voie + Nom de la rue + Complément + Code postal + Ville
|
| 53 |
+
"Champ de texte 70": ("entreprise", "Lieu dexécution du contrat (si différent du siège)"),
|
| 54 |
+
"Champ de texte 79": ("entreprise", "Numéro de téléphone"),
|
| 55 |
+
"Champ de texte 80": ("entreprise", "E-mail"),
|
| 56 |
+
"Champ de texte 81": ("entreprise", "SIRET de lentreprise"),
|
| 57 |
+
"Champ de texte 82": ("entreprise", "Type demployeur"),
|
| 58 |
+
"Champ de texte 83": ("entreprise", "Employeur specifique"),
|
| 59 |
+
"Champ de texte 84": ("entreprise", "Nombre de salariés"),
|
| 60 |
+
"Champ de texte 85": ("entreprise", "Code APE / NAF"),
|
| 61 |
+
"Champ de texte 86": ("entreprise", "Code IDCC"),
|
| 62 |
+
"Champ de texte 87": ("entreprise", "Convention collective"),
|
| 63 |
+
"Champ de texte 88": ("entreprise", "Caisse de retraite complémentaire"), # Non présent dans Airtable
|
| 64 |
+
"Champ de texte 89": ("entreprise", "Date de création de lentreprise"), # Non présent dans Airtable
|
| 65 |
+
|
| 66 |
+
# =====================================================
|
| 67 |
+
# REPRÉSENTANT LÉGAL (Table: Fiche de Renseignement Entreprise)
|
| 68 |
+
# =====================================================
|
| 69 |
+
"Champ de texte 91": ("entreprise", "Nom du representant legal"),
|
| 70 |
+
"Champ de texte 92": ("entreprise", "Titre exact du representant legal"),
|
| 71 |
+
|
| 72 |
+
# =====================================================
|
| 73 |
+
# OPCO (Table: Fiche de Renseignement Entreprise)
|
| 74 |
+
# =====================================================
|
| 75 |
+
"Champ de texte 93": ("entreprise", "Nom de lOPCO"),
|
| 76 |
+
"Champ de texte 94": ("entreprise", "Téléphone OPCO"), # Non présent - utiliser email
|
| 77 |
+
"Champ de texte 95": ("entreprise", "Contact OPCO"), # Non présent
|
| 78 |
+
"Champ de texte 96": ("entreprise", "E-mail OPCO"), # Non présent
|
| 79 |
+
"Champ de texte 97": ("entreprise", "Adresse OPCO"),
|
| 80 |
+
|
| 81 |
+
# =====================================================
|
| 82 |
+
# FACTURATION (Table: Fiche de Renseignement Entreprise)
|
| 83 |
+
# =====================================================
|
| 84 |
+
"Champ de texte 101": ("entreprise", "Nom contact facturation"), # Non présent
|
| 85 |
+
"Champ de texte 102": ("entreprise", "Adresse facturation"), # Composite: Code postal facturation + Ville facturation
|
| 86 |
+
"Champ de texte 103": ("entreprise", "Téléphone facturation"), # Non présent
|
| 87 |
+
"Champ de texte 104": ("entreprise", "E-mail facturation"), # Non présent
|
| 88 |
+
"Champ de texte 105": ("entreprise", "N de bon de commande"),
|
| 89 |
+
|
| 90 |
+
# =====================================================
|
| 91 |
+
# CONTACT RH (Table: Fiche de Renseignement Entreprise)
|
| 92 |
+
# - Non présent dans Airtable actuellement
|
| 93 |
+
# =====================================================
|
| 94 |
+
"Champ de texte 106": ("entreprise", "Nom RH"),
|
| 95 |
+
"Champ de texte 107": ("entreprise", "Prénom RH"),
|
| 96 |
+
"Champ de texte 108": ("entreprise", "Fonction RH"),
|
| 97 |
+
"Champ de texte 109": ("entreprise", "Téléphone RH"),
|
| 98 |
+
"Champ de texte 1010": ("entreprise", "Email RH"),
|
| 99 |
+
|
| 100 |
+
# =====================================================
|
| 101 |
+
# APPRENTI (Table: Liste des candidats)
|
| 102 |
+
# =====================================================
|
| 103 |
+
"Champ de texte 110": ("candidat", "Prénom"),
|
| 104 |
+
"Champ de texte 111": ("candidat", "Téléphone"),
|
| 105 |
+
"Champ de texte 112": ("candidat", "Département"),
|
| 106 |
+
"Champ de texte 113": ("candidat", "Déclare être inscrits sur la liste des sportifs de haut niveau"),
|
| 107 |
+
"Champ de texte 114": ("candidat", "Date de naissance"),
|
| 108 |
+
"Champ de texte 115": ("candidat", "Nationalité"),
|
| 109 |
+
"Champ de texte 117": ("candidat", "E-mail"),
|
| 110 |
+
"Champ de texte 118": ("candidat", "Commune de naissance"),
|
| 111 |
+
"Champ de texte 120": ("candidat", "Sexe"),
|
| 112 |
+
"Champ de texte 121": ("candidat", "Régime social"),
|
| 113 |
+
"Champ de texte 122": ("candidat", "Adresse lieu dexécution du contrat"),
|
| 114 |
+
"Champ de texte 123": ("candidat", "NOM de naissance"),
|
| 115 |
+
"Champ de texte 124": ("candidat", "NOM dusage"),
|
| 116 |
+
"Champ de texte 125": ("candidat", "NIR"),
|
| 117 |
+
"Champ de texte 126": ("candidat", "Déclare bénéficier de la reconnaissance travailleur handicapé"),
|
| 118 |
+
"Champ de texte 127": ("candidat", "Situation avant le contrat"),
|
| 119 |
+
"Champ de texte 128": ("candidat", "Dernier diplôme ou titre préparé"),
|
| 120 |
+
"Champ de texte 129": ("candidat", "Dernière classe / année suivie"),
|
| 121 |
+
"Champ de texte 130": ("candidat", "Intitulé précis du dernier diplôme"), # Non présent
|
| 122 |
+
"Champ de texte 131": ("candidat", "Diplôme ou titre le plus élevé obtenu"),
|
| 123 |
+
"Champ de texte 132": ("candidat", "Déclare avoir un projet de création ou de reprise dentreprise"),
|
| 124 |
+
|
| 125 |
+
# =====================================================
|
| 126 |
+
# MAÎTRE D'APPRENTISSAGE (Table: Fiche de Renseignement Entreprise)
|
| 127 |
+
# =====================================================
|
| 128 |
+
"Champ de texte 133": ("entreprise", "Nom du maitre dapprentissage"),
|
| 129 |
+
"Champ de texte 134": ("entreprise", "Prenom du maitre dapprentissage"),
|
| 130 |
+
"Champ de texte 135": ("entreprise", "Date de naissance du maitre dapprentissage"),
|
| 131 |
+
"Champ de texte 136": ("entreprise", "N de securite sociale maitre dapprentissage"),
|
| 132 |
+
"Champ de texte 137": ("entreprise", "Fonction maitre dapprentissage"),
|
| 133 |
+
"Champ de texte 138": ("entreprise", "Diplome ou titre le plus eleve obtenu (maitre dapprentissage)"),
|
| 134 |
+
"Champ de texte 142": ("entreprise", "Niveau de diplome maitre dapprentissage"),
|
| 135 |
+
"Champ de texte 143": ("entreprise", "Telephone maitre dapprentissage"),
|
| 136 |
+
"Champ de texte 144": ("entreprise", "E-mail maitre dapprentissage"),
|
| 137 |
+
|
| 138 |
+
# =====================================================
|
| 139 |
+
# CONTRAT (Table: Fiche de Renseignement Entreprise)
|
| 140 |
+
# =====================================================
|
| 141 |
+
"Champ de texte 145": ("entreprise", "Type de contrat"),
|
| 142 |
+
"Champ de texte 147": ("entreprise", "Type de dérogation"),
|
| 143 |
+
"Champ de texte 151": ("entreprise", "N du contrat précédent"), # Non présent
|
| 144 |
+
"Champ de texte 154": ("entreprise", "Date de début de contrat"),
|
| 145 |
+
"Champ de texte 155": ("entreprise", "Date de fin de contrat"),
|
| 146 |
+
"Champ de texte 156": ("entreprise", "Nombre de mois du contrat"),
|
| 147 |
+
"Champ de texte 157": ("entreprise", "Durée hebdomadaire du travail"),
|
| 148 |
+
"Champ de texte 158": ("entreprise", "Poste occupé"),
|
| 149 |
+
"Champ de texte 159": ("entreprise", "Formation de lalternant(e) (pour les missions)"),
|
| 150 |
+
|
| 151 |
+
# =====================================================
|
| 152 |
+
# SALAIRE (Table: Fiche de Renseignement Entreprise)
|
| 153 |
+
# =====================================================
|
| 154 |
+
"Champ de texte 160": ("entreprise", "Base de calcul salaire"),
|
| 155 |
+
"Champ de texte 161": ("entreprise", "Montant du salaire mensuel brut"),
|
| 156 |
+
|
| 157 |
+
# =====================================================
|
| 158 |
+
# CONTACT TAXE APPRENTISSAGE (Table: Fiche de Renseignement Entreprise)
|
| 159 |
+
# =====================================================
|
| 160 |
+
"Champ de texte 162": ("entreprise", "Nom contact taxe dapprentissage"), # Composite
|
| 161 |
+
"Champ de texte 163": ("entreprise", "Fonction contact taxe dapprentissage"),
|
| 162 |
+
"Champ de texte 164": ("entreprise", "Téléphone contact taxe dapprentissage"),
|
| 163 |
+
"Champ de texte 165": ("entreprise", "E-mail contact taxe dapprentissage"),
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class PDFGeneratorService:
|
| 168 |
+
"""Service de génération de fiches de renseignements PDF"""
|
| 169 |
+
|
| 170 |
+
def __init__(self, airtable_repo, entreprise_repo):
|
| 171 |
+
"""
|
| 172 |
+
Initialise le service avec les repositories nécessaires
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
airtable_repo: Repository pour la table "Liste des candidats"
|
| 176 |
+
entreprise_repo: Repository pour la table "Fiche de Renseignement Entreprise"
|
| 177 |
+
"""
|
| 178 |
+
self.airtable_repo = airtable_repo
|
| 179 |
+
self.entreprise_repo = entreprise_repo
|
| 180 |
+
self.pdf_template = self._get_template_path()
|
| 181 |
+
|
| 182 |
+
def _get_template_path(self) -> Path:
|
| 183 |
+
"""Trouve le chemin du template PDF"""
|
| 184 |
+
logger.info(f"Recherche du template PDF...")
|
| 185 |
+
logger.info(f" Chemin 1: {PDF_TEMPLATE} (existe: {PDF_TEMPLATE.exists()})")
|
| 186 |
+
logger.info(f" Chemin 2: {ALT_PDF_TEMPLATE} (existe: {ALT_PDF_TEMPLATE.exists()})")
|
| 187 |
+
logger.info(f" Chemin 3: {THIRD_PDF_TEMPLATE} (existe: {THIRD_PDF_TEMPLATE.exists()})")
|
| 188 |
+
|
| 189 |
+
if PDF_TEMPLATE.exists():
|
| 190 |
+
logger.info(f"✅ Template PDF trouvé: {PDF_TEMPLATE}")
|
| 191 |
+
return PDF_TEMPLATE
|
| 192 |
+
elif ALT_PDF_TEMPLATE.exists():
|
| 193 |
+
logger.info(f"✅ Template PDF alternatif trouvé: {ALT_PDF_TEMPLATE}")
|
| 194 |
+
return ALT_PDF_TEMPLATE
|
| 195 |
+
elif THIRD_PDF_TEMPLATE.exists():
|
| 196 |
+
logger.info(f"✅ Template PDF (app/templates) trouvé: {THIRD_PDF_TEMPLATE}")
|
| 197 |
+
return THIRD_PDF_TEMPLATE
|
| 198 |
+
else:
|
| 199 |
+
raise FileNotFoundError(
|
| 200 |
+
f"Template PDF non trouvé. Chemins vérifiés:\n"
|
| 201 |
+
f" - {PDF_TEMPLATE}\n"
|
| 202 |
+
f" - {ALT_PDF_TEMPLATE}\n"
|
| 203 |
+
f" - {THIRD_PDF_TEMPLATE}"
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
def _sanitize_filename(self, text: str) -> str:
|
| 207 |
+
"""Nettoie un texte pour l'utiliser comme nom de fichier"""
|
| 208 |
+
if not text:
|
| 209 |
+
return "inconnu"
|
| 210 |
+
return re.sub(r"[^\w\d-]", "_", str(text))
|
| 211 |
+
|
| 212 |
+
def _get_candidat_data(self, etudiant_id: str) -> Optional[Dict]:
|
| 213 |
+
"""
|
| 214 |
+
Récupère les données du candidat depuis Airtable
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
etudiant_id: L'ID du candidat dans la table "Liste des candidats"
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
Dict avec les données du candidat ou None
|
| 221 |
+
"""
|
| 222 |
+
try:
|
| 223 |
+
record = self.airtable_repo.get_candidate_by_id(etudiant_id)
|
| 224 |
+
if record:
|
| 225 |
+
return record.get('fields', {})
|
| 226 |
+
return None
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"Erreur récupération candidat {etudiant_id}: {e}")
|
| 229 |
+
return None
|
| 230 |
+
|
| 231 |
+
def _get_entreprise_data(self, etudiant_id: str) -> Optional[Dict]:
|
| 232 |
+
"""
|
| 233 |
+
Récupère les données de l'entreprise liée au candidat depuis Airtable
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
etudiant_id: L'ID du candidat (recordIdetudiant dans la table entreprise)
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
Dict avec les données de l'entreprise ou None
|
| 240 |
+
"""
|
| 241 |
+
try:
|
| 242 |
+
# Chercher la fiche entreprise par recordIdetudiant
|
| 243 |
+
formula = f"{{recordIdetudiant}} = '{etudiant_id}'"
|
| 244 |
+
records = self.entreprise_repo.table.all(formula=formula)
|
| 245 |
+
|
| 246 |
+
if records:
|
| 247 |
+
return records[0].get('fields', {})
|
| 248 |
+
|
| 249 |
+
logger.warning(f"Aucune fiche entreprise trouvée pour l'étudiant {etudiant_id}")
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
except Exception as e:
|
| 253 |
+
logger.error(f"Erreur récupération entreprise pour {etudiant_id}: {e}")
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
def _merge_data(self, candidat_data: Dict, entreprise_data: Dict) -> Tuple[Dict, Dict]:
|
| 257 |
+
"""
|
| 258 |
+
Prépare les données fusionnées pour le PDF
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
Tuple (candidat_fields, entreprise_fields)
|
| 262 |
+
"""
|
| 263 |
+
return (
|
| 264 |
+
candidat_data or {},
|
| 265 |
+
entreprise_data or {}
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
def generate_pdf(self, etudiant_id: str) -> Tuple[str, str, str]:
|
| 269 |
+
"""
|
| 270 |
+
Génère le PDF de fiche de renseignements
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
etudiant_id: L'ID du candidat
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
Tuple (pdf_path, nom, prenom) - Chemin du PDF temporaire généré
|
| 277 |
+
|
| 278 |
+
Raises:
|
| 279 |
+
ValueError: Si les données sont insuffisantes
|
| 280 |
+
FileNotFoundError: Si le template n'existe pas
|
| 281 |
+
"""
|
| 282 |
+
logger.info(f"🔄 Génération PDF pour l'étudiant {etudiant_id}")
|
| 283 |
+
|
| 284 |
+
# 1. Récupérer les données des deux tables
|
| 285 |
+
candidat_data = self._get_candidat_data(etudiant_id)
|
| 286 |
+
entreprise_data = self._get_entreprise_data(etudiant_id)
|
| 287 |
+
|
| 288 |
+
if not candidat_data:
|
| 289 |
+
raise ValueError(f"Candidat {etudiant_id} non trouvé dans Airtable")
|
| 290 |
+
|
| 291 |
+
logger.info(f"✅ Données candidat récupérées: {len(candidat_data)} champs")
|
| 292 |
+
|
| 293 |
+
if entreprise_data:
|
| 294 |
+
logger.info(f"✅ Données entreprise récupérées: {len(entreprise_data)} champs")
|
| 295 |
+
else:
|
| 296 |
+
logger.warning(f"⚠️ Pas de données entreprise pour {etudiant_id}")
|
| 297 |
+
entreprise_data = {}
|
| 298 |
+
|
| 299 |
+
# 2. Préparer les données
|
| 300 |
+
candidat_fields, entreprise_fields = self._merge_data(candidat_data, entreprise_data)
|
| 301 |
+
|
| 302 |
+
# 3. Lire le template PDF
|
| 303 |
+
base_pdf = PdfReader(str(self.pdf_template))
|
| 304 |
+
overlay = BytesIO()
|
| 305 |
+
c = canvas.Canvas(overlay, pagesize=A4)
|
| 306 |
+
c.setFont("Helvetica", 9)
|
| 307 |
+
|
| 308 |
+
# 4. Remplir les champs du PDF
|
| 309 |
+
fields_filled = 0
|
| 310 |
+
|
| 311 |
+
for page in base_pdf.pages:
|
| 312 |
+
if page.Annots:
|
| 313 |
+
for annot in page.Annots:
|
| 314 |
+
if annot.T and annot.Rect:
|
| 315 |
+
field_name = annot.T.to_unicode()
|
| 316 |
+
|
| 317 |
+
if field_name in PDF_MAPPING:
|
| 318 |
+
source, airtable_key = PDF_MAPPING[field_name]
|
| 319 |
+
|
| 320 |
+
# Sélectionner la bonne source de données
|
| 321 |
+
if source == "candidat":
|
| 322 |
+
value = str(candidat_fields.get(airtable_key, ""))
|
| 323 |
+
else: # entreprise
|
| 324 |
+
value = str(entreprise_fields.get(airtable_key, ""))
|
| 325 |
+
|
| 326 |
+
if value and value != "None":
|
| 327 |
+
x0, y0, _, _ = map(float, annot.Rect)
|
| 328 |
+
# Ajuster la position pour un meilleur alignement
|
| 329 |
+
c.drawString(x0 + 4, y0 + 6, value[:100]) # Limiter à 100 caractères
|
| 330 |
+
fields_filled += 1
|
| 331 |
+
|
| 332 |
+
c.showPage()
|
| 333 |
+
|
| 334 |
+
c.save()
|
| 335 |
+
overlay.seek(0)
|
| 336 |
+
|
| 337 |
+
logger.info(f"📝 {fields_filled} champs remplis dans le PDF")
|
| 338 |
+
|
| 339 |
+
# 5. Fusionner template et overlay
|
| 340 |
+
overlay_pdf = PdfReader(overlay)
|
| 341 |
+
writer = PdfWriter()
|
| 342 |
+
|
| 343 |
+
for i in range(len(base_pdf.pages)):
|
| 344 |
+
PageMerge(base_pdf.pages[i]).add(overlay_pdf.pages[i]).render()
|
| 345 |
+
writer.addpage(base_pdf.pages[i])
|
| 346 |
+
|
| 347 |
+
# 6. Créer le fichier temporaire
|
| 348 |
+
nom = self._sanitize_filename(candidat_fields.get("NOM", "Nom"))
|
| 349 |
+
prenom = self._sanitize_filename(candidat_fields.get("Prénom", "Prenom"))
|
| 350 |
+
|
| 351 |
+
with tempfile.NamedTemporaryFile(
|
| 352 |
+
delete=False,
|
| 353 |
+
suffix=".pdf",
|
| 354 |
+
prefix=f"fiche_renseignement_{nom}_{prenom}_"
|
| 355 |
+
) as tmp:
|
| 356 |
+
writer.write(tmp.name)
|
| 357 |
+
pdf_path = tmp.name
|
| 358 |
+
logger.info(f"✅ PDF temporaire créé: {pdf_path}")
|
| 359 |
+
|
| 360 |
+
return pdf_path, nom, prenom
|
| 361 |
+
|
| 362 |
+
def generate_and_upload(self, etudiant_id: str) -> Dict:
|
| 363 |
+
"""
|
| 364 |
+
Génère le PDF et l'upload vers Airtable dans la colonne "Fiche entreprise"
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
etudiant_id: L'ID du candidat
|
| 368 |
+
|
| 369 |
+
Returns:
|
| 370 |
+
Dict avec le statut et les détails de l'opération
|
| 371 |
+
"""
|
| 372 |
+
pdf_path = None
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
# 1. Générer le PDF
|
| 376 |
+
pdf_path, nom, prenom = self.generate_pdf(etudiant_id)
|
| 377 |
+
|
| 378 |
+
# 2. Upload vers Airtable (colonne "Fiche entreprise" de la table "Liste des candidats")
|
| 379 |
+
success = self.airtable_repo.upload_document(
|
| 380 |
+
record_id=etudiant_id,
|
| 381 |
+
column_name="Fiche entreprise",
|
| 382 |
+
file_path=pdf_path
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
if success:
|
| 386 |
+
logger.info(f"✅ PDF uploadé vers Airtable pour {etudiant_id}")
|
| 387 |
+
return {
|
| 388 |
+
"status": "success",
|
| 389 |
+
"record_id": etudiant_id,
|
| 390 |
+
"candidate_name": f"{prenom} {nom}",
|
| 391 |
+
"message": "Fiche de renseignements générée et uploadée avec succès"
|
| 392 |
+
}
|
| 393 |
+
else:
|
| 394 |
+
logger.error(f"❌ Échec de l'upload du PDF pour {etudiant_id}")
|
| 395 |
+
return {
|
| 396 |
+
"status": "partial",
|
| 397 |
+
"record_id": etudiant_id,
|
| 398 |
+
"candidate_name": f"{prenom} {nom}",
|
| 399 |
+
"message": "PDF généré mais échec de l'upload vers Airtable"
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
except ValueError as e:
|
| 403 |
+
logger.error(f"❌ Erreur de données: {e}")
|
| 404 |
+
return {
|
| 405 |
+
"status": "error",
|
| 406 |
+
"record_id": etudiant_id,
|
| 407 |
+
"message": str(e)
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
except FileNotFoundError as e:
|
| 411 |
+
logger.error(f"❌ Template non trouvé: {e}")
|
| 412 |
+
return {
|
| 413 |
+
"status": "error",
|
| 414 |
+
"record_id": etudiant_id,
|
| 415 |
+
"message": f"Template PDF non trouvé: {e}"
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
except Exception as e:
|
| 419 |
+
logger.error(f"❌ Erreur inattendue: {e}")
|
| 420 |
+
import traceback
|
| 421 |
+
traceback.print_exc()
|
| 422 |
+
return {
|
| 423 |
+
"status": "error",
|
| 424 |
+
"record_id": etudiant_id,
|
| 425 |
+
"message": f"Erreur lors de la génération: {str(e)}"
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
finally:
|
| 429 |
+
# 3. Nettoyage du fichier temporaire
|
| 430 |
+
if pdf_path and os.path.exists(pdf_path):
|
| 431 |
+
os.remove(pdf_path)
|
| 432 |
+
logger.info(f"🗑️ Fichier temporaire supprimé: {pdf_path}")
|
app/templates/CD_ENTRETIEN_GUIDE.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Template CD Entretien - Grille d'Évaluation CC25
|
| 2 |
+
|
| 3 |
+
Ce template doit être créé dans Word avec les champs suivants :
|
| 4 |
+
|
| 5 |
+
## Structure du document :
|
| 6 |
+
|
| 7 |
+
### En-tête
|
| 8 |
+
- Logo Rush School
|
| 9 |
+
- Titre : "CD ENTRETIEN - GRILLE D'ÉVALUATION"
|
| 10 |
+
- Référence : CC25
|
| 11 |
+
|
| 12 |
+
### 1. INFORMATIONS CANDIDAT
|
| 13 |
+
- Nom : {{ nom }}
|
| 14 |
+
- Prénom : {{ prenom }}
|
| 15 |
+
- Date d'entretien : {{ date_entretien }}
|
| 16 |
+
- Poste visé : {{ poste_vise }}
|
| 17 |
+
- Email : {{ email }}
|
| 18 |
+
- Téléphone : {{ telephone }}
|
| 19 |
+
|
| 20 |
+
### 2. PRÉSENTATION (Note sur 20)
|
| 21 |
+
- Tenue vestimentaire : {{ tenue_vestimentaire }}/5 - {{ tenue_vestimentaire_symbol }}
|
| 22 |
+
- Ponctualité : {{ ponctualite }}/5 - {{ ponctualite_symbol }}
|
| 23 |
+
- Première impression : {{ premiere_impression }}/5 - {{ premiere_impression_symbol }}
|
| 24 |
+
- Commentaires : {{ presentation_commentaires }}
|
| 25 |
+
**Sous-total : {{ presentation_total }}/15**
|
| 26 |
+
|
| 27 |
+
### 3. COMMUNICATION ET EXPRESSION (Note sur 20)
|
| 28 |
+
- Clarté de l'expression : {{ clarte_expression }}/5 - {{ clarte_expression_symbol }}
|
| 29 |
+
- Écoute active : {{ ecoute_active }}/5 - {{ ecoute_active_symbol }}
|
| 30 |
+
- Capacité d'argumentation : {{ capacite_argumentation }}/5 - {{ capacite_argumentation_symbol }}
|
| 31 |
+
- Langage professionnel : {{ langage_professionnel }}/5 - {{ langage_professionnel_symbol }}
|
| 32 |
+
- Commentaires : {{ communication_commentaires }}
|
| 33 |
+
**Sous-total : {{ communication_total }}/20**
|
| 34 |
+
|
| 35 |
+
### 4. COMPÉTENCES TECHNIQUES (Note sur 20)
|
| 36 |
+
- Maîtrise du domaine : {{ maitrise_domaine }}/5 - {{ maitrise_domaine_symbol }}
|
| 37 |
+
- Expérience pertinente : {{ experience_pertinente }}/5 - {{ experience_pertinente_symbol }}
|
| 38 |
+
- Diplômes/Certifications : {{ diplomes_certifications }}/5 - {{ diplomes_certifications_symbol }}
|
| 39 |
+
- Compétences spécifiques : {{ competences_specifiques }}/5 - {{ competences_specifiques_symbol }}
|
| 40 |
+
- Commentaires : {{ competences_techniques_commentaires }}
|
| 41 |
+
**Sous-total : {{ competences_techniques_total }}/20**
|
| 42 |
+
|
| 43 |
+
### 5. COMPÉTENCES COMPORTEMENTALES (Note sur 25)
|
| 44 |
+
- Motivation : {{ motivation }}/5 - {{ motivation_symbol }}
|
| 45 |
+
- Esprit d'équipe : {{ esprit_equipe }}/5 - {{ esprit_equipe_symbol }}
|
| 46 |
+
- Autonomie : {{ autonomie }}/5 - {{ autonomie_symbol }}
|
| 47 |
+
- Adaptabilité : {{ adaptabilite }}/5 - {{ adaptabilite_symbol }}
|
| 48 |
+
- Gestion du stress : {{ gestion_stress }}/5 - {{ gestion_stress_symbol }}
|
| 49 |
+
- Commentaires : {{ competences_comportementales_commentaires }}
|
| 50 |
+
**Sous-total : {{ competences_comportementales_total }}/25**
|
| 51 |
+
|
| 52 |
+
### 6. PROJET PROFESSIONNEL (Note sur 15)
|
| 53 |
+
- Cohérence du projet : {{ coherence_projet }}/5 - {{ coherence_projet_symbol }}
|
| 54 |
+
- Connaissance de l'entreprise : {{ connaissance_entreprise }}/5 - {{ connaissance_entreprise_symbol }}
|
| 55 |
+
- Adéquation avec le poste : {{ adequation_poste }}/5 - {{ adequation_poste_symbol }}
|
| 56 |
+
- Perspectives d'évolution : {{ perspectives_evolution }}/5 - {{ perspectives_evolution_symbol }}
|
| 57 |
+
- Commentaires : {{ projet_professionnel_commentaires }}
|
| 58 |
+
**Sous-total : {{ projet_professionnel_total }}/15**
|
| 59 |
+
|
| 60 |
+
### 7. SYNTHÈSE ET DÉCISION
|
| 61 |
+
|
| 62 |
+
**POINTS FORTS :**
|
| 63 |
+
{{ points_forts }}
|
| 64 |
+
|
| 65 |
+
**POINTS À AMÉLIORER :**
|
| 66 |
+
{{ points_amelioration }}
|
| 67 |
+
|
| 68 |
+
**NOTE GLOBALE : {{ note_globale }}/100**
|
| 69 |
+
|
| 70 |
+
**DÉCISION : {{ decision }}**
|
| 71 |
+
{{ decision_symbol }}
|
| 72 |
+
|
| 73 |
+
**Commentaires finaux :**
|
| 74 |
+
{{ commentaires_finaux }}
|
| 75 |
+
|
| 76 |
+
---
|
| 77 |
+
|
| 78 |
+
**Évaluateur :** {{ evaluateur_nom }}
|
| 79 |
+
**Fonction :** {{ evaluateur_fonction }}
|
| 80 |
+
**Date :** {{ date_evaluation }}
|
| 81 |
+
|
| 82 |
+
**Signature :** ___________________
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
### Footer
|
| 87 |
+
📞 06 38 03 60 18 / 07 86 16 84 00 | ✉️ contact@rush-school.com
|
| 88 |
+
📍 15 Bis rue des Goulvents, 92000 NANTERRE | 🌐 www.rush-school.com
|
app/templates/GUIDE_TEMPLATE.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📝 Guide de création du Template CV
|
| 2 |
+
|
| 3 |
+
## 🎯 Comment créer votre template.docx
|
| 4 |
+
|
| 5 |
+
### Étape 1: Ouvrir Word/LibreOffice
|
| 6 |
+
|
| 7 |
+
Prenez votre CV modèle (comme celui d'Ezechiel Monrou) et remplacez les données par des **placeholders** (balises).
|
| 8 |
+
|
| 9 |
+
### Étape 2: Syntaxe des placeholders
|
| 10 |
+
|
| 11 |
+
Utilisez la syntaxe **Jinja2** :
|
| 12 |
+
|
| 13 |
+
```
|
| 14 |
+
{{ variable }} → Affiche une variable
|
| 15 |
+
{% for item in liste %} → Boucle sur une liste
|
| 16 |
+
{% endfor %} → Fin de boucle
|
| 17 |
+
{% if condition %} → Condition
|
| 18 |
+
{% endif %} → Fin de condition
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
### Étape 3: Variables disponibles
|
| 22 |
+
|
| 23 |
+
#### Informations personnelles
|
| 24 |
+
| Placeholder | Description |
|
| 25 |
+
|------------|-------------|
|
| 26 |
+
| `{{ full_name }}` | Nom complet |
|
| 27 |
+
| `{{ email }}` | Email |
|
| 28 |
+
| `{{ phone }}` | Téléphone |
|
| 29 |
+
| `{{ address }}` | Adresse |
|
| 30 |
+
| `{{ linkedin }}` | LinkedIn (optionnel) |
|
| 31 |
+
| `{{ portfolio }}` | Portfolio (optionnel) |
|
| 32 |
+
| `{{ summary }}` | Résumé/Profil |
|
| 33 |
+
|
| 34 |
+
#### Expériences (boucle)
|
| 35 |
+
```
|
| 36 |
+
{% for exp in experiences %}
|
| 37 |
+
{{ exp.job_title }} - {{ exp.company }}
|
| 38 |
+
({{ exp.start_date }} - {{ exp.end_date }})
|
| 39 |
+
{% for line in exp.description_lines %}
|
| 40 |
+
• {{ line }}
|
| 41 |
+
{% endfor %}
|
| 42 |
+
{% endfor %}
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
#### Formation (boucle)
|
| 46 |
+
```
|
| 47 |
+
{% for edu in education %}
|
| 48 |
+
{{ edu.degree }}
|
| 49 |
+
({{ edu.start_date }} - {{ edu.end_date }}, {{ edu.institution }})
|
| 50 |
+
{{ edu.description }}
|
| 51 |
+
{% endfor %}
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
#### Compétences (boucle)
|
| 55 |
+
```
|
| 56 |
+
{% for skill in skills %}
|
| 57 |
+
• {{ skill.display }}
|
| 58 |
+
{% endfor %}
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
#### Langues (boucle)
|
| 62 |
+
```
|
| 63 |
+
{% for lang in languages %}
|
| 64 |
+
• {{ lang.display }}
|
| 65 |
+
{% endfor %}
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
#### Centres d'intérêt (boucle)
|
| 69 |
+
```
|
| 70 |
+
{% for hobby in hobbies %}
|
| 71 |
+
• {{ hobby }}
|
| 72 |
+
{% endfor %}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### Étape 4: Exemple de template
|
| 76 |
+
|
| 77 |
+
Voici à quoi devrait ressembler votre template :
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
┌──────────────────┬────────────────────────────────────┐
|
| 81 |
+
│ COLONNE │ COLONNE DROITE │
|
| 82 |
+
│ GAUCHE │ │
|
| 83 |
+
│ │ {{ full_name }} │
|
| 84 |
+
│ CONTACT │ Candidat │
|
| 85 |
+
│ ────────── │ │
|
| 86 |
+
│ {{ phone }} │ PROFIL │
|
| 87 |
+
│ {{ email }} │ ────────── │
|
| 88 |
+
│ {{ address }} │ {{ summary }} │
|
| 89 |
+
│ │ │
|
| 90 |
+
│ COMPETENCES │ FORMATION │
|
| 91 |
+
│ ────────── │ ────────── │
|
| 92 |
+
│ {% for skill │ {% for edu in education %} │
|
| 93 |
+
│ in skills %} │ {{ edu.degree }} │
|
| 94 |
+
│ • {{ skill. │ ({{ edu.start_date }} - │
|
| 95 |
+
│ display }} │ {{ edu.end_date }}, │
|
| 96 |
+
│ {% endfor %} │ {{ edu.institution }}) │
|
| 97 |
+
│ │ {% endfor %} │
|
| 98 |
+
│ LANGUES │ │
|
| 99 |
+
│ ────────── │ EXPERIENCES │
|
| 100 |
+
│ {% for lang │ ────────── │
|
| 101 |
+
│ in languages %} │ {% for exp in experiences %} │
|
| 102 |
+
│ • {{ lang. │ {{ exp.job_title }} - │
|
| 103 |
+
│ display }} │ {{ exp.company }} │
|
| 104 |
+
│ {% endfor %} │ ({{ exp.start_date }} - │
|
| 105 |
+
│ │ {{ exp.end_date }}) │
|
| 106 |
+
│ CENTRES │ {% for line in │
|
| 107 |
+
│ D'INTERET │ exp.description_lines %} │
|
| 108 |
+
│ ────────── │ • {{ line }} │
|
| 109 |
+
│ {% for hobby │ {% endfor %} │
|
| 110 |
+
│ in hobbies %} │ {% endfor %} │
|
| 111 |
+
│ • {{ hobby }} │ │
|
| 112 |
+
│ {% endfor %} │ │
|
| 113 |
+
└──────────────────┴────────────────────────────────────┘
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
### Étape 5: Sauvegarder
|
| 117 |
+
|
| 118 |
+
Sauvegardez votre fichier template sous:
|
| 119 |
+
```
|
| 120 |
+
backend/app/templates/template.docx
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
### 🚨 Important
|
| 124 |
+
|
| 125 |
+
1. **Gardez le design** : Le template conservera toutes les polices, couleurs, mises en page
|
| 126 |
+
2. **Testez les boucles** : Assurez-vous que les boucles `{% for %}...{% endfor %}` sont sur des lignes séparées
|
| 127 |
+
3. **Vérifiez les espaces** : Les placeholders doivent être exactement comme indiqué
|
| 128 |
+
|
| 129 |
+
### 📌 Placeholders rapides à copier-coller
|
| 130 |
+
|
| 131 |
+
```
|
| 132 |
+
{{ full_name }}
|
| 133 |
+
{{ email }}
|
| 134 |
+
{{ phone }}
|
| 135 |
+
{{ address }}
|
| 136 |
+
{{ summary }}
|
| 137 |
+
|
| 138 |
+
{% for exp in experiences %}
|
| 139 |
+
{{ exp.job_title }}
|
| 140 |
+
{{ exp.company }}
|
| 141 |
+
{{ exp.start_date }}
|
| 142 |
+
{{ exp.end_date }}
|
| 143 |
+
{% for line in exp.description_lines %}
|
| 144 |
+
{{ line }}
|
| 145 |
+
{% endfor %}
|
| 146 |
+
{% endfor %}
|
| 147 |
+
|
| 148 |
+
{% for edu in education %}
|
| 149 |
+
{{ edu.degree }}
|
| 150 |
+
{{ edu.institution }}
|
| 151 |
+
{{ edu.start_date }}
|
| 152 |
+
{{ edu.end_date }}
|
| 153 |
+
{% endfor %}
|
| 154 |
+
|
| 155 |
+
{% for skill in skills %}
|
| 156 |
+
{{ skill.display }}
|
| 157 |
+
{% endfor %}
|
| 158 |
+
|
| 159 |
+
{% for lang in languages %}
|
| 160 |
+
{{ lang.display }}
|
| 161 |
+
{% endfor %}
|
| 162 |
+
|
| 163 |
+
{% for hobby in hobbies %}
|
| 164 |
+
{{ hobby }}
|
| 165 |
+
{% endfor %}
|
| 166 |
+
```
|
app/templates/entretien.docx
ADDED
|
Binary file (31.5 kB). View file
|
|
|
app/templates/template.docx
ADDED
|
Binary file (73.4 kB). View file
|
|
|
requirements.txt
CHANGED
|
@@ -7,6 +7,7 @@ python-jose[cryptography]==3.3.0
|
|
| 7 |
passlib[bcrypt]==1.7.4
|
| 8 |
aiofiles==23.2.1
|
| 9 |
Pillow==10.1.0
|
| 10 |
-
pydantic==
|
| 11 |
-
pydantic-settings==2.1.0
|
| 12 |
httpx==0.25.2
|
|
|
|
|
|
|
|
|
| 7 |
passlib[bcrypt]==1.7.4
|
| 8 |
aiofiles==23.2.1
|
| 9 |
Pillow==10.1.0
|
| 10 |
+
pydantic==1.10.12
|
|
|
|
| 11 |
httpx==0.25.2
|
| 12 |
+
pdfrw==0.4
|
| 13 |
+
reportlab==4.0.7
|