diff --git a/CLAUDE.md b/CLAUDE.md index bb083a1..6d47c33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,10 +54,12 @@ docker compose up -d --build # → http://127.0.0.1:53487/ l'issue #43 ; `logo_url` n'est plus saisi mais déduit du favicon, issue #50), `Candidature` (cœur du suivi, étapes de progression + `motif_cloture` = clôture ; `cv` = CV joint, -issue #49), `StatusHistory`, +issue #49 ; `localisation` = zone géographique de l'offre, issue #52), +`StatusHistory`, `Reminder`, `Interview`, `Contact`, `ApiToken`, `CV` (avec analyse IA des informations principales — champs `analysis`/`analyzed_at`/… , issue #44 ; -`actif` = archivage, issue #48), +`actif` = archivage, issue #48 ; `par_defaut` = CV dont l'adresse sert d'origine +aux trajets, issue #52), `AIConfig` (singleton de config du coaching IA, clé Gemini chiffrée — issue #33). Énumérations `TextChoices` : `Source`, `Canal`, `Statut`, `MotifCloture` (certaines avec icône @@ -68,6 +70,12 @@ emoji dans le libellé pour les menus). - Logos dérivés du favicon : `tracking/logos.py` (`favicon_service_url`, stdlib uniquement) ; chargé par défaut à l'enregistrement d'un site (issue #27). - Géométrie du donut de stats : `tracking/statistics.py`. +- Temps de trajet (issue #52) : la fiche candidature géocode la `localisation` + de l'offre et l'adresse du **CV par défaut** (`CV.par_defaut`, + `CV.home_location`) via Nominatim, puis calcule un itinéraire **routier** par + OSRM côté client (aucune clé). Un lien 🚆 renvoie vers Google Maps pour le + calcul en transport en commun. Le plugin Chrome récupère la zone géographique + via `jobLocation` (schema.org) puis sélecteurs DOM. - Coaching IA (issues #33, #34, #39) : `tracking/ai.py` (clients REST stdlib pour **Gemini, Mistral, OpenAI/ChatGPT, Anthropic/Claude, Perplexity** ; `generate(..., provider=...)` aiguille — OpenAI/Mistral/Perplexity partagent le diff --git a/chrome-extension/GUIDE.md b/chrome-extension/GUIDE.md new file mode 100644 index 0000000..b63f6ed --- /dev/null +++ b/chrome-extension/GUIDE.md @@ -0,0 +1,131 @@ +# Guide pas à pas — Enregistrer une annonce LinkedIn dans CandiTrack + +Objectif : depuis une offre d'emploi affichée sur LinkedIn, créer la candidature +correspondante dans CandiTrack **en un clic** grâce à l'extension Chrome. + +> ℹ️ L'extension ne se connecte pas à LinkedIn et n'utilise aucune API LinkedIn. +> Elle lit simplement les informations de **la page que vous avez ouverte** (titre du +> poste, entreprise, URL) et les envoie à **votre** instance CandiTrack. + +--- + +## Étape 0 — Prérequis (à faire une seule fois) + +### 0.1 Démarrer le backend avec un jeton +Dans un terminal, à la racine du projet : +```powershell +.venv\Scripts\Activate.ps1 +``` +Si `CANDITRACK_API_TOKEN` n'est pas encore dans votre `.env`, générez-en un : +```powershell +python -c "import secrets; print(secrets.token_urlsafe(32))" +``` +Copiez la valeur dans `.env` : +``` +CANDITRACK_API_TOKEN=la_valeur_generee +``` +Puis lancez le serveur : +```powershell +python manage.py runserver +``` +Laissez ce terminal ouvert. CandiTrack tourne sur **http://127.0.0.1:8000/**. + +### 0.2 Installer l'extension +1. Ouvrez **`chrome://extensions`** dans Chrome. +2. Activez le **Mode développeur** (interrupteur en haut à droite). +3. Cliquez **Charger l'extension non empaquetée**. +4. Sélectionnez le dossier **`chrome-extension/`** du projet. +5. L'extension **CandiTrack** apparaît. Cliquez sur l'icône 🧩 (puzzle) de Chrome puis + sur l'épingle à côté de CandiTrack pour **l'afficher** dans la barre d'outils. + +### 0.3 Configurer l'extension +1. Clic **droit** sur l'icône CandiTrack → **Options**. +2. **URL du backend** : `http://127.0.0.1:8000` +3. **Jeton API** : collez la **même valeur** que `CANDITRACK_API_TOKEN` de votre `.env`. +4. Cliquez **Enregistrer** → vous devez voir « Enregistré ✓ ». + +✅ Tout est prêt. Les étapes suivantes sont à refaire pour **chaque** annonce. + +--- + +## Étape 1 — Ouvrir l'annonce sur LinkedIn + +1. Allez sur **https://www.linkedin.com/jobs/** et lancez une recherche (mot-clé + lieu). +2. Dans les résultats, **cliquez sur une offre** : son détail s'affiche à droite. +3. **Important** : ouvrez l'offre sur sa **page dédiée** pour une extraction fiable. + - soit cliquez sur le titre de l'offre / le bouton qui ouvre la page complète, + - l'URL doit ressembler à `https://www.linkedin.com/jobs/view/4039xxxxxx/`. +4. Attendez que la page soit **entièrement chargée** (titre du poste + nom de l'entreprise visibles). + +--- + +## Étape 2 — Ouvrir l'extension et vérifier le pré-remplissage + +1. Cliquez sur l'icône **CandiTrack** dans la barre d'outils. +2. Une petite **popup** s'ouvre et tente de remplir automatiquement : + - **Entreprise** → ex. « ACME France » + - **URL de l'offre** → l'adresse `.../jobs/view/...` + - **Localisation** → la zone géographique de l'offre (ex. « Lyon, France ») + - **Source** → affichée en bas : `linkedin` + +> ℹ️ Le plugin **ne capture volontairement pas l'intitulé du poste ni la date d'envoi** : +> vous les compléterez dans CandiTrack une fois la candidature réellement envoyée. +> Si l'entreprise est vide ou inexacte (LinkedIn change parfois sa mise en page), +> **corrigez-la** dans la popup. L'URL, elle, est toujours correcte. + +--- + +## Étape 3 — Ajouter la candidature + +1. Vérifiez/ajustez les champs. +2. Cliquez **Ajouter**. +3. La popup affiche **« Ajouté ✓ »** avec un lien **voir**. + - Le bouton se réactive et un message d'erreur s'affiche si quelque chose ne va pas + (voir Dépannage). + +--- + +## Étape 4 — Vérifier dans CandiTrack + +1. Ouvrez (ou rafraîchissez) **http://127.0.0.1:8000/**. +2. La nouvelle candidature apparaît dans la liste : + - **Libellé** = le nom de l'entreprise + - **Source** = LinkedIn, **URL de l'offre** cliquable + - **Poste** et **date d'envoi** restent **vides** (à compléter) +3. Cliquez sur son libellé pour ouvrir le détail et compléter le poste, la date d'envoi, + les étapes, les dates d'entretien, etc. + +🎉 L'annonce LinkedIn est enregistrée. + +--- + +## Dépannage + +| Symptôme | Cause probable | Solution | +|---|---|---| +| « Configurez l'URL et le jeton… » | Options non remplies | Refaire l'**étape 0.3** | +| « Erreur : unauthorized » (401) | Jeton de l'extension ≠ `CANDITRACK_API_TOKEN` du `.env` | Recopier exactement le jeton, **Enregistrer**, réessayer | +| « Erreur réseau… » | Serveur arrêté ou mauvaise URL backend | Vérifier que `runserver` tourne et que l'URL est `http://127.0.0.1:8000` | +| Entreprise vide dans la popup | Page pas finie de charger, ou structure LinkedIn différente | Recharger l'annonce, rouvrir la popup, sinon **saisir à la main** | +| Rien ne se passe au clic sur l'icône | Vous n'êtes pas sur un onglet de page web (ex. `chrome://`) | Ouvrir l'extension depuis l'**onglet de l'annonce** | +| Modifs de l'extension non prises en compte | Cache de l'extension | `chrome://extensions` → bouton **⟳** sur la carte CandiTrack | + +### Tester sans LinkedIn (vérifier que la chaîne fonctionne) +Sur n'importe quelle page d'offre (Indeed, Welcome to the Jungle…), la même procédure +s'applique. Pour un test purement backend, voir la section 3 de **`../TESTING.md`** +(`Invoke-RestMethod` vers `/api/candidatures/`). + +--- + +## Notes et limites + +- **Page dédiée recommandée** : sur la page de recherche LinkedIn (`/jobs/search/`), + plusieurs offres coexistent ; ouvrez l'offre sur sa page `/jobs/view/...` pour que + l'entreprise et le poste soient extraits de manière fiable. +- **Pas de connexion requise côté extension** : elle lit la page visible. Si l'offre n'est + visible que connecté à LinkedIn, restez connecté dans votre onglet — l'extension lit + alors ce que **vous** voyez. +- **Doublons** : cliquer « Ajouter » deux fois crée deux candidatures. Supprimez l'éventuel + doublon depuis CandiTrack. +- **Autre machine / hôte** : si votre backend n'est pas en local, ajoutez son URL dans + `host_permissions` du `manifest.json`, puis rechargez l'extension. diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index fe7b946..4bf838d 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -24,6 +24,8 @@

