From 7180a10645c434733d944bc7f77cb19fffd8c210 Mon Sep 17 00:00:00 2001 From: plenoir Date: Tue, 16 Jun 2026 19:50:51 +0200 Subject: [PATCH 1/9] =?UTF-8?q?#61=20#62=20=C3=89dition=20de=20l'analyse?= =?UTF-8?q?=20CV=20et=20r=C3=A9f=C3=A9rences=20=C3=A0=20fournir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #61 — Édition manuelle de l'analyse d'un CV : vue cv_edit + formulaire JS dynamique (sérialisation JSON), normalisation partagée via coaching.normalize_cv_analysis. Bouton « Modifier l'analyse » sur la fiche. #62 — Modèle Reference (rattaché à un CV, lié facultativement à une expérience), ReferenceForm, vues create/update/delete et gestion depuis la fiche CV. La section « Références » de l'ancien bloc coordonnées est renommée « Coordonnées ». Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 ++ CLAUDE.md | 14 ++ templates/tracking/cv_detail.html | 53 +++++- templates/tracking/cv_edit.html | 228 +++++++++++++++++++++++++ templates/tracking/reference_form.html | 16 ++ tracking/coaching.py | 5 + tracking/forms.py | 38 ++++- tracking/migrations/0027_reference.py | 33 ++++ tracking/models.py | 56 ++++++ tracking/tests.py | 135 ++++++++++++++- tracking/urls.py | 9 + tracking/views.py | 89 +++++++++- 12 files changed, 685 insertions(+), 4 deletions(-) create mode 100644 templates/tracking/cv_edit.html create mode 100644 templates/tracking/reference_form.html create mode 100644 tracking/migrations/0027_reference.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f2bec33..afb6d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ versionnage [SemVer](https://semver.org/lang/fr/). Chaque version correspond à un milestone GitHub ; la liste des issues traitées est aussi publiée dans la release du même nom. +## [Non publié] — 1.3.0 + +- #61 — CV : **édition manuelle de l'analyse**. Une fois un CV chargé (analysé + par l'IA ou non), un bouton « ✏️ Modifier l'analyse » ouvre un formulaire + permettant de corriger ou compléter chaque section (profil, coordonnées, + compétences, langues, loisirs, infos) et d'**ajouter/supprimer** des + expériences et des formations. Les données sont normalisées comme une analyse + IA pour conserver une structure stable. +- #62 — CV : **références à fournir**. Depuis la fiche d'un CV (en pratique le CV + par défaut), on peut enregistrer des référents (nom, prénom, téléphone, email, + LinkedIn), chacun pouvant être **rattaché à une expérience professionnelle** du + CV. Les références s'affichent sur la fiche avec des liens de contact directs. + ## [Non publié] — 1.2.2 - #55 — Sites d'emploi : ajout d'un **type** (Généraliste / ESN / Direct) ; les diff --git a/CLAUDE.md b/CLAUDE.md index 45d205a..05f0ab3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,12 @@ fiche, issue #58), `StatusHistory`, `Reminder`, `Interview`, `Contact`, `ApiToken`, `CV` (avec analyse IA des informations principales — champs `analysis`/`analyzed_at`/… , issue #44 ; +analyse **éditable manuellement** via `cv_edit`, issue #61 ; `actif` = archivage, issue #48 ; `par_defaut` = CV dont l'adresse sert d'origine aux trajets, issue #52), +`Reference` (référent à fournir : nom/prénom/téléphone/email/linkedin, rattaché +à une expérience du CV via `experience_index` = rang dans +`CV.analysis['experiences']` ; géré depuis la fiche CV, issue #62), `AIConfig` (singleton de config du coaching IA, clé Gemini chiffrée — issue #33). Énumérations `TextChoices` : `Canal`, `Statut`, `MotifCloture` (certaines avec icône @@ -107,6 +111,16 @@ emoji dans le libellé pour les menus). 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. +- Édition de l'analyse (issue #61) : vue `cv_edit` + template `cv_edit.html` + (formulaire dynamique JS qui sérialise les sections en JSON, sans formset). Le + POST repasse par `coaching.normalize_cv_analysis` (alias public de + `_normalize_cv_analysis`) pour garantir la même structure que l'IA ; un CV non + analysé devient « analysé » dès la première saisie manuelle. +- Références (issue #62) : modèle `Reference` (FK `CV`), `ReferenceForm` propose + l'expérience associée dans une liste déroulante (rang -> libellé) construite + depuis `cv.analysis['experiences']`. Vues `reference_create/update/delete`, + affichage et ajout depuis `cv_detail`. La section « 📇 Références » de l'ancien + bloc coordonnées a été renommée « Coordonnées » pour libérer le terme. - 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 diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html index 87858a7..307467f 100644 --- a/templates/tracking/cv_detail.html +++ b/templates/tracking/cv_detail.html @@ -75,6 +75,21 @@ border:2px solid var(--accent); box-shadow:var(--shadow); font-size:0.95rem; } + /* Références à fournir (issue #62). */ + .cv-reflist { display:flex; flex-direction:column; gap:0.7rem; } + .cv-refcard { border:1px solid var(--border); border-radius:var(--radius-sm); + padding:0.7rem 0.85rem; display:flex; justify-content:space-between; + gap:0.8rem; flex-wrap:wrap; align-items:flex-start; } + .cv-refcard .ref-name { font-weight:700; color:var(--text); } + .cv-refcard .ref-exp { font-size:0.78rem; color:var(--accent); background:var(--accent-soft); + display:inline-block; padding:0.1rem 0.5rem; border-radius:999px; margin-top:0.3rem; } + .cv-refcard .ref-contacts { display:flex; flex-wrap:wrap; gap:0.4rem; margin-top:0.4rem; } + .cv-refcard .ref-contacts a { display:inline-flex; align-items:center; gap:0.25rem; + font-size:0.8rem; padding:0.1rem 0.5rem; border-radius:999px; + background:var(--chip-bg); color:var(--chip-text); text-decoration:none; } + .cv-refcard .ref-contacts a:hover { text-decoration:underline; } + .cv-refcard .ref-acts { display:flex; gap:0.35rem; } + /* Barre d'export (JSON Resume, Europass, HR-Open, PDF). */ .cv-exports { display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; } .cv-exports-label { font-weight:600; color:var(--muted); margin-right:0.2rem; } @@ -91,6 +106,7 @@

