From 4af98708427e5598da80f3b791f7e867371d61bf Mon Sep 17 00:00:00 2001 From: plenoir Date: Sun, 14 Jun 2026 15:15:26 +0200 Subject: [PATCH 1/4] =?UTF-8?q?#44=20Analyse=20IA=20des=20CV=20au=20charge?= =?UTF-8?q?ment=20et=20=C3=A0=20la=20demande?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Au chargement d'un CV, si une IA est configurée, une case (cochée par défaut, avec avertissement) propose de l'analyser. L'IA extrait les informations principales (profil, expériences, formations, compétences, langues, infos), via un JSON normalisé stocké par CV (coaching.analyze_cv). Les détails sont consultables sur la fiche du CV (cv_detail), l'analyse est remise à zéro à chaque (ré)analyse et relançable depuis la fiche (cv_analyze). Le CV est joint pour Gemini ; texte brut pour les autres fournisseurs. Retrait de la note « prochaine itération » de la liste des CV. Migration 0019, tests, documentation. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + CLAUDE.md | 10 +- README.md | 5 +- templates/tracking/cv_detail.html | 85 +++++++++ templates/tracking/cv_form.html | 11 ++ templates/tracking/cv_list.html | 13 +- tracking/coaching.py | 164 +++++++++++++++++- ...alysis_error_cv_analysis_model_and_more.py | 38 ++++ tracking/models.py | 46 ++++- tracking/tests.py | 136 ++++++++++++++- tracking/urls.py | 2 + tracking/views.py | 53 +++++- 12 files changed, 552 insertions(+), 17 deletions(-) create mode 100644 templates/tracking/cv_detail.html create mode 100644 tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cee4fbb..c66f292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ release du même nom. ## [Non publié] — 1.2.0 +- #44 — Import/analyse des CV : au chargement d'un CV, si une IA est configurée, + une case (cochée par défaut, avec un avertissement) propose de l'**analyser**. + L'IA extrait alors les informations principales (profil, expériences, + formations, compétences, langues, infos diverses), enregistrées **par CV** et + consultables depuis la fiche du CV (clic dans la liste). L'analyse est remise à + zéro à chaque (ré)analyse, relançable à la demande depuis la fiche. - #43 — Sites d'emploi : suppression de la gestion des **identifiants et mots de passe**. Seuls le nom, l'URL et le logo d'un site sont désormais conservés ; les champs `username`/`password` (et leur stockage chiffré) sont retirés du diff --git a/CLAUDE.md b/CLAUDE.md index 10b50ca..eb7a321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,9 @@ docker compose up -d --build # → http://127.0.0.1:53487/ `JobSite` (nom, URL, `is_builtin`, `logo_url` — plus d'identifiants depuis l'issue #43), `Candidature` (cœur du suivi, étapes de progression + `motif_cloture` = clôture), `StatusHistory`, -`Reminder`, `Interview`, `Contact`, `ApiToken`, `CV`, `AIConfig` (singleton de +`Reminder`, `Interview`, `Contact`, `ApiToken`, `CV` (avec analyse IA des +informations principales — champs `analysis`/`analyzed_at`/… , issue #44), +`AIConfig` (singleton de config du coaching IA, clé Gemini chiffrée — issue #33). Énumérations `TextChoices` : `Source`, `Canal`, `Statut`, `MotifCloture` (certaines avec icône emoji dans le libellé pour les menus). @@ -75,6 +77,12 @@ emoji dans le libellé pour les menus). catégorie IA, issue #34). Endpoints POST AJAX `api/coaching/` (bilan) et `api/candidatures//relance/` (mail de relance) ; UI = modal partagé `#ai-modal` dans `base.html` (spinner + rendu Markdown). +- Analyse de CV (issue #44) : `coaching.analyze_cv(cv)` demande à l'IA un JSON + structuré (profil, expériences, formations, compétences, langues, infos), + normalisé et stocké dans `CV.analysis`. CV joint pour Gemini, texte brut pour + les autres fournisseurs (formats texte seulement). Déclenchée au chargement via + la case « Analyser » (vue `cv_create`) ou à la demande (`cv_analyze`) ; + résultat affiché sur la fiche `cv_detail`. - Quotas IA (issue #36) : `ai.generate` renvoie un `GenerationResult` (texte + tokens) ; `coaching._run` journalise chaque appel dans `AIUsage`. `AIConfig` porte une limite mensuelle de tokens par fournisseur (0 = illimitée) ; la diff --git a/README.md b/README.md index 8ad3eae..53d107b 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ données. | `/` (`/candidatures/`) | Liste des candidatures (créer / modifier / consulter) | | `/sites/` | Sites d'emploi : ajout, modification, suppression, logo (#366) | | `/stats/` | Statistiques (#367 — premiers KPI) | -| `/cv/` | CV : chargement et suppression (#368) | +| `/cv/` | CV : chargement, analyse IA des informations principales et suppression (#368, #44) | | `/aide/` | **Options** : apparence (thème), jetons de l'extension Chrome et configuration du coaching IA (#33) | ## Prochaines itérations @@ -220,3 +220,6 @@ données. - #367 : KPI complets (taux de réponse par source, délais moyens, graphes) - #368 : reformatage du CV (technique/pro) + import LinkedIn - #365 : intégration API France Travail, rappels actifs et notifications + +> L'analyse IA du CV (extraction des informations principales) est disponible +> depuis l'issue #44. diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html new file mode 100644 index 0000000..8cd81d5 --- /dev/null +++ b/templates/tracking/cv_detail.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}{{ cv.label }} — CandiTrack{% endblock %} +{% block content %} +
+

{{ cv.label }}

+ ← Retour +
+ +
+

+ Télécharger le fichier + · Ajouté le {{ cv.uploaded_at|date:"d/m/Y H:i" }} +

+
+ {% csrf_token %} + +
+ {% if not ai_config.is_configured %} + Configurez une IA dans Options → IA pour analyser ce CV. + {% endif %} +
+ +{% if cv.analysis_error %} +
⚠️ {{ cv.analysis_error }}
+{% endif %} + +{% if cv.is_analyzed %} +{% with a=cv.analysis %} +
+

Analyse IA

+ {% if a.titre_profil %}

Profil : {{ a.titre_profil }}

{% endif %} + + {% if a.experiences %} +

Expériences

+
    + {% for exp in a.experiences %} +
  • + {{ exp.poste }}{% if exp.entreprise %} — {{ exp.entreprise }}{% endif %} + {% if exp.periode %} ({{ exp.periode }}){% endif %} + {% if exp.description %}
    {{ exp.description }}{% endif %} +
  • + {% endfor %} +
+ {% endif %} + + {% if a.formations %} +

Formations

+
    + {% for f in a.formations %} +
  • + {{ f.intitule }}{% if f.etablissement %} — {{ f.etablissement }}{% endif %} + {% if f.periode %} ({{ f.periode }}){% endif %} +
  • + {% endfor %} +
+ {% endif %} + + {% if a.competences %} +

Compétences

+

{{ a.competences|join:" · " }}

+ {% endif %} + + {% if a.langues %} +

Langues

+

{{ a.langues|join:" · " }}

+ {% endif %} + + {% if a.infos %} +

Informations diverses

+

{{ a.infos|linebreaksbr }}

+ {% endif %} + +

+ Analysé le {{ cv.analyzed_at|date:"d/m/Y H:i" }} + {% if cv.analysis_provider_label %}par {{ cv.analysis_provider_label }}{% endif %} + {% if cv.analysis_model %} · {{ cv.analysis_model }}{% endif %} +

+
+{% endwith %} +{% elif not cv.analysis_error %} +

Ce CV n'a pas encore été analysé.

+{% endif %} +{% endblock %} diff --git a/templates/tracking/cv_form.html b/templates/tracking/cv_form.html index a9a1281..6affd3e 100644 --- a/templates/tracking/cv_form.html +++ b/templates/tracking/cv_form.html @@ -7,6 +7,17 @@

{{ title }}

{% csrf_token %} {{ form.as_p }}

Formats acceptés : PDF, DOC, DOCX, ODT, RTF, TXT.

+ {% if ai_config.is_configured %} +
+

⚠️ Ce CV sera analysé par l'IA configurée + ({{ ai_config.get_provider_display }}) pour en extraire + automatiquement les informations principales (expériences, formations…).

+ +
+ {% endif %}
Annuler diff --git a/templates/tracking/cv_list.html b/templates/tracking/cv_list.html index 29f00de..ca06132 100644 --- a/templates/tracking/cv_list.html +++ b/templates/tracking/cv_list.html @@ -5,20 +5,21 @@

Mes CV

+ Charger un CV
-

Le reformatage en formats techniques/professionnels et l'import LinkedIn - sont prévus pour une prochaine itération.

