Refresh
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""Authenticatie-backends voor VacatureRadar.
|
||||
|
||||
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
|
||||
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
|
||||
gebruikersnaam te breken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class EmailOrUsernameModelBackend(ModelBackend):
|
||||
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
user_model = get_user_model()
|
||||
if username is None:
|
||||
username = kwargs.get(user_model.USERNAME_FIELD)
|
||||
if username is None or password is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = user_model.objects.get(
|
||||
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username})
|
||||
| Q(email__iexact=username)
|
||||
)
|
||||
except user_model.DoesNotExist:
|
||||
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
|
||||
user_model().set_password(password)
|
||||
return None
|
||||
except user_model.MultipleObjectsReturned:
|
||||
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
|
||||
user = (
|
||||
user_model.objects.filter(
|
||||
**{f"{user_model.USERNAME_FIELD}__iexact": username}
|
||||
)
|
||||
.order_by("id")
|
||||
.first()
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if user.check_password(password) and self.user_can_authenticate(user):
|
||||
return user
|
||||
return None
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
|
||||
|
||||
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
|
||||
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
|
||||
bestaande tests en de kernapplicatie volledig werken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class EntraConfigError(RuntimeError):
|
||||
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntraConfig:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
tenant_id: str
|
||||
scopes: list[str]
|
||||
allowed_domains: tuple[str, ...]
|
||||
redirect_uri: str | None
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return f"https://login.microsoftonline.com/{self.tenant_id}"
|
||||
|
||||
|
||||
def entra_enabled() -> bool:
|
||||
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
|
||||
|
||||
|
||||
def load_config() -> EntraConfig:
|
||||
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
|
||||
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
|
||||
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
|
||||
if not (client_id and client_secret and tenant_id):
|
||||
raise EntraConfigError(
|
||||
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
|
||||
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
|
||||
)
|
||||
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
|
||||
allowed = tuple(
|
||||
d.lower().lstrip("@")
|
||||
for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", [])
|
||||
if d
|
||||
)
|
||||
return EntraConfig(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_id=tenant_id,
|
||||
scopes=scopes,
|
||||
allowed_domains=allowed,
|
||||
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
|
||||
)
|
||||
|
||||
|
||||
def _build_app(config: EntraConfig):
|
||||
try:
|
||||
import msal # lazy import: alleen nodig als SSO aan staat
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
|
||||
raise EntraConfigError(
|
||||
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
|
||||
"om Entra ID SSO te gebruiken."
|
||||
) from exc
|
||||
|
||||
return msal.ConfidentialClientApplication(
|
||||
client_id=config.client_id,
|
||||
client_credential=config.client_secret,
|
||||
authority=config.authority,
|
||||
)
|
||||
|
||||
|
||||
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
|
||||
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
|
||||
app = _build_app(config)
|
||||
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
|
||||
|
||||
|
||||
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
|
||||
"""Wissel de teruggekeerde code in voor tokens en claims."""
|
||||
app = _build_app(config)
|
||||
return app.acquire_token_by_auth_code_flow(flow, auth_response)
|
||||
|
||||
|
||||
def extract_identity(token_result: dict) -> tuple[str, str]:
|
||||
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
|
||||
claims = token_result.get("id_token_claims") or {}
|
||||
email = (
|
||||
claims.get("preferred_username")
|
||||
or claims.get("email")
|
||||
or claims.get("upn")
|
||||
or ""
|
||||
).strip()
|
||||
name = (claims.get("name") or "").strip()
|
||||
return email, name
|
||||
|
||||
|
||||
def domain_allowed(config: EntraConfig, email: str) -> bool:
|
||||
if not config.allowed_domains:
|
||||
return True
|
||||
domain = email.rsplit("@", 1)[-1].lower()
|
||||
return domain in config.allowed_domains
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Formulieren voor authenticatie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class EmailAuthenticationForm(AuthenticationForm):
|
||||
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
|
||||
|
||||
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
|
||||
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
|
||||
"""
|
||||
|
||||
error_messages = {
|
||||
**AuthenticationForm.error_messages,
|
||||
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
field = self.fields["username"]
|
||||
field.label = _("E-mailadres")
|
||||
field.widget.attrs.update(
|
||||
{
|
||||
"type": "email",
|
||||
"autocomplete": "username",
|
||||
"autocapitalize": "none",
|
||||
"spellcheck": "false",
|
||||
"placeholder": "naam@organisatie.nl",
|
||||
}
|
||||
)
|
||||
@@ -1,12 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model, login
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import 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 TemplateView
|
||||
|
||||
from apps.core import entra as entra_sso
|
||||
from apps.core.forms import EmailAuthenticationForm
|
||||
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
|
||||
from apps.jobs.services.cockpit import build_dashboard_cockpit
|
||||
from apps.profiles.models import SearchProfile
|
||||
@@ -15,9 +24,32 @@ from apps.sources.services.health import collect_source_health
|
||||
from .health import readiness
|
||||
from .services.automation import build_automation_cockpit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
|
||||
SESSION_ENTRA_FLOW = "entra_auth_flow"
|
||||
SESSION_ENTRA_NEXT = "entra_next"
|
||||
|
||||
|
||||
def _safe_next(request, candidate: str | None) -> str | None:
|
||||
if candidate and url_has_allowed_host_and_scheme(
|
||||
candidate,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
class SecurityAwareLoginView(LoginView):
|
||||
template_name = "registration/login.html"
|
||||
authentication_form = EmailAuthenticationForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["entra_enabled"] = entra_sso.entra_enabled()
|
||||
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _remaining_minutes(seconds: int) -> str:
|
||||
@@ -68,6 +100,142 @@ class SecurityAwareLoginView(LoginView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def _split_name(full_name: str) -> tuple[str, str]:
|
||||
parts = full_name.split()
|
||||
if not parts:
|
||||
return "", ""
|
||||
if len(parts) == 1:
|
||||
return parts[0][:150], ""
|
||||
return parts[0][:150], " ".join(parts[1:])[:150]
|
||||
|
||||
|
||||
def _unique_username(base: str) -> str:
|
||||
user_model = get_user_model()
|
||||
base = (base or "gebruiker")[:140].strip() or "gebruiker"
|
||||
candidate = base
|
||||
suffix = 1
|
||||
while user_model.objects.filter(username__iexact=candidate).exists():
|
||||
suffix += 1
|
||||
candidate = f"{base}-{suffix}"
|
||||
return candidate
|
||||
|
||||
|
||||
def _get_or_create_sso_user(email: str, full_name: str):
|
||||
user_model = get_user_model()
|
||||
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
|
||||
first, last = _split_name(full_name)
|
||||
if user is None:
|
||||
user = user_model(
|
||||
username=_unique_username(email.split("@", 1)[0]),
|
||||
email=email,
|
||||
first_name=first,
|
||||
last_name=last,
|
||||
)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
elif full_name and not (user.first_name or user.last_name):
|
||||
user.first_name, user.last_name = first, last
|
||||
user.save(update_fields=["first_name", "last_name"])
|
||||
return user
|
||||
|
||||
|
||||
@require_POST
|
||||
def entra_login(request):
|
||||
"""Start de Entra ID authorization-code flow."""
|
||||
if not entra_sso.entra_enabled():
|
||||
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
|
||||
return redirect("login")
|
||||
try:
|
||||
config = entra_sso.load_config()
|
||||
redirect_uri = config.redirect_uri or request.build_absolute_uri(
|
||||
reverse("entra-callback")
|
||||
)
|
||||
flow = entra_sso.build_auth_flow(config, redirect_uri)
|
||||
except entra_sso.EntraConfigError as exc:
|
||||
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
|
||||
return redirect("login")
|
||||
|
||||
request.session[SESSION_ENTRA_FLOW] = flow
|
||||
next_url = _safe_next(request, request.POST.get("next"))
|
||||
if next_url:
|
||||
request.session[SESSION_ENTRA_NEXT] = next_url
|
||||
return redirect(flow["auth_uri"])
|
||||
|
||||
|
||||
def entra_callback(request):
|
||||
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
|
||||
if not entra_sso.entra_enabled():
|
||||
return redirect("login")
|
||||
|
||||
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
|
||||
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
|
||||
if not flow:
|
||||
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
|
||||
return redirect("login")
|
||||
|
||||
try:
|
||||
config = entra_sso.load_config()
|
||||
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
|
||||
except entra_sso.EntraConfigError as exc:
|
||||
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
|
||||
return redirect("login")
|
||||
|
||||
if "error" in result:
|
||||
logger.warning(
|
||||
"Entra ID-fout: %s", result.get("error_description") or result.get("error")
|
||||
)
|
||||
messages.error(request, "Inloggen via Entra ID is mislukt.")
|
||||
return redirect("login")
|
||||
|
||||
email, name = entra_sso.extract_identity(result)
|
||||
if not email:
|
||||
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
|
||||
return redirect("login")
|
||||
if not entra_sso.domain_allowed(config, email):
|
||||
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
|
||||
return redirect("login")
|
||||
|
||||
user = _get_or_create_sso_user(email, name)
|
||||
login(request, user, backend=SSO_BACKEND)
|
||||
clear_rate_limit(request, namespace="login")
|
||||
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
|
||||
@require_POST
|
||||
def demo_login(request):
|
||||
"""Log in als afgeschermd demo-account."""
|
||||
if not getattr(settings, "DEMO_MODE_ENABLED", False):
|
||||
messages.error(request, "De demomodus is niet ingeschakeld.")
|
||||
return redirect("login")
|
||||
|
||||
user_model = get_user_model()
|
||||
user, created = user_model.objects.get_or_create(
|
||||
username=settings.DEMO_USERNAME,
|
||||
defaults={
|
||||
"email": settings.DEMO_EMAIL,
|
||||
"first_name": "Demo",
|
||||
"last_name": "Gebruiker",
|
||||
},
|
||||
)
|
||||
if created:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
try:
|
||||
from apps.jobs.services.demo_seed import seed_demo_environment
|
||||
|
||||
seed_demo_environment(user)
|
||||
except Exception:
|
||||
# De demo mag nooit de login blokkeren.
|
||||
logger.exception("Kon de demo-omgeving niet seeden")
|
||||
|
||||
login(request, user, backend=SSO_BACKEND)
|
||||
next_url = _safe_next(request, request.POST.get("next"))
|
||||
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
|
||||
def health_live(request):
|
||||
return JsonResponse({"ok": True, "service": "vacatureradar"})
|
||||
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Vult een fictieve demo-omgeving voor het demo-account.
|
||||
|
||||
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
|
||||
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
|
||||
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
|
||||
niet opnieuw geseed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
|
||||
|
||||
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
|
||||
# skills_preferred, score, recommendation, positives, concerns)
|
||||
_DEMO_JOBS = [
|
||||
(
|
||||
"Infrastructure Engineer",
|
||||
"CloudNexus",
|
||||
"cloudnexus.example",
|
||||
"Hasselt",
|
||||
"Limburg",
|
||||
"hybrid",
|
||||
["Microsoft 365", "VMware", "Azure"],
|
||||
["Terraform"],
|
||||
Decimal("92.4"),
|
||||
"strong",
|
||||
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
|
||||
[],
|
||||
),
|
||||
(
|
||||
"Cloud Architect",
|
||||
"Skylink Solutions",
|
||||
"skylink.example",
|
||||
"Remote",
|
||||
"Vlaanderen",
|
||||
"remote",
|
||||
["Azure", "Kubernetes"],
|
||||
["Bicep", "Landing Zones"],
|
||||
Decimal("88.1"),
|
||||
"strong",
|
||||
["Volledig remote", "Azure-zwaartepunt sluit aan"],
|
||||
["Iets seniorder dan gevraagd"],
|
||||
),
|
||||
(
|
||||
"Systeembeheerder Microsoft 365",
|
||||
"Gemeente Genk",
|
||||
"genk.example",
|
||||
"Genk",
|
||||
"Limburg",
|
||||
"on_site",
|
||||
["Microsoft 365", "Active Directory"],
|
||||
["Intune"],
|
||||
Decimal("83.6"),
|
||||
"strong",
|
||||
["Publieke sector met stabiliteit", "M365-focus"],
|
||||
["Volledig op locatie"],
|
||||
),
|
||||
(
|
||||
"DevOps Engineer",
|
||||
"Datastroom BV",
|
||||
"datastroom.example",
|
||||
"Leuven",
|
||||
"Vlaams-Brabant",
|
||||
"hybrid",
|
||||
["CI/CD", "Docker", "Azure"],
|
||||
["GitLab", "Observability"],
|
||||
Decimal("77.9"),
|
||||
"possible",
|
||||
["Moderne toolchain", "Hybride"],
|
||||
["Reistijd naar Leuven"],
|
||||
),
|
||||
(
|
||||
"Network Engineer",
|
||||
"Noorder Telecom",
|
||||
"noordertelecom.example",
|
||||
"Antwerpen",
|
||||
"Antwerpen",
|
||||
"on_site",
|
||||
["Cisco", "Netwerkbeheer"],
|
||||
["Fortinet"],
|
||||
Decimal("70.4"),
|
||||
"possible",
|
||||
["Stevige netwerkrol"],
|
||||
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
|
||||
),
|
||||
(
|
||||
"IT Support Specialist",
|
||||
"MediCare Groep",
|
||||
"medicare.example",
|
||||
"Diepenbeek",
|
||||
"Limburg",
|
||||
"on_site",
|
||||
["Servicedesk", "Windows"],
|
||||
["ITIL"],
|
||||
Decimal("62.3"),
|
||||
"weak",
|
||||
["Dichtbij"],
|
||||
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _digest(*parts: str) -> str:
|
||||
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def demo_environment_seeded(user) -> bool:
|
||||
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
|
||||
if profile is None:
|
||||
return False
|
||||
from apps.jobs.models import ScoreRun
|
||||
|
||||
return ScoreRun.objects.filter(profile=profile).exists()
|
||||
|
||||
|
||||
def seed_demo_environment(user) -> None:
|
||||
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
|
||||
if demo_environment_seeded(user):
|
||||
return
|
||||
|
||||
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
|
||||
|
||||
profile, _ = SearchProfile.objects.get_or_create(
|
||||
user=user,
|
||||
name=_DEMO_PROFILE_NAME,
|
||||
defaults={
|
||||
"is_active": True,
|
||||
"home_municipality": "Hasselt",
|
||||
"home_latitude": Decimal("50.930700"),
|
||||
"home_longitude": Decimal("5.332500"),
|
||||
"max_distance_km": 60,
|
||||
"experience_years": 5,
|
||||
"learning_enabled": True,
|
||||
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
|
||||
"excluded_titles": ["sales", "recruiter"],
|
||||
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
|
||||
"allowed_employment_types": ["full_time", "permanent"],
|
||||
"preferred_workplace": ["hybrid", "remote", "on_site"],
|
||||
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
|
||||
},
|
||||
)
|
||||
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
|
||||
if not profile.is_active:
|
||||
profile.is_active = True
|
||||
profile.save(update_fields=["is_active"])
|
||||
|
||||
now = timezone.now()
|
||||
workplace_map = {
|
||||
"hybrid": JobPosting.Workplace.HYBRID,
|
||||
"remote": JobPosting.Workplace.REMOTE,
|
||||
"on_site": JobPosting.Workplace.ON_SITE,
|
||||
}
|
||||
created_jobs: list = []
|
||||
|
||||
for index, (
|
||||
title,
|
||||
employer_name,
|
||||
domain,
|
||||
municipality,
|
||||
region,
|
||||
workplace,
|
||||
req_skills,
|
||||
pref_skills,
|
||||
score,
|
||||
recommendation,
|
||||
positives,
|
||||
concerns,
|
||||
) in enumerate(_DEMO_JOBS):
|
||||
employer, _ = Employer.objects.get_or_create(
|
||||
normalized_name=employer_name.lower(),
|
||||
defaults={
|
||||
"name": employer_name,
|
||||
"domain": domain,
|
||||
"is_direct_employer": True,
|
||||
"is_recruiter": False,
|
||||
"confidence": 0.92,
|
||||
},
|
||||
)
|
||||
slug = _digest("demo-job", str(index), title, employer_name)
|
||||
job, job_created = JobPosting.objects.get_or_create(
|
||||
canonical_key=slug,
|
||||
defaults={
|
||||
"employer": employer,
|
||||
"original_title": title,
|
||||
"normalized_title": title.lower(),
|
||||
"job_family": "infrastructure",
|
||||
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
|
||||
"content_hash": _digest("demo-content", slug),
|
||||
"description_text": (
|
||||
f"{title} bij {employer_name} in {municipality}. "
|
||||
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
|
||||
),
|
||||
"raw_location": f"{municipality}, {region}",
|
||||
"region": region,
|
||||
"municipality": municipality,
|
||||
"workplace_type": workplace_map[workplace],
|
||||
"employment_types": ["full_time", "permanent"],
|
||||
"skills_required": req_skills,
|
||||
"skills_preferred": pref_skills,
|
||||
"direct_employer": True,
|
||||
"recruiter": False,
|
||||
"extraction_confidence": 0.9,
|
||||
"analysis_features": {
|
||||
"support_ratio": 0.1,
|
||||
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
|
||||
"experience_years_max": 6,
|
||||
},
|
||||
"status": JobPosting.Status.ACTIVE,
|
||||
"first_seen": now - timedelta(days=index + 1),
|
||||
"last_seen": now,
|
||||
"date_posted": now - timedelta(days=index + 1),
|
||||
},
|
||||
)
|
||||
created_jobs.append(job)
|
||||
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=score,
|
||||
confidence=Decimal("0.78"),
|
||||
recommendation=recommendation,
|
||||
components={
|
||||
"content": float(score) * 0.45,
|
||||
"skills": float(score) * 0.30,
|
||||
"location": float(score) * 0.25,
|
||||
},
|
||||
positives=positives,
|
||||
concerns=concerns,
|
||||
hard_exclusions=[],
|
||||
evidence={"demo": True},
|
||||
model_version="demo-seed",
|
||||
prompt_version="1.0.0",
|
||||
created_at=now - timedelta(hours=index),
|
||||
)
|
||||
|
||||
# Een kleine sollicitatiepijplijn voor de demo.
|
||||
if created_jobs:
|
||||
Application.objects.get_or_create(
|
||||
user=user,
|
||||
job=created_jobs[0],
|
||||
defaults={
|
||||
"status": Application.Status.INTERVIEW,
|
||||
"applied_at": now - timedelta(days=6),
|
||||
"follow_up_date": (now + timedelta(days=2)).date(),
|
||||
"contact_name": "Sofie Vandael",
|
||||
"contact_email": "sofie.vandael@cloudnexus.example",
|
||||
"notes": "Eerste gesprek gepland. Demo-data.",
|
||||
},
|
||||
)
|
||||
if len(created_jobs) > 3:
|
||||
Application.objects.get_or_create(
|
||||
user=user,
|
||||
job=created_jobs[3],
|
||||
defaults={
|
||||
"status": Application.Status.APPLIED,
|
||||
"applied_at": now - timedelta(days=2),
|
||||
"follow_up_date": (now + timedelta(days=5)).date(),
|
||||
"notes": "Sollicitatie verstuurd. Demo-data.",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user