Ajouter à CandiTrack

+ +
Source : diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index 16ebf96..66168ad 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -16,7 +16,22 @@ function extractFromPage() { return el ? (el.textContent || "").trim() : ""; } + // Compose a readable location string from a schema.org PostalAddress (issue #52). + function formatAddress(addr) { + if (!addr) return ""; + if (typeof addr === "string") return addr; + if (Array.isArray(addr)) return formatAddress(addr[0]); + if (addr["@type"] === "Place" && addr.address) return formatAddress(addr.address); + var parts = [addr.streetAddress, addr.postalCode, addr.addressLocality, + addr.addressRegion, addr.addressCountry]; + return parts + .map(function (p) { return p && typeof p === "object" ? (p.name || "") : (p || ""); }) + .filter(function (p) { return p; }) + .join(", "); + } + var entreprise = ""; + var localisation = ""; // 1) schema.org JobPosting (JSON-LD) — most reliable when present. var scripts = document.querySelectorAll('script[type="application/ld+json"]'); @@ -34,7 +49,12 @@ function extractFromPage() { if (isJob && node.hiringOrganization) { var org = node.hiringOrganization; var name = typeof org === "string" ? org : org.name; - if (name) { entreprise = String(name); break; } + if (name) { entreprise = String(name); } + } + // Zone géographique de l'offre (issue #52). + if (isJob && !localisation && node.jobLocation) { + var loc = Array.isArray(node.jobLocation) ? node.jobLocation[0] : node.jobLocation; + if (loc) localisation = formatAddress(loc.address || loc); } } } catch (e) { /* ignore malformed JSON-LD */ } @@ -43,10 +63,11 @@ function extractFromPage() { // 2) Site-specific DOM selectors (pages without JSON-LD, e.g. LinkedIn signed-in). if (!entreprise) { var selectors = [ - ".topcard__org-name-link", // LinkedIn (déconnecté) - ".topcard__flavor", // LinkedIn (déconnecté, variante) - ".job-details-jobs-unified-top-card__company-name", // LinkedIn (connecté) - ".jobs-unified-top-card__company-name", // LinkedIn (ancienne UI) + ".topcard__org-name-link", // LinkedIn (déconnecté) + ".topcard__flavor", // LinkedIn (déconnecté, variante) + ".job-details-jobs-unified-top-card__company-name a", // LinkedIn (connecté, lien société) + ".job-details-jobs-unified-top-card__company-name", // LinkedIn (connecté) + ".jobs-unified-top-card__company-name", // LinkedIn (ancienne UI) '[data-testid="inlineHeader-companyName"]', // Indeed '[data-testid="company-name"]', // Indeed (variante) ".jobsearch-CompanyInfoContainer a", // Indeed (ancienne UI) @@ -57,6 +78,42 @@ function extractFromPage() { } } + // 2bis) Sélecteurs DOM pour la localisation (issue #52). + if (!localisation) { + var locSelectors = [ + ".topcard__flavor--bullet", // LinkedIn (déconnecté) + ".job-details-jobs-unified-top-card__bullet", // LinkedIn (ancienne UI connectée) + '[data-testid="inlineHeader-companyLocation"]', // Indeed + '[data-testid="job-location"]', // Indeed (variante) + '[data-testid="jobsearch-JobInfoHeader-companyLocation"]', // Indeed (ancienne UI) + ".location", // génériques (APEC, Monster…) + ]; + for (var m = 0; m < locSelectors.length && !localisation; m++) { + localisation = text(locSelectors[m]); + } + } + + // 2ter) LinkedIn (UI connectée récente, vues /jobs/view et /jobs/collections) : + // la localisation est le 1er segment (avant « · ») du conteneur de description + // sous le titre, mêlée à la date de publication et au nombre de candidats. + if (!localisation) { + var descSelectors = [ + ".job-details-jobs-unified-top-card__primary-description-container", + ".job-details-jobs-unified-top-card__tertiary-description-container", + ".jobs-unified-top-card__primary-description", + ]; + for (var p = 0; p < descSelectors.length && !localisation; p++) { + var raw = text(descSelectors[p]); + if (!raw) continue; + var segs = raw.split(/[·•|]/) + .map(function (s) { return s.replace(/\s+/g, " ").trim(); }) + .filter(function (s) { return s; }); + // Ignore un éventuel 1er segment qui répète le nom de l'entreprise. + if (segs.length && entreprise && segs[0] === entreprise) segs.shift(); + if (segs.length) localisation = segs[0]; + } + } + // 3) Open Graph — but ignore generic job-board names (LinkedIn, Indeed…). if (!entreprise) { var og = meta("og:site_name"); @@ -64,7 +121,7 @@ function extractFromPage() { if (og && !generic.test(og)) entreprise = og; } - return { url: location.href, entreprise: entreprise.trim() }; + return { url: location.href, entreprise: entreprise.trim(), localisation: localisation.trim() }; } function sourceFromUrl(url) { @@ -117,6 +174,7 @@ document.addEventListener("DOMContentLoaded", function () { var data = results[0].result || {}; if (data.entreprise) document.getElementById("entreprise").value = data.entreprise; if (data.url) document.getElementById("url").value = data.url; + if (data.localisation) document.getElementById("localisation").value = data.localisation; } ); }); @@ -131,6 +189,7 @@ document.addEventListener("DOMContentLoaded", function () { var payload = { url: document.getElementById("url").value.trim(), entreprise: document.getElementById("entreprise").value.trim(), + localisation: document.getElementById("localisation").value.trim(), source: current.source }; btn.disabled = true; diff --git a/templates/base.html b/templates/base.html index 36e0518..ac638a5 100644 --- a/templates/base.html +++ b/templates/base.html @@ -158,6 +158,22 @@ .row-actions a, .row-actions form { margin-right:0.5rem; } .linklike { background:none; border:none; padding:0; color:var(--accent); cursor:pointer; font:inherit; text-decoration:underline; } + /* Actions de ligne sous forme d'icônes (issue #52). */ + .act-row { display:flex; gap:0.35rem; align-items:center; flex-wrap:nowrap; white-space:nowrap; } + .act-row form { margin:0; display:inline-flex; } + .icon-btn, .icon-btn:visited, .icon-btn:hover { display:inline-flex; align-items:center; + justify-content:center; width:34px; height:34px; padding:0; border:1px solid var(--border); + border-radius:var(--radius-sm); background:var(--card); color:var(--text); cursor:pointer; + font-size:1rem; line-height:1; text-decoration:none; box-shadow:var(--shadow); + transition:transform .12s ease, filter .15s, background .15s, border-color .15s; } + .icon-btn:hover { transform:translateY(-1px); background:var(--chip-bg); box-shadow:var(--shadow-lg); } + .icon-btn:active { transform:translateY(0); } + .icon-btn.danger:hover { background:rgba(224,88,75,0.16); border-color:#e0584b; } + /* Étoile « CV par défaut » (issue #52) : on/off graphique. */ + .icon-btn.star { filter:grayscale(1); opacity:0.55; } + .icon-btn.star:hover { filter:none; opacity:1; } + .icon-btn.star.is-default { filter:none; opacity:1; border-color:var(--accent); + background:var(--accent-soft); } /* Progress bars / steps, shared across pages */ .bar-track { display:block; background:var(--track); border-radius:6px; height:0.85rem; overflow:hidden; } .bar-fill { display:block; height:100%; background:var(--accent); border-radius:6px; min-width:2px; } @@ -193,6 +209,19 @@ .closed-row { opacity:0.55; } .closed-row td { background:rgba(224,88,75,0.06); } .src-logo { height:16px; width:16px; object-fit:contain; vertical-align:middle; margin-right:0.35rem; border-radius:3px; } + /* Logo + libellé de la source sur une seule ligne (issue #52). */ + .src-cell { display:inline-flex; align-items:center; white-space:nowrap; } + /* Menu déroulant (téléchargement d'un CV par format, issue #52). */ + .dd { position:relative; display:inline-block; } + .dd > summary { list-style:none; cursor:pointer; } + .dd > summary::-webkit-details-marker { display:none; } + .dd-menu { position:absolute; z-index:40; top:calc(100% + 4px); left:0; min-width:200px; + background:var(--card); border:1px solid var(--border); border-radius:var(--radius-sm); + box-shadow:var(--shadow-lg); padding:0.3rem; } + .dd-menu a { display:block; padding:0.42rem 0.6rem; border-radius:var(--radius-sm); color:var(--text); + text-decoration:none; font-size:0.9rem; white-space:nowrap; } + .dd-menu a:hover { background:var(--chip-bg); } + .dd-sep { height:1px; background:var(--border); margin:0.3rem 0; } /* Toasts (issue #13) */ .toast-container { position:fixed; top:1rem; right:1rem; z-index:1500; display:flex; flex-direction:column; gap:0.5rem; max-width:360px; } @@ -321,6 +350,13 @@ + +{% if candidature.localisation and home_location %} + +{% endif %} {% endblock %} diff --git a/templates/tracking/candidature_list.html b/templates/tracking/candidature_list.html index 92d6b71..e033078 100644 --- a/templates/tracking/candidature_list.html +++ b/templates/tracking/candidature_list.html @@ -41,16 +41,19 @@

