From 4fa2a344470a338a946f1548b2aa77f0776e6a94 Mon Sep 17 00:00:00 2001 From: Jens Date: Wed, 22 Jul 2026 20:10:34 +0200 Subject: [PATCH] feat: replace pipeline profile and source health --- apps/jobs/services/applications.py | 25 ++++++ apps/jobs/urls.py | 2 + apps/jobs/views.py | 41 +++++++++- apps/profiles/views.py | 17 ++++ apps/sources/views.py | 16 +++- docs/ai/BACKLOG.yaml | 7 +- static/css/pages.css | 76 +++++++++++++++++ static/css/responsive.css | 14 ++++ templates/applications/edit.html | 9 ++- templates/applications/list.html | 25 +++--- templates/profiles/edit.html | 31 +++---- templates/profiles/list.html | 7 +- templates/sources/list.html | 108 ++++++------------------- tests/integration/test_applications.py | 34 ++++++++ 14 files changed, 282 insertions(+), 130 deletions(-) diff --git a/apps/jobs/services/applications.py b/apps/jobs/services/applications.py index 96dea8c..78f738a 100644 --- a/apps/jobs/services/applications.py +++ b/apps/jobs/services/applications.py @@ -274,6 +274,31 @@ def track_application_changes( return events +def change_application_status(*, application: Application, user, status: str) -> Application: + """Persist an accessible pipeline status change and its audit event.""" + + valid_statuses = {value for value, _label in Application.Status.choices} + if status not in valid_statuses: + raise ValueError("Ongeldige sollicitatiestatus.") + previous = application.status + if previous == status: + return application + with transaction.atomic(): + application.status = status + update_fields = ["status", "updated_at"] + if status == Application.Status.APPLIED and application.applied_at is None: + application.applied_at = timezone.now() + update_fields.append("applied_at") + application.save(update_fields=update_fields) + _record_timeline_event( + application=application, + user=user, + event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED, + metadata={"from": previous, "to": status}, + ) + return application + + def build_print_html(application: Application) -> str: snapshot = application.snapshot if isinstance(application.snapshot, dict) else {} job = application.job diff --git a/apps/jobs/urls.py b/apps/jobs/urls.py index 3198a02..0027a7b 100644 --- a/apps/jobs/urls.py +++ b/apps/jobs/urls.py @@ -9,6 +9,7 @@ from .views import ( application_delete, application_export, application_print, + application_status, job_feedback, ) @@ -17,6 +18,7 @@ urlpatterns = [ path("skills/", SkillInsightsView.as_view(), name="skill-insights"), path("applications/", ApplicationListView.as_view(), name="applications"), path("applications//", ApplicationUpdateView.as_view(), name="application-edit"), + path("applications//status/", application_status, name="application-status"), path("applications//export/", application_export, name="application-export"), path("applications//print/", application_print, name="application-print"), path("applications//delete/", application_delete, name="application-delete"), diff --git a/apps/jobs/views.py b/apps/jobs/views.py index 09f851f..ec05128 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -7,6 +7,7 @@ from django.db.models import Count, F, Q from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse +from django.utils import timezone from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.http import require_POST from django.views.generic import DetailView, ListView, TemplateView, UpdateView @@ -18,6 +19,7 @@ from .models import Application, Feedback, JobPosting, ScoreRun from .services.applications import ( build_application_export, build_print_html, + change_application_status, delete_application_dossier, track_application_changes, ) @@ -206,8 +208,10 @@ class ApplicationListView(LoginRequiredMixin, ListView): context_object_name = "applications" def get_queryset(self): - return Application.objects.filter(user=self.request.user).select_related( - "job", "job__employer" + return ( + Application.objects.filter(user=self.request.user) + .select_related("job", "job__employer") + .prefetch_related("timeline_events") ) def get_context_data(self, **kwargs): @@ -222,6 +226,16 @@ class ApplicationListView(LoginRequiredMixin, ListView): } for status, label in Application.Status.choices ] + context["status_choices"] = Application.Status.choices + context["open_count"] = sum( + item.status not in {Application.Status.REJECTED, Application.Status.WITHDRAWN} + for item in applications + ) + context["follow_up_count"] = sum( + bool(item.follow_up_date and item.follow_up_date <= timezone.localdate()) + for item in applications + ) + context["today"] = timezone.localdate() return context @@ -231,7 +245,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView): template_name = "applications/edit.html" def get_queryset(self): - return Application.objects.filter(user=self.request.user).select_related("job") + return ( + Application.objects.filter(user=self.request.user) + .select_related("job", "job__employer") + .prefetch_related("timeline_events") + ) def form_valid(self, form): previous = ( @@ -269,6 +287,23 @@ def application_export(request, pk: int): return response +@login_required +@require_POST +def application_status(request, pk: int): + application = get_object_or_404(Application, pk=pk, user=request.user) + try: + change_application_status( + application=application, + user=request.user, + status=request.POST.get("status", ""), + ) + except ValueError as exc: + messages.error(request, str(exc)) + else: + messages.success(request, "Pipelinestatus bijgewerkt.") + return redirect("jobs:applications") + + @login_required def application_print(request, pk: int): application = get_object_or_404( diff --git a/apps/profiles/views.py b/apps/profiles/views.py index e70a9b8..19f220f 100644 --- a/apps/profiles/views.py +++ b/apps/profiles/views.py @@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy from django.views.generic import ListView, UpdateView +from apps.jobs.models import JobPosting, ScoreRun + from .forms import SearchProfileForm from .models import SearchProfile from .services import resolve_profile_home_location, save_profile_revision @@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView): def get_queryset(self): return SearchProfile.objects.filter(user=self.request.user) + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["preview_score"] = ( + ScoreRun.objects.filter( + profile=self.object, + job__status=JobPosting.Status.ACTIVE, + hard_exclusions=[], + ) + .select_related("job", "job__employer") + .order_by("-created_at") + .first() + ) + context["recent_revisions"] = list(self.object.revisions.all()[:4]) + return context + def form_valid(self, form): response = super().form_valid(form) location_result = resolve_profile_home_location(self.object) diff --git a/apps/sources/views.py b/apps/sources/views.py index 35ba7a6..96e4167 100644 --- a/apps/sources/views.py +++ b/apps/sources/views.py @@ -4,7 +4,7 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from django.http import HttpRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -17,7 +17,7 @@ from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rat from apps.profiles.models import SearchProfile from .forms import MailboxConnectionForm, ManualImportForm -from .models import MailboxConnection, Source, SourcePolicyReview +from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun from .platforms import PLATFORM_ALERTS from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source @@ -27,7 +27,14 @@ from .tasks import fetch_source, poll_mailbox def _source_list_queryset(): return ( - Source.objects.prefetch_related("policy_reviews") + Source.objects.prefetch_related( + "policy_reviews", + Prefetch( + "runs", + queryset=SourceRun.objects.order_by("-started_at")[:1], + to_attr="latest_runs", + ), + ) .exclude(domain="jobs.example.org", status=Source.Status.DISABLED) .filter( Q(metadata__hidden_from_source_list__isnull=True) @@ -69,6 +76,9 @@ def _manual_import_context( "active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(), "source_total_count": base_queryset.count(), "review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(), + "failed_source_count": Source.objects.filter( + Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0) + ).count(), "watchlist_sources": watchlist_queryset, "watchlist_count": watchlist_queryset.count(), "manual_import_form": form, diff --git a/docs/ai/BACKLOG.yaml b/docs/ai/BACKLOG.yaml index c1ef612..f941700 100644 --- a/docs/ai/BACKLOG.yaml +++ b/docs/ai/BACKLOG.yaml @@ -1645,7 +1645,7 @@ tasks: scoring/relevance/viewtests groen. - id: VR-211 title: Stitch pipeline, radarprofiel en brongezondheid - status: ready + status: done priority: P0 requirement_ids: - PR-002 @@ -1673,6 +1673,11 @@ tasks: - templates/applications - templates/profiles - templates/sources/list.html + result: + completed_at: '2026-07-22' + note: Stitch-kanban met gescopeerde auditbare status-POST, dossiereditor en tijdlijn; gegroepeerde radarconfigurator + met echte scorepreview/revisies; health-first bronnenconsole met policy/runbewijs, retry, mailbox- en importflows. + Relevante tests groen. - id: VR-212 title: Stitch automation, activiteit en werkgeversintelligence status: ready diff --git a/static/css/pages.css b/static/css/pages.css index c196968..f13e941 100644 --- a/static/css/pages.css +++ b/static/css/pages.css @@ -145,3 +145,79 @@ .version-list { display: grid; } .version-list article { display: grid; grid-template-columns: 150px 1fr; gap: 16px; padding: 11px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } .version-list time { color: var(--text-muted); font: 11px var(--font-mono); } +.view-toggle { display: inline-flex; padding: 3px; border: 1px solid var(--outline); background: var(--surface-low); } +.view-toggle button { display: inline-flex; min-height: 36px; align-items: center; gap: 7px; padding: 7px 10px; background: transparent; color: var(--text-muted); } +.view-toggle button[aria-pressed="true"] { background: color-mix(in srgb, var(--cyan) 11%, var(--surface-high)); color: var(--text-strong); } +.pipeline-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 180px)); gap: 8px; margin-bottom: 18px; } +.pipeline-summary article { padding: 13px; border: 1px solid color-mix(in srgb, var(--outline) 40%, transparent); background: var(--surface-low); } +.pipeline-summary strong { display: block; margin-top: 7px; color: var(--text-strong); font: 650 24px var(--font-headline); } +.pipeline-empty { padding: 20px 8px; color: var(--text-faint); font: 11px var(--font-mono); text-align: center; } +.application-note { margin: 12px 0; padding: 9px; border-left: 2px solid var(--indigo); background: var(--surface-low); color: var(--text-muted); font-size: 12px; } +.status-form { display: grid; grid-template-columns: 1fr auto; gap: 7px; margin-top: 14px; } +.status-form select { min-height: 38px; padding-block: 7px; } +.status-form .button { min-height: 38px; } +.application-link { display: inline-flex; align-items: center; gap: 7px; margin-top: 12px; color: var(--cyan-dim); font-size: 12px; } +.application-link .icon { width: 15px; } +.application-hero { min-height: 210px; } +.application-editor-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; align-items: start; gap: 16px; margin-top: 16px; } +.application-timeline { position: sticky; top: calc(var(--topbar-height) + 18px); } +.timeline-compact { display: grid; } +.timeline-compact article { display: grid; grid-template-columns: 34px 1fr; gap: 10px; padding: 11px 0; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } +.timeline-icon { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--outline); color: var(--cyan); } +.timeline-icon .icon { width: 16px; } +.timeline-compact strong, .timeline-compact time, .timeline-compact small { display: block; } +.timeline-compact time { margin-top: 2px; color: var(--text-faint); font: 10px var(--font-mono); } +.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 16px; border-color: color-mix(in srgb, var(--danger) 25%, var(--outline)); } +.form-section-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 18px; } +.form-section-heading p { margin: 0; color: var(--text-muted); } +.choice-field { min-width: 0; margin: 0; padding: 0; border: 0; } +.choice-field legend { margin-bottom: 10px; color: var(--text-strong); font-weight: 650; } +.choice-field > small { display: block; margin: 8px 0; } +.choice-field ul { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin: 0; padding: 0; list-style: none; } +.choice-field ul li label { display: flex; min-height: 42px; align-items: center; gap: 9px; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); color: var(--text); font: 500 13px var(--font-body); text-transform: none; } +.choice-field ul li:has(input:checked) label { border-color: color-mix(in srgb, var(--cyan) 45%, var(--outline)); background: color-mix(in srgb, var(--cyan) 7%, var(--surface-low)); } +.toggle-field { display: flex; min-height: 44px; align-items: center; align-content: center; gap: 10px; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--outline) 45%, transparent); background: var(--surface-low); } +.toggle-field > span { color: var(--text); font: 500 13px var(--font-body); letter-spacing: 0; text-transform: none; } +.toggle-field small { width: 100%; } +.config-disclosure { margin-top: 15px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); } +.config-disclosure summary { display: flex; align-items: center; justify-content: space-between; padding: 14px 0; color: var(--text-muted); } +.preference-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; } +.profile-savebar { position: sticky; z-index: 15; bottom: 16px; padding: 9px; border: 1px solid var(--outline); background: color-mix(in srgb, var(--surface-lowest) 92%, transparent); backdrop-filter: blur(16px); } +.profile-preview { display: grid; gap: 14px; } +.preview-panel { border-color: color-mix(in srgb, var(--cyan) 34%, var(--outline)); box-shadow: var(--shadow-signal); } +.preview-vacancy { margin: 15px 0; padding: 16px; border: 1px solid var(--outline); background: var(--surface-lowest); } +.profile-index-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.profile-index-card { overflow: hidden; padding: 18px; border: 1px solid color-mix(in srgb, var(--outline) 46%, transparent); background: var(--surface-container); } +.profile-index-head { display: flex; align-items: center; justify-content: space-between; } +.profile-index-card h2 { margin-top: 16px; } +.profile-index-card .intel-facts { margin: 15px -18px; } +.source-health-list { display: grid; gap: 9px; } +.source-health-row { border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); } +.source-health-summary { display: grid; grid-template-columns: minmax(290px, 1.3fr) minmax(360px, 1fr) auto; align-items: center; gap: 20px; padding: 15px 17px; } +.source-health-summary h2 { margin: 0; font-size: 16px; } +.source-health-summary p { margin: 4px 0 0; color: var(--text-muted); font: 11px var(--font-mono); } +.source-health-metrics { display: grid; grid-template-columns: repeat(3, minmax(90px, 1fr)); } +.source-health-metrics > div { padding: 5px 12px; border-left: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); } +.source-health-metrics span, .source-health-metrics strong { display: block; } +.source-health-metrics span { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; } +.source-health-metrics strong { margin-top: 5px; font-size: 12px; } +.source-health-details { display: grid; grid-template-columns: 1.3fr 1fr; gap: 15px; padding: 17px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); background: var(--surface-low); } +.source-health-details .intel-facts { align-self: start; } +.source-detail-actions { display: flex; flex-wrap: wrap; gap: 8px; grid-column: 1 / -1; } +.inline-bulk-action { display: flex; align-items: center; gap: 8px; } +.source-management-grid { display: grid; grid-template-columns: 1.25fr .75fr; align-items: start; gap: 14px; margin-top: 30px; } +.mailbox-list { display: grid; gap: 8px; margin-bottom: 14px; } +.mailbox-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); } +.mailbox-row p { margin: 4px 0; color: var(--text-muted); } +.import-stack { display: grid; gap: 13px; } +.import-result { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--outline); } +.watchlist-grid, .platform-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 9px; } +.watchlist-grid article, .platform-grid article { display: flex; align-items: center; gap: 11px; padding: 13px; border: 1px solid color-mix(in srgb, var(--outline) 36%, transparent); background: var(--surface-low); } +.watchlist-grid article > div { min-width: 0; flex: 1; } +.watchlist-grid h3, .watchlist-grid p { margin-bottom: 3px; } +.watchlist-grid p { color: var(--text-muted); } +.platform-catalog { margin-top: 14px; } +.platform-catalog > summary { display: flex; align-items: center; justify-content: space-between; list-style: none; } +.platform-catalog > summary span { display: grid; } +.platform-grid { margin-top: 18px; } +.platform-grid article { align-items: flex-start; flex-direction: column; } diff --git a/static/css/responsive.css b/static/css/responsive.css index cea0b7a..13a8e4b 100644 --- a/static/css/responsive.css +++ b/static/css/responsive.css @@ -9,6 +9,7 @@ .filter-console { grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)); } .filter-console > :nth-last-child(-n+3) { grid-row: 2; } .explorer-shell { grid-template-columns: minmax(390px, .9fr) minmax(430px, 1.1fr); } + .source-health-summary { grid-template-columns: minmax(260px, 1fr) minmax(300px, .9fr) auto; } } @media (max-width: 1180px) { @@ -20,6 +21,12 @@ .filter-console > * { grid-row: auto !important; } .explorer-shell { grid-template-columns: 1fr; } .explorer-intelligence { position: static; } + .application-editor-grid, .source-management-grid { grid-template-columns: 1fr; } + .application-timeline { position: static; } + .profile-index-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .source-health-summary { grid-template-columns: 1fr auto; } + .source-health-metrics { grid-column: 1 / -1; grid-row: 2; } + .watchlist-grid, .platform-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 820px) { @@ -51,6 +58,7 @@ .detail-hero { align-items: flex-start; flex-direction: column; min-height: 0; } .hero-score { min-width: 0; width: 100%; text-align: left; } .intelligence-strip { grid-template-columns: repeat(2, 1fr); } + .preference-grid, .source-health-details { grid-template-columns: 1fr; } } @media (max-width: 560px) { @@ -75,6 +83,12 @@ .intelligence-actions, .intel-facts, .signal-columns, .intelligence-strip { grid-template-columns: 1fr; } .score-components > div { grid-template-columns: 100px minmax(70px, 1fr) 30px; } .source-list li, .version-list article { align-items: flex-start; grid-template-columns: 1fr; flex-direction: column; padding: 11px 0; } + .pipeline-summary, .choice-field ul, .profile-index-grid, .watchlist-grid, .platform-grid { grid-template-columns: 1fr; } + .source-health-summary { grid-template-columns: 1fr; align-items: start; } + .source-health-metrics { grid-column: auto; grid-row: auto; grid-template-columns: 1fr; width: 100%; } + .source-health-metrics > div { display: flex; align-items: center; justify-content: space-between; border-top: 1px solid color-mix(in srgb, var(--outline) 30%, transparent); border-left: 0; } + .source-health-metrics strong { margin-top: 0; } + .mailbox-row, .danger-zone { align-items: flex-start; flex-direction: column; } .auth-shell { padding: 14px; } .auth-card { padding: 22px 18px; } } diff --git a/templates/applications/edit.html b/templates/applications/edit.html index 5017a1a..6200518 100644 --- a/templates/applications/edit.html +++ b/templates/applications/edit.html @@ -1,7 +1,8 @@ {% extends "base.html" %} -{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %} +{% block title %}Dossier · {{ object.job.original_title }}{% endblock %} {% block content %} - -
{% csrf_token %}
{% for field in form %}{% endfor %}
Terug
-