{{ cv.label }}{% if not cv.actif %} archivé{% en {% if cv.is_analyzed %}🔄 Ré-analyser{% else %}✨ Analyser avec l'IA{% endif %} + ✏️ Modifier l'analyse
{% csrf_token %} @@ -203,7 +219,7 @@

ℹ️ Informations divers {% if a.coordonnees %} {% with c=a.coordonnees %}
-

📇 Références

+

📇 Coordonnées

{% if c.adresse %}
🏠 @@ -336,4 +352,39 @@

🗺️ Localisations

{% elif not cv.analysis_error %}

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

{% endif %} + +{# Références à fournir (issue #62) — indépendantes de l'analyse IA. #} +
+
+

🤝 Références à fournir

+ + Ajouter une référence +
+ {% if cv.references.all %} +
+ {% for ref in cv.references.all %} +
+
+ {{ ref }} + {% if ref.experience_label %}
💼 {{ ref.experience_label }}
{% endif %} +
+ {% if ref.telephone %}📞 {{ ref.telephone }}{% endif %} + {% if ref.email %}✉️ {{ ref.email }}{% endif %} + {% if ref.linkedin %}🔗 LinkedIn{% endif %} +
+
+
+ ✏️ + + {% csrf_token %} + + +
+
+ {% endfor %} +
+ {% else %} +

Aucune référence enregistrée pour ce CV.

+ {% endif %} +
{% endblock %} \ No newline at end of file diff --git a/templates/tracking/cv_edit.html b/templates/tracking/cv_edit.html new file mode 100644 index 0000000..ae5cc3d --- /dev/null +++ b/templates/tracking/cv_edit.html @@ -0,0 +1,228 @@ +{% extends "base.html" %} +{% block title %}Modifier l'analyse — {{ cv.label }}{% endblock %} +{% block content %} + + +
+

✏️ Modifier l'analyse

+ ← Retour +
+

CV « {{ cv.label }} » — corrigez ou complétez chaque section, ajoutez expériences, formations, compétences…

+ +{{ analysis_json|json_script:"cv-analysis-data" }} + +
+ {% csrf_token %} + + +
+

👤 Profil

+
+ + +
+
+ + +
+
+ +
+

💼 Expériences

+
+ +
+ +
+

🎓 Formations

+
+ +
+ +
+

📇 Coordonnées

+
+
+
+
+
+
+
+
+
+
+
+ +
+

🛠️ Compétences

+
+ +
+ +
+

🌐 Langues

+
+ +
+ +
+

🎨 Loisirs

+
+ +
+ +
+

ℹ️ Informations diverses

+
+ +
+
+ +
+ + Annuler +
+
+ + +{% endblock %} diff --git a/templates/tracking/reference_form.html b/templates/tracking/reference_form.html new file mode 100644 index 0000000..7a1ad4f --- /dev/null +++ b/templates/tracking/reference_form.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — CandiTrack{% endblock %} +{% block content %} +

{{ title }}

+

CV « {{ cv.label }} »

+
+
+ {% csrf_token %} + {{ form.as_p }} +
+ + Annuler +
+
+
+{% endblock %} diff --git a/tracking/coaching.py b/tracking/coaching.py index 3587de5..7454782 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -316,6 +316,11 @@ def _normalize_cv_analysis(data): } +# Alias public : l'édition manuelle d'un CV réutilise la même normalisation +# que l'analyse IA pour garantir une structure stable (issue #61). +normalize_cv_analysis = _normalize_cv_analysis + + def _parse_cv_analysis(text): """Parse la réponse de l'IA en dict normalisé, ou ``None`` si illisible.""" cleaned = text.strip() diff --git a/tracking/forms.py b/tracking/forms.py index dc74dcd..59a4b8e 100644 --- a/tracking/forms.py +++ b/tracking/forms.py @@ -3,7 +3,7 @@ from django import forms from .logos import favicon_service_url -from .models import CV, Candidature, JobSite +from .models import CV, Candidature, JobSite, Reference class CandidatureForm(forms.ModelForm): @@ -132,3 +132,39 @@ def clean_file(self): f"Fichier trop volumineux ({actuel:.1f} Mo). Taille maximale : {limite} Mo." ) return f + + +class ReferenceForm(forms.ModelForm): + """Saisie d'une référence rattachée à un CV (issue #62). + + L'expérience associée est proposée dans une liste déroulante construite à + partir des expériences analysées du CV (rang -> libellé). + """ + + class Meta: + model = Reference + fields = ["nom", "prenom", "telephone", "email", "linkedin", "experience_index"] + widgets = { + "telephone": forms.TextInput(attrs={"placeholder": "06 12 34 56 78"}), + "linkedin": forms.URLInput( + attrs={"placeholder": "https://www.linkedin.com/in/…"} + ), + } + + def __init__(self, *args, cv=None, **kwargs): + super().__init__(*args, **kwargs) + cv = cv or getattr(self.instance, "cv", None) + experiences = (cv.analysis or {}).get("experiences") or [] if cv else [] + choices = [("", "— Aucune —")] + for i, exp in enumerate(experiences): + parts = [p for p in (exp.get("poste"), exp.get("entreprise")) if p] + choices.append((str(i), " · ".join(parts) or f"Expérience {i + 1}")) + # On remplace le champ entier par une liste déroulante des expériences, + # tout en conservant un entier (ou None) côté modèle. + self.fields["experience_index"] = forms.TypedChoiceField( + label="Expérience associée", + choices=choices, + required=False, + coerce=int, + empty_value=None, + ) diff --git a/tracking/migrations/0027_reference.py b/tracking/migrations/0027_reference.py new file mode 100644 index 0000000..2cbe45f --- /dev/null +++ b/tracking/migrations/0027_reference.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.1 on 2026-06-16 17:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0026_alter_candidature_envoyee'), + ] + + operations = [ + migrations.CreateModel( + name='Reference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nom', models.CharField(max_length=100, verbose_name='nom')), + ('prenom', models.CharField(blank=True, max_length=100, verbose_name='prénom')), + ('telephone', models.CharField(blank=True, max_length=40, verbose_name='téléphone')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email')), + ('linkedin', models.URLField(blank=True, verbose_name='LinkedIn')), + ('experience_index', models.PositiveIntegerField(blank=True, null=True, verbose_name='expérience associée')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('cv', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='tracking.cv', verbose_name='CV')), + ], + options={ + 'verbose_name': 'référence', + 'verbose_name_plural': 'références', + 'ordering': ['nom', 'prenom'], + }, + ), + ] diff --git a/tracking/models.py b/tracking/models.py index e5bfd13..6426fd0 100644 --- a/tracking/models.py +++ b/tracking/models.py @@ -455,6 +455,62 @@ def reset_analysis(self): self.analysis_error = "" +class Reference(models.Model): + """Personne pouvant servir de référence pour un CV (issue #62). + + Coordonnées d'un référent à transmettre à un interlocuteur, rattachées + facultativement à une expérience professionnelle du CV — désignée par son + rang dans l'analyse IA (``CV.analysis['experiences']``). Les références sont + gérées depuis la fiche du CV, en pratique le CV par défaut. + """ + + cv = models.ForeignKey( + CV, + verbose_name="CV", + on_delete=models.CASCADE, + related_name="references", + ) + nom = models.CharField("nom", max_length=100) + prenom = models.CharField("prénom", max_length=100, blank=True) + telephone = models.CharField("téléphone", max_length=40, blank=True) + email = models.EmailField("email", blank=True) + linkedin = models.URLField("LinkedIn", blank=True) + # Rang de l'expérience associée dans l'analyse du CV (issue #62) ; null si la + # référence n'est rattachée à aucune expérience précise. + experience_index = models.PositiveIntegerField( + "expérience associée", null=True, blank=True + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "référence" + verbose_name_plural = "références" + ordering = ["nom", "prenom"] + + def __str__(self): + parts = [p for p in (self.prenom, self.nom) if p] + return " ".join(parts) or "Référence" + + @property + def experience(self): + """Expérience associée (dict de l'analyse), ou ``None`` (issue #62).""" + if self.experience_index is None: + return None + experiences = (self.cv.analysis or {}).get("experiences") or [] + if 0 <= self.experience_index < len(experiences): + return experiences[self.experience_index] + return None + + @property + def experience_label(self): + """Libellé court de l'expérience associée, ou chaîne vide (issue #62).""" + exp = self.experience + if not exp: + return "" + parts = [p for p in (exp.get("poste"), exp.get("entreprise")) if p] + return " · ".join(parts) + + 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 9892018..8b398f5 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -7,6 +7,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings from django.urls import reverse +from django.utils import timezone from . import ai, coaching, cv_export, views from .forms import CandidatureForm, CVForm, JobSiteForm @@ -19,6 +20,7 @@ Candidature, JobSite, MotifCloture, + Reference, Statut, ) from .statistics import compute_stats @@ -1788,4 +1790,135 @@ def test_chart_animation_hooks(self): resp = self.client.get(reverse("tracking:stats")) self.assertContains(resp, "js-bar") self.assertContains(resp, "js-seg") - self.assertContains(resp, "data-dash") + + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class CVEditTests(TestCase): + """Issue #61 — édition manuelle des sections de l'analyse d'un CV.""" + + def _make_cv(self, **kwargs): + return CV.objects.create( + label="CV", file=SimpleUploadedFile("cv.txt", b"x"), **kwargs + ) + + def _post(self, cv, payload): + return self.client.post( + reverse("tracking:cv_edit", args=[cv.pk]), + {"analysis": json.dumps(payload)}, + ) + + def test_edition_enregistre_les_sections(self): + cv = self._make_cv() + payload = { + "titre_profil": "Dev", + "experiences": [{"poste": "Lead", "entreprise": "Acme"}], + "competences": ["Python", ""], + } + resp = self._post(cv, payload) + self.assertRedirects(resp, reverse("tracking:cv_detail", args=[cv.pk])) + cv.refresh_from_db() + self.assertTrue(cv.is_analyzed) + self.assertEqual(cv.analysis["titre_profil"], "Dev") + self.assertEqual(len(cv.analysis["experiences"]), 1) + # La normalisation retire les chaînes vides des listes. + self.assertEqual(cv.analysis["competences"], ["Python"]) + + def test_edition_marque_le_cv_analyse(self): + cv = self._make_cv() + self.assertFalse(cv.is_analyzed) + self._post(cv, {"titre_profil": "X"}) + cv.refresh_from_db() + self.assertIsNotNone(cv.analyzed_at) + + def test_json_invalide_n_ecrase_pas(self): + cv = self._make_cv( + analysis={"titre_profil": "Ancien"}, analyzed_at=timezone.now() + ) + resp = self.client.post( + reverse("tracking:cv_edit", args=[cv.pk]), {"analysis": "{pas du json"} + ) + self.assertEqual(resp.status_code, 200) + cv.refresh_from_db() + self.assertEqual(cv.analysis["titre_profil"], "Ancien") + + def test_page_edition_accessible(self): + cv = self._make_cv() + resp = self.client.get(reverse("tracking:cv_edit", args=[cv.pk])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Modifier l'analyse") + + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class ReferenceTests(TestCase): + """Issue #62 — références à fournir, rattachées à un CV.""" + + def setUp(self): + self.cv = CV.objects.create( + label="CV", + file=SimpleUploadedFile("cv.txt", b"x"), + analysis={ + "experiences": [ + {"poste": "Lead", "entreprise": "Acme"}, + {"poste": "Dev", "entreprise": "Globex"}, + ] + }, + analyzed_at=timezone.now(), + ) + + def test_creation_reference(self): + resp = self.client.post( + reverse("tracking:reference_create", args=[self.cv.pk]), + { + "nom": "Durand", + "prenom": "Marie", + "email": "marie@example.com", + "experience_index": "0", + }, + ) + self.assertRedirects(resp, reverse("tracking:cv_detail", args=[self.cv.pk])) + ref = Reference.objects.get() + self.assertEqual(ref.cv, self.cv) + self.assertEqual(ref.experience_index, 0) + self.assertEqual(ref.experience_label, "Lead · Acme") + + def test_reference_sans_experience(self): + self.client.post( + reverse("tracking:reference_create", args=[self.cv.pk]), + {"nom": "Petit", "experience_index": ""}, + ) + ref = Reference.objects.get() + self.assertIsNone(ref.experience_index) + self.assertEqual(ref.experience_label, "") + + def test_experience_label_hors_borne(self): + ref = Reference.objects.create(cv=self.cv, nom="X", experience_index=9) + self.assertEqual(ref.experience_label, "") + + def test_modification_reference(self): + ref = Reference.objects.create(cv=self.cv, nom="X") + self.client.post( + reverse("tracking:reference_update", args=[ref.pk]), + {"nom": "Y", "experience_index": "1"}, + ) + ref.refresh_from_db() + self.assertEqual(ref.nom, "Y") + self.assertEqual(ref.experience_index, 1) + + def test_suppression_reference(self): + ref = Reference.objects.create(cv=self.cv, nom="X") + resp = self.client.post(reverse("tracking:reference_delete", args=[ref.pk])) + self.assertRedirects(resp, reverse("tracking:cv_detail", args=[self.cv.pk])) + self.assertEqual(Reference.objects.count(), 0) + + def test_suppression_refuse_get(self): + ref = Reference.objects.create(cv=self.cv, nom="X") + resp = self.client.get(reverse("tracking:reference_delete", args=[ref.pk])) + self.assertEqual(resp.status_code, 405) + + def test_reference_affichee_sur_la_fiche(self): + Reference.objects.create( + cv=self.cv, nom="Durand", prenom="Marie", experience_index=0 + ) + resp = self.client.get(reverse("tracking:cv_detail", args=[self.cv.pk])) + self.assertContains(resp, "Marie Durand") + self.assertContains(resp, "Lead · Acme") diff --git a/tracking/urls.py b/tracking/urls.py index 4dde0f1..d8ffc86 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -28,11 +28,20 @@ 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//modifier/", views.cv_edit, name="cv_edit"), path("cv//archiver/", views.cv_toggle_active, name="cv_toggle_active"), path("cv//defaut/", views.cv_set_default, name="cv_set_default"), 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"), + # Références d'un CV (issue #62) + path( + "cv//references/ajouter/", + views.reference_create, + name="reference_create", + ), + path("references//modifier/", views.reference_update, name="reference_update"), + path("references//supprimer/", views.reference_delete, name="reference_delete"), # Aide & configuration de l'extension (issue #6) path("aide/", views.help_page, name="help"), path("aide/extension.zip", views.extension_download, name="extension_download"), diff --git a/tracking/views.py b/tracking/views.py index 67f0006..bd1b612 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -9,6 +9,7 @@ from django.db.models import Case, IntegerField, Q, When from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.utils.text import slugify from django.views.decorators.csrf import csrf_exempt @@ -17,7 +18,7 @@ from . import coaching from . import cv_export as cv_exporters from .ai import AIError -from .forms import CandidatureForm, CVForm, JobSiteForm +from .forms import CandidatureForm, CVForm, JobSiteForm, ReferenceForm from .models import ( CV, AIConfig, @@ -25,6 +26,7 @@ ApiToken, Candidature, JobSite, + Reference, Statut, StatusHistory, ) @@ -449,6 +451,91 @@ def cv_analyze(request, pk): return redirect("tracking:cv_detail", pk=pk) +def cv_edit(request, pk): + """Édition manuelle des sections de l'analyse d'un CV (issue #61). + + Permet de corriger/compléter les informations extraites par l'IA — ou d'en + saisir de toutes pièces si le CV n'a pas été analysé. Les données arrivent + sérialisées en JSON (construit côté client) puis sont normalisées comme une + analyse IA pour garantir une structure stable. + """ + cv = get_object_or_404(CV, pk=pk) + if request.method == "POST": + raw = request.POST.get("analysis", "") + try: + data = json.loads(raw) if raw else {} + except (json.JSONDecodeError, ValueError): + data = None + if not isinstance(data, dict): + messages.error(request, "Données d'analyse invalides.") + else: + cv.analysis = coaching.normalize_cv_analysis(data) + cv.analysis_error = "" + # Un CV jamais analysé devient « analysé » dès la première saisie. + if not cv.analyzed_at: + cv.analyzed_at = timezone.now() + cv.save(update_fields=["analysis", "analysis_error", "analyzed_at"]) + messages.success(request, "Analyse du CV mise à jour.") + return redirect("tracking:cv_detail", pk=cv.pk) + return render( + request, + "tracking/cv_edit.html", + { + "cv": cv, + "analysis_json": json.dumps(cv.analysis or {}, ensure_ascii=False), + }, + ) + + +def reference_create(request, cv_pk): + """Ajoute une référence rattachée à un CV (issue #62).""" + cv = get_object_or_404(CV, pk=cv_pk) + if request.method == "POST": + form = ReferenceForm(request.POST, cv=cv) + if form.is_valid(): + reference = form.save(commit=False) + reference.cv = cv + reference.save() + messages.success(request, "Référence ajoutée.") + return redirect("tracking:cv_detail", pk=cv.pk) + else: + form = ReferenceForm(cv=cv) + return render( + request, + "tracking/reference_form.html", + {"form": form, "cv": cv, "title": "Ajouter une référence"}, + ) + + +def reference_update(request, pk): + """Modifie une référence existante (issue #62).""" + reference = get_object_or_404(Reference, pk=pk) + cv = reference.cv + if request.method == "POST": + form = ReferenceForm(request.POST, instance=reference, cv=cv) + if form.is_valid(): + form.save() + messages.success(request, "Référence mise à jour.") + return redirect("tracking:cv_detail", pk=cv.pk) + else: + form = ReferenceForm(instance=reference, cv=cv) + return render( + request, + "tracking/reference_form.html", + {"form": form, "cv": cv, "title": "Modifier la référence"}, + ) + + +@require_POST +def reference_delete(request, pk): + """Supprime une référence (issue #62).""" + reference = get_object_or_404(Reference, pk=pk) + cv_pk = reference.cv_id + reference.delete() + messages.success(request, "Référence supprimée.") + return redirect("tracking:cv_detail", pk=cv_pk) + + def cv_delete(request, pk): cv = get_object_or_404(CV, pk=pk) if request.method == "POST": From 6f4e588532aa32d2951a971cdbdaffd74c19291b Mon Sep 17 00:00:00 2001 From: plenoir Date: Tue, 16 Jun 2026 20:02:22 +0200 Subject: [PATCH 2/9] =?UTF-8?q?#61=20=C3=89dition=20de=20l'analyse=20CV=20?= =?UTF-8?q?par=20section=20+=20puces=20en=20retours=20ligne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Édition localisée : cv_edit(pk, section) ne modifie qu'une section à la fois (CV_SECTIONS + _apply_cv_section), re-normalisée via normalize_cv_analysis. Boutons « ✏️ » par section sur la fiche, sections vides affichées et éditables. - Correction du bug d'éditeur vide : la valeur était json.dumps puis ré-encodée par json_script (double encodage) ; on passe désormais l'objet brut. - Prompt d'analyse : les puces « - » des descriptions sont restituées en retours à la ligne ; rendu via linebreaksbr sur la fiche. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 +- CLAUDE.md | 16 +- templates/tracking/cv_detail.html | 72 ++++---- templates/tracking/cv_edit.html | 269 +++++++++++++----------------- tracking/coaching.py | 4 + tracking/tests.py | 56 +++++-- tracking/urls.py | 2 +- tracking/views.py | 73 ++++++-- 8 files changed, 272 insertions(+), 233 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb6d38..c8cdae6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,13 @@ release du même nom. ## [Non publié] — 1.3.0 -- #61 — CV : **édition manuelle de l'analyse**. Une fois un CV chargé (analysé - par l'IA ou non), un bouton « ✏️ Modifier l'analyse » ouvre un formulaire - permettant de corriger ou compléter chaque section (profil, coordonnées, - compétences, langues, loisirs, infos) et d'**ajouter/supprimer** des - expériences et des formations. Les données sont normalisées comme une analyse - IA pour conserver une structure stable. +- #61 — CV : **édition manuelle de l'analyse, section par section**. Chaque + section de la fiche (profil, expériences, formations, coordonnées, + compétences, langues, loisirs, infos) porte son propre bouton « ✏️ » qui + ouvre un éditeur dédié ; on peut corriger, compléter et **ajouter/supprimer** + expériences, formations et items de liste sans toucher au reste. Les sections + vides sont affichées et restent éditables. L'analyse IA distingue désormais + les puces « - » des descriptions et les restitue en **retours à la ligne**. - #62 — CV : **références à fournir**. Depuis la fiche d'un CV (en pratique le CV par défaut), on peut enregistrer des référents (nom, prénom, téléphone, email, LinkedIn), chacun pouvant être **rattaché à une expérience professionnelle** du diff --git a/CLAUDE.md b/CLAUDE.md index 05f0ab3..6135b6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,11 +111,17 @@ emoji dans le libellé pour les menus). 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. -- Édition de l'analyse (issue #61) : vue `cv_edit` + template `cv_edit.html` - (formulaire dynamique JS qui sérialise les sections en JSON, sans formset). Le - POST repasse par `coaching.normalize_cv_analysis` (alias public de - `_normalize_cv_analysis`) pour garantir la même structure que l'IA ; un CV non - analysé devient « analysé » dès la première saisie manuelle. +- Édition de l'analyse (issue #61) : **par section**. `CV_SECTIONS` (vues) + décrit chaque section éditable (label/icône/`kind`) ; la vue + `cv_edit(pk, section)` ne modifie que la section ciblée (`_apply_cv_section`) + puis re-normalise tout via `coaching.normalize_cv_analysis` (alias public de + `_normalize_cv_analysis`). Template `cv_edit.html` = éditeur JS dynamique + piloté par `kind` (profile/coord/text/chips/experiences/formations), + sérialisé dans un champ caché `value` (objet passé via `json_script`, **pas** + pré-`json.dumps` sinon double encodage). `cv_detail` affiche toutes les + sections (même vides) avec un bouton `.sec-edit` par section. Un CV non + analysé devient « analysé » dès la première saisie. Les descriptions + d'expériences sont rendues avec `linebreaksbr` (puces « - » → retours ligne). - Références (issue #62) : modèle `Reference` (FK `CV`), `ReferenceForm` propose l'expérience associée dans une liste déroulante (rang -> libellé) construite depuis `cv.analysis['experiences']`. Vues `reference_create/update/delete`, diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html index 307467f..b04343e 100644 --- a/templates/tracking/cv_detail.html +++ b/templates/tracking/cv_detail.html @@ -26,6 +26,10 @@ .cv-section-title { display:flex; align-items:center; gap:0.5rem; margin:0 0 0.9rem; font-size:1.05rem; color:var(--accent); } .cv-section-title .ico { font-size:1.15rem; } + /* Bouton d'édition localisée d'une section (issue #61). */ + .sec-edit, .sec-edit:visited, .sec-edit:hover { margin-left:auto; flex:0 0 auto; + font-size:0.9rem; width:30px; height:30px; } + .cv-empty { color:var(--muted); font-size:0.9rem; margin:0; } /* Timeline pour expériences et formations. */ .cv-timeline { list-style:none; padding:0; margin:0; position:relative; } @@ -106,7 +110,6 @@

{{ cv.label }}{% if not cv.actif %} archivé{% en {% if cv.is_analyzed %}🔄 Ré-analyser{% else %}✨ Analyser avec l'IA{% endif %} - ✏️ Modifier l'analyse
{% csrf_token %} @@ -141,21 +144,24 @@

📨
⚠️ {{ cv.analysis_error }}
{% endif %} -{% if cv.is_analyzed %} {% with a=cv.analysis %}
👤
-
{% if a.titre_profil %}{{ a.titre_profil }}{% else %}Profil analysé{% endif %}
+
{% if a.titre_profil %}{{ a.titre_profil }}{% else %}Profil à compléter{% endif %}
{% if a.localisation %}
📍 {{ a.localisation }}
{% endif %} + {% if cv.is_analyzed %}
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 %}
+ {% endif %}
+ ✏️
+{% if cv.is_analyzed %}
⬇️ Exporter : {% for fmt, label in export_formats.items %} @@ -163,12 +169,14 @@

📨 {% endfor %} 📄 PDF professionnel

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

💼 Expériences

+

💼 Expériences + ✏️

+ {% if a.experiences %}
    {% for exp in a.experiences %}
  • @@ -180,16 +188,17 @@

    💼 Expériences

    {% if exp.lieu %}📍 {{ exp.lieu }}{% endif %} {% if exp.lien %}🔗 Site{% endif %}
- {% if exp.description %}
{{ exp.description }}
{% endif %} + {% if exp.description %}
{{ exp.description|linebreaksbr }}
{% endif %} {% endfor %} + {% else %}

Aucune expérience renseignée.

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

🎓 Formations

+

🎓 Formations + ✏️

+ {% if a.formations %}
    {% for f in a.formations %}
  • @@ -204,22 +213,23 @@

    🎓 Formations

  • {% endfor %}
+ {% else %}

Aucune formation renseignée.

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

ℹ️ Informations diverses

-

{{ a.infos|linebreaksbr }}

+

ℹ️ Informations diverses + ✏️

+ {% if a.infos %}

{{ a.infos|linebreaksbr }}

+ {% else %}

Aucune information renseignée.

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

📇 Coordonnées

+

📇 Coordonnées + ✏️

+ {% with c=a.coordonnees %} + {% if c.adresse or c.telephone or c.email or c.permis %}
{% if c.adresse %}
🏠 @@ -240,36 +250,39 @@

📇 Coordonnées

Permis{{ c.permis }}
{% endif %}
+ {% else %}

Aucune coordonnée renseignée.

{% endif %} + {% endwith %}
- {% endwith %} - {% endif %} - {% if a.competences %}
-

🛠️ Compétences

+

🛠️ Compétences + ✏️

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

Aucune compétence renseignée.

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

🌐 Langues

+

🌐 Langues + ✏️

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

Aucune langue renseignée.

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

🎨 Loisirs

+

🎨 Loisirs + ✏️

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

Aucun loisir renseigné.

{% endif %}
- {% endif %} {% if localisations %}
@@ -349,9 +362,6 @@

🗺️ Localisations

{% endif %} {% endwith %} -{% elif not cv.analysis_error %} -

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

-{% endif %} {# Références à fournir (issue #62) — indépendantes de l'analyse IA. #}
diff --git a/templates/tracking/cv_edit.html b/templates/tracking/cv_edit.html index ae5cc3d..1f57c3a 100644 --- a/templates/tracking/cv_edit.html +++ b/templates/tracking/cv_edit.html @@ -1,14 +1,11 @@ {% extends "base.html" %} -{% block title %}Modifier l'analyse — {{ cv.label }}{% endblock %} +{% block title %}Modifier « {{ meta.label }} » — {{ cv.label }}{% endblock %} {% block content %}
-

✏️ Modifier l'analyse

+

{{ meta.icon }} Modifier — {{ meta.label }}

← Retour
-

CV « {{ cv.label }} » — corrigez ou complétez chaque section, ajoutez expériences, formations, compétences…

+

CV « {{ cv.label }} » — seule cette section est modifiée, le reste de l'analyse est conservé.

-{{ analysis_json|json_script:"cv-analysis-data" }} +{{ value|json_script:"cv-section-value" }} {% csrf_token %} - - -
-

👤 Profil

-
- - -
-
- - -
-
- -
-

💼 Expériences

-
- -
- -
-

🎓 Formations

-
- + +
+
- -
-

📇 Coordonnées

-
-
-
-
-
-
-
-
-
-
-
- -
-

🛠️ Compétences

-
- -
- -
-

🌐 Langues

-
- -
- -
-

🎨 Loisirs

-
- -
- -
-

ℹ️ Informations diverses

-
- -
-
-
Annuler @@ -102,93 +38,140 @@

ℹ️ Informations diverses

diff --git a/tracking/coaching.py b/tracking/coaching.py index 7454782..a1bda56 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -230,6 +230,10 @@ def relance_email(candidature, config=None): "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" + 'Dans "description", lorsque le CV présente plusieurs missions ou tâches ' + "introduites par des tirets « - » (ou des puces), restitue chacune sur sa " + "propre ligne en les séparant par des retours à la ligne (\\n), pour une " + "mise en page lisible.\n" "Utilise une liste vide ou une chaîne vide quand l'information est absente. " "N'invente pas d'expériences, de formations ni de coordonnées. " "Réponds en français." diff --git a/tracking/tests.py b/tracking/tests.py index 8b398f5..8a4d63a 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1801,32 +1801,48 @@ def _make_cv(self, **kwargs): label="CV", file=SimpleUploadedFile("cv.txt", b"x"), **kwargs ) - def _post(self, cv, payload): + def _post(self, cv, section, value): return self.client.post( - reverse("tracking:cv_edit", args=[cv.pk]), - {"analysis": json.dumps(payload)}, + reverse("tracking:cv_edit", args=[cv.pk, section]), + {"value": json.dumps(value)}, ) - def test_edition_enregistre_les_sections(self): + def test_edition_section_experiences(self): cv = self._make_cv() - payload = { - "titre_profil": "Dev", - "experiences": [{"poste": "Lead", "entreprise": "Acme"}], - "competences": ["Python", ""], - } - resp = self._post(cv, payload) + resp = self._post(cv, "experiences", [{"poste": "Lead", "entreprise": "Acme"}]) self.assertRedirects(resp, reverse("tracking:cv_detail", args=[cv.pk])) cv.refresh_from_db() self.assertTrue(cv.is_analyzed) - self.assertEqual(cv.analysis["titre_profil"], "Dev") self.assertEqual(len(cv.analysis["experiences"]), 1) - # La normalisation retire les chaînes vides des listes. + + def test_edition_section_profil(self): + cv = self._make_cv() + self._post(cv, "profil", {"titre_profil": "Dev", "localisation": "Lyon"}) + cv.refresh_from_db() + self.assertEqual(cv.analysis["titre_profil"], "Dev") + self.assertEqual(cv.analysis["localisation"], "Lyon") + + def test_edition_section_ne_touche_pas_les_autres(self): + cv = self._make_cv( + analysis={"titre_profil": "Dev", "competences": ["Python"]}, + analyzed_at=timezone.now(), + ) + # On ne modifie que les langues : le reste doit être préservé. + self._post(cv, "langues", ["Anglais"]) + cv.refresh_from_db() + self.assertEqual(cv.analysis["langues"], ["Anglais"]) + self.assertEqual(cv.analysis["titre_profil"], "Dev") self.assertEqual(cv.analysis["competences"], ["Python"]) + def test_section_inconnue_404(self): + cv = self._make_cv() + resp = self.client.get(reverse("tracking:cv_edit", args=[cv.pk, "inexistante"])) + self.assertEqual(resp.status_code, 404) + def test_edition_marque_le_cv_analyse(self): cv = self._make_cv() self.assertFalse(cv.is_analyzed) - self._post(cv, {"titre_profil": "X"}) + self._post(cv, "profil", {"titre_profil": "X"}) cv.refresh_from_db() self.assertIsNotNone(cv.analyzed_at) @@ -1835,17 +1851,21 @@ def test_json_invalide_n_ecrase_pas(self): analysis={"titre_profil": "Ancien"}, analyzed_at=timezone.now() ) resp = self.client.post( - reverse("tracking:cv_edit", args=[cv.pk]), {"analysis": "{pas du json"} + reverse("tracking:cv_edit", args=[cv.pk, "profil"]), + {"value": "{pas du json"}, ) self.assertEqual(resp.status_code, 200) cv.refresh_from_db() self.assertEqual(cv.analysis["titre_profil"], "Ancien") - def test_page_edition_accessible(self): - cv = self._make_cv() - resp = self.client.get(reverse("tracking:cv_edit", args=[cv.pk])) + def test_page_edition_prefille_la_valeur(self): + cv = self._make_cv( + analysis={"titre_profil": "Dev Python"}, analyzed_at=timezone.now() + ) + resp = self.client.get(reverse("tracking:cv_edit", args=[cv.pk, "profil"])) self.assertEqual(resp.status_code, 200) - self.assertContains(resp, "Modifier l'analyse") + # La valeur courante est sérialisée pour pré-remplir l'éditeur JS. + self.assertContains(resp, "Dev Python") @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) diff --git a/tracking/urls.py b/tracking/urls.py index d8ffc86..c7bc95a 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -28,7 +28,7 @@ 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//modifier/", views.cv_edit, name="cv_edit"), + path("cv//modifier//", views.cv_edit, name="cv_edit"), path("cv//archiver/", views.cv_toggle_active, name="cv_toggle_active"), path("cv//defaut/", views.cv_set_default, name="cv_set_default"), path("cv//export//", views.cv_export, name="cv_export"), diff --git a/tracking/views.py b/tracking/views.py index bd1b612..78df2e3 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -451,39 +451,76 @@ def cv_analyze(request, pk): return redirect("tracking:cv_detail", pk=pk) -def cv_edit(request, pk): - """Édition manuelle des sections de l'analyse d'un CV (issue #61). +# Sections éditables d'une analyse de CV (issue #61). Chaque section porte un +# libellé et un « kind » qui pilote le widget d'édition côté gabarit. +CV_SECTIONS = { + "profil": {"label": "Profil", "icon": "👤", "kind": "profile"}, + "experiences": {"label": "Expériences", "icon": "💼", "kind": "experiences"}, + "formations": {"label": "Formations", "icon": "🎓", "kind": "formations"}, + "coordonnees": {"label": "Coordonnées", "icon": "📇", "kind": "coord"}, + "competences": {"label": "Compétences", "icon": "🛠️", "kind": "chips"}, + "langues": {"label": "Langues", "icon": "🌐", "kind": "chips"}, + "loisirs": {"label": "Loisirs", "icon": "🎨", "kind": "chips"}, + "infos": {"label": "Informations diverses", "icon": "ℹ️", "kind": "text"}, +} + + +def _cv_section_value(cv, section): + """Valeur courante d'une section de l'analyse, pour pré-remplir l'éditeur.""" + analysis = cv.analysis or {} + if section == "profil": + return { + "titre_profil": analysis.get("titre_profil", ""), + "localisation": analysis.get("localisation", ""), + } + if section == "coordonnees": + return analysis.get("coordonnees") or {} + if section == "infos": + return analysis.get("infos", "") + return analysis.get(section) or [] + + +def _apply_cv_section(analysis, section, value): + """Fusionne la valeur éditée d'une section dans l'analyse (issue #61).""" + data = dict(analysis or {}) + if section == "profil": + data["titre_profil"] = (value or {}).get("titre_profil", "") + data["localisation"] = (value or {}).get("localisation", "") + else: + data[section] = value + return data + - Permet de corriger/compléter les informations extraites par l'IA — ou d'en - saisir de toutes pièces si le CV n'a pas été analysé. Les données arrivent - sérialisées en JSON (construit côté client) puis sont normalisées comme une - analyse IA pour garantir une structure stable. +def cv_edit(request, pk, section): + """Édition localisée d'une section de l'analyse d'un CV (issue #61). + + Une seule section est modifiée à la fois (les autres restent intactes) ; + l'ensemble est re-normalisé comme une analyse IA pour garantir une structure + stable. Un CV jamais analysé devient « analysé » dès la première saisie. """ cv = get_object_or_404(CV, pk=pk) + meta = CV_SECTIONS.get(section) + if meta is None: + raise Http404("Section inconnue.") if request.method == "POST": - raw = request.POST.get("analysis", "") + raw = request.POST.get("value", "") try: - data = json.loads(raw) if raw else {} + value = json.loads(raw) if raw else None except (json.JSONDecodeError, ValueError): - data = None - if not isinstance(data, dict): - messages.error(request, "Données d'analyse invalides.") + messages.error(request, "Données invalides.") else: - cv.analysis = coaching.normalize_cv_analysis(data) + merged = _apply_cv_section(cv.analysis, section, value) + cv.analysis = coaching.normalize_cv_analysis(merged) cv.analysis_error = "" - # Un CV jamais analysé devient « analysé » dès la première saisie. if not cv.analyzed_at: cv.analyzed_at = timezone.now() cv.save(update_fields=["analysis", "analysis_error", "analyzed_at"]) - messages.success(request, "Analyse du CV mise à jour.") + messages.success(request, f"Section « {meta['label']} » mise à jour.") return redirect("tracking:cv_detail", pk=cv.pk) return render( request, "tracking/cv_edit.html", - { - "cv": cv, - "analysis_json": json.dumps(cv.analysis or {}, ensure_ascii=False), - }, + {"cv": cv, "section": section, "meta": meta, "value": _cv_section_value(cv, section)}, ) From e01e69555d0fdb082d4f6528b2caec722305201c Mon Sep 17 00:00:00 2001 From: plenoir Date: Tue, 16 Jun 2026 20:08:37 +0200 Subject: [PATCH 3/9] =?UTF-8?q?#61=20R=C3=A9agencement=20des=20items=20dan?= =?UTF-8?q?s=20l'=C3=A9diteur=20de=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boutons ⬆️/⬇️ sur chaque expérience, formation et item de liste (compétences, langues, loisirs) pour les réordonner ; l'ordre enregistré suit l'ordre à l'écran (sérialisation = ordre DOM). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 9 ++++--- templates/tracking/cv_edit.html | 43 +++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8cdae6..b855963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ release du même nom. - #61 — CV : **édition manuelle de l'analyse, section par section**. Chaque section de la fiche (profil, expériences, formations, coordonnées, compétences, langues, loisirs, infos) porte son propre bouton « ✏️ » qui - ouvre un éditeur dédié ; on peut corriger, compléter et **ajouter/supprimer** - expériences, formations et items de liste sans toucher au reste. Les sections - vides sont affichées et restent éditables. L'analyse IA distingue désormais - les puces « - » des descriptions et les restitue en **retours à la ligne**. + ouvre un éditeur dédié ; on peut corriger, compléter, **ajouter/supprimer** et + **réagencer** (boutons ⬆️/⬇️) expériences, formations et items de liste sans + toucher au reste. Les sections vides sont affichées et restent éditables. + L'analyse IA distingue désormais les puces « - » des descriptions et les + restitue en **retours à la ligne**. - #62 — CV : **références à fournir**. Depuis la fiche d'un CV (en pratique le CV par défaut), on peut enregistrer des référents (nom, prénom, téléphone, email, LinkedIn), chacun pouvant être **rattaché à une expérience professionnelle** du diff --git a/templates/tracking/cv_edit.html b/templates/tracking/cv_edit.html index 1f57c3a..47cdf9c 100644 --- a/templates/tracking/cv_edit.html +++ b/templates/tracking/cv_edit.html @@ -7,11 +7,11 @@ gap:1rem; margin-bottom:1rem; flex-wrap:wrap; } .edit-head h1 { margin:0; font-size:1.3rem; } .edit-item { border:1px solid var(--border); border-radius:var(--radius-sm); - padding:0.8rem 0.9rem; margin-bottom:0.8rem; position:relative; + padding:0.8rem 0.9rem; margin-bottom:0.8rem; background:var(--input-bg); } - .edit-item .item-del { position:absolute; top:0.5rem; right:0.5rem; } + .item-acts { display:flex; justify-content:flex-end; gap:0.3rem; margin-bottom:0.4rem; } .edit-list { display:flex; flex-direction:column; gap:0.5rem; } - .edit-chip-row { display:flex; gap:0.4rem; align-items:center; } + .edit-chip-row { display:flex; gap:0.3rem; align-items:center; } .edit-chip-row input { flex:1; } .add-btn { margin-top:0.2rem; } @@ -72,6 +72,18 @@

{{ meta.icon }} Modifier — {{ meta.label }}

if (val) { n.value = val; } return n; } + function actionBtn(label, title, cls) { + var b = el("button", { type: "button", "class": "icon-btn " + (cls || ""), title: title }); + b.textContent = label; + return b; + } + // Déplace un élément parmi ses frères (réagencement, ordre = ordre DOM). + function move(node, dir) { + var sib = dir < 0 ? node.previousElementSibling : node.nextElementSibling; + if (!sib) { return; } + if (dir < 0) { node.parentNode.insertBefore(node, sib); } + else { node.parentNode.insertBefore(sib, node); } + } // --- Constructeurs par type de section ------------------------------- function buildProfile() { @@ -95,13 +107,21 @@

