feat: add automation activity and employer intelligence

This commit is contained in:
Jens
2026-07-22 20:17:20 +02:00
parent 4fa2a34447
commit afe18e5b96
16 changed files with 679 additions and 18 deletions
+1
View File
@@ -0,0 +1 @@
"""Read services for the application cockpit."""
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.db.models import Count, Q, Sum
from django.utils import timezone
from apps.jobs.models import JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..health import readiness
def build_automation_cockpit() -> dict[str, Any]:
"""Build an honest operational read model without probing unsafe hidden endpoints."""
since = timezone.now() - timedelta(hours=24)
recent_runs = SourceRun.objects.filter(started_at__gte=since)
run_metrics = recent_runs.aggregate(
discovered=Sum("discovered_count"),
extracted=Sum("extracted_count"),
created=Sum("created_count"),
updated=Sum("updated_count"),
duplicates=Sum("duplicate_count"),
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
)
notification_counts = {
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
"sent_24h": DigestOutbox.objects.filter(
status=DigestOutbox.Status.SENT, sent_at__gte=since
).count()
+ ReminderOutbox.objects.filter(
status=ReminderOutbox.Status.SENT, sent_at__gte=since
).count(),
}
source_total = Source.objects.count()
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
phase_rows = [
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
{
"key": "extract",
"label": "Extraheren",
"value": run_metrics["extracted"] or 0,
"unit": "items",
},
{
"key": "normalize",
"label": "Normaliseren",
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
"unit": "records",
},
{
"key": "dedupe",
"label": "Dedupliceren",
"value": run_metrics["duplicates"] or 0,
"unit": "herkend",
},
{
"key": "filter",
"label": "IT-filter",
"value": active_jobs.exclude(it_relevance_query()).count(),
"unit": "uit beeld",
},
{
"key": "analyze",
"label": "Analyseren",
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
"unit": "scores / 24u",
},
{
"key": "notify",
"label": "Notificeren",
"value": notification_counts["sent_24h"],
"unit": "verzonden / 24u",
},
]
return {
"health": readiness(),
"source_total": source_total,
"source_active": source_active,
"run_metrics": run_metrics,
"run_count": recent_runs.count(),
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
"review_jobs": ScoreRun.objects.filter(
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
job__status=JobPosting.Status.ACTIVE,
).count(),
"notification_counts": notification_counts,
"phase_rows": phase_rows,
"recent_runs": list(
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
),
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
"queue_state": "Eager/lokaal"
if settings.CELERY_TASK_ALWAYS_EAGER
else "Geconfigureerd, worker niet gemeten",
"ai_state": "Geconfigureerd"
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
else "Niet geconfigureerd",
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
"browser_state": "Geen aparte browserextractor geconfigureerd",
}
+2 -6
View File
@@ -4,18 +4,16 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.db.models import Count
from django.http import JsonResponse from django.http import JsonResponse
from django.views.generic import TemplateView from django.views.generic import TemplateView
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.models import JobPosting
from apps.jobs.services.cockpit import build_dashboard_cockpit from apps.jobs.services.cockpit import build_dashboard_cockpit
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health from apps.sources.services.health import collect_source_health
from .health import readiness from .health import readiness
from .services.automation import build_automation_cockpit
class SecurityAwareLoginView(LoginView): class SecurityAwareLoginView(LoginView):
@@ -95,8 +93,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["health"] = readiness() context.update(build_automation_cockpit())
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
context["source_health"] = collect_source_health() context["source_health"] = collect_source_health()
return context return context
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from django.core.paginator import Page, Paginator
from django.urls import reverse
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import ProfileRevision
from apps.sources.models import SourceRun
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
@dataclass(frozen=True)
class ActivityEvent:
occurred_at: datetime
event_type: str
title: str
detail: str
tone: str
url: str
object_label: str
ACTIVITY_FILTERS = (
("all", "Alle activiteit"),
("source", "Bronruns"),
("job", "Vacatures"),
("application", "Sollicitaties"),
("feedback", "Feedback"),
("notification", "Notificaties"),
("profile", "Profiel"),
)
def _source_events() -> list[ActivityEvent]:
events = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
detail = (
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
f"{run.duplicate_count} duplicaat"
)
if run.error_category:
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
events.append(
ActivityEvent(
run.started_at,
"source",
f"Bronrun {run.get_status_display().lower()}",
detail,
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
reverse("sources:list") + f"#source-details-{run.source_id}",
run.source.name,
)
)
return events
def _job_events() -> list[ActivityEvent]:
return [
ActivityEvent(
version.created_at,
"job",
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
", ".join(version.changed_fields)
if version.changed_fields
else "Bronmoment vastgelegd",
"neutral",
reverse("jobs:detail", kwargs={"pk": version.job_id}),
version.job.original_title,
)
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
]
def build_activity_page(
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
) -> Page:
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
events: list[ActivityEvent] = []
if event_type in {"all", "source"}:
events.extend(_source_events())
if event_type in {"all", "job"}:
events.extend(_job_events())
if event_type in {"all", "feedback"}:
for item in (
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
):
events.append(
ActivityEvent(
item.created_at,
"feedback",
item.get_action_display(),
item.reason or "Gebruikersactie opgeslagen",
"neutral",
reverse("jobs:detail", kwargs={"pk": item.job_id}),
item.job.original_title,
)
)
if event_type in {"all", "application"}:
for item in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:100]
):
events.append(
ActivityEvent(
item.created_at,
"application",
item.get_event_type_display(),
"Sollicitatiedossier bijgewerkt",
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
item.application.job.original_title,
)
)
if event_type in {"all", "notification"}:
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
events.append(
ActivityEvent(
item.created_at,
"notification",
f"{label} {item.get_status_display().lower()}",
item.subject,
"positive" if item.status == item.Status.SENT else "attention",
reverse("dashboard:system"),
label,
)
)
if event_type in {"all", "profile"}:
for item in (
ProfileRevision.objects.filter(profile__user=user)
.select_related("profile")
.order_by("-created_at")[:60]
):
events.append(
ActivityEvent(
item.created_at,
"profile",
f"Zoekprofiel versie {item.version}",
item.reason or "Profielconfiguratie opgeslagen",
"neutral",
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
item.profile.name,
)
)
normalized_query = query.strip().casefold()
if normalized_query:
events = [
item
for item in events
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
]
events.sort(key=lambda item: item.occurred_at, reverse=True)
return Paginator(events, per_page).get_page(page)
@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from django.db.models import Avg, Count, Max, Q, QuerySet
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from ..models import Application, Employer, JobPosting, ScoreRun
from .cockpit import annotate_latest_profile_score
from .relevance import it_relevance_query
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
return Employer.objects.annotate(
active_vacancies=Count(
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
),
relevant_vacancies=Count(
"jobs",
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
distinct=True,
),
previous_applications=Count(
"jobs__applications",
filter=Q(jobs__applications__user=user),
distinct=True,
),
source_count=Count("jobs__source_aliases__source", distinct=True),
latest_job_at=Max("jobs__first_seen"),
average_match=Avg("jobs__scores__score", filter=score_filter),
)
def filter_employers(
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
) -> QuerySet[Employer]:
if query.strip():
queryset = queryset.filter(
Q(name__icontains=query.strip())
| Q(domain__icontains=query.strip())
| Q(jobs__raw_location__icontains=query.strip())
)
if channel == "direct":
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(is_recruiter=True)
if active_only:
queryset = queryset.filter(active_vacancies__gt=0)
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
def build_employer_detail(
*, employer: Employer, user, profile: SearchProfile | None
) -> dict[str, Any]:
jobs = annotate_latest_profile_score(
employer.jobs.select_related("employer").all(), profile
).order_by("-first_seen")
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
locations = list(
employer.jobs.exclude(raw_location="")
.order_by("raw_location")
.values_list("raw_location", flat=True)
.distinct()[:8]
)
return {
"employer": employer,
"recent_jobs": list(jobs[:12]),
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
"applications": list(
Application.objects.filter(user=user, job__employer=employer)
.select_related("job")
.order_by("-updated_at")[:8]
),
"sources": list(sources[:10]),
"locations": locations,
"match_history": list(
ScoreRun.objects.filter(profile=profile, job__employer=employer)
.select_related("job")
.order_by("-created_at")[:20]
)
if profile
else [],
}
+6
View File
@@ -1,8 +1,11 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
ActivityListView,
ApplicationListView, ApplicationListView,
ApplicationUpdateView, ApplicationUpdateView,
EmployerDetailView,
EmployerListView,
JobDetailView, JobDetailView,
JobListView, JobListView,
SkillInsightsView, SkillInsightsView,
@@ -16,6 +19,9 @@ from .views import (
urlpatterns = [ urlpatterns = [
path("", JobListView.as_view(), name="list"), path("", JobListView.as_view(), name="list"),
path("skills/", SkillInsightsView.as_view(), name="skill-insights"), path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
path("activity/", ActivityListView.as_view(), name="activity"),
path("employers/", EmployerListView.as_view(), name="employers"),
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
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>/status/", application_status, name="application-status"),
+93 -1
View File
@@ -15,7 +15,8 @@ from django.views.generic import DetailView, ListView, TemplateView, UpdateView
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
from .forms import ApplicationForm from .forms import ApplicationForm
from .models import Application, Feedback, JobPosting, ScoreRun from .models import Application, Employer, Feedback, JobPosting, ScoreRun
from .services.activity import ACTIVITY_FILTERS, build_activity_page
from .services.applications import ( from .services.applications import (
build_application_export, build_application_export,
build_print_html, build_print_html,
@@ -24,6 +25,11 @@ from .services.applications import (
track_application_changes, track_application_changes,
) )
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
from .services.employer_intelligence import (
build_employer_detail,
employer_index_queryset,
filter_employers,
)
from .services.feedback import record_feedback from .services.feedback import record_feedback
from .services.relevance import it_relevance_query from .services.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report from .services.skill_demand import build_skill_demand_report
@@ -48,6 +54,92 @@ class SkillInsightsView(LoginRequiredMixin, TemplateView):
return context return context
class ActivityListView(LoginRequiredMixin, TemplateView):
template_name = "activity/list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
event_type = self.request.GET.get("type", "all")
valid_types = {value for value, _label in ACTIVITY_FILTERS}
if event_type not in valid_types:
event_type = "all"
context.update(
{
"event_page": build_activity_page(
user=self.request.user,
event_type=event_type,
query=self.request.GET.get("q", ""),
page=self.request.GET.get("page", 1),
),
"activity_filters": ACTIVITY_FILTERS,
"current_type": event_type,
}
)
return context
class EmployerListView(LoginRequiredMixin, ListView):
model = Employer
template_name = "employers/list.html"
context_object_name = "employers"
paginate_by = 40
def get_queryset(self):
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
return filter_employers(
employer_index_queryset(user=self.request.user, profile=self.profile),
query=self.request.GET.get("q", ""),
channel=self.request.GET.get("channel", ""),
active_only=self.request.GET.get("active") == "1",
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
employers = list(context["employers"])
selected_id = self.request.GET.get("selected", "")
selected = next((item for item in employers if str(item.pk) == selected_id), None)
if selected is None and employers:
selected = employers[0]
context.update(
{
"selected_employer": selected,
"selected_detail": build_employer_detail(
employer=selected, user=self.request.user, profile=self.profile
)
if selected
else None,
"active_profile": self.profile,
"employer_total": Employer.objects.count(),
"employer_active_total": Employer.objects.filter(
jobs__status=JobPosting.Status.ACTIVE
)
.distinct()
.count(),
}
)
return context
class EmployerDetailView(LoginRequiredMixin, DetailView):
model = Employer
template_name = "employers/detail.html"
context_object_name = "employer"
def get_queryset(self):
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
return employer_index_queryset(user=self.request.user, profile=self.profile)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
build_employer_detail(
employer=self.object, user=self.request.user, profile=self.profile
)
)
context["active_profile"] = self.profile
return context
class JobListView(LoginRequiredMixin, ListView): class JobListView(LoginRequiredMixin, ListView):
model = JobPosting model = JobPosting
template_name = "jobs/list.html" template_name = "jobs/list.html"
+6 -1
View File
@@ -1680,7 +1680,7 @@ tasks:
Relevante tests groen. Relevante tests groen.
- id: VR-212 - id: VR-212
title: Stitch automation, activiteit en werkgeversintelligence title: Stitch automation, activiteit en werkgeversintelligence
status: ready status: done
priority: P0 priority: P0
requirement_ids: requirement_ids:
- PR-040 - PR-040
@@ -1710,6 +1710,11 @@ tasks:
- templates/activity - templates/activity
- templates/employers - templates/employers
- templates/system/status.html - templates/system/status.html
result:
completed_at: '2026-07-22'
note: Echte automationcockpit met 24u-pipeline en eerlijke runtimeconfiguratie; gepagineerde/filterbare activiteitreadservice;
werkgeversindex/detail met vacature-, score-, bron- en dossieraggregaties. Nieuwe routes, templates en scope-tests
groen.
- id: VR-213 - id: VR-213
title: Stitch responsive validatie en legacyverwijdering title: Stitch responsive validatie en legacyverwijdering
status: ready status: ready
+78
View File
@@ -221,3 +221,81 @@
.platform-catalog > summary span { display: grid; } .platform-catalog > summary span { display: grid; }
.platform-grid { margin-top: 18px; } .platform-grid { margin-top: 18px; }
.platform-grid article { align-items: flex-start; flex-direction: column; } .platform-grid article { align-items: flex-start; flex-direction: column; }
.activity-filterbar { display: grid; grid-template-columns: minmax(260px, 1fr) 220px auto; align-items: end; gap: 10px; margin-bottom: 28px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
.activity-timeline { position: relative; display: grid; gap: 16px; padding-left: 78px; }
.activity-timeline::before { position: absolute; inset: 0 auto 0 28px; width: 1px; content: ""; background: linear-gradient(var(--cyan), var(--outline) 70%, transparent); opacity: .45; }
.activity-event { position: relative; }
.activity-marker { position: absolute; top: 0; left: -78px; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid var(--outline); background: var(--surface-low); color: var(--text-muted); }
.activity-marker.is-positive { border-color: color-mix(in srgb, var(--emerald) 45%, var(--outline)); color: var(--emerald); }
.activity-marker.is-attention { border-color: color-mix(in srgb, var(--coral) 45%, var(--outline)); color: var(--coral); }
.activity-card { padding: 20px; border: 1px solid color-mix(in srgb, var(--outline) 44%, transparent); background: var(--surface-container); }
.activity-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.activity-card-head h2 { margin: 9px 0 0; }
.activity-card-head time { color: var(--text-faint); font: 11px var(--font-mono); }
.activity-object { color: var(--text-muted); }
.decision-note { margin: 14px 0; padding: 12px 14px; border-left: 2px solid var(--cyan); background: var(--surface-high); }
.decision-note p { margin: 7px 0 0; }
.employer-kpis { display: flex; gap: 8px; }
.employer-kpis > div { min-width: 130px; padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
.employer-kpis span, .employer-kpis strong { display: block; }
.employer-kpis span { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
.employer-kpis strong { margin-top: 5px; color: var(--text-strong); font: 650 22px var(--font-headline); }
.employer-radar-layout { display: grid; grid-template-columns: minmax(0, 1fr) 390px; align-items: start; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
.employer-table-panel { min-width: 0; padding: 18px; }
.employer-table td:first-child a { display: flex; align-items: center; gap: 10px; }
.employer-table td:first-child strong, .employer-table td:first-child small { display: block; }
.employer-table tr.is-selected { background: color-mix(in srgb, var(--cyan) 6%, var(--surface-high)); }
.employer-mark { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border: 1px solid color-mix(in srgb, var(--cyan) 35%, var(--outline)); background: var(--surface-lowest); color: var(--cyan); font: 650 10px var(--font-mono); }
.employer-mark.large { width: 54px; height: 54px; margin-bottom: 14px; font-size: 15px; }
.employer-mark.hero-mark { width: 112px; height: 112px; font-size: 26px; }
.signal-number { color: var(--emerald); }
.employer-intelligence-rail { position: sticky; top: calc(var(--topbar-height) + 18px); overflow: hidden; border-left: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
.employer-intel-header { padding: 22px; border-bottom: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
.employer-intel-header h2 { font-size: 26px; }
.employer-intel-section { padding: 17px; border-top: 1px solid color-mix(in srgb, var(--outline) 35%, transparent); }
.employer-job-link, .employer-source { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 12px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
.employer-job-link strong, .employer-job-link small { display: block; }
.employer-job-link b { color: var(--cyan); font: 650 15px var(--font-mono); }
.employer-intelligence-rail > .button { margin: 17px; width: calc(100% - 34px); }
.employer-detail-hero { min-height: 230px; }
.employer-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; align-items: start; gap: 16px; }
.employer-job-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.employer-job-grid article { padding: 15px; border: 1px solid color-mix(in srgb, var(--outline) 38%, transparent); background: var(--surface-low); }
.employer-job-grid h3 { margin: 10px 0 4px; }
.match-history { display: grid; gap: 7px; }
.match-history > div { display: grid; grid-template-columns: 42px minmax(100px, 1fr) 40px minmax(160px, 1.3fr); align-items: center; gap: 9px; }
.match-history span { color: var(--text-faint); font: 10px var(--font-mono); }
.match-history i { position: relative; display: block; height: 5px; background: var(--surface-highest); }
.match-history i::after { position: absolute; inset: 0 auto 0 0; width: var(--match); content: ""; background: linear-gradient(90deg, var(--cyan-dim), var(--emerald)); }
.match-history strong { color: var(--cyan); font: 12px var(--font-mono); }
.automation-status { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 19px 22px; border: 1px solid var(--outline); background: var(--surface-container); }
.automation-status.is-healthy { border-color: color-mix(in srgb, var(--emerald) 30%, var(--outline)); }
.automation-status h2 { display: flex; align-items: center; gap: 10px; margin: 8px 0 0; }
.automation-status dl { display: flex; margin: 0; }
.automation-status dl > div { min-width: 150px; padding: 0 16px; border-left: 1px solid var(--outline); }
.automation-status dt { color: var(--text-faint); font: 9px var(--font-mono); text-transform: uppercase; }
.automation-status dd { margin: 5px 0 0; }
.automation-pipeline { display: grid; grid-template-columns: repeat(8, minmax(112px, 1fr)); gap: 4px; overflow-x: auto; margin: 28px 0 16px; padding-top: 28px; }
.automation-pipeline > .section-kicker { position: absolute; margin-top: -27px; }
.automation-pipeline article { position: relative; display: grid; min-height: 130px; place-items: center; align-content: center; padding: 11px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); border-bottom: 2px solid var(--cyan-dim); background: var(--surface-high); text-align: center; }
.automation-pipeline article::after { position: absolute; z-index: 1; top: 50%; right: -8px; width: 12px; content: ""; border-top: 1px solid var(--cyan); }
.automation-pipeline article:last-child::after { display: none; }
.automation-pipeline .icon { color: var(--cyan); }
.automation-pipeline strong { margin-top: 8px; font: 600 10px var(--font-mono); text-transform: uppercase; }
.automation-pipeline b { margin-top: 7px; color: var(--text-strong); font: 650 20px var(--font-headline); }
.automation-pipeline small { font: 9px var(--font-mono); }
.pipeline-phase-index { position: absolute; top: 7px; left: 8px; color: var(--text-faint); font: 9px var(--font-mono); }
.automation-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin: 17px 0 28px; }
.automation-kpis article { min-height: 120px; padding: 17px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
.automation-kpis strong, .automation-kpis small { display: block; }
.automation-kpis strong { margin: 15px 0 4px; color: var(--text-strong); font: 650 32px var(--font-headline); }
.automation-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(300px, .5fr); align-items: start; gap: 14px; }
.automation-source-table, .technical-log-panel { padding: 18px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-container); }
.technical-log-panel { background: var(--surface-lowest); }
.live-log { display: grid; max-height: 520px; overflow-y: auto; margin-bottom: 15px; }
.live-log article { display: grid; grid-template-columns: 68px 62px 1fr; gap: 7px; padding: 11px 0; border-bottom: 1px solid color-mix(in srgb, var(--outline) 27%, transparent); font: 10px/1.5 var(--font-mono); }
.live-log time { color: var(--text-faint); }
.live-log strong { color: var(--indigo); }
.live-log article.is-success strong { color: var(--emerald); }
.live-log article.is-error strong { color: var(--coral); }
.live-log small { grid-column: 2 / -1; }
+17
View File
@@ -27,6 +27,9 @@
.source-health-summary { grid-template-columns: 1fr auto; } .source-health-summary { grid-template-columns: 1fr auto; }
.source-health-metrics { grid-column: 1 / -1; grid-row: 2; } .source-health-metrics { grid-column: 1 / -1; grid-row: 2; }
.watchlist-grid, .platform-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .watchlist-grid, .platform-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.employer-radar-layout, .employer-detail-grid, .automation-layout { grid-template-columns: 1fr; }
.employer-intelligence-rail { position: static; border-top: 1px solid var(--outline); border-left: 0; }
.automation-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 820px) { @media (max-width: 820px) {
@@ -59,6 +62,10 @@
.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; } .preference-grid, .source-health-details { grid-template-columns: 1fr; }
.automation-status { align-items: flex-start; flex-direction: column; }
.automation-status dl { display: grid; grid-template-columns: repeat(3, 1fr); width: 100%; }
.automation-status dl > div { min-width: 0; border-top: 1px solid var(--outline); border-left: 0; padding: 11px 4px; }
.employer-job-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
@@ -89,6 +96,16 @@
.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 > 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; } .source-health-metrics strong { margin-top: 0; }
.mailbox-row, .danger-zone { align-items: flex-start; flex-direction: column; } .mailbox-row, .danger-zone { align-items: flex-start; flex-direction: column; }
.activity-filterbar, .automation-kpis { grid-template-columns: 1fr; }
.activity-timeline { padding-left: 52px; }
.activity-timeline::before { left: 20px; }
.activity-marker { left: -52px; width: 42px; height: 42px; }
.activity-card-head { flex-direction: column; gap: 8px; }
.employer-kpis { width: 100%; }
.employer-kpis > div { min-width: 0; flex: 1; }
.automation-status dl { grid-template-columns: 1fr; }
.match-history > div { grid-template-columns: 38px 1fr 36px; }
.match-history a { grid-column: 1 / -1; }
.auth-shell { padding: 14px; } .auth-shell { padding: 14px; }
.auth-card { padding: 22px 18px; } .auth-card { padding: 22px 18px; }
} }
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Activiteit · VacatureRadar{% endblock %}
{% block content %}
<header class="cockpit-heading"><div><p class="eyebrow">Radar audit trail</p><h1>Activiteit</h1><p>Betrouwbare gebeurtenissen uit bronruns, vacatureversies, feedback, sollicitaties, notificaties en profielrevisies.</p></div><div class="radar-status"><span class="status-beacon is-live" aria-hidden="true"></span><span>{{ event_page.paginator.count }} gebeurtenissen in deze selectie</span></div></header>
<form class="activity-filterbar" method="get"><label class="grow"><span class="field-label">Zoek in activiteit</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Vacature, werkgever of gebeurtenis"></span></label><label><span class="field-label">Type</span><select name="type">{% for value,label in activity_filters %}<option value="{{ value }}" {% if current_type == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><button class="button button-secondary" type="submit"><svg class="icon"><use href="#icon-filter"></use></svg>Filter</button></form>
<section class="activity-timeline" aria-label="Activiteitstijdlijn">
{% for event in event_page %}<article class="activity-event"><span class="activity-marker {% if event.tone == 'positive' %}is-positive{% elif event.tone == 'attention' %}is-attention{% endif %}"><svg class="icon"><use href="{% if event.event_type == 'source' %}#icon-sources{% elif event.event_type == 'application' %}#icon-pipeline{% elif event.event_type == 'profile' %}#icon-profile{% elif event.event_type == 'notification' %}#icon-mail{% else %}#icon-activity{% endif %}"></use></svg></span><div class="activity-card"><div class="activity-card-head"><div><span class="pill pill-{{ event.tone }}">{{ event.event_type }}</span><h2>{{ event.title }}</h2></div><time datetime="{{ event.occurred_at|date:'c' }}">{{ event.occurred_at|date:"d/m/Y H:i" }}</time></div><p class="activity-object">{{ event.object_label }}</p><div class="decision-note"><span class="field-label">Gebeurtenisdetails</span><p>{{ event.detail }}</p></div><a class="section-link" href="{{ event.url }}">Open betrokken onderdeel <svg class="icon"><use href="#icon-arrow"></use></svg></a></div></article>{% empty %}<div class="empty-state"><div class="empty-icon"><svg class="icon"><use href="#icon-activity"></use></svg></div><h2>Geen activiteit gevonden</h2><p>Deze filter bevat nog geen auditwaardige gebeurtenis.</p><a class="button button-ghost" href="{% url 'jobs:activity' %}">Alle activiteit tonen</a></div>{% endfor %}
</section>
{% if event_page.paginator.num_pages > 1 %}<nav class="pagination" aria-label="Activiteitspaginering">{% if event_page.has_previous %}<a class="button button-ghost" href="?page={{ event_page.previous_page_number }}&type={{ current_type }}&q={{ request.GET.q|urlencode }}">Vorige</a>{% endif %}<span>Pagina {{ event_page.number }} van {{ event_page.paginator.num_pages }}</span>{% if event_page.has_next %}<a class="button button-ghost" href="?page={{ event_page.next_page_number }}&type={{ current_type }}&q={{ request.GET.q|urlencode }}">Volgende</a>{% endif %}</nav>{% endif %}
{% endblock %}
+2
View File
@@ -33,11 +33,13 @@
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'detail' %}is-active{% endif %}" href="{% url 'jobs:list' %}?focus=all"><svg class="icon"><use href="#icon-briefcase"></use></svg><span>Alle vacatures</span></a> <a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'detail' %}is-active{% endif %}" href="{% url 'jobs:list' %}?focus=all"><svg class="icon"><use href="#icon-briefcase"></use></svg><span>Alle vacatures</span></a>
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'skill-insights' %}is-active{% endif %}" href="{% url 'jobs:skill-insights' %}"><svg class="icon"><use href="#icon-chart"></use></svg><span>Skillsradar</span></a> <a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'skill-insights' %}is-active{% endif %}" href="{% url 'jobs:skill-insights' %}"><svg class="icon"><use href="#icon-chart"></use></svg><span>Skillsradar</span></a>
<a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}is-active{% endif %}" href="{% url 'jobs:applications' %}"><svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties</span></a> <a class="nav-item {% if request.resolver_match.namespace == 'jobs' and request.resolver_match.url_name == 'applications' or request.resolver_match.url_name == 'application-edit' %}is-active{% endif %}" href="{% url 'jobs:applications' %}"><svg class="icon"><use href="#icon-pipeline"></use></svg><span>Sollicitaties</span></a>
<a class="nav-item {% if request.resolver_match.url_name == 'employers' or request.resolver_match.url_name == 'employer-detail' %}is-active{% endif %}" href="{% url 'jobs:employers' %}"><svg class="icon"><use href="#icon-building"></use></svg><span>Werkgevers</span></a>
<p class="nav-caption">Radarbeheer</p> <p class="nav-caption">Radarbeheer</p>
<a class="nav-item {% if request.resolver_match.namespace == 'profiles' %}is-active{% endif %}" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg><span>Zoekprofiel</span></a> <a class="nav-item {% if request.resolver_match.namespace == 'profiles' %}is-active{% endif %}" href="{% url 'profiles:list' %}"><svg class="icon"><use href="#icon-profile"></use></svg><span>Zoekprofiel</span></a>
<a class="nav-item {% if request.resolver_match.namespace == 'sources' %}is-active{% endif %}" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg><span>Bronnen</span></a> <a class="nav-item {% if request.resolver_match.namespace == 'sources' %}is-active{% endif %}" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg><span>Bronnen</span></a>
<a class="nav-item {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'system' %}is-active{% endif %}" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg><span>Automatisering</span></a> <a class="nav-item {% if request.resolver_match.namespace == 'dashboard' and request.resolver_match.url_name == 'system' %}is-active{% endif %}" href="{% url 'dashboard:system' %}"><svg class="icon"><use href="#icon-system"></use></svg><span>Automatisering</span></a>
<a class="nav-item {% if request.resolver_match.url_name == 'activity' %}is-active{% endif %}" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg><span>Activiteit</span></a>
</nav> </nav>
<div class="sidebar-radar-card"> <div class="sidebar-radar-card">
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}{{ employer.name }} intelligence · VacatureRadar{% endblock %}
{% block content %}
<a class="back-link" href="{% url 'jobs:employers' %}">← Terug naar werkgeversradar</a>
<header class="detail-hero employer-detail-hero"><div><p class="eyebrow">Employer intelligence #{{ employer.pk }}</p><div class="title-row">{% if employer.is_recruiter %}<span class="pill">Recruiter</span>{% elif employer.is_direct_employer %}<span class="pill pill-success">Directe werkgever</span>{% endif %}{% if employer.employer_type %}<span class="pill">{{ employer.employer_type }}</span>{% endif %}</div><h1>{{ employer.name }}</h1><p class="detail-employer">{{ employer.domain|default:"Domein onbekend" }}</p></div><span class="employer-mark hero-mark">{{ employer.name|slice:":2"|upper }}</span></header>
<section class="intelligence-strip"><div><span class="field-label">Actieve vacatures</span><strong>{{ employer.active_vacancies }}</strong><small>alle functies</small></div><div><span class="field-label">Relevante IT</span><strong>{{ employer.relevant_vacancies }}</strong><small>deterministisch gefilterd</small></div><div><span class="field-label">Gemiddelde match</span><strong>{% if employer.average_match is not None %}{{ employer.average_match|floatformat:0 }}%{% else %}—{% endif %}</strong><small>{% if active_profile %}{{ active_profile.name }}{% else %}geen actief profiel{% endif %}</small></div><div><span class="field-label">Brondekking</span><strong>{{ employer.source_count }}</strong><small>unieke bronnen</small></div></section>
<div class="employer-detail-grid"><main><section class="detail-section"><div class="section-heading"><div><span class="section-kicker">Vacaturehistorie</span><h2>Recente vacatures</h2><p>Actief en historisch, uitsluitend uit opgeslagen bronrecords.</p></div></div><div class="employer-job-grid">{% for job in recent_jobs %}<article><div class="title-row"><span class="pill pill-{{ job.status }}">{{ job.get_status_display }}</span>{% if job.match_recommendation %}<span class="pill pill-{{ job.match_recommendation }}">{{ job.match_recommendation }}</span>{% endif %}</div><h3><a href="{% url 'jobs:detail' job.pk %}">{{ job.original_title }}</a></h3><p>{{ job.raw_location|default:"Locatie onbekend" }} · {{ job.get_workplace_type_display }}</p><div class="job-meta"><span>{{ job.first_seen|date:"d/m/Y" }}</span>{% if job.match_score is not None %}<strong>{{ job.match_score|floatformat:0 }}% match</strong>{% endif %}</div></article>{% empty %}<div class="empty-state compact-empty"><h2>Geen vacaturehistorie</h2></div>{% endfor %}</div></section>{% if match_history %}<section class="detail-section"><span class="section-kicker">Matchpatroon</span><h2>Recente profielbeoordelingen</h2><div class="match-history">{% for score in match_history %}<div><span>{{ score.created_at|date:"d/m" }}</span><i style="--match: {{ score.score }}%"></i><strong>{{ score.score|floatformat:0 }}%</strong><a href="{% url 'jobs:detail' score.job.pk %}">{{ score.job.original_title }}</a></div>{% endfor %}</div></section>{% endif %}</main><aside class="detail-sidebar"><section class="panel"><span class="section-kicker">Waargenomen context</span><h2>Werkgeversprofiel</h2><dl class="intel-facts"><div><dt>Locaties</dt><dd>{% if locations %}{{ locations|join:", " }}{% else %}Onbekend{% endif %}</dd></div><div><dt>Type</dt><dd>{{ employer.employer_type|default:"Onbekend" }}</dd></div><div><dt>Laatste signaal</dt><dd>{{ employer.latest_job_at|date:"d/m/Y"|default:"—" }}</dd></div><div><dt>Confidence</dt><dd>{% widthratio employer.confidence 1 100 %}%</dd></div></dl></section><section class="panel"><span class="section-kicker">Brongezondheid</span><h2>{{ sources|length }} gekoppelde bronnen</h2>{% for source in sources %}<a class="employer-source" href="{% url 'sources:list' %}"><span>{{ source.name }}</span><span class="pill {% if source.status == 'active' %}pill-success{% else %}pill-review{% endif %}">{{ source.get_status_display }}</span></a>{% empty %}<p class="muted">Geen gekoppelde bron opgeslagen.</p>{% endfor %}</section><section class="panel"><span class="section-kicker">Eerdere sollicitaties</span><h2>{{ applications|length }} dossier{% if applications|length != 1 %}s{% endif %}</h2>{% for application in applications %}<a class="employer-source" href="{% url 'jobs:application-edit' application.pk %}"><span>{{ application.job.original_title }}</span><span>{{ application.get_status_display }}</span></a>{% empty %}<p class="muted">Nog geen dossier bij deze werkgever.</p>{% endfor %}</section></aside></div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Werkgevers intelligence · VacatureRadar{% endblock %}
{% block content %}
<header class="cockpit-heading"><div><p class="eyebrow">Intelligent tracking</p><h1>Werkgeversradar</h1><p>Genormaliseerde werkgeversdata uit echte vacatures, bronnen, profielscores en sollicitatiedossiers.</p></div><div class="employer-kpis"><div><span>Gemonitord</span><strong>{{ employer_total }}</strong></div><div><span>Met actieve vacatures</span><strong>{{ employer_active_total }}</strong></div></div></header>
<form class="activity-filterbar" method="get"><label class="grow"><span class="field-label">Zoek werkgevers</span><span class="input-with-icon"><svg class="icon"><use href="#icon-search"></use></svg><input type="search" name="q" value="{{ request.GET.q }}" placeholder="Naam, domein of locatie"></span></label><label><span class="field-label">Kanaal</span><select name="channel"><option value="">Alle kanalen</option><option value="direct" {% if request.GET.channel == 'direct' %}selected{% endif %}>Directe werkgever</option><option value="recruiter" {% if request.GET.channel == 'recruiter' %}selected{% endif %}>Recruiter</option></select></label><label class="toggle-field"><input type="checkbox" name="active" value="1" {% if request.GET.active == '1' %}checked{% endif %}><span>Alleen actief</span></label><button class="button button-secondary" type="submit">Filter</button></form>
<div class="employer-radar-layout">
<section class="employer-table-panel" aria-labelledby="employer-list-heading"><div class="section-heading"><div><span class="section-kicker">Gevolgde werkgevers</span><h2 id="employer-list-heading">{{ page_obj.paginator.count }} werkgevers</h2></div></div><div class="data-table-wrap" tabindex="0" aria-label="Werkgevers, horizontaal scrolbaar"><table class="data-table employer-table"><thead><tr><th>Werkgever</th><th>Type</th><th>Actief</th><th>Relevant IT</th><th>Bronnen</th><th>Gem. match</th><th>Laatste signaal</th></tr></thead><tbody>{% for item in employers %}<tr class="{% if selected_employer.pk == item.pk %}is-selected{% endif %}"><td><a href="?selected={{ item.pk }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}"><span class="employer-mark">{{ item.name|slice:":2"|upper }}</span><span><strong>{{ item.name }}</strong><small>{{ item.domain|default:"Geen domein" }}</small></span></a></td><td>{% if item.is_recruiter %}<span class="pill">Recruiter</span>{% elif item.is_direct_employer %}<span class="pill pill-success">Direct</span>{% else %}<span class="pill">Onbekend</span>{% endif %}</td><td>{{ item.active_vacancies }}</td><td><strong class="signal-number">{{ item.relevant_vacancies }}</strong></td><td>{{ item.source_count }}</td><td>{% if item.average_match is not None %}{{ item.average_match|floatformat:0 }}%{% else %}—{% endif %}</td><td>{{ item.latest_job_at|date:"d/m/Y"|default:"—" }}</td></tr>{% empty %}<tr><td colspan="7">Geen werkgevers gevonden.</td></tr>{% endfor %}</tbody></table></div>{% if is_paginated %}<nav class="pagination" aria-label="Werkgeverspaginering">{% if page_obj.has_previous %}<a class="button button-ghost" href="?page={{ page_obj.previous_page_number }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}">Vorige</a>{% endif %}<span>{{ page_obj.number }}/{{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a class="button button-ghost" href="?page={{ page_obj.next_page_number }}&q={{ request.GET.q|urlencode }}&channel={{ request.GET.channel }}&active={{ request.GET.active }}">Volgende</a>{% endif %}</nav>{% endif %}</section>
<aside class="employer-intelligence-rail" aria-label="Geselecteerde werkgever">{% if selected_employer %}<div class="employer-intel-header"><span class="section-kicker">Employer report #{{ selected_employer.pk }}</span><span class="employer-mark large">{{ selected_employer.name|slice:":2"|upper }}</span><h2>{{ selected_employer.name }}</h2><p>{{ selected_employer.domain|default:"Domein onbekend" }}</p></div><dl class="intel-facts"><div><dt>Actieve vacatures</dt><dd>{{ selected_employer.active_vacancies }}</dd></div><div><dt>Relevante IT</dt><dd>{{ selected_employer.relevant_vacancies }}</dd></div><div><dt>Eerdere dossiers</dt><dd>{{ selected_employer.previous_applications }}</dd></div><div><dt>Bronnen</dt><dd>{{ selected_employer.source_count }}</dd></div><div><dt>Gem. match</dt><dd>{% if selected_employer.average_match is not None %}{{ selected_employer.average_match|floatformat:0 }}%{% else %}Nog niet gemeten{% endif %}</dd></div><div><dt>Kanaal</dt><dd>{% if selected_employer.is_recruiter %}Recruiter{% elif selected_employer.is_direct_employer %}Direct{% else %}Onbekend{% endif %}</dd></div></dl><section class="employer-intel-section"><span class="field-label">Recente vacatures</span>{% for job in selected_detail.recent_jobs|slice:":4" %}<a class="employer-job-link" href="{% url 'jobs:detail' job.pk %}"><span><strong>{{ job.original_title }}</strong><small>{{ job.first_seen|date:"d/m/Y" }}</small></span>{% if job.match_score is not None %}<b>{{ job.match_score|floatformat:0 }}%</b>{% endif %}</a>{% empty %}<p class="muted">Nog geen vacaturehistorie.</p>{% endfor %}</section><section class="employer-intel-section"><span class="field-label">Waargenomen locaties</span><p>{% if selected_detail.locations %}{{ selected_detail.locations|join:", " }}{% else %}Onbekend{% endif %}</p></section><a class="button button-primary button-block" href="{% url 'jobs:employer-detail' selected_employer.pk %}">Volledig werkgeversrapport</a>{% else %}<div class="empty-state"><h2>Kies een werkgever</h2></div>{% endif %}</aside>
</div>
{% endblock %}
+7 -10
View File
@@ -1,15 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Systeemstatus · VacatureRadar{% endblock %} {% block title %}Automation center · VacatureRadar{% endblock %}
{% block content %} {% block content %}
<header class="page-header"><div><p class="eyebrow">Automatisering centrum</p><h1>Radarbesturing</h1><p>Readiness, bronruns en veilige beheeracties op basis van actuele systeemdata.</p></div><span class="pill {% if health.ok %}pill-success{% else %}pill-failed{% endif %}">{% if health.ok %}Operationeel{% else %}Aandacht nodig{% endif %}</span></header> <header class="cockpit-heading"><div><p class="eyebrow">Automation center</p><h1>Radarbesturing</h1><p>Echte 24-uursverwerking, bronruns, notificaties en configuratiestatus. Niet-gemeten runtime-informatie blijft expliciet onbekend.</p></div><div class="page-actions"><a class="button button-ghost" href="{% url 'jobs:activity' %}"><svg class="icon"><use href="#icon-activity"></use></svg>Activiteitenlog</a><a class="button button-primary" href="{% url 'sources:list' %}"><svg class="icon"><use href="#icon-sources"></use></svg>Bronnen beheren</a></div></header>
<section class="system-hero"> <section class="automation-status {% if health.ok %}is-healthy{% endif %}"><div><span class="field-label">Systeemstatus</span><h2><span class="status-beacon {% if health.ok %}is-live{% endif %}" aria-hidden="true"></span>{% if health.ok %}Database operationeel{% else %}Databasecontrole vereist{% endif %}</h2></div><dl><div><dt>Queue</dt><dd>{{ queue_state }}</dd></div><div><dt>AI</dt><dd>{{ ai_state }}{% if ai_profiles %} · {{ ai_profiles }} profiel{% endif %}</dd></div><div><dt>Browserextractie</dt><dd>{{ browser_state }}</dd></div></dl></section>
<article class="system-readiness {% if health.ok %}is-healthy{% endif %}"><span class="source-icon"><svg class="icon"><use href="#icon-system"></use></svg></span><div><span class="field-label">Systeemreadiness</span><h2>{% if health.ok %}Radar is operationeel{% else %}Controle vereist{% endif %}</h2><p>Databasecontrole: {{ health.checks.database }}</p></div><span class="signal-dot" aria-hidden="true"></span></article>
<div class="system-counts">{% for row in source_summary %}<article><span class="field-label">Bronnen · {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}{% for row in job_summary %}<article><span class="field-label">Vacatures · {{ row.status }}</span><strong>{{ row.total }}</strong></article>{% endfor %}</div>
</section>
<div class="section-heading"><div><span class="section-kicker">Brontelemetrie</span><h2>Recente rungezondheid</h2><p>Meetwaarden uit maximaal de laatste 30 runs per bron.</p></div><a class="section-link" href="{% url 'sources:list' %}">Bronnen beheren <svg class="icon"><use href="#icon-arrow"></use></svg></a></div> <section class="automation-pipeline" aria-labelledby="pipeline-title"><span class="section-kicker">Verwerkingspipeline · laatste 24 uur</span><h2 id="pipeline-title" class="sr-only">Verwerkingspipeline</h2>{% for phase in phase_rows %}<article><span class="pipeline-phase-index">0{{ forloop.counter }}</span><svg class="icon"><use href="{% if phase.key == 'discover' %}#icon-radar{% elif phase.key == 'fetch' %}#icon-refresh{% elif phase.key == 'notify' %}#icon-mail{% else %}#icon-system{% endif %}"></use></svg><strong>{{ phase.label }}</strong><b>{{ phase.value }}</b><small>{{ phase.unit }}</small></article>{% endfor %}</section>
<div class="health-grid">{% for row in source_health %}<article class="health-card"><div class="health-card-head"><div><span class="field-label">{{ row.source_type }}</span><h3>{{ row.source_name }}</h3></div><span class="pill {% if row.success_ratio >= 0.8 %}pill-success{% elif row.monitored_runs == 0 %}pill-review{% else %}pill-failed{% endif %}">{% if row.monitored_runs == 0 %}Geen runs{% else %}Ratio {{ row.success_ratio|floatformat:2 }}{% endif %}</span></div><dl><div><dt>Runs</dt><dd>{{ row.monitored_runs }}</dd></div><div><dt>Gevonden</dt><dd>{{ row.extracted_count }}</dd></div><div><dt>Bijgewerkt</dt><dd>{{ row.updated_count }}</dd></div><div><dt>Latency</dt><dd>{% if row.avg_latency_ms is not None %}{{ row.avg_latency_ms|floatformat:0 }} ms{% else %}—{% endif %}</dd></div></dl>{% if row.last_health_reason %}<p class="attention">{{ row.last_health_reason }}</p>{% endif %}</article>{% empty %}<div class="empty-state compact-empty"><h2>Nog geen brontelemetrie</h2><p>Bronruns verschijnen hier zodra een toegestane bron is uitgevoerd.</p></div>{% endfor %}</div>
<div class="section-heading"><div><span class="section-kicker">Operaties</span><h2>Veilige beheeropdrachten</h2><p>Uitsluitend uitvoeren vanaf een vertrouwde shell op de server.</p></div></div> <section class="automation-kpis"><article><span class="field-label">Bronruns / 24u</span><strong>{{ run_count }}</strong><small>{{ run_metrics.failures|default:0 }} mislukt</small></article><article><span class="field-label">Vacatures gevonden</span><strong>{{ run_metrics.discovered|default:0 }}</strong><small>{{ run_metrics.extracted|default:0 }} geëxtraheerd</small></article><article><span class="field-label">IT-vacatures actief</span><strong>{{ active_jobs }}</strong><small>{{ review_jobs }} beoordeelde kansen</small></article><article><span class="field-label">Notificatie-outbox</span><strong>{{ notification_counts.pending }}</strong><small>{{ notification_counts.failed }} mislukt · {{ notification_counts.sent_24h }} verzonden</small></article></section>
<section class="panel command-panel"><div><span class="field-label">Verificatie</span><pre><code>make verify</code></pre></div><div><span class="field-label">Back-up</span><pre><code>./scripts/backup.sh</code></pre></div><div><span class="field-label">Herstel</span><pre><code>./scripts/restore.sh /pad/naar/backup.sql.gz</code></pre></div><div><span class="field-label">Bronnen initialiseren</span><pre><code>uv run python manage.py seed_sources</code></pre></div></section>
<div class="automation-layout"><section class="automation-source-table"><div class="section-heading"><div><span class="section-kicker">Brongezondheid</span><h2>{{ source_active }}/{{ source_total }} actief</h2></div><a class="section-link" href="{% url 'sources:list' %}">Alle bronnen <svg class="icon"><use href="#icon-arrow"></use></svg></a></div><div class="data-table-wrap" tabindex="0"><table class="data-table"><thead><tr><th>Bron</th><th>Status</th><th>Runs</th><th>Succesratio</th><th>Extractie</th><th>Latency</th></tr></thead><tbody>{% for row in source_health %}<tr><td><strong>{{ row.source_name }}</strong><small>{{ row.source_type }}</small></td><td><span class="pill {% if row.success_ratio >= 0.8 %}pill-success{% elif row.monitored_runs == 0 %}pill-review{% else %}pill-failed{% endif %}">{% if row.monitored_runs == 0 %}Geen meting{% else %}{{ row.source_status }}{% endif %}</span></td><td>{{ row.monitored_runs }}</td><td>{% widthratio row.success_ratio 1 100 %}%</td><td>{{ row.extracted_count }}</td><td>{% if row.avg_latency_ms is not None %}{{ row.avg_latency_ms|floatformat:0 }} ms{% else %}—{% endif %}</td></tr>{% empty %}<tr><td colspan="6">Nog geen brontelemetrie.</td></tr>{% endfor %}</tbody></table></div></section><aside class="technical-log-panel"><div class="section-heading"><div><span class="section-kicker">Technische logs</span><h2>Recente bronruns</h2></div></div><div class="live-log">{% for run in recent_runs %}<article class="{% if run.status == 'failed' %}is-error{% elif run.status == 'success' %}is-success{% endif %}"><time>[{{ run.started_at|date:"H:i:s" }}]</time><strong>{{ run.get_status_display|upper }}</strong><span>{{ run.source.name }}</span><small>{% if run.error_category %}{{ run.error_category }} · {{ run.error_message }}{% else %}{{ run.extracted_count }} geëxtraheerd · {{ run.duplicate_count }} duplicaat{% endif %}</small></article>{% empty %}<p class="muted">Nog geen echte bronrun geregistreerd.</p>{% endfor %}</div><a class="button button-ghost button-block" href="{% url 'jobs:activity' %}?type=source">Volledig logboek</a></aside></div>
{% endblock %} {% endblock %}
@@ -0,0 +1,80 @@
from __future__ import annotations
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
from apps.jobs.models import Feedback
@pytest.mark.integration
@pytest.mark.django_db
@pytest.mark.parametrize(
"route_name",
["jobs:activity", "jobs:employers", "dashboard:system"],
)
def test_intelligence_routes_require_login(client, route_name):
response = client.get(reverse(route_name))
assert response.status_code == 302
assert reverse("login") in response.url
@pytest.mark.integration
@pytest.mark.django_db
def test_activity_feed_filters_and_scopes_user_events(client, user, job):
other = get_user_model().objects.create_user(username="activity-other")
Feedback.objects.create(
user=other,
job=job,
action=Feedback.Action.HIDE,
reason="private-other-marker",
)
Feedback.objects.create(
user=user,
job=job,
action=Feedback.Action.INTERESTING,
reason="own-event-marker",
)
client.force_login(user)
response = client.get(reverse("jobs:activity"), {"type": "feedback"})
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "own-event-marker" in body
assert "private-other-marker" not in body
assert response.context["event_page"].paginator.count == 1
@pytest.mark.integration
@pytest.mark.django_db
def test_employer_index_and_detail_use_real_aggregates(client, user, profile, job, source):
client.force_login(user)
response = client.get(reverse("jobs:employers"), {"q": job.employer.name})
assert response.status_code == 200
assert response.context["selected_employer"].pk == job.employer_id
assert response.context["selected_employer"].active_vacancies == 1
assert response.context["selected_employer"].relevant_vacancies == 1
assert job.original_title in response.content.decode("utf-8")
detail = client.get(reverse("jobs:employer-detail", kwargs={"pk": job.employer_id}))
assert detail.status_code == 200
assert detail.context["employer"].pk == job.employer_id
assert [item.pk for item in detail.context["recent_jobs"]] == [job.pk]
@pytest.mark.integration
@pytest.mark.django_db
def test_automation_center_labels_unmeasured_runtime_honestly(client, user):
client.force_login(user)
response = client.get(reverse("dashboard:system"))
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "Automation center" in body
assert "worker niet gemeten" in body or "Eager/lokaal" in body
assert "Niet geconfigureerd" in body
assert len(response.context["phase_rows"]) == 8