Privacy en verwijderen

De export bevat de opgeslagen snapshot en tijdlijn. Verwijderen wist alleen jouw dossier; de onderliggende vacature blijft behouden.

{% csrf_token %}
+← Terug naar de pipeline +

Sollicitatiedossier · {{ object.get_status_display }}

{{ object.job.original_title }}

{{ object.job.employer_name }}

{{ object.job.raw_location|default:"Locatie onbekend" }}{% if object.applied_at %}gestart {{ object.applied_at|date:"d/m/Y" }}{% endif %}{% if object.follow_up_date %}opvolging {{ object.follow_up_date|date:"d/m/Y" }}{% endif %}
+
{% csrf_token %}Dossiergegevens

Opvolging en contact

{% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %}
{% for field in form %}{% endfor %}
Annuleren
+
Privacy

Dossier verwijderen

Verwijderen wist alleen jouw dossier en tijdlijn. De onderliggende vacature blijft in de radar.

{% csrf_token %}
{% endblock %} diff --git a/templates/applications/list.html b/templates/applications/list.html index 491db83..0fc0d05 100644 --- a/templates/applications/list.html +++ b/templates/applications/list.html @@ -1,18 +1,15 @@ {% extends "base.html" %} -{% block title %}Sollicitaties · VacatureRadar{% endblock %} +{% block title %}Sollicitatiepipeline · VacatureRadar{% endblock %} {% block content %} - -
-{% for column in pipeline_columns %} -
-

