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}"