Candidatures

{{ c.entreprise|default:"—" }} {{ c.poste|default:"—" }} - {% if c.source_logo %}{% endif %}{{ c.get_source_display }} + {% if c.source_logo %}{% endif %}{{ c.get_source_display }} {{ c.etape_courante }} {% if p.closed %}Terminée{% else %}{{ p.percent }} %{% endif %} - - Modifier - Supprimer + +
+ 👁️ + ✏️ + 🗑️ +
{% endwith %} diff --git a/templates/tracking/cv_list.html b/templates/tracking/cv_list.html index 72a5b22..a76902a 100644 --- a/templates/tracking/cv_list.html +++ b/templates/tracking/cv_list.html @@ -12,17 +12,25 @@

Mes CV

{% for cv in cvs %} - {{ cv.label }} - Télécharger + {{ cv.label }}{% if cv.par_defaut %} ⭐ par défaut{% endif %} + {% include "tracking/_cv_download.html" %} {% if cv.is_analyzed %}✓ analysé{% elif cv.analysis_error %}⚠️ échec{% else %}{% endif %} {{ cv.uploaded_at|date:"d/m/Y H:i" }} - - Détails -
- {% csrf_token %} - -
- Supprimer + +
+ 👁️ +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+ 🗑️ +
{% endfor %} @@ -42,14 +50,17 @@

📦 CV archivés

{% for cv in cvs_archives %} {{ cv.label }} archivé - Télécharger + {% include "tracking/_cv_download.html" %} {{ cv.uploaded_at|date:"d/m/Y H:i" }} - -
- {% csrf_token %} - -
- Supprimer + +
+ 👁️ +
+ {% csrf_token %} + +
+ 🗑️ +
{% endfor %} diff --git a/templates/tracking/site_list.html b/templates/tracking/site_list.html index 7379d9f..3fcaa21 100644 --- a/templates/tracking/site_list.html +++ b/templates/tracking/site_list.html @@ -19,17 +19,19 @@

Sites d'emploi

{% if s.is_builtin %} défaut{% endif %} {% if not s.actif %} désactivé{% endif %} {% if s.url %}{{ s.url }}{% else %}—{% endif %} - - Modifier -
- {% csrf_token %} - -
- {% if not s.is_builtin %}Supprimer{% endif %} + +
+ ✏️ +
+ {% csrf_token %} + +
+ {% if not s.is_builtin %}🗑️{% endif %} +
{% endfor %} diff --git a/tracking/forms.py b/tracking/forms.py index db95a95..85cd2df 100644 --- a/tracking/forms.py +++ b/tracking/forms.py @@ -17,6 +17,7 @@ class Meta: "cv", "source", "url_offre", + "localisation", "date_envoi", "canal_envoi", "statut", @@ -38,6 +39,9 @@ class Meta: "libelle": forms.TextInput( attrs={"placeholder": "Laisser vide : généré depuis entreprise et poste"} ), + "localisation": forms.TextInput( + attrs={"placeholder": "Ville ou zone géographique de l'offre"} + ), "date_envoi": forms.DateInput(attrs={"type": "date"}), "date_entretien_1": forms.DateInput(attrs={"type": "date"}), "date_entretien_2": forms.DateInput(attrs={"type": "date"}), diff --git a/tracking/migrations/0022_candidature_localisation_cv_par_defaut.py b/tracking/migrations/0022_candidature_localisation_cv_par_defaut.py new file mode 100644 index 0000000..de187f1 --- /dev/null +++ b/tracking/migrations/0022_candidature_localisation_cv_par_defaut.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-06-15 17:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0021_candidature_cv'), + ] + + operations = [ + migrations.AddField( + model_name='candidature', + name='localisation', + field=models.CharField(blank=True, max_length=200, verbose_name='localisation'), + ), + migrations.AddField( + model_name='cv', + name='par_defaut', + field=models.BooleanField(default=False, verbose_name='CV par défaut'), + ), + ] diff --git a/tracking/models.py b/tracking/models.py index c450db2..6243003 100644 --- a/tracking/models.py +++ b/tracking/models.py @@ -109,6 +109,9 @@ class Candidature(models.Model): "source", max_length=20, choices=Source.choices, default=Source.AUTRE ) url_offre = models.URLField("URL de l'offre", blank=True) + # Zone géographique de l'offre (issue #52) : sert au calcul du temps de + # trajet depuis le domicile (localisation du CV par défaut). + localisation = models.CharField("localisation", max_length=200, blank=True) date_envoi = models.DateField( "date d'envoi", default=timezone.localdate, null=True, blank=True ) @@ -376,6 +379,9 @@ class CV(models.Model): # Un CV archivé reste en base mais n'est plus proposé pour de nouvelles # candidatures (issues #48, #49). actif = models.BooleanField("actif", default=True) + # CV par défaut (issue #52) : son adresse sert d'origine au calcul du temps + # de trajet des candidatures. Un seul CV est par défaut à la fois. + par_defaut = models.BooleanField("CV par défaut", default=False) # Analyse IA du contenu du CV (issue #44). analysis = models.JSONField("analyse IA", default=dict, blank=True) @@ -408,6 +414,28 @@ 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 home_location(self): + """Adresse/ville du candidat, origine des trajets (issue #52). + + Tirée de l'analyse IA : adresse précise si disponible, sinon la + localisation générale du profil. Vide si le CV n'est pas analysé. + """ + coords = self.analysis.get("coordonnees") or {} + return coords.get("adresse") or self.analysis.get("localisation") or "" + + @classmethod + def default(cls): + """CV par défaut actif, ou ``None`` (issue #52).""" + return cls.objects.filter(par_defaut=True, actif=True).first() + + def set_as_default(self): + """Désigne ce CV comme défaut, en retirant le marqueur des autres (issue #52).""" + CV.objects.exclude(pk=self.pk).update(par_defaut=False) + if not self.par_defaut: + self.par_defaut = True + self.save(update_fields=["par_defaut"]) + @property def analysis_provider_label(self): """Libellé lisible du fournisseur ayant produit l'analyse (issue #44).""" diff --git a/tracking/tests.py b/tracking/tests.py index 6c8a006..e2d5714 100644 --- a/tracking/tests.py +++ b/tracking/tests.py @@ -1458,6 +1458,72 @@ def test_supprimer_cv_delie_la_candidature(self): self.assertIsNone(cand.cv) +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class LocalisationTrajetTests(TestCase): + """Issue #52 — localisation des candidatures et CV par défaut.""" + + def _make_cv(self, label="CV", **extra): + cv = CV.objects.create(label=label, file=SimpleUploadedFile(f"{label}.txt", b"x")) + if extra: + CV.objects.filter(pk=cv.pk).update(**extra) + cv.refresh_from_db() + return cv + + def test_localisation_enregistree_sur_la_candidature(self): + self.client.post(reverse("tracking:candidature_create"), { + "poste": "Dev", "localisation": "Lyon", "source": "autre", + "canal_envoi": "email", "statut": Statut.ENVOYEE, + }) + self.assertEqual(Candidature.objects.get().localisation, "Lyon") + + def test_un_seul_cv_par_defaut(self): + a = self._make_cv("A") + b = self._make_cv("B") + a.set_as_default() + b.set_as_default() + a.refresh_from_db() + self.assertFalse(a.par_defaut) + self.assertTrue(b.par_defaut) + self.assertEqual(CV.default(), b) + + def test_set_default_toggle_via_vue(self): + cv = self._make_cv() + self.client.post(reverse("tracking:cv_set_default", args=[cv.pk])) + cv.refresh_from_db() + self.assertTrue(cv.par_defaut) + self.client.post(reverse("tracking:cv_set_default", args=[cv.pk])) + cv.refresh_from_db() + self.assertFalse(cv.par_defaut) + + def test_set_default_refuse_get(self): + cv = self._make_cv() + resp = self.client.get(reverse("tracking:cv_set_default", args=[cv.pk])) + self.assertEqual(resp.status_code, 405) + + def test_home_location_depuis_analyse(self): + cv = self._make_cv(analysis={"coordonnees": {"adresse": "1 rue X, Paris"}}) + self.assertEqual(cv.home_location, "1 rue X, Paris") + cv2 = self._make_cv("C2", analysis={"localisation": "Nantes"}) + self.assertEqual(cv2.home_location, "Nantes") + + def test_detail_affiche_carte_trajet_si_domicile(self): + self._make_cv("Défaut", par_defaut=True, analysis={"localisation": "Paris"}) + cand = Candidature.objects.create(poste="Dev", localisation="Lyon") + resp = self.client.get(reverse("tracking:candidature_detail", args=[cand.pk])) + self.assertContains(resp, "Temps de trajet") + self.assertContains(resp, "travelmode=transit") + + def test_api_enregistre_la_localisation(self): + tok = ApiToken.objects.create(token=ApiToken.new_token()) + self.client.post( + reverse("tracking:api_candidature_create"), + data=json.dumps({"entreprise": "ACME", "localisation": "Lille"}), + content_type="application/json", + HTTP_X_API_TOKEN=tok.token, + ) + self.assertEqual(Candidature.objects.get().localisation, "Lille") + + 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 4999ee8..4dde0f1 100644 --- a/tracking/urls.py +++ b/tracking/urls.py @@ -29,6 +29,7 @@ path("cv//", views.cv_detail, name="cv_detail"), path("cv//analyser/", views.cv_analyze, name="cv_analyze"), 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"), diff --git a/tracking/views.py b/tracking/views.py index 69581e2..cc317b6 100644 --- a/tracking/views.py +++ b/tracking/views.py @@ -127,10 +127,16 @@ def candidature_detail(request, pk): candidature = get_object_or_404( Candidature.objects.select_related("site", "cv"), pk=pk ) + # Origine du calcul de trajet : adresse du CV par défaut (issue #52). + default_cv = CV.default() return render( request, "tracking/candidature_detail.html", - {"candidature": candidature}, + { + "candidature": candidature, + "home_location": default_cv.home_location if default_cv else "", + "default_cv": default_cv, + }, ) @@ -300,6 +306,7 @@ def cv_list(request): { "cvs": [cv for cv in cvs if cv.actif], "cvs_archives": [cv for cv in cvs if not cv.actif], + "export_formats": cv_exporters.EXPORT_LABELS, }, ) @@ -321,6 +328,25 @@ def cv_toggle_active(request, pk): return redirect("tracking:cv_list") +@require_POST +def cv_set_default(request, pk): + """Désigne (ou retire) le CV par défaut, origine des trajets (issue #52).""" + cv = get_object_or_404(CV, pk=pk) + if cv.par_defaut: + cv.par_defaut = False + cv.save(update_fields=["par_defaut"]) + messages.success(request, f"CV « {cv.label} » n'est plus le CV par défaut.") + else: + cv.set_as_default() + messages.success(request, f"CV « {cv.label} » défini comme CV par défaut.") + next_url = request.POST.get("next") + if next_url and url_has_allowed_host_and_scheme( + next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure() + ): + return redirect(next_url) + return redirect("tracking:cv_list") + + def _cv_localisations(cv): """Points du parcours (lieu + société + type) pour la carte des lieux (issue #44).""" if not cv.is_analyzed: @@ -666,6 +692,7 @@ def api_candidature_create(request): url = (data.get("url") or "").strip() entreprise = (data.get("entreprise") or "").strip() + localisation = (data.get("localisation") or "").strip() source = (data.get("source") or "").strip() if source not in Source.values: source = Source.AUTRE @@ -680,6 +707,7 @@ def api_candidature_create(request): entreprise=entreprise, poste="", url_offre=url, + localisation=localisation, source=source, statut=Statut.ENVOYEE, date_envoi=None,