feat: replace dashboard and vacancy intelligence

This commit is contained in:
Jens
2026-07-22 19:56:15 +02:00
parent 09af98193f
commit 3371455255
9 changed files with 388 additions and 216 deletions
+5 -59
View File
@@ -4,14 +4,13 @@ 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, OuterRef, Q, Subquery
from django.db.models import Count
from django.http import JsonResponse
from django.utils import timezone
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 Application, JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
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
@@ -86,61 +85,8 @@ class TodayView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer", "profile")
.filter(
profile=profile,
job__status=JobPosting.Status.ACTIVE,
hard_exclusions=[],
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
)
.filter(it_relevance_query("job__"), pk=Subquery(latest_score_id))
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
seen: set[str] = set()
cards = []
for score in latest_scores[:250]:
key = str(score.job_id)
if key in seen:
continue
seen.add(key)
cards.append(score)
if len(cards) >= 20:
break
active_it_jobs = JobPosting.objects.filter(
Q(status=JobPosting.Status.ACTIVE) & it_relevance_query()
)
all_active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
context.update(
{
"score_cards": cards,
"priority_applications": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": all_active_jobs.exclude(it_relevance_query()).count(),
"new_today": active_it_jobs.filter(first_seen__date=timezone.localdate()).count(),
"applications_open": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.count(),
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
total=Count("id"),
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
),
}
)
context.update(build_dashboard_cockpit(self.request.user, profile))
context["active_profile"] = profile
return context
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import (
CharField,
Count,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
QuerySet,
Subquery,
)
from django.utils import timezone
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun
from .relevance import it_relevance_query
User = get_user_model()
def annotate_latest_profile_score(
queryset: QuerySet[JobPosting], profile: SearchProfile | None
) -> QuerySet[JobPosting]:
"""Annotate jobs with one deterministic latest score for the active profile."""
if profile is None:
return queryset
latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at")
return queryset.annotate(
match_score=Subquery(
latest.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest.values("recommendation")[:1], output_field=CharField()
),
match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()),
match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()),
match_hard_exclusions=Subquery(
latest.values("hard_exclusions")[:1], output_field=JSONField()
),
match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()),
)
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
activity: list[dict[str, Any]] = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]:
activity.append(
{
"occurred_at": run.started_at,
"kind": "source",
"title": f"Bronrun {run.get_status_display().lower()}",
"detail": run.source.name,
"tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention",
}
)
for event in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:4]
):
activity.append(
{
"occurred_at": event.created_at,
"kind": "application",
"title": event.get_event_type_display(),
"detail": event.application.job.original_title,
"tone": "neutral",
}
)
return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5]
def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]:
"""Build truthful dashboard metrics and bounded read lists."""
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
active_it_jobs = active_jobs.filter(it_relevance_query())
today = timezone.localdate()
latest_scores = ScoreRun.objects.none()
if profile is not None:
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer")
.filter(
profile=profile,
pk=Subquery(latest_score_id),
job__status=JobPosting.Status.ACTIVE,
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
hard_exclusions=[],
)
.filter(it_relevance_query("job__"))
)
sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
source_counts = sources.aggregate(
total=Count("id"),
active=Count("id", filter=Q(status=Source.Status.ACTIVE)),
attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
)
open_applications = Application.objects.filter(user=user).exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
)
new_today = active_it_jobs.filter(first_seen__date=today).count()
strong_today = latest_scores.filter(
recommendation=ScoreRun.Recommendation.STRONG,
job__first_seen__date=today,
).count()
filtered_count = active_jobs.exclude(it_relevance_query()).count()
duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count()
if strong_today:
day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht."
elif new_today:
day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)."
else:
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
return {
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:8]),
"priority_applications": list(
open_applications.select_related("job", "job__employer").order_by(
F("follow_up_date").asc(nulls_last=True), "-updated_at"
)[:4]
),
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": filtered_count,
"duplicate_jobs": duplicate_count,
"new_today": new_today,
"strong_today": strong_today,
"applications_open": open_applications.count(),
"source_counts": source_counts,
"recent_activity": _recent_dashboard_activity(user),
"day_conclusion": day_conclusion,
"latest_scan": SourceRun.objects.order_by("-started_at").first(),
}
def build_job_intelligence(
*, job: JobPosting, profile: SearchProfile | None, user: User
) -> dict[str, Any]:
"""Return the bounded, user-scoped read model for explorer and detail."""
score = (
ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first()
if profile
else None
)
return {
"score": score,
"application": Application.objects.filter(user=user, job=job).first(),
"duplicate_count": job.duplicates.count(),
"source_count": job.source_aliases.count(),
"version_count": job.versions.count(),
"latest_versions": list(job.versions.all()[:5]),
"provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]),
}
+26 -49
View File
@@ -3,16 +3,7 @@ from __future__ import annotations
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 (
CharField,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
Subquery,
)
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
@@ -30,6 +21,7 @@ from .services.applications import (
delete_application_dossier,
track_application_changes,
)
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report
@@ -70,40 +62,15 @@ class JobListView(LoginRequiredMixin, ListView):
VALID_SORTS = {"match", "newest", "closing"}
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
queryset = JobPosting.objects.select_related("employer").annotate(
duplicate_count=Count("duplicates", distinct=True)
)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
if profile:
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
"-created_at"
)
queryset = queryset.annotate(
match_score=Subquery(
latest_score.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest_score.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
match_positives=Subquery(
latest_score.values("positives")[:1], output_field=JSONField()
),
match_concerns=Subquery(
latest_score.values("concerns")[:1], output_field=JSONField()
),
match_hard_exclusions=Subquery(
latest_score.values("hard_exclusions")[:1], output_field=JSONField()
),
match_scored_at=Subquery(
latest_score.values("created_at")[:1], output_field=DateTimeField()
),
)
queryset = annotate_latest_profile_score(queryset, profile)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip()
channel = self.request.GET.get("channel", "").strip()
focus = self.request.GET.get("focus", "it").strip()
recommendation = self.request.GET.get("match", "radar").strip()
sort = self.request.GET.get("sort", "match").strip()
@@ -120,6 +87,10 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status)
if workplace:
queryset = queryset.filter(workplace_type=workplace)
if channel == "direct":
queryset = queryset.filter(direct_employer=True, recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(recruiter=True)
if focus != "all":
queryset = queryset.filter(it_relevance_query())
if profile and recommendation == "radar":
@@ -162,8 +133,21 @@ class JobListView(LoginRequiredMixin, ListView):
else "radar"
),
"current_sort": self.request.GET.get("sort", "match"),
"current_channel": self.request.GET.get("channel", ""),
}
)
jobs = list(context["jobs"])
selected_id = self.request.GET.get("selected", "")
selected_job = next((job for job in jobs if str(job.pk) == selected_id), None)
if selected_job is None and jobs:
selected_job = jobs[0]
context["selected_job"] = selected_job
if selected_job is not None:
context["selected_intelligence"] = build_job_intelligence(
job=selected_job,
profile=context["active_profile"],
user=self.request.user,
)
return context
@@ -180,16 +164,9 @@ class JobDetailView(LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["score"] = (
ScoreRun.objects.filter(job=self.object, profile=profile)
.order_by("-created_at")
.first()
if profile
else None
context.update(
build_job_intelligence(job=self.object, profile=profile, user=self.request.user)
)
context["application"] = Application.objects.filter(
user=self.request.user, job=self.object
).first()
context["latest_feedback"] = Feedback.objects.filter(
user=self.request.user, job=self.object
).first()