373 lines
15 KiB
Python
373 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
import uuid
|
|
|
|
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"]),
|
|
models.Index(fields=["source_name", "source_version"]),
|
|
]
|
|
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, blank=True)
|
|
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"]),
|
|
models.Index(fields=["model_name", "prompt_version", "schema_version"]),
|
|
]
|
|
|
|
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"]),
|
|
models.Index(fields=["user", "created_at"]),
|
|
]
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.application} — {self.event_type}"
|