Initial deploy setup
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
View File
+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"
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
def default_weights() -> dict[str, float]:
return {
"content": 25.0,
"skills": 20.0,
"location": 15.0,
"conditions": 10.0,
"employer": 10.0,
"seniority": 10.0,
"preferences": 10.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]
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from django import forms
from .models import SearchProfile
class SearchProfileForm(forms.ModelForm):
desired_titles_text = forms.CharField(
label="Gewenste functietitels",
required=False,
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "Eén titel per regel"}),
)
excluded_titles_text = forms.CharField(
label="Uitgesloten titelwoorden",
required=False,
widget=forms.Textarea(attrs={"rows": 3, "placeholder": "Eén term per regel"}),
)
desired_skills_text = forms.CharField(
label="Gewenste skills",
required=False,
widget=forms.Textarea(attrs={"rows": 4, "placeholder": "Eén skill per regel"}),
)
excluded_skills_text = forms.CharField(
label="Uitgesloten skills",
required=False,
widget=forms.Textarea(attrs={"rows": 3, "placeholder": "Eén skill per regel"}),
)
class Meta:
model = SearchProfile
fields = [
"name",
"is_active",
"home_postal_code",
"home_municipality",
"home_latitude",
"home_longitude",
"max_distance_km",
"allowed_employment_types",
"preferred_workplace",
"preferred_regions",
"excluded_regions",
"recommendation_threshold",
"top_match_threshold",
"digest_time",
"learning_enabled",
]
widgets = {
"allowed_employment_types": forms.TextInput(
attrs={"placeholder": '["full_time", "part_time"]'}
),
"preferred_workplace": forms.TextInput(attrs={"placeholder": '["hybrid", "on_site"]'}),
"preferred_regions": forms.TextInput(attrs={"placeholder": '["Limburg"]'}),
"excluded_regions": forms.TextInput(attrs={"placeholder": '["Brussel"]'}),
"digest_time": forms.TimeInput(attrs={"type": "time"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance and self.instance.pk:
self.fields["desired_titles_text"].initial = "\n".join(self.instance.desired_titles)
self.fields["excluded_titles_text"].initial = "\n".join(self.instance.excluded_titles)
self.fields["desired_skills_text"].initial = "\n".join(self.instance.desired_skills)
self.fields["excluded_skills_text"].initial = "\n".join(self.instance.excluded_skills)
@staticmethod
def _lines(value: str) -> list[str]:
return [line.strip() for line in value.splitlines() if line.strip()]
def save(self, commit=True):
instance = super().save(commit=False)
instance.desired_titles = self._lines(self.cleaned_data["desired_titles_text"])
instance.excluded_titles = self._lines(self.cleaned_data["excluded_titles_text"])
instance.desired_skills = self._lines(self.cleaned_data["desired_skills_text"])
instance.excluded_skills = self._lines(self.cleaned_data["excluded_skills_text"])
if commit:
instance.save()
self.save_m2m()
return instance
+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),
),
]
+157
View File
@@ -0,0 +1,157 @@
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,
)
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)
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,
"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}"
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from django.db import transaction
from .models import ProfileRevision, SearchProfile
@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
+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"),
]
+47
View File
@@ -0,0 +1,47 @@
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 .forms import SearchProfileForm
from .models import SearchProfile
from .services import 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 form_valid(self, form):
response = super().form_valid(form)
if self.object.is_active:
self.object.activate()
save_profile_revision(self.object, reason="user_edit")
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")