feat: replace pipeline profile and source health

This commit is contained in:
Jens
2026-07-22 20:10:34 +02:00
parent 3371455255
commit 4fa2a34447
14 changed files with 282 additions and 130 deletions
+25
View File
@@ -274,6 +274,31 @@ def track_application_changes(
return events
def change_application_status(*, application: Application, user, status: str) -> Application:
"""Persist an accessible pipeline status change and its audit event."""
valid_statuses = {value for value, _label in Application.Status.choices}
if status not in valid_statuses:
raise ValueError("Ongeldige sollicitatiestatus.")
previous = application.status
if previous == status:
return application
with transaction.atomic():
application.status = status
update_fields = ["status", "updated_at"]
if status == Application.Status.APPLIED and application.applied_at is None:
application.applied_at = timezone.now()
update_fields.append("applied_at")
application.save(update_fields=update_fields)
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
metadata={"from": previous, "to": status},
)
return application
def build_print_html(application: Application) -> str:
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
job = application.job
+2
View File
@@ -9,6 +9,7 @@ from .views import (
application_delete,
application_export,
application_print,
application_status,
job_feedback,
)
@@ -17,6 +18,7 @@ urlpatterns = [
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
path("applications/", ApplicationListView.as_view(), name="applications"),
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
path("applications/<int:pk>/status/", application_status, name="application-status"),
path("applications/<int:pk>/export/", application_export, name="application-export"),
path("applications/<int:pk>/print/", application_print, name="application-print"),
path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
+38 -3
View File
@@ -7,6 +7,7 @@ from django.db.models import Count, F, Q
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
@@ -18,6 +19,7 @@ from .models import Application, Feedback, JobPosting, ScoreRun
from .services.applications import (
build_application_export,
build_print_html,
change_application_status,
delete_application_dossier,
track_application_changes,
)
@@ -206,8 +208,10 @@ class ApplicationListView(LoginRequiredMixin, ListView):
context_object_name = "applications"
def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related(
"job", "job__employer"
return (
Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
)
def get_context_data(self, **kwargs):
@@ -222,6 +226,16 @@ class ApplicationListView(LoginRequiredMixin, ListView):
}
for status, label in Application.Status.choices
]
context["status_choices"] = Application.Status.choices
context["open_count"] = sum(
item.status not in {Application.Status.REJECTED, Application.Status.WITHDRAWN}
for item in applications
)
context["follow_up_count"] = sum(
bool(item.follow_up_date and item.follow_up_date <= timezone.localdate())
for item in applications
)
context["today"] = timezone.localdate()
return context
@@ -231,7 +245,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
template_name = "applications/edit.html"
def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related("job")
return (
Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
)
def form_valid(self, form):
previous = (
@@ -269,6 +287,23 @@ def application_export(request, pk: int):
return response
@login_required
@require_POST
def application_status(request, pk: int):
application = get_object_or_404(Application, pk=pk, user=request.user)
try:
change_application_status(
application=application,
user=request.user,
status=request.POST.get("status", ""),
)
except ValueError as exc:
messages.error(request, str(exc))
else:
messages.success(request, "Pipelinestatus bijgewerkt.")
return redirect("jobs:applications")
@login_required
def application_print(request, pk: int):
application = get_object_or_404(
+17
View File
@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView
from apps.jobs.models import JobPosting, ScoreRun
from .forms import SearchProfileForm
from .models import SearchProfile
from .services import resolve_profile_home_location, save_profile_revision
@@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
def get_queryset(self):
return SearchProfile.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["preview_score"] = (
ScoreRun.objects.filter(
profile=self.object,
job__status=JobPosting.Status.ACTIVE,
hard_exclusions=[],
)
.select_related("job", "job__employer")
.order_by("-created_at")
.first()
)
context["recent_revisions"] = list(self.object.revisions.all()[:4])
return context
def form_valid(self, form):
response = super().form_valid(form)
location_result = resolve_profile_home_location(self.object)
+13 -3
View File
@@ -4,7 +4,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.db.models import Count, Prefetch, Q
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -17,7 +17,7 @@ from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rat
from apps.profiles.models import SearchProfile
from .forms import MailboxConnectionForm, ManualImportForm
from .models import MailboxConnection, Source, SourcePolicyReview
from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun
from .platforms import PLATFORM_ALERTS
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
@@ -27,7 +27,14 @@ from .tasks import fetch_source, poll_mailbox
def _source_list_queryset():
return (
Source.objects.prefetch_related("policy_reviews")
Source.objects.prefetch_related(
"policy_reviews",
Prefetch(
"runs",
queryset=SourceRun.objects.order_by("-started_at")[:1],
to_attr="latest_runs",
),
)
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
.filter(
Q(metadata__hidden_from_source_list__isnull=True)
@@ -69,6 +76,9 @@ def _manual_import_context(
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
"source_total_count": base_queryset.count(),
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
"failed_source_count": Source.objects.filter(
Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0)
).count(),
"watchlist_sources": watchlist_queryset,
"watchlist_count": watchlist_queryset.count(),
"manual_import_form": form,