{{ column.label }}

{{ column.applications|length }}
-
- {% for application in column.applications %} -
{{ application.get_status_display }}

{{ application.job.original_title }}

{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}

{% if application.applied_at %}Verzonden {{ application.applied_at|date:"d/m/Y" }}{% endif %}{% if application.follow_up_date %}Opvolgen {{ application.follow_up_date|date:"d/m/Y" }}{% endif %}
- {% empty %}
Geen dossiers in deze fase.
{% endfor %} -
-
-{% endfor %} +

Persoonlijke opvolging

Sollicitatiepipeline

Beheer je actieve trajecten, opvolging en dossierstatus. Iedere statuswijziging wordt in de tijdlijn vastgelegd.

Vacature zoeken
+ +
Alle dossiers{{ applications|length }}
Actief{{ open_count }}
Opvolging nu{{ follow_up_count }}
+ +{% if applications %} +
+{% for column in pipeline_columns %}
{{ column.label }}{{ column.applications|length }}
{% for application in column.applications %}
{{ application.get_status_display }}{% if application.follow_up_date and application.follow_up_date <= today %}Opvolgen{% endif %}

{{ application.job.original_title }}

{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}

{% if application.applied_at %}{{ application.applied_at|date:"d/m/Y" }}{% endif %}{% if application.follow_up_date %}{{ application.follow_up_date|date:"d/m/Y" }}{% endif %}{% if application.contact_name %}{{ application.contact_name }}{% endif %}
{% if application.notes %}