-
{% if cvs %} - + {% for cv in cvs %} - + + - + {% endfor %} diff --git a/tracking/coaching.py b/tracking/coaching.py index 257cf61..a2b3c5f 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -6,9 +6,13 @@ - :func:`coaching_advice` — un bilan global à partir du CV, des postes visés et des retours reçus (volume de candidatures, motifs de refus…). - :func:`relance_email` — un brouillon de mail de relance pour une candidature. +- :func:`analyze_cv` — extraction des informations principales d'un CV (issue #44). """ +import json + from django.db.models import Count +from django.utils import timezone from . import ai from .models import CV, AIConfig, AIUsage, Candidature, MotifCloture, Statut @@ -19,9 +23,8 @@ MAX_CV_BYTES = 5 * 1024 * 1024 -def _latest_cv_attachment(): - """Renvoie ``(mime_type, bytes)`` du dernier CV, ou ``None``.""" - cv = CV.objects.order_by("-uploaded_at").first() +def cv_attachment(cv): + """``(mime_type, bytes)`` d'un CV donné pour Gemini, ou ``None``.""" if not cv or not cv.file: return None mime = ai.guess_mime(cv.file.name) @@ -36,6 +39,26 @@ def _latest_cv_attachment(): return None +def _latest_cv_attachment(): + """Renvoie ``(mime_type, bytes)`` du dernier CV, ou ``None``.""" + return cv_attachment(CV.objects.order_by("-uploaded_at").first()) + + +def _cv_text(cv): + """Texte brut d'un CV au format texte (fournisseurs autres que Gemini).""" + if not cv or not cv.file: + return "" + mime = ai.guess_mime(cv.file.name) or "" + if not mime.startswith("text/"): + return "" + try: + with cv.file.open("rb") as handle: + raw = handle.read(MAX_CV_BYTES) + except (OSError, ValueError): + return "" + return raw.decode("utf-8", errors="replace").strip() + + def _run(config, prompt, attachments=None): """Appelle l'IA, journalise la consommation (issue #36) et renvoie le texte.""" result = ai.generate( @@ -151,3 +174,138 @@ def relance_email(candidature, config=None): ) return _run(config, prompt) + + +# --- Analyse de CV (issue #44) -------------------------------------------- + +# Schéma JSON attendu de l'IA. On l'impose dans le prompt et on normalise la +# réponse pour que le gabarit puisse l'afficher sans surprise. +CV_ANALYSIS_PROMPT = ( + "Tu es un assistant RH qui analyse des CV. À partir du CV fourni, extrais " + "les informations principales et renvoie UNIQUEMENT un objet JSON valide, " + "sans texte autour ni balises de code, avec exactement ces clés :\n" + '- "titre_profil" : chaîne (intitulé/poste principal du profil) ;\n' + '- "experiences" : liste d\'objets ' + '{"poste", "entreprise", "periode", "description"} ;\n' + '- "formations" : liste d\'objets {"intitule", "etablissement", "periode"} ;\n' + '- "competences" : liste de chaînes ;\n' + '- "langues" : liste de chaînes ;\n' + '- "infos" : chaîne (informations diverses : contact, certifications, ' + "centres d'intérêt…).\n" + "Utilise une liste vide ou une chaîne vide quand l'information est absente. " + "N'invente rien. Réponds en français." +) + + +def _as_text(value): + """Chaîne nettoyée à partir d'une valeur JSON quelconque.""" + return str(value).strip() if value is not None else "" + + +def _as_str_list(value): + """Liste de chaînes non vides à partir d'une valeur JSON.""" + if not isinstance(value, list): + return [] + return [_as_text(item) for item in value if _as_text(item)] + + +def _as_dict_list(value, keys): + """Liste de dictionnaires restreints à ``keys`` (entrées vides ignorées).""" + if not isinstance(value, list): + return [] + rows = [] + for item in value: + if not isinstance(item, dict): + continue + row = {key: _as_text(item.get(key)) for key in keys} + if any(row.values()): + rows.append(row) + return rows + + +def _normalize_cv_analysis(data): + """Structure stable de l'analyse, quel que soit le détail renvoyé par l'IA.""" + return { + "titre_profil": _as_text(data.get("titre_profil")), + "experiences": _as_dict_list( + data.get("experiences"), + ["poste", "entreprise", "periode", "description"], + ), + "formations": _as_dict_list( + data.get("formations"), ["intitule", "etablissement", "periode"] + ), + "competences": _as_str_list(data.get("competences")), + "langues": _as_str_list(data.get("langues")), + "infos": _as_text(data.get("infos")), + } + + +def _parse_cv_analysis(text): + """Parse la réponse de l'IA en dict normalisé, ou ``None`` si illisible.""" + cleaned = text.strip() + # Retire d'éventuelles clôtures Markdown (```json … ```), tolérées en sortie. + if cleaned.startswith("```"): + lines = cleaned.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip().startswith("```"): + lines = lines[:-1] + cleaned = "\n".join(lines).strip() + try: + data = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(data, dict): + return None + return _normalize_cv_analysis(data) + + +def analyze_cv(cv, config=None): + """Analyse un CV via l'IA et enregistre les infos extraites (issue #44). + + L'analyse est remise à zéro avant chaque passe. Le CV est joint en pièce + jointe pour Gemini ; pour les autres fournisseurs, seul un CV au format + texte peut être lu. Lève :class:`ai.AIError` si l'appel échoue ; en cas de + réponse non exploitable ou de format illisible, enregistre un message + d'erreur sur le CV (sans interrompre le chargement). + """ + config = config or AIConfig.load() + cv.reset_analysis() + + attachments = None + extra = "" + if config.provider == AIConfig.Provider.GEMINI: + attachment = cv_attachment(cv) + if attachment: + attachments = [attachment] + extra = "\n\nLe CV est joint à ce message ; analyse-le." + if attachments is None: + text = _cv_text(cv) + if not text: + cv.analysis_error = ( + "Ce format de CV ne peut être lu que par Google Gemini. " + "Choisis Gemini dans Options → IA, ou charge un CV au format texte." + ) + cv.save(update_fields=cv.ANALYSIS_FIELDS) + return cv + extra = "\n\nVoici le contenu texte du CV :\n" + text + + result = ai.generate( + CV_ANALYSIS_PROMPT + extra, + provider=config.provider, + api_key=config.api_key, + model=config.model, + attachments=attachments, + ) + AIUsage.record(config.provider, config.model, result) + + data = _parse_cv_analysis(result.text) + if data is None: + cv.analysis_error = "L'IA n'a pas renvoyé d'analyse exploitable." + else: + cv.analysis = data + cv.analyzed_at = timezone.now() + cv.analysis_provider = config.provider + cv.analysis_model = config.model + cv.save(update_fields=cv.ANALYSIS_FIELDS) + return cv diff --git a/tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py b/tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py new file mode 100644 index 0000000..af7a1d2 --- /dev/null +++ b/tracking/migrations/0019_cv_analysis_cv_analysis_error_cv_analysis_model_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.1 on 2026-06-14 13:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0018_remove_jobsite_password_remove_jobsite_username'), + ] + + operations = [ + migrations.AddField( + model_name='cv', + name='analysis', + field=models.JSONField(blank=True, default=dict, verbose_name='analyse IA'), + ), + migrations.AddField( + model_name='cv', + name='analysis_error', + field=models.CharField(blank=True, max_length=300, verbose_name="erreur d'analyse"), + ), + migrations.AddField( + model_name='cv', + name='analysis_model', + field=models.CharField(blank=True, max_length=100, verbose_name="modèle de l'analyse"), + ), + migrations.AddField( + model_name='cv', + name='analysis_provider', + field=models.CharField(blank=True, max_length=10, verbose_name="fournisseur de l'analyse"), + ), + migrations.AddField( + model_name='cv', + name='analyzed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='analysé le'), + ), + ] diff --git a/tracking/models.py b/tracking/models.py index 3219cf5..660fe83 100644 --- a/tracking/models.py +++ b/tracking/models.py @@ -351,12 +351,36 @@ def cv_upload_path(instance, filename): class CV(models.Model): - """An uploaded CV (issue #368). Reformatting/import is a later iteration.""" + """An uploaded CV (issue #368), optionnellement analysé par l'IA (issue #44). + + Lorsqu'une IA est configurée et que l'utilisateur l'accepte au chargement, + le contenu du CV est analysé pour en extraire les informations principales + (expériences, formations, compétences…), stockées dans :attr:`analysis`. + L'analyse est propre à chaque CV et remise à zéro à chaque (ré)analyse. + """ label = models.CharField(LIBELLE_VERBOSE, max_length=200) file = models.FileField("fichier", upload_to=cv_upload_path) uploaded_at = models.DateTimeField("ajouté le", auto_now_add=True) + # Analyse IA du contenu du CV (issue #44). + analysis = models.JSONField("analyse IA", default=dict, blank=True) + analyzed_at = models.DateTimeField("analysé le", null=True, blank=True) + analysis_provider = models.CharField( + "fournisseur de l'analyse", max_length=10, blank=True + ) + analysis_model = models.CharField("modèle de l'analyse", max_length=100, blank=True) + analysis_error = models.CharField("erreur d'analyse", max_length=300, blank=True) + + # Champs réécrits à chaque (ré)analyse (issue #44). + ANALYSIS_FIELDS = [ + "analysis", + "analyzed_at", + "analysis_provider", + "analysis_model", + "analysis_error", + ] + class Meta: verbose_name = "CV" verbose_name_plural = "CV" @@ -365,6 +389,26 @@ class Meta: def __str__(self): return self.label + @property + def is_analyzed(self): + """Vrai si une analyse IA exploitable a été enregistrée (issue #44).""" + return bool(self.analyzed_at and self.analysis) + + @property + def analysis_provider_label(self): + """Libellé lisible du fournisseur ayant produit l'analyse (issue #44).""" + return dict(AIConfig.Provider.choices).get( + self.analysis_provider, self.analysis_provider + ) + + def reset_analysis(self): + """Remet l'analyse à zéro avant une nouvelle passe (issue #44).""" + self.analysis = {} + self.analyzed_at = None + self.analysis_provider = "" + self.analysis_model = "" + self.analysis_error = "" + class AIConfig(models.Model): """Configuration du module de coaching IA (issues #33, #34, #39). diff --git a/tracking/tests.py b/tracking/tests.py index 2e19106..cfe2097 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1,4 +1,5 @@ import json +import tempfile import urllib.error from unittest import mock @@ -7,9 +8,10 @@ from django.test import TestCase, override_settings from django.urls import reverse -from . import ai +from . import ai, coaching from .forms import CandidatureForm, CVForm, JobSiteForm from .models import ( + CV, AIConfig, AIUsage, ApiToken, @@ -920,6 +922,138 @@ def test_provider_passed_to_client(self, gen): self.assertIsNone(gen.call_args.kwargs.get("attachments")) +CV_ANALYSIS_JSON = json.dumps( + { + "titre_profil": "Développeur Python", + "experiences": [ + { + "poste": "Développeur backend", + "entreprise": "ACME", + "periode": "2020-2023", + "description": "APIs Django", + } + ], + "formations": [ + {"intitule": "Master Informatique", "etablissement": "Univ", "periode": "2018"} + ], + "competences": ["Python", "Django"], + "langues": ["Français", "Anglais"], + "infos": "Permis B", + } +) + + +@override_settings( + CANDITRACK_FERNET_KEY=TEST_FERNET_KEY, MEDIA_ROOT=tempfile.mkdtemp() +) +class CVAnalysisTests(TestCase): + """Issue #44 — analyse IA des CV au chargement et à la demande.""" + + def _configure_ai(self): + config = AIConfig.load() + config.gemini_api_key = "k" + config.save() + return config + + def _upload(self, analyser=True, content=b"Contenu du CV"): + upload = SimpleUploadedFile("cv.txt", content, content_type="text/plain") + data = {"label": "Mon CV", "file": upload} + if analyser: + data["analyser"] = "on" + return self.client.post(reverse("tracking:cv_create"), data) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(CV_ANALYSIS_JSON, 10, 20, 30), + ) + def test_upload_avec_analyse_stocke_les_infos(self, gen): + self._configure_ai() + self._upload(analyser=True) + self.assertTrue(gen.called) + cv = CV.objects.get() + self.assertTrue(cv.is_analyzed) + self.assertEqual(cv.analysis["titre_profil"], "Développeur Python") + self.assertEqual(len(cv.analysis["experiences"]), 1) + self.assertIn("Python", cv.analysis["competences"]) + self.assertEqual(cv.analysis_provider, "gemini") + # La consommation de tokens est journalisée (issue #36). + self.assertEqual(AIUsage.objects.count(), 1) + + @mock.patch("tracking.coaching.ai.generate") + def test_upload_sans_case_cochee_pas_d_analyse(self, gen): + self._configure_ai() + self._upload(analyser=False) + self.assertFalse(gen.called) + self.assertFalse(CV.objects.get().is_analyzed) + + @mock.patch("tracking.coaching.ai.generate") + def test_upload_sans_ia_configuree_pas_d_analyse(self, gen): + self._upload(analyser=True) + self.assertFalse(gen.called) + self.assertFalse(CV.objects.get().is_analyzed) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult("ceci n'est pas du JSON", 1, 1, 2), + ) + def test_reponse_illisible_enregistre_une_erreur(self, _gen): + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + self.assertFalse(cv.is_analyzed) + self.assertTrue(cv.analysis_error) + + @mock.patch("tracking.coaching.ai.generate") + def test_reanalyse_remet_a_zero_et_met_a_jour(self, gen): + config = self._configure_ai() + # Première analyse. + gen.return_value = ai.GenerationResult(CV_ANALYSIS_JSON, 1, 1, 2) + self._upload(analyser=True) + cv = CV.objects.get() + # Ré-analyse avec un autre contenu. + autre = json.dumps({"titre_profil": "Chef de projet", "competences": ["Agile"]}) + gen.return_value = ai.GenerationResult(autre, 1, 1, 2) + self.client.post(reverse("tracking:cv_analyze", args=[cv.pk])) + cv.refresh_from_db() + self.assertEqual(cv.analysis["titre_profil"], "Chef de projet") + self.assertEqual(cv.analysis["experiences"], []) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(CV_ANALYSIS_JSON, 1, 1, 2), + ) + def test_detail_affiche_les_sections(self, _gen): + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertContains(resp, "Expériences") + self.assertContains(resp, "Développeur Python") + self.assertContains(resp, "Python") + + def test_formulaire_propose_la_case_si_ia_configuree(self): + self._configure_ai() + resp = self.client.get(reverse("tracking:cv_create")) + self.assertContains(resp, 'name="analyser"') + + def test_formulaire_sans_case_si_pas_d_ia(self): + resp = self.client.get(reverse("tracking:cv_create")) + self.assertNotContains(resp, 'name="analyser"') + + def test_liste_ne_montre_plus_la_note_future(self): + resp = self.client.get(reverse("tracking:cv_list")) + self.assertNotContains(resp, "prochaine itération") + + def test_parse_tolere_les_balises_de_code(self): + text = "```json\n" + CV_ANALYSIS_JSON + "\n```" + data = coaching._parse_cv_analysis(text) + self.assertIsNotNone(data) + self.assertEqual(data["titre_profil"], "Développeur Python") + + def test_parse_json_invalide_renvoie_none(self): + self.assertIsNone(coaching._parse_cv_analysis("pas du json")) + + def io_bytes(data): """Petit helper : un flux binaire lisible pour simuler HTTPError.read().""" import io diff --git a/tracking/urls.py b/tracking/urls.py index 59c8b9e..64ce2e0 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -26,6 +26,8 @@ path("stats/", views.stats, name="stats"), path("cv/", views.cv_list, name="cv_list"), path("cv/charger/", views.cv_create, name="cv_create"), + path("cv//", views.cv_detail, name="cv_detail"), + path("cv//analyser/", views.cv_analyze, name="cv_analyze"), path("cv//supprimer/", views.cv_delete, name="cv_delete"), # Aide & configuration de l'extension (issue #6) path("aide/", views.help_page, name="help"), diff --git a/tracking/views.py b/tracking/views.py index 510d3c1..aa4bdda 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -289,27 +289,72 @@ def stats(request): @require_GET def cv_list(request): - """Issue #368 — list uploaded CVs (reformat/LinkedIn import come later).""" + """Issue #368 — liste des CV chargés (analyse IA optionnelle, issue #44).""" cvs = CV.objects.all() return render(request, "tracking/cv_list.html", {"cvs": cvs}) +@require_GET +def cv_detail(request, pk): + """Détail d'un CV et de son analyse IA (issue #44).""" + cv = get_object_or_404(CV, pk=pk) + return render( + request, + "tracking/cv_detail.html", + {"cv": cv, "ai_config": AIConfig.load()}, + ) + + +def _analyze_cv_safely(request, cv): + """Analyse un CV en convertissant les erreurs en messages (issue #44).""" + try: + coaching.analyze_cv(cv) + except AIError as exc: + messages.warning(request, f"CV chargé, mais l'analyse IA a échoué : {exc}") + return + if cv.analysis_error: + messages.warning( + request, f"CV chargé, mais l'analyse n'a pu aboutir : {cv.analysis_error}" + ) + else: + messages.success(request, "CV chargé et analysé par l'IA.") + + def cv_create(request): + config = AIConfig.load() if request.method == "POST": form = CVForm(request.POST, request.FILES) if form.is_valid(): - form.save() - messages.success(request, "CV chargé.") + cv = form.save() + # Analyse IA optionnelle, si une IA est configurée et acceptée (issue #44). + if config.is_configured and request.POST.get("analyser"): + _analyze_cv_safely(request, cv) + else: + messages.success(request, "CV chargé.") return redirect("tracking:cv_list") else: form = CVForm() return render( request, "tracking/cv_form.html", - {"form": form, "title": "Charger un CV"}, + {"form": form, "title": "Charger un CV", "ai_config": config}, ) +@require_POST +def cv_analyze(request, pk): + """(Ré)analyse un CV à la demande (issue #44).""" + cv = get_object_or_404(CV, pk=pk) + config = AIConfig.load() + if not config.is_configured: + messages.error( + request, "Aucune clé IA configurée. Renseignez-la dans Options → IA." + ) + else: + _analyze_cv_safely(request, cv) + return redirect("tracking:cv_detail", pk=pk) + + def cv_delete(request, pk): cv = get_object_or_404(CV, pk=pk) if request.method == "POST": From 59936eb03b13f31604734d7d0a17cb2d76ecb460 Mon Sep 17 00:00:00 2001 From: plenoir Date: Sun, 14 Jun 2026 15:22:42 +0200 Subject: [PATCH 2/4] #44 Corrige l'affichage de la case d'analyse et la lecture des CV Word/ODT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La case « Analyser » (et l'encart d'avertissement) réutilisait la classe `.toast`, invisible par défaut (opacité 0, conteneur fixe) : remplacée par un encart classique. Même correction pour l'encart d'erreur de la fiche CV. - Lecture des CV : extraction de texte en stdlib pour les formats Word (.docx) et OpenDocument (.odt), en plus du texte (.txt), pour que l'analyse fonctionne avec tous les fournisseurs (les PDF restent lus directement par Gemini). Message d'erreur clarifié. Co-Authored-By: Claude Opus 4.8 --- templates/tracking/cv_detail.html | 2 +- templates/tracking/cv_form.html | 2 +- tracking/coaching.py | 43 ++++++++++++++++++++++++++----- tracking/tests.py | 37 ++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html index 8cd81d5..bb95303 100644 --- a/templates/tracking/cv_detail.html +++ b/templates/tracking/cv_detail.html @@ -23,7 +23,7 @@

{{ cv.label }}

{% if cv.analysis_error %} -
⚠️ {{ cv.analysis_error }}
+
⚠️ {{ cv.analysis_error }}
{% endif %} {% if cv.is_analyzed %} diff --git a/templates/tracking/cv_form.html b/templates/tracking/cv_form.html index 6affd3e..232063b 100644 --- a/templates/tracking/cv_form.html +++ b/templates/tracking/cv_form.html @@ -8,7 +8,7 @@

{{ title }}

{{ form.as_p }}

Formats acceptés : PDF, DOC, DOCX, ODT, RTF, TXT.

{% if ai_config.is_configured %} -
+

⚠️ Ce CV sera analysé par l'IA configurée ({{ ai_config.get_provider_display }}) pour en extraire automatiquement les informations principales (expériences, formations…).

diff --git a/tracking/coaching.py b/tracking/coaching.py index a2b3c5f..5bf739e 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -9,7 +9,11 @@ - :func:`analyze_cv` — extraction des informations principales d'un CV (issue #44). """ +import html +import io import json +import re +import zipfile from django.db.models import Count from django.utils import timezone @@ -44,19 +48,42 @@ def _latest_cv_attachment(): return cv_attachment(CV.objects.order_by("-uploaded_at").first()) +def _office_xml_text(raw, member, para_close): + """Texte d'un document Office (zip + XML), paragraphes séparés (stdlib).""" + try: + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + xml = archive.read(member).decode("utf-8", errors="replace") + except (zipfile.BadZipFile, KeyError, OSError, ValueError): + return "" + # Saut de ligne en fin de paragraphe, puis suppression de toutes les balises. + xml = xml.replace(para_close, "\n") + text = re.sub(r"<[^>]+>", "", xml) + return html.unescape(text).strip() + + def _cv_text(cv): - """Texte brut d'un CV au format texte (fournisseurs autres que Gemini).""" + """Texte brut d'un CV pour les fournisseurs qui ne lisent pas les pièces jointes. + + Gère, en bibliothèque standard uniquement, les formats texte (.txt), Word + (.docx) et OpenDocument (.odt). Renvoie une chaîne vide pour les formats non + extractibles (ex. PDF, à confier à Gemini). + """ if not cv or not cv.file: return "" - mime = ai.guess_mime(cv.file.name) or "" - if not mime.startswith("text/"): - return "" + name = (cv.file.name or "").lower() try: with cv.file.open("rb") as handle: raw = handle.read(MAX_CV_BYTES) except (OSError, ValueError): return "" - return raw.decode("utf-8", errors="replace").strip() + if name.endswith(".docx"): + return _office_xml_text(raw, "word/document.xml", "") + if name.endswith(".odt"): + return _office_xml_text(raw, "content.xml", "") + mime = ai.guess_mime(cv.file.name) or "" + if mime.startswith("text/"): + return raw.decode("utf-8", errors="replace").strip() + return "" def _run(config, prompt, attachments=None): @@ -283,8 +310,10 @@ def analyze_cv(cv, config=None): text = _cv_text(cv) if not text: cv.analysis_error = ( - "Ce format de CV ne peut être lu que par Google Gemini. " - "Choisis Gemini dans Options → IA, ou charge un CV au format texte." + "Ce format de CV n'a pas pu être lu par le fournisseur actif " + f"({config.get_provider_display()}). Les PDF sont lus directement " + "par Google Gemini ; les formats Word (.docx), OpenDocument (.odt) " + "et texte (.txt) sont lus par tous les fournisseurs." ) cv.save(update_fields=cv.ANALYSIS_FIELDS) return cv diff --git a/tracking/tests.py b/tracking/tests.py index cfe2097..1afc15f 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1053,6 +1053,43 @@ def test_parse_tolere_les_balises_de_code(self): def test_parse_json_invalide_renvoie_none(self): self.assertIsNone(coaching._parse_cv_analysis("pas du json")) + def _docx_bytes(self, text): + import io + import zipfile + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as archive: + archive.writestr( + "word/document.xml", + '' + f"{text}" + "", + ) + return buf.getvalue() + + def test_extrait_le_texte_d_un_docx(self): + cv = CV.objects.create( + label="x", + file=SimpleUploadedFile("cv.docx", self._docx_bytes("Ingénieur logiciel")), + ) + self.assertIn("Ingénieur logiciel", coaching._cv_text(cv)) + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(CV_ANALYSIS_JSON, 1, 1, 2), + ) + def test_analyse_docx_passe_le_texte_dans_le_prompt(self, gen): + self._configure_ai() # Gemini par défaut + cv = CV.objects.create( + label="x", + file=SimpleUploadedFile("cv.docx", self._docx_bytes("Ingénieur logiciel")), + ) + coaching.analyze_cv(cv) + self.assertTrue(cv.is_analyzed) + # Word n'est pas joignable : le texte extrait part dans le prompt, sans pièce jointe. + self.assertIsNone(gen.call_args.kwargs.get("attachments")) + self.assertIn("Ingénieur logiciel", gen.call_args.args[0]) + def io_bytes(data): """Petit helper : un flux binaire lisible pour simuler HTTPError.read().""" From e6d98bd495900b203f894bc8414a4cf7b7d68875 Mon Sep 17 00:00:00 2001 From: plenoir Date: Sun, 14 Jun 2026 19:57:44 +0200 Subject: [PATCH 3/4] #44 Mise en page visuelle de la fiche CV, carte OSM et exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Affichage dynamique de l'analyse (timeline expériences/formations, chips). - Analyse enrichie : localisation, lieux et liens des sociétés/établissements, séparation coordonnées/références et loisirs des infos diverses. - Carte des localisations via OpenStreetMap/Leaflet + géocodage Nominatim (marqueur emoji par type, popup société), sans clé API. - Exports JSON Resume, Europass, HR-Open et PDF professionnel (impression). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 15 +- templates/tracking/cv_detail.html | 358 +++++++++++++++++++++++++----- templates/tracking/cv_print.html | 136 ++++++++++++ tracking/coaching.py | 59 ++++- tracking/cv_export.py | 212 ++++++++++++++++++ tracking/tests.py | 224 ++++++++++++++++++- tracking/urls.py | 2 + tracking/views.py | 57 ++++- 8 files changed, 990 insertions(+), 73 deletions(-) create mode 100644 templates/tracking/cv_print.html create mode 100644 tracking/cv_export.py diff --git a/CLAUDE.md b/CLAUDE.md index eb7a321..5750371 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,11 +78,22 @@ emoji dans le libellé pour les menus). `api/candidatures//relance/` (mail de relance) ; UI = modal partagé `#ai-modal` dans `base.html` (spinner + rendu Markdown). - Analyse de CV (issue #44) : `coaching.analyze_cv(cv)` demande à l'IA un JSON - structuré (profil, expériences, formations, compétences, langues, infos), + structuré (profil, expériences, formations, compétences, langues, coordonnées/ + références — adresse, téléphone, email, permis —, loisirs, infos diverses), normalisé et stocké dans `CV.analysis`. CV joint pour Gemini, texte brut pour les autres fournisseurs (formats texte seulement). Déclenchée au chargement via la case « Analyser » (vue `cv_create`) ou à la demande (`cv_analyze`) ; - résultat affiché sur la fiche `cv_detail`. + résultat affiché sur la fiche `cv_detail`. Le profil donne aussi une + `localisation`, et chaque expérience/formation un `lieu` + un `lien` (URL du + site, validée http(s) via `_as_url`). La fiche affiche les expériences et + formations en timeline et **cartographie les lieux** (`_cv_localisations`) avec + **OpenStreetMap/Leaflet** (géocodage **Nominatim**, marqueur emoji par type, + popup société) — aucune clé API requise. +- Exports de CV (issue #44) : `tracking/cv_export.py` convertit `CV.analysis` en + **JSON Resume**, **Europass** (SkillsPassport) et **HR-Open Standards** + (`EXPORTERS`/`EXPORT_LABELS`, stdlib) ; vues `cv_export` (téléchargement JSON) et + `cv_print` (template `cv_print.html` autonome, **PDF via impression navigateur**). + Boutons sur la fiche `cv_detail`. - Quotas IA (issue #36) : `ai.generate` renvoie un `GenerationResult` (texte + tokens) ; `coaching._run` journalise chaque appel dans `AIUsage`. `AIConfig` porte une limite mensuelle de tokens par fournisseur (0 = illimitée) ; la diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html index bb95303..8dcafe9 100644 --- a/templates/tracking/cv_detail.html +++ b/templates/tracking/cv_detail.html @@ -1,85 +1,323 @@ {% extends "base.html" %} {% block title %}{{ cv.label }} — CandiTrack{% endblock %} {% block content %} -
-

{{ cv.label }}

- ← Retour + + +
+

{{ cv.label }}

+
+
+ {% csrf_token %} + + + ← Retour +
-
-

- Télécharger le fichier +

+ + 📄 Télécharger le fichier · Ajouté le {{ cv.uploaded_at|date:"d/m/Y H:i" }} -

-
- {% csrf_token %} - - +
{% if not ai_config.is_configured %} Configurez une IA dans Options → IA pour analyser ce CV. {% endif %}
{% if cv.analysis_error %} -
⚠️ {{ cv.analysis_error }}
+
⚠️ {{ cv.analysis_error }}
{% endif %} {% if cv.is_analyzed %} {% with a=cv.analysis %} -
-

Analyse IA

- {% if a.titre_profil %}

Profil : {{ a.titre_profil }}

{% endif %} - - {% if a.experiences %} -

Expériences

-
    - {% for exp in a.experiences %} -
  • - {{ exp.poste }}{% if exp.entreprise %} — {{ exp.entreprise }}{% endif %} - {% if exp.periode %} ({{ exp.periode }}){% endif %} - {% if exp.description %}
    {{ exp.description }}{% endif %} -
  • - {% endfor %} -
- {% endif %} +
+
👤
+
+
{% if a.titre_profil %}{{ a.titre_profil }}{% else %}Profil analysé{% endif %}
+ {% if a.localisation %}
📍 {{ a.localisation }}
{% endif %} +
+ Analysé le {{ cv.analyzed_at|date:"d/m/Y H:i" }} + {% if cv.analysis_provider_label %}par {{ cv.analysis_provider_label }}{% endif %} + {% if cv.analysis_model %} · {{ cv.analysis_model }}{% endif %} +
+
+
- {% if a.formations %} -

Formations

-
    - {% for f in a.formations %} -
  • - {{ f.intitule }}{% if f.etablissement %} — {{ f.etablissement }}{% endif %} - {% if f.periode %} ({{ f.periode }}){% endif %} -
  • - {% endfor %} -
- {% endif %} +
+ ⬇️ Exporter : + {% for fmt, label in export_formats.items %} + {{ label }} + {% endfor %} + 📄 PDF professionnel +
- {% if a.competences %} -

Compétences

-

{{ a.competences|join:" · " }}

- {% endif %} +
+
+ {% if a.experiences %} +
+

💼 Expériences

+
    + {% for exp in a.experiences %} +
  • +
    + {{ exp.poste }}{% if exp.entreprise %} · {{ exp.entreprise }}{% endif %} +
    +
    + {% if exp.periode %}🗓️ {{ exp.periode }}{% endif %} + {% if exp.lieu %}📍 {{ exp.lieu }}{% endif %} + {% if exp.lien %}🔗 Site{% endif %} +
    + {% if exp.description %}
    {{ exp.description }}
    {% endif %} +
  • + {% endfor %} +
+
+ {% endif %} - {% if a.langues %} -

Langues

-

{{ a.langues|join:" · " }}

- {% endif %} + {% if a.formations %} +
+

🎓 Formations

+
    + {% for f in a.formations %} +
  • +
    + {{ f.intitule }}{% if f.etablissement %} · {{ f.etablissement }}{% endif %} +
    +
    + {% if f.periode %}🗓️ {{ f.periode }}{% endif %} + {% if f.lieu %}📍 {{ f.lieu }}{% endif %} + {% if f.lien %}🔗 Site{% endif %} +
    +
  • + {% endfor %} +
+
+ {% endif %} - {% if a.infos %} -

Informations diverses

-

{{ a.infos|linebreaksbr }}

- {% endif %} + {% if a.infos %} +
+

ℹ️ Informations diverses

+

{{ a.infos|linebreaksbr }}

+
+ {% endif %} +
+ +
+ {% if a.coordonnees %} + {% with c=a.coordonnees %} +
+

📇 Références

+
+ {% if c.adresse %} +
🏠 + Adresse{{ c.adresse }}
+ {% endif %} + {% if c.telephone %} +
📞 + Téléphone + {{ c.telephone }}
+ {% endif %} + {% if c.email %} +
✉️ + Email + {{ c.email }}
+ {% endif %} + {% if c.permis %} +
🚗 + Permis{{ c.permis }}
+ {% endif %} +
+
+ {% endwith %} + {% endif %} + + {% if a.competences %} +
+

🛠️ Compétences

+
+ {% for c in a.competences %}{{ c }}{% endfor %} +
+
+ {% endif %} + + {% if a.langues %} +
+

🌐 Langues

+
+ {% for l in a.langues %}{{ l }}{% endfor %} +
+
+ {% endif %} -

- Analysé le {{ cv.analyzed_at|date:"d/m/Y H:i" }} - {% if cv.analysis_provider_label %}par {{ cv.analysis_provider_label }}{% endif %} - {% if cv.analysis_model %} · {{ cv.analysis_model }}{% endif %} -

+ {% if a.loisirs %} +
+

🎨 Loisirs

+
+ {% for h in a.loisirs %}{{ h }}{% endfor %} +
+
+ {% endif %} + + {% if localisations %} +
+

🗺️ Localisations

+ {{ localisations|json_script:"cv-localisations-data" }} +
+ +
+ {% endif %} +
+ +{% if localisations %} + + + +{% endif %} {% endwith %} {% elif not cv.analysis_error %} -

Ce CV n'a pas encore été analysé.

+

Ce CV n'a pas encore été analysé.

{% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/templates/tracking/cv_print.html b/templates/tracking/cv_print.html new file mode 100644 index 0000000..505b6f9 --- /dev/null +++ b/templates/tracking/cv_print.html @@ -0,0 +1,136 @@ + +{% with a=cv.analysis %} + + + + + {{ cv.label }} — CV + + + +
+ + ← Retour à la fiche +
+ +
+
+

{{ cv.label }}

+ {% if a.titre_profil %}
{{ a.titre_profil }}
{% endif %} +
+ {% with c=a.coordonnees %} + {% if c.adresse %}🏠 {{ c.adresse }}{% endif %} + {% if a.localisation %}📍 {{ a.localisation }}{% endif %} + {% if c.telephone %}📞 {{ c.telephone }}{% endif %} + {% if c.email %}✉️ {{ c.email }}{% endif %} + {% if c.permis %}🚗 {{ c.permis }}{% endif %} + {% endwith %} +
+
+ + {% if a.experiences %} +
+

Expériences professionnelles

+ {% for exp in a.experiences %} +
+
+ {{ exp.poste }}{% if exp.entreprise %} · {{ exp.entreprise }}{% endif %} + {% if exp.periode %}{{ exp.periode }}{% endif %} +
+ {% if exp.lieu %}
📍 {{ exp.lieu }}{% if exp.lien %} · {{ exp.lien }}{% endif %}
+ {% elif exp.lien %}{% endif %} + {% if exp.description %}
{{ exp.description }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if a.formations %} +
+

Formations

+ {% for f in a.formations %} +
+
+ {{ f.intitule }}{% if f.etablissement %} · {{ f.etablissement }}{% endif %} + {% if f.periode %}{{ f.periode }}{% endif %} +
+ {% if f.lieu %}
📍 {{ f.lieu }}
{% endif %} +
+ {% endfor %} +
+ {% endif %} + + {% if a.competences %} +
+

Compétences

+
{% for c in a.competences %}{{ c }}{% endfor %}
+
+ {% endif %} + + {% if a.langues %} +
+

Langues

+
{% for l in a.langues %}{{ l }}{% endfor %}
+
+ {% endif %} + + {% if a.loisirs %} +
+

Loisirs

+
{% for h in a.loisirs %}{{ h }}{% endfor %}
+
+ {% endif %} + + {% if a.infos %} +
+

Informations diverses

+

{{ a.infos|linebreaksbr }}

+
+ {% endif %} +
+ + +{% endwith %} diff --git a/tracking/coaching.py b/tracking/coaching.py index 5bf739e..befef79 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -212,15 +212,27 @@ def relance_email(candidature, config=None): "les informations principales et renvoie UNIQUEMENT un objet JSON valide, " "sans texte autour ni balises de code, avec exactement ces clés :\n" '- "titre_profil" : chaîne (intitulé/poste principal du profil) ;\n' + '- "localisation" : chaîne (ville/région du candidat) ;\n' '- "experiences" : liste d\'objets ' - '{"poste", "entreprise", "periode", "description"} ;\n' - '- "formations" : liste d\'objets {"intitule", "etablissement", "periode"} ;\n' + '{"poste", "entreprise", "lieu", "lien", "periode", "description"} ;\n' + '- "formations" : liste d\'objets ' + '{"intitule", "etablissement", "lieu", "lien", "periode"} ;\n' '- "competences" : liste de chaînes ;\n' '- "langues" : liste de chaînes ;\n' - '- "infos" : chaîne (informations diverses : contact, certifications, ' - "centres d'intérêt…).\n" + '- "coordonnees" : objet {"adresse", "telephone", "email", "permis"} ' + "(coordonnées et références personnelles du candidat) ;\n" + '- "loisirs" : liste de chaînes (centres d\'intérêt, hobbies) ;\n' + '- "infos" : chaîne (autres informations diverses : certifications, ' + "distinctions… qui ne sont ni des coordonnées ni des loisirs).\n" + 'Pour chaque "lieu", indique la localisation (ville, pays) de l\'entreprise ' + "ou du centre de formation si elle figure dans le CV. " + 'Pour chaque "lien", indique l\'URL du site officiel de l\'entreprise ou du ' + "centre de formation : reprends-la si elle figure dans le CV, sinon déduis " + "l'URL officielle la plus plausible quand tu la connais avec certitude " + "(format https://…), et laisse vide en cas de doute.\n" "Utilise une liste vide ou une chaîne vide quand l'information est absente. " - "N'invente rien. Réponds en français." + "N'invente pas d'expériences, de formations ni de coordonnées. " + "Réponds en français." ) @@ -236,6 +248,19 @@ def _as_str_list(value): return [_as_text(item) for item in value if _as_text(item)] +def _as_url(value): + """URL http(s) nettoyée, ou chaîne vide (écarte les schémas douteux).""" + url = _as_text(value) + if not url: + return "" + if url.startswith(("http://", "https://")): + return url + # Tolère une URL sans schéma (« exemple.com ») en la préfixant en https. + if "." in url and " " not in url and ":" not in url: + return "https://" + url + return "" + + def _as_dict_list(value, keys): """Liste de dictionnaires restreints à ``keys`` (entrées vides ignorées).""" if not isinstance(value, list): @@ -244,25 +269,43 @@ def _as_dict_list(value, keys): for item in value: if not isinstance(item, dict): continue - row = {key: _as_text(item.get(key)) for key in keys} + row = { + key: (_as_url(item.get(key)) if key == "lien" else _as_text(item.get(key))) + for key in keys + } if any(row.values()): rows.append(row) return rows +COORDONNEES_KEYS = ["adresse", "telephone", "email", "permis"] + + +def _as_coordonnees(value): + """Coordonnées/références normalisées, ou ``{}`` si toutes vides (issue #44).""" + if not isinstance(value, dict): + return {} + coords = {key: _as_text(value.get(key)) for key in COORDONNEES_KEYS} + return coords if any(coords.values()) else {} + + def _normalize_cv_analysis(data): """Structure stable de l'analyse, quel que soit le détail renvoyé par l'IA.""" return { "titre_profil": _as_text(data.get("titre_profil")), + "localisation": _as_text(data.get("localisation")), "experiences": _as_dict_list( data.get("experiences"), - ["poste", "entreprise", "periode", "description"], + ["poste", "entreprise", "lieu", "lien", "periode", "description"], ), "formations": _as_dict_list( - data.get("formations"), ["intitule", "etablissement", "periode"] + data.get("formations"), + ["intitule", "etablissement", "lieu", "lien", "periode"], ), "competences": _as_str_list(data.get("competences")), "langues": _as_str_list(data.get("langues")), + "coordonnees": _as_coordonnees(data.get("coordonnees")), + "loisirs": _as_str_list(data.get("loisirs")), "infos": _as_text(data.get("infos")), } diff --git a/tracking/cv_export.py b/tracking/cv_export.py new file mode 100644 index 0000000..08f0f35 --- /dev/null +++ b/tracking/cv_export.py @@ -0,0 +1,212 @@ +"""Export de l'analyse d'un CV vers des formats standards (issue #44). + +À partir du dictionnaire normalisé ``CV.analysis`` (voir +:func:`tracking.coaching._normalize_cv_analysis`), on produit des représentations +structurées selon trois standards de CV/recrutement : + +- **JSON Resume** (https://jsonresume.org/schema/) ; +- **Europass** (modèle « SkillsPassport » JSON) ; +- **HR Open Standards** (objet ``Candidate``, conventions camelCase). + +Les périodes d'expérience/formation du CV étant du texte libre, elles sont +exposées telles quelles (champ ``period`` / ``description``) plutôt que découpées +en dates structurées. Stdlib uniquement. +""" + +EXPORT_LABELS = { + "json-resume": "JSON Resume", + "europass": "Europass", + "hr-open": "HR-Open", +} + + +def _nonempty(items): + """Filtre les chaînes vides d'une liste.""" + return [x for x in items if x] + + +def to_json_resume(cv): + """Convertit l'analyse en document JSON Resume (schéma v1.0.0).""" + a = cv.analysis + coord = a.get("coordonnees", {}) + summary = " ".join(_nonempty([a.get("infos", ""), + "Permis : " + coord["permis"] if coord.get("permis") else ""])) + return { + "$schema": "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json", + "basics": { + "name": cv.label, + "label": a.get("titre_profil", ""), + "email": coord.get("email", ""), + "phone": coord.get("telephone", ""), + "summary": summary, + "location": { + "address": coord.get("adresse", ""), + "region": a.get("localisation", ""), + }, + }, + "work": [ + { + "name": exp.get("entreprise", ""), + "position": exp.get("poste", ""), + "location": exp.get("lieu", ""), + "url": exp.get("lien", ""), + "period": exp.get("periode", ""), + "summary": exp.get("description", ""), + } + for exp in a.get("experiences", []) + ], + "education": [ + { + "institution": form.get("etablissement", ""), + "area": form.get("intitule", ""), + "url": form.get("lien", ""), + "location": form.get("lieu", ""), + "period": form.get("periode", ""), + } + for form in a.get("formations", []) + ], + "skills": [{"name": c} for c in a.get("competences", [])], + "languages": [{"language": lang} for lang in a.get("langues", [])], + "interests": [{"name": h} for h in a.get("loisirs", [])], + } + + +def to_europass(cv): + """Convertit l'analyse au modèle Europass « SkillsPassport » (JSON).""" + a = cv.analysis + coord = a.get("coordonnees", {}) + identification = { + "PersonName": {"FirstName": "", "Surname": cv.label}, + "ContactInfo": {}, + } + contact = identification["ContactInfo"] + if coord.get("adresse") or a.get("localisation"): + contact["Address"] = {"Contact": { + "AddressLine": coord.get("adresse", ""), + "Municipality": a.get("localisation", ""), + }} + if coord.get("email"): + contact["Email"] = {"Contact": coord["email"]} + if coord.get("telephone"): + contact["Telephone"] = [{"Contact": coord["telephone"]}] + + learner = { + "Identification": identification, + "Headline": { + "Type": {"Code": "position", "Label": "Position visée"}, + "Description": {"Label": a.get("titre_profil", "")}, + }, + "WorkExperience": [ + { + "Period": {"Label": exp.get("periode", "")}, + "Position": {"Label": exp.get("poste", "")}, + "Activities": exp.get("description", ""), + "Employer": { + "Name": exp.get("entreprise", ""), + "ContactInfo": { + "Address": {"Contact": {"Municipality": exp.get("lieu", "")}}, + "Website": {"Contact": exp.get("lien", "")}, + }, + }, + } + for exp in a.get("experiences", []) + ], + "Education": [ + { + "Period": {"Label": form.get("periode", "")}, + "Title": form.get("intitule", ""), + "Organisation": { + "Name": form.get("etablissement", ""), + "ContactInfo": { + "Address": {"Contact": {"Municipality": form.get("lieu", "")}}, + "Website": {"Contact": form.get("lien", "")}, + }, + }, + } + for form in a.get("formations", []) + ], + "Skills": { + "Linguistic": { + "ForeignLanguage": [ + {"Description": {"Label": lang}} for lang in a.get("langues", []) + ], + }, + "Other": [{"Description": {"Label": c}} for c in a.get("competences", [])], + }, + } + if coord.get("permis"): + learner["DrivingLicence"] = [coord["permis"]] + if a.get("loisirs"): + learner["Hobbies"] = {"Description": ", ".join(a["loisirs"])} + if a.get("infos"): + learner["Achievement"] = [{"Description": a["infos"]}] + + return { + "SkillsPassport": { + "DocumentInfo": {"DocumentType": "ECV", "Generator": "CandiTrack"}, + "LearnerInfo": learner, + } + } + + +def to_hr_open(cv): + """Convertit l'analyse en objet ``Candidate`` HR Open Standards (JSON).""" + a = cv.analysis + coord = a.get("coordonnees", {}) + communication = {} + if coord.get("email"): + communication["email"] = [{"address": coord["email"]}] + if coord.get("telephone"): + communication["phone"] = [{"dialNumber": coord["telephone"]}] + if coord.get("adresse") or a.get("localisation"): + communication["address"] = [{ + "line": _nonempty([coord.get("adresse", "")]), + "cityName": a.get("localisation", ""), + }] + + candidate = { + "personName": {"formattedName": cv.label}, + "communication": communication, + "profiles": [{"summary": a.get("titre_profil", "")}], + "employmentHistory": [ + { + "organizationName": exp.get("entreprise", ""), + "positionTitle": exp.get("poste", ""), + "locationSummary": exp.get("lieu", ""), + "organizationUrl": exp.get("lien", ""), + "validPeriod": {"description": exp.get("periode", "")}, + "description": exp.get("description", ""), + } + for exp in a.get("experiences", []) + ], + "educationHistory": [ + { + "institutionName": form.get("etablissement", ""), + "programName": form.get("intitule", ""), + "locationSummary": form.get("lieu", ""), + "institutionUrl": form.get("lien", ""), + "validPeriod": {"description": form.get("periode", "")}, + } + for form in a.get("formations", []) + ], + "personCompetencies": [{"competencyName": c} for c in a.get("competences", [])], + "languageCompetencies": [{"languageName": lang} for lang in a.get("langues", [])], + "interests": list(a.get("loisirs", [])), + } + if coord.get("permis"): + candidate["licenses"] = [{"name": coord["permis"]}] + if a.get("infos"): + candidate["additionalInformation"] = a["infos"] + + return { + "schemaName": "Candidate", + "schemaVersion": "1.0", + "candidate": candidate, + } + + +EXPORTERS = { + "json-resume": to_json_resume, + "europass": to_europass, + "hr-open": to_hr_open, +} diff --git a/tracking/tests.py b/tracking/tests.py index 1afc15f..307b467 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -8,7 +8,7 @@ from django.test import TestCase, override_settings from django.urls import reverse -from . import ai, coaching +from . import ai, coaching, cv_export, views from .forms import CandidatureForm, CVForm, JobSiteForm from .models import ( CV, @@ -1031,6 +1031,163 @@ def test_detail_affiche_les_sections(self, _gen): self.assertContains(resp, "Développeur Python") self.assertContains(resp, "Python") + @mock.patch("tracking.coaching.ai.generate") + def test_carte_localisations(self, gen): + """Issue #44 — points de la carte (société + type) ne gardent que les lieux.""" + analyse = json.dumps( + { + "experiences": [ + {"poste": "Dev", "entreprise": "ACME", "lieu": "Paris"}, + {"poste": "Lead", "entreprise": "Sans lieu"}, + ], + "formations": [ + {"intitule": "Master", "etablissement": "Univ", "lieu": "Lyon"} + ], + } + ) + gen.return_value = ai.GenerationResult(analyse, 1, 1, 2) + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + points = views._cv_localisations(cv) + # Seuls les éléments avec un lieu sont retenus. + self.assertEqual([p["lieu"] for p in points], ["Paris", "Lyon"]) + self.assertEqual(points[0]["type"], "exp") + self.assertEqual(points[0]["societe"], "ACME") + self.assertEqual(points[1]["type"], "form") + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult( + json.dumps( + {"experiences": [{"poste": "Dev", "entreprise": "ACME", "lieu": "Paris"}]} + ), + 1, 1, 2, + ), + ) + def test_carte_openstreetmap(self, _gen): + """Issue #44 — carte OpenStreetMap/Leaflet sans clé API.""" + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertContains(resp, 'id="cv-map"') + self.assertContains(resp, 'id="cv-localisations-data"') + # Leaflet + tuiles OSM + données de localisation embarquées. + self.assertContains(resp, "unpkg.com/leaflet") + self.assertContains(resp, "tile.openstreetmap.org") + self.assertContains(resp, "nominatim.openstreetmap.org") + self.assertContains(resp, "ACME") + # Plus aucune dépendance à Google Maps. + self.assertNotContains(resp, "maps.googleapis.com") + + def test_pas_de_carte_sans_localisation(self): + """Sans lieu géolocalisable, aucune carte n'est rendue (issue #44).""" + analyse = json.dumps({"titre_profil": "Dev", "competences": ["Python"]}) + with mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(analyse, 1, 1, 2), + ): + self._configure_ai() + self._upload(analyser=True) + cv = CV.objects.get() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertNotContains(resp, 'id="cv-map"') + + # --- Exports (issue #44) --------------------------------------------- + + EXPORT_ANALYSIS = json.dumps( + { + "titre_profil": "Développeur Python", + "localisation": "Lyon", + "experiences": [ + {"poste": "Dev", "entreprise": "ACME", "lieu": "Paris", + "lien": "https://acme.example", "periode": "2020-2023", + "description": "APIs Django"} + ], + "formations": [ + {"intitule": "Master", "etablissement": "Univ", "lieu": "Lyon", + "periode": "2018"} + ], + "competences": ["Python", "Django"], + "langues": ["Français", "Anglais"], + "coordonnees": {"adresse": "1 rue X", "telephone": "0600", + "email": "a@b.fr", "permis": "Permis B"}, + "loisirs": ["Course"], + "infos": "Certifié AWS", + } + ) + + def _upload_analysed(self): + with mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult(self.EXPORT_ANALYSIS, 1, 1, 2), + ): + self._configure_ai() + self._upload(analyser=True) + return CV.objects.get() + + def test_export_json_resume(self): + cv = self._upload_analysed() + resp = self.client.get( + reverse("tracking:cv_export", args=[cv.pk, "json-resume"]) + ) + self.assertEqual(resp.status_code, 200) + self.assertIn("application/json", resp["Content-Type"]) + self.assertIn("attachment", resp["Content-Disposition"]) + data = json.loads(resp.content) + self.assertEqual(data["basics"]["label"], "Développeur Python") + self.assertEqual(data["basics"]["email"], "a@b.fr") + self.assertEqual(data["work"][0]["position"], "Dev") + self.assertEqual(data["work"][0]["name"], "ACME") + self.assertEqual([s["name"] for s in data["skills"]], ["Python", "Django"]) + self.assertEqual(data["interests"][0]["name"], "Course") + + def test_export_europass(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_export", args=[cv.pk, "europass"])) + data = json.loads(resp.content) + learner = data["SkillsPassport"]["LearnerInfo"] + self.assertEqual(learner["Headline"]["Description"]["Label"], "Développeur Python") + self.assertEqual(learner["WorkExperience"][0]["Position"]["Label"], "Dev") + self.assertEqual(learner["DrivingLicence"], ["Permis B"]) + + def test_export_hr_open(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_export", args=[cv.pk, "hr-open"])) + data = json.loads(resp.content) + cand = data["candidate"] + self.assertEqual(cand["employmentHistory"][0]["positionTitle"], "Dev") + self.assertEqual(cand["languageCompetencies"][0]["languageName"], "Français") + self.assertEqual(cand["licenses"][0]["name"], "Permis B") + + def test_export_format_inconnu_404(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_export", args=[cv.pk, "xml"])) + self.assertEqual(resp.status_code, 404) + + def test_export_cv_non_analyse_404(self): + self._upload(analyser=False) + cv = CV.objects.get() + resp = self.client.get( + reverse("tracking:cv_export", args=[cv.pk, "json-resume"]) + ) + self.assertEqual(resp.status_code, 404) + + def test_vue_impression_pdf(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_print", args=[cv.pk])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Développeur Python") + self.assertContains(resp, "window.print()") + + def test_boutons_export_sur_la_fiche(self): + cv = self._upload_analysed() + resp = self.client.get(reverse("tracking:cv_detail", args=[cv.pk])) + self.assertContains(resp, reverse("tracking:cv_export", args=[cv.pk, "europass"])) + self.assertContains(resp, reverse("tracking:cv_print", args=[cv.pk])) + self.assertContains(resp, "JSON Resume") + def test_formulaire_propose_la_case_si_ia_configuree(self): self._configure_ai() resp = self.client.get(reverse("tracking:cv_create")) @@ -1053,6 +1210,71 @@ def test_parse_tolere_les_balises_de_code(self): def test_parse_json_invalide_renvoie_none(self): self.assertIsNone(coaching._parse_cv_analysis("pas du json")) + def test_parse_extrait_lieux_et_liens(self): + """Issue #44 — localisation, lieux et liens des expériences/formations.""" + text = json.dumps( + { + "localisation": "Lyon, France", + "experiences": [ + { + "poste": "Dev", + "entreprise": "ACME", + "lieu": "Paris", + "lien": "acme.example", + "periode": "2020", + } + ], + "formations": [ + { + "intitule": "Master", + "etablissement": "Univ", + "lieu": "Lyon", + "lien": "https://univ.example", + } + ], + } + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["localisation"], "Lyon, France") + self.assertEqual(data["experiences"][0]["lieu"], "Paris") + # URL sans schéma → préfixée en https. + self.assertEqual(data["experiences"][0]["lien"], "https://acme.example") + self.assertEqual(data["formations"][0]["lien"], "https://univ.example") + + def test_parse_separe_coordonnees_et_loisirs(self): + """Issue #44 — références (coordonnées) et loisirs en sections distinctes.""" + text = json.dumps( + { + "coordonnees": { + "adresse": "1 rue X, Paris", + "telephone": "0600000000", + "email": "a@b.fr", + "permis": "Permis B", + }, + "loisirs": ["Course à pied", "Photographie"], + "infos": "Certifié AWS", + } + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["coordonnees"]["telephone"], "0600000000") + self.assertEqual(data["coordonnees"]["permis"], "Permis B") + self.assertEqual(data["loisirs"], ["Course à pied", "Photographie"]) + self.assertEqual(data["infos"], "Certifié AWS") + + def test_parse_coordonnees_vides_donnent_dict_vide(self): + """Des coordonnées absentes restent un dict vide (issue #44).""" + data = coaching._parse_cv_analysis(json.dumps({"titre_profil": "Dev"})) + self.assertEqual(data["coordonnees"], {}) + self.assertEqual(data["loisirs"], []) + + def test_parse_ecarte_les_liens_dangereux(self): + """Un schéma non http(s) est écarté (issue #44).""" + text = json.dumps( + {"experiences": [{"poste": "Dev", "lien": "javascript:alert(1)"}]} + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["experiences"][0]["lien"], "") + def _docx_bytes(self, text): import io import zipfile diff --git a/tracking/urls.py b/tracking/urls.py index 64ce2e0..b7af25e 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -28,6 +28,8 @@ path("cv/charger/", views.cv_create, name="cv_create"), path("cv//", views.cv_detail, name="cv_detail"), path("cv//analyser/", views.cv_analyze, name="cv_analyze"), + path("cv//export//", views.cv_export, name="cv_export"), + path("cv//imprimer/", views.cv_print, name="cv_print"), path("cv//supprimer/", views.cv_delete, name="cv_delete"), # Aide & configuration de l'extension (issue #6) path("aide/", views.help_page, name="help"), diff --git a/tracking/views.py b/tracking/views.py index aa4bdda..2afa5c8 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -6,12 +6,14 @@ from django.conf import settings from django.contrib import messages from django.db.models import Case, IntegerField, Q, When -from django.http import HttpResponse, JsonResponse +from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils.text import slugify from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET, require_POST from . import coaching +from . import cv_export as cv_exporters from .ai import AIError from .forms import CandidatureForm, CVForm, JobSiteForm from .models import ( @@ -294,6 +296,29 @@ def cv_list(request): return render(request, "tracking/cv_list.html", {"cvs": cvs}) +def _cv_localisations(cv): + """Points du parcours (lieu + société + type) pour la carte des lieux (issue #44).""" + if not cv.is_analyzed: + return [] + analysis = cv.analysis + points = [] + for exp in analysis.get("experiences", []): + if exp.get("lieu"): + points.append( + {"type": "exp", "lieu": exp["lieu"], "societe": exp.get("entreprise", "")} + ) + for form in analysis.get("formations", []): + if form.get("lieu"): + points.append( + { + "type": "form", + "lieu": form["lieu"], + "societe": form.get("etablissement", ""), + } + ) + return points + + @require_GET def cv_detail(request, pk): """Détail d'un CV et de son analyse IA (issue #44).""" @@ -301,10 +326,38 @@ def cv_detail(request, pk): return render( request, "tracking/cv_detail.html", - {"cv": cv, "ai_config": AIConfig.load()}, + { + "cv": cv, + "ai_config": AIConfig.load(), + "localisations": _cv_localisations(cv), + "export_formats": cv_exporters.EXPORT_LABELS, + }, ) +@require_GET +def cv_export(request, pk, fmt): + """Exporte l'analyse d'un CV vers un format standard (issue #44).""" + cv = get_object_or_404(CV, pk=pk) + exporter = cv_exporters.EXPORTERS.get(fmt) + if not cv.is_analyzed or exporter is None: + raise Http404("Export indisponible pour ce CV.") + payload = json.dumps(exporter(cv), ensure_ascii=False, indent=2) + filename = f"{slugify(cv.label) or 'cv'}-{fmt}.json" + response = HttpResponse(payload, content_type="application/json; charset=utf-8") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + return response + + +@require_GET +def cv_print(request, pk): + """Vue d'impression d'un CV (PDF via l'impression navigateur, issue #44).""" + cv = get_object_or_404(CV, pk=pk) + if not cv.is_analyzed: + raise Http404("Ce CV n'a pas encore été analysé.") + return render(request, "tracking/cv_print.html", {"cv": cv}) + + def _analyze_cv_safely(request, cv): """Analyse un CV en convertissant les erreurs en messages (issue #44).""" try: From a1a432bf62620ebef400d2c2138f443c9a1ff24f Mon Sep 17 00:00:00 2001 From: plenoir Date: Sun, 14 Jun 2026 20:04:41 +0200 Subject: [PATCH 4/4] #44 Force https pour les liens de CV (quality gate Sonar S5332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le helper _as_url n'accepte plus que http/https et promeut les liens http en https (évite le contenu mixte), sans littéral « http:// » signalé par SonarCloud (security hotspot S5332). Co-Authored-By: Claude Opus 4.8 --- tracking/coaching.py | 12 +++++++++--- tracking/tests.py | 16 +++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/tracking/coaching.py b/tracking/coaching.py index befef79..3587de5 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -249,12 +249,18 @@ def _as_str_list(value): def _as_url(value): - """URL http(s) nettoyée, ou chaîne vide (écarte les schémas douteux).""" + """URL https nettoyée, ou chaîne vide. + + Seuls les schémas http/https sont acceptés ; un lien http est promu en https + (évite tout contenu mixte sur une page servie en https) et les schémas + douteux (``javascript:``…) sont écartés. + """ url = _as_text(value) if not url: return "" - if url.startswith(("http://", "https://")): - return url + scheme, sep, rest = url.partition("://") + if sep: + return "https://" + rest if scheme.lower() in {"http", "https"} else "" # Tolère une URL sans schéma (« exemple.com ») en la préfixant en https. if "." in url and " " not in url and ":" not in url: return "https://" + url diff --git a/tracking/tests.py b/tracking/tests.py index 307b467..12601e2 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1270,10 +1270,24 @@ def test_parse_coordonnees_vides_donnent_dict_vide(self): def test_parse_ecarte_les_liens_dangereux(self): """Un schéma non http(s) est écarté (issue #44).""" text = json.dumps( - {"experiences": [{"poste": "Dev", "lien": "javascript:alert(1)"}]} + { + "experiences": [ + {"poste": "A", "lien": "javascript:alert(1)"}, + {"poste": "B", "lien": "ftp://host.example/x"}, + ] + } ) data = coaching._parse_cv_analysis(text) self.assertEqual(data["experiences"][0]["lien"], "") + self.assertEqual(data["experiences"][1]["lien"], "") + + def test_parse_promeut_les_liens_http_en_https(self): + """Un lien http est promu en https pour éviter le contenu mixte (issue #44).""" + text = json.dumps( + {"experiences": [{"poste": "Dev", "lien": "http://acme.example/x"}]} + ) + data = coaching._parse_cv_analysis(text) + self.assertEqual(data["experiences"][0]["lien"], "https://acme.example/x") def _docx_bytes(self, text): import io
LibelléFichierAjouté leActions
LibelléFichierAnalyse IAAjouté leActions
{{ cv.label }}{{ cv.label }} Télécharger{% if cv.is_analyzed %}✓ analysé{% elif cv.analysis_error %}⚠️ échec{% else %}{% endif %} {{ cv.uploaded_at|date:"d/m/Y H:i" }}Supprimer + Détails + Supprimer +