feat: ship premium IT-focused vacancy radar
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-22 15:16:15 +02:00
parent 7d3d026d1d
commit 029df89265
23 changed files with 838 additions and 53 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]:
context = {
"app_name": "VacatureRadar",
"app_version": "0.2.2",
"app_version": "0.2.11",
}
if request.user.is_authenticated:
context["navigation_profile"] = (
+29 -9
View File
@@ -4,13 +4,15 @@ 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, Q
from django.db.models import Count, OuterRef, Q, Subquery
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.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health
@@ -83,9 +85,24 @@ 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(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
.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.
@@ -99,6 +116,10 @@ class TodayView(LoginRequiredMixin, TemplateView):
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,
@@ -106,13 +127,12 @@ class TodayView(LoginRequiredMixin, TemplateView):
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
"new_today": JobPosting.objects.filter(
first_seen__date=timezone.localdate()
).count(),
"applications_open": Application.objects.exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
).count(),
"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(
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from django.db.models import Q
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
IT_TITLE_TERMS = (
"it",
"ict",
"informatica",
"software",
"development engineer",
"devops",
"secops",
"cloud",
"data engineer",
"data scientist",
"data steward",
"database",
"dba",
"business intelligence",
"bi analyst",
"system engineer",
"systems engineer",
"systeembeheer",
"infrastructure engineer",
"infrastructuur engineer",
"network engineer",
"network architect",
"netwerkbeheer",
"netwerk engineer",
"cybersecurity",
"cyber security",
"security engineer",
"security architect",
"security analyst",
"information security",
"informatiebeveiliging",
"workplace engineer",
"modern workplace",
"digital workplace",
"service desk",
"servicedesk",
"helpdesk",
"support engineer",
"support specialist",
"application lead",
"application engineer",
"application manager",
"application specialist",
"applicatiebeheer",
"platform engineer",
"platform expert",
"solution architect",
"solutions architect",
"technical architect",
"technisch architect",
"functional analyst",
"functioneel analist",
"business analyst",
"product owner",
"web developer",
"mobile developer",
"low-code developer",
"low code developer",
"api developer",
"idm developer",
"power platform",
"forgerock",
"embedded",
"c#",
"front end",
"frontend",
"back end",
"backend",
"full stack",
"fullstack",
"php",
".net",
"java",
"python",
"powershell",
"azure",
"aws",
"microsoft 365",
"linux",
"windows server",
"kubernetes",
"docker",
"terraform",
"erp",
"sap",
"machine learning",
"ai engineer",
"artificial intelligence",
"image processing",
"computer vision",
"qa engineer",
"test automation",
)
# These phrases can contain IT vocabulary while describing commercial, recruitment,
# or educational-design work. They therefore override positive title signals.
NON_IT_TITLE_TERMS = (
"business developer",
"business development",
"it recruitment",
"it recruiter",
"learning designer",
)
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
def _term_pattern(term: str) -> str:
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
return rf"(?<!\w){escaped}(?!\w)"
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
@dataclass(frozen=True)
class ItRelevanceAssessment:
relevant: bool
signals: tuple[str, ...]
reason: str
def assess_it_relevance(title: str) -> ItRelevanceAssessment:
normalized_title = normalize_token(title)
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
if blocked_signal:
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
)
signals = tuple(
term
for term in IT_TITLE_TERMS
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
)
if signals:
return ItRelevanceAssessment(
relevant=True,
signals=signals[:4],
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Geen aantoonbaar IT-signaal in de functietitel.",
)
def profile_requires_it_focus(profile: SearchProfile) -> bool:
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
normalized = normalize_token(configured)
return any(
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
for term in IT_PROFILE_TERMS
)
def it_relevance_query(prefix: str = "") -> Q:
"""Return the database equivalent of the conservative title-led classifier."""
return Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN}) & ~Q(
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
)
+12
View File
@@ -14,6 +14,7 @@ from apps.jobs.services.geocoding import resolve_cached_location
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
from .relevance import assess_it_relevance, profile_requires_it_focus
@dataclass(frozen=True)
@@ -227,6 +228,9 @@ def _hard_exclusions(
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
if conflicts:
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
it_relevance = assess_it_relevance(job.original_title)
if profile_requires_it_focus(profile) and not it_relevance.relevant:
reasons.append(it_relevance.reason)
return reasons, distance.distance_confidence
@@ -292,6 +296,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
distance = _distance(job, profile)
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
it_relevance = assess_it_relevance(job.original_title)
title_fit = _title_fit(job, profile)
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
features = job.analysis_features or {}
@@ -381,6 +386,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns: list[str] = []
if title_fit >= 0.75:
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
if it_relevance.relevant:
positives.append("IT-focus bevestigd via de functietitel.")
if present_skills:
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
if job.direct_employer and not job.recruiter:
@@ -435,6 +442,11 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns=concerns,
hard_exclusions=exclusions,
evidence={
"it_relevance": {
"relevant": it_relevance.relevant,
"signals": list(it_relevance.signals),
"reason": it_relevance.reason,
},
"distance_km": distance.exact_distance_km,
"distance_has_data": distance.has_distance_data,
"distance_exact": distance.exact_distance_km is not None,
+90 -3
View File
@@ -3,10 +3,20 @@ 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, DecimalField, OuterRef, Q, Subquery
from django.db.models import (
CharField,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
Subquery,
)
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, UpdateView
@@ -21,6 +31,7 @@ from .services.applications import (
track_application_changes,
)
from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
class JobListView(LoginRequiredMixin, ListView):
@@ -29,6 +40,15 @@ class JobListView(LoginRequiredMixin, ListView):
context_object_name = "jobs"
paginate_by = 30
VALID_RECOMMENDATIONS = {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
ScoreRun.Recommendation.HIDDEN,
}
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
VALID_SORTS = {"match", "newest", "closing"}
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
@@ -48,10 +68,27 @@ class JobListView(LoginRequiredMixin, ListView):
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()
),
)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").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()
if recommendation not in self.VALID_MATCH_FILTERS:
recommendation = "radar"
if query:
queryset = queryset.filter(
Q(original_title__icontains=query)
@@ -63,7 +100,51 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status)
if workplace:
queryset = queryset.filter(workplace_type=workplace)
return queryset.order_by("-first_seen")
if focus != "all":
queryset = queryset.filter(it_relevance_query())
if profile and recommendation == "radar":
queryset = queryset.filter(
Q(
match_recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
]
)
| Q(match_recommendation__isnull=True)
)
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
queryset = queryset.filter(match_recommendation=recommendation)
if sort not in self.VALID_SORTS:
sort = "match"
if sort == "newest":
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
if sort == "closing":
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
it_jobs = active_jobs.filter(it_relevance_query())
context.update(
{
"active_profile": SearchProfile.objects.filter(
user=self.request.user, is_active=True
).first(),
"active_job_count": active_jobs.count(),
"it_job_count": it_jobs.count(),
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
"current_focus": self.request.GET.get("focus", "it"),
"current_match": (
self.request.GET.get("match", "radar")
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
else "radar"
),
"current_sort": self.request.GET.get("sort", "match"),
}
)
return context
class JobDetailView(LoginRequiredMixin, DetailView):
@@ -113,7 +194,13 @@ def job_feedback(request, pk):
)
messages.success(request, "Actie opgeslagen.")
next_url = request.POST.get("next")
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
class ApplicationListView(LoginRequiredMixin, ListView):