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.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.db.models import Count
from django.http import JsonResponse
from django.views.generic import TemplateView
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.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health
from .health import readiness
from .services.automation import build_automation_cockpit
class SecurityAwareLoginView(LoginView):
@@ -95,8 +93,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["health"] = readiness()
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
context.update(build_automation_cockpit())
context["source_health"] = collect_source_health()
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 .views import (
ActivityListView,
ApplicationListView,
ApplicationUpdateView,
EmployerDetailView,
EmployerListView,
JobDetailView,
JobListView,
SkillInsightsView,
@@ -16,6 +19,9 @@ from .views import (
urlpatterns = [
path("", JobListView.as_view(), name="list"),
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/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
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 .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 (
build_application_export,
build_print_html,
@@ -24,6 +25,11 @@ from .services.applications import (
track_application_changes,
)
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.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report
@@ -48,6 +54,92 @@ class SkillInsightsView(LoginRequiredMixin, TemplateView):
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):
model = JobPosting
template_name = "jobs/list.html"