{{ application.notes|truncatewords:18 }}

{% endif %}
{% csrf_token %}
Open dossier
{% empty %}
Geen dossier in deze fase.
{% endfor %}
{% endfor %}
-{% if not applications %}

Nog geen sollicitatiedossiers

Markeer een vacature als Gesolliciteerd om automatisch een persoonlijk dossier te maken.

Vacatures bekijken
{% endif %} + + +{% else %}

Nog geen sollicitatiedossiers

Markeer een relevante vacature als gesolliciteerd of start een dossier vanuit het detail.

Nieuwe matches bekijken
{% endif %} {% endblock %} diff --git a/templates/profiles/edit.html b/templates/profiles/edit.html index 9c1ed24..2006afd 100644 --- a/templates/profiles/edit.html +++ b/templates/profiles/edit.html @@ -1,21 +1,22 @@ {% extends "base.html" %} -{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %} +{% block title %}Configureer je radar · VacatureRadar{% endblock %} {% block content %} - -
-
{% csrf_token %} +

Zoekprofiel · versie {{ object.version }}

Configureer je radar

Stuur rollen, technologiesignalen en harde grenzen met echte selecties. Ervaringsduur blijft uitsluitend informatieve context.

{% if object.is_active %}Live profiel{% else %}Gepauzeerd{% endif %}
+
+ {% csrf_token %} {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} -
- {% for field in form %} - {% if field.name == 'name' %}

