feat: add automation activity and employer intelligence
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Read services for the application cockpit."""
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
},
|
||||
]
|
||||
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 "Geconfigureerd, worker niet gemeten",
|
||||
"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",
|
||||
}
|
||||
+2
-6
@@ -4,18 +4,16 @@ 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
|
||||
from django.http import JsonResponse
|
||||
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 JobPosting
|
||||
from apps.jobs.services.cockpit import build_dashboard_cockpit
|
||||
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
|
||||
from .services.automation import build_automation_cockpit
|
||||
|
||||
|
||||
class SecurityAwareLoginView(LoginView):
|
||||
@@ -95,8 +93,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
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.update(build_automation_cockpit())
|
||||
context["source_health"] = collect_source_health()
|
||||
return context
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||
from apps.profiles.models import ProfileRevision
|
||||
from apps.sources.models import SourceRun
|
||||
|
||||
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityEvent:
|
||||
occurred_at: datetime
|
||||
event_type: str
|
||||
title: str
|
||||
detail: str
|
||||
tone: str
|
||||
url: str
|
||||
object_label: str
|
||||
|
||||
|
||||
ACTIVITY_FILTERS = (
|
||||
("all", "Alle activiteit"),
|
||||
("source", "Bronruns"),
|
||||
("job", "Vacatures"),
|
||||
("application", "Sollicitaties"),
|
||||
("feedback", "Feedback"),
|
||||
("notification", "Notificaties"),
|
||||
("profile", "Profiel"),
|
||||
)
|
||||
|
||||
|
||||
def _source_events() -> list[ActivityEvent]:
|
||||
events = []
|
||||
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
|
||||
detail = (
|
||||
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||
f"{run.duplicate_count} duplicaat"
|
||||
)
|
||||
if run.error_category:
|
||||
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
run.started_at,
|
||||
"source",
|
||||
f"Bronrun {run.get_status_display().lower()}",
|
||||
detail,
|
||||
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
|
||||
reverse("sources:list") + f"#source-details-{run.source_id}",
|
||||
run.source.name,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _job_events() -> list[ActivityEvent]:
|
||||
return [
|
||||
ActivityEvent(
|
||||
version.created_at,
|
||||
"job",
|
||||
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
|
||||
", ".join(version.changed_fields)
|
||||
if version.changed_fields
|
||||
else "Bronmoment vastgelegd",
|
||||
"neutral",
|
||||
reverse("jobs:detail", kwargs={"pk": version.job_id}),
|
||||
version.job.original_title,
|
||||
)
|
||||
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
|
||||
]
|
||||
|
||||
|
||||
def build_activity_page(
|
||||
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
|
||||
) -> Page:
|
||||
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
|
||||
|
||||
events: list[ActivityEvent] = []
|
||||
if event_type in {"all", "source"}:
|
||||
events.extend(_source_events())
|
||||
if event_type in {"all", "job"}:
|
||||
events.extend(_job_events())
|
||||
if event_type in {"all", "feedback"}:
|
||||
for item in (
|
||||
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"feedback",
|
||||
item.get_action_display(),
|
||||
item.reason or "Gebruikersactie opgeslagen",
|
||||
"neutral",
|
||||
reverse("jobs:detail", kwargs={"pk": item.job_id}),
|
||||
item.job.original_title,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "application"}:
|
||||
for item in (
|
||||
ApplicationTimelineEvent.objects.filter(user=user)
|
||||
.select_related("application__job")
|
||||
.order_by("-created_at")[:100]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"application",
|
||||
item.get_event_type_display(),
|
||||
"Sollicitatiedossier bijgewerkt",
|
||||
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
|
||||
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
|
||||
item.application.job.original_title,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "notification"}:
|
||||
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
|
||||
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"notification",
|
||||
f"{label} {item.get_status_display().lower()}",
|
||||
item.subject,
|
||||
"positive" if item.status == item.Status.SENT else "attention",
|
||||
reverse("dashboard:system"),
|
||||
label,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "profile"}:
|
||||
for item in (
|
||||
ProfileRevision.objects.filter(profile__user=user)
|
||||
.select_related("profile")
|
||||
.order_by("-created_at")[:60]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"profile",
|
||||
f"Zoekprofiel versie {item.version}",
|
||||
item.reason or "Profielconfiguratie opgeslagen",
|
||||
"neutral",
|
||||
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
|
||||
item.profile.name,
|
||||
)
|
||||
)
|
||||
normalized_query = query.strip().casefold()
|
||||
if normalized_query:
|
||||
events = [
|
||||
item
|
||||
for item in events
|
||||
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
|
||||
]
|
||||
events.sort(key=lambda item: item.occurred_at, reverse=True)
|
||||
return Paginator(events, per_page).get_page(page)
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Avg, Count, Max, Q, QuerySet
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source
|
||||
|
||||
from ..models import Application, Employer, JobPosting, ScoreRun
|
||||
from .cockpit import annotate_latest_profile_score
|
||||
from .relevance import it_relevance_query
|
||||
|
||||
|
||||
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
|
||||
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
|
||||
return Employer.objects.annotate(
|
||||
active_vacancies=Count(
|
||||
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
|
||||
),
|
||||
relevant_vacancies=Count(
|
||||
"jobs",
|
||||
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
|
||||
distinct=True,
|
||||
),
|
||||
previous_applications=Count(
|
||||
"jobs__applications",
|
||||
filter=Q(jobs__applications__user=user),
|
||||
distinct=True,
|
||||
),
|
||||
source_count=Count("jobs__source_aliases__source", distinct=True),
|
||||
latest_job_at=Max("jobs__first_seen"),
|
||||
average_match=Avg("jobs__scores__score", filter=score_filter),
|
||||
)
|
||||
|
||||
|
||||
def filter_employers(
|
||||
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
|
||||
) -> QuerySet[Employer]:
|
||||
if query.strip():
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=query.strip())
|
||||
| Q(domain__icontains=query.strip())
|
||||
| Q(jobs__raw_location__icontains=query.strip())
|
||||
)
|
||||
if channel == "direct":
|
||||
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
|
||||
elif channel == "recruiter":
|
||||
queryset = queryset.filter(is_recruiter=True)
|
||||
if active_only:
|
||||
queryset = queryset.filter(active_vacancies__gt=0)
|
||||
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
|
||||
|
||||
|
||||
def build_employer_detail(
|
||||
*, employer: Employer, user, profile: SearchProfile | None
|
||||
) -> dict[str, Any]:
|
||||
jobs = annotate_latest_profile_score(
|
||||
employer.jobs.select_related("employer").all(), profile
|
||||
).order_by("-first_seen")
|
||||
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
|
||||
locations = list(
|
||||
employer.jobs.exclude(raw_location="")
|
||||
.order_by("raw_location")
|
||||
.values_list("raw_location", flat=True)
|
||||
.distinct()[:8]
|
||||
)
|
||||
return {
|
||||
"employer": employer,
|
||||
"recent_jobs": list(jobs[:12]),
|
||||
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
|
||||
"applications": list(
|
||||
Application.objects.filter(user=user, job__employer=employer)
|
||||
.select_related("job")
|
||||
.order_by("-updated_at")[:8]
|
||||
),
|
||||
"sources": list(sources[:10]),
|
||||
"locations": locations,
|
||||
"match_history": list(
|
||||
ScoreRun.objects.filter(profile=profile, job__employer=employer)
|
||||
.select_related("job")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
if profile
|
||||
else [],
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
ActivityListView,
|
||||
ApplicationListView,
|
||||
ApplicationUpdateView,
|
||||
EmployerDetailView,
|
||||
EmployerListView,
|
||||
JobDetailView,
|
||||
JobListView,
|
||||
SkillInsightsView,
|
||||
@@ -16,6 +19,9 @@ from .views import (
|
||||
urlpatterns = [
|
||||
path("", JobListView.as_view(), name="list"),
|
||||
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
|
||||
path("activity/", ActivityListView.as_view(), name="activity"),
|
||||
path("employers/", EmployerListView.as_view(), name="employers"),
|
||||
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
|
||||
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"),
|
||||
|
||||
+93
-1
@@ -15,7 +15,8 @@ from django.views.generic import DetailView, ListView, TemplateView, UpdateView
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .forms import ApplicationForm
|
||||
from .models import Application, Feedback, JobPosting, ScoreRun
|
||||
from .models import Application, Employer, Feedback, JobPosting, ScoreRun
|
||||
from .services.activity import ACTIVITY_FILTERS, build_activity_page
|
||||
from .services.applications import (
|
||||
build_application_export,
|
||||
build_print_html,
|
||||
@@ -24,6 +25,11 @@ from .services.applications import (
|
||||
track_application_changes,
|
||||
)
|
||||
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
|
||||
from .services.employer_intelligence import (
|
||||
build_employer_detail,
|
||||
employer_index_queryset,
|
||||
filter_employers,
|
||||
)
|
||||
from .services.feedback import record_feedback
|
||||
from .services.relevance import it_relevance_query
|
||||
from .services.skill_demand import build_skill_demand_report
|
||||
@@ -48,6 +54,92 @@ class SkillInsightsView(LoginRequiredMixin, TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class ActivityListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "activity/list.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
event_type = self.request.GET.get("type", "all")
|
||||
valid_types = {value for value, _label in ACTIVITY_FILTERS}
|
||||
if event_type not in valid_types:
|
||||
event_type = "all"
|
||||
context.update(
|
||||
{
|
||||
"event_page": build_activity_page(
|
||||
user=self.request.user,
|
||||
event_type=event_type,
|
||||
query=self.request.GET.get("q", ""),
|
||||
page=self.request.GET.get("page", 1),
|
||||
),
|
||||
"activity_filters": ACTIVITY_FILTERS,
|
||||
"current_type": event_type,
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class EmployerListView(LoginRequiredMixin, ListView):
|
||||
model = Employer
|
||||
template_name = "employers/list.html"
|
||||
context_object_name = "employers"
|
||||
paginate_by = 40
|
||||
|
||||
def get_queryset(self):
|
||||
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
return filter_employers(
|
||||
employer_index_queryset(user=self.request.user, profile=self.profile),
|
||||
query=self.request.GET.get("q", ""),
|
||||
channel=self.request.GET.get("channel", ""),
|
||||
active_only=self.request.GET.get("active") == "1",
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
employers = list(context["employers"])
|
||||
selected_id = self.request.GET.get("selected", "")
|
||||
selected = next((item for item in employers if str(item.pk) == selected_id), None)
|
||||
if selected is None and employers:
|
||||
selected = employers[0]
|
||||
context.update(
|
||||
{
|
||||
"selected_employer": selected,
|
||||
"selected_detail": build_employer_detail(
|
||||
employer=selected, user=self.request.user, profile=self.profile
|
||||
)
|
||||
if selected
|
||||
else None,
|
||||
"active_profile": self.profile,
|
||||
"employer_total": Employer.objects.count(),
|
||||
"employer_active_total": Employer.objects.filter(
|
||||
jobs__status=JobPosting.Status.ACTIVE
|
||||
)
|
||||
.distinct()
|
||||
.count(),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class EmployerDetailView(LoginRequiredMixin, DetailView):
|
||||
model = Employer
|
||||
template_name = "employers/detail.html"
|
||||
context_object_name = "employer"
|
||||
|
||||
def get_queryset(self):
|
||||
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
return employer_index_queryset(user=self.request.user, profile=self.profile)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
build_employer_detail(
|
||||
employer=self.object, user=self.request.user, profile=self.profile
|
||||
)
|
||||
)
|
||||
context["active_profile"] = self.profile
|
||||
return context
|
||||
|
||||
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
model = JobPosting
|
||||
template_name = "jobs/list.html"
|
||||
|
||||
Reference in New Issue
Block a user