feat: replace pipeline profile and source health
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<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>/print/", application_print, name="application-print"),
|
||||
path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
|
||||
|
||||
+38
-3
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-3
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sollicitatiedossier · VacatureRadar{% endblock %}
|
||||
{% block title %}Dossier · {{ object.job.original_title }}{% endblock %}
|
||||
{% 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>
|
||||
<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>
|
||||
<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>
|
||||
<a class="back-link" href="{% url 'jobs:applications' %}">← Terug naar de pipeline</a>
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sollicitaties · VacatureRadar{% endblock %}
|
||||
{% block title %}Sollicitatiepipeline · VacatureRadar{% endblock %}
|
||||
{% 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>
|
||||
<section class="pipeline" aria-label="Sollicitaties per status">
|
||||
{% for column in pipeline_columns %}
|
||||
<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>
|
||||
<div class="pipeline-cards">
|
||||
{% for application in column.applications %}
|
||||
<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 %}
|
||||
<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-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>
|
||||
|
||||
{% if applications %}
|
||||
<section class="pipeline-board" data-view="kanban" aria-label="Sollicitaties per status">
|
||||
{% 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 %}
|
||||
</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 %}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ object.name }} bewerken · VacatureRadar{% endblock %}
|
||||
{% block title %}Configureer je radar · VacatureRadar{% endblock %}
|
||||
{% 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>
|
||||
<div class="editor-layout">
|
||||
<form id="profile-form" method="post" class="panel form-panel profile-editor">{% csrf_token %}
|
||||
<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="profile-editor-layout">
|
||||
<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 %}
|
||||
<div class="form-grid">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
</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="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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Zoekprofielen · VacatureRadar{% endblock %}
|
||||
{% block content %}
|
||||
<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 %}
|
||||
{% block title %}Zoekprofiel · VacatureRadar{% endblock %}
|
||||
{% 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 %}
|
||||
|
||||
+23
-85
@@ -1,98 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Bronnen · VacatureRadar{% endblock %}
|
||||
{% block title %}Bronnen & gezondheid · VacatureRadar{% endblock %}
|
||||
{% 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">
|
||||
<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="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>
|
||||
|
||||
<section class="panel" aria-labelledby="regional-scope-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>
|
||||
{% 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 %}
|
||||
</section>
|
||||
|
||||
{% if watchlist_sources %}<section class="panel" aria-labelledby="employer-watchlist-title">
|
||||
<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-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>
|
||||
<section class="source-console" aria-labelledby="source-list-title">
|
||||
<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>
|
||||
<div class="source-health-list">
|
||||
{% for source in sources %}{% with latest_run=source.latest_runs.0 %}
|
||||
<article class="source-health-row">
|
||||
<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="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>
|
||||
</article>
|
||||
{% empty %}
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="panel import-disclosure" {% if manual_import_form.errors %}open{% endif %}>
|
||||
<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>
|
||||
<div class="import-disclosure-body"><p class="muted">URL-import passeert altijd het bronbeleid en de SSRF-validatie.</p>
|
||||
<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-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>
|
||||
<div class="source-management-grid">
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<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 %}
|
||||
<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>
|
||||
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user