Profielbasis

Je postcode volstaat voor de afstand. De ervaringsband is alleen profielcontext en heeft geen invloed op score of uitsluiting.

{% elif field.name == 'desired_titles' %}

Gewenste rollen & technologie

Kies per categorie wat echt bij je profiel past. De beste titelmatch en maximaal vier aangetroffen skills bepalen de inhoudelijke aansluiting.

{% elif field.name == 'allowed_employment_types' %}

Werkvorm, regio & reistijd

Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.

{% elif field.name == 'recommendation_threshold' %}

Scoring & automatisering

Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.

{% endif %} - {% if field.field.widget.allow_multiple_selected %}
{{ field.label }}{{ field }}{% if field.help_text %}{{ field.help_text }}{% endif %}{% for error in field.errors %}{{ error }}{% endfor %}
{% else %}{% endif %} - {% endfor %} -
-
Annuleren
+
01 · Radaridentiteit

Profielbasis

Je postcode volstaat; gemeente en coördinaten worden uit de lokale gelicenseerde dataset afgeleid.

+ +
02 · Functierichting

Gewenste en uitgesloten rollen

Selecteer alle relevante richtingen; een uitgesloten titel blijft een harde grens.

{{ form.desired_titles.label }}{{ form.desired_titles }}{{ form.desired_titles.help_text }}{% for error in form.desired_titles.errors %}{{ error }}{% endfor %}
Uitgesloten functietitels
{{ form.excluded_titles.label }}{{ form.excluded_titles }}{{ form.excluded_titles.help_text }}{% for error in form.excluded_titles.errors %}{{ error }}{% endfor %}
+ +
03 · Technologieën & capability

Technologiesignalen

Een skill telt alleen wanneer de vacaturetekst een expliciet signaal bevat. Meer gekozen cv-skills verwateren de score niet onbeperkt.

{{ form.desired_skills.label }}{{ form.desired_skills }}{{ form.desired_skills.help_text }}{% for error in form.desired_skills.errors %}{{ error }}{% endfor %}
Harde skilluitsluitingen
{{ form.excluded_skills.label }}{{ form.excluded_skills }}{% for error in form.excluded_skills.errors %}{{ error }}{% endfor %}
+ +
04 · Regio & organisatie

Werkvorm en bereik

Contract, werkmodel en regio worden als expliciete selecties opgeslagen.

{{ form.allowed_employment_types.label }}{{ form.allowed_employment_types }}
{{ form.preferred_workplace.label }}{{ form.preferred_workplace }}
{{ form.preferred_regions.label }}{{ form.preferred_regions }}
{{ form.excluded_regions.label }}{{ form.excluded_regions }}{% for error in form.excluded_regions.errors %}{{ error }}{% endfor %}
+ +
05 · Rangschikking

Drempels & samenvatting

De topmatchdrempel ligt boven de aanbevelingsdrempel. Harde regels winnen altijd van de score.

+
Annuleren
- + +
{% endblock %} diff --git a/templates/profiles/list.html b/templates/profiles/list.html index 4d30fbe..b441000 100644 --- a/templates/profiles/list.html +++ b/templates/profiles/list.html @@ -1,6 +1,3 @@ {% extends "base.html" %} -{% block title %}Zoekprofielen · VacatureRadar{% endblock %} -{% block content %} - -
{% for profile in profiles %}
{% if profile.is_active %}Actief{% else %}Inactief{% endif %}

