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
+344
View File
@@ -0,0 +1,344 @@
from __future__ import annotations
import csv
import io
import json
import zipfile
from datetime import date, timedelta
from decimal import Decimal
from html import escape
from typing import Any
from django.db import transaction
from django.utils import timezone
from django.utils.text import slugify
from apps.jobs.models import (
Application,
ApplicationTimelineEvent,
JobPosting,
ScoreRun,
)
from apps.jobs.services.pipeline import job_snapshot
from apps.profiles.models import SearchProfile
SNAPSHOT_VERSION = "1.0.0"
def _coerce_json_value(value: Any) -> Any:
if isinstance(value, (date,)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, set):
return sorted(value)
return value
def _timeline_dict_rows(application: Application) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for event in application.timeline_events.order_by("created_at").all():
metadata = event.metadata if isinstance(event.metadata, dict) else {}
rows.append(
{
"timestamp": event.created_at.isoformat(),
"type": event.event_type,
"actor": str(getattr(event.user, "username", "")),
"from": str(metadata.get("from", "")),
"to": str(metadata.get("to", "")),
"note": str(metadata.get("note", "")),
}
)
return rows
def _build_export_filename(application: Application) -> str:
slug = slugify(application.job.normalized_title or application.job.original_title) or "vacature"
return f"application-{application.pk}-{slug[:48]}.zip"
def _source_links(job: JobPosting) -> list[dict[str, Any]]:
links: list[dict[str, Any]] = []
seen: set[str] = set()
for alias in job.source_aliases.select_related("source").all():
url = alias.canonical_url or alias.url
if not url:
continue
if url in seen:
continue
seen.add(url)
links.append(
{
"source": alias.source.name if alias.source else "",
"url": url,
"is_canonical": alias.is_canonical,
"source_domain": (alias.source.domain if alias.source else ""),
}
)
return links
def _latest_score_snapshot(job: JobPosting, user) -> dict[str, Any] | None:
profile = SearchProfile.objects.filter(user=user, is_active=True).first()
if not profile:
return None
run = (
ScoreRun.objects.filter(job=job, profile=profile)
.order_by("-created_at")
.only("score", "recommendation", "confidence", "components", "positives", "concerns", "hard_exclusions", "evidence", "profile_version")
.first()
)
if not run:
return None
return {
"profile_id": profile.pk,
"profile_name": profile.name,
"score": float(run.score),
"recommendation": run.recommendation,
"confidence": float(run.confidence),
"components": dict(run.components),
"positives": list(run.positives),
"concerns": list(run.concerns),
"hard_exclusions": list(run.hard_exclusions),
"evidence": _coerce_json_value(run.evidence),
"captured_at": run.created_at.isoformat(),
"profile_version": run.profile_version,
}
def _build_application_snapshot_payload(application: Application) -> dict[str, Any]:
now = timezone.now().isoformat()
snapshot = job_snapshot(application.job)
snapshot["description_html"] = application.job.description_html_sanitized
return {
"schema_version": SNAPSHOT_VERSION,
"title": application.job.original_title,
"frozen_at": now,
"job": snapshot,
"sources": _source_links(application.job),
"scores": _latest_score_snapshot(application.job, application.user),
"snapshot_kind": "application",
}
def _record_timeline_event(*, application: Application, user, event_type: str, metadata: dict[str, Any] | None = None) -> ApplicationTimelineEvent:
payload: dict[str, Any] = {}
if metadata:
payload.update({key: value for key, value in metadata.items() if value is not None})
return ApplicationTimelineEvent.objects.create(
application=application,
user=user,
event_type=event_type,
metadata=payload,
)
def _normalize_str(value: Any) -> str:
return ("" if value is None else str(value)).strip()
def apply_application_on_feedback(*, user, job: JobPosting) -> Application:
"""Create or promote an application when the user marks the job as applied."""
with transaction.atomic():
follow_up_date = timezone.localdate() + timedelta(days=7)
application, created = Application.objects.get_or_create(
user=user,
job=job,
defaults={
"status": Application.Status.APPLIED,
"applied_at": timezone.now(),
"follow_up_date": follow_up_date,
},
)
previous = {
"status": application.status,
"contact_name": application.contact_name,
"contact_email": application.contact_email,
"notes": application.notes,
"snapshot": application.snapshot,
}
snapshot_created = False
if application.status == Application.Status.PREPARING:
application.status = Application.Status.APPLIED
if not application.applied_at:
application.applied_at = application.applied_at or timezone.now()
if not application.follow_up_date:
application.follow_up_date = follow_up_date
if not application.snapshot:
application.snapshot = _build_application_snapshot_payload(application)
snapshot_created = True
updates: list[str] = []
if created:
updates.extend(["status", "applied_at", "follow_up_date", "snapshot", "updated_at"])
else:
if previous["status"] != application.status:
updates.extend(["status", "applied_at", "follow_up_date", "updated_at"])
if not previous["snapshot"]:
updates.append("snapshot")
if updates:
updates = sorted(set(updates))
application.save(update_fields=updates)
if created:
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.CREATED,
metadata={
"status": application.status,
},
)
if snapshot_created:
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.SNAPSHOT_CAPTURED,
metadata={
"version": SNAPSHOT_VERSION,
},
)
if previous["status"] != application.status:
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
metadata={
"from": previous["status"],
"to": application.status,
},
)
return application
def track_application_changes(
*, application: Application, user, previous: dict[str, Any], current: dict[str, Any]
) -> int:
"""Persist timeline events for manual application edits."""
events = 0
if previous.get("status") != current.get("status"):
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
metadata={
"from": previous.get("status", ""),
"to": current.get("status", ""),
},
)
events += 1
if _normalize_str(previous.get("notes")) != _normalize_str(current.get("notes")):
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.NOTES_UPDATED,
metadata={
"note": _normalize_str(current.get("notes", ""))[:250],
},
)
events += 1
if (_normalize_str(previous.get("contact_name")) != _normalize_str(current.get("contact_name"))) or (
_normalize_str(previous.get("contact_email")) != _normalize_str(current.get("contact_email"))
):
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.CONTACT_UPDATED,
metadata={
"contact_name": _normalize_str(current.get("contact_name")),
"contact_email": _normalize_str(current.get("contact_email")),
},
)
events += 1
return events
def build_print_html(application: Application) -> str:
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
job = application.job
timeline_rows = _timeline_dict_rows(application)
safe_rows = []
for row in timeline_rows:
safe_rows.append(
(
f"<tr>"
f"<td>{escape(row['timestamp'])}</td>"
f"<td>{escape(row['type'])}</td>"
f"<td>{escape(row['actor'])}</td>"
f"<td>{escape(row['from'])}</td>"
f"<td>{escape(row['to'])}</td>"
f"<td>{escape(row['note'])}</td>"
"</tr>"
)
)
source_rows = []
for source in snapshot.get("sources", []):
source_name = escape(str(source.get("source", "")))
source_url = escape(str(source.get("url", "")))
source_rows.append(f"<li>{source_name}: {source_url}</li>")
source_section = "".join(source_rows) if source_rows else "<li>Niet beschikbaar</li>"
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>Sollicitatiedossier</title><style>body{font-family:Arial,sans-serif;margin:24px}"
"table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:8px;text-align:left}"
"th{background:#f2f2f2} </style></head><body>"
f"<h1>{escape(job.original_title)}</h1>"
f"<p>Vacature: {escape(job.canonical_url)}</p>"
f"<p>Status: {escape(application.get_status_display())}</p>"
f"<p>Geslaagd op: {escape(str(application.snapshot.get('frozen_at') if isinstance(application.snapshot, dict) else ''))}</p>"
f"<h2>Bronnen</h2><ul>{source_section}</ul>"
f"<h2>Timeline</h2><table><thead><tr><th>Tijd</th><th>Type</th><th>Actor</th><th>Van</th><th>Naar</th><th>Notitie</th></tr></thead><tbody>"
f"{''.join(safe_rows)}</tbody></table>"
f"<h2>Snapshot</h2><pre>{escape(json.dumps(snapshot, indent=2, ensure_ascii=False, sort_keys=True))}</pre>"
"</body></html>"
)
def build_application_export(application: Application) -> tuple[bytes, str]:
payload = {
"application": {
"id": str(application.pk),
"job_id": str(application.job_id),
"status": application.status,
"contact_name": application.contact_name,
"contact_email": application.contact_email,
"contact_metadata": application.contact_metadata,
"notes": application.notes,
"snapshot_version": SNAPSHOT_VERSION,
"exported_at": timezone.now().isoformat(),
"applied_at": application.applied_at.isoformat() if application.applied_at else None,
"follow_up_date": application.follow_up_date.isoformat() if application.follow_up_date else None,
},
"snapshot": _coerce_json_value(application.snapshot),
"timeline": _timeline_dict_rows(application),
}
timeline_buffer = io.StringIO()
writer = csv.DictWriter(
timeline_buffer,
fieldnames=["timestamp", "type", "actor", "from", "to", "note"],
)
writer.writeheader()
for row in payload["timeline"]:
writer.writerow(row)
content_buffer = io.BytesIO()
with zipfile.ZipFile(content_buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("application.json", json.dumps(_coerce_json_value(payload), indent=2, ensure_ascii=False, sort_keys=True))
zf.writestr("timeline.csv", timeline_buffer.getvalue())
zf.writestr("application_print.html", build_print_html(application))
return content_buffer.getvalue(), _build_export_filename(application)
def delete_application_dossier(*, application_id: int, user) -> bool:
deleted, _ = Application.objects.filter(pk=application_id, user=user).delete()
return bool(deleted)