Initial deploy setup
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
View File
+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"
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Any
from django.http import HttpRequest
def navigation_context(request: HttpRequest) -> dict[str, Any]:
return {
"app_name": "VacatureRadar",
"app_version": "0.1.0",
}
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from dataclasses import asdict, dataclass
from django.db import connections
from django.db.utils import OperationalError
@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 OperationalError as exc:
ok = False
checks["database"] = f"error:{exc.__class__.__name__}"
return HealthResult(ok=ok, checks=checks)
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from collections.abc import Callable
from django.http import HttpRequest, HttpResponse
class SecurityHeadersMiddleware:
"""Kleine CSP zonder externe assets; vacature-HTML wordt bovendien gesanitized."""
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")
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
+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
def _client_ip(request: HttpRequest) -> str:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "").strip()
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "unknown").strip() or "unknown"
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))
+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"),
]
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.contrib.auth.views import LoginView
from django.db.models import Count, Q
from django.http import JsonResponse
from django.utils import timezone
from django.views.generic import TemplateView
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.sources.models import Source
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from .health import readiness
from apps.sources.services.health import collect_source_health
class SecurityAwareLoginView(LoginView):
template_name = "registration/login.html"
@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 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)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer", "profile")
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
seen: set[str] = set()
cards = []
for score in latest_scores[:250]:
key = str(score.job_id)
if key in seen:
continue
seen.add(key)
cards.append(score)
if len(cards) >= 20:
break
context.update(
{
"score_cards": cards,
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
"new_today": JobPosting.objects.filter(
first_seen__date=timezone.localdate()
).count(),
"applications_open": Application.objects.exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
).count(),
"source_counts": Source.objects.aggregate(
total=Count("id"),
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
),
}
)
return context
class SystemStatusView(LoginRequiredMixin, TemplateView):
template_name = "system/status.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["health"] = readiness()
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
context["source_health"] = collect_source_health()
return context