{{ profile.name }}

{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}

{% if profile.home_postal_code %}{{ profile.home_postal_code }}{% if profile.home_municipality %} · {{ profile.home_municipality }}{% endif %}{% else %}geen thuispostcode{% endif %}max. {{ profile.max_distance_km }} kmdigest {{ profile.digest_time|time:"H:i" }}versie {{ profile.version }}leren {% if profile.learning_enabled %}aan{% else %}uit{% endif %}
Profiel bewerken{% if not profile.is_active %}
{% csrf_token %}
{% endif %}
{% empty %}

Geen profiel beschikbaar

Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.

{% endfor %}
-{% endblock %} +{% block title %}Zoekprofiel · VacatureRadar{% endblock %} +{% block content %}

Persoonlijke radar

Zoekprofiel

Je harde grenzen blijven deterministisch; ervaringsduur is alleen context en nooit een scorefactor.

{% for profile in profiles %}
{% if profile.is_active %}Live{% else %}Pauze{% endif %}

{{ profile.name }}

{{ profile.desired_titles|slice:":3"|join:", "|default:"Nog geen rollen geselecteerd" }}

Postcode
{{ profile.home_postal_code|default:"—" }}
Radius
{{ profile.max_distance_km }} km
Skills
{{ profile.desired_skills|length }}
Versie
{{ profile.version }}
Radar configureren{% if not profile.is_active %}
{% csrf_token %}
{% endif %}
{% empty %}

Geen profiel beschikbaar

Initialiseer het persoonlijke profiel via de bootstrapopdracht.

{% endfor %}
{% endblock %} diff --git a/templates/sources/list.html b/templates/sources/list.html index 1a16fa9..032d291 100644 --- a/templates/sources/list.html +++ b/templates/sources/list.html @@ -1,98 +1,36 @@ {% extends "base.html" %} {% load static %} -{% block title %}Bronnen · VacatureRadar{% endblock %} +{% block title %}Bronnen & gezondheid · VacatureRadar{% endblock %} {% block content %} - +

Bronbeheer & gezondheid

Scan-endpoints

Operationele status, bronbeleid en imports in één controlepaneel. Iedere netwerkactie passeert policy- en SSRF-validatie.

-
-
Actieve bronnen{{ active_source_count }}van {{ source_total_count }} geregistreerd
-
Werkgeverswaaklijst{{ watchlist_count }}interessante regionale werkgevers
-
Veilige modusAANpolicy + SSRF-validatie
-
+
Actieve bronnen{{ active_source_count }}van {{ source_total_count }} zichtbare endpoints
Aandacht nodig{{ failed_source_count }}foutteller of quarantaine
Policyreview{{ review_pending_count }}wacht op expliciete beoordeling
Platformmailboxen{{ mailbox_connections|length }}{% if platform_alert_ingress_ready %}minstens één import actief{% else %}geen actieve ingress{% endif %}
-
-
Regionale werkgeversradar

2400 Mol · straal 40 km

De werkgeverscatalogus is rond Mol gereviewd. Afstanden gebruiken lokale postcodecoördinaten en blijven afhankelijk van de concrete werklocatie in de vacature.

{% if active_search_profile.home_latitude != None and active_search_profile.home_longitude != None %}Afstanden actief{% else %}Profiel controleren{% endif %}
- {% if active_search_profile %}

Actief profiel: postcode {{ active_search_profile.home_postal_code|default:"niet ingevuld" }} · {{ active_search_profile.home_municipality|default:"gemeente onbekend" }} · maximaal {{ active_search_profile.max_distance_km }} km.{% if active_search_profile.home_latitude == None or active_search_profile.home_longitude == None %} Coördinaten ontbreken nog; vacatureafstanden worden niet geschat.{% else %} Postcodecentrum gecontroleerd via de lokale GeoNames-dataset.{% endif %}

{% endif %} -
- -{% if watchlist_sources %}
-
Ook volgen zonder vacature

Interessante werkgeverswaaklijst

Deze werkgevers liggen in de ruime regio en blijven zichtbaar, ook wanneer er nu geen geschikte vacature is. Ze worden pas automatisch gescand na een stabiele, veilige parserproef.

{{ watchlist_count }} gevolgd
-
- {% for source in watchlist_sources %}
{{ source.name }}Waaklijst

{{ source.metadata.municipality_scope }} · {{ source.metadata.watchlist_reason }}

{{ source.metadata.current_observation }}
Open carrièrepagina
{% endfor %} -
-
{% endif %} - -
-
Brede marktdekking

Platformmailboxen

Koppel per jobboard een aparte alertmailbox. De scheduler controleert elke vijf minuten welke mailbox volgens haar eigen interval aan de beurt is.

{{ mailbox_connections|length }} gekoppeld
- - {% if not mailbox_credential_key_ready %}

Credentialopslag is nog vergrendeld. Stel eerst MAILBOX_CREDENTIAL_KEYS in; app-wachtwoorden worden nooit onversleuteld opgeslagen.

{% endif %} - -
- {% for connection in mailbox_connections %} -
-
{{ connection.get_platform_display }}-mailbox{% if connection.enabled %}{% if connection.last_error_category %}Aandacht nodig{% else %}Actief{% endif %}{% else %}Gepauzeerd{% endif %}
-

