This commit is contained in:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user