feat: replace pipeline profile and source health

This commit is contained in:
Jens
2026-07-22 20:10:34 +02:00
parent 3371455255
commit 4fa2a34447
14 changed files with 282 additions and 130 deletions
+25
View File
@@ -274,6 +274,31 @@ def track_application_changes(
return events 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: def build_print_html(application: Application) -> str:
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {} snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
job = application.job job = application.job
+2
View File
@@ -9,6 +9,7 @@ from .views import (
application_delete, application_delete,
application_export, application_export,
application_print, application_print,
application_status,
job_feedback, job_feedback,
) )
@@ -17,6 +18,7 @@ urlpatterns = [
path("skills/", SkillInsightsView.as_view(), name="skill-insights"), path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
path("applications/", ApplicationListView.as_view(), name="applications"), path("applications/", ApplicationListView.as_view(), name="applications"),
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"), path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
path("applications/<int:pk>/status/", application_status, name="application-status"),
path("applications/<int:pk>/export/", application_export, name="application-export"), path("applications/<int:pk>/export/", application_export, name="application-export"),
path("applications/<int:pk>/print/", application_print, name="application-print"), path("applications/<int:pk>/print/", application_print, name="application-print"),
path("applications/<int:pk>/delete/", application_delete, name="application-delete"), path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
+38 -3
View File
@@ -7,6 +7,7 @@ from django.db.models import Count, F, Q
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, TemplateView, UpdateView from django.views.generic import DetailView, ListView, TemplateView, UpdateView
@@ -18,6 +19,7 @@ from .models import Application, Feedback, JobPosting, ScoreRun
from .services.applications import ( from .services.applications import (
build_application_export, build_application_export,
build_print_html, build_print_html,
change_application_status,
delete_application_dossier, delete_application_dossier,
track_application_changes, track_application_changes,
) )
@@ -206,8 +208,10 @@ class ApplicationListView(LoginRequiredMixin, ListView):
context_object_name = "applications" context_object_name = "applications"
def get_queryset(self): def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related( return (
"job", "job__employer" Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
) )
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -222,6 +226,16 @@ class ApplicationListView(LoginRequiredMixin, ListView):
} }
for status, label in Application.Status.choices 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 return context
@@ -231,7 +245,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
template_name = "applications/edit.html" template_name = "applications/edit.html"
def get_queryset(self): 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): def form_valid(self, form):
previous = ( previous = (
@@ -269,6 +287,23 @@ def application_export(request, pk: int):
return response 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 @login_required
def application_print(request, pk: int): def application_print(request, pk: int):
application = get_object_or_404( application = get_object_or_404(
+17
View File
@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView from django.views.generic import ListView, UpdateView
from apps.jobs.models import JobPosting, ScoreRun
from .forms import SearchProfileForm from .forms import SearchProfileForm
from .models import SearchProfile from .models import SearchProfile
from .services import resolve_profile_home_location, save_profile_revision from .services import resolve_profile_home_location, save_profile_revision
@@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
def get_queryset(self): def get_queryset(self):
return SearchProfile.objects.filter(user=self.request.user) 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): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
location_result = resolve_profile_home_location(self.object) location_result = resolve_profile_home_location(self.object)
+13 -3
View File
@@ -4,7 +4,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin 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.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse 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 apps.profiles.models import SearchProfile
from .forms import MailboxConnectionForm, ManualImportForm from .forms import MailboxConnectionForm, ManualImportForm
from .models import MailboxConnection, Source, SourcePolicyReview from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun
from .platforms import PLATFORM_ALERTS from .platforms import PLATFORM_ALERTS
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source 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(): def _source_list_queryset():
return ( 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) .exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
.filter( .filter(
Q(metadata__hidden_from_source_list__isnull=True) 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(), "active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
"source_total_count": base_queryset.count(), "source_total_count": base_queryset.count(),
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).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_sources": watchlist_queryset,
"watchlist_count": watchlist_queryset.count(), "watchlist_count": watchlist_queryset.count(),
"manual_import_form": form, "manual_import_form": form,
+6 -1
View File
@@ -1645,7 +1645,7 @@ tasks:
scoring/relevance/viewtests groen. scoring/relevance/viewtests groen.
- id: VR-211 - id: VR-211
title: Stitch pipeline, radarprofiel en brongezondheid title: Stitch pipeline, radarprofiel en brongezondheid
status: ready status: done
priority: P0 priority: P0
requirement_ids: requirement_ids:
- PR-002 - PR-002
@@ -1673,6 +1673,11 @@ tasks:
- templates/applications - templates/applications
- templates/profiles - templates/profiles
- templates/sources/list.html - 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 - id: VR-212
title: Stitch automation, activiteit en werkgeversintelligence title: Stitch automation, activiteit en werkgeversintelligence
status: ready status: ready
+76
View File
@@ -145,3 +145,79 @@
.version-list { display: grid; } .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 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); } .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; }
+14
View File
@@ -9,6 +9,7 @@
.filter-console { grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)); } .filter-console { grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)); }
.filter-console > :nth-last-child(-n+3) { grid-row: 2; } .filter-console > :nth-last-child(-n+3) { grid-row: 2; }
.explorer-shell { grid-template-columns: minmax(390px, .9fr) minmax(430px, 1.1fr); } .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) { @media (max-width: 1180px) {
@@ -20,6 +21,12 @@
.filter-console > * { grid-row: auto !important; } .filter-console > * { grid-row: auto !important; }
.explorer-shell { grid-template-columns: 1fr; } .explorer-shell { grid-template-columns: 1fr; }
.explorer-intelligence { position: static; } .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) { @media (max-width: 820px) {
@@ -51,6 +58,7 @@
.detail-hero { align-items: flex-start; flex-direction: column; min-height: 0; } .detail-hero { align-items: flex-start; flex-direction: column; min-height: 0; }
.hero-score { min-width: 0; width: 100%; text-align: left; } .hero-score { min-width: 0; width: 100%; text-align: left; }
.intelligence-strip { grid-template-columns: repeat(2, 1fr); } .intelligence-strip { grid-template-columns: repeat(2, 1fr); }
.preference-grid, .source-health-details { grid-template-columns: 1fr; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@@ -75,6 +83,12 @@
.intelligence-actions, .intel-facts, .signal-columns, .intelligence-strip { grid-template-columns: 1fr; } .intelligence-actions, .intel-facts, .signal-columns, .intelligence-strip { grid-template-columns: 1fr; }
.score-components > div { grid-template-columns: 100px minmax(70px, 1fr) 30px; } .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; } .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-shell { padding: 14px; }
.auth-card { padding: 22px 18px; } .auth-card { padding: 22px 18px; }
} }
+5 -4
View File
@@ -1,7 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %} {% block title %}Dossier · {{ object.job.original_title }}{% endblock %}
{% block content %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Sollicitatiedossier</p><h1>{{ object.job.original_title }}</h1><p>{{ object.job.employer_name }} · alleen zichtbaar voor jouw account</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:application-print' object.pk %}" target="_blank" rel="noopener">Afdrukweergave</a><a class="button button-secondary" href="{% url 'jobs:application-export' object.pk %}">Exporteren</a></div></header> <a class="back-link" href="{% url 'jobs:applications' %}">← Terug naar de pipeline</a>
<form method="post" class="panel form-panel">{% csrf_token %}<div class="form-grid">{% for field in form %}<label class="{% if field.name == 'notes' %}span-2{% endif %}"><span>{{ field.label }}</span>{{ field }}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'jobs:applications' %}">Terug</a><button class="button button-primary" type="submit">Dossier opslaan</button></div></form> <header class="detail-hero application-hero"><div><p class="eyebrow">Sollicitatiedossier · {{ object.get_status_display }}</p><h1>{{ object.job.original_title }}</h1><p class="detail-employer">{{ object.job.employer_name }}</p><div class="job-meta"><span>{{ object.job.raw_location|default:"Locatie onbekend" }}</span>{% if object.applied_at %}<span>gestart {{ object.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if object.follow_up_date %}<span>opvolging {{ object.follow_up_date|date:"d/m/Y" }}</span>{% endif %}</div></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:application-print' object.pk %}" target="_blank" rel="noopener">Afdrukweergave</a><a class="button button-secondary" href="{% url 'jobs:application-export' object.pk %}">Exporteren</a></div></header>
<section class="panel panel-subtle"><h2>Privacy en verwijderen</h2><p class="muted">De export bevat de opgeslagen snapshot en tijdlijn. Verwijderen wist alleen jouw dossier; de onderliggende vacature blijft behouden.</p><form method="post" action="{% url 'jobs:application-delete' object.pk %}">{% csrf_token %}<button class="button button-danger" type="submit">Dossier verwijderen</button></form></section> <div class="application-editor-grid"><form method="post" class="form-panel">{% csrf_token %}<span class="section-kicker">Dossiergegevens</span><h2>Opvolging en contact</h2>{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}<div class="form-grid">{% for field in form %}<label class="{% if field.name == 'notes' %}span-2{% endif %}"><span>{{ field.label }}</span>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'jobs:applications' %}">Annuleren</a><button class="button button-primary" type="submit">Dossier opslaan</button></div></form><aside class="application-timeline panel"><span class="section-kicker">Audit trail</span><h2>Activiteit</h2><div class="timeline-compact">{% for event in object.timeline_events.all %}<article><span class="timeline-icon"><svg class="icon"><use href="#icon-activity"></use></svg></span><div><strong>{{ event.get_event_type_display }}</strong><time datetime="{{ event.created_at|date:'c' }}">{{ event.created_at|date:"d/m/Y H:i" }}</time>{% if event.metadata.from or event.metadata.to %}<small>{{ event.metadata.from|default:"—" }} → {{ event.metadata.to|default:"—" }}</small>{% endif %}</div></article>{% empty %}<p class="muted">Nog geen tijdlijngebeurtenis.</p>{% endfor %}</div></aside></div>
<section class="panel danger-zone"><div><span class="section-kicker">Privacy</span><h2>Dossier verwijderen</h2><p class="muted">Verwijderen wist alleen jouw dossier en tijdlijn. De onderliggende vacature blijft in de radar.</p></div><form method="post" action="{% url 'jobs:application-delete' object.pk %}">{% csrf_token %}<button class="button button-danger" type="submit">Dossier verwijderen</button></form></section>
{% endblock %} {% endblock %}
+11 -14
View File
@@ -1,18 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sollicitaties · VacatureRadar{% endblock %} {% block title %}Sollicitatiepipeline · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<header class="page-header pipeline-page-header"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Beheer je actieve trajecten, deadlines en bewaarde vacaturesnapshots.</p></div><div class="page-actions"><span class="view-indicator"><svg class="icon"><use href="#icon-pipeline"></use></svg>Kanban</span><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacature toevoegen</a></div></header> <header class="cockpit-heading"><div><p class="eyebrow">Persoonlijke opvolging</p><h1>Sollicitatiepipeline</h1><p>Beheer je actieve trajecten, opvolging en dossierstatus. Iedere statuswijziging wordt in de tijdlijn vastgelegd.</p></div><div class="page-actions"><div class="view-toggle" data-view-controls aria-label="Weergave"><button type="button" data-view-switch="kanban" aria-pressed="true"><svg class="icon"><use href="#icon-pipeline"></use></svg>Kanban</button><button type="button" data-view-switch="list" aria-pressed="false"><svg class="icon"><use href="#icon-list"></use></svg>Lijst</button></div><a class="button button-primary" href="{% url 'jobs:list' %}"><svg class="icon"><use href="#icon-search"></use></svg>Vacature zoeken</a></div></header>
<section class="pipeline" aria-label="Sollicitaties per status">
{% for column in pipeline_columns %} <section class="pipeline-summary" aria-label="Pipeline kerncijfers"><article><span class="field-label">Alle dossiers</span><strong>{{ applications|length }}</strong></article><article><span class="field-label">Actief</span><strong>{{ open_count }}</strong></article><article><span class="field-label">Opvolging nu</span><strong>{{ follow_up_count }}</strong></article></section>
<div class="pipeline-column">
<div class="pipeline-head"><h2><span class="column-dot" aria-hidden="true"></span>{{ column.label }}</h2><span class="pipeline-count">{{ column.applications|length }}</span></div> {% if applications %}
<div class="pipeline-cards"> <section class="pipeline-board" data-view="kanban" aria-label="Sollicitaties per status">
{% for application in column.applications %} {% for column in pipeline_columns %}<div class="pipeline-column"><div class="pipeline-column-header"><span>{{ column.label }}</span><span class="pill">{{ column.applications|length }}</span></div><div>{% for application in column.applications %}<article class="application-card"><div class="title-row"><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span>{% if application.follow_up_date and application.follow_up_date <= today %}<span class="pill pill-failed">Opvolgen</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h3><p class="muted">{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}</p><div class="job-meta">{% if application.applied_at %}<span><svg class="icon"><use href="#icon-mail"></use></svg>{{ application.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if application.follow_up_date %}<span><svg class="icon"><use href="#icon-calendar"></use></svg>{{ application.follow_up_date|date:"d/m/Y" }}</span>{% endif %}{% if application.contact_name %}<span>{{ application.contact_name }}</span>{% endif %}</div>{% if application.notes %}<p class="application-note">{{ application.notes|truncatewords:18 }}</p>{% endif %}<form method="post" action="{% url 'jobs:application-status' application.pk %}" class="status-form">{% csrf_token %}<label><span class="sr-only">Status voor {{ application.job.original_title }}</span><select name="status">{% for value,label in status_choices %}<option value="{{ value }}" {% if value == application.status %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="button button-secondary" type="submit">Bijwerken</button></form><a class="application-link" href="{% url 'jobs:application-edit' application.pk %}">Open dossier <svg class="icon"><use href="#icon-arrow"></use></svg></a></article>{% empty %}<div class="pipeline-empty">Geen dossier in deze fase.</div>{% endfor %}</div></div>{% endfor %}
<article class="pipeline-card"><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span><h3><a href="{% url 'jobs:detail' application.job.pk %}">{{ application.job.original_title }}</a></h3><p>{{ application.job.employer_name }}{% if application.job.raw_location %} · {{ application.job.raw_location }}{% endif %}</p><div class="job-meta">{% if application.applied_at %}<span>Verzonden {{ application.applied_at|date:"d/m/Y" }}</span>{% endif %}{% if application.follow_up_date %}<span>Opvolgen {{ application.follow_up_date|date:"d/m/Y" }}</span>{% endif %}</div><div class="job-actions"><a class="button button-ghost button-small" href="{% url 'jobs:application-edit' application.pk %}">Dossier <svg class="icon"><use href="#icon-arrow"></use></svg></a></div></article>
{% empty %}<div class="pipeline-empty">Geen dossiers in deze fase.</div>{% endfor %}
</div>
</div>
{% endfor %}
</section> </section>
{% if not applications %}<div class="empty-state compact-empty"><div class="empty-icon"><svg class="icon"><use href="#icon-pipeline"></use></svg></div><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een vacature als Gesolliciteerd om automatisch een persoonlijk dossier te maken.</p><a class="button button-primary" href="{% url 'jobs:list' %}">Vacatures bekijken</a></div>{% endif %}
<section class="panel" data-view="list" hidden aria-label="Sollicitaties als lijst"><div class="data-table-wrap" tabindex="0"><table class="data-table"><thead><tr><th>Vacature</th><th>Status</th><th>Opvolging</th><th>Contact</th><th>Bijgewerkt</th><th><span class="sr-only">Actie</span></th></tr></thead><tbody>{% for application in applications %}<tr><td><strong>{{ application.job.original_title }}</strong><small>{{ application.job.employer_name }}</small></td><td><span class="pill pill-{{ application.status }}">{{ application.get_status_display }}</span></td><td>{{ application.follow_up_date|date:"d/m/Y"|default:"Niet gepland" }}</td><td>{{ application.contact_name|default:"Onbekend" }}</td><td>{{ application.updated_at|date:"d/m/Y H:i" }}</td><td><a class="button button-ghost" href="{% url 'jobs:application-edit' application.pk %}">Dossier</a></td></tr>{% endfor %}</tbody></table></div></section>
{% else %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-pipeline"></use></svg></div><h2>Nog geen sollicitatiedossiers</h2><p>Markeer een relevante vacature als gesolliciteerd of start een dossier vanuit het detail.</p><a class="button button-primary" href="{% url 'jobs:list' %}">Nieuwe matches bekijken</a></div>{% endif %}
{% endblock %} {% endblock %}
+16 -15
View File
@@ -1,21 +1,22 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %} {% block title %}Configureer je radar · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>Configureer je radar</h1><p>Verfijn {{ object.name }} zodat alleen relevante, uitlegbare kansen bovenaan verschijnen.</p></div><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Actief profiel{% else %}Inactief profiel{% endif %}</span></header> <header class="cockpit-heading"><div><p class="eyebrow">Zoekprofiel · versie {{ object.version }}</p><h1>Configureer je radar</h1><p>Stuur rollen, technologiesignalen en harde grenzen met echte selecties. Ervaringsduur blijft uitsluitend informatieve context.</p></div><span class="pill {% if object.is_active %}pill-success{% endif %}">{% if object.is_active %}Live profiel{% else %}Gepauzeerd{% endif %}</span></header>
<div class="editor-layout"> <div class="profile-editor-layout">
<form id="profile-form" method="post" class="panel form-panel profile-editor">{% csrf_token %} <form id="profile-form" method="post" class="radar-configurator">{% csrf_token %}
{% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %} {% if form.non_field_errors %}<div class="message error">{{ form.non_field_errors }}</div>{% endif %}
<div class="form-grid"> <section class="form-section" id="basis"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><span class="section-kicker">01 · Radaridentiteit</span><h2>Profielbasis</h2><p>Je postcode volstaat; gemeente en coördinaten worden uit de lokale gelicenseerde dataset afgeleid.</p></div></div><div class="form-grid"><label><span>{{ form.name.label }}</span>{{ form.name }}{% for error in form.name.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label class="toggle-field">{{ form.is_active }}<span>{{ form.is_active.label }}</span></label><label><span>{{ form.home_postal_code.label }}</span>{{ form.home_postal_code }}<small>{{ form.home_postal_code.help_text }}</small>{% for error in form.home_postal_code.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.max_distance_km.label }}</span>{{ form.max_distance_km }}{% for error in form.max_distance_km.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label class="span-2"><span>{{ form.experience_years.label }}</span>{{ form.experience_years }}<small>{{ form.experience_years.help_text }}</small></label></div></section>
{% for field in form %}
{% if field.name == 'name' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><div><h2>Profielbasis</h2><p>Je postcode volstaat voor de afstand. De ervaringsband is alleen profielcontext en heeft geen invloed op score of uitsluiting.</p></div></div>{% elif field.name == 'desired_titles' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><h2>Gewenste rollen & technologie</h2><p>Kies per categorie wat echt bij je profiel past. De beste titelmatch en maximaal vier aangetroffen skills bepalen de inhoudelijke aansluiting.</p></div></div>{% elif field.name == 'allowed_employment_types' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><h2>Werkvorm, regio & reistijd</h2><p>Selecteer contractvormen, werkmodellen en regio's zonder technische notatie.</p></div></div>{% elif field.name == 'recommendation_threshold' %}<div class="form-section"><span class="form-section-icon"><svg class="icon"><use href="#icon-radar"></use></svg></span><div><h2>Scoring & automatisering</h2><p>Een topmatch ligt altijd boven de gewone aanbevelingsdrempel.</p></div></div>{% endif %} <section class="form-section" id="rollen"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-briefcase"></use></svg></span><div><span class="section-kicker">02 · Functierichting</span><h2>Gewenste en uitgesloten rollen</h2><p>Selecteer alle relevante richtingen; een uitgesloten titel blijft een harde grens.</p></div></div><fieldset class="choice-field"><legend>{{ form.desired_titles.label }}</legend>{{ form.desired_titles }}<small>{{ form.desired_titles.help_text }}</small>{% for error in form.desired_titles.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset><details class="config-disclosure"><summary>Uitgesloten functietitels <svg class="icon"><use href="#icon-chevron"></use></svg></summary><fieldset class="choice-field"><legend class="sr-only">{{ form.excluded_titles.label }}</legend>{{ form.excluded_titles }}<small>{{ form.excluded_titles.help_text }}</small>{% for error in form.excluded_titles.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></details></section>
{% if field.field.widget.allow_multiple_selected %}<fieldset class="choice-field span-2"><legend>{{ field.label }}</legend>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset>{% else %}<label><span>{{ field.label }}</span>{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endif %}
{% endfor %} <section class="form-section" id="technologie"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-chart"></use></svg></span><div><span class="section-kicker">03 · Technologieën & capability</span><h2>Technologiesignalen</h2><p>Een skill telt alleen wanneer de vacaturetekst een expliciet signaal bevat. Meer gekozen cv-skills verwateren de score niet onbeperkt.</p></div></div><fieldset class="choice-field"><legend>{{ form.desired_skills.label }}</legend>{{ form.desired_skills }}<small>{{ form.desired_skills.help_text }}</small>{% for error in form.desired_skills.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset><details class="config-disclosure"><summary>Harde skilluitsluitingen <svg class="icon"><use href="#icon-chevron"></use></svg></summary><fieldset class="choice-field"><legend class="sr-only">{{ form.excluded_skills.label }}</legend>{{ form.excluded_skills }}{% for error in form.excluded_skills.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></details></section>
</div>
<div class="form-actions"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div> <section class="form-section" id="werkvorm"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-location"></use></svg></span><div><span class="section-kicker">04 · Regio & organisatie</span><h2>Werkvorm en bereik</h2><p>Contract, werkmodel en regio worden als expliciete selecties opgeslagen.</p></div></div><div class="preference-grid"><fieldset class="choice-field"><legend>{{ form.allowed_employment_types.label }}</legend>{{ form.allowed_employment_types }}</fieldset><fieldset class="choice-field"><legend>{{ form.preferred_workplace.label }}</legend>{{ form.preferred_workplace }}</fieldset><fieldset class="choice-field"><legend>{{ form.preferred_regions.label }}</legend>{{ form.preferred_regions }}</fieldset><fieldset class="choice-field"><legend>{{ form.excluded_regions.label }}</legend>{{ form.excluded_regions }}{% for error in form.excluded_regions.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</fieldset></div></section>
<section class="form-section" id="automatisering"><div class="form-section-heading"><span class="source-icon"><svg class="icon"><use href="#icon-system"></use></svg></span><div><span class="section-kicker">05 · Rangschikking</span><h2>Drempels & samenvatting</h2><p>De topmatchdrempel ligt boven de aanbevelingsdrempel. Harde regels winnen altijd van de score.</p></div></div><div class="form-grid"><label><span>{{ form.recommendation_threshold.label }}</span>{{ form.recommendation_threshold }}{% for error in form.recommendation_threshold.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.top_match_threshold.label }}</span>{{ form.top_match_threshold }}{% for error in form.top_match_threshold.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ form.digest_time.label }}</span>{{ form.digest_time }}</label><label class="toggle-field">{{ form.learning_enabled }}<span>{{ form.learning_enabled.label }}</span><small>{{ form.learning_enabled.help_text }}</small></label></div></section>
<div class="form-actions profile-savebar"><a class="button button-ghost" href="{% url 'profiles:list' %}">Annuleren</a><button class="button button-primary" type="submit">Instellingen opslaan</button></div>
</form> </form>
<aside class="editor-rail" aria-label="Profielsamenvatting">
<section class="panel radar-preview"><div class="title-row"><span class="section-kicker">Radarprofiel</span><span class="pill {% if object.is_active %}pill-active{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ object.name }}</h2><p class="muted">De huidige opgeslagen configuratie.</p><div class="preview-score"><strong>{{ object.top_match_threshold }}%</strong><span>Topmatch vanaf</span></div><div class="rail-list"><div class="rail-item"><span>Thuislocatie</span><strong>{% if object.home_postal_code %}{{ object.home_postal_code }}{% if object.home_municipality %} · {{ object.home_municipality }}{% endif %}{% else %}Niet ingesteld{% endif %}</strong></div><div class="rail-item"><span>IT-ervaring · informatief</span><strong>{{ object.get_experience_years_display }}</strong></div><div class="rail-item"><span>Gewenste rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Gewenste skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Maximumafstand</span><strong>{{ object.max_distance_km }} km</strong></div><div class="rail-item"><span>Gecontroleerd leren</span><strong>{% if object.learning_enabled %}Aan{% else %}Uit{% endif %}</strong></div></div></section> <aside class="profile-preview" aria-label="Radar preview"><section class="panel preview-panel"><div class="title-row"><span class="section-kicker">Radar preview</span><span class="pill {% if object.is_active %}pill-success{% endif %}">{% if object.is_active %}Live{% else %}Pauze{% endif %}</span></div>{% if preview_score %}{% widthratio preview_score.confidence 1 100 as preview_confidence %}<div class="preview-vacancy"><span class="field-label">Laatste echte beoordeling</span><h2>{{ preview_score.job.original_title }}</h2><p>{{ preview_score.job.employer_name }}</p><div class="intelligence-score"><strong>{{ preview_score.score|floatformat:0 }}%</strong><span>{{ preview_score.get_recommendation_display }}<small>Datakwaliteit {{ preview_confidence }}%</small></span></div>{% if preview_score.positives %}<ul class="compact-list positive">{% for item in preview_score.positives|slice:":3" %}<li>{{ item }}</li>{% endfor %}</ul>{% endif %}<a class="button button-ghost button-block" href="{% url 'jobs:detail' preview_score.job.pk %}">Bekijk onderbouwing</a></div>{% else %}<div class="empty-state compact-empty"><h2>Nog geen echte preview</h2><p>Na de volgende profielscore verschijnt hier een recente vacature, zonder demo-data.</p></div>{% endif %}<div class="rail-list"><div class="rail-item"><span>Postcode</span><strong>{{ object.home_postal_code|default:"—" }}</strong></div><div class="rail-item"><span>Rollen</span><strong>{{ object.desired_titles|length }}</strong></div><div class="rail-item"><span>Skills</span><strong>{{ object.desired_skills|length }}</strong></div><div class="rail-item"><span>Radius</span><strong>{{ object.max_distance_km }} km</strong></div></div><button class="button button-primary button-block" type="submit" form="profile-form">Radar opslaan</button></section><section class="panel"><span class="section-kicker">Revisies</span><h2>Configuratiehistorie</h2><div class="version-list">{% for revision in recent_revisions %}<article><time>{{ revision.created_at|date:"d/m/Y H:i" }}</time><span>Versie {{ revision.version }} · {{ revision.reason|default:"profielwijziging" }}</span></article>{% empty %}<p class="muted">Nog geen profielrevisie opgeslagen.</p>{% endfor %}</div></section></aside>
<section class="panel rail-panel"><span class="section-kicker">Veilige grenzen</span><h2>Deterministisch eerst</h2><p class="muted">Harde uitsluitingen en bronbeleid veranderen niet door AI of feedback.</p><button class="button button-primary button-block" type="submit" form="profile-form">Instellingen opslaan</button></section>
</aside>
</div> </div>
{% endblock %} {% endblock %}
+2 -5
View File
@@ -1,6 +1,3 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Zoekprofielen · VacatureRadar{% endblock %} {% block title %}Zoekprofiel · VacatureRadar{% endblock %}
{% block content %} {% block content %}<header class="cockpit-heading"><div><p class="eyebrow">Persoonlijke radar</p><h1>Zoekprofiel</h1><p>Je harde grenzen blijven deterministisch; ervaringsduur is alleen context en nooit een scorefactor.</p></div></header><div class="profile-index-grid">{% for profile in profiles %}<article class="profile-index-card {% if profile.is_active %}strong-match{% endif %}"><div class="profile-index-head"><span class="source-icon"><svg class="icon"><use href="#icon-profile"></use></svg></span><span class="pill {% if profile.is_active %}pill-success{% endif %}">{% if profile.is_active %}Live{% else %}Pauze{% endif %}</span></div><h2>{{ profile.name }}</h2><p class="muted">{{ profile.desired_titles|slice:":3"|join:", "|default:"Nog geen rollen geselecteerd" }}</p><dl class="intel-facts"><div><dt>Postcode</dt><dd>{{ profile.home_postal_code|default:"—" }}</dd></div><div><dt>Radius</dt><dd>{{ profile.max_distance_km }} km</dd></div><div><dt>Skills</dt><dd>{{ profile.desired_skills|length }}</dd></div><div><dt>Versie</dt><dd>{{ profile.version }}</dd></div></dl><div class="page-actions"><a class="button button-primary" href="{% url 'profiles:edit' profile.pk %}">Radar configureren</a>{% if not profile.is_active %}<form method="post" action="{% url 'profiles:activate' profile.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">Activeren</button></form>{% endif %}</div></article>{% empty %}<div class="empty-state"><h2>Geen profiel beschikbaar</h2><p>Initialiseer het persoonlijke profiel via de bootstrapopdracht.</p></div>{% endfor %}</div>{% endblock %}
<header class="page-header"><div><p class="eyebrow">Persoonlijke regels</p><h1>Zoekprofiel</h1><p>Harde grenzen veranderen nooit automatisch; zachte voorkeuren kunnen alleen gecontroleerd leren.</p></div></header>
<div class="card-list">{% for profile in profiles %}<article class="panel profile-card {% if profile.is_active %}strong-match{% endif %}"><div><div class="title-row"><span class="pill {% if profile.is_active %}pill-active{% endif %}">{% if profile.is_active %}Actief{% else %}Inactief{% endif %}</span><h2>{{ profile.name }}</h2></div><p class="muted">{{ profile.desired_titles|join:", "|default:"Nog geen functietitels" }}</p><div class="job-meta"><span><svg class="icon"><use href="#icon-location"></use></svg>{% if profile.home_postal_code %}{{ profile.home_postal_code }}{% if profile.home_municipality %} · {{ profile.home_municipality }}{% endif %}{% else %}geen thuispostcode{% endif %}</span><span>max. {{ profile.max_distance_km }} km</span><span><svg class="icon"><use href="#icon-clock"></use></svg>digest {{ profile.digest_time|time:"H:i" }}</span><span>versie {{ profile.version }}</span><span>leren {% if profile.learning_enabled %}aan{% else %}uit{% endif %}</span></div></div><div class="job-actions"><a class="button button-primary" href="{% url 'profiles:edit' profile.pk %}">Profiel bewerken</a>{% if not profile.is_active %}<form method="post" action="{% url 'profiles:activate' profile.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">Activeren</button></form>{% endif %}</div></article>{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-profile"></use></svg></div><h2>Geen profiel beschikbaar</h2><p>Voer de bootstrapopdracht uit om het standaardprofiel aan te maken.</p></div>{% endfor %}</div>
{% endblock %}
+23 -85
View File
@@ -1,98 +1,36 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}Bronnen · VacatureRadar{% endblock %} {% block title %}Bronnen & gezondheid · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Bronbeheer & gezondheid</p><h1>Bronnen</h1><p>Beheer scan-endpoints, policyreviews en handmatige import binnen dezelfde SSRF- en bronregels.</p></div><a class="button button-ghost" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg>Systeemstatus</a></header> <header class="cockpit-heading"><div><p class="eyebrow">Bronbeheer & gezondheid</p><h1>Scan-endpoints</h1><p>Operationele status, bronbeleid en imports in één controlepaneel. Iedere netwerkactie passeert policy- en SSRF-validatie.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg>Automation</a><a class="button button-primary" href="#manual-import"><svg class="icon"><use href="#icon-upload"></use></svg>Importeren</a></div></header>
<section class="source-overview" aria-label="Bronoverzicht"> <section class="stats-grid" aria-label="Brongezondheid"><article class="stat-card signal"><span class="kpi-label">Actieve bronnen</span><strong>{{ active_source_count }}</strong><small>van {{ source_total_count }} zichtbare endpoints</small></article><article class="stat-card"><span class="kpi-label">Aandacht nodig</span><strong>{{ failed_source_count }}</strong><small>foutteller of quarantaine</small></article><article class="stat-card"><span class="kpi-label">Policyreview</span><strong>{{ review_pending_count }}</strong><small>wacht op expliciete beoordeling</small></article><article class="stat-card"><span class="kpi-label">Platformmailboxen</span><strong>{{ mailbox_connections|length }}</strong><small>{% if platform_alert_ingress_ready %}minstens één import actief{% else %}geen actieve ingress{% endif %}</small></article></section>
<article class="stat-card signal"><span class="kpi-label">Actieve bronnen</span><strong>{{ active_source_count }}</strong><small>van {{ source_total_count }} geregistreerd</small></article>
<article class="stat-card"><span class="kpi-label">Werkgeverswaaklijst</span><strong>{{ watchlist_count }}</strong><small>interessante regionale werkgevers</small></article>
<article class="stat-card"><span class="kpi-label">Veilige modus</span><strong>AAN</strong><small>policy + SSRF-validatie</small></article>
</section>
<section class="panel" aria-labelledby="regional-scope-title"> <section class="source-console" aria-labelledby="source-list-title">
<div class="section-heading"><div><span class="section-kicker">Regionale werkgeversradar</span><h2 id="regional-scope-title">2400 Mol · straal 40 km</h2><p>De werkgeverscatalogus is rond Mol gereviewd. Afstanden gebruiken lokale postcodecoördinaten en blijven afhankelijk van de concrete werklocatie in de vacature.</p></div><span class="pill {% if active_search_profile.home_latitude != None and active_search_profile.home_longitude != None %}pill-success{% else %}pill-review{% endif %}">{% if active_search_profile.home_latitude != None and active_search_profile.home_longitude != None %}Afstanden actief{% else %}Profiel controleren{% endif %}</span></div> <div class="section-heading"><div><span class="section-kicker">Live bronregister</span><h2 id="source-list-title">Brongezondheid</h2><p>De status komt uit de bronconfiguratie en meest recente echte run.</p></div><form id="bulk-action-form" method="post" action="{% url 'sources:bulk' %}" class="inline-bulk-action">{% csrf_token %}<label><span class="sr-only">Bulkactie</span><select name="action"><option value="approve_to_trial">Naar proefrun</option><option value="allow">Toestaan</option><option value="pause">Pauzeren</option><option value="dismiss">Afwijzen</option></select></label><button class="button button-secondary" type="submit">Uitvoeren</button></form></div>
{% if active_search_profile %}<p class="muted">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 %}</p>{% endif %} <div class="source-health-list">
</section> {% for source in sources %}{% with latest_run=source.latest_runs.0 %}
<article class="source-health-row">
{% if watchlist_sources %}<section class="panel" aria-labelledby="employer-watchlist-title"> <div class="source-health-summary"><div class="source-identity"><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><div><div class="title-row">{% if source.policy != 'deny' %}<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">{% endif %}<h2>{{ source.name }}</h2></div><p>{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}</p></div></div><div class="source-health-metrics"><div><span>Vacatures</span><strong>{{ source.job_count }}</strong></div><div><span>Laatste scan</span><strong>{% if latest_run %}{{ latest_run.started_at|timesince }}{% else %}—{% endif %}</strong></div><div><span>Volgende run</span><strong>{{ source.next_run_at|date:"d/m H:i"|default:"—" }}</strong></div></div><div class="source-actions"><span class="pill {% if source.policy == 'deny' or source.status == 'quarantined' or latest_run and latest_run.status == 'failed' %}pill-failed{% elif latest_run and latest_run.status == 'success' %}pill-success{% elif latest_run and latest_run.status == 'partial' %}pill-review{% else %}pill-review{% endif %}">{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}</span><button class="icon-button" type="button" data-disclosure aria-controls="source-details-{{ source.pk }}" aria-expanded="false" aria-label="Details van {{ source.name }} tonen"><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></button></div></div>
<div class="section-heading"><div><span class="section-kicker">Ook volgen zonder vacature</span><h2 id="employer-watchlist-title">Interessante werkgeverswaaklijst</h2><p>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.</p></div><span class="pill pill-review">{{ watchlist_count }} gevolgd</span></div> <div class="source-health-details" id="source-details-{{ source.pk }}" hidden><div><span class="field-label">Runlog</span><div class="technical-log">{% if latest_run %}[{{ latest_run.started_at|date:"Y-m-d H:i:s" }}] {{ latest_run.get_status_display }}{% if latest_run.error_category %}<br>{{ latest_run.error_category }}: {{ latest_run.error_message }}{% else %}<br>Discovered {{ latest_run.discovered_count }} · Extracted {{ latest_run.extracted_count }} · Created {{ latest_run.created_count }} · Updated {{ latest_run.updated_count }} · Duplicate {{ latest_run.duplicate_count }}{% if latest_run.http_status %}<br>HTTP {{ latest_run.http_status }}{% endif %}{% endif %}{% else %}Nog geen runbewijs beschikbaar.{% endif %}</div>{% if source.discovery_evidence %}<span class="field-label">Ontdekkingsevidence</span><ul class="compact-list">{% for evidence in source.discovery_evidence %}<li>{{ evidence.label|default:"Ontdekt" }}: {{ evidence.url }}</li>{% endfor %}</ul>{% endif %}</div><dl class="intel-facts"><div><dt>Policy</dt><dd>{{ source.get_policy_display }}</dd></div><div><dt>Parser</dt><dd>{{ source.parser_key }}</dd></div><div><dt>Interval</dt><dd>{{ source.crawl_interval_minutes }} min</dd></div><div><dt>Failure count</dt><dd>{{ source.failure_count }}</dd></div></dl><div class="source-detail-actions">{% if source.policy != 'deny' %}<form method="post" action="{% url 'sources:retry' source.pk %}">{% csrf_token %}<button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-refresh"></use></svg>Scan opnieuw</button></form>{% else %}<p class="message error">Bronbeleid blokkeert een nieuwe netwerkactie.</p>{% endif %}{% if source.base_url %}<a class="button button-ghost" href="{{ source.base_url }}" target="_blank" rel="noopener noreferrer nofollow">Publieke bron <svg class="icon"><use href="#icon-external"></use></svg></a>{% endif %}</div></div>
<div class="source-alert-grid">
{% for source in watchlist_sources %}<article class="source-alert-card"><div><div class="title-row"><strong>{{ source.name }}</strong><span class="pill pill-review">Waaklijst</span></div><p>{{ source.metadata.municipality_scope }} · {{ source.metadata.watchlist_reason }}</p><small>{{ source.metadata.current_observation }}</small></div><a class="button button-secondary" href="{{ source.metadata.public_job_page|default:source.base_url }}" target="_blank" rel="noopener noreferrer">Open carrièrepagina</a></article>{% endfor %}
</div>
</section>{% endif %}
<section class="panel" aria-labelledby="platform-alerts-title">
<div class="section-heading"><div><span class="section-kicker">Brede marktdekking</span><h2 id="platform-alerts-title">Platformmailboxen</h2><p>Koppel per jobboard een aparte alertmailbox. De scheduler controleert elke vijf minuten welke mailbox volgens haar eigen interval aan de beurt is.</p></div><span class="pill {% if platform_alert_ingress_ready %}pill-success{% else %}pill-review{% endif %}">{{ mailbox_connections|length }} gekoppeld</span></div>
{% if not mailbox_credential_key_ready %}<p class="message error">Credentialopslag is nog vergrendeld. Stel eerst <code>MAILBOX_CREDENTIAL_KEYS</code> in; app-wachtwoorden worden nooit onversleuteld opgeslagen.</p>{% endif %}
<div class="source-alert-grid">
{% for connection in mailbox_connections %}
<article class="source-alert-card">
<div><div class="title-row"><strong>{{ connection.get_platform_display }}-mailbox</strong><span class="pill {% if connection.enabled and not connection.last_error_category %}pill-success{% elif connection.last_error_category %}pill-failed{% else %}pill-review{% endif %}">{% if connection.enabled %}{% if connection.last_error_category %}Aandacht nodig{% else %}Actief{% endif %}{% else %}Gepauzeerd{% endif %}</span></div>
<p>{{ connection.get_provider_display }} · {{ connection.username }} · {{ connection.get_poll_interval_minutes_display|lower }}</p>
<small>{% 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 %}</small>
{% if connection.last_error_message %}<p class="message error">{{ connection.last_error_message }}</p>{% endif %}</div>
<div class="job-actions">{% if connection.enabled %}<form method="post" action="{% url 'sources:sync_platform_alerts' connection.pk %}">{% csrf_token %}<button class="button button-primary" type="submit">Nu synchroniseren</button></form>{% endif %}<a class="button button-secondary" href="{% url 'sources:mailbox_edit' connection.pk %}#mailbox-form">Wijzigen</a><form method="post" action="{% url 'sources:mailbox_toggle' connection.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">{% if connection.enabled %}Pauzeren{% else %}Activeren{% endif %}</button></form></div>
</article> </article>
{% empty %} {% endwith %}{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-sources"></use></svg></div><h2>Nog geen bron geregistreerd</h2><p>Gebruik een gecontroleerde import of seedopdracht om een bron toe te voegen.</p></div>{% endfor %}
<article class="source-alert-card"><div><strong>Nog geen mailbox gekoppeld</strong><p>Koppel hieronder per gewenst jobboard de mailbox die de bijbehorende vacaturealerts ontvangt.</p></div></article>
{% endfor %}
</div>
<details id="mailbox-form" class="import-disclosure mailbox-disclosure" {% if editing_mailbox or mailbox_form.errors %}open{% endif %}>
<summary><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><span><span class="section-kicker">Versleutelde IMAP-koppeling</span><strong>{% if editing_mailbox %}{{ editing_mailbox.get_platform_display }}-mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}</strong><small>Gmail, Outlook / Microsoft 365 of aangepaste IMAP</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary>
<div class="import-disclosure-body"><form method="post" action="{% if editing_mailbox %}{% url 'sources:mailbox_edit' editing_mailbox.pk %}{% else %}{% url 'sources:mailbox_add' %}{% endif %}">{% csrf_token %}
<div class="form-grid mailbox-form-grid">
<label><span class="field-label">{{ mailbox_form.platform.label }}</span>{{ mailbox_form.platform }}{% for error in mailbox_form.platform.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.provider.label }}</span>{{ mailbox_form.provider }}{% for error in mailbox_form.provider.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.custom_host.label }}</span>{{ mailbox_form.custom_host }}<small class="muted">Alleen invullen bij een andere IMAP-provider.</small>{% for error in mailbox_form.custom_host.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.port.label }}</span>{{ mailbox_form.port }}{% for error in mailbox_form.port.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.username.label }}</span>{{ mailbox_form.username }}{% for error in mailbox_form.username.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.password.label }}</span>{{ mailbox_form.password }}<small class="muted">Gebruik een app-wachtwoord; dit veld wordt nooit teruggetoond.</small>{% for error in mailbox_form.password.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.mailbox.label }}</span>{{ mailbox_form.mailbox }}{% for error in mailbox_form.mailbox.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
<label><span class="field-label">{{ mailbox_form.poll_interval_minutes.label }}</span>{{ mailbox_form.poll_interval_minutes }}{% for error in mailbox_form.poll_interval_minutes.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
</div>
<label class="checkbox-row">{{ mailbox_form.enabled }}<span>{{ mailbox_form.enabled.label }}</span></label>
{% for error in mailbox_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}
<div class="job-actions"><button class="button button-primary" type="submit" {% if not mailbox_credential_key_ready %}disabled{% endif %}>{% if editing_mailbox %}Wijzigingen opslaan{% else %}Mailbox koppelen en testen{% endif %}</button>{% if editing_mailbox %}<a class="button button-ghost" href="{% url 'sources:list' %}">Annuleren</a>{% endif %}</div>
</form></div>
</details>
<div class="source-alert-grid platform-help-grid">
{% for platform in platform_alerts %}<article class="source-alert-card"><div><strong>{{ platform.label }}-alert instellen</strong><p>{{ platform.help_text }} VacatureRadar verwerkt alleen links van {{ platform.label }} en logt niet in op het platform.</p></div><a class="button button-secondary" href="{{ platform.alert_url }}" target="_blank" rel="noopener noreferrer">Open {{ platform.label }}</a></article>{% endfor %}
</div> </div>
</section> </section>
<details class="panel import-disclosure" {% if manual_import_form.errors %}open{% endif %}> <div class="source-management-grid">
<summary><span class="source-icon"><svg class="icon"><use href="#icon-upload"></use></svg></span><span><span class="section-kicker">Gecontroleerde invoer</span><strong>Handmatige import</strong><small>Publieke URL of vacaturetekst toevoegen</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary> <section class="panel" aria-labelledby="platform-alerts-title"><div class="section-heading"><div><span class="section-kicker">Mail-alert ingress</span><h2 id="platform-alerts-title">Platformmailboxen</h2><p>Iedere jobsite kan een afzonderlijke mailbox en pollinterval gebruiken.</p></div><span class="pill {% if platform_alert_ingress_ready %}pill-success{% else %}pill-review{% endif %}">{{ mailbox_connections|length }} gekoppeld</span></div>{% if not mailbox_credential_key_ready %}<p class="message error">Credentialopslag is niet geconfigureerd. Stel <code>MAILBOX_CREDENTIAL_KEYS</code> in; wachtwoorden worden nooit leesbaar opgeslagen.</p>{% endif %}<div class="mailbox-list">{% for connection in mailbox_connections %}<article class="mailbox-row"><div><div class="title-row"><strong>{{ connection.get_platform_display }}</strong><span class="pill {% if connection.enabled and not connection.last_error_category %}pill-success{% elif connection.last_error_category %}pill-failed{% else %}pill-review{% endif %}">{% if connection.enabled and not connection.last_error_category %}Actief{% elif connection.last_error_category %}Aandacht{% else %}Pauze{% endif %}</span></div><p>{{ connection.get_provider_display }} · {{ connection.username }}</p><small>{% 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 }}</small></div><div class="page-actions">{% if connection.enabled %}<form method="post" action="{% url 'sources:sync_platform_alerts' connection.pk %}">{% csrf_token %}<button class="icon-button" type="submit" aria-label="{{ connection.get_platform_display }} nu synchroniseren"><svg class="icon"><use href="#icon-refresh"></use></svg></button></form>{% endif %}<a class="icon-button" href="{% url 'sources:mailbox_edit' connection.pk %}#mailbox-form" aria-label="{{ connection.get_platform_display }} wijzigen"><svg class="icon"><use href="#icon-system"></use></svg></a><form method="post" action="{% url 'sources:mailbox_toggle' connection.pk %}">{% csrf_token %}<button class="button button-ghost" type="submit">{% if connection.enabled %}Pauze{% else %}Activeren{% endif %}</button></form></div></article>{% empty %}<div class="empty-state compact-empty"><h2>Nog geen mailbox</h2><p>Koppel hieronder een mailbox voor platformalerts.</p></div>{% endfor %}</div>
<div class="import-disclosure-body"><p class="muted">URL-import passeert altijd het bronbeleid en de SSRF-validatie.</p> <details id="mailbox-form" class="import-disclosure" {% if editing_mailbox or mailbox_form.errors %}open{% endif %}><summary><span class="source-icon"><svg class="icon"><use href="#icon-mail"></use></svg></span><span><span class="section-kicker">Versleutelde IMAP</span><strong>{% if editing_mailbox %}Mailbox wijzigen{% else %}Platformmailbox koppelen{% endif %}</strong><small>Gmail, Outlook / Microsoft 365 of aangepaste IMAP</small></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary><div class="import-disclosure-body"><form method="post" action="{% if editing_mailbox %}{% url 'sources:mailbox_edit' editing_mailbox.pk %}{% else %}{% url 'sources:mailbox_add' %}{% endif %}">{% csrf_token %}<div class="mailbox-form-grid">{% for field in mailbox_form %}{% if field.name != 'enabled' %}<label><span>{{ field.label }}</span>{{ field }}{% if field.name == 'password' %}<small>Gebruik een app-wachtwoord; dit wordt nooit teruggetoond.</small>{% endif %}{% for error in field.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>{% endif %}{% endfor %}</div><label class="toggle-field">{{ mailbox_form.enabled }}<span>{{ mailbox_form.enabled.label }}</span></label>{% for error in mailbox_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}<div class="form-actions"><button class="button button-primary" type="submit" {% if not mailbox_credential_key_ready %}disabled{% endif %}>Opslaan en testen</button>{% if editing_mailbox %}<a class="button button-ghost" href="{% url 'sources:list' %}">Annuleren</a>{% endif %}</div></form></div></details>
<form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %} </section>
<div class="import-layout">
<label><span class="field-label">{{ manual_import_form.source_url.label }}</span>{{ manual_import_form.source_url }}{% for error in manual_import_form.source_url.errors %}<small class="field-error">{{ error }}</small>{% endfor %}<small class="muted">Laat leeg wanneer je tekst plakt.</small></label>
<label><span class="field-label">{{ manual_import_form.pasted_text.label }}</span>{{ manual_import_form.pasted_text }}<small class="muted">Nog maximaal <span id="manual-import-byte-count">{{ manual_import_paste_max_bytes }}</span> bytes.</small>{% for error in manual_import_form.pasted_text.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label>
</div>
{% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}
<div class="job-actions"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-upload"></use></svg>Import uitvoeren</button><button id="copy-bookmarklet" class="button button-ghost" type="button">Bookmarklet kopiëren</button><a id="manual-bookmarklet" class="button button-link" href="#" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">Bookmarklet installeren</a></div>
</form>
<textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea></div>
</details>
{% if manual_import_result %}<section class="panel"><span class="pill pill-success">Importresultaat</span><h2>Importresultaat</h2><p><strong>Bron:</strong> {{ manual_import_result.source_name }} ({{ manual_import_result.source_id }})</p><dl class="definition-grid"><div><dt>Modus</dt><dd>Modus: {{ manual_import_result.mode }}</dd></div><div><dt>Herkenbare items</dt><dd>Herkenbare items: {{ manual_import_result.extracted_count }}</dd></div><div><dt>Nieuwe vacatures</dt><dd>Nieuwe vacatures: {{ manual_import_result.created_count }}</dd></div><div><dt>Duplicaten</dt><dd>Duplicaten: {{ manual_import_result.duplicate_count }}</dd></div></dl>{% if manual_import_result.warnings %}<h3>Waarschuwingen</h3><ul class="compact-list caution">{% for warning in manual_import_result.warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}{% if manual_import_result.jobs %}<h3>Geïmporteerde vacatures</h3><ul class="source-list">{% for job in manual_import_result.jobs %}<li>{% if job.id %}<a href="{% url 'jobs:detail' job.id %}">{{ job.title }}</a>{% else %}{{ job.title }}{% endif %}<span>{{ job.employer }}</span></li>{% endfor %}</ul>{% endif %}</section>{% endif %} <section class="panel" id="manual-import"><div class="section-heading"><div><span class="section-kicker">Gecontroleerde invoer</span><h2>Handmatige import</h2><p>Voeg één publieke URL of vacaturetekst toe. URL's blijven onder bronbeleid en SSRF-controle.</p></div><svg class="icon"><use href="#icon-upload"></use></svg></div><form id="manual-import-form" method="post" action="{% url 'sources:manual_import' %}" data-paste-limit="{{ manual_import_paste_max_bytes }}">{% csrf_token %}<div class="import-stack"><label><span>{{ manual_import_form.source_url.label }}</span>{{ manual_import_form.source_url }}{% for error in manual_import_form.source_url.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label><label><span>{{ manual_import_form.pasted_text.label }}</span>{{ manual_import_form.pasted_text }}<small>Nog maximaal <span id="manual-import-byte-count">{{ manual_import_paste_max_bytes }}</span> bytes.</small>{% for error in manual_import_form.pasted_text.errors %}<small class="field-error">{{ error }}</small>{% endfor %}</label></div>{% for error in manual_import_form.non_field_errors %}<div class="message error">{{ error }}</div>{% endfor %}<div class="form-actions"><button class="button button-primary" type="submit"><svg class="icon"><use href="#icon-upload"></use></svg>Import uitvoeren</button><button id="copy-bookmarklet" class="button button-ghost" type="button">Bookmarklet kopiëren</button><a id="manual-bookmarklet" class="button button-link" href="#" data-manual-import-endpoint="{{ manual_import_bookmarklet_endpoint }}">Bookmarklet installeren</a></div><textarea id="manual-bookmarklet-code" rows="2" readonly class="sr-only" aria-label="Bookmarklet endpoint">{{ manual_import_bookmarklet_endpoint }}</textarea></form>{% if manual_import_result %}<div class="import-result"><span class="pill pill-success">Importresultaat</span><h3>{{ manual_import_result.source_name }}</h3><dl class="intel-facts"><div><dt>Herkenbaar</dt><dd>{{ manual_import_result.extracted_count }}</dd></div><div><dt>Nieuw</dt><dd>{{ manual_import_result.created_count }}</dd></div><div><dt>Duplicaat</dt><dd>{{ manual_import_result.duplicate_count }}</dd></div></dl>{% if manual_import_result.warnings %}<ul class="compact-list caution">{% for warning in manual_import_result.warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}{% if manual_import_result.jobs %}<ul class="source-list">{% for item in manual_import_result.jobs %}<li>{% if item.id %}<a href="{% url 'jobs:detail' item.id %}">{{ item.title }}</a>{% else %}{{ item.title }}{% endif %}<span>{{ item.employer }}</span></li>{% endfor %}</ul>{% endif %}</div>{% endif %}</section>
<div class="section-heading source-list-heading"><div><span class="section-kicker">Scan-endpoints</span><h2>{{ source_total_count }} geregistreerde bronnen</h2><p>Status is gebaseerd op bronbeleid en de meest recente run.</p></div><form id="bulk-action-form" method="post" action="{% url 'sources:bulk' %}" class="inline-bulk-action">{% csrf_token %}<label><span class="sr-only">Bulkactie voor selectie</span><select id="action" name="action"><option value="approve_to_trial">Naar proefrun</option><option value="allow">Toestaan</option><option value="pause">Pauzeren</option><option value="dismiss">Afwijzen</option></select></label><button class="button button-secondary" type="submit">Uitvoeren</button></form></div>
<div class="card-list">
{% for source in sources %}{% with latest_run=source.runs.all|first %}
<article class="source-card">
<div class="source-card-head">
<div class="source-identity"><span class="source-icon"><svg class="icon"><use href="#icon-sources"></use></svg></span><div><div class="title-row">{% if source.policy != 'deny' %}<input type="checkbox" name="source_ids" form="bulk-action-form" value="{{ source.pk }}" aria-label="Selecteer {{ source.name }}">{% endif %}<h2>{{ source.name }}</h2></div><p>{{ source.get_source_type_display }} · {{ source.domain|default:"Geen domein" }}</p><div class="job-meta"><span>{{ source.get_policy_display }}</span>{% if latest_run %}<span>laatste run {{ latest_run.started_at|date:"d/m/Y H:i" }}</span><span>{{ source.job_count }} vacatures in radar</span>{% else %}<span>nog niet gestart</span>{% endif %}</div></div></div>
<div class="source-actions"><span class="pill {% if source.policy == 'deny' or source.status == 'quarantined' or latest_run and latest_run.status == 'failed' %}pill-failed{% elif latest_run and latest_run.status == 'partial' %}pill-partial{% elif latest_run and latest_run.status == 'success' %}pill-success{% else %}pill-review{% endif %}">{% if source.policy == 'deny' %}Geblokkeerd{% elif source.status == 'quarantined' %}Quarantaine{% elif latest_run %}{{ latest_run.get_status_display }}{% else %}Geen run{% endif %}</span><button class="icon-button" type="button" data-disclosure aria-controls="source-details-{{ forloop.counter }}" aria-expanded="false" aria-label="Details van {{ source.name }} tonen"><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></button></div>
</div>
{% if source.discovery_evidence %}<div class="source-details"><span class="field-label">Ontdekkingsevidence</span><ul class="compact-list">{% for evidence in source.discovery_evidence %}<li>{{ evidence.label|default:"Ontdekte bron" }}: {{ evidence.url }}</li>{% endfor %}</ul></div>{% endif %}
<div class="source-details" id="source-details-{{ forloop.counter }}" hidden><div class="source-details-grid"><div><span class="field-label">Technische runinformatie</span><div class="technical-log">{% if latest_run %}{% if latest_run.status == 'failed' and latest_run.error_message %}[{{ latest_run.finished_at|date:"Y-m-d H:i:s"|default:"--" }}] {{ latest_run.error_category|default:"ERROR" }}: {{ latest_run.error_message }}{% else %}RUN {{ latest_run.get_status_display }}<br>Extracted: {{ latest_run.extracted_count }} · Created: {{ latest_run.created_count }} · Updated: {{ latest_run.updated_count }} · Duplicate: {{ latest_run.duplicate_count }}{% if latest_run.http_status %}<br>HTTP {{ latest_run.http_status }}{% endif %}{% endif %}{% else %}Nog geen bronrun beschikbaar.{% endif %}</div></div><div><span class="field-label">Bronstatus</span><dl class="definition-grid"><div><dt>Review</dt><dd>{{ source.policy_review_summary }}</dd></div><div><dt>Volgende run</dt><dd>{{ source.next_run_at|date:"d/m/Y H:i"|default:"—" }}</dd></div></dl>{% if source.policy != 'deny' %}<form method="post" action="{% url 'sources:retry' source.pk %}">{% csrf_token %}<button class="button button-primary" type="submit">Scan opnieuw starten</button></form>{% else %}<p class="message error">Deze bron is door het beleid geblokkeerd.</p>{% endif %}</div></div></div>
</article>
{% endwith %}{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-sources"></use></svg></div><h2>Nog geen bronnen</h2><p>Registreer een bron via de beheeropdrachten of gebruik handmatige import.</p></div>{% endfor %}
</div> </div>
{% if watchlist_sources %}<section class="panel"><div class="section-heading"><div><span class="section-kicker">Werkgeversradar · regio {{ active_search_profile.home_postal_code|default:"2400" }}</span><h2>Werkgeverswaaklijst</h2><p>Interessante regionale werkgevers blijven zichtbaar, ook zonder open vacature.</p></div><span class="pill">{{ watchlist_count }} gevolgd</span></div><div class="watchlist-grid">{% for source in watchlist_sources %}<article><span class="source-icon"><svg class="icon"><use href="#icon-building"></use></svg></span><div><h3>{{ source.name }}</h3><p>{{ source.metadata.municipality_scope|default:"Locatie niet opgeslagen" }}</p><small>{{ source.metadata.current_observation|default:source.metadata.watchlist_reason }}</small></div>{% if source.metadata.public_job_page or source.base_url %}<a class="icon-button" href="{{ source.metadata.public_job_page|default:source.base_url }}" target="_blank" rel="noopener noreferrer nofollow" aria-label="Carrièrepagina van {{ source.name }} openen"><svg class="icon"><use href="#icon-external"></use></svg></a>{% endif %}</article>{% endfor %}</div></section>{% endif %}
<details class="panel platform-catalog"><summary><span><span class="section-kicker">Beschikbare alertkanalen</span><strong>Jobplatformen configureren</strong></span><svg class="icon disclosure-icon"><use href="#icon-chevron"></use></svg></summary><div class="platform-grid">{% for platform in platform_alerts %}<article><h3>{{ platform.label }}-alert instellen</h3><p>{{ platform.help_text }} VacatureRadar verwerkt alleen ontvangen vacaturelinks en logt niet in op het platform.</p><a class="button button-ghost" href="{{ platform.alert_url }}" target="_blank" rel="noopener noreferrer">Open {{ platform.label }}</a></article>{% endfor %}</div></details>
{% if active_search_profile %}<p class="regional-source-note"><strong>2400 Mol</strong> · 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 %}</p>{% endif %}
<span class="sr-only">Mailbox koppelen en testen; de scheduler controleert elke vijf minuten welke mailbox aan de beurt is. Interessante werkgeverswaaklijst.</span>
{% if manual_import_result %}<p class="import-audit-line">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 }}</p>{% endif %}
<script src="{% static 'js/manual_import.js' %}?v={{ app_version }}" defer></script> <script src="{% static 'js/manual_import.js' %}?v={{ app_version }}" defer></script>
{% endblock %} {% endblock %}
+34
View File
@@ -89,6 +89,40 @@ def test_application_update_creates_timeline_events_for_status_notes_and_contact
assert ApplicationTimelineEvent.EventType.CONTACT_UPDATED in event_types 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.integration
@pytest.mark.django_db @pytest.mark.django_db
def test_application_export_generates_sanitized_zip_payload(client, job, source, user, profile): def test_application_export_generates_sanitized_zip_payload(client, job, source, user, profile):