Publish curated VacatureRadar source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:19:10 +02:00
commit e29774cb7f
338 files changed
+32699

No files matched your search

View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
# Kern bevat alleen abstracte modellen.
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.core"
verbose_name = "Kern"
+45
View File
@@ -0,0 +1,45 @@
"""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
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from typing import Any
from django.conf import settings
from django.http import HttpRequest
from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]:
match = request.resolver_match
route_key = (match.namespace, match.url_name) if match else (None, None)
page_contexts = {
("dashboard", "today"): "Dagoverzicht",
("dashboard", "system"): "Automatisering",
("jobs", "list"): "Vacatureverkenner",
("jobs", "detail"): "Vacatureanalyse",
("jobs", "applications"): "Sollicitatiepipeline",
("jobs", "application-edit"): "Sollicitatiedossier",
("jobs", "skill-insights"): "Skillsradar",
("jobs", "activity"): "Activiteit",
("jobs", "employers"): "Werkgevers",
("jobs", "employer-detail"): "Werkgeveranalyse",
("profiles", "list"): "Zoekprofiel",
("profiles", "edit"): "Radarconfiguratie",
("sources", "list"): "Bronbeheer",
}
context = {
"app_name": "VacatureRadar",
"app_version": settings.VACATURERADAR_VERSION,
"app_owner_name": settings.VACATURERADAR_OWNER_NAME,
"page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"),
}
if request.user.is_authenticated:
context["navigation_profile"] = (
SearchProfile.objects.filter(user=request.user, is_active=True)
.only("id", "name")
.first()
)
context["demo_read_only"] = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and request.user.username == settings.DEMO_USERNAME
)
return context
+102
View File
@@ -0,0 +1,102 @@
"""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
+33
View File
@@ -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",
}
)
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass
from django.conf import settings
from django.core.cache import cache
from django.db import connections
from django.db.utils import DatabaseError
@dataclass(frozen=True)
class HealthResult:
ok: bool
checks: dict[str, str]
def to_dict(self) -> dict[str, object]:
return asdict(self)
def readiness() -> HealthResult:
checks: dict[str, str] = {}
ok = True
try:
with connections["default"].cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
checks["database"] = "ok"
except DatabaseError as exc:
ok = False
checks["database"] = f"error:{exc.__class__.__name__}"
if getattr(settings, "HEALTHCHECK_REQUIRE_CACHE", False):
key = f"health:{secrets.token_hex(8)}"
try:
cache.set(key, "ok", timeout=10)
if cache.get(key) != "ok":
raise RuntimeError("cache roundtrip failed")
cache.delete(key)
checks["cache"] = "ok"
except Exception as exc: # Health boundary: report category, never payload/details.
ok = False
checks["cache"] = f"error:{exc.__class__.__name__}"
return HealthResult(ok=ok, checks=checks)
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from collections.abc import Callable
from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from scripts.migration_worker_gate import hold_active
class MigrationMaintenanceMiddleware:
"""Block candidate traffic before durable migration activation, including GET writes."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
try:
held = hold_active()
except (OSError, RuntimeError):
held = True
health = request.method in {"GET", "HEAD"} and request.path_info in {
"/health/ready/", "/health/live/",
}
if held and not health:
response = JsonResponse({"error": "migration_maintenance"}, status=503)
response["Retry-After"] = "30"
response["Cache-Control"] = "no-store"
return response
return self.get_response(request)
class DemoReadOnlyMiddleware:
"""Prevent a shared public demo account from mutating application data."""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
user = getattr(request, "user", None)
is_demo = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and user
and getattr(user, "is_authenticated", False)
and getattr(user, "username", "") == settings.DEMO_USERNAME
)
if not is_demo or request.method in self.SAFE_METHODS:
return self.get_response(request)
if request.path_info == reverse("logout"):
return self.get_response(request)
if "application/json" in request.headers.get("Accept", ""):
return JsonResponse(
{
"ok": False,
"error": "demo_read_only",
"message": "De publieke demo is alleen-lezen.",
},
status=403,
)
messages.warning(
request,
"De publieke demo is alleen-lezen; wijzigingen zijn uitgeschakeld.",
)
referer = request.META.get("HTTP_REFERER", "")
if referer and url_has_allowed_host_and_scheme(
referer,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(referer)
return redirect("dashboard:today")
class SecurityHeadersMiddleware:
"""Small CSP without external assets; vacancy HTML is sanitized separately."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
response = self.get_response(request)
response.headers.setdefault(
"Content-Security-Policy",
"; ".join(
[
"default-src 'self'",
"img-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"script-src 'self'",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
]
),
)
response.headers.setdefault(
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
if not getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
return response
+11
View File
@@ -0,0 +1,11 @@
from __future__ import annotations
from django.db import models
class TimeStampedModel(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import ipaddress
from functools import lru_cache
from django.conf import settings
from django.http import HttpRequest
@lru_cache(maxsize=32)
def _trusted_networks(
values: tuple[str, ...],
) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
return tuple(ipaddress.ip_network(value, strict=False) for value in values)
def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
candidate = value.strip()
if not candidate:
return None
try:
return ipaddress.ip_address(candidate)
except ValueError:
return None
def client_ip(request: HttpRequest) -> str:
"""Return a proxy-aware client IP without trusting arbitrary forwarded headers."""
remote = _parse_ip(request.META.get("REMOTE_ADDR", ""))
if remote is None:
return "unknown"
networks = _trusted_networks(tuple(getattr(settings, "TRUSTED_PROXY_CIDRS", ())))
if not networks or not any(remote in network for network in networks):
return str(remote)
forwarded = [
parsed
for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
if (parsed := _parse_ip(item)) is not None
]
if not forwarded:
return str(remote)
# Walk from the nearest hop back to the client. The first address outside
# the explicitly trusted proxy ranges is the client address.
for candidate in reversed([*forwarded, remote]):
if not any(candidate in network for network in networks):
return str(candidate)
return str(forwarded[0])
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from dataclasses import dataclass
from django.core.cache import cache
from django.http import HttpRequest
from django.utils import timezone
from apps.core.network import client_ip
def _identity_for_user(request: HttpRequest) -> str:
user = getattr(request, "user", None)
if user and getattr(user, "is_authenticated", False):
return f"user:{user.pk}"
username = (
(request.POST.get("username", "") if request.method == "POST" else "").strip().lower()
)
return f"anon:{username or client_ip(request)}"
@dataclass(frozen=True)
class RateLimitState:
is_blocked: bool
remaining_seconds: int
attempts: int
def _attempts_key(namespace: str, identity: str) -> str:
return f"core-rate-limit:{namespace}:{identity}:attempts"
def _block_key(namespace: str, identity: str) -> str:
return f"core-rate-limit:{namespace}:{identity}:block"
def is_rate_limited(
request: HttpRequest,
*,
namespace: str,
max_attempts: int,
window_seconds: int,
block_seconds: int,
) -> RateLimitState:
now = timezone.now().timestamp()
identity = _identity_for_user(request)
block_until = cache.get(_block_key(namespace, identity))
if block_until and isinstance(block_until, int | float) and block_until > now:
return RateLimitState(True, max(0, int(block_until - now)), 0)
if cache.get(_attempts_key(namespace, identity), 0) >= max_attempts:
cache.set(
_block_key(namespace, identity),
now + max(block_seconds, 1),
timeout=block_seconds,
)
cache.delete(_attempts_key(namespace, identity))
return RateLimitState(True, max(block_seconds, 1), 0)
return RateLimitState(False, 0, int(cache.get(_attempts_key(namespace, identity), 0)))
def register_rate_limit_failure(
request: HttpRequest,
*,
namespace: str,
max_attempts: int,
window_seconds: int,
block_seconds: int,
) -> RateLimitState:
remaining = is_rate_limited(
request,
namespace=namespace,
max_attempts=max_attempts,
window_seconds=window_seconds,
block_seconds=block_seconds,
)
if remaining.is_blocked:
return remaining
identity = _identity_for_user(request)
attempts = int(cache.get(_attempts_key(namespace, identity), 0)) + 1
if attempts >= max_attempts:
now = timezone.now().timestamp()
cache.set(
_block_key(namespace, identity), now + max(block_seconds, 1), timeout=block_seconds
)
cache.delete(_attempts_key(namespace, identity))
return RateLimitState(True, max(block_seconds, 1), attempts)
cache.set(_attempts_key(namespace, identity), attempts, timeout=max(window_seconds, 1))
return RateLimitState(False, 0, attempts)
def clear_rate_limit(request: HttpRequest, *, namespace: str) -> None:
identity = _identity_for_user(request)
cache.delete(_attempts_key(namespace, identity))
cache.delete(_block_key(namespace, identity))
+1
View File
@@ -0,0 +1 @@
"""Read services for the application cockpit."""
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.core.cache import cache
from django.db.models import Count, Q, Sum
from django.utils import timezone
from apps.jobs.models import JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..health import readiness
from ..tasks import RUNTIME_HEARTBEAT_KEY
def _runtime_heartbeat_state() -> dict[str, Any]:
if settings.CELERY_TASK_ALWAYS_EAGER:
return {"label": "Eager/lokaal", "healthy": True, "observed_at": None}
raw_timestamp = cache.get(RUNTIME_HEARTBEAT_KEY)
if not raw_timestamp:
return {"label": "Geen recente heartbeat", "healthy": False, "observed_at": None}
try:
observed_at = timezone.datetime.fromisoformat(str(raw_timestamp))
if timezone.is_naive(observed_at):
observed_at = timezone.make_aware(observed_at)
except (TypeError, ValueError):
return {"label": "Ongeldige heartbeat", "healthy": False, "observed_at": None}
age_seconds = max(0, int((timezone.now() - observed_at).total_seconds()))
return {
"label": f"Actief · {age_seconds}s geleden"
if age_seconds <= 150
else "Heartbeat verouderd",
"healthy": age_seconds <= 150,
"observed_at": observed_at,
}
def build_automation_cockpit() -> dict[str, Any]:
"""Build an honest operational read model without probing unsafe hidden endpoints."""
since = timezone.now() - timedelta(hours=24)
recent_runs = SourceRun.objects.filter(started_at__gte=since)
run_metrics = recent_runs.aggregate(
discovered=Sum("discovered_count"),
extracted=Sum("extracted_count"),
created=Sum("created_count"),
updated=Sum("updated_count"),
duplicates=Sum("duplicate_count"),
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
)
notification_counts = {
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
"sent_24h": DigestOutbox.objects.filter(
status=DigestOutbox.Status.SENT, sent_at__gte=since
).count()
+ ReminderOutbox.objects.filter(
status=ReminderOutbox.Status.SENT, sent_at__gte=since
).count(),
}
source_total = Source.objects.count()
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
phase_rows = [
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
{
"key": "extract",
"label": "Extraheren",
"value": run_metrics["extracted"] or 0,
"unit": "items",
},
{
"key": "normalize",
"label": "Normaliseren",
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
"unit": "records",
},
{
"key": "dedupe",
"label": "Dedupliceren",
"value": run_metrics["duplicates"] or 0,
"unit": "herkend",
},
{
"key": "filter",
"label": "IT-filter",
"value": active_jobs.exclude(it_relevance_query()).count(),
"unit": "uit beeld",
},
{
"key": "analyze",
"label": "Analyseren",
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
"unit": "scores / 24u",
},
{
"key": "notify",
"label": "Notificeren",
"value": notification_counts["sent_24h"],
"unit": "verzonden / 24u",
},
]
runtime_heartbeat = _runtime_heartbeat_state()
return {
"health": readiness(),
"source_total": source_total,
"source_active": source_active,
"run_metrics": run_metrics,
"run_count": recent_runs.count(),
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
"review_jobs": ScoreRun.objects.filter(
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
job__status=JobPosting.Status.ACTIVE,
).count(),
"notification_counts": notification_counts,
"phase_rows": phase_rows,
"recent_runs": list(
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
),
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
"queue_state": "Eager/lokaal"
if settings.CELERY_TASK_ALWAYS_EAGER
else "Diepte niet beschikbaar",
"runtime_heartbeat": runtime_heartbeat,
"ai_state": "Geconfigureerd"
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
else "Niet geconfigureerd",
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
"browser_state": "Geen aparte browserextractor geconfigureerd",
}
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import base64
from django.conf import settings
class Microsoft365OAuthError(RuntimeError):
"""Safe OAuth boundary error that never includes tokens or client secrets."""
def acquire_exchange_access_token(*, client_factory=None) -> str:
tenant_id = settings.M365_TENANT_ID.strip()
client_id = settings.M365_CLIENT_ID.strip()
client_secret = settings.M365_CLIENT_SECRET.strip()
if not all((tenant_id, client_id, client_secret)):
raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.")
if client_factory is None:
import msal
client_factory = msal.ConfidentialClientApplication
factory = client_factory
client = factory(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret,
)
result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE])
token = result.get("access_token") if isinstance(result, dict) else None
if not isinstance(token, str) or not token:
category = "unknown"
if isinstance(result, dict):
category = str(result.get("error") or "unknown")[:80]
raise Microsoft365OAuthError(
f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})."
)
return token
def xoauth2_bytes(username: str, access_token: str) -> bytes:
if not username or "\x00" in username or "\r" in username or "\n" in username:
raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.")
return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode()
def xoauth2_b64(username: str, access_token: str) -> str:
return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii")
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from celery import shared_task
from django.core.cache import cache
from django.utils import timezone
RUNTIME_HEARTBEAT_KEY = "vacatureradar:runtime:heartbeat"
RUNTIME_HEARTBEAT_TTL_SECONDS = 180
@shared_task(name="apps.core.tasks.record_runtime_heartbeat", ignore_result=True)
def record_runtime_heartbeat() -> str:
"""Record proof that beat dispatch and a worker execution both succeeded."""
timestamp = timezone.now().isoformat()
cache.set(RUNTIME_HEARTBEAT_KEY, timestamp, timeout=RUNTIME_HEARTBEAT_TTL_SECONDS)
return timestamp
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from .views import SystemStatusView, TodayView, health_live, health_ready
urlpatterns = [
path("", TodayView.as_view(), name="today"),
path("system/", SystemStatusView.as_view(), name="system"),
path("health/live/", health_live, name="health-live"),
path("health/ready/", health_ready, name="health-ready"),
]
+271
View File
@@ -0,0 +1,271 @@
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 HttpResponse, 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
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:
minutes = max(1, (seconds + 59) // 60)
return f"{minutes} minuut" if minutes == 1 else f"{minutes} minuten"
def dispatch(self, request, *args, **kwargs):
if request.method == "POST":
state = is_rate_limited(
request,
namespace="login",
max_attempts=settings.AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
window_seconds=settings.AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS,
block_seconds=settings.AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS,
)
if state.is_blocked:
messages.error(
request,
(
"Te veel inlogpogingen op dit account. Wacht "
f"{self._remaining_minutes(state.remaining_seconds)} en probeer daarna "
"opnieuw."
),
)
return self.form_invalid(self.get_form())
return super().dispatch(request, *args, **kwargs)
def form_invalid(self, form):
state = register_rate_limit_failure(
self.request,
namespace="login",
max_attempts=settings.AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
window_seconds=settings.AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS,
block_seconds=settings.AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS,
)
if state.is_blocked:
messages.error(
self.request,
(
"Te veel inlogpogingen. Wacht "
f"{self._remaining_minutes(state.remaining_seconds)} en probeer daarna opnieuw."
),
)
return super().form_invalid(form)
def form_valid(self, form):
clear_rate_limit(self.request, namespace="login")
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)
request.session.set_expiry(settings.DEMO_SESSION_SECONDS)
next_url = _safe_next(request, request.POST.get("next"))
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
def robots_txt(request):
if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
content = "User-agent: *\nAllow: /\n"
else:
content = "User-agent: *\nDisallow: /\n"
return HttpResponse(content, content_type="text/plain; charset=utf-8")
def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"})
def health_ready(request):
result = readiness()
return JsonResponse(result.to_dict(), status=200 if result.ok else 503)
class TodayView(LoginRequiredMixin, TemplateView):
template_name = "dashboard/today.html"
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.update(build_dashboard_cockpit(self.request.user, profile))
context["active_profile"] = profile
return context
class SystemStatusView(LoginRequiredMixin, TemplateView):
template_name = "system/status.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(build_automation_cockpit())
context["source_health"] = collect_source_health()
return context