Files
Jens a4eced8be5
deploy / deploy (push) Canceled after 0s
Fix release blockers and deployment build
2026-07-21 21:22:29 +02:00

380 lines
15 KiB
Python

from __future__ import annotations
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.utils import timezone
from apps.core.models import TimeStampedModel
class Employer(TimeStampedModel):
name = models.CharField(max_length=300)
normalized_name = models.CharField(max_length=300, db_index=True)
domain = models.CharField(max_length=255, blank=True, db_index=True)
is_direct_employer = models.BooleanField(default=True)
is_recruiter = models.BooleanField(default=False)
employer_type = models.CharField(max_length=80, blank=True)
confidence = models.DecimalField(max_digits=4, decimal_places=3, default=0.5)
aliases = models.JSONField(default=list, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["normalized_name", "domain"], name="unique_employer_name_domain"
)
]
def __str__(self) -> str:
return self.name
class JobPosting(TimeStampedModel):
class Status(models.TextChoices):
NEW = "new", "Nieuw"
ACTIVE = "active", "Actief"
UNCERTAIN = "uncertain", "Onzeker"
EXPIRED = "expired", "Verlopen"
REMOVED = "removed", "Verwijderd"
DUPLICATE = "duplicate", "Duplicaat"
QUARANTINED = "quarantined", "Quarantaine"
class Workplace(models.TextChoices):
ON_SITE = "on_site", "Op locatie"
HYBRID = "hybrid", "Hybride"
REMOTE = "remote", "Remote"
UNKNOWN = "unknown", "Onbekend"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
employer = models.ForeignKey(
Employer, on_delete=models.SET_NULL, null=True, blank=True, related_name="jobs"
)
original_title = models.CharField(max_length=500)
normalized_title = models.CharField(max_length=500, db_index=True)
job_family = models.CharField(max_length=160, blank=True, db_index=True)
seniority = models.CharField(max_length=80, blank=True)
language = models.CharField(max_length=16, blank=True)
canonical_url = models.URLField(max_length=2000, blank=True, db_index=True)
canonical_key = models.CharField(max_length=64, unique=True)
content_hash = models.CharField(max_length=64, db_index=True)
description_html_sanitized = models.TextField(blank=True)
description_text = models.TextField(blank=True)
requirements = models.JSONField(default=list, blank=True)
benefits = models.JSONField(default=list, blank=True)
raw_location = models.CharField(max_length=500, blank=True)
country = models.CharField(max_length=120, blank=True)
region = models.CharField(max_length=160, blank=True, db_index=True)
municipality = models.CharField(max_length=160, blank=True, db_index=True)
postal_code = models.CharField(max_length=24, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
workplace_type = models.CharField(
max_length=20, choices=Workplace.choices, default=Workplace.UNKNOWN
)
employment_types = models.JSONField(default=list, blank=True)
hours_text = models.CharField(max_length=200, blank=True)
compensation = models.JSONField(default=dict, blank=True)
skills_required = models.JSONField(default=list, blank=True)
skills_preferred = models.JSONField(default=list, blank=True)
analysis_features = models.JSONField(default=dict, blank=True)
date_posted = models.DateTimeField(null=True, blank=True)
valid_through = models.DateTimeField(null=True, blank=True, db_index=True)
first_seen = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField(default=timezone.now, db_index=True)
last_changed = models.DateTimeField(default=timezone.now)
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.NEW, db_index=True
)
direct_employer = models.BooleanField(default=True)
recruiter = models.BooleanField(default=False)
extraction_confidence = models.DecimalField(max_digits=4, decimal_places=3, default=0.5)
duplicate_of = models.ForeignKey(
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="duplicates"
)
class Meta:
ordering = ["-first_seen"]
indexes = [
models.Index(fields=["normalized_title", "region"]),
models.Index(fields=["status", "valid_through"]),
]
def __str__(self) -> str:
employer = self.employer.name if self.employer else "Onbekende werkgever"
return f"{self.original_title}{employer}"
@property
def employer_name(self) -> str:
return self.employer.name if self.employer else "Onbekende werkgever"
class JobSourceAlias(TimeStampedModel):
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="source_aliases")
source = models.ForeignKey(
"sources.Source",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="job_aliases",
)
raw_document = models.ForeignKey(
"sources.RawDocument",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="job_aliases",
)
url = models.URLField(max_length=2000)
canonical_url = models.URLField(max_length=2000, db_index=True)
external_id = models.CharField(max_length=500, blank=True, db_index=True)
source_title = models.CharField(max_length=500, blank=True)
source_employer = models.CharField(max_length=300, blank=True)
extraction_method = models.CharField(max_length=120, blank=True)
extraction_confidence = models.DecimalField(max_digits=4, decimal_places=3, default=0.5)
first_seen = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField(default=timezone.now)
is_canonical = models.BooleanField(default=False)
payload = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-is_canonical", "-last_seen"]
indexes = [
models.Index(fields=["source", "external_id"]),
models.Index(fields=["canonical_url"]),
]
def __str__(self) -> str:
return self.canonical_url
class FieldProvenance(TimeStampedModel):
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="provenance")
source_alias = models.ForeignKey(
JobSourceAlias, on_delete=models.CASCADE, related_name="field_evidence"
)
field_name = models.CharField(max_length=120)
extraction_method = models.CharField(max_length=120)
confidence = models.DecimalField(max_digits=4, decimal_places=3, default=0.5)
evidence_excerpt = models.CharField(max_length=1000, blank=True)
parser_version = models.CharField(max_length=80, blank=True)
class Meta:
ordering = ["field_name", "-confidence"]
class GeocodeLocationLookup(TimeStampedModel):
class QueryKind(models.TextChoices):
POSTAL = "postal", "Postcode"
MUNICIPALITY = "municipality", "Gemeente"
source_name = models.CharField(max_length=120)
source_version = models.CharField(max_length=80)
source_license_name = models.CharField(max_length=160, blank=True)
source_license_url = models.URLField(max_length=500, blank=True)
source_metadata = models.JSONField(default=dict, blank=True)
query_kind = models.CharField(max_length=12, choices=QueryKind.choices)
query_value = models.CharField(max_length=200, db_index=True)
postal_code = models.CharField(max_length=12, db_index=True)
municipality = models.CharField(max_length=200, db_index=True)
region = models.CharField(max_length=160, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6)
longitude = models.DecimalField(max_digits=9, decimal_places=6)
confidence = models.DecimalField(max_digits=3, decimal_places=2, default=Decimal("0.85"))
class Meta:
ordering = ["query_kind", "query_value", "municipality"]
indexes = [
models.Index(
fields=["query_kind", "query_value"], name="geocode_lookup_kind_query_idx"
),
models.Index(
fields=["source_name", "source_version"], name="geocode_lookup_source_idx"
),
]
constraints = [
models.UniqueConstraint(
fields=[
"source_name",
"source_version",
"query_kind",
"query_value",
"postal_code",
"municipality",
],
name="unique_geocode_lookup_row",
)
]
def __str__(self) -> str:
return f"{self.source_name}:{self.source_version}:{self.query_kind}:{self.query_value}"
class JobVersion(TimeStampedModel):
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="versions")
content_hash = models.CharField(max_length=64)
snapshot = models.JSONField(default=dict)
changed_fields = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["job", "content_hash"], name="unique_job_content_version"
)
]
class ScoreRun(TimeStampedModel):
class Recommendation(models.TextChoices):
STRONG = "strong", "Sterke match"
POSSIBLE = "possible", "Mogelijke match"
WEAK = "weak", "Lage match"
HIDDEN = "hidden", "Verborgen"
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="scores")
profile = models.ForeignKey(
"profiles.SearchProfile", on_delete=models.CASCADE, related_name="scores"
)
profile_version = models.PositiveIntegerField()
score = models.DecimalField(max_digits=5, decimal_places=2)
confidence = models.DecimalField(max_digits=4, decimal_places=3)
recommendation = models.CharField(max_length=16, choices=Recommendation.choices)
components = models.JSONField(default=dict)
positives = models.JSONField(default=list)
concerns = models.JSONField(default=list)
hard_exclusions = models.JSONField(default=list)
evidence = models.JSONField(default=dict, blank=True)
model_version = models.CharField(max_length=120, blank=True)
prompt_version = models.CharField(max_length=120, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["profile", "-score", "-created_at"])]
class AiAnalysisCache(TimeStampedModel):
class Status(models.TextChoices):
OK = "ok", "Succes"
DISABLED = "disabled", "Uitgeschakeld"
ERROR = "error", "Fout"
TIMEOUT = "timeout", "Timeout"
INVALID = "invalid", "Ongeldige output"
content_hash = models.CharField(max_length=64, db_index=True)
model_name = models.CharField(max_length=120)
prompt_version = models.CharField(max_length=120)
schema_version = models.CharField(max_length=24, default="1.0.0")
status = models.CharField(max_length=16, choices=Status.choices)
error_category = models.CharField(max_length=120, blank=True)
summary_nl = models.TextField(blank=True)
features = models.JSONField(default=dict)
warnings = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(
fields=["content_hash", "model_name", "prompt_version", "schema_version"],
name="unique_ai_analysis_cache",
)
]
indexes = [
models.Index(fields=["content_hash"], name="jobs_aianal_cache_content_idx"),
models.Index(
fields=["model_name", "prompt_version", "schema_version"],
name="jobs_aianal_cache_model_idx",
),
]
def __str__(self) -> str:
return f"{self.model_name}:{self.prompt_version}:{self.content_hash[:8]}:{self.status}"
class Feedback(TimeStampedModel):
class Action(models.TextChoices):
INTERESTING = "interesting", "Interessant"
SAVE = "save", "Bewaren"
HIDE = "hide", "Verbergen"
APPLIED = "applied", "Gesolliciteerd"
UNDO = "undo", "Ongedaan maken"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
profile = models.ForeignKey(
"profiles.SearchProfile", on_delete=models.SET_NULL, null=True, blank=True
)
job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name="feedback")
action = models.CharField(max_length=20, choices=Action.choices)
reason = models.CharField(max_length=200, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
class Application(TimeStampedModel):
class Status(models.TextChoices):
PREPARING = "preparing", "Voorbereiden"
APPLIED = "applied", "Verzonden"
INTERVIEW = "interview", "Gesprek"
OFFER = "offer", "Aanbod"
REJECTED = "rejected", "Afgewezen"
WITHDRAWN = "withdrawn", "Ingetrokken"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
job = models.ForeignKey(JobPosting, on_delete=models.PROTECT, related_name="applications")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PREPARING)
applied_at = models.DateTimeField(null=True, blank=True)
follow_up_date = models.DateField(null=True, blank=True)
contact_name = models.CharField(max_length=200, blank=True)
contact_email = models.EmailField(blank=True)
contact_metadata = models.JSONField(default=dict, blank=True)
notes = models.TextField(blank=True)
document_manifest = models.JSONField(default=list, blank=True)
snapshot = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-updated_at"]
constraints = [
models.UniqueConstraint(fields=["user", "job"], name="unique_application_per_user_job")
]
def __str__(self) -> str:
return f"{self.job}{self.get_status_display()}"
class ApplicationTimelineEvent(TimeStampedModel):
class EventType(models.TextChoices):
CREATED = "created", "Aangemaakt"
STATUS_CHANGED = "status_changed", "Status gewijzigd"
NOTES_UPDATED = "notes_updated", "Notitie bijgewerkt"
CONTACT_UPDATED = "contact_updated", "Contact bijgewerkt"
SNAPSHOT_CAPTURED = "snapshot_captured", "Snapshot vastgelegd"
application = models.ForeignKey(
Application,
on_delete=models.CASCADE,
related_name="timeline_events",
)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
event_type = models.CharField(max_length=20, choices=EventType.choices)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["application", "created_at"], name="jobs_app_timeline_app_idx"),
models.Index(fields=["user", "created_at"], name="jobs_app_timeline_user_idx"),
]
def __str__(self) -> str:
return f"{self.application}{self.event_type}"