Publish curated VacatureRadar source snapshot
This commit is contained in:
commit
e29774cb7f
338 files changed
+32699
No files matched your search
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
# Kern bevat alleen abstracte modellen.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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])
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
"""Read services for the application cockpit."""
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user