Liantsoaxx08 commited on
Commit
8629ddc
·
1 Parent(s): d57f194

ajout du module generation de fiche de renseignement

Browse files
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==2.5.0
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