Files
VacatureRadar/apps/core/views.py
T
Jens 029df89265
deploy / deploy (push) Canceled after 0s
feat: ship premium IT-focused vacancy radar
2026-07-22 15:16:15 +02:00

157 lines
6.2 KiB
Python

from __future__ import annotations
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView
from django.db.models import Count, OuterRef, Q, Subquery
from django.http import JsonResponse
from django.utils import timezone
from django.views.generic import TemplateView
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from apps.sources.services.health import collect_source_health
from .health import readiness
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)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer", "profile")
.filter(
profile=profile,
job__status=JobPosting.Status.ACTIVE,
hard_exclusions=[],
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
)
.filter(it_relevance_query("job__"), pk=Subquery(latest_score_id))
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
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
active_it_jobs = JobPosting.objects.filter(
Q(status=JobPosting.Status.ACTIVE) & it_relevance_query()
)
all_active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
context.update(
{
"score_cards": cards,
"priority_applications": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": all_active_jobs.exclude(it_relevance_query()).count(),
"new_today": active_it_jobs.filter(first_seen__date=timezone.localdate()).count(),
"applications_open": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.count(),
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
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