feat: replace dashboard and vacancy intelligence
This commit is contained in:
+5
-59
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user