{{ meta.icon }} Modifier — {{ meta.label }}

editor.appendChild(fieldBlock("Contenu", area)); } function itemFields() { return KIND === "experiences" ? EXP_FIELDS : FORM_FIELDS; } + function actionsRow(node) { + var acts = el("div", { "class": "item-acts" }); + var up = actionBtn("⬆️", "Monter"); + var down = actionBtn("⬇️", "Descendre"); + var del = actionBtn("🗑️", "Supprimer", "danger"); + up.addEventListener("click", function () { move(node, -1); }); + down.addEventListener("click", function () { move(node, 1); }); + del.addEventListener("click", function () { node.remove(); }); + acts.appendChild(up); acts.appendChild(down); acts.appendChild(del); + return acts; + } function addItem(values) { values = values || {}; var wrap = el("div", { "class": "edit-item" }); - var del = el("button", { type: "button", "class": "icon-btn danger item-del", title: "Supprimer" }); - del.textContent = "🗑️"; - del.addEventListener("click", function () { wrap.remove(); }); - wrap.appendChild(del); + wrap.appendChild(actionsRow(wrap)); itemFields().forEach(function (f) { var input = makeInput(f[2], values[f[0]]); input.setAttribute("data-key", f[0]); @@ -121,10 +141,13 @@

{{ meta.icon }} Modifier — {{ meta.label }}

function addChip(v) { var row = el("div", { "class": "edit-chip-row" }); var input = makeInput("input", v); input.setAttribute("data-chip", "1"); - var del = el("button", { type: "button", "class": "icon-btn danger", title: "Supprimer" }); - del.textContent = "🗑️"; + var up = actionBtn("⬆️", "Monter"); + var down = actionBtn("⬇️", "Descendre"); + var del = actionBtn("🗑️", "Supprimer", "danger"); + up.addEventListener("click", function () { move(row, -1); }); + down.addEventListener("click", function () { move(row, 1); }); del.addEventListener("click", function () { row.remove(); }); - row.appendChild(input); row.appendChild(del); + row.appendChild(input); row.appendChild(up); row.appendChild(down); row.appendChild(del); document.getElementById("chips-list").appendChild(row); } function buildChips() { From 23b7363d677cf7a4b1b980d4b6e6489b80aef1e4 Mon Sep 17 00:00:00 2001 From: plenoir Date: Tue, 16 Jun 2026 20:17:42 +0200 Subject: [PATCH 4/9] =?UTF-8?q?#63=20Renomme=20la=20section=20=C2=AB=20Sit?= =?UTF-8?q?es=20=C2=BB=20en=20=C2=AB=20Contacts=20=C2=BB=20+=20fiche=20d?= =?UTF-8?q?=C3=A9tail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navigation, titres, libellés et messages renommés « Sites » -> « Contacts ». - Nouvelle vue/fiche site_detail : type, URL et liste des opportunités associées (candidatures dont le contact est la source), avec accès direct. - Lien depuis la liste vers la fiche de détail. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 ++ CLAUDE.md | 3 +- templates/base.html | 4 +- templates/tracking/site_confirm_delete.html | 4 +- templates/tracking/site_detail.html | 64 +++++++++++++++++++++ templates/tracking/site_list.html | 11 ++-- tracking/tests.py | 38 ++++++++++++ tracking/urls.py | 1 + tracking/views.py | 33 ++++++++--- 9 files changed, 144 insertions(+), 19 deletions(-) create mode 100644 templates/tracking/site_detail.html diff --git a/CHANGELOG.md b/CHANGELOG.md index b855963..22f3d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ release du même nom. ## [Non publié] — 1.3.0 +- #63 — La section **« Sites » est renommée « Contacts »** (navigation, titres, + libellés et messages). Le nom d'un contact ouvre désormais une **fiche de + détail** présentant son type, son URL et la **liste des opportunités + associées** (candidatures dont il est la source), avec accès direct à chacune. + - #61 — CV : **édition manuelle de l'analyse, section par section**. Chaque section de la fiche (profil, expériences, formations, coordonnées, compétences, langues, loisirs, infos) porte son propre bouton « ✏️ » qui diff --git a/CLAUDE.md b/CLAUDE.md index 6135b6d..798f85a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,7 +53,8 @@ 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 ; `logo_url` n'est plus saisi mais déduit du favicon, issue #50 ; `type` = `JobSite.Type` Généraliste/ESN/Direct, défaut Généraliste pour les -sites par défaut, issue #55), +sites par défaut, issue #55 ; côté UI la section est intitulée **« Contacts »** +et une fiche `site_detail` liste les opportunités associées, issue #63), `Candidature` (cœur du suivi, étapes de progression + `motif_cloture` = clôture ; `cv` = CV joint, issue #49 ; `localisation` = zone géographique de l'offre, issue #52 ; `source` diff --git a/templates/base.html b/templates/base.html index efcf0f6..5878a74 100644 --- a/templates/base.html +++ b/templates/base.html @@ -324,8 +324,8 @@ href="{% url 'tracking:candidature_list' %}" data-label="Candidatures"> Candidatures - Sites + href="{% url 'tracking:site_list' %}" data-label="Contacts"> + Contacts Statistiques diff --git a/templates/tracking/site_confirm_delete.html b/templates/tracking/site_confirm_delete.html index 4064657..2e9e49f 100644 --- a/templates/tracking/site_confirm_delete.html +++ b/templates/tracking/site_confirm_delete.html @@ -1,9 +1,9 @@ {% extends "base.html" %} {% block title %}Supprimer {{ site.name }} — CandiTrack{% endblock %} {% block content %} -

Supprimer le site

+

Supprimer le contact

-

Supprimer définitivement le site {{ site.name }} ?

+

Supprimer définitivement le contact {{ site.name }} ?

{% csrf_token %} diff --git a/templates/tracking/site_detail.html b/templates/tracking/site_detail.html new file mode 100644 index 0000000..c39c5d9 --- /dev/null +++ b/templates/tracking/site_detail.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{{ site.name }} — Contacts — CandiTrack{% endblock %} +{% block main_class %}wide{% endblock %} +{% block content %} +
+

+ {% if site.logo_url %}{% endif %} + {{ site.name }} + {% if site.is_builtin %}défaut{% endif %} + {% if not site.actif %}désactivé{% endif %} +

+ +
+ +
+
Type
{{ site.get_type_display }}
+
URL
+ {% if site.url %}{{ site.url }}{% else %}—{% endif %}
+
+ +
+

Opportunités associées{% if candidatures %} {{ candidatures|length }}{% endif %}

+ {% if candidatures %} + + + + + + + + + + + + + {% for c in candidatures %} + {% with p=c.progression %} + + + + + + + + + {% endwith %} + {% endfor %} + +
CandidatureEntreprisePosteStatutAvancement
{{ c }}{{ c.entreprise|default:"—" }}{{ c.poste|default:"—" }}{{ c.etape_courante }} + + {% if p.closed %}Terminée{% else %}{{ p.percent }} %{% endif %} + +
+ 👁️ +
+
+ {% else %} +

Aucune opportunité associée à ce contact pour le moment.

+ {% endif %} +
+{% endblock %} diff --git a/templates/tracking/site_list.html b/templates/tracking/site_list.html index c4f4859..6d51d6b 100644 --- a/templates/tracking/site_list.html +++ b/templates/tracking/site_list.html @@ -1,9 +1,9 @@ {% extends "base.html" %} -{% block title %}Sites — CandiTrack{% endblock %} +{% block title %}Contacts — CandiTrack{% endblock %} {% block content %}
-

Sites d'emploi

- + Ajouter un site +

Contacts

+ + Ajouter un contact

Le logo (favicon du site) est récupéré automatiquement à l'enregistrement.

@@ -15,13 +15,14 @@

Sites d'emploi

{% for s in sites %} {% if s.logo_url %}{% else %}{% endif %} - {{ s.name }} + {{ s.name }} {% if s.is_builtin %} défaut{% endif %} {% if not s.actif %} désactivé{% endif %} {{ s.get_type_display }} {% if s.url %}{{ s.url }}{% else %}—{% endif %}
+ 👁️ ✏️ {% csrf_token %} @@ -39,7 +40,7 @@

Sites d'emploi

{% else %} -

Aucun site. Ajoutez-en un.

+

Aucun contact. Ajoutez-en un.

{% endif %}
{% endblock %} diff --git a/tracking/tests.py b/tracking/tests.py index 8a4d63a..d1be827 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1942,3 +1942,41 @@ def test_reference_affichee_sur_la_fiche(self): resp = self.client.get(reverse("tracking:cv_detail", args=[self.cv.pk])) self.assertContains(resp, "Marie Durand") self.assertContains(resp, "Lead · Acme") + + +class ContactDetailTests(TestCase): + """Issue #63 — section « Contacts » et opportunités associées au détail.""" + + def test_nav_et_liste_renommees_en_contacts(self): + resp = self.client.get(reverse("tracking:site_list")) + # Libellé de navigation et titre de page renommés. + self.assertContains(resp, "Contacts") + self.assertContains(resp, "+ Ajouter un contact") + + def test_liste_lie_vers_le_detail(self): + site = JobSite.objects.create(name="Acme") + resp = self.client.get(reverse("tracking:site_list")) + self.assertContains(resp, reverse("tracking:site_detail", args=[site.pk])) + + def test_detail_liste_les_opportunites_associees(self): + acme = JobSite.objects.create(name="Acme") + autre = JobSite.objects.create(name="Globex") + liee = Candidature.objects.create(poste="Dev", entreprise="Acme", source=acme) + Candidature.objects.create(poste="Lead", entreprise="Globex", source=autre) + resp = self.client.get(reverse("tracking:site_detail", args=[acme.pk])) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "Opportunités associées") + self.assertContains(resp, str(liee)) + # Une candidature d'un autre contact ne doit pas apparaître. + self.assertNotContains(resp, "Lead") + + def test_detail_sans_opportunite(self): + site = JobSite.objects.create(name="Acme") + resp = self.client.get(reverse("tracking:site_detail", args=[site.pk])) + self.assertContains(resp, "Aucune opportunité associée") + + def test_message_creation_parle_de_contact(self): + resp = self.client.post( + reverse("tracking:site_create"), {"name": "Nouveau"}, follow=True + ) + self.assertContains(resp, "Contact ajouté.") diff --git a/tracking/urls.py b/tracking/urls.py index c7bc95a..8ab873b 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -20,6 +20,7 @@ ), path("sites/", views.site_list, name="site_list"), path("sites/nouveau/", views.site_create, name="site_create"), + path("sites//", views.site_detail, name="site_detail"), path("sites//modifier/", views.site_update, name="site_update"), path("sites//supprimer/", views.site_delete, name="site_delete"), path("sites//desactiver/", views.site_toggle_active, name="site_toggle_active"), diff --git a/tracking/views.py b/tracking/views.py index 78df2e3..974fda6 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -217,24 +217,39 @@ def candidature_delete(request, pk): @require_GET def site_list(request): - """Issue #366 — list of job sites with manual management.""" + """Issue #366 — liste des contacts (sites/employeurs), gérés à la main.""" sites = JobSite.objects.all() return render(request, "tracking/site_list.html", {"sites": sites}) +@require_GET +def site_detail(request, pk): + """Détail d'un contact et de ses opportunités associées (issue #63). + + Les opportunités sont les candidatures dont ce contact est la source + (`Candidature.source`), via le related_name ``candidatures``. + """ + site = get_object_or_404(JobSite, pk=pk) + return render( + request, + "tracking/site_detail.html", + {"site": site, "candidatures": site.candidatures.all()}, + ) + + def site_create(request): if request.method == "POST": form = JobSiteForm(request.POST) if form.is_valid(): form.save() - messages.success(request, "Site ajouté.") + messages.success(request, "Contact ajouté.") return redirect(SITE_LIST_ROUTE) else: form = JobSiteForm() return render( request, "tracking/site_form.html", - {"form": form, "title": "Ajouter un site"}, + {"form": form, "title": "Ajouter un contact"}, ) @@ -244,7 +259,7 @@ def site_update(request, pk): form = JobSiteForm(request.POST, instance=site) if form.is_valid(): form.save() - messages.success(request, "Site mis à jour.") + messages.success(request, "Contact mis à jour.") return redirect(SITE_LIST_ROUTE) else: form = JobSiteForm(instance=site) @@ -257,28 +272,28 @@ def site_update(request, pk): def site_delete(request, pk): site = get_object_or_404(JobSite, pk=pk) - # Les sites par défaut ne se suppriment pas : on les désactive (issue #22). + # Les contacts par défaut ne se suppriment pas : on les désactive (issue #22). if site.is_builtin: messages.error( request, - f"« {site.name} » est un site par défaut : désactivez-le plutôt que de le supprimer.", + f"« {site.name} » est un contact par défaut : désactivez-le plutôt que de le supprimer.", ) return redirect(SITE_LIST_ROUTE) if request.method == "POST": site.delete() - messages.success(request, "Site supprimé.") + messages.success(request, "Contact supprimé.") return redirect(SITE_LIST_ROUTE) return render(request, "tracking/site_confirm_delete.html", {"site": site}) def site_toggle_active(request, pk): - """Issue #22 — activer/désactiver un site (surtout les sites par défaut).""" + """Issue #22 — activer/désactiver un contact (surtout ceux par défaut).""" site = get_object_or_404(JobSite, pk=pk) if request.method == "POST": site.actif = not site.actif site.save(update_fields=["actif", "updated_at"]) etat = "activé" if site.actif else "désactivé" - messages.success(request, f"Site « {site.name} » {etat}.") + messages.success(request, f"Contact « {site.name} » {etat}.") return redirect(SITE_LIST_ROUTE) From 83cc5ba80a9842d51f809a92e23fd63ec3b57cc8 Mon Sep 17 00:00:00 2001 From: plenoir Date: Tue, 16 Jun 2026 20:23:08 +0200 Subject: [PATCH 5/9] =?UTF-8?q?#64=20Bouton=20d'extraction=20des=20r=C3=A9?= =?UTF-8?q?f=C3=A9rences=20par=20l'IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute un bouton « ✨ Extrait pour email (IA) » dans la section Références du CV. Nouvel endpoint api/cv//references/ (ai_references) + coaching.references_email : génère un court texte prêt à coller dans un email (« Comme demandé, je vous joins les références… ») listant chaque référent et ses coordonnées, affiché dans le modal IA partagé. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +++ CLAUDE.md | 8 ++++-- templates/tracking/cv_detail.html | 24 +++++++++++++++- tracking/coaching.py | 43 +++++++++++++++++++++++++++++ tracking/tests.py | 46 +++++++++++++++++++++++++++++++ tracking/urls.py | 1 + tracking/views.py | 12 ++++++++ 7 files changed, 134 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f3d23..49b2793 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ release du même nom. ## [Non publié] — 1.3.0 +- #64 — CV : bouton **« ✨ Extrait pour email (IA) »** dans la section Références. + Il génère, via l'IA, un court texte prêt à coller dans un email (« Comme + demandé, je vous joins les références… ») listant chaque référent et ses + coordonnées, affiché dans le modal IA partagé (copie en un clic). - #63 — La section **« Sites » est renommée « Contacts »** (navigation, titres, libellés et messages). Le nom d'un contact ouvre désormais une **fiche de détail** présentant son type, son URL et la **liste des opportunités diff --git a/CLAUDE.md b/CLAUDE.md index 798f85a..b61b2c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,9 +97,11 @@ emoji dans le libellé pour les menus). `_api_key/_model/_monthly_limit`, accès générique par getattr), le `provider` actif détermine `api_key`/`model`. `MODELS_BY_PROVIDER`, `DEFAULTS` et `PROVIDER_INFO` (tier gratuit + liens doc/clé) pilotent l'UI. Config via `/aide/` (page Options, - 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). + catégorie IA, issue #34). Endpoints POST AJAX `api/coaching/` (bilan), + `api/candidatures//relance/` (mail de relance) et + `api/cv//references/` (extrait d'email des références, + `coaching.references_email`, issue #64) ; UI = modal partagé `#ai-modal` dans + `base.html` (spinner + rendu Markdown), ouvert via `window.openAiModal`. - Analyse de CV (issue #44) : `coaching.analyze_cv(cv)` demande à l'IA un JSON structuré (profil, expériences, formations, compétences, langues, coordonnées/ références — adresse, téléphone, email, permis —, loisirs, infos diverses), diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html index b04343e..4510fb0 100644 --- a/templates/tracking/cv_detail.html +++ b/templates/tracking/cv_detail.html @@ -367,7 +367,14 @@

🗺️ Localisations

🤝 Références à fournir

- + Ajouter une référence +
+ {% if cv.references.all %} + + {% endif %} + + Ajouter une référence +
{% if cv.references.all %}
@@ -397,4 +404,19 @@

🤝

Aucune référence enregistrée pour ce CV.

{% endif %}

+ + {% endblock %} \ No newline at end of file diff --git a/tracking/coaching.py b/tracking/coaching.py index a1bda56..4247356 100644 --- a/tracking/coaching.py +++ b/tracking/coaching.py @@ -203,6 +203,49 @@ def relance_email(candidature, config=None): return _run(config, prompt) +def references_email(cv, config=None): + """Rédige un extrait d'email transmettant les références d'un CV (issue #64). + + Produit un court texte prêt à coller dans un email (« Comme demandé, je vous + joins les références… ») listant chaque référent et ses coordonnées. + """ + config = config or AIConfig.load() + + lines = [] + for ref in cv.references.all(): + details = [] + if ref.experience_label: + details.append(f"référent pour : {ref.experience_label}") + if ref.telephone: + details.append(f"tél. {ref.telephone}") + if ref.email: + details.append(f"email {ref.email}") + if ref.linkedin: + details.append(f"LinkedIn {ref.linkedin}") + line = f"- {ref}" + if details: + line += " (" + " ; ".join(details) + ")" + lines.append(line) + + prompt = ( + "Tu es un assistant qui rédige des emails professionnels en français, " + "polis et concis.\n\n" + "Rédige un court extrait à insérer dans un email pour transmettre à un " + "recruteur les références professionnelles ci-dessous (du type « Comme " + "demandé, je vous joins les références que vous pouvez contacter… »).\n\n" + "Références :\n" + "\n".join(lines) + "\n\n" + "Consignes :\n" + "- Commence par une phrase d'introduction courtoise.\n" + "- Présente chaque référence de façon lisible (nom, lien avec le poste " + "ou l'expérience le cas échéant, puis coordonnées : téléphone, email, " + "LinkedIn).\n" + "- Termine par une formule indiquant qu'elles peuvent être contactées.\n" + "- N'invente aucune coordonnée absente de la liste ci-dessus." + ) + + 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 diff --git a/tracking/tests.py b/tracking/tests.py index d1be827..f26c2d7 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -856,6 +856,52 @@ def test_detail_page_shows_relance_button(self): self.assertContains(resp, "openAiModal") +@override_settings(CANDITRACK_FERNET_KEY=TEST_FERNET_KEY, MEDIA_ROOT=tempfile.mkdtemp()) +class AIReferencesViewTests(TestCase): + """Issue #64 — extrait d'email des références via l'IA.""" + + def setUp(self): + self.cv = CV.objects.create(label="CV", file=SimpleUploadedFile("cv.txt", b"x")) + config = AIConfig.load() + config.gemini_api_key = "k" + config.save() + + @mock.patch( + "tracking.coaching.ai.generate", + return_value=ai.GenerationResult("Comme demandé, voici mes références.", 5, 8, 13), + ) + def test_returns_excerpt(self, gen): + Reference.objects.create( + cv=self.cv, nom="Durand", prenom="Marie", telephone="0102030405" + ) + resp = self.client.post(reverse("tracking:ai_references", args=[self.cv.pk])) + self.assertEqual(resp.status_code, 200) + self.assertIn("références", resp.json()["text"]) + # Le prompt envoyé mentionne la référence et ses coordonnées. + prompt = gen.call_args.args[0] + self.assertIn("Durand", prompt) + self.assertIn("0102030405", prompt) + + def test_sans_reference_erreur_400(self): + resp = self.client.post(reverse("tracking:ai_references", args=[self.cv.pk])) + self.assertEqual(resp.status_code, 400) + self.assertIn("référence", resp.json()["error"]) + + def test_unknown_cv_404(self): + resp = self.client.post(reverse("tracking:ai_references", args=[99999])) + self.assertEqual(resp.status_code, 404) + + def test_refuse_get(self): + resp = self.client.get(reverse("tracking:ai_references", args=[self.cv.pk])) + self.assertEqual(resp.status_code, 405) + + def test_detail_page_shows_button(self): + Reference.objects.create(cv=self.cv, nom="Durand") + resp = self.client.get(reverse("tracking:cv_detail", args=[self.cv.pk])) + self.assertContains(resp, "Extrait pour email (IA)") + self.assertContains(resp, reverse("tracking:ai_references", args=[self.cv.pk])) + + class GeminiClientTests(TestCase): """Issue #33 — client HTTP Gemini (parsing et erreurs), réseau simulé.""" diff --git a/tracking/urls.py b/tracking/urls.py index 8ab873b..698734b 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -49,6 +49,7 @@ # Coaching IA (issue #33) path("api/coaching/", views.ai_coaching, name="ai_coaching"), path("api/candidatures//relance/", views.ai_relance, name="ai_relance"), + path("api/cv//references/", views.ai_references, name="ai_references"), # API for the Chrome extension (issue #2) path("api/candidatures/", views.api_candidature_create, name="api_candidature_create"), ] diff --git a/tracking/views.py b/tracking/views.py index 974fda6..6d3b47f 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -776,6 +776,18 @@ def ai_relance(request, pk): return _ai_endpoint(request, lambda: coaching.relance_email(candidature)) +@require_POST +def ai_references(request, pk): + """Renvoie un extrait d'email transmettant les références d'un CV (issue #64).""" + cv = get_object_or_404(CV, pk=pk) + if not cv.references.exists(): + return JsonResponse( + {"error": "Aucune référence enregistrée pour ce CV. Ajoutez-en d'abord."}, + status=400, + ) + return _ai_endpoint(request, lambda: coaching.references_email(cv)) + + @require_GET def extension_download(request): """Serve the chrome-extension/ folder as a zip the user can install (issue #6).""" From 1f1ea3e306f8311a564bb0c2282951a4298e2731 Mon Sep 17 00:00:00 2001 From: plenoir Date: Tue, 16 Jun 2026 20:39:13 +0200 Subject: [PATCH 6/9] =?UTF-8?q?#64=20Corrige=20le=20bouton=20=C2=AB=20Extr?= =?UTF-8?q?ait=20pour=20email=20(IA)=20=C2=BB=20+=20cible=20la=201.2.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Le gestionnaire de clic appelait openAiModal au moment de l attache, alors que openAiModal est defini plus bas dans base.html : on le reference dans le clic (comme le mail de relance), sinon le bouton restait inerte. - Renumerote les evolutions 1.3.0 -> 1.2.3 (milestone de l issue #64). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- templates/tracking/cv_detail.html | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b2793..e5116f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ versionnage [SemVer](https://semver.org/lang/fr/). Chaque version correspond à un milestone GitHub ; la liste des issues traitées est aussi publiée dans la release du même nom. -## [Non publié] — 1.3.0 +## [Non publié] — 1.2.3 - #64 — CV : bouton **« ✨ Extrait pour email (IA) »** dans la section Références. Il génère, via l'IA, un court texte prêt à coller dans un email (« Comme diff --git a/templates/tracking/cv_detail.html b/templates/tracking/cv_detail.html index 4510fb0..4ff6b24 100644 --- a/templates/tracking/cv_detail.html +++ b/templates/tracking/cv_detail.html @@ -407,9 +407,11 @@

🤝