Publish curated VacatureRadar source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:19:10 +02:00
commit e29774cb7f
338 files changed
+32699

No files matched your search

View File
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
from django.contrib import admin
from .models import ProfileRevision, SearchProfile
@admin.register(SearchProfile)
class SearchProfileAdmin(admin.ModelAdmin):
list_display = ("name", "user", "is_active", "max_distance_km", "version", "updated_at")
list_filter = ("is_active", "locale", "learning_enabled")
search_fields = ("name", "user__username", "home_municipality")
@admin.register(ProfileRevision)
class ProfileRevisionAdmin(admin.ModelAdmin):
list_display = ("profile", "version", "reason", "created_at")
readonly_fields = ("profile", "version", "snapshot", "reason", "created_at", "updated_at")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProfilesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.profiles"
verbose_name = "Zoekprofielen"
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
def default_weights() -> dict[str, float]:
return {
"content": 30.0,
"skills": 25.0,
"location": 20.0,
"conditions": 10.0,
"employer": 10.0,
"preferences": 5.0,
"ai": 0.0,
}
def default_hard_rules() -> dict[str, object]:
return {
"excluded_title_terms": [],
"excluded_employment_types": ["freelance"],
"excluded_skills": [],
"unknown_is_insufficient": False,
}
def default_soft_preferences() -> dict[str, float]:
return {
"direct_employer": 0.9,
"public_sector": 0.7,
"limited_first_line_support": 0.8,
}
def default_follow_up_weekdays() -> list[int]:
return [0, 1, 2, 3, 4]
+206
View File
@@ -0,0 +1,206 @@
from __future__ import annotations
import re
from django import forms
from .models import SearchProfile
from .taxonomy import (
EXCLUDED_SKILL_CHOICES,
EXCLUDED_TITLE_CHOICES,
SKILL_CHOICES,
TITLE_CHOICES,
iter_choices,
)
EMPLOYMENT_CHOICES = (
("full_time", "Voltijds"),
("part_time", "Deeltijds"),
("permanent", "Vast contract"),
("fixed_term", "Tijdelijk contract"),
("freelance", "Freelance"),
("internship", "Stage"),
)
WORKPLACE_CHOICES = (
("hybrid", "Hybride"),
("remote", "Volledig op afstand"),
("on_site", "Op locatie"),
)
REGION_CHOICES = (
("Antwerpen", "Antwerpen"),
("Brussels Hoofdstedelijk Gewest", "Brussel"),
("Henegouwen", "Henegouwen"),
("Limburg", "Limburg"),
("Luik", "Luik"),
("Luxemburg", "Luxemburg"),
("Namen", "Namen"),
("Oost-Vlaanderen", "Oost-Vlaanderen"),
("Vlaams-Brabant", "Vlaams-Brabant"),
("Waals-Brabant", "Waals-Brabant"),
("West-Vlaanderen", "West-Vlaanderen"),
)
class SearchProfileForm(forms.ModelForm):
desired_titles = forms.MultipleChoiceField(
label="Gewenste functietitels",
required=False,
choices=TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Selecteer alle rollen die bij je zoekrichting passen; de beste titelmatch telt.",
)
excluded_titles = forms.MultipleChoiceField(
label="Uitgesloten functietitels",
required=False,
choices=EXCLUDED_TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
)
desired_skills = forms.MultipleChoiceField(
label="Gewenste skills",
required=False,
choices=SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text=(
"Kies je herkenbare cv-skills ruim. Maximaal vier aangetroffen skills volstaan voor "
"de volledige skillcomponent; extra keuzes verwateren je score niet."
),
)
excluded_skills = forms.MultipleChoiceField(
label="Uitgesloten kenmerken",
required=False,
choices=EXCLUDED_SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
)
allowed_employment_types = forms.MultipleChoiceField(
label="Toegestane contractvormen",
required=False,
choices=EMPLOYMENT_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
preferred_workplace = forms.MultipleChoiceField(
label="Voorkeurswerkmodel",
required=False,
choices=WORKPLACE_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
preferred_regions = forms.MultipleChoiceField(
label="Voorkeursregio's",
required=False,
choices=REGION_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
excluded_regions = forms.MultipleChoiceField(
label="Uitgesloten regio's",
required=False,
choices=REGION_CHOICES,
widget=forms.CheckboxSelectMultiple,
)
class Meta:
model = SearchProfile
fields = [
"name",
"is_active",
"home_postal_code",
"max_distance_km",
"experience_years",
"desired_titles",
"excluded_titles",
"desired_skills",
"excluded_skills",
"allowed_employment_types",
"preferred_workplace",
"preferred_regions",
"excluded_regions",
"recommendation_threshold",
"top_match_threshold",
"digest_time",
"learning_enabled",
]
labels = {
"name": "Naam",
"is_active": "Actief profiel",
"home_postal_code": "Thuispostcode",
"max_distance_km": "Maximale afstand (km)",
"experience_years": "Relevante IT-ervaring",
"recommendation_threshold": "Aanbevelingsdrempel",
"top_match_threshold": "Topmatchdrempel",
"digest_time": "Tijdstip dagelijkse samenvatting",
"learning_enabled": "Gecontroleerd leren",
}
help_texts = {
"home_postal_code": (
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
"lokale geodata beschikbaar is."
),
"experience_years": (
"Optionele profielcontext. Deze waarde telt nooit mee voor je matchscore en "
"kan een vacature niet uitsluiten."
),
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
}
widgets = {
"home_postal_code": forms.TextInput(
attrs={"inputmode": "numeric", "pattern": "[0-9]{4}", "maxlength": 4}
),
"digest_time": forms.TimeInput(attrs={"type": "time"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["home_postal_code"].widget.attrs["maxlength"] = 4
if not self.instance or not self.instance.pk:
return
for field_name in (
"desired_titles",
"excluded_titles",
"desired_skills",
"excluded_skills",
"allowed_employment_types",
"preferred_workplace",
"preferred_regions",
"excluded_regions",
):
self._include_existing_choices(field_name)
def _include_existing_choices(self, field_name: str) -> None:
field = self.fields[field_name]
stored = getattr(self.instance, field_name, [])
existing_by_casefold = {
str(value).casefold(): str(value) for value, _label in iter_choices(field.choices)
}
extra = [
(str(value), str(value))
for value in stored
if str(value).casefold() not in existing_by_casefold
]
if extra:
field.choices = [*field.choices, ("Eerder opgeslagen", tuple(extra))]
field.initial = [
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
]
def clean_home_postal_code(self) -> str:
value = str(self.cleaned_data.get("home_postal_code") or "").strip()
if value and not re.fullmatch(r"\d{4}", value):
raise forms.ValidationError("Gebruik een Belgische postcode van vier cijfers.")
return value
def clean(self):
cleaned = super().clean()
overlap_checks = (
("desired_titles", "excluded_titles", "functietitels"),
("desired_skills", "excluded_skills", "skills"),
("preferred_regions", "excluded_regions", "regio's"),
)
for desired_field, excluded_field, label in overlap_checks:
overlap = set(cleaned.get(desired_field) or []) & set(cleaned.get(excluded_field) or [])
if overlap:
self.add_error(
excluded_field,
f"Dezelfde {label} kunnen niet tegelijk gewenst en uitgesloten zijn.",
)
return cleaned
+81
View File
@@ -0,0 +1,81 @@
# Generated by Django 5.2.16 on 2026-07-20 23:57
import apps.profiles.defaults
import datetime
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SearchProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(default='Primair profiel', max_length=120)),
('is_active', models.BooleanField(default=True)),
('locale', models.CharField(default='nl-BE', max_length=10)),
('timezone', models.CharField(default='Europe/Brussels', max_length=64)),
('home_postal_code', models.CharField(blank=True, max_length=12)),
('home_municipality', models.CharField(blank=True, max_length=120)),
('home_latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('home_longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('max_distance_km', models.PositiveIntegerField(default=45)),
('desired_titles', models.JSONField(blank=True, default=list)),
('excluded_titles', models.JSONField(blank=True, default=list)),
('desired_skills', models.JSONField(blank=True, default=list)),
('excluded_skills', models.JSONField(blank=True, default=list)),
('allowed_employment_types', models.JSONField(blank=True, default=list)),
('preferred_workplace', models.JSONField(blank=True, default=list)),
('preferred_regions', models.JSONField(blank=True, default=list)),
('excluded_regions', models.JSONField(blank=True, default=list)),
('hard_rules', models.JSONField(blank=True, default=apps.profiles.defaults.default_hard_rules)),
('soft_preferences', models.JSONField(blank=True, default=apps.profiles.defaults.default_soft_preferences)),
('weights', models.JSONField(blank=True, default=apps.profiles.defaults.default_weights)),
('recommendation_threshold', models.PositiveSmallIntegerField(default=65)),
('top_match_threshold', models.PositiveSmallIntegerField(default=90)),
('digest_time', models.TimeField(default=datetime.time(7, 30))),
('quiet_hours_start', models.TimeField(default=datetime.time(22, 0))),
('quiet_hours_end', models.TimeField(default=datetime.time(7, 0))),
('learning_enabled', models.BooleanField(default=True)),
('version', models.PositiveIntegerField(default=1)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-is_active', 'name'],
},
),
migrations.CreateModel(
name='ProfileRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('version', models.PositiveIntegerField()),
('snapshot', models.JSONField(default=dict)),
('reason', models.CharField(blank=True, max_length=200)),
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='revisions', to='profiles.searchprofile')),
],
options={
'ordering': ['-version'],
},
),
migrations.AddConstraint(
model_name='searchprofile',
constraint=models.UniqueConstraint(fields=('user', 'name'), name='unique_profile_name_per_user'),
),
migrations.AddConstraint(
model_name='profilerevision',
constraint=models.UniqueConstraint(fields=('profile', 'version'), name='unique_revision_version_per_profile'),
),
]
@@ -0,0 +1,18 @@
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="searchprofile",
name="ai_scoring_enabled",
field=models.BooleanField(default=False),
)
]
@@ -0,0 +1,18 @@
from __future__ import annotations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0002_ai_scoring_enabled"),
]
operations = [
migrations.AlterField(
model_name="searchprofile",
name="learning_enabled",
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.16 on 2026-07-21 00:00
import apps.profiles.defaults
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0003_learning_enabled_default_false"),
]
operations = [
migrations.AddField(
model_name="searchprofile",
name="reminders_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="searchprofile",
name="top_match_reminders_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="searchprofile",
name="follow_up_weekdays",
field=models.JSONField(default=apps.profiles.defaults.default_follow_up_weekdays, blank=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-07-22 13:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0004_reminder_settings'),
]
operations = [
migrations.AddField(
model_name='searchprofile',
name='experience_years',
field=models.PositiveSmallIntegerField(choices=[(0, 'Niet ingesteld'), (1, 'Minder dan 2 jaar'), (3, '2 tot 4 jaar'), (5, '5 tot 7 jaar'), (8, '8 tot 9 jaar'), (10, '10 tot 14 jaar'), (15, '15 jaar of meer')], default=0),
),
]
@@ -0,0 +1,22 @@
from django.db import migrations
def remove_seniority_weight(apps, schema_editor):
search_profile = apps.get_model("profiles", "SearchProfile")
for profile in search_profile.objects.all().iterator():
weights = dict(profile.weights or {})
if "seniority" not in weights:
continue
weights.pop("seniority", None)
profile.weights = weights
profile.save(update_fields=["weights"])
class Migration(migrations.Migration):
dependencies = [
("profiles", "0005_searchprofile_experience_years"),
]
operations = [
migrations.RunPython(remove_seniority_weight, migrations.RunPython.noop),
]
Whitespace-only changes.
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from datetime import time
from typing import Any
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models, transaction
from apps.core.models import TimeStampedModel
from .defaults import (
default_follow_up_weekdays,
default_hard_rules,
default_soft_preferences,
default_weights,
)
EXPERIENCE_YEARS_CHOICES = (
(0, "Niet ingesteld"),
(1, "Minder dan 2 jaar"),
(3, "2 tot 4 jaar"),
(5, "5 tot 7 jaar"),
(8, "8 tot 9 jaar"),
(10, "10 tot 14 jaar"),
(15, "15 jaar of meer"),
)
class SearchProfile(TimeStampedModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
name = models.CharField(max_length=120, default="Primair profiel")
is_active = models.BooleanField(default=True)
locale = models.CharField(max_length=10, default="nl-BE")
timezone = models.CharField(max_length=64, default="Europe/Brussels")
home_postal_code = models.CharField(max_length=12, blank=True)
home_municipality = models.CharField(max_length=120, blank=True)
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
max_distance_km = models.PositiveIntegerField(default=45)
experience_years = models.PositiveSmallIntegerField(
choices=EXPERIENCE_YEARS_CHOICES,
default=0,
)
desired_titles = models.JSONField(default=list, blank=True)
excluded_titles = models.JSONField(default=list, blank=True)
desired_skills = models.JSONField(default=list, blank=True)
excluded_skills = models.JSONField(default=list, blank=True)
allowed_employment_types = models.JSONField(default=list, blank=True)
preferred_workplace = models.JSONField(default=list, blank=True)
preferred_regions = models.JSONField(default=list, blank=True)
excluded_regions = models.JSONField(default=list, blank=True)
hard_rules = models.JSONField(default=default_hard_rules, blank=True)
soft_preferences = models.JSONField(default=default_soft_preferences, blank=True)
weights = models.JSONField(default=default_weights, blank=True)
recommendation_threshold = models.PositiveSmallIntegerField(default=65)
top_match_threshold = models.PositiveSmallIntegerField(default=90)
digest_time = models.TimeField(default=time(7, 30))
quiet_hours_start = models.TimeField(default=time(22, 0))
quiet_hours_end = models.TimeField(default=time(7, 0))
reminders_enabled = models.BooleanField(default=True)
top_match_reminders_enabled = models.BooleanField(default=False)
follow_up_weekdays = models.JSONField(default=default_follow_up_weekdays, blank=True)
learning_enabled = models.BooleanField(default=False)
ai_scoring_enabled = models.BooleanField(default=False)
version = models.PositiveIntegerField(default=1)
class Meta:
ordering = ["-is_active", "name"]
constraints = [
models.UniqueConstraint(fields=["user", "name"], name="unique_profile_name_per_user")
]
def __str__(self) -> str:
return self.name
def clean(self) -> None:
errors: dict[str, str] = {}
if self.recommendation_threshold > self.top_match_threshold:
errors["top_match_threshold"] = (
"De topmatchdrempel moet minstens de aanbevelingsdrempel zijn."
)
try:
weights_valid = (
isinstance(self.weights, dict)
and bool(self.weights)
and all(float(value) >= 0 for value in self.weights.values())
and sum(float(value) for value in self.weights.values()) > 0
)
except (TypeError, ValueError):
weights_valid = False
if not weights_valid:
errors["weights"] = "Gewichten moeten numeriek, niet-negatief en samen positief zijn."
if self.home_latitude is not None and not (-90 <= float(self.home_latitude) <= 90):
errors["home_latitude"] = "Ongeldige breedtegraad."
if self.home_longitude is not None and not (-180 <= float(self.home_longitude) <= 180):
errors["home_longitude"] = "Ongeldige lengtegraad."
if not isinstance(self.follow_up_weekdays, list) or not all(
isinstance(day, int) and 0 <= day <= 6 for day in self.follow_up_weekdays
):
errors["follow_up_weekdays"] = (
"Volgweken moet uit geldige weekdagnummers (0-6) bestaan."
)
if errors:
raise ValidationError(errors)
def snapshot(self) -> dict[str, Any]:
return {
"name": self.name,
"locale": self.locale,
"timezone": self.timezone,
"home_postal_code": self.home_postal_code,
"home_municipality": self.home_municipality,
"home_latitude": float(self.home_latitude) if self.home_latitude is not None else None,
"home_longitude": float(self.home_longitude)
if self.home_longitude is not None
else None,
"max_distance_km": self.max_distance_km,
"experience_years": self.experience_years,
"desired_titles": self.desired_titles,
"excluded_titles": self.excluded_titles,
"desired_skills": self.desired_skills,
"excluded_skills": self.excluded_skills,
"allowed_employment_types": self.allowed_employment_types,
"preferred_workplace": self.preferred_workplace,
"preferred_regions": self.preferred_regions,
"excluded_regions": self.excluded_regions,
"hard_rules": self.hard_rules,
"soft_preferences": self.soft_preferences,
"weights": self.weights,
"recommendation_threshold": self.recommendation_threshold,
"top_match_threshold": self.top_match_threshold,
"reminders_enabled": self.reminders_enabled,
"top_match_reminders_enabled": self.top_match_reminders_enabled,
"follow_up_weekdays": self.follow_up_weekdays,
"digest_time": self.digest_time.isoformat(),
"quiet_hours_start": self.quiet_hours_start.isoformat(),
"quiet_hours_end": self.quiet_hours_end.isoformat(),
"learning_enabled": self.learning_enabled,
"ai_scoring_enabled": self.ai_scoring_enabled,
"version": self.version,
}
def activate(self) -> None:
with transaction.atomic():
SearchProfile.objects.filter(user=self.user, is_active=True).exclude(pk=self.pk).update(
is_active=False
)
if not self.is_active:
self.is_active = True
self.save(update_fields=["is_active", "updated_at"])
class ProfileRevision(TimeStampedModel):
profile = models.ForeignKey(SearchProfile, on_delete=models.CASCADE, related_name="revisions")
version = models.PositiveIntegerField()
snapshot = models.JSONField(default=dict)
reason = models.CharField(max_length=200, blank=True)
class Meta:
ordering = ["-version"]
constraints = [
models.UniqueConstraint(
fields=["profile", "version"], name="unique_revision_version_per_profile"
)
]
def __str__(self) -> str:
return f"{self.profile} v{self.version}"
+64
View File
@@ -0,0 +1,64 @@
from __future__ import annotations
from django.db import transaction
from apps.jobs.services.geocoding import LocationMatchResult, resolve_cached_location
from .models import ProfileRevision, SearchProfile
@transaction.atomic
def resolve_profile_home_location(profile: SearchProfile) -> LocationMatchResult:
"""Resolve a user-entered postal code without exposing coordinate fields in the UI."""
query = profile.home_postal_code.strip()
result = (
resolve_cached_location(query) if query else LocationMatchResult(query=query, location=None)
)
location = result.location
profile.home_municipality = location.municipality or "" if location else ""
profile.home_latitude = location.point.latitude if location and location.point else None
profile.home_longitude = location.point.longitude if location and location.point else None
profile.save(
update_fields=[
"home_municipality",
"home_latitude",
"home_longitude",
"updated_at",
]
)
return result
@transaction.atomic
def save_profile_revision(profile: SearchProfile, *, reason: str) -> ProfileRevision:
latest = profile.revisions.order_by("-version").first()
next_version = (latest.version if latest else 0) + 1
if profile.version != next_version:
profile.version = next_version
profile.save(update_fields=["version", "updated_at"])
return ProfileRevision.objects.create(
profile=profile,
version=next_version,
snapshot=profile.snapshot(),
reason=reason,
)
@transaction.atomic
def apply_feedback_delta(
profile: SearchProfile,
feature: str,
delta: float,
*,
min_weight: float = 0.0,
max_weight: float = 40.0,
) -> SearchProfile:
if not profile.learning_enabled:
return profile
weights = dict(profile.weights)
current = float(weights.get(feature, 0.0))
weights[feature] = round(max(min_weight, min(max_weight, current + delta)), 2)
profile.weights = weights
profile.save(update_fields=["weights", "updated_at"])
save_profile_revision(profile, reason=f"feedback_delta:{feature}:{delta:+.2f}")
return profile
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
from collections.abc import Iterable, Iterator, Sequence
Choice = tuple[str, str]
ChoiceGroup = tuple[str, tuple[Choice, ...]]
TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Field service & support",
(
("it field engineer", "IT Field Engineer"),
("field service engineer", "Field Service Engineer"),
("it engineer", "IT Engineer"),
("it support engineer", "IT Support Engineer"),
("support engineer", "Support Engineer"),
("onsite support engineer", "On-site Support Engineer"),
("service desk engineer", "Service Desk Engineer"),
("technical support engineer", "Technical Support Engineer"),
("it technician", "IT Technician"),
("pc technician", "PC Technician"),
),
),
(
"Systemen & infrastructuur",
(
("system engineer", "System Engineer"),
("system network engineer", "System & Network Engineer"),
("infrastructure engineer", "Infrastructure Engineer"),
("system administrator", "System Administrator"),
("systeembeheerder", "Systeembeheerder"),
("network engineer", "Network Engineer"),
("network administrator", "Network Administrator"),
("netwerkbeheerder", "Netwerkbeheerder"),
("infrastructure consultant", "Infrastructure Consultant"),
("implementation engineer", "Implementation Engineer"),
),
),
(
"Modern Workplace & endpoint",
(
("workplace engineer", "Workplace Engineer"),
("modern workplace engineer", "Modern Workplace Engineer"),
("microsoft 365 engineer", "Microsoft 365 Engineer"),
("microsoft 365 consultant", "Microsoft 365 Consultant"),
("modern workplace consultant", "Modern Workplace Consultant"),
("endpoint engineer", "Endpoint Engineer"),
("intune engineer", "Intune Engineer"),
),
),
(
"Andere IT-richtingen",
(
("cloud engineer", "Cloud Engineer"),
("devops engineer", "DevOps Engineer"),
("security engineer", "Security Engineer"),
("platform engineer", "Platform Engineer"),
("software engineer", "Software Engineer"),
("data engineer", "Data Engineer"),
("solution architect", "Solution Architect"),
("project manager", "IT-projectmanager"),
),
),
)
EXCLUDED_TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Commercieel & werving",
(
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("business developer", "Business Developer"),
("callcenter", "Callcenter"),
),
),
(
"Software, data & analyse",
(
("software developer", "Software Developer"),
("software engineer", "Software Engineer"),
("web developer", "Web Developer"),
("developer", "Developmentfuncties (breed)"),
("software", "Softwarefuncties (breed)"),
("backend", "Backendfuncties"),
("frontend", "Frontendfuncties"),
("php", "PHP-ontwikkeling"),
("data engineer", "Data Engineer"),
("data scientist", "Data Scientist"),
("data", "Datafuncties (breed)"),
("ai engineer", "AI Engineer"),
("ai", "AI-functies (breed)"),
("machine learning", "Machine Learning"),
("functional analyst", "Functional Analyst"),
("functioneel analist", "Functioneel Analist"),
("business analyst", "Business Analyst"),
),
),
(
"Management & starters",
(
("product owner", "Product Owner"),
("project manager", "Projectmanager"),
("architect", "Architectrollen"),
("service delivery manager", "Service Delivery Manager"),
("team lead", "Teamlead"),
("head of", "Head-of-rollen"),
("procurement", "Procurement"),
("stage", "Stage"),
("internship", "Internship"),
("student", "Studentenjob"),
),
),
(
"Niet-IT-techniek",
(
("electromechanical", "Elektromechanica"),
("maintenance technician", "Onderhoudstechnieker"),
("process engineer", "Process Engineer"),
("automation engineer", "Automation Engineer"),
),
),
(
"Onderzoek & beeldverwerking",
(
("research", "Onderzoeksfuncties"),
("scientist", "Scientist"),
("image processing", "Beeldverwerking"),
("remote sensing", "Remote sensing"),
),
),
(
"Cloud & businessplatformen",
(
("cloud", "Cloudfuncties (breed)"),
("devops", "DevOps"),
("azure consultant", "Azure Consultant"),
("sap", "SAP"),
("power platform", "Power Platform"),
),
),
(
"Onderwijs",
(
("lecturer", "Lecturer"),
("lector", "Lector"),
("docent", "Docent"),
("gastdocent", "Gastdocent"),
),
),
)
SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Field service & uitvoering",
(
("troubleshooting", "Troubleshooting"),
("onsite support", "On-site support"),
("hardware", "Hardware & randapparatuur"),
("installations", "Installaties & roll-outs"),
("migrations", "Migraties"),
("technical documentation", "Technische documentatie"),
("customer support", "Klantondersteuning"),
("second line support", "Tweede- en derdelijnssupport"),
("customer experience", "Klanttevredenheid / CSAT"),
),
),
(
"Microsoft & endpoint",
(
("microsoft 365", "Microsoft 365"),
("exchange online", "Exchange Online"),
("sharepoint", "SharePoint"),
("microsoft teams", "Microsoft Teams"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("autopilot", "Windows Autopilot"),
("windows server", "Windows Server"),
("active directory", "Active Directory"),
("group policy", "Group Policy / GPO"),
("dynamics 365", "Microsoft Dynamics 365"),
("microsoft copilot", "Microsoft Copilot"),
("microsoft defender", "Microsoft Defender"),
("microsoft sentinel", "Microsoft Sentinel"),
("conditional access", "Conditional Access"),
),
),
(
"Netwerk & security",
(
("networking", "Netwerken"),
("tcp/ip", "TCP/IP"),
("vlan", "VLAN"),
("vpn", "VPN"),
("dhcp", "DHCP"),
("dns", "DNS"),
("firewalls", "Firewalls"),
("watchguard", "WatchGuard"),
("cisco", "Cisco"),
("ubiquiti", "Ubiquiti"),
("wi-fi", "Wi-Fi"),
("routing", "Routing"),
("switching", "Switching"),
("sd-wan", "SD-WAN"),
("fortinet", "Fortinet / FortiGate"),
("palo alto", "Palo Alto Networks"),
("aruba", "HPE Aruba"),
("meraki", "Cisco Meraki"),
("sophos", "Sophos"),
("zero trust", "Zero Trust"),
),
),
(
"Back-up & monitoring",
(
("backup and restore", "Back-up & restore"),
("veeam", "Veeam"),
("windows server backup", "Windows Server Backup"),
("synology", "Synology"),
("zabbix", "Zabbix"),
("monitoring", "Infrastructuurmonitoring"),
("observability", "Observability"),
("high availability", "High availability"),
("disaster recovery", "Disaster recovery"),
),
),
(
"Virtualisatie & platform",
(
("vmware", "VMware"),
("proxmox", "Proxmox"),
("hyper-v", "Hyper-V"),
("docker", "Docker"),
("azure", "Microsoft Azure"),
("aws", "AWS"),
("linux", "Linux"),
("kubernetes", "Kubernetes"),
("terraform", "Terraform"),
("ci/cd", "CI/CD"),
("deployment", "Deployment & uitrol"),
("reliability", "Platform reliability"),
("scalability", "Schaalbaarheid"),
),
),
(
"VoIP & telefonie",
(
("voip", "VoIP"),
("3cx", "3CX"),
("innovaphone", "Innovaphone"),
("teams telephony", "Teams-telefonie"),
("yealink", "Yealink"),
("snom", "Snom"),
),
),
(
"Automation & scripting",
(
("powershell", "PowerShell"),
("bash", "Bash"),
("python", "Python"),
("c#", "C#"),
),
),
(
"IT-servicemanagement",
(
("itil", "ITIL / IT-servicemanagement"),
("incident management", "Incidentmanagement"),
("problem management", "Problemmanagement"),
("change management", "Changemanagement"),
("servicenow", "ServiceNow"),
("jira service management", "Jira Service Management"),
),
),
(
"Digitale werkplek & adoptie",
(
("digital workplace", "Digital Workplace"),
("m365 governance", "Microsoft 365-governance"),
("document management", "Documentmanagement"),
("data governance", "Datagovernance"),
("user adoption", "Gebruikersadoptie"),
("training and workshops", "Training & workshops"),
),
),
)
EXCLUDED_SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Commerciële voorwaarden",
(
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
),
),
(
"Werkregeling",
(
("night shift", "Nachtwerk"),
("rotating shifts", "Ploegendienst"),
("on-call duty", "Structurele wachtdienst"),
),
),
)
def iter_choices(choices: Iterable[Choice | tuple[str, Sequence[Choice]]]) -> Iterator[Choice]:
"""Flatten Django optgroups while retaining compatibility with legacy flat choices."""
for value, label_or_choices in choices:
if isinstance(label_or_choices, tuple | list):
yield from iter_choices(label_or_choices)
else:
yield str(value), str(label_or_choices)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import ProfileListView, ProfileUpdateView, activate_profile
urlpatterns = [
path("", ProfileListView.as_view(), name="list"),
path("<int:pk>/", ProfileUpdateView.as_view(), name="edit"),
path("<int:pk>/activate/", activate_profile, name="activate"),
]
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView
from apps.jobs.models import JobPosting, ScoreRun
from .forms import SearchProfileForm
from .models import SearchProfile
from .services import resolve_profile_home_location, save_profile_revision
class ProfileListView(LoginRequiredMixin, ListView):
model = SearchProfile
template_name = "profiles/list.html"
context_object_name = "profiles"
def get_queryset(self):
return SearchProfile.objects.filter(user=self.request.user)
class ProfileUpdateView(LoginRequiredMixin, UpdateView):
model = SearchProfile
form_class = SearchProfileForm
template_name = "profiles/edit.html"
success_url = reverse_lazy("profiles:list")
def get_queryset(self):
return SearchProfile.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["preview_score"] = (
ScoreRun.objects.filter(
profile=self.object,
job__status=JobPosting.Status.ACTIVE,
hard_exclusions=[],
)
.select_related("job", "job__employer")
.order_by("-created_at")
.first()
)
context["recent_revisions"] = list(self.object.revisions.all()[:4])
return context
def form_valid(self, form):
response = super().form_valid(form)
location_result = resolve_profile_home_location(self.object)
if self.object.is_active:
self.object.activate()
save_profile_revision(self.object, reason="user_edit")
if self.object.home_postal_code and location_result.ambiguous:
messages.warning(
self.request,
"Zoekprofiel opgeslagen. Deze postcode omvat meerdere gemeenten; afstand blijft "
"onbekend tot de lokale geodata eenduidig is.",
)
elif self.object.home_postal_code and location_result.location is None:
messages.warning(
self.request,
"Zoekprofiel opgeslagen. De postcode is bewaard; lokale geodata ontbreekt nog "
"voor een afstandsberekening.",
)
else:
messages.success(self.request, "Zoekprofiel opgeslagen.")
return response
def activate_profile(request, pk: int):
if request.method != "POST":
return redirect("profiles:list")
profile = get_object_or_404(SearchProfile, pk=pk, user=request.user)
profile.activate()
messages.success(request, f"{profile.name} is nu het actieve profiel.")
return redirect("profiles:list")