@@ -0,0 +1,372 @@
|
||||
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}"
|
||||
Reference in New Issue
Block a user