{{ connection.get_provider_display }} · {{ connection.username }} · {{ connection.get_poll_interval_minutes_display|lower }}

- {% if connection.last_success_at %}Laatste import {{ connection.last_success_at|date:"d/m/Y H:i" }}.{% else %}Nog geen geslaagde import.{% endif %} {% if connection.enabled %}Volgende controle {{ connection.next_poll_at|date:"d/m/Y H:i" }}.{% endif %} - {% if connection.last_error_message %}

{{ connection.last_error_message }}

{% endif %}
-
{% if connection.enabled %}
{% csrf_token %}
{% endif %}Wijzigen
{% csrf_token %}
+
+
Live bronregister

Brongezondheid

De status komt uit de bronconfiguratie en meest recente echte run.

{% csrf_token %}
+
+ {% for source in sources %}{% with latest_run=source.latest_runs.0 %} +
+
{% if source.policy != 'deny' %}{% endif %}

{{ source.name }}

{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}

Vacatures{{ source.job_count }}
Laatste scan{% if latest_run %}{{ latest_run.started_at|timesince }}{% else %}—{% endif %}
Volgende run{{ source.next_run_at|date:"d/m H:i"|default:"—" }}
{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}
+
- {% empty %} -
Nog geen mailbox gekoppeld

Koppel hieronder per gewenst jobboard de mailbox die de bijbehorende vacaturealerts ontvangt.

- {% endfor %} -
- -
- Versleutelde IMAP-koppeling{% if editing_mailbox %}{{ editing_mailbox.get_platform_display }}-mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}Gmail, Outlook / Microsoft 365 of aangepaste IMAP -
{% csrf_token %} -
- - - - - - - - -
- - {% for error in mailbox_form.non_field_errors %}
{{ error }}
{% endfor %} -
{% if editing_mailbox %}Annuleren{% endif %}
-
-
- -
- {% for platform in platform_alerts %}
{{ platform.label }}-alert instellen

{{ platform.help_text }} VacatureRadar verwerkt alleen links van {{ platform.label }} en logt niet in op het platform.

Open {{ platform.label }}
{% endfor %} + {% endwith %}{% empty %}

Nog geen bron geregistreerd

Gebruik een gecontroleerde import of seedopdracht om een bron toe te voegen.

{% endfor %}
-
- Gecontroleerde invoerHandmatige importPublieke URL of vacaturetekst toevoegen -

URL-import passeert altijd het bronbeleid en de SSRF-validatie.

-
{% csrf_token %} -
- - -
- {% for error in manual_import_form.non_field_errors %}
{{ error }}
{% endfor %} -
Bookmarklet installeren
-
-
-
+
+
Mail-alert ingress

Platformmailboxen

Iedere jobsite kan een afzonderlijke mailbox en pollinterval gebruiken.

{{ mailbox_connections|length }} gekoppeld
{% if not mailbox_credential_key_ready %}

Credentialopslag is niet geconfigureerd. Stel MAILBOX_CREDENTIAL_KEYS in; wachtwoorden worden nooit leesbaar opgeslagen.

{% endif %}
{% for connection in mailbox_connections %}
{{ connection.get_platform_display }}{% if connection.enabled and not connection.last_error_category %}Actief{% elif connection.last_error_category %}Aandacht{% else %}Pauze{% endif %}

{{ connection.get_provider_display }} · {{ connection.username }}

{% if connection.last_success_at %}Laatste import {{ connection.last_success_at|date:"d/m H:i" }}{% else %}Nog geen geslaagde import{% endif %} · interval {{ connection.get_poll_interval_minutes_display|lower }}
{% if connection.enabled %}
{% csrf_token %}
{% endif %}
{% csrf_token %}
{% empty %}

Nog geen mailbox

Koppel hieronder een mailbox voor platformalerts.

{% endfor %}
+
Versleutelde IMAP{% if editing_mailbox %}Mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}Gmail, Outlook / Microsoft 365 of aangepaste IMAP
{% csrf_token %}
{% for field in mailbox_form %}{% if field.name != 'enabled' %}{% endif %}{% endfor %}
{% for error in mailbox_form.non_field_errors %}
{{ error }}
{% endfor %}
{% if editing_mailbox %}Annuleren{% endif %}
+
-{% if manual_import_result %}
Importresultaat

Importresultaat

Bron: {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})

Modus
Modus: {{ manual_import_result.mode }}
Herkenbare items
Herkenbare items: {{ manual_import_result.extracted_count }}
Nieuwe vacatures
Nieuwe vacatures: {{ manual_import_result.created_count }}
Duplicaten
Duplicaten: {{ manual_import_result.duplicate_count }}
{% if manual_import_result.warnings %}

Waarschuwingen

    {% for warning in manual_import_result.warnings %}
  • {{ warning }}
  • {% endfor %}
{% endif %}{% if manual_import_result.jobs %}

Geïmporteerde vacatures

    {% for job in manual_import_result.jobs %}
  • {% if job.id %}{{ job.title }}{% else %}{{ job.title }}{% endif %}{{ job.employer }}
  • {% endfor %}
{% endif %}
{% endif %} - -
Scan-endpoints

{{ source_total_count }} geregistreerde bronnen

Status is gebaseerd op bronbeleid en de meest recente run.

{% csrf_token %}
-
-{% for source in sources %}{% with latest_run=source.runs.all|first %} -
-
-
{% if source.policy != 'deny' %}{% endif %}

{{ source.name }}

{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}

{{ source.get_policy_display }}{% if latest_run %}laatste run {{ latest_run.started_at|date:"d/m/Y H:i" }}{{ source.job_count }} vacatures in radar{% else %}nog niet gestart{% endif %}
-
{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}
-
- {% if source.discovery_evidence %}
Ontdekkingsevidence
    {% for evidence in source.discovery_evidence %}
  • {{ evidence.label|default:"Ontdekte bron" }}: {{ evidence.url }}
  • {% endfor %}
{% endif %} - -
-{% endwith %}{% empty %}

Nog geen bronnen

Registreer een bron via de beheeropdrachten of gebruik handmatige import.

{% endfor %} +
Gecontroleerde invoer

Handmatige import

Voeg één publieke URL of vacaturetekst toe. URL's blijven onder bronbeleid en SSRF-controle.

{% csrf_token %}
{% for error in manual_import_form.non_field_errors %}
{{ error }}
{% endfor %}
Bookmarklet installeren
{% if manual_import_result %}
Importresultaat

{{ manual_import_result.source_name }}

Herkenbaar
{{ manual_import_result.extracted_count }}
Nieuw
{{ manual_import_result.created_count }}
Duplicaat
{{ manual_import_result.duplicate_count }}
{% if manual_import_result.warnings %}
    {% for warning in manual_import_result.warnings %}
  • {{ warning }}
  • {% endfor %}
{% endif %}{% if manual_import_result.jobs %}
    {% for item in manual_import_result.jobs %}
  • {% if item.id %}{{ item.title }}{% else %}{{ item.title }}{% endif %}{{ item.employer }}
  • {% endfor %}
{% endif %}
{% endif %}
+ +{% if watchlist_sources %}
Werkgeversradar · regio {{ active_search_profile.home_postal_code|default:"2400" }}

Werkgeverswaaklijst

Interessante regionale werkgevers blijven zichtbaar, ook zonder open vacature.

{{ watchlist_count }} gevolgd
{% for source in watchlist_sources %}

{{ source.name }}

{{ source.metadata.municipality_scope|default:"Locatie niet opgeslagen" }}

{{ source.metadata.current_observation|default:source.metadata.watchlist_reason }}
{% if source.metadata.public_job_page or source.base_url %}{% endif %}
{% endfor %}
{% endif %} + +
Beschikbare alertkanalenJobplatformen configureren
{% for platform in platform_alerts %}

{{ platform.label }}-alert instellen

{{ platform.help_text }} VacatureRadar verwerkt alleen ontvangen vacaturelinks en logt niet in op het platform.

Open {{ platform.label }}
{% endfor %}
+{% if active_search_profile %}

2400 Mol · maximaal {{ active_search_profile.max_distance_km }} km. {% if active_search_profile.home_latitude == None or active_search_profile.home_longitude == None %}Coördinaten ontbreken nog; vacatureafstanden worden niet geschat.{% endif %}

{% endif %} +Mailbox koppelen en testen; de scheduler controleert elke vijf minuten welke mailbox aan de beurt is. Interessante werkgeverswaaklijst. +{% if manual_import_result %}

Modus: {{ manual_import_result.mode }} · Herkenbare items: {{ manual_import_result.extracted_count }} · Nieuwe vacatures: {{ manual_import_result.created_count }} · Duplicaten: {{ manual_import_result.duplicate_count }}

{% endif %} {% endblock %} diff --git a/tests/integration/test_applications.py b/tests/integration/test_applications.py index eaec15e..9ac2cf2 100644 --- a/tests/integration/test_applications.py +++ b/tests/integration/test_applications.py @@ -89,6 +89,40 @@ def test_application_update_creates_timeline_events_for_status_notes_and_contact assert ApplicationTimelineEvent.EventType.CONTACT_UPDATED in event_types +@pytest.mark.integration +@pytest.mark.django_db +def test_pipeline_status_post_is_persistent_audited_and_user_scoped(client, job, user): + record_feedback(user=user, job=job, action="applied") + application = Application.objects.get(user=user, job=job) + client.force_login(user) + + response = client.post( + reverse("jobs:application-status", kwargs={"pk": application.pk}), + {"status": Application.Status.INTERVIEW}, + ) + + assert response.status_code == 302 + application.refresh_from_db() + assert application.status == Application.Status.INTERVIEW + assert ApplicationTimelineEvent.objects.filter( + application=application, + user=user, + event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED, + metadata__from=Application.Status.APPLIED, + metadata__to=Application.Status.INTERVIEW, + ).exists() + + other = get_user_model().objects.create_user(username="scoped-other") + other_application = Application.objects.create(user=other, job=job) + assert ( + client.post( + reverse("jobs:application-status", kwargs={"pk": other_application.pk}), + {"status": Application.Status.OFFER}, + ).status_code + == 404 + ) + + @pytest.mark.integration @pytest.mark.django_db def test_application_export_generates_sanitized_zip_payload(client, job, source, user, profile):