Author SHA1 Message Date
Jens cbd7220d8d perf: optimize import persistence for 0.3.14
deploy / deploy (push) Canceled after 0s
2026-07-29 18:56:11 +02:00
Jens 0d953173a8 docs: record final production evidence
deploy / deploy (push) Canceled after 0s
2026-07-29 17:37:55 +02:00
Jens a4a71ebc23 fix: harden production release deployment
deploy / deploy (push) Canceled after 0s
2026-07-29 17:36:03 +02:00
Jens 5e2453c29a feat: professionalize platform release 0.3.13
deploy / deploy (push) Canceled after 0s
2026-07-29 16:35:37 +02:00
Jens 78fcf4fa5f docs: expand readme product gallery
deploy / deploy (push) Canceled after 0s
2026-07-28 07:21:50 +02:00
Jens 61349e9b75 Bugfixing
deploy / deploy (push) Canceled after 0s
2026-07-28 04:43:20 +02:00
Jens 7caea3a1ee demo mode
deploy / deploy (push) Canceled after 0s
2026-07-27 23:38:37 +02:00
Jens d28fd05bf6 docs: make readme user-first with product screenshots
deploy / deploy (push) Canceled after 0s
2026-07-27 14:28:14 +02:00
Jens 6a505047f5 docs: record widescreen refinement deployment
deploy / deploy (push) Canceled after 0s
2026-07-27 03:31:53 +02:00
Jens 695b553672 feat: refine transparent brand and widescreen shell
deploy / deploy (push) Canceled after 0s
2026-07-27 03:30:24 +02:00
Jens f36af616a1 docs: record visual audit deployment evidence
deploy / deploy (push) Canceled after 0s
2026-07-26 05:06:05 +02:00
Jens fd5b50b8fe feat: complete premium visual audit and brand refinement
deploy / deploy (push) Canceled after 0s
2026-07-26 05:03:53 +02:00
Jens 2a32e9fec9 feat: integrate ITWorx branding and refine cockpit UX
deploy / deploy (push) Canceled after 0s
2026-07-26 04:13:42 +02:00
Jens eea74f97ef feat: verfijn UX en borg Unraid deployment
deploy / deploy (push) Canceled after 0s
2026-07-26 02:26:45 +02:00
Jens 6c09d16a02 fix: locate PostgreSQL binaries in Unraid image
deploy / deploy (push) Canceled after 0s
2026-07-25 23:18:50 +02:00
Jens 8648b4f4d1 deploy: consolidate Unraid stack in Dockerman
deploy / deploy (push) Canceled after 0s
2026-07-25 23:17:01 +02:00
Jens bdf44eec04 Refresh 2026-07-24 22:46:37 +02:00
Jens 0101660bd7 docs: complete Stitch frontend replacement 2026-07-22 20:48:48 +02:00
Jens 5f435d030e perf: eliminate source health N plus one queries 2026-07-22 20:37:01 +02:00
Jens ee915742bf fix: validate responsive Stitch cockpit 2026-07-22 20:32:38 +02:00
Jens 4acd81afb5 feat: finish Stitch skills and authentication screens 2026-07-22 20:21:55 +02:00
Jens afe18e5b96 feat: add automation activity and employer intelligence 2026-07-22 20:17:20 +02:00
Jens 4fa2a34447 feat: replace pipeline profile and source health 2026-07-22 20:10:34 +02:00
Jens 3371455255 feat: replace dashboard and vacancy intelligence 2026-07-22 19:56:15 +02:00
Jens 09af98193f feat: establish Stitch cockpit frontend contract 2026-07-22 19:48:36 +02:00
Jens a30ecbc553 feat: personalize scoring and add skills radar 2026-07-22 19:33:29 +02:00
Jens 029df89265 feat: ship premium IT-focused vacancy radar
deploy / deploy (push) Canceled after 0s
2026-07-22 15:16:15 +02:00
Jens 7d3d026d1d fix: validate connected peer for rotating CDNs
deploy / deploy (push) Canceled after 0s
2026-07-22 05:56:30 +02:00
Jens e234641c36 fix: route celery tasks to consumed queue
deploy / deploy (push) Canceled after 0s
2026-07-22 05:43:08 +02:00
197 changed files with 12360 additions and 2297 deletions
+1
View File
@@ -12,3 +12,4 @@ docs/reference/*.docx
docs/reference/*.pdf docs/reference/*.pdf
docs/design/stitch-reference docs/design/stitch-reference
docs/design/validation docs/design/validation
artifacts
+33
View File
@@ -1,9 +1,13 @@
# Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME. # Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME.
DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value
DJANGO_DEBUG=1 DJANGO_DEBUG=1
# Leeg voor lokaal gebruik. In productie: https://vacatureradar.example.be
PUBLIC_BASE_URL=
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226 DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
DJANGO_TIME_ZONE=Europe/Brussels DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.14
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling. # Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
POSTGRES_DB=vacatureradar POSTGRES_DB=vacatureradar
@@ -13,6 +17,9 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432 POSTGRES_PORT=5432
DATABASE_URL= DATABASE_URL=
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
# Gedeelde cache voor rate limiting en readiness; gebruik een afzonderlijke Redis-database.
CACHE_URL=redis://redis:6379/1
HEALTHCHECK_REQUIRE_CACHE=1
CELERY_TASK_ALWAYS_EAGER=0 CELERY_TASK_ALWAYS_EAGER=0
# Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat. # Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat.
@@ -51,11 +58,37 @@ OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL= OLLAMA_MODEL=
OLLAMA_TIMEOUT_SECONDS=60 OLLAMA_TIMEOUT_SECONDS=60
# Microsoft Entra ID (Azure AD) SSO. Laat ENTRA_ID_ENABLED=0 om SSO uit te zetten.
# Registreer een app in Entra ID en zet de redirect-URI op <host>/auth/entra/callback/.
ENTRA_ID_ENABLED=0
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=CHANGE_ME_entra_client_secret
ENTRA_TENANT_ID=
# Optioneel: overschrijf de redirect-URI (anders automatisch afgeleid van de host).
ENTRA_REDIRECT_URI=
# Optioneel: beperk tot bepaalde e-maildomeinen, komma-gescheiden (bv. organisatie.nl).
ENTRA_ALLOWED_DOMAINS=
ENTRA_SCOPES=User.Read
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
DEMO_MODE_ENABLED=1
DEMO_READ_ONLY=1
DEMO_SESSION_SECONDS=3600
DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local
# Beveiliging # Beveiliging
# Zet TRUST_PROXY_HEADERS alleen aan achter een beheerde proxy die Host en X-Forwarded-Proto overschrijft.
TRUST_PROXY_HEADERS=0
USE_X_FORWARDED_HOST=0
# Alleen nodig wanneer client-IPheaders voor rate limiting worden vertrouwd.
TRUSTED_PROXY_CIDRS=
SESSION_COOKIE_SECURE=0 SESSION_COOKIE_SECURE=0
CSRF_COOKIE_SECURE=0 CSRF_COOKIE_SECURE=0
SECURE_SSL_REDIRECT=0 SECURE_SSL_REDIRECT=0
SECURE_HSTS_SECONDS=0 SECURE_HSTS_SECONDS=0
SEARCH_ENGINE_INDEXING_ENABLED=0
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8 AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300 AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300 AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
+109
View File
@@ -0,0 +1,109 @@
# VacatureRadar — all-in-one container (docker-compose.unraid.yml)
#
# Gebruik:
# cp .env.unraid.example .env
# # vervang ALLE CHANGE_ME-waarden
# docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
#
# Dit profiel draait rechtstreeks op http://<server-ip>:1226 zonder TLS.
# Zet pas een reverse proxy ervoor via deployment/production.env.example
# (SECURE_SSL_REDIRECT, cookie-secure en HSTS staan hier bewust uit).
#
# PostgreSQL en Redis draaien IN de container op 127.0.0.1; laat die waarden staan.
# Genereer met: python -c "import secrets; print(secrets.token_urlsafe(64))"
DJANGO_SECRET_KEY=CHANGE_ME_generate_at_least_50_random_characters
DJANGO_DEBUG=0
# Leeg laten tot er een publieke HTTPS-URL is.
PUBLIC_BASE_URL=
DJANGO_ALLOWED_HOSTS=192.168.10.150,127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226,http://127.0.0.1:1226
DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.12
# Interne database en cache. Niet wijzigen voor de AIO-container.
POSTGRES_DB=vacatureradar
POSTGRES_USER=vacatureradar
POSTGRES_PASSWORD=CHANGE_ME_unique_database_password
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
DATABASE_URL=
REDIS_URL=redis://127.0.0.1:6379/0
CACHE_URL=redis://127.0.0.1:6379/1
HEALTHCHECK_REQUIRE_CACHE=1
CELERY_TASK_ALWAYS_EAGER=0
# Maakt bij een lege database eenmalig het beheerdersaccount aan.
# Inloggen kan met de gebruikersnaam of het e-mailadres.
VACATURERADAR_ADMIN_USERNAME=jens@itworx.tech
VACATURERADAR_ADMIN_EMAIL=jens@itworx.tech
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
VACATURERADAR_AUTO_BOOTSTRAP=1
DEMO_MODE_ENABLED=0
DEMO_READ_ONLY=1
DEMO_SESSION_SECONDS=3600
DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local
# Geen proxy voor de container: laat deze op 0 tot NPM/Traefik ervoor staat.
TRUST_PROXY_HEADERS=0
USE_X_FORWARDED_HOST=0
TRUSTED_PROXY_CIDRS=
SESSION_COOKIE_SECURE=0
CSRF_COOKIE_SECURE=0
SECURE_SSL_REDIRECT=0
SECURE_HSTS_SECONDS=0
SECURE_HSTS_INCLUDE_SUBDOMAINS=0
SECURE_HSTS_PRELOAD=0
SEARCH_ENGINE_INDEXING_ENABLED=0
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS=12
MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS=120
MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS=300
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=VacatureRadar <vacatureradar@localhost>
DIGEST_RECIPIENT=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_USE_TLS=1
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
FETCHER_USER_AGENT=VacatureRadar/0.1 (+personal-use; contact=local-admin)
FETCHER_TIMEOUT_SECONDS=20
FETCHER_MAX_BYTES=3000000
FETCHER_MAX_REDIRECTS=5
FETCHER_ALLOW_NONSTANDARD_PORTS=0
SOURCE_POLICY_MODE=strict
OLLAMA_ENABLED=0
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=
OLLAMA_TIMEOUT_SECONDS=60
ENTRA_ID_ENABLED=0
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
ENTRA_TENANT_ID=
ENTRA_REDIRECT_URI=
ENTRA_ALLOWED_DOMAINS=
ENTRA_SCOPES=User.Read
# Optioneel: pad in de container naar een pg_dump-bestand dat bij een lege
# database eenmalig wordt teruggezet, bv. /app/local/backups/aio-migration.dump
VACATURERADAR_RESTORE_DUMP=
# Optioneel: andere hostpoort dan 1226 publiceren.
VACATURERADAR_HOST_PORT=1226
+21
View File
@@ -24,6 +24,8 @@ jobs:
run: python -m pip install --disable-pip-version-check uv==0.10.0 run: python -m pip install --disable-pip-version-check uv==0.10.0
- name: Sync locked dependencies - name: Sync locked dependencies
run: uv sync --frozen --all-groups run: uv sync --frozen --all-groups
- name: Install Chromium for mandatory browser tests
run: uv run playwright install --with-deps chromium
- name: Secret scan - name: Secret scan
uses: trufflesecurity/trufflehog@v3.79.0 uses: trufflesecurity/trufflehog@v3.79.0
with: with:
@@ -47,6 +49,13 @@ jobs:
IMAP_ENABLED: "0" IMAP_ENABLED: "0"
OLLAMA_ENABLED: "0" OLLAMA_ENABLED: "0"
run: ./scripts/codex_verify.sh run: ./scripts/codex_verify.sh
- name: Upload browser failure artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: browser-failure-artifacts
path: artifacts/browser-tests/
if-no-files-found: ignore
container: container:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -64,5 +73,17 @@ jobs:
severity: HIGH,CRITICAL severity: HIGH,CRITICAL
exit-code: "1" exit-code: "1"
ignore-unfixed: true ignore-unfixed: true
- name: Generate CycloneDX software bill of materials
uses: aquasecurity/trivy-action@0.30.0
with:
image-ref: vacatureradar:ci
format: cyclonedx
output: vacatureradar-sbom.cdx.json
scan-type: image
- name: Upload software bill of materials
uses: actions/upload-artifact@v4
with:
name: vacatureradar-sbom
path: vacatureradar-sbom.cdx.json
- name: Inspect image configuration - name: Inspect image configuration
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
+1
View File
@@ -17,6 +17,7 @@ local/
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.unraid.example
secrets/ secrets/
# IDE / OS # IDE / OS
+7
View File
@@ -28,6 +28,13 @@ Lees niet blind alle referentiedocumenten. Gebruik `docs/quality/TRACEABILITY_MA
- Geen productieafhankelijkheid toevoegen zonder duidelijke noodzaak, lockfile-update, tests en documentatie. - Geen productieafhankelijkheid toevoegen zonder duidelijke noodzaak, lockfile-update, tests en documentatie.
- Geen secrets, tokens, echte e-mailadressen of persoonlijke gegevens committen. - Geen secrets, tokens, echte e-mailadressen of persoonlijke gegevens committen.
## Frontendcontract
- De productiefrontend volgt `docs/design/STITCH_FRONTEND_CONTRACT.md`; Stitch is de visuele bron van waarheid, de Django-backend blijft leidend voor werking en data.
- Gebruik uitsluitend echte of eerlijk als onbekend gemarkeerde data. Geen demo-KPI's, nepstatussen, dode controls, auto-apply of externe runtimeassets.
- Kernflows blijven server-rendered en bruikbaar zonder JavaScript; afwijkingen voor security, toegankelijkheid en ontbrekende data worden gedocumenteerd.
- Na een frontendmigratie blijft geen parallelle legacy-shell, ongebruikte componentstijl of verborgen oude UI bestaan.
## Codeconventies ## Codeconventies
- Python 3.12/3.13; type hints op publieke services. - Python 3.12/3.13; type hints op publieke services.
+12
View File
@@ -0,0 +1,12 @@
# Architectuur
VacatureRadar gebruikt een Django-modulaire monoliet met PostgreSQL, Redis en Celery. Bedrijfsregels staan in domeinservices, views en tasks orkestreren en bronadapters leveren `ExtractionResult` zonder databasewrites.
Lees verder:
- [Systeemarchitectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
- [Datastroom](docs/architecture/DATA_FLOW.md)
- [Datamodel](docs/architecture/DATA_MODEL.md)
- [Bronadapters](docs/architecture/SOURCE_ADAPTERS.md)
- [Securityarchitectuur](docs/architecture/SECURITY_ARCHITECTURE.md)
- [Scoring](docs/architecture/SCORING_ENGINE.md)
+7
View File
@@ -0,0 +1,7 @@
# Back-up en herstel
De uitvoerbare procedure, retentie, veilige testrestore en vergelijkingsstappen staan in [docs/operations/BACKUP_RESTORE.md](docs/operations/BACKUP_RESTORE.md). Een productieback-up geldt pas als bewezen nadat hij naar een geïsoleerd doel is hersteld en vergeleken.
Op 2026-07-29 is de productieback-up werkelijk gevalideerd. Het bewijs staat in `/mnt/user/appdata/VacatureRadar/backups/release-20260729T151304Z` en bevat een PostgreSQL custom dump, media-archief, composebestand, env-fingerprint, recordtellingen en `SHA256SUMS`. De dump is als normale applicatierol hersteld naar de geïsoleerde database `vacatureradar_restore_20260729`; migrations, kernrecordaantallen en een afzonderlijke Gunicorn-readinessprobe slaagden. Het testdoel is pas daarna verwijderd.
Maak alleen de lege restoredatabase met de PostgreSQL-beheerrol en voer `pg_restore` als `vacatureradar` uit. Herstellen als beheerrol met `--no-owner` laat tabellen anders aan de verkeerde rol toebehoren.
+125 -1
View File
@@ -2,6 +2,128 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak. Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
## 0.3.14 - 2026-07-29
### Gewijzigd
- De importpipeline hergebruikt werkgevers en actieve profielen binnen één run, synchroniseert provenance gebundeld en vermijdt volledige rescoring van inhoudelijk ongewijzigde vacatures terwijl ScoreRun-historiek behouden blijft.
- `last_changed` wijzigt alleen nog bij een inhoudelijke vacaturewijziging; een nieuwe waarneming actualiseert uitsluitend `last_seen`.
- Een fail-closed PostgreSQL-runner levert één warm-up, drie metingen, p50/p95, throughput, geheugen en queryfamilies als machineleesbaar rapport.
### Performance
- De officiële 250-itemmeting is gemiddeld 44,74% sneller, gebruikt minstens 63,73% minder queries en verwerkt 83,42% meer documenten per seconde dan 0.3.13.
## Niet uitgebracht
### Toegevoegd
- `PUBLIC_BASE_URL` als één productiebron voor hostvalidatie, CSRF-origin, secure cookies en HTTPS-redirect.
- Een idempotente `configure_public_url.py`-helper voor bestaande `.env`-bestanden achter Nginx Proxy Manager.
- Een concrete Nginx Proxy Manager-runbook, productie-envvoorbeeld, publieke `robots.txt` en cache-readinesscheck.
- Server-side alleen-lezenbescherming en begrensde sessies voor de gedeelde gastdemo.
### Gewijzigd
- De repository-README volgt nu de gebruikersreis van doel en dagelijkse workflow naar quickstart, configuratie, veiligheidsgrenzen, beheer en pas daarna architectuur en agentoverdracht.
- Negen actuele, compacte productbeelden vertellen nu de volledige gebruikersreis: dagoverzicht, vacatureverkenner, beslisanalyse, Skillsradar, werkgevers, sollicitaties, bronnen, automatisering en mobiel zoekprofiel.
- De README-galerij gebruikt taakgerichte captions, klikbare beelden, een consistente 1920×1080-desktopviewport en een gebalanceerde dark/light-compositie.
- Het VacatureRadar-productlogo is hertekend als transparante vier-elementenradar zonder donkere tegel, rand of schaduw; login en sidebar gebruiken hetzelfde cleaner beeldmerk.
- De sidebarfooter combineert radarstatus en een transparante, themabewuste ITWorx-credit; de dubbele demo-/accountidentiteit is verwijderd en blijft uitsluitend via het topbarmenu bereikbaar.
- Widescreencontent schaalt nu gecontroleerd naar 2.200 px en op 4K naar 2.480 px, terwijl dashboardverhoudingen, korte viewports en mobiele layouts begrensd blijven.
- De volledige visuele audit is vertaald naar één gedeeld contentframe voor topbar en pagina, een dashboard met maximaal vijf kernmatches en een compactere activiteitstijdlijn.
- Secundaire actielinks hebben grotere klikdoelen, reduced motion dekt alle animaties en geïmporteerde stylesheets delen voortaan expliciet de releasecacheversie.
- De ultrawide cockpit gebruikt één begrensd, links verankerd grid; headers, filters, pipeline, KPI's en detailpanelen delen daardoor op brede schermen hetzelfde startpunt zonder uitgerekte leesregels.
- VacatureRadar heeft een zelfstandig open-radar-beeldmerk met scanstraal en kanssignaal voor shell, login, favicon en Dockerman; de officiële ITWorx.tech-wordmark staat uitsluitend nog als subtiele `Ontwikkeld door`-credit.
- VacatureRadar gebruikt nu één scherper radarbeeldmerk voor favicon, login en applicatieshell; de persoonlijke eigenaar is via `VACATURERADAR_OWNER_NAME` configureerbaar.
- De bovenbalk geeft naast zoeken ook eigenaar en actuele werkcontext aan; navigatie-, focus- en hoverstates zijn verfijnd zonder de compacte mobiele cockpit te verzwaren.
- Het zoekprofiel bevat een lokale interactieve radarillustratie met pointerparallax, een rustige scansweep, light/dark-kleuren en een statische reduced-motionvariant; de illustratie is decoratief en introduceert geen fictieve data.
- De Unraid-heruitrol gebruikt een gevalideerde SSH-hostalias met afzonderlijke deploysleutel en voert synchronisatie, build, healthwait en readiness-smoke als één reproduceerbaar commando uit.
- De cockpit heeft een premium “calm intelligence”-laag met zachtere oppervlaktescheidingen, selectief cyan, humane navigatietypografie en een duidelijkere componenthiërarchie in donker en licht thema.
- Mobiele kerncijfers staan compact in een 2×2-raster; vacaturefilters vormen een responsieve commandobar en de kanbanpipeline scrolt per kolom zonder documentoverflow.
- Dashboardkaarten, vacature-intelligence en het zoekprofiel gebruiken rustigere composities met minder decoratieve randen en beter gedoseerde elevatie.
### Hersteld
- De productieconfiguratiehelper voldoet opnieuw aan Ruff en de `.env`-bestandmodustest houdt rekening met Windows, waar POSIX-modusbits niet afdwingbaar zijn.
- Publieke Nginx-hostnamen veroorzaken niet langer `DisallowedHost` wanneer `PUBLIC_BASE_URL` correct staat.
- HTTPS-deployment gebruikt de externe poort in plaats van de interne applicatiepoort voor CSRF en publieke URLs.
- Interne healthchecks blijven bereikbaar bij `SECURE_SSL_REDIRECT=1`; productie-rate-limiting gebruikt een gedeelde Redis-cache.
- Forwarded client-IPheaders worden alleen vertrouwd vanaf expliciet ingestelde proxy-CIDRs.
- Productiechecks kunnen de URL-configuratie laden voordat een staticfilesmanifest aanwezig is.
- De optionele Playwrightfallback voert geen synchrone databasecall meer uit binnen een async context.
- Op mobiel overlappen `Wachtwoord` en `Wachtwoord vergeten?` niet meer; loginiconen zijn zelfstandige lijn-SVG's en de dubbele herstel-link is verwijderd.
- De Unraid-synchelper negeert verwijderde indexpaden bij een release vanuit een geldige dirty worktree; vervangen merkassets blokkeren de archiefopbouw daardoor niet meer.
- Rechtstreekse SSH naar het Unraid-IP koos lokaal de verkeerde gebruiker en identiteit; het gedocumenteerde aliasprofiel selecteert nu reproduceerbaar `root` met de afzonderlijke deploysleutel.
- Inloginteracties laden nu als lokale externe JavaScriptasset; de eigen Content Security Policy blokkeert wachtwoordweergave en submitfeedback niet langer.
- Het Unraid/Dockerman-containericoon gebruikt nu een lokale 256×256 PNG via de gepubliceerde webpoort in plaats van de fragiele SVG-favicon-URL.
- De icoon-URL gebruikt een concrete server-side loopback-URL; Dockermans niet-ondersteunde `[IP]`/`[PORT]`-placeholders verhinderden anders het vullen van de icooncache.
- De deploymentregressietest controleert de volledige WebUI-/icoonlabels en valideert dat de verpakte icoonasset werkelijk een PNG van 256×256 pixels is.
## 0.3.0 — 2026-07-22
### Gewijzigd
- De voormalige MVP-frontend is volledig vervangen door één high-fidelity, server-rendered Stitch Intelligence Cockpit met nieuwe lokale tokens, shell, SVG-iconen, responsive lagen en progressive enhancement.
- Alle negen Stitch-schermen zijn functioneel gekoppeld: dashboard, vacatureverkenner/detail, sollicitatiepipeline, zoekprofiel, brongezondheid, automatisering, activiteit en werkgeversintelligence.
- Login, skillsradar, dossieredit, foutpagina's, empty states en messages gebruiken dezelfde componenttaal; de normale productiefrontend heeft geen legacyflag of parallelle shell.
- Nieuwe read services leveren echte dashboard-, vacature-, activiteit-, werkgever- en automationdata; vacaturefilters tonen standaard alleen aantoonbaar IT-relevante kansen.
- Sollicitatiestatus is een gebruikersgescopeerde CSRF-POST met audittrail. Niet-bestaande runtime- of marktdata blijft expliciet onbekend in plaats van gesimuleerd.
- Bronhealth gebruikt één geslicete prefetch voor alle bronnen; het automatiseringsscherm daalde in de representatieve dataset van 63 naar 23 queries.
### Verificatie
- De geïntegreerde Codex-browser controleerde alle negen schermen op desktop en 390 px, plus kernflows op 360, 768, 1024, 1280, 1440, 1920 en 2560 px zonder documentoverflow, kapotte assets of consolewaarschuwingen.
- Elf screenshots staan onder `artifacts/visual-validation/`; dark/light, mobiele drawer en toegankelijke actielabels zijn functioneel getest.
- Gerichte regressies, Ruff, Django system check, migratiecheck en de queryregressietest zijn groen; de volledige gate-uitkomst staat in `docs/ai/PROJECT_STATE.md`.
## 0.2.13 — 2026-07-22
### Gewijzigd
- Ervaringsjaren en senioriteitslabels zijn volledig uit de deterministische én AI-score gehaald; de profielwaarde blijft uitsluitend informatieve context en bestaande senioriteitsgewichten worden via migratie verwijderd.
- De nieuwe persoonlijke Skillsradar analyseert alleen actieve, IT-relevante, niet-uitgesloten vacatures van de huidige profielversie en negeert vacatures waarvan alle bronaliassen uitgeschakeld zijn.
- Per technologie of capability toont de radar vacaturevolume, profieldekking, skillveldbewijs versus tekstinferentie, onderliggende vacatures en een niet-blokkerende mogelijke leer-/bijscholingsstap.
- Categorie- en dekkingsfilters maken de marktvraag doorzoekbaar; de profielcheckboxes zijn uitgebreid met actuele Microsoft-, netwerk/security-, observability-, platform-, ITSM- en adoptieskills.
- De Stitch-cockpit gebruikt een aparte vraag-/leerkansencompositie met adaptieve kaarten, context-rail en tweekoloms skillfeed op ultrawide.
### Verificatie
- 240 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,73% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsercontrole bevestigde filtering en bronselectie plus layouts op 2560×1440, 1440×900 en 390×844 zonder horizontale paginaoverflow of consolewaarschuwingen.
## 0.2.12 — 2026-07-22
### Gewijzigd
- De zoekprofieleditor biedt gegroepeerde selecties voor field service, infrastructuur, Modern Workplace, netwerk/security, back-up/monitoring, virtualisatie, VoIP en scripting.
- Relevante IT-ervaring is configureerbaar als ervaringsband en wordt alleen met expliciet gevraagde vacaturejaren vergeleken.
- Een breed cv-profiel verwatert de skillscore niet onbeperkt: vier aangetroffen skills volstaan voor de volledige skillcomponent en niet-vermelde voorkeurskills zijn geen bewezen tekort.
- Ambigue field-/implementatietitels vereisen naast de titel een sterk IT-contextsignaal in de vacaturetekst; korte en meervoudige uitsluittermen gebruiken woordgrenzen.
- De mobiele categoriekaarten stapelen op één kolom en behouden de Sticky-opslagactie zonder horizontale paginaoverflow.
### Verificatie
- 232 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,59% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsercontrole bevestigde de gegroepeerde editor op ultrawide en 390 px, de ervaringsband, lokale profielselecties en afwezigheid van horizontale paginaoverflow.
- Persoonlijke cv-inhoud is alleen gebruikt om de lokale gebruikersprofielen af te stellen en is niet in code, fixtures, tests of documentatie opgenomen.
## 0.2.11 — 2026-07-22
### Gewijzigd
- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
### Verificatie
- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
## 0.2.10 — 2026-07-22 ## 0.2.10 — 2026-07-22
### Gewijzigd ### Gewijzigd
@@ -11,12 +133,14 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl
- Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren. - Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren.
- De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans. - De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans.
- HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass. - HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass.
- Celery gebruikt expliciet queue `default`, zodat bronruns en andere standaardtaken daadwerkelijk door de productie-worker worden geconsumeerd.
- De fetcher controleert het werkelijk verbonden peer-IP en accepteert daardoor veilige publieke CDN-rotatie zonder de SSRF-blokkade voor private adressen te versoepelen.
### Verificatie ### Verificatie
- De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan. - De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan.
- GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord. - GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord.
- 215 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,42% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen. - 218 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,46% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px. - De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
## 0.2.9 — 2026-07-22 ## 0.2.9 — 2026-07-22
+5
View File
@@ -0,0 +1,5 @@
# Huidige toestand
VacatureRadar 0.3.14 is een single-user-first, server-rendered vacature-intelligencecockpit. De actuele uitvoeringsstatus, laatste gate en externe restpunten staan in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md). Productgebruik en installatie beginnen in [README.md](README.md).
Release 0.3.14 verlaagt voor de vaste 250-itemimport de gemiddelde duur met 44,74% en het queryvolume met minstens 63,73%, met behoud van parser-, dedupe-, ranking-, provenance- en scorehistoriekcontracten. Het actuele productie-image en livebewijs worden na deployment in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md) geregistreerd. Alleen optionele externe identity- en mailboxcredentials staan nog in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md).
+20
View File
@@ -0,0 +1,20 @@
# Deployment
De standaard productieopstelling gebruikt afzonderlijke web-, worker-, scheduler-, PostgreSQL- en Rediscontainers. Voor Unraid is daarnaast een bewaakte AIO-variant beschikbaar. Poort 1226 is de configureerbare externe standaard; de applicatie luistert intern op 8080.
- [Unraiddeployment](docs/operations/UNRAID_DEPLOYMENT.md)
- [Nginx Proxy Manager](docs/operations/NGINX_PROXY_MANAGER.md)
- [Giteadeployment](docs/operations/GITEA_DEPLOYMENT.md)
- [Releasechecklist](docs/quality/RELEASE_CHECKLIST.md)
Gebruik nooit wildcardhosts of secrets uit voorbeeldbestanden. `PUBLIC_BASE_URL` is de publieke bron van waarheid voor host-, origin-, HTTPS- en cookieconfiguratie.
## Productiebewijs 0.3.13
- Doel: `/mnt/user/appdata/VacatureRadar`, composeproject `vacatureradar`, hostpoort 1226 naar containerpoort 8080.
- Actieve image: `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`.
- Docker 27.5.1 en Compose 2.40.3 bouwden werkelijk; 148 static files werden verzameld.
- `migrate --noinput` meldde geen openstaande migrations. Een volledige containerrestart eindigde healthy met database en cache `ok`.
- Publiek HTTP redirect naar HTTPS; OpenResty levert HSTS en de applicatie antwoordt zonder `DisallowedHost`.
Exacte rollback: tag de bewaarde image als `vacatureradar:unraid` en voer in de doelmap `docker compose -f docker-compose.unraid.yml up -d --force-recreate --no-build app` uit. Deze procedure is live getest en daarna teruggedraaid naar 0.3.13.
+4 -1
View File
@@ -26,7 +26,10 @@ RUN uv sync --frozen --no-dev --no-install-project
COPY . . COPY . .
RUN uv sync --frozen --no-dev \ RUN uv sync --frozen --no-dev \
&& chmod +x scripts/*.sh \ && chmod +x scripts/*.sh \
&& python manage.py collectstatic --noinput \ && DJANGO_DEBUG=0 \
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
PUBLIC_BASE_URL=https://build.invalid \
python manage.py collectstatic --noinput \
&& mkdir -p /app/media /app/logs /app/local \ && mkdir -p /app/media /app/logs /app/local \
&& chown -R app:app /app \ && chown -R app:app /app \
&& chmod -R 0775 /app/media /app/logs /app/local /app/staticfiles /app/.venv && chmod -R 0775 /app/media /app/logs /app/local /app/staticfiles /app/.venv
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim-trixie
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_DEFAULT_INDEX=https://pypi.org/simple \
PATH="/app/.venv/bin:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates libpq5 postgresql redis-server supervisor \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.10.0 /uv /uvx /bin/
WORKDIR /app
RUN addgroup --system app \
&& adduser --system --ingroup app --home /app --no-create-home app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev \
&& chmod +x scripts/*.sh \
&& DJANGO_DEBUG=0 \
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
PUBLIC_BASE_URL=https://build.invalid \
python manage.py collectstatic --noinput \
&& mkdir -p /app/media /app/logs /app/local /var/log/supervisor \
&& chown -R app:app /app
EXPOSE 8080
ENTRYPOINT ["/app/scripts/unraid_aio_entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
# Operations
Dagelijks beheer omvat readiness, worker/beat-heartbeat, bronruns, notificatie-outbox, back-upouderdom en foutcategorieën. De automationcockpit toont alleen echte database- en cachewaarden; niet-meetbare queuediepte blijft expliciet onbekend.
- [Operationeel runbook](docs/operations/RUNBOOK.md)
- [Broncatalogus](docs/operations/LIVE_SOURCE_CATALOG.md)
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
- [Externe releaseacties](USER_INPUT_REQUIRED.md)
## Live observability 2026-07-29
Na build, deploy en een afzonderlijke restart waren PostgreSQL, Redis, Gunicorn, Celery worker en Celery beat actief. `/health/ready/` rapporteerde database en cache `ok`. De automationpagina werd via het publieke domein geladen; queuediepte blijft bewust onbekend wanneer de cachebackend die niet betrouwbaar kan meten. De gecombineerde runtimeheartbeat bewijst beat-dispatch en workeruitvoering.
+1020 -295
View File
File diff suppressed because it is too large Load Diff
+175 -131
View File
@@ -1,195 +1,239 @@
# VacatureRadar <p align="center">
<img src="static/img/vacatureradar-logo.svg" width="88" alt="VacatureRadar-logo">
</p>
VacatureRadar is een persoonlijke, vrijwel autonome vacature-assistent voor toegestane publieke werkgeversbronnen, publieke ATS-pagina's, RSS/Atom en vacaturemails. De applicatie verzamelt vacatures, normaliseert en dedupliceert ze, past harde filters toe, berekent een uitlegbare matchscore en bundelt de beste resultaten in een dagelijkse digest. <h1 align="center">VacatureRadar</h1>
De repository bevat zowel een **werkende MVP-kern** als de volledige product-, architectuur-, UX-, beveiligings-, operationele en agentische overdrachtsdocumentatie om het project verder autonoom af te bouwen. <p align="center"><strong>Van vacature-overload naar een kleine, uitlegbare selectie die bij jou past.</strong></p>
## Huidige status VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-paginas, feeds en vacaturemails, verwijdert dubbels en irrelevante resultaten en rangschikt de overblijvende kansen tegen jouw zoekprofiel. Jij behoudt altijd de controle: VacatureRadar helpt zoeken en opvolgen, maar solliciteert nooit automatisch.
Reeds geïmplementeerd: ![Dagoverzicht van VacatureRadar in licht thema](docs/screenshots/readme/dashboard-light.png)
- Django 5.2-modulaire monoliet met Nederlandstalige responsive dark/light Intelligence Cockpit; ## Voor wie is dit?
- PostgreSQL in productie en SQLite voor lokale ontwikkeling;
- Celery-worker en scheduler via Redis;
- veilige HTTP-fetcher met domeinbeleid, redirecthercontrole, contentlimieten en SSRF-blokkering;
- JSON-LD `JobPosting`, generieke HTML-, RSS/Atom- en vacaturemailparsers;
- normalisatie, herkomstbewijs, vacatureversies en idempotente opslag;
- exacte en fuzzy deduplicatie;
- deterministische kenmerken, harde uitsluitregels en uitlegbare scoring;
- zoekprofielen met revisiehistoriek en begrensde feedbackaanpassing;
- dagelijkse digests, vacaturelevenscyclus en sollicitatiedossiers;
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
- Docker Compose voor lokaal gebruik en een Unraid-variant;
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
Nog niet als productievoltooid beschouwd: VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installatie die:
- live broninventaris en bronvoorwaarden per concrete werkgever; - meerdere vacaturebronnen wil volgen zonder iedere site dagelijks te openen;
- een gekozen actuele geodataset met licentie; - alleen aantoonbaar relevante vacatures wil beoordelen;
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry. - wil begrijpen waarom een vacature wel of niet past;
- sollicitaties, deadlines en opvolging op één plek wil bewaren;
- lokale controle over profiel-, vacature- en brondata belangrijk vindt.
Deze punten staan als uitvoerbare taken in [`docs/ai/BACKLOG.yaml`](docs/ai/BACKLOG.yaml). Externe credentials blokkeren nooit de rest van het werk: taken worden met mocks en interfaces afgewerkt en daarna als `blocked-external` gedocumenteerd waar echte configuratie nodig is. ## Zo gebruik je VacatureRadar
Release-productiepad: 1. **Stel je radar in.** Kies rollen, skills, regio, afstand, werkmodel en harde grenzen.
2. **Laat toegestane bronnen verwerken.** De radar ontdekt, haalt op, normaliseert en dedupliceert vacatures volgens bronbeleid en SSRF-beveiliging.
3. **Beoordeel de beste signalen.** Iedere match toont score, datakwaliteit, positieve signalen en aandachtspunten.
4. **Leer van de markt.** De Skillsradar laat zien welke technologieën terugkomen en waar je profiel dekking heeft.
5. **Volg acties op.** Bewaar interessante vacatures, beheer sollicitatiedossiers en houd gesprekken en deadlines bij.
6. **Controleer de werking.** Het Automation center maakt bronruns, pipelinegezondheid en onbekende runtime-informatie zichtbaar.
- `./scripts/release_verify.sh` bouwt de smoke-, backup/restore- en artifactcontrole; ## Wat zit erin?
- [`docs/operations/UNRAID_DEPLOYMENT.md`](docs/operations/UNRAID_DEPLOYMENT.md) bevat een expliciet upgrade/rollbackpad met releaseartefacten.
## Snelste start voor Codex | Onderdeel | Wat je eraan hebt |
|---|---|
| Dagoverzicht | Prioriteiten, beste matches, open trajecten en radarstatus in één beeld |
| Vacatureverkenner | Zoeken en filteren met een uitlegbare detailanalyse naast de resultaten |
| Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel |
| Sollicitatiepipeline | Dossiers, status, notities, deadlines en een auditbare tijdlijn |
| Bronnenbeheer | Veilige bronconfiguratie, gezondheid, handmatige import en mailboxkoppelingen |
| Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend |
Open de uitgepakte repository als project en geef Codex deze opdracht: ## Bekijk de volledige productflow
```text De beelden komen uit de lokale demo-omgeving. De getoonde cijfers zijn omgevingsdata, geen marketingclaims. Klik op een beeld voor de volledige resolutie.
Lees CODEX_START_HERE.md en AGENTS.md volledig. Voer daarna het autonome uitvoeringsprotocol uit. Werk alle taken met status ready in docs/ai/BACKLOG.yaml op volgorde af. Vraag geen productbeslissingen; gebruik de vastgelegde defaults. Wanneer een extern secret, account of live bronreview ontbreekt, implementeer en test de interface met fixtures, markeer uitsluitend dat externe deel als blocked-external en ga meteen door met de volgende taak. Stop pas nadat alle ready taken done zijn en scripts/codex_verify.sh volledig slaagt.
```
De repository-instructies staan in [`AGENTS.md`](AGENTS.md). De handmatige startpagina staat in [`CODEX_START_HERE.md`](CODEX_START_HERE.md). ### 1. Vind de relevante kansen
## Lokale quickstart De verkenner brengt zoeken, filters, gerangschikte resultaten en de belangrijkste matchcontext samen. Zo hoef je niet tussen lijst- en detailpaginas te blijven wisselen.
Vereisten: Python 3.12 of 3.13 en `uv`. <p align="center">
<a href="docs/screenshots/readme/matches-dark.png"><img src="docs/screenshots/readme/matches-dark.png" alt="Vacatureverkenner met gerangschikte matches en directe intelligence"></a>
</p>
### 2. Begrijp waarom een vacature past
Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broninformatie afzonderlijk zichtbaar. Ervaring en senioriteitslabels worden als context getoond, niet als verborgen scorefactor.
<p align="center">
<a href="docs/screenshots/readme/job-analysis-dark.png"><img src="docs/screenshots/readme/job-analysis-dark.png" alt="Uitlegbare vacatureanalyse met scorecomponenten en besliscontext"></a>
</p>
### 3. Bouw markt- en werkgeversinzicht op
<table>
<tr>
<td width="50%"><strong>Skillsradar</strong><br><sub>Vergelijk aantoonbare marktvraag met je profiel, zonder ontbrekende skills als afwijzingsregel te behandelen.</sub></td>
<td width="50%"><strong>Werkgeversradar</strong><br><sub>Bekijk werkgevers, actieve IT-vacatures, bronnen en eerdere dossiers in één genormaliseerd overzicht.</sub></td>
</tr>
<tr>
<td><a href="docs/screenshots/readme/skills-light.png"><img src="docs/screenshots/readme/skills-light.png" alt="Skillsradar met profieldekking en actuele marktvraag"></a></td>
<td><a href="docs/screenshots/readme/employers-light.png"><img src="docs/screenshots/readme/employers-light.png" alt="Werkgeversradar met vacature- en broninformatie"></a></td>
</tr>
</table>
### 4. Volg trajecten op en houd bronnen gezond
<table>
<tr>
<td width="50%"><strong>Sollicitatiepipeline</strong><br><sub>Bewaar status, deadlines, notities en tijdlijn per dossier; de uiteindelijke sollicitatie blijft altijd handmatig.</sub></td>
<td width="50%"><strong>Bronnenbeheer</strong><br><sub>Zie policyreview, laatste scans, volgende runs en brongezondheid zonder technische logs te moeten doorzoeken.</sub></td>
</tr>
<tr>
<td><a href="docs/screenshots/readme/applications-dark.png"><img src="docs/screenshots/readme/applications-dark.png" alt="Sollicitatiepipeline met persoonlijke dossiers"></a></td>
<td><a href="docs/screenshots/readme/sources-light.png"><img src="docs/screenshots/readme/sources-light.png" alt="Bronnenbeheer met policy- en gezondheidsstatus"></a></td>
</tr>
</table>
### 5. Controleer de radar — ook mobiel
<table>
<tr>
<td width="72%"><strong>Automation center</strong><br><sub>Volg de echte verwerkingspipeline, bronruns en systeemstatus. Niet-gemeten runtime-informatie blijft expliciet onbekend.</sub></td>
<td width="28%"><strong>Mobiel zoekprofiel</strong><br><sub>Controleer je persoonlijke radar en navigeer door de kernflow vanaf een smal scherm.</sub></td>
</tr>
<tr>
<td><a href="docs/screenshots/readme/automation-dark.png"><img src="docs/screenshots/readme/automation-dark.png" alt="Automation center met pipeline en bronruns"></a></td>
<td align="center"><a href="docs/screenshots/readme/profile-mobile-light.png"><img src="docs/screenshots/readme/profile-mobile-light.png" width="240" alt="Mobiel zoekprofiel van VacatureRadar"></a></td>
</tr>
</table>
## Snel starten
### Optie 1 — lokale demo
Vereisten: Python 3.12 of 3.13, [`uv`](https://docs.astral.sh/uv/) en Git Bash op Windows.
```bash ```bash
./scripts/codex_bootstrap.sh ./scripts/codex_bootstrap.sh
uv run python manage.py runserver 0.0.0.0:8080 uv run python manage.py runserver 0.0.0.0:8080
``` ```
Het bootstrap-script: Open daarna [http://127.0.0.1:8080](http://127.0.0.1:8080). Wanneer het bootstrap-script de ontwikkelomgeving heeft aangemaakt, kun je lokaal aanmelden met:
1. maakt alleen wanneer nodig een lokale `.env` aan;
2. installeert exact de gelockte dependencies;
3. voert migraties uit;
4. maakt demo-inhoud aan;
5. draait lint, systeemchecks, migratiecheck, tests en taakledgervalidatie.
Bij een door het script aangemaakte ontwikkelomgeving is de tijdelijke login:
```text ```text
admin / codex-local-only admin / codex-local-only
``` ```
Gebruik die nooit buiten lokale ontwikkeling. Het bootstrap-script maakt zo nodig `.env`, installeert de gelockte dependencies, voert migraties uit, laadt veilige demodata en draait de kwaliteitsgates. Deze tijdelijke login is uitsluitend voor lokale ontwikkeling; wijzig of verwijder hem voordat de installatie vanaf een ander toestel bereikbaar is.
## Docker Compose ### Optie 2 — Docker Compose
Vereisten: Docker met Compose v2 en Bash.
```bash ```bash
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
``` ```
De webinterface luistert op `0.0.0.0:1226` (containerpoort `8080`). Voor een one-shot deploy kun je nu draaien: Open daarna [http://127.0.0.1:1226](http://127.0.0.1:1226). Het deployscript vult ontbrekende lokale `.env`-waarden met sterke gegenereerde defaults, start PostgreSQL, Redis, Django, de worker en scheduler en controleert de health-endpoints.
```bash ```text
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh http://127.0.0.1:1226/health/ready/
http://127.0.0.1:1226/health/live/
``` ```
De script zet ontbrekende `.env`-waarden automatisch met sterke defaults (secrets, database-wachtwoord, admin-credentials, ports) en start direct de volledige Docker-stack. Daarna: Gebruik voor een bestaande installatie geen nieuwe gegenereerde `.env`: bewaar de huidige secrets en databasegegevens. Zie [lokale ontwikkeling](docs/operations/LOCAL_DEVELOPMENT.md) of [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) voor het volledige pad.
- `http://127.0.0.1:1226/health/ready/` ## Eerste configuratie
- `http://127.0.0.1:1226/health/live/`
Zet voor publieke productie: Na de eerste login:
- `APP_HOST=<jouw-domein>`
- `APP_SCHEME=https`
- `DJANGO_DEBUG=0`
- `SESSION_COOKIE_SECURE=1`
- `CSRF_COOKIE_SECURE=1`
- `SECURE_SSL_REDIRECT=1`
De script blijft geen handmatige invulstappen voor compose vereisen; pas deze omgevingsvariabelen aan vóór eerste deploy. 1. Open **Zoekprofiel** en stel regio, radius, rollen, skills en werkvoorkeuren in.
2. Open **Bronnen** en activeer alleen bronnen waarvan beleid en voorwaarden beoordeeld zijn.
3. Configureer optioneel een eigen vacaturemailbox; credentials worden niet in de repository opgeslagen.
4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt.
5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks.
## Push-to-deploy via Gitea Voor een publieke HTTPS-host configureer je de bestaande `.env` veilig met:
Er is een `.gitea/workflows/deploy.yml` toegevoegd voor automatisch deployen op elke push naar `main` en `master`. ```bash
python scripts/configure_public_url.py \
https://vacatureradar.example.be \
--env-file .env \
--cache-url redis://127.0.0.1:6379/1
```
Wat je nodig hebt: De publieke URL wordt vervolgens gebruikt voor `ALLOWED_HOSTS`, CSRF, secure cookies en HTTPS-detectie. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; een debugpagina mag nooit publiek zichtbaar zijn. Hermaak de container na een environmentwijziging.
- éénmalige setup van een **self-hosted Gitea-runner** op je server met Docker-toegang Publiceer poort `1226` niet rechtstreeks op internet. Gebruik een beheerde reverse proxy met TLS en volg [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) en de concrete [Nginx Proxy Manager-handleiding](docs/operations/NGINX_PROXY_MANAGER.md). Een volledig productievoorbeeld staat in [`deployment/production.env.example`](deployment/production.env.example).
Daarna doet elke push automatisch: ## Eerlijke grenzen
- checkout van de repo op de runner VacatureRadar:
- `bash scripts/deploy_docker.sh` op de server
- automatische healthcheck + `collectstatic`
De workflow verwacht geen host/SSH-repo secrets uit het Actions-configuratiescherm. - logt niet automatisch in op vacaturesites en omzeilt geen CAPTCHA of rate limit;
- crawlt denylistplatformen niet rechtstreeks;
- behandelt HTML, e-mail en modeloutput altijd als onbetrouwbare data;
- laat iedere URL en redirect door bronbeleid en SSRF-validatie gaan;
- past deterministische harde regels toe vóór optionele AI-analyse;
- verstuurt nooit automatisch een sollicitatie;
- toont ontbrekende of niet-gemeten informatie als onbekend in plaats van die te simuleren.
Zie [Gitea push-to-deploy](docs/operations/GITEA_DEPLOYMENT.md) voor de exacte serverconfiguratie. Lees vóór productiegebruik [SECURITY.md](SECURITY.md) en het [threat model](docs/quality/THREAT_MODEL.md).
## Zelf een schone overdrachts-ZIP maken ## Ontwikkelen en verifiëren
De kern is een server-rendered Django 5.2-modulaire monoliet met PostgreSQL, Redis/Celery en een lokale dark/light-interface zonder runtime-CDN of SPA-afhankelijkheid. SQLite wordt gebruikt voor lokale tests.
```bash
uv sync --frozen --all-groups
uv run playwright install chromium
uv run python manage.py migrate
uv run python manage.py runserver 0.0.0.0:8080
```
Voer vóór iedere oplevering de volledige gate uit:
```bash
./scripts/codex_verify.sh
```
Die controleert formatting, lint, Django-configuratie, migraties, de volledige testset, verplichte Chromium-E2E, minimaal 70% branch-aware dekking, documentatie en de taakledger. De browsergate controleert de kernreis, focus, consolefouten en documentoverflow op mobiel, tablet, laptop, desktop, widescreen en ultrawide.
## Beheer en deployment
- [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md)
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md)
- [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md)
- [Operationeel runbook](docs/operations/RUNBOOK.md)
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
- [Professionalisering baseline](docs/audit/BASELINE.md)
- [Performanceaudit](docs/audit/PERFORMANCE.md)
- [Finale acceptance](docs/audit/FINAL_ACCEPTANCE.md)
Een overdrachtsarchief zonder secrets, lokale databases of caches maak en controleer je met:
```bash ```bash
uv run python scripts/package_project.py ../VacatureRadar_Project.zip uv run python scripts/package_project.py ../VacatureRadar_Project.zip
uv run python scripts/verify_package.py ../VacatureRadar_Project.zip uv run python scripts/verify_package.py ../VacatureRadar_Project.zip
``` ```
Het packaging-script sluit `.env`, virtuele omgevingen, caches, lokale databases, logs, media en back-ups uit. In de ZIP staat `PROJECT_MANIFEST.json` met grootte en SHA-256 per bestand.
## Kwaliteitsgates
```bash
./scripts/codex_verify.sh
```
De gate vereist:
- Ruff zonder bevindingen;
- een geldige Django-configuratie;
- geen ontbrekende migraties;
- alle tests geslaagd;
- minimaal 70% branch-aware codedekking;
- een geldige machineleesbare backlog.
## Repositorykaart ## Repositorykaart
```text ```text
apps/ Django-domeinmodules apps/ Django-domeinen en bedrijfsservices
core/ dashboard, health en beveiligingsheaders config/ Django- en Celery-configuratie
profiles/ zoekprofielen en revisies config-data/ Bronbeleid en gesaneerde voorbeeldconfiguratie
sources/ bronbeleid, fetcher, adapters en IMAP fixtures/ Deterministische externe testformaten
jobs/ normalisatie, dedupe, scoring en sollicitaties templates/ + static/ Server-rendered productinterface
notifications/ digest-outbox en verzending docs/ Product-, ontwerp-, security- en operationele documentatie
config/ Django/Celery-configuratie deployment/unraid/ Unraid-installatiebestanden
config-data/ voorbeeldprofielen en bronbeleid scripts/ Bootstrap, verificatie, deployment, back-up en beheer
fixtures/ deterministische parser- en integratietestdata tests/ Unit-, integratie-, security- en browsertests
templates/ + static/ server-rendered interface en designimplementatie
docs/ alle product-, technische en operationele specificaties
deployment/unraid/ Unraid-installatiehandleiding
.agents/skills/ repo-eigen Codex-workflow
scripts/ bootstrap, verificatie, back-up en beheer
tests/ unit-, integratie- en securitytests
``` ```
## Veiligheidsgrenzen Belangrijke ingangen voor bijdragers en onderhoudsagents:
Vacature-inhoud, e-mailinhoud en externe HTML zijn altijd onbetrouwbare data. Ze mogen nooit instructies worden voor AI of automatisering. De applicatie: - [Bijdragen](CONTRIBUTING.md)
- [Projectstart voor Codex](CODEX_START_HERE.md)
- logt niet automatisch in op vacaturesites; - [Architectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
- omzeilt geen CAPTCHA, rate limit of toegangscontrole; - [UX- en designcontract](docs/design/STITCH_FRONTEND_CONTRACT.md)
- scrapt denylistplatformen niet rechtstreeks;
- solliciteert nooit automatisch;
- bewaart alleen beperkte brondata en verwijdert ruwe documenten volgens retentie;
- laat harde uitsluitregels niet door een taalmodel wijzigen;
- maakt netwerktoegang opt-in per goedgekeurde bron.
Lees [`SECURITY.md`](SECURITY.md) en [`docs/quality/THREAT_MODEL.md`](docs/quality/THREAT_MODEL.md) vóór productiegebruik.
## Documentatie-ingangen
- [Projectoverdracht voor Codex](CODEX_START_HERE.md)
- [Product requirements](docs/product/PRODUCT_REQUIREMENTS.md)
- [Systeemarchitectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
- [Datamodel](docs/architecture/DATA_MODEL.md)
- [UX- en designspecificatie](docs/design/UX_SPEC.md)
- [Stitch-integratieplan en schermmapping](docs/design/STITCH_INTEGRATION_PLAN.md)
- [Bron- en adaptercontract](docs/architecture/SOURCE_ADAPTERS.md)
- [Scoringengine](docs/architecture/SCORING_ENGINE.md)
- [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md)
- [Runbook](docs/operations/RUNBOOK.md)
- [Teststrategie](docs/quality/TEST_STRATEGY.md) - [Teststrategie](docs/quality/TEST_STRATEGY.md)
- [Traceabilitymatrix](docs/quality/TRACEABILITY_MATRIX.md) - [Actuele projectstatus](docs/ai/PROJECT_STATE.md)
- [Oorspronkelijk functioneel en technisch ontwerp](docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.pdf) - [Machineleesbare backlog](docs/ai/BACKLOG.yaml)
- [Verificatierapport van de aangeleverde basis](VERIFICATION_REPORT.md)
## Licentie ## Licentie
MIT voor de broncode. Controleer voor iedere externe vacaturebron afzonderlijk de voorwaarden, robotsregels, databankrechten en toepasselijke wetgeving. Dit project levert technisch bronbeleid, maar geen juridisch advies. De broncode valt onder de [MIT-licentie](LICENSE). Controleer voor iedere externe vacaturebron afzonderlijk de voorwaarden, robotsregels, databankrechten en toepasselijke wetgeving. Het project levert technisch bronbeleid, geen juridisch advies.
+6 -2
View File
@@ -30,7 +30,11 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea
- minimale ruwe-dataretentie; - minimale ruwe-dataretentie;
- geen secrets in logs of databasefixtures; - geen secrets in logs of databasefixtures;
- AI uitgeschakeld als veilige standaard; - AI uitgeschakeld als veilige standaard;
- menselijke bevestiging voor iedere sollicitatie. - menselijke bevestiging voor iedere sollicitatie;
- een publieke URL die exact in host- en CSRF-validatie zit;
- `DEBUG=False`, secure cookies en correcte proxy-HTTPS-detectie;
- forwarded client-IPheaders alleen vanaf expliciet vertrouwde proxy-CIDR's;
- een gedeeld gastaccount uitsluitend in server-side read-onlymodus.
## Kwetsbaarheid melden ## Kwetsbaarheid melden
@@ -42,7 +46,7 @@ Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documente
4. voorgestelde mitigatie; 4. voorgestelde mitigatie;
5. eventuele rotatie- of verwijderacties. 5. eventuele rotatie- of verwijderacties.
Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Publieke reverse-proxyproblemen worden behandeld volgens `docs/operations/NGINX_PROXY_MANAGER.md`.
## Geen securityclaims ## Geen securityclaims
+11
View File
@@ -0,0 +1,11 @@
# Testen
De verplichte lokale gate is:
```bash
./scripts/codex_verify.sh
```
Deze voert lint, Django checks, migratiecontrole, unit-, integratie-, security- en verplichte Playwrighttests, coverage, ledger- en repositoryvalidatie uit. Chromium moet vooraf via `playwright install chromium` beschikbaar zijn. CI installeert de browser reproduceerbaar en bewaart screenshots bij fouten.
Zie [docs/quality/TEST_STRATEGY.md](docs/quality/TEST_STRATEGY.md) voor testlagen en [docs/quality/DEFINITION_OF_DONE.md](docs/quality/DEFINITION_OF_DONE.md) voor de releasegate.
+12
View File
@@ -0,0 +1,12 @@
# Externe acties
Dit register bevat uitsluitend stappen die niet betrouwbaar vanuit de repository of beschikbare productieomgeving kunnen worden uitgevoerd. Publieke productieacceptatie en de productieback-up/restore zijn op 2026-07-29 uitgevoerd en staan als bewijs in `docs/audit/FINAL_ACCEPTANCE.md` en `BACKUP_RESTORE.md`.
## EXT-003 — Externe identity- en mailcredentials
- Waarom externe toegang nodig is: Entra-appregistratie en mailboxcredentials zijn accountgebonden secrets.
- Veilige standaard: integraties blijven uitgeschakeld zonder volledige configuratie; lokale login en gastdemo blijven werken.
- Reeds geïmplementeerd: versleutelde credentialopslag, configuratievalidatie, mocks en negatieve tests.
- Resterende handeling: vul alleen wanneer gewenst de bestaande productievariabelen en appregistratie in en voer de beschreven smoke tests uit.
- Impact zonder actie: de kernapplicatie blijft production-ready; alleen de optionele Entra- en mailboxintegraties blijven inactief.
- Prioriteit: P2.
+50 -84
View File
@@ -1,102 +1,68 @@
# Verificatierapport van de aangeleverde projectbasis # Verificatierapport — VacatureRadar 0.3.12
- Datum: 2026-07-20 - Datum: 27 juli 2026
- Omgeving: Linux, Python 3.13.5, uv 0.10.0 - Onderwerp: productiehardening, Nginx Proxy Manager, gastmodus en overdrachts-ZIP
- Projectversie: 0.1.0 - Branch: `fix/public-production-hardening`
- Doel: aantonen dat de ZIP een reproduceerbare, werkende MVP-basis en autonome overdracht bevat
## Uitgevoerde volledige gate ## Gecontroleerd
Commando: ### Applicatie en regressies
```bash ```text
./scripts/codex_verify.sh 296 tests geslaagd
2 optionele Playwrighttests overgeslagen omdat de browserbinary niet lokaal aanwezig was
83,43% branch-aware codedekking; vereiste drempel 70%
``` ```
Resultaat: De afzonderlijke HTML- en accessibilityprobe bleef actief. De Playwrightfallback is hersteld zodat een geïnstalleerde Pythonmodule zonder browserbinary geen synchrone databasecall meer binnen de Playwright-eventloop uitvoert.
- Ruff: geslaagd; ### Django en data
- Django system check: geslaagd, 0 issues;
- migratiecontrole: geslaagd, geen ontbrekende migraties;
- pytest: 43/43 tests geslaagd;
- branch-aware codedekking: 80,42% (`apps` en `config`), drempel 70%;
- machineleesbare backlog: geldig, 35 taken en acyclische dependencies;
- lokale Markdownlinks, YAML/Compose/OpenAPI, `.env.example` en Codex-skillfrontmatter: geldig.
De tests omvatten unit-, integratie- en securitypaden voor parsers, URL-security, fetchbeleid, sanitization, normalisatie, dedupe, scoring, profiles, e-mailimport, notificaties, lifecycle, tasks en webviews. - `python manage.py check`: 0 issues;
- `python manage.py makemigrations --check --dry-run`: geen wijzigingen;
- productie-settings met `PUBLIC_BASE_URL=https://vacatureradar.itworx.tech`: laden correct;
- `manage.py check --deploy`: geen errors; HSTS-preload blijft bewust uit en geeft daarom alleen de verwachte Django-waarschuwing;
- publieke host en HTTPS-origin worden aantoonbaar aan `ALLOWED_HOSTS` en `CSRF_TRUSTED_ORIGINS` toegevoegd.
## Clean-room ZIP-proef ### Repository en deployment
De gegenereerde ZIP is in een lege directory uitgepakt. Voor de start is gecontroleerd dat `.env`, `.venv` en `local/db.sqlite3` niet aanwezig waren. Daarna is uitgevoerd: - repositoryvalidator: geslaagd;
- taakledger: geldig, 64 taken, 0 uitvoerbare ready-taken;
- Docker Compose, Unraid Compose en backlog-YAML: parseerbaar;
- shellsyntax van deploy- en entrypoints: geldig;
- Pythoncompilecheck: geslaagd;
- `git diff --check`: geslaagd;
- packagevalidatie en manifestcontrole: geslaagd na definitieve packaging.
```bash ## Belangrijkste herstelde productieproblemen
./scripts/codex_bootstrap.sh
```
Resultaat: - `DisallowedHost` voor de publieke Nginx-host;
- publieke Django-debugmodus;
- verkeerde externe CSRF-origin met interne poort `1226`;
- healthchecks die door HTTPS-redirect konden falen;
- proceslokale rate limiting bij meerdere Gunicornworkers;
- onvoorwaardelijk vertrouwen in `X-Forwarded-For`;
- schrijfbare gedeelde demo;
- afwijkende Unraid `.env`-paden;
- database-URL-interpolatie buiten het Unraid-`env_file`;
- URL-import die zonder staticfilesmanifest kon falen tijdens deployment checks;
- stale project-/assetversie en verouderd verificatierapport.
- nieuwe virtuele omgeving uit `uv.lock` opgebouwd; ## Omgevingsbeperking
- alle migraties op een lege SQLite-database toegepast;
- lokaal beheeraccount, standaardprofiel en één demovacature aangemaakt;
- volledige kwaliteitsgate opnieuw geslaagd met 43 tests en 80,42% dekking;
- ontwikkelserver gestart;
- `/health/live/` gaf `200` met `ok: true`;
- `/health/ready/` gaf `200` met databasecheck `ok`;
- `/login/` gaf `200` met de Nederlandstalige titel `Aanmelden · VacatureRadar`.
## Offline/reproduceerbare eigenschappen De normale `uv sync --frozen` kon in deze uitvoeringsomgeving geen packages downloaden door DNS-uitval. Voor de regressies zijn de pure Python-packages uit de door de gebruiker aangeleverde gelockte Windows-omgeving gecombineerd met reeds aanwezige Linux-wheels. Daardoor konden Django, pytest en de volledige offline suite betrouwbaar draaien.
- De standaardtests doen geen live internetrequests. De Ruff-Linuxbinary en Playwright-browser konden zonder netwerk niet worden geïnstalleerd. Pythoncompile, alle tests, Djangochecks, lijnlengtecontrole op gewijzigde Pythonbestanden en `git diff --check` zijn wel uitgevoerd. Een Dockerdaemon en de live Unraid/Nginx-omgeving waren niet beschikbaar.
- IMAP en Ollama staan standaard uit.
- Celery draait in tests eager en vereist geen Redis.
- SQLite wordt voor lokale bootstrap/tests gebruikt; productieconfiguratie ondersteunt PostgreSQL.
- Externe bronformaten worden via gesaneerde fixtures getest.
- `.venv`, caches, `.env`, lokale database, logs, media en back-ups worden door het packaging-script uitgesloten.
## Autonome overdracht ## Nog op de doelserver uitvoeren
Gevalideerd: 1. bestaande `.env` back-uppen;
2. `scripts/configure_public_url.py` uitvoeren;
3. container met `--force-recreate` herbouwen;
4. interne liveness/readiness controleren;
5. publieke TLS-, login-, logout- en CSRF-smoke uitvoeren;
6. demo-read-only controleren;
7. exacte Nginx Proxy Manager-CIDR instellen wanneer individuele client-IP-rate-limiting gewenst is;
8. databaseback-up en herstel-smoke uitvoeren.
```bash Zie `docs/operations/NGINX_PROXY_MANAGER.md` en `docs/quality/PRODUCTION_AUDIT_2026-07-27.md`.
python scripts/backlog.py validate
python scripts/backlog.py next
```
Ledgerstatus bij oplevering:
- 10 taken `done`: de werkende MVP- en overdrachtsbasis;
- 17 taken `ready`: uitvoerbare product-, hardening- en releasestappen;
- 5 taken `blocked-external`: uitsluitend live bronreview, accounts, domein/registry of bulkdataset;
- 3 taken `deferred`: multi-tenant SaaS, automatisch solliciteren en algemene publieke API.
Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, accepted ADRs, een repo-eigen skill en een startprompt. Een externe blokkade mag alleen het live restpunt blokkeren; interface, tests, mocks en documentatie moeten eerst worden voltooid.
## Niet in deze omgeving uitgevoerd
Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving.
Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`.
## VR-118 — Stitch Intelligence Cockpit (2026-07-21)
Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd.
Uitgevoerde browsermatrix:
- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat;
- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem;
- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline;
- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix.
Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer.
Volledige afrondingsgate:
- Ruff: geslaagd;
- Django system check: geslaagd;
- migratiecontrole: geen wijzigingen;
- pytest: 146 geslaagd, 2 optionele Playwrightvarianten overgeslagen met geslaagde HTML/a11y-fallback;
- branch-aware codedekking: 82,00%, boven de vereiste 70%;
- taakledger: 36 taken, waarvan 28 done, 5 blocked-external, 3 deferred en 0 ready;
- repositorydocumentatie en configuratie: geldig.
+45
View File
@@ -0,0 +1,45 @@
"""Authenticatie-backends voor VacatureRadar.
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
gebruikersnaam te breken.
"""
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
class EmailOrUsernameModelBackend(ModelBackend):
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
def authenticate(self, request, username=None, password=None, **kwargs):
user_model = get_user_model()
if username is None:
username = kwargs.get(user_model.USERNAME_FIELD)
if username is None or password is None:
return None
try:
user = user_model.objects.get(
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username}) | Q(email__iexact=username)
)
except user_model.DoesNotExist:
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
user_model().set_password(password)
return None
except user_model.MultipleObjectsReturned:
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
user = (
user_model.objects.filter(**{f"{user_model.USERNAME_FIELD}__iexact": username})
.order_by("id")
.first()
)
if user is None:
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
+25 -1
View File
@@ -2,15 +2,35 @@ from __future__ import annotations
from typing import Any from typing import Any
from django.conf import settings
from django.http import HttpRequest from django.http import HttpRequest
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
def navigation_context(request: HttpRequest) -> dict[str, Any]: def navigation_context(request: HttpRequest) -> dict[str, Any]:
match = request.resolver_match
route_key = (match.namespace, match.url_name) if match else (None, None)
page_contexts = {
("dashboard", "today"): "Dagoverzicht",
("dashboard", "system"): "Automatisering",
("jobs", "list"): "Vacatureverkenner",
("jobs", "detail"): "Vacatureanalyse",
("jobs", "applications"): "Sollicitatiepipeline",
("jobs", "application-edit"): "Sollicitatiedossier",
("jobs", "skill-insights"): "Skillsradar",
("jobs", "activity"): "Activiteit",
("jobs", "employers"): "Werkgevers",
("jobs", "employer-detail"): "Werkgeveranalyse",
("profiles", "list"): "Zoekprofiel",
("profiles", "edit"): "Radarconfiguratie",
("sources", "list"): "Bronbeheer",
}
context = { context = {
"app_name": "VacatureRadar", "app_name": "VacatureRadar",
"app_version": "0.2.2", "app_version": settings.VACATURERADAR_VERSION,
"app_owner_name": settings.VACATURERADAR_OWNER_NAME,
"page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"),
} }
if request.user.is_authenticated: if request.user.is_authenticated:
context["navigation_profile"] = ( context["navigation_profile"] = (
@@ -18,4 +38,8 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]:
.only("id", "name") .only("id", "name")
.first() .first()
) )
context["demo_read_only"] = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and request.user.username == settings.DEMO_USERNAME
)
return context return context
+102
View File
@@ -0,0 +1,102 @@
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
bestaande tests en de kernapplicatie volledig werken.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
class EntraConfigError(RuntimeError):
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
@dataclass(frozen=True)
class EntraConfig:
client_id: str
client_secret: str
tenant_id: str
scopes: list[str]
allowed_domains: tuple[str, ...]
redirect_uri: str | None
@property
def authority(self) -> str:
return f"https://login.microsoftonline.com/{self.tenant_id}"
def entra_enabled() -> bool:
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
def load_config() -> EntraConfig:
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
if not (client_id and client_secret and tenant_id):
raise EntraConfigError(
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
)
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
allowed = tuple(
d.lower().lstrip("@") for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", []) if d
)
return EntraConfig(
client_id=client_id,
client_secret=client_secret,
tenant_id=tenant_id,
scopes=scopes,
allowed_domains=allowed,
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
)
def _build_app(config: EntraConfig):
try:
import msal # lazy import: alleen nodig als SSO aan staat
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
raise EntraConfigError(
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
"om Entra ID SSO te gebruiken."
) from exc
return msal.ConfidentialClientApplication(
client_id=config.client_id,
client_credential=config.client_secret,
authority=config.authority,
)
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
app = _build_app(config)
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
"""Wissel de teruggekeerde code in voor tokens en claims."""
app = _build_app(config)
return app.acquire_token_by_auth_code_flow(flow, auth_response)
def extract_identity(token_result: dict) -> tuple[str, str]:
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
claims = token_result.get("id_token_claims") or {}
email = (
claims.get("preferred_username") or claims.get("email") or claims.get("upn") or ""
).strip()
name = (claims.get("name") or "").strip()
return email, name
def domain_allowed(config: EntraConfig, email: str) -> bool:
if not config.allowed_domains:
return True
domain = email.rsplit("@", 1)[-1].lower()
return domain in config.allowed_domains
+33
View File
@@ -0,0 +1,33 @@
"""Formulieren voor authenticatie."""
from __future__ import annotations
from django.contrib.auth.forms import AuthenticationForm
from django.utils.translation import gettext_lazy as _
class EmailAuthenticationForm(AuthenticationForm):
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
"""
error_messages = {
**AuthenticationForm.error_messages,
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
field = self.fields["username"]
field.label = _("E-mailadres")
field.widget.attrs.update(
{
"type": "email",
"autocomplete": "username",
"autocapitalize": "none",
"spellcheck": "false",
"placeholder": "naam@organisatie.nl",
}
)
+18 -2
View File
@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from django.conf import settings
from django.core.cache import cache
from django.db import connections from django.db import connections
from django.db.utils import OperationalError from django.db.utils import DatabaseError
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -23,7 +26,20 @@ def readiness() -> HealthResult:
cursor.execute("SELECT 1") cursor.execute("SELECT 1")
cursor.fetchone() cursor.fetchone()
checks["database"] = "ok" checks["database"] = "ok"
except OperationalError as exc: except DatabaseError as exc:
ok = False ok = False
checks["database"] = f"error:{exc.__class__.__name__}" checks["database"] = f"error:{exc.__class__.__name__}"
if getattr(settings, "HEALTHCHECK_REQUIRE_CACHE", False):
key = f"health:{secrets.token_hex(8)}"
try:
cache.set(key, "ok", timeout=10)
if cache.get(key) != "ok":
raise RuntimeError("cache roundtrip failed")
cache.delete(key)
checks["cache"] = "ok"
except Exception as exc: # Health boundary: report category, never payload/details.
ok = False
checks["cache"] = f"error:{exc.__class__.__name__}"
return HealthResult(ok=ok, checks=checks) return HealthResult(ok=ok, checks=checks)
+55 -2
View File
@@ -2,11 +2,62 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from django.http import HttpRequest, HttpResponse from django.conf import settings
from django.contrib import messages
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
class DemoReadOnlyMiddleware:
"""Prevent a shared public demo account from mutating application data."""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
user = getattr(request, "user", None)
is_demo = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and user
and getattr(user, "is_authenticated", False)
and getattr(user, "username", "") == settings.DEMO_USERNAME
)
if not is_demo or request.method in self.SAFE_METHODS:
return self.get_response(request)
if request.path_info == reverse("logout"):
return self.get_response(request)
if "application/json" in request.headers.get("Accept", ""):
return JsonResponse(
{
"ok": False,
"error": "demo_read_only",
"message": "De publieke demo is alleen-lezen.",
},
status=403,
)
messages.warning(
request,
"De publieke demo is alleen-lezen; wijzigingen zijn uitgeschakeld.",
)
referer = request.META.get("HTTP_REFERER", "")
if referer and url_has_allowed_host_and_scheme(
referer,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(referer)
return redirect("dashboard:today")
class SecurityHeadersMiddleware: class SecurityHeadersMiddleware:
"""Kleine CSP zonder externe assets; vacature-HTML wordt bovendien gesanitized.""" """Small CSP without external assets; vacancy HTML is sanitized separately."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response self.get_response = get_response
@@ -33,4 +84,6 @@ class SecurityHeadersMiddleware:
"Permissions-Policy", "camera=(), microphone=(), geolocation=()" "Permissions-Policy", "camera=(), microphone=(), geolocation=()"
) )
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
if not getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
return response return response
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import ipaddress
from functools import lru_cache
from django.conf import settings
from django.http import HttpRequest
@lru_cache(maxsize=32)
def _trusted_networks(
values: tuple[str, ...],
) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
return tuple(ipaddress.ip_network(value, strict=False) for value in values)
def _parse_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
candidate = value.strip()
if not candidate:
return None
try:
return ipaddress.ip_address(candidate)
except ValueError:
return None
def client_ip(request: HttpRequest) -> str:
"""Return a proxy-aware client IP without trusting arbitrary forwarded headers."""
remote = _parse_ip(request.META.get("REMOTE_ADDR", ""))
if remote is None:
return "unknown"
networks = _trusted_networks(tuple(getattr(settings, "TRUSTED_PROXY_CIDRS", ())))
if not networks or not any(remote in network for network in networks):
return str(remote)
forwarded = [
parsed
for item in request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")
if (parsed := _parse_ip(item)) is not None
]
if not forwarded:
return str(remote)
# Walk from the nearest hop back to the client. The first address outside
# the explicitly trusted proxy ranges is the client address.
for candidate in reversed([*forwarded, remote]):
if not any(candidate in network for network in networks):
return str(candidate)
return str(forwarded[0])
+2 -7
View File
@@ -6,12 +6,7 @@ from django.core.cache import cache
from django.http import HttpRequest from django.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from apps.core.network import client_ip
def _client_ip(request: HttpRequest) -> str:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "").strip()
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "unknown").strip() or "unknown"
def _identity_for_user(request: HttpRequest) -> str: def _identity_for_user(request: HttpRequest) -> str:
@@ -21,7 +16,7 @@ def _identity_for_user(request: HttpRequest) -> str:
username = ( username = (
(request.POST.get("username", "") if request.method == "POST" else "").strip().lower() (request.POST.get("username", "") if request.method == "POST" else "").strip().lower()
) )
return f"anon:{username or _client_ip(request)}" return f"anon:{username or client_ip(request)}"
@dataclass(frozen=True) @dataclass(frozen=True)
+1
View File
@@ -0,0 +1 @@
"""Read services for the application cockpit."""
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.core.cache import cache
from django.db.models import Count, Q, Sum
from django.utils import timezone
from apps.jobs.models import JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..health import readiness
from ..tasks import RUNTIME_HEARTBEAT_KEY
def _runtime_heartbeat_state() -> dict[str, Any]:
if settings.CELERY_TASK_ALWAYS_EAGER:
return {"label": "Eager/lokaal", "healthy": True, "observed_at": None}
raw_timestamp = cache.get(RUNTIME_HEARTBEAT_KEY)
if not raw_timestamp:
return {"label": "Geen recente heartbeat", "healthy": False, "observed_at": None}
try:
observed_at = timezone.datetime.fromisoformat(str(raw_timestamp))
if timezone.is_naive(observed_at):
observed_at = timezone.make_aware(observed_at)
except (TypeError, ValueError):
return {"label": "Ongeldige heartbeat", "healthy": False, "observed_at": None}
age_seconds = max(0, int((timezone.now() - observed_at).total_seconds()))
return {
"label": f"Actief · {age_seconds}s geleden"
if age_seconds <= 150
else "Heartbeat verouderd",
"healthy": age_seconds <= 150,
"observed_at": observed_at,
}
def build_automation_cockpit() -> dict[str, Any]:
"""Build an honest operational read model without probing unsafe hidden endpoints."""
since = timezone.now() - timedelta(hours=24)
recent_runs = SourceRun.objects.filter(started_at__gte=since)
run_metrics = recent_runs.aggregate(
discovered=Sum("discovered_count"),
extracted=Sum("extracted_count"),
created=Sum("created_count"),
updated=Sum("updated_count"),
duplicates=Sum("duplicate_count"),
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
)
notification_counts = {
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
"sent_24h": DigestOutbox.objects.filter(
status=DigestOutbox.Status.SENT, sent_at__gte=since
).count()
+ ReminderOutbox.objects.filter(
status=ReminderOutbox.Status.SENT, sent_at__gte=since
).count(),
}
source_total = Source.objects.count()
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
phase_rows = [
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
{
"key": "extract",
"label": "Extraheren",
"value": run_metrics["extracted"] or 0,
"unit": "items",
},
{
"key": "normalize",
"label": "Normaliseren",
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
"unit": "records",
},
{
"key": "dedupe",
"label": "Dedupliceren",
"value": run_metrics["duplicates"] or 0,
"unit": "herkend",
},
{
"key": "filter",
"label": "IT-filter",
"value": active_jobs.exclude(it_relevance_query()).count(),
"unit": "uit beeld",
},
{
"key": "analyze",
"label": "Analyseren",
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
"unit": "scores / 24u",
},
{
"key": "notify",
"label": "Notificeren",
"value": notification_counts["sent_24h"],
"unit": "verzonden / 24u",
},
]
runtime_heartbeat = _runtime_heartbeat_state()
return {
"health": readiness(),
"source_total": source_total,
"source_active": source_active,
"run_metrics": run_metrics,
"run_count": recent_runs.count(),
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
"review_jobs": ScoreRun.objects.filter(
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
job__status=JobPosting.Status.ACTIVE,
).count(),
"notification_counts": notification_counts,
"phase_rows": phase_rows,
"recent_runs": list(
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
),
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
"queue_state": "Eager/lokaal"
if settings.CELERY_TASK_ALWAYS_EAGER
else "Diepte niet beschikbaar",
"runtime_heartbeat": runtime_heartbeat,
"ai_state": "Geconfigureerd"
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
else "Niet geconfigureerd",
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
"browser_state": "Geen aparte browserextractor geconfigureerd",
}
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from celery import shared_task
from django.core.cache import cache
from django.utils import timezone
RUNTIME_HEARTBEAT_KEY = "vacatureradar:runtime:heartbeat"
RUNTIME_HEARTBEAT_TTL_SECONDS = 180
@shared_task(name="apps.core.tasks.record_runtime_heartbeat", ignore_result=True)
def record_runtime_heartbeat() -> str:
"""Record proof that beat dispatch and a worker execution both succeeded."""
timestamp = timezone.now().isoformat()
cache.set(RUNTIME_HEARTBEAT_KEY, timestamp, timeout=RUNTIME_HEARTBEAT_TTL_SECONDS)
return timestamp
+181 -46
View File
@@ -1,24 +1,55 @@
from __future__ import annotations from __future__ import annotations
import logging
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView from django.contrib.auth.views import LoginView
from django.db.models import Count, Q from django.http import HttpResponse, JsonResponse
from django.http import JsonResponse from django.shortcuts import redirect
from django.utils import timezone from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import TemplateView from django.views.generic import TemplateView
from apps.core import entra as entra_sso
from apps.core.forms import EmailAuthenticationForm
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.models import Application, JobPosting, ScoreRun from apps.jobs.services.cockpit import build_dashboard_cockpit
from apps.sources.models import Source from apps.profiles.models import SearchProfile
from apps.sources.services.health import collect_source_health from apps.sources.services.health import collect_source_health
from .health import readiness from .health import readiness
from .services.automation import build_automation_cockpit
logger = logging.getLogger(__name__)
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
SESSION_ENTRA_FLOW = "entra_auth_flow"
SESSION_ENTRA_NEXT = "entra_next"
def _safe_next(request, candidate: str | None) -> str | None:
if candidate and url_has_allowed_host_and_scheme(
candidate,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return candidate
return None
class SecurityAwareLoginView(LoginView): class SecurityAwareLoginView(LoginView):
template_name = "registration/login.html" template_name = "registration/login.html"
authentication_form = EmailAuthenticationForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["entra_enabled"] = entra_sso.entra_enabled()
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
return context
@staticmethod @staticmethod
def _remaining_minutes(seconds: int) -> str: def _remaining_minutes(seconds: int) -> str:
@@ -69,6 +100,147 @@ class SecurityAwareLoginView(LoginView):
return super().form_valid(form) return super().form_valid(form)
def _split_name(full_name: str) -> tuple[str, str]:
parts = full_name.split()
if not parts:
return "", ""
if len(parts) == 1:
return parts[0][:150], ""
return parts[0][:150], " ".join(parts[1:])[:150]
def _unique_username(base: str) -> str:
user_model = get_user_model()
base = (base or "gebruiker")[:140].strip() or "gebruiker"
candidate = base
suffix = 1
while user_model.objects.filter(username__iexact=candidate).exists():
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def _get_or_create_sso_user(email: str, full_name: str):
user_model = get_user_model()
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
first, last = _split_name(full_name)
if user is None:
user = user_model(
username=_unique_username(email.split("@", 1)[0]),
email=email,
first_name=first,
last_name=last,
)
user.set_unusable_password()
user.save()
elif full_name and not (user.first_name or user.last_name):
user.first_name, user.last_name = first, last
user.save(update_fields=["first_name", "last_name"])
return user
@require_POST
def entra_login(request):
"""Start de Entra ID authorization-code flow."""
if not entra_sso.entra_enabled():
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
return redirect("login")
try:
config = entra_sso.load_config()
redirect_uri = config.redirect_uri or request.build_absolute_uri(reverse("entra-callback"))
flow = entra_sso.build_auth_flow(config, redirect_uri)
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
request.session[SESSION_ENTRA_FLOW] = flow
next_url = _safe_next(request, request.POST.get("next"))
if next_url:
request.session[SESSION_ENTRA_NEXT] = next_url
return redirect(flow["auth_uri"])
def entra_callback(request):
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
if not entra_sso.entra_enabled():
return redirect("login")
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
if not flow:
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
return redirect("login")
try:
config = entra_sso.load_config()
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
if "error" in result:
logger.warning("Entra ID-fout: %s", result.get("error_description") or result.get("error"))
messages.error(request, "Inloggen via Entra ID is mislukt.")
return redirect("login")
email, name = entra_sso.extract_identity(result)
if not email:
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
return redirect("login")
if not entra_sso.domain_allowed(config, email):
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
return redirect("login")
user = _get_or_create_sso_user(email, name)
login(request, user, backend=SSO_BACKEND)
clear_rate_limit(request, namespace="login")
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
@require_POST
def demo_login(request):
"""Log in als afgeschermd demo-account."""
if not getattr(settings, "DEMO_MODE_ENABLED", False):
messages.error(request, "De demomodus is niet ingeschakeld.")
return redirect("login")
user_model = get_user_model()
user, created = user_model.objects.get_or_create(
username=settings.DEMO_USERNAME,
defaults={
"email": settings.DEMO_EMAIL,
"first_name": "Demo",
"last_name": "Gebruiker",
},
)
if created:
user.set_unusable_password()
user.save()
try:
from apps.jobs.services.demo_seed import seed_demo_environment
seed_demo_environment(user)
except Exception:
# De demo mag nooit de login blokkeren.
logger.exception("Kon de demo-omgeving niet seeden")
login(request, user, backend=SSO_BACKEND)
request.session.set_expiry(settings.DEMO_SESSION_SECONDS)
next_url = _safe_next(request, request.POST.get("next"))
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
def robots_txt(request):
if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
content = "User-agent: *\nAllow: /\n"
else:
content = "User-agent: *\nDisallow: /\n"
return HttpResponse(content, content_type="text/plain; charset=utf-8")
def health_live(request): def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"}) return JsonResponse({"ok": True, "service": "vacatureradar"})
@@ -83,44 +255,9 @@ class TodayView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
latest_scores = ( profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
ScoreRun.objects.select_related("job", "job__employer", "profile") context.update(build_dashboard_cockpit(self.request.user, profile))
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[]) context["active_profile"] = profile
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
seen: set[str] = set()
cards = []
for score in latest_scores[:250]:
key = str(score.job_id)
if key in seen:
continue
seen.add(key)
cards.append(score)
if len(cards) >= 20:
break
context.update(
{
"score_cards": cards,
"priority_applications": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
"new_today": JobPosting.objects.filter(
first_seen__date=timezone.localdate()
).count(),
"applications_open": Application.objects.exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
).count(),
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
total=Count("id"),
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
),
}
)
return context return context
@@ -129,8 +266,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["health"] = readiness() context.update(build_automation_cockpit())
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
context["source_health"] = collect_source_health() context["source_health"] = collect_source_health()
return context return context
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from django.core.paginator import Page, Paginator
from django.urls import reverse
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import ProfileRevision
from apps.sources.models import SourceRun
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
@dataclass(frozen=True)
class ActivityEvent:
occurred_at: datetime
event_type: str
title: str
detail: str
tone: str
url: str
object_label: str
ACTIVITY_FILTERS = (
("all", "Alle activiteit"),
("source", "Bronruns"),
("job", "Vacatures"),
("application", "Sollicitaties"),
("feedback", "Feedback"),
("notification", "Notificaties"),
("profile", "Profiel"),
)
def _source_events() -> list[ActivityEvent]:
events = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
detail = (
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
f"{run.duplicate_count} duplicaat"
)
if run.error_category:
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
events.append(
ActivityEvent(
run.started_at,
"source",
f"Bronrun {run.get_status_display().lower()}",
detail,
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
reverse("sources:list") + f"#source-details-{run.source_id}",
run.source.name,
)
)
return events
def _job_events() -> list[ActivityEvent]:
return [
ActivityEvent(
version.created_at,
"job",
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
", ".join(version.changed_fields)
if version.changed_fields
else "Bronmoment vastgelegd",
"neutral",
reverse("jobs:detail", kwargs={"pk": version.job_id}),
version.job.original_title,
)
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
]
def build_activity_page(
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
) -> Page:
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
events: list[ActivityEvent] = []
if event_type in {"all", "source"}:
events.extend(_source_events())
if event_type in {"all", "job"}:
events.extend(_job_events())
if event_type in {"all", "feedback"}:
for item in (
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
):
events.append(
ActivityEvent(
item.created_at,
"feedback",
item.get_action_display(),
item.reason or "Gebruikersactie opgeslagen",
"neutral",
reverse("jobs:detail", kwargs={"pk": item.job_id}),
item.job.original_title,
)
)
if event_type in {"all", "application"}:
for item in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:100]
):
events.append(
ActivityEvent(
item.created_at,
"application",
item.get_event_type_display(),
"Sollicitatiedossier bijgewerkt",
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
item.application.job.original_title,
)
)
if event_type in {"all", "notification"}:
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
events.append(
ActivityEvent(
item.created_at,
"notification",
f"{label} {item.get_status_display().lower()}",
item.subject,
"positive" if item.status == item.Status.SENT else "attention",
reverse("dashboard:system"),
label,
)
)
if event_type in {"all", "profile"}:
for item in (
ProfileRevision.objects.filter(profile__user=user)
.select_related("profile")
.order_by("-created_at")[:60]
):
events.append(
ActivityEvent(
item.created_at,
"profile",
f"Zoekprofiel versie {item.version}",
item.reason or "Profielconfiguratie opgeslagen",
"neutral",
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
item.profile.name,
)
)
normalized_query = query.strip().casefold()
if normalized_query:
events = [
item
for item in events
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
]
events.sort(key=lambda item: item.occurred_at, reverse=True)
return Paginator(events, per_page).get_page(page)
+25
View File
@@ -274,6 +274,31 @@ def track_application_changes(
return events return events
def change_application_status(*, application: Application, user, status: str) -> Application:
"""Persist an accessible pipeline status change and its audit event."""
valid_statuses = {value for value, _label in Application.Status.choices}
if status not in valid_statuses:
raise ValueError("Ongeldige sollicitatiestatus.")
previous = application.status
if previous == status:
return application
with transaction.atomic():
application.status = status
update_fields = ["status", "updated_at"]
if status == Application.Status.APPLIED and application.applied_at is None:
application.applied_at = timezone.now()
update_fields.append("applied_at")
application.save(update_fields=update_fields)
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
metadata={"from": previous, "to": status},
)
return application
def build_print_html(application: Application) -> str: def build_print_html(application: Application) -> str:
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {} snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
job = application.job job = application.job
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import (
CharField,
Count,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
QuerySet,
Subquery,
)
from django.utils import timezone
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun
from .relevance import it_relevance_query
User = get_user_model()
def annotate_latest_profile_score(
queryset: QuerySet[JobPosting], profile: SearchProfile | None
) -> QuerySet[JobPosting]:
"""Annotate jobs with one deterministic latest score for the active profile."""
if profile is None:
return queryset
latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at")
return queryset.annotate(
match_score=Subquery(
latest.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest.values("recommendation")[:1], output_field=CharField()
),
match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()),
match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()),
match_hard_exclusions=Subquery(
latest.values("hard_exclusions")[:1], output_field=JSONField()
),
match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()),
)
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
activity: list[dict[str, Any]] = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]:
activity.append(
{
"occurred_at": run.started_at,
"kind": "source",
"title": f"Bronrun {run.get_status_display().lower()}",
"detail": run.source.name,
"tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention",
}
)
for event in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:4]
):
activity.append(
{
"occurred_at": event.created_at,
"kind": "application",
"title": event.get_event_type_display(),
"detail": event.application.job.original_title,
"tone": "neutral",
}
)
return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5]
def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]:
"""Build truthful dashboard metrics and bounded read lists."""
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
active_it_jobs = active_jobs.filter(it_relevance_query())
today = timezone.localdate()
latest_scores = ScoreRun.objects.none()
if profile is not None:
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer")
.filter(
profile=profile,
pk=Subquery(latest_score_id),
job__status=JobPosting.Status.ACTIVE,
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
hard_exclusions=[],
)
.filter(it_relevance_query("job__"))
)
sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
source_counts = sources.aggregate(
total=Count("id"),
active=Count("id", filter=Q(status=Source.Status.ACTIVE)),
attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
)
open_applications = Application.objects.filter(user=user).exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
)
new_today = active_it_jobs.filter(first_seen__date=today).count()
strong_today = latest_scores.filter(
recommendation=ScoreRun.Recommendation.STRONG,
job__first_seen__date=today,
).count()
filtered_count = active_jobs.exclude(it_relevance_query()).count()
duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count()
if strong_today:
day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht."
elif new_today:
day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)."
else:
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
return {
# Het dagoverzicht blijft een beslissingsscherm; de volledige rangschikking
# hoort in de verkenner. Vijf kaarten houden de primaire taak scanbaar.
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:5]),
"priority_applications": list(
open_applications.select_related("job", "job__employer").order_by(
F("follow_up_date").asc(nulls_last=True), "-updated_at"
)[:4]
),
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": filtered_count,
"duplicate_jobs": duplicate_count,
"new_today": new_today,
"strong_today": strong_today,
"applications_open": open_applications.count(),
"source_counts": source_counts,
"recent_activity": _recent_dashboard_activity(user),
"day_conclusion": day_conclusion,
"latest_scan": SourceRun.objects.order_by("-started_at").first(),
}
def build_job_intelligence(
*, job: JobPosting, profile: SearchProfile | None, user: User
) -> dict[str, Any]:
"""Return the bounded, user-scoped read model for explorer and detail."""
score = (
ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first()
if profile
else None
)
return {
"score": score,
"application": Application.objects.filter(user=user, job=job).first(),
"duplicate_count": job.duplicates.count(),
"source_count": job.source_aliases.count(),
"version_count": job.versions.count(),
"latest_versions": list(job.versions.all()[:5]),
"provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]),
}
+269
View File
@@ -0,0 +1,269 @@
"""Vult een fictieve demo-omgeving voor het demo-account.
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
niet opnieuw geseed.
"""
from __future__ import annotations
import hashlib
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from apps.profiles.models import SearchProfile
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
# skills_preferred, score, recommendation, positives, concerns)
_DEMO_JOBS = [
(
"Infrastructure Engineer",
"CloudNexus",
"cloudnexus.example",
"Hasselt",
"Limburg",
"hybrid",
["Microsoft 365", "VMware", "Azure"],
["Terraform"],
Decimal("92.4"),
"strong",
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
[],
),
(
"Cloud Architect",
"Skylink Solutions",
"skylink.example",
"Remote",
"Vlaanderen",
"remote",
["Azure", "Kubernetes"],
["Bicep", "Landing Zones"],
Decimal("88.1"),
"strong",
["Volledig remote", "Azure-zwaartepunt sluit aan"],
["Iets seniorder dan gevraagd"],
),
(
"Systeembeheerder Microsoft 365",
"Gemeente Genk",
"genk.example",
"Genk",
"Limburg",
"on_site",
["Microsoft 365", "Active Directory"],
["Intune"],
Decimal("83.6"),
"strong",
["Publieke sector met stabiliteit", "M365-focus"],
["Volledig op locatie"],
),
(
"DevOps Engineer",
"Datastroom BV",
"datastroom.example",
"Leuven",
"Vlaams-Brabant",
"hybrid",
["CI/CD", "Docker", "Azure"],
["GitLab", "Observability"],
Decimal("77.9"),
"possible",
["Moderne toolchain", "Hybride"],
["Reistijd naar Leuven"],
),
(
"Network Engineer",
"Noorder Telecom",
"noordertelecom.example",
"Antwerpen",
"Antwerpen",
"on_site",
["Cisco", "Netwerkbeheer"],
["Fortinet"],
Decimal("70.4"),
"possible",
["Stevige netwerkrol"],
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
),
(
"IT Support Specialist",
"MediCare Groep",
"medicare.example",
"Diepenbeek",
"Limburg",
"on_site",
["Servicedesk", "Windows"],
["ITIL"],
Decimal("62.3"),
"weak",
["Dichtbij"],
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
),
]
def _digest(*parts: str) -> str:
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
def demo_environment_seeded(user) -> bool:
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
if profile is None:
return False
from apps.jobs.models import ScoreRun
return ScoreRun.objects.filter(profile=profile).exists()
def seed_demo_environment(user) -> None:
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
if demo_environment_seeded(user):
return
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
profile, _ = SearchProfile.objects.get_or_create(
user=user,
name=_DEMO_PROFILE_NAME,
defaults={
"is_active": True,
"home_municipality": "Hasselt",
"home_latitude": Decimal("50.930700"),
"home_longitude": Decimal("5.332500"),
"max_distance_km": 60,
"experience_years": 5,
"learning_enabled": True,
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
"excluded_titles": ["sales", "recruiter"],
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
"allowed_employment_types": ["full_time", "permanent"],
"preferred_workplace": ["hybrid", "remote", "on_site"],
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
},
)
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
if not profile.is_active:
profile.is_active = True
profile.save(update_fields=["is_active"])
now = timezone.now()
workplace_map = {
"hybrid": JobPosting.Workplace.HYBRID,
"remote": JobPosting.Workplace.REMOTE,
"on_site": JobPosting.Workplace.ON_SITE,
}
created_jobs: list = []
for index, (
title,
employer_name,
domain,
municipality,
region,
workplace,
req_skills,
pref_skills,
score,
recommendation,
positives,
concerns,
) in enumerate(_DEMO_JOBS):
employer, _ = Employer.objects.get_or_create(
normalized_name=employer_name.lower(),
defaults={
"name": employer_name,
"domain": domain,
"is_direct_employer": True,
"is_recruiter": False,
"confidence": 0.92,
},
)
slug = _digest("demo-job", str(index), title, employer_name)
job, job_created = JobPosting.objects.get_or_create(
canonical_key=slug,
defaults={
"employer": employer,
"original_title": title,
"normalized_title": title.lower(),
"job_family": "infrastructure",
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
"content_hash": _digest("demo-content", slug),
"description_text": (
f"{title} bij {employer_name} in {municipality}. "
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
),
"raw_location": f"{municipality}, {region}",
"region": region,
"municipality": municipality,
"workplace_type": workplace_map[workplace],
"employment_types": ["full_time", "permanent"],
"skills_required": req_skills,
"skills_preferred": pref_skills,
"direct_employer": True,
"recruiter": False,
"extraction_confidence": 0.9,
"analysis_features": {
"support_ratio": 0.1,
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
"experience_years_max": 6,
},
"status": JobPosting.Status.ACTIVE,
"first_seen": now - timedelta(days=index + 1),
"last_seen": now,
"date_posted": now - timedelta(days=index + 1),
},
)
created_jobs.append(job)
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=score,
confidence=Decimal("0.78"),
recommendation=recommendation,
components={
"content": float(score) * 0.45,
"skills": float(score) * 0.30,
"location": float(score) * 0.25,
},
positives=positives,
concerns=concerns,
hard_exclusions=[],
evidence={"demo": True},
model_version="demo-seed",
prompt_version="1.0.0",
created_at=now - timedelta(hours=index),
)
# Een kleine sollicitatiepijplijn voor de demo.
if created_jobs:
Application.objects.get_or_create(
user=user,
job=created_jobs[0],
defaults={
"status": Application.Status.INTERVIEW,
"applied_at": now - timedelta(days=6),
"follow_up_date": (now + timedelta(days=2)).date(),
"contact_name": "Sofie Vandael",
"contact_email": "sofie.vandael@cloudnexus.example",
"notes": "Eerste gesprek gepland. Demo-data.",
},
)
if len(created_jobs) > 3:
Application.objects.get_or_create(
user=user,
job=created_jobs[3],
defaults={
"status": Application.Status.APPLIED,
"applied_at": now - timedelta(days=2),
"follow_up_date": (now + timedelta(days=5)).date(),
"notes": "Sollicitatie verstuurd. Demo-data.",
},
)
@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from django.db.models import Avg, Count, Max, Q, QuerySet
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from ..models import Application, Employer, JobPosting, ScoreRun
from .cockpit import annotate_latest_profile_score
from .relevance import it_relevance_query
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
return Employer.objects.annotate(
active_vacancies=Count(
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
),
relevant_vacancies=Count(
"jobs",
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
distinct=True,
),
previous_applications=Count(
"jobs__applications",
filter=Q(jobs__applications__user=user),
distinct=True,
),
source_count=Count("jobs__source_aliases__source", distinct=True),
latest_job_at=Max("jobs__first_seen"),
average_match=Avg("jobs__scores__score", filter=score_filter),
)
def filter_employers(
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
) -> QuerySet[Employer]:
if query.strip():
queryset = queryset.filter(
Q(name__icontains=query.strip())
| Q(domain__icontains=query.strip())
| Q(jobs__raw_location__icontains=query.strip())
)
if channel == "direct":
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(is_recruiter=True)
if active_only:
queryset = queryset.filter(active_vacancies__gt=0)
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
def build_employer_detail(
*, employer: Employer, user, profile: SearchProfile | None
) -> dict[str, Any]:
jobs = annotate_latest_profile_score(
employer.jobs.select_related("employer").all(), profile
).order_by("-first_seen")
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
locations = list(
employer.jobs.exclude(raw_location="")
.order_by("raw_location")
.values_list("raw_location", flat=True)
.distinct()[:8]
)
return {
"employer": employer,
"recent_jobs": list(jobs[:12]),
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
"applications": list(
Application.objects.filter(user=user, job__employer=employer)
.select_related("job")
.order_by("-updated_at")[:8]
),
"sources": list(sources[:10]),
"locations": locations,
"match_history": list(
ScoreRun.objects.filter(profile=profile, job__employer=employer)
.select_related("job")
.order_by("-created_at")[:20]
)
if profile
else [],
}
-1
View File
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
"location", "location",
"conditions", "conditions",
"employer", "employer",
"seniority",
"preferences", "preferences",
} }
+8
View File
@@ -49,13 +49,21 @@ EMPLOYMENT_MAP = {
"vast": "permanent", "vast": "permanent",
} }
TITLE_FAMILIES = { TITLE_FAMILIES = {
"it field engineer": "field-support",
"field service engineer": "field-support",
"it engineer": "infrastructure",
"system engineer": "infrastructure", "system engineer": "infrastructure",
"system network engineer": "infrastructure",
"system administrator": "infrastructure",
"systeembeheerder": "infrastructure", "systeembeheerder": "infrastructure",
"infrastructure engineer": "infrastructure", "infrastructure engineer": "infrastructure",
"network engineer": "network", "network engineer": "network",
"network administrator": "network",
"netwerkbeheerder": "network", "netwerkbeheerder": "network",
"workplace engineer": "workplace", "workplace engineer": "workplace",
"support engineer": "support", "support engineer": "support",
"it technician": "support",
"pc technician": "support",
"helpdesk": "support", "helpdesk": "support",
"developer": "software-development", "developer": "software-development",
"data engineer": "data", "data engineer": "data",
+148 -23
View File
@@ -1,12 +1,21 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from decimal import Decimal from decimal import Decimal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.utils import timezone from django.utils import timezone
from apps.jobs.models import Employer, FieldProvenance, JobPosting, JobSourceAlias, JobVersion from apps.jobs.models import (
Employer,
FieldProvenance,
JobPosting,
JobSourceAlias,
JobVersion,
ScoreRun,
)
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
from apps.sources.adapters.base import FieldEvidence from apps.sources.adapters.base import FieldEvidence
from apps.sources.adapters.registry import registry from apps.sources.adapters.registry import registry
@@ -29,17 +38,31 @@ RECRUITER_TERMS = {
} }
@dataclass
class PersistenceContext:
"""Run-scoped reference cache; never shared between workers or imports."""
employers: dict[tuple[str, str], Employer | None] = dataclass_field(default_factory=dict)
active_profiles: list[SearchProfile] | None = None
latest_scores: dict[tuple[object, int], ScoreRun] = dataclass_field(default_factory=dict)
def _confidence(value: float) -> Decimal: def _confidence(value: float) -> Decimal:
return Decimal(str(max(0.0, min(1.0, value)))) return Decimal(str(max(0.0, min(1.0, value))))
def resolve_employer(draft: CanonicalJobDraft) -> Employer | None: def resolve_employer(
draft: CanonicalJobDraft, *, context: PersistenceContext | None = None
) -> Employer | None:
name = draft.employer_name.strip() name = draft.employer_name.strip()
domain = draft.employer_domain.strip() domain = draft.employer_domain.strip()
if not name and not domain: if not name and not domain:
return None return None
display_name = name or domain display_name = name or domain
normalized = normalize_token(display_name) normalized = normalize_token(display_name)
cache_key = (normalized, domain)
if context is not None and cache_key in context.employers:
return context.employers[cache_key]
recruiter = any(term in normalized for term in RECRUITER_TERMS) recruiter = any(term in normalized for term in RECRUITER_TERMS)
employer, _ = Employer.objects.get_or_create( employer, _ = Employer.objects.get_or_create(
normalized_name=normalized, normalized_name=normalized,
@@ -61,6 +84,8 @@ def resolve_employer(draft: CanonicalJobDraft) -> Employer | None:
changed.extend(["is_recruiter", "is_direct_employer"]) changed.extend(["is_recruiter", "is_direct_employer"])
if changed: if changed:
employer.save(update_fields=[*changed, "updated_at"]) employer.save(update_fields=[*changed, "updated_at"])
if context is not None:
context.employers[cache_key] = employer
return employer return employer
@@ -162,12 +187,81 @@ def _apply_draft(
if direct and canonical_url and job.canonical_url != canonical_url: if direct and canonical_url and job.canonical_url != canonical_url:
job.canonical_url = canonical_url job.canonical_url = canonical_url
changed.append("canonical_url") changed.append("canonical_url")
if changed and "last_changed" not in changed: substantive_changes = [field for field in changed if field != "last_seen"]
if substantive_changes and "last_changed" not in changed:
job.last_changed = timezone.now() job.last_changed = timezone.now()
changed.append("last_changed") changed.append("last_changed")
return changed return changed
def _sync_evidence(
*,
job: JobPosting,
alias: JobSourceAlias,
evidence_items: list[FieldEvidence],
parser_version: str,
) -> None:
existing = {
(item.field_name, item.extraction_method): item
for item in FieldProvenance.objects.filter(job=job, source_alias=alias)
}
creates: list[FieldProvenance] = []
updates: list[FieldProvenance] = []
for evidence in evidence_items:
key = (evidence.field_name, evidence.method)
values = {
"confidence": _confidence(evidence.confidence),
"evidence_excerpt": evidence.evidence[:1000],
"parser_version": parser_version,
}
current = existing.get(key)
if current is None:
creates.append(
FieldProvenance(
job=job,
source_alias=alias,
field_name=evidence.field_name,
extraction_method=evidence.method,
**values,
)
)
continue
changed = False
for field_name, value in values.items():
if getattr(current, field_name) != value:
setattr(current, field_name, value)
changed = True
if changed:
current.updated_at = timezone.now()
updates.append(current)
if creates:
FieldProvenance.objects.bulk_create(creates, batch_size=250)
if updates:
FieldProvenance.objects.bulk_update(
updates,
["confidence", "evidence_excerpt", "parser_version", "updated_at"],
batch_size=250,
)
def _copy_score(score: ScoreRun) -> ScoreRun:
return ScoreRun.objects.create(
job=score.job,
profile=score.profile,
profile_version=score.profile_version,
score=score.score,
confidence=score.confidence,
recommendation=score.recommendation,
components=score.components,
positives=score.positives,
concerns=score.concerns,
hard_exclusions=score.hard_exclusions,
evidence=score.evidence,
model_version=score.model_version,
prompt_version=score.prompt_version,
)
@transaction.atomic @transaction.atomic
def persist_draft( def persist_draft(
draft: CanonicalJobDraft, draft: CanonicalJobDraft,
@@ -176,14 +270,16 @@ def persist_draft(
parser_key: str, parser_key: str,
parser_version: str, parser_version: str,
extraction_confidence: float, extraction_confidence: float,
context: PersistenceContext | None = None,
) -> tuple[JobPosting, DedupeDecision, bool]: ) -> tuple[JobPosting, DedupeDecision, bool]:
source = document.source source = document.source
employer = resolve_employer(draft) employer = resolve_employer(draft, context=context)
decision = find_existing_job(draft, source=source) decision = find_existing_job(draft, source=source)
direct = _source_is_direct(source, draft) direct = _source_is_direct(source, draft)
if decision.resolved_direct: if decision.resolved_direct:
direct = True direct = True
created = False created = False
substantive_change = False
if decision.job is None: if decision.job is None:
try: try:
@@ -240,6 +336,7 @@ def persist_draft(
if extraction_confidence > float(job.extraction_confidence): if extraction_confidence > float(job.extraction_confidence):
job.extraction_confidence = _confidence(extraction_confidence) job.extraction_confidence = _confidence(extraction_confidence)
changed.append("extraction_confidence") changed.append("extraction_confidence")
substantive_change = any(field not in {"last_seen", "last_changed"} for field in changed)
if changed: if changed:
job.save(update_fields=list(dict.fromkeys([*changed, "updated_at"]))) job.save(update_fields=list(dict.fromkeys([*changed, "updated_at"])))
@@ -284,31 +381,58 @@ def persist_draft(
update_fields=["last_seen", "raw_document", "payload", "is_canonical", "updated_at"] update_fields=["last_seen", "raw_document", "payload", "is_canonical", "updated_at"]
) )
for evidence in draft.evidence: _sync_evidence(
FieldProvenance.objects.update_or_create(
job=job,
source_alias=alias,
field_name=evidence.field_name,
extraction_method=evidence.method,
defaults={
"confidence": _confidence(evidence.confidence),
"evidence_excerpt": evidence.evidence[:1000],
"parser_version": parser_version,
},
)
JobVersion.objects.get_or_create(
job=job, job=job,
content_hash=job.content_hash, alias=alias,
defaults={"snapshot": job_snapshot(job), "changed_fields": []}, evidence_items=draft.evidence,
parser_version=parser_version,
) )
for profile in SearchProfile.objects.filter(is_active=True):
score_and_save(job, profile) if created or substantive_change:
JobVersion.objects.get_or_create(
job=job,
content_hash=job.content_hash,
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
)
if context is not None:
if context.active_profiles is None:
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
profiles = context.active_profiles
else:
profiles = SearchProfile.objects.filter(is_active=True)
for profile in profiles:
score = score_and_save(job, profile)
if context is not None:
context.latest_scores[(job.pk, profile.pk)] = score
else:
if context is not None:
if context.active_profiles is None:
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
profiles = context.active_profiles
else:
profiles = SearchProfile.objects.filter(is_active=True)
for profile in profiles:
cache_key = (job.pk, profile.pk)
previous = context.latest_scores.get(cache_key) if context is not None else None
if previous is None:
previous = (
ScoreRun.objects.filter(job=job, profile=profile)
.order_by("-created_at")
.first()
)
if previous is None or previous.profile_version != profile.version:
score = score_and_save(job, profile)
else:
score = _copy_score(previous)
if context is not None:
context.latest_scores[cache_key] = score
return job, decision, created return job, decision, created
@transaction.atomic @transaction.atomic
def process_raw_document(document: RawDocument) -> dict[str, int | str | list[str]]: def process_raw_document(
document: RawDocument, *, context: PersistenceContext | None = None
) -> dict[str, int | str | list[str]]:
result = registry.extract(document) result = registry.extract(document)
document.parser_key = result.parser_key document.parser_key = result.parser_key
document.parser_version = result.parser_version document.parser_version = result.parser_version
@@ -341,6 +465,7 @@ def process_raw_document(document: RawDocument) -> dict[str, int | str | list[st
parser_key=result.parser_key, parser_key=result.parser_key,
parser_version=result.parser_version, parser_version=result.parser_version,
extraction_confidence=result.confidence, extraction_confidence=result.confidence,
context=context,
) )
if was_created: if was_created:
created += 1 created += 1
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from django.db.models import Q
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
IT_TITLE_TERMS = (
"it",
"ict",
"informatica",
"software",
"development engineer",
"devops",
"secops",
"cloud",
"data engineer",
"data scientist",
"data steward",
"database",
"dba",
"business intelligence",
"bi analyst",
"system engineer",
"systems engineer",
"system network engineer",
"system administrator",
"systeembeheer",
"infrastructure engineer",
"infrastructuur engineer",
"network engineer",
"network administrator",
"network architect",
"netwerkbeheer",
"netwerk engineer",
"cybersecurity",
"cyber security",
"security engineer",
"security architect",
"security analyst",
"information security",
"informatiebeveiliging",
"workplace engineer",
"endpoint engineer",
"microsoft 365 engineer",
"m365 engineer",
"intune engineer",
"modern workplace",
"digital workplace",
"service desk",
"servicedesk",
"helpdesk",
"support engineer",
"support specialist",
"it technician",
"pc technician",
"application lead",
"application engineer",
"application manager",
"application specialist",
"applicatiebeheer",
"platform engineer",
"platform expert",
"solution architect",
"solutions architect",
"technical architect",
"technisch architect",
"functional analyst",
"functioneel analist",
"business analyst",
"product owner",
"web developer",
"mobile developer",
"low-code developer",
"low code developer",
"api developer",
"idm developer",
"power platform",
"forgerock",
"embedded",
"c#",
"front end",
"frontend",
"back end",
"backend",
"full stack",
"fullstack",
"php",
".net",
"java",
"python",
"powershell",
"azure",
"aws",
"microsoft 365",
"linux",
"windows server",
"kubernetes",
"docker",
"terraform",
"erp",
"sap",
"machine learning",
"ai engineer",
"artificial intelligence",
"image processing",
"computer vision",
"qa engineer",
"test automation",
)
# These engineering titles occur both in IT and in unrelated technical sectors. They are
# accepted only when the title supplies the role context and the description independently
# supplies a strong IT signal. A description signal by itself remains insufficient.
CONTEXTUAL_IT_TITLE_TERMS = (
"field engineer",
"field service engineer",
"implementation engineer",
"implementation consultant",
"service engineer",
)
IT_DESCRIPTION_TERMS = (
"microsoft 365",
"m365",
"windows server",
"active directory",
"entra id",
"intune",
"autopilot",
"exchange online",
"sharepoint",
"vmware",
"proxmox",
"hyper-v",
"networking",
"netwerkbeheer",
"tcp/ip",
"vlan",
"vpn",
"dhcp",
"dns",
"firewall",
"switches",
"routers",
"workstations",
"desktops",
"laptops",
"voip",
"3cx",
"powershell",
)
# These phrases can contain IT vocabulary while describing commercial, recruitment,
# or educational-design work. They therefore override positive title signals.
NON_IT_TITLE_TERMS = (
"business developer",
"business development",
"it recruitment",
"it recruiter",
"learning designer",
)
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
def _term_pattern(term: str) -> str:
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
return rf"(?<!\w){escaped}(?!\w)"
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
CONTEXTUAL_IT_TITLE_PATTERN = (
"(?:" + "|".join(_term_pattern(term) for term in CONTEXTUAL_IT_TITLE_TERMS) + ")"
)
IT_DESCRIPTION_PATTERN = (
"(?:" + "|".join(_term_pattern(term) for term in IT_DESCRIPTION_TERMS) + ")"
)
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
_CONTEXTUAL_IT_TITLE_RE = re.compile(CONTEXTUAL_IT_TITLE_PATTERN, re.IGNORECASE)
_IT_DESCRIPTION_RE = re.compile(IT_DESCRIPTION_PATTERN, re.IGNORECASE)
@dataclass(frozen=True)
class ItRelevanceAssessment:
relevant: bool
signals: tuple[str, ...]
reason: str
def assess_it_relevance(title: str, description: str = "") -> ItRelevanceAssessment:
normalized_title = normalize_token(title)
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
if blocked_signal:
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
)
signals = tuple(
term
for term in IT_TITLE_TERMS
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
)
if signals:
return ItRelevanceAssessment(
relevant=True,
signals=signals[:4],
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
)
contextual_title = _CONTEXTUAL_IT_TITLE_RE.search(normalized_title)
if contextual_title:
description_signal = _IT_DESCRIPTION_RE.search(normalize_token(description))
if description_signal:
return ItRelevanceAssessment(
relevant=True,
signals=(contextual_title.group(0), description_signal.group(0)),
reason=(
"IT-context bevestigd via ambigue functietitel en vacaturetekst: "
f"{contextual_title.group(0)}, {description_signal.group(0)}."
),
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Ambigue technische functietitel zonder aantoonbare IT-context.",
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Geen aantoonbaar IT-signaal in de functietitel.",
)
def profile_requires_it_focus(profile: SearchProfile) -> bool:
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
normalized = normalize_token(configured)
return any(
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
for term in IT_PROFILE_TERMS
)
def it_relevance_query(prefix: str = "") -> Q:
"""Return the database equivalent of the conservative title-led classifier."""
clear_title = Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN})
contextual_title = Q(**{f"{prefix}original_title__iregex": CONTEXTUAL_IT_TITLE_PATTERN}) & Q(
**{f"{prefix}description_text__iregex": IT_DESCRIPTION_PATTERN}
)
return (clear_title | contextual_title) & ~Q(
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
)
+36 -23
View File
@@ -14,6 +14,8 @@ from apps.jobs.services.geocoding import resolve_cached_location
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
from .normalization import normalize_token from .normalization import normalize_token
from .relevance import assess_it_relevance, profile_requires_it_focus
from .skill_terms import contains_term, skill_is_present
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -51,6 +53,7 @@ class DistanceAssessment:
EXACT_DISTANCE_CONF_THRESHOLD = 0.80 EXACT_DISTANCE_CONF_THRESHOLD = 0.80
AI_MAX_WEIGHT = 20.0 AI_MAX_WEIGHT = 20.0
SKILL_MATCH_TARGET = 4
def _similarity(left: str, right: str) -> float: def _similarity(left: str, right: str) -> float:
@@ -112,6 +115,14 @@ def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
) )
def _contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
return contains_term(text, term, allow_plural=allow_plural)
def _skill_is_present(skill: str, text: str) -> bool:
return skill_is_present(skill, text)
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]: def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
desired = {normalize_token(skill) for skill in profile.desired_skills if skill} desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
if not desired: if not desired:
@@ -119,9 +130,10 @@ def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str
text = normalize_token( text = normalize_token(
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text " ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
) )
present = sorted(skill for skill in desired if skill and skill in text) present = sorted(skill for skill in desired if _skill_is_present(skill, text))
missing = sorted(desired - set(present)) missing = sorted(desired - set(present))
return len(present) / len(desired), present, missing evidence_target = min(SKILL_MATCH_TARGET, len(desired))
return min(1.0, len(present) / evidence_target), present, missing
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment: def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
@@ -172,7 +184,7 @@ def _hard_exclusions(
configured_terms = list(profile.excluded_titles) configured_terms = list(profile.excluded_titles)
configured_terms += list(profile.hard_rules.get("excluded_title_terms", [])) configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
for term in configured_terms: for term in configured_terms:
if normalize_token(term) and normalize_token(term) in title: if _contains_term(title, normalize_token(term), allow_plural=True):
reasons.append(f"Uitgesloten titelterm: {term}") reasons.append(f"Uitgesloten titelterm: {term}")
excluded_types = set(profile.hard_rules.get("excluded_employment_types", [])) excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
@@ -227,6 +239,9 @@ def _hard_exclusions(
conflicts = sorted(excluded_skills.intersection(explicit_job_skills)) conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
if conflicts: if conflicts:
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts)) reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
it_relevance = assess_it_relevance(job.original_title, job.description_text)
if profile_requires_it_focus(profile) and not it_relevance.relevant:
reasons.append(it_relevance.reason)
return reasons, distance.distance_confidence return reasons, distance.distance_confidence
@@ -244,20 +259,10 @@ def _ai_feature_score(features: dict[str, Any]) -> float:
support_ratio = float(features.get("support_ratio") or 0.0) support_ratio = float(features.get("support_ratio") or 0.0)
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0) consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
travel_ratio = float(features.get("travel_ratio") or 0.0) travel_ratio = float(features.get("travel_ratio") or 0.0)
seniority = str(features.get("seniority") or "").strip().lower()
seniority_boost = {
"junior": 0.0,
"medior": 0.08,
"senior": 0.12,
"lead": 0.14,
"expert": 0.16,
"unknown": 0.03,
"": 0.03,
}.get(seniority, 0.05)
score = ( score = (
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio) 0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
) )
return max(0.0, min(1.0, score + seniority_boost)) return max(0.0, min(1.0, score))
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]: def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
@@ -292,6 +297,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult: def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
distance = _distance(job, profile) distance = _distance(job, profile)
exclusions, distance_confidence = _hard_exclusions(job, profile, distance) exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
it_relevance = assess_it_relevance(job.original_title, job.description_text)
title_fit = _title_fit(job, profile) title_fit = _title_fit(job, profile)
skill_fit, present_skills, missing_skills = _skill_fit(job, profile) skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
features = job.analysis_features or {} features = job.analysis_features or {}
@@ -321,12 +327,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
else: else:
conditions_fit = 0.65 conditions_fit = 0.65
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45 employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
experience_years = features.get("experience_years_max")
seniority_fit = (
0.75
if experience_years is None
else max(0.25, 1.0 - max(0, int(experience_years) - 5) * 0.1)
)
if profile.preferred_workplace: if profile.preferred_workplace:
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45 preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
else: else:
@@ -342,7 +342,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
"location": location_fit, "location": location_fit,
"conditions": conditions_fit, "conditions": conditions_fit,
"employer": employer_fit, "employer": employer_fit,
"seniority": seniority_fit,
"preferences": preference_fit, "preferences": preference_fit,
} }
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile) ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
@@ -381,6 +380,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns: list[str] = [] concerns: list[str] = []
if title_fit >= 0.75: if title_fit >= 0.75:
positives.append("Functietitel sluit goed aan op het zoekprofiel.") positives.append("Functietitel sluit goed aan op het zoekprofiel.")
if it_relevance.relevant:
positives.append("IT-focus bevestigd via de functietitel.")
if present_skills: if present_skills:
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6])) positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
if job.direct_employer and not job.recruiter: if job.direct_employer and not job.recruiter:
@@ -402,8 +403,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
) )
if support_ratio >= 0.5: if support_ratio >= 0.5:
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.") concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
if missing_skills:
concerns.append("Niet duidelijk teruggevonden: " + ", ".join(missing_skills[:6]))
if ( if (
distance.exact_distance_km is None distance.exact_distance_km is None
and distance.has_distance_data and distance.has_distance_data
@@ -435,6 +434,20 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns=concerns, concerns=concerns,
hard_exclusions=exclusions, hard_exclusions=exclusions,
evidence={ evidence={
"it_relevance": {
"relevant": it_relevance.relevant,
"signals": list(it_relevance.signals),
"reason": it_relevance.reason,
},
"skills": {
"matched": present_skills,
"configured_count": len(present_skills) + len(missing_skills),
"match_target": min(
SKILL_MATCH_TARGET,
len(present_skills) + len(missing_skills),
),
"not_observed": missing_skills,
},
"distance_km": distance.exact_distance_km, "distance_km": distance.exact_distance_km,
"distance_has_data": distance.has_distance_data, "distance_has_data": distance.has_distance_data,
"distance_exact": distance.exact_distance_km is not None, "distance_exact": distance.exact_distance_km is not None,
+252
View File
@@ -0,0 +1,252 @@
from __future__ import annotations
from dataclasses import dataclass
from django.db.models import CharField, Exists, OuterRef, Q, QuerySet, Subquery
from django.utils.text import slugify
from apps.jobs.models import JobPosting, JobSourceAlias, ScoreRun
from apps.profiles.models import SearchProfile
from apps.profiles.taxonomy import SKILL_CHOICES
from apps.sources.models import Source
from .normalization import normalize_token
from .relevance import it_relevance_query
from .skill_terms import canonical_skill_key, skill_is_present
@dataclass(frozen=True)
class SkillDefinition:
key: str
label: str
category: str
category_key: str
@dataclass(frozen=True)
class SkillJobExample:
id: str
title: str
employer: str
@dataclass(frozen=True)
class SkillDemandItem:
key: str
label: str
category: str
category_key: str
vacancy_count: int
share_percent: int
explicit_count: int
inferred_count: int
covered: bool
learning_focus: str
search_term: str
examples: tuple[SkillJobExample, ...]
@dataclass(frozen=True)
class SkillDemandReport:
total_jobs: int
jobs_with_signals: int
signal_count: int
covered_count: int
gap_count: int
coverage_percent: int
items: tuple[SkillDemandItem, ...]
categories: tuple[tuple[str, str], ...]
selected_category: str
selected_coverage: str
def skill_catalog() -> tuple[SkillDefinition, ...]:
return tuple(
SkillDefinition(
key=normalize_token(value),
label=label,
category=category,
category_key=slugify(category),
)
for category, choices in SKILL_CHOICES
for value, label in choices
)
def relevant_jobs_for_profile(profile: SearchProfile) -> QuerySet[JobPosting]:
latest_score = ScoreRun.objects.filter(
job=OuterRef("pk"),
profile=profile,
profile_version=profile.version,
).order_by("-created_at")
source_aliases = JobSourceAlias.objects.filter(job=OuterRef("pk"))
active_source_aliases = source_aliases.filter(source__status=Source.Status.ACTIVE)
return (
JobPosting.objects.select_related("employer")
.filter(status=JobPosting.Status.ACTIVE)
.filter(it_relevance_query())
.annotate(
profile_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
has_source_alias=Exists(source_aliases),
has_active_source_alias=Exists(active_source_aliases),
)
.filter(
profile_recommendation__in=(
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
)
)
.filter(Q(has_source_alias=False) | Q(has_active_source_alias=True))
.order_by("-first_seen")
)
def _structured_skill_text(job: JobPosting) -> str:
return normalize_token(
" ".join(str(value) for value in [*job.skills_required, *job.skills_preferred])
)
def _inferred_skill_text(job: JobPosting) -> str:
requirements = " ".join(str(value) for value in job.requirements)
return normalize_token(f"{job.original_title} {requirements} {job.description_text}")
def _learning_focus(definition: SkillDefinition) -> str:
focuses = {
"Microsoft & endpoint": (
"Oefen een praktische beheer- of migratiecase en leg de relevante "
"Microsoft Learn-modules vast."
),
"Netwerk & security": (
"Bouw een kleine labcase rond configuratie, troubleshooting en beveiligde toegang."
),
"Back-up & monitoring": (
"Oefen detectie, herstel en rapportering met een reproduceerbare homelabcase."
),
"Virtualisatie & platform": (
"Maak een deployment- of platformlab en documenteer beschikbaarheid en herstel."
),
"IT-servicemanagement": (
"Koppel de methodiek aan een concrete incident-, problem- of changecase."
),
"Digitale werkplek & adoptie": (
"Werk een kleine governance- of adoptiecase uit met meetbare gebruikersimpact."
),
"Automation & scripting": (
"Automatiseer één herkenbare beheertaak en publiceer een veilig voorbeeldscript."
),
"VoIP & telefonie": "Simuleer configuratie en troubleshooting in een kleine telefoniecase.",
"Field service & uitvoering": (
"Documenteer een end-to-end interventie: diagnose, oplossing en overdracht."
),
}
return focuses.get(
definition.category,
"Maak een kleine praktijkcase en leg vast welke vacature-eis je ermee kunt aantonen.",
)
def _profile_skill_keys(profile: SearchProfile, catalog: tuple[SkillDefinition, ...]) -> set[str]:
catalog_keys = {definition.key for definition in catalog}
return {
canonical
for value in profile.desired_skills
if (canonical := canonical_skill_key(str(value), catalog_keys)) is not None
}
def build_skill_demand_report(
profile: SearchProfile,
*,
category: str = "all",
coverage: str = "all",
jobs: QuerySet[JobPosting] | list[JobPosting] | None = None,
) -> SkillDemandReport:
catalog = skill_catalog()
valid_categories = {definition.category_key for definition in catalog}
selected_category = category if category in valid_categories else "all"
selected_coverage = coverage if coverage in {"all", "gap", "covered"} else "all"
job_list = list(relevant_jobs_for_profile(profile) if jobs is None else jobs)
profile_skills = _profile_skill_keys(profile, catalog)
items: list[SkillDemandItem] = []
jobs_with_signals: set[str] = set()
for definition in catalog:
examples: list[SkillJobExample] = []
explicit_count = 0
inferred_count = 0
for job in job_list:
explicit = skill_is_present(definition.key, _structured_skill_text(job))
inferred = skill_is_present(definition.key, _inferred_skill_text(job))
if not explicit and not inferred:
continue
if explicit:
explicit_count += 1
else:
inferred_count += 1
jobs_with_signals.add(str(job.pk))
if len(examples) < 3:
examples.append(
SkillJobExample(
id=str(job.pk),
title=job.original_title,
employer=job.employer_name,
)
)
vacancy_count = explicit_count + inferred_count
if vacancy_count == 0:
continue
items.append(
SkillDemandItem(
key=definition.key,
label=definition.label,
category=definition.category,
category_key=definition.category_key,
vacancy_count=vacancy_count,
share_percent=round(vacancy_count / len(job_list) * 100) if job_list else 0,
explicit_count=explicit_count,
inferred_count=inferred_count,
covered=definition.key in profile_skills,
learning_focus=_learning_focus(definition),
search_term=definition.key,
examples=tuple(examples),
)
)
ranked = sorted(
items,
key=lambda item: (-item.vacancy_count, -item.explicit_count, item.label.casefold()),
)
covered_count = sum(item.covered for item in ranked)
gap_count = len(ranked) - covered_count
filtered = tuple(
item
for item in ranked
if (selected_category == "all" or item.category_key == selected_category)
and (
selected_coverage == "all"
or (selected_coverage == "covered" and item.covered)
or (selected_coverage == "gap" and not item.covered)
)
)
categories = tuple(
(slugify(category_label), category_label)
for category_label, _choices in SKILL_CHOICES
if any(item.category == category_label for item in ranked)
)
return SkillDemandReport(
total_jobs=len(job_list),
jobs_with_signals=len(jobs_with_signals),
signal_count=len(ranked),
covered_count=covered_count,
gap_count=gap_count,
coverage_percent=round(covered_count / len(ranked) * 100) if ranked else 0,
items=filtered,
categories=categories,
selected_category=selected_category,
selected_coverage=selected_coverage,
)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import re
from apps.jobs.services.normalization import normalize_token
SKILL_ALIASES: dict[str, tuple[str, ...]] = {
"microsoft 365": ("m365", "office 365", "o365"),
"microsoft teams": ("ms teams",),
"entra id": ("azure active directory", "azure ad"),
"intune": ("microsoft intune", "endpoint manager"),
"autopilot": ("windows autopilot",),
"group policy": ("group policy object", "gpo"),
"dynamics 365": ("microsoft dynamics 365", "d365"),
"microsoft copilot": ("copilot for microsoft 365", "m365 copilot"),
"microsoft defender": ("defender for endpoint", "microsoft defender for endpoint"),
"microsoft sentinel": ("azure sentinel", "sentinel siem"),
"conditional access": ("voorwaardelijke toegang",),
"networking": ("network", "netwerk", "lan", "wan"),
"routing": ("routering",),
"switching": ("network switches", "switches"),
"sd-wan": ("sd wan",),
"firewalls": ("firewall",),
"fortinet": ("fortigate",),
"palo alto": ("palo alto networks",),
"wi-fi": ("wifi", "wireless"),
"backup and restore": ("backup", "back-up", "restore"),
"disaster recovery": ("business continuity", "bcp", "dr plan"),
"monitoring": ("infrastructure monitoring", "system monitoring"),
"observability": ("telemetry", "tracing"),
"high availability": ("hoogbeschikbaarheid", "ha architecture"),
"vmware": ("vsphere", "esxi"),
"ci/cd": ("continuous integration", "continuous delivery", "ci cd"),
"deployment": ("deployments", "software deployment", "uitrol"),
"reliability": ("platform reliability", "site reliability", "sre"),
"scalability": ("scalable", "schaalbaarheid"),
"voip": ("voice over ip", "telefonie", "telephony", "3cx", "innovaphone"),
"teams telephony": ("teams phone", "teams telefonie", "teams voice"),
"onsite support": ("on-site support", "support op locatie", "field support"),
"second line support": ("second-line support", "2nd line", "tweedelijnssupport"),
"installations": ("installatie", "installaties", "roll-out", "rollout"),
"migrations": ("migratie", "migraties"),
"technical documentation": ("technische documentatie",),
"customer support": ("klantondersteuning", "user support"),
"incident management": ("incidentbeheer", "incident response", "escalations"),
"problem management": ("probleembeheer", "root cause analysis"),
"change management": ("wijzigingsbeheer", "organizational change"),
"itil": ("itil 4", "it service management"),
"servicenow": ("service now",),
"jira service management": ("jira service desk", "jsm"),
"digital workplace": ("digitale werkplek",),
"m365 governance": ("microsoft 365 governance", "office 365 governance"),
"document management": ("documentbeheer", "document management system", "dms"),
"data governance": ("data governance", "datagovernance"),
"user adoption": ("gebruikersadoptie", "technology adoption"),
"training and workshops": ("user training", "workshops", "training geven"),
"customer experience": ("customer satisfaction", "csat", "nps"),
}
def contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
normalized_term = normalize_token(term)
if not normalized_term:
return False
pattern = re.escape(normalized_term).replace(r"\ ", r"[\s/_-]+")
if allow_plural and normalized_term[-1].isalpha() and not normalized_term.endswith("s"):
pattern += "s?"
return re.search(rf"(?<!\w){pattern}(?!\w)", text, re.IGNORECASE) is not None
def skill_terms(skill: str) -> tuple[str, ...]:
normalized = normalize_token(skill)
return (normalized, *SKILL_ALIASES.get(normalized, ()))
def skill_is_present(skill: str, text: str) -> bool:
return any(contains_term(text, candidate) for candidate in skill_terms(skill))
def canonical_skill_key(value: str, catalog_keys: set[str]) -> str | None:
normalized = normalize_token(value)
if normalized in catalog_keys:
return normalized
for key in catalog_keys:
if normalized in {normalize_token(term) for term in SKILL_ALIASES.get(key, ())}:
return key
return None
+10
View File
@@ -1,20 +1,30 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
ActivityListView,
ApplicationListView, ApplicationListView,
ApplicationUpdateView, ApplicationUpdateView,
EmployerDetailView,
EmployerListView,
JobDetailView, JobDetailView,
JobListView, JobListView,
SkillInsightsView,
application_delete, application_delete,
application_export, application_export,
application_print, application_print,
application_status,
job_feedback, job_feedback,
) )
urlpatterns = [ urlpatterns = [
path("", JobListView.as_view(), name="list"), path("", JobListView.as_view(), name="list"),
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
path("activity/", ActivityListView.as_view(), name="activity"),
path("employers/", EmployerListView.as_view(), name="employers"),
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
path("applications/", ApplicationListView.as_view(), name="applications"), path("applications/", ApplicationListView.as_view(), name="applications"),
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"), path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
path("applications/<int:pk>/status/", application_status, name="application-status"),
path("applications/<int:pk>/export/", application_export, name="application-export"), path("applications/<int:pk>/export/", application_export, name="application-export"),
path("applications/<int:pk>/print/", application_print, name="application-print"), path("applications/<int:pk>/print/", application_print, name="application-print"),
path("applications/<int:pk>/delete/", application_delete, name="application-delete"), path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
+247 -36
View File
@@ -3,55 +3,173 @@ from __future__ import annotations
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery from django.db.models import Count, F, Q
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, UpdateView from django.views.generic import DetailView, ListView, TemplateView, UpdateView
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
from .forms import ApplicationForm from .forms import ApplicationForm
from .models import Application, Feedback, JobPosting, ScoreRun from .models import Application, Employer, Feedback, JobPosting, ScoreRun
from .services.activity import ACTIVITY_FILTERS, build_activity_page
from .services.applications import ( from .services.applications import (
build_application_export, build_application_export,
build_print_html, build_print_html,
change_application_status,
delete_application_dossier, delete_application_dossier,
track_application_changes, track_application_changes,
) )
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
from .services.employer_intelligence import (
build_employer_detail,
employer_index_queryset,
filter_employers,
)
from .services.feedback import record_feedback from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report
class SkillInsightsView(LoginRequiredMixin, TemplateView):
template_name = "jobs/skill_insights.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["active_profile"] = profile
context["report"] = (
build_skill_demand_report(
profile,
category=self.request.GET.get("category", "all"),
coverage=self.request.GET.get("coverage", "all"),
)
if profile
else None
)
return context
class ActivityListView(LoginRequiredMixin, TemplateView):
template_name = "activity/list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
event_type = self.request.GET.get("type", "all")
valid_types = {value for value, _label in ACTIVITY_FILTERS}
if event_type not in valid_types:
event_type = "all"
context.update(
{
"event_page": build_activity_page(
user=self.request.user,
event_type=event_type,
query=self.request.GET.get("q", ""),
page=self.request.GET.get("page", 1),
),
"activity_filters": ACTIVITY_FILTERS,
"current_type": event_type,
}
)
return context
class EmployerListView(LoginRequiredMixin, ListView):
model = Employer
template_name = "employers/list.html"
context_object_name = "employers"
paginate_by = 40
def get_queryset(self):
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
return filter_employers(
employer_index_queryset(user=self.request.user, profile=self.profile),
query=self.request.GET.get("q", ""),
channel=self.request.GET.get("channel", ""),
active_only=self.request.GET.get("active") == "1",
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
employers = list(context["employers"])
selected_id = self.request.GET.get("selected", "")
selected = next((item for item in employers if str(item.pk) == selected_id), None)
if selected is None and employers:
selected = employers[0]
context.update(
{
"selected_employer": selected,
"selected_detail": build_employer_detail(
employer=selected, user=self.request.user, profile=self.profile
)
if selected
else None,
"active_profile": self.profile,
"employer_total": Employer.objects.count(),
"employer_active_total": Employer.objects.filter(
jobs__status=JobPosting.Status.ACTIVE
)
.distinct()
.count(),
}
)
return context
class EmployerDetailView(LoginRequiredMixin, DetailView):
model = Employer
template_name = "employers/detail.html"
context_object_name = "employer"
def get_queryset(self):
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
return employer_index_queryset(user=self.request.user, profile=self.profile)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
build_employer_detail(
employer=self.object, user=self.request.user, profile=self.profile
)
)
context["active_profile"] = self.profile
return context
class JobListView(LoginRequiredMixin, ListView): class JobListView(LoginRequiredMixin, ListView):
model = JobPosting model = JobPosting
template_name = "jobs/list.html" template_name = "jobs/list.html"
context_object_name = "jobs" context_object_name = "jobs"
paginate_by = 30 paginate_by = 20
VALID_RECOMMENDATIONS = {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
ScoreRun.Recommendation.HIDDEN,
}
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
VALID_SORTS = {"match", "newest", "closing"}
def get_queryset(self): def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all() queryset = JobPosting.objects.select_related("employer").annotate(
duplicate_count=Count("duplicates", distinct=True)
)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first() profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
if profile: queryset = annotate_latest_profile_score(queryset, profile)
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
"-created_at"
)
queryset = queryset.annotate(
match_score=Subquery(
latest_score.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest_score.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
)
query = self.request.GET.get("q", "").strip() query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip() status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip() workplace = self.request.GET.get("workplace", "").strip()
channel = self.request.GET.get("channel", "").strip()
focus = self.request.GET.get("focus", "it").strip()
recommendation = self.request.GET.get("match", "radar").strip()
sort = self.request.GET.get("sort", "match").strip()
if recommendation not in self.VALID_MATCH_FILTERS:
recommendation = "radar"
if query: if query:
queryset = queryset.filter( queryset = queryset.filter(
Q(original_title__icontains=query) Q(original_title__icontains=query)
@@ -63,7 +181,68 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status) queryset = queryset.filter(status=status)
if workplace: if workplace:
queryset = queryset.filter(workplace_type=workplace) queryset = queryset.filter(workplace_type=workplace)
return queryset.order_by("-first_seen") if channel == "direct":
queryset = queryset.filter(direct_employer=True, recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(recruiter=True)
if focus != "all":
queryset = queryset.filter(it_relevance_query())
if profile and recommendation == "radar":
queryset = queryset.filter(
Q(
match_recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
]
)
| Q(match_recommendation__isnull=True)
)
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
queryset = queryset.filter(match_recommendation=recommendation)
if sort not in self.VALID_SORTS:
sort = "match"
if sort == "newest":
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
if sort == "closing":
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
it_jobs = active_jobs.filter(it_relevance_query())
context.update(
{
"active_profile": SearchProfile.objects.filter(
user=self.request.user, is_active=True
).first(),
"active_job_count": active_jobs.count(),
"it_job_count": it_jobs.count(),
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
"current_focus": self.request.GET.get("focus", "it"),
"current_match": (
self.request.GET.get("match", "radar")
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
else "radar"
),
"current_sort": self.request.GET.get("sort", "match"),
"current_channel": self.request.GET.get("channel", ""),
}
)
jobs = list(context["jobs"])
selected_id = self.request.GET.get("selected", "")
selected_job = next((job for job in jobs if str(job.pk) == selected_id), None)
if selected_job is None and jobs:
selected_job = jobs[0]
context["selected_job"] = selected_job
if selected_job is not None:
context["selected_intelligence"] = build_job_intelligence(
job=selected_job,
profile=context["active_profile"],
user=self.request.user,
)
return context
class JobDetailView(LoginRequiredMixin, DetailView): class JobDetailView(LoginRequiredMixin, DetailView):
@@ -79,16 +258,9 @@ class JobDetailView(LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first() profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["score"] = ( context.update(
ScoreRun.objects.filter(job=self.object, profile=profile) build_job_intelligence(job=self.object, profile=profile, user=self.request.user)
.order_by("-created_at")
.first()
if profile
else None
) )
context["application"] = Application.objects.filter(
user=self.request.user, job=self.object
).first()
context["latest_feedback"] = Feedback.objects.filter( context["latest_feedback"] = Feedback.objects.filter(
user=self.request.user, job=self.object user=self.request.user, job=self.object
).first() ).first()
@@ -113,7 +285,13 @@ def job_feedback(request, pk):
) )
messages.success(request, "Actie opgeslagen.") messages.success(request, "Actie opgeslagen.")
next_url = request.POST.get("next") next_url = request.POST.get("next")
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk})) if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
class ApplicationListView(LoginRequiredMixin, ListView): class ApplicationListView(LoginRequiredMixin, ListView):
@@ -122,8 +300,10 @@ class ApplicationListView(LoginRequiredMixin, ListView):
context_object_name = "applications" context_object_name = "applications"
def get_queryset(self): def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related( return (
"job", "job__employer" Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
) )
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -138,6 +318,16 @@ class ApplicationListView(LoginRequiredMixin, ListView):
} }
for status, label in Application.Status.choices for status, label in Application.Status.choices
] ]
context["status_choices"] = Application.Status.choices
context["open_count"] = sum(
item.status not in {Application.Status.REJECTED, Application.Status.WITHDRAWN}
for item in applications
)
context["follow_up_count"] = sum(
bool(item.follow_up_date and item.follow_up_date <= timezone.localdate())
for item in applications
)
context["today"] = timezone.localdate()
return context return context
@@ -147,7 +337,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
template_name = "applications/edit.html" template_name = "applications/edit.html"
def get_queryset(self): def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related("job") return (
Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
)
def form_valid(self, form): def form_valid(self, form):
previous = ( previous = (
@@ -185,6 +379,23 @@ def application_export(request, pk: int):
return response return response
@login_required
@require_POST
def application_status(request, pk: int):
application = get_object_or_404(Application, pk=pk, user=request.user)
try:
change_application_status(
application=application,
user=request.user,
status=request.POST.get("status", ""),
)
except ValueError as exc:
messages.error(request, str(exc))
else:
messages.success(request, "Pipelinestatus bijgewerkt.")
return redirect("jobs:applications")
@login_required @login_required
def application_print(request, pk: int): def application_print(request, pk: int):
application = get_object_or_404( application = get_object_or_404(
+4 -5
View File
@@ -3,13 +3,12 @@ from __future__ import annotations
def default_weights() -> dict[str, float]: def default_weights() -> dict[str, float]:
return { return {
"content": 25.0, "content": 30.0,
"skills": 20.0, "skills": 25.0,
"location": 15.0, "location": 20.0,
"conditions": 10.0, "conditions": 10.0,
"employer": 10.0, "employer": 10.0,
"seniority": 10.0, "preferences": 5.0,
"preferences": 10.0,
"ai": 0.0, "ai": 0.0,
} }
+24 -56
View File
@@ -5,55 +5,12 @@ import re
from django import forms from django import forms
from .models import SearchProfile from .models import SearchProfile
from .taxonomy import (
TITLE_CHOICES = ( EXCLUDED_SKILL_CHOICES,
("system engineer", "System engineer"), EXCLUDED_TITLE_CHOICES,
("infrastructure engineer", "Infrastructure engineer"), SKILL_CHOICES,
("cloud engineer", "Cloud engineer"), TITLE_CHOICES,
("devops engineer", "DevOps engineer"), iter_choices,
("network engineer", "Network engineer"),
("security engineer", "Security engineer"),
("workplace engineer", "Workplace engineer"),
("support engineer", "Support engineer"),
("software engineer", "Software engineer"),
("data engineer", "Data engineer"),
("solution architect", "Solution architect"),
("project manager", "Projectmanager"),
)
EXCLUDED_TITLE_CHOICES = (
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("callcenter", "Callcenter"),
("stage", "Stage"),
("student", "Studentenjob"),
)
SKILL_CHOICES = (
("azure", "Microsoft Azure"),
("aws", "AWS"),
("microsoft 365", "Microsoft 365"),
("active directory", "Active Directory"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("linux", "Linux"),
("windows server", "Windows Server"),
("networking", "Netwerken"),
("security", "Security"),
("python", "Python"),
("powershell", "PowerShell"),
("terraform", "Terraform"),
("docker", "Docker"),
("kubernetes", "Kubernetes"),
("ci/cd", "CI/CD"),
)
EXCLUDED_SKILL_CHOICES = (
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
("night shift", "Nachtwerk"),
) )
EMPLOYMENT_CHOICES = ( EMPLOYMENT_CHOICES = (
@@ -91,27 +48,31 @@ class SearchProfileForm(forms.ModelForm):
label="Gewenste functietitels", label="Gewenste functietitels",
required=False, required=False,
choices=TITLE_CHOICES, choices=TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Selecteer alle rollen die bij je zoekrichting passen.", help_text="Selecteer alle rollen die bij je zoekrichting passen; de beste titelmatch telt.",
) )
excluded_titles = forms.MultipleChoiceField( excluded_titles = forms.MultipleChoiceField(
label="Uitgesloten functietitels", label="Uitgesloten functietitels",
required=False, required=False,
choices=EXCLUDED_TITLE_CHOICES, choices=EXCLUDED_TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.", help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
) )
desired_skills = forms.MultipleChoiceField( desired_skills = forms.MultipleChoiceField(
label="Gewenste skills", label="Gewenste skills",
required=False, required=False,
choices=SKILL_CHOICES, choices=SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple, 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( excluded_skills = forms.MultipleChoiceField(
label="Uitgesloten kenmerken", label="Uitgesloten kenmerken",
required=False, required=False,
choices=EXCLUDED_SKILL_CHOICES, choices=EXCLUDED_SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
) )
allowed_employment_types = forms.MultipleChoiceField( allowed_employment_types = forms.MultipleChoiceField(
label="Toegestane contractvormen", label="Toegestane contractvormen",
@@ -145,6 +106,7 @@ class SearchProfileForm(forms.ModelForm):
"is_active", "is_active",
"home_postal_code", "home_postal_code",
"max_distance_km", "max_distance_km",
"experience_years",
"desired_titles", "desired_titles",
"excluded_titles", "excluded_titles",
"desired_skills", "desired_skills",
@@ -163,6 +125,7 @@ class SearchProfileForm(forms.ModelForm):
"is_active": "Actief profiel", "is_active": "Actief profiel",
"home_postal_code": "Thuispostcode", "home_postal_code": "Thuispostcode",
"max_distance_km": "Maximale afstand (km)", "max_distance_km": "Maximale afstand (km)",
"experience_years": "Relevante IT-ervaring",
"recommendation_threshold": "Aanbevelingsdrempel", "recommendation_threshold": "Aanbevelingsdrempel",
"top_match_threshold": "Topmatchdrempel", "top_match_threshold": "Topmatchdrempel",
"digest_time": "Tijdstip dagelijkse samenvatting", "digest_time": "Tijdstip dagelijkse samenvatting",
@@ -173,6 +136,10 @@ class SearchProfileForm(forms.ModelForm):
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer " "Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
"lokale geodata beschikbaar is." "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.", "learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
} }
widgets = { widgets = {
@@ -203,14 +170,15 @@ class SearchProfileForm(forms.ModelForm):
field = self.fields[field_name] field = self.fields[field_name]
stored = getattr(self.instance, field_name, []) stored = getattr(self.instance, field_name, [])
existing_by_casefold = { existing_by_casefold = {
str(value).casefold(): str(value) for value, _label in field.choices str(value).casefold(): str(value) for value, _label in iter_choices(field.choices)
} }
extra = [ extra = [
(str(value), str(value)) (str(value), str(value))
for value in stored for value in stored
if str(value).casefold() not in existing_by_casefold if str(value).casefold() not in existing_by_casefold
] ]
field.choices = [*field.choices, *extra] if extra:
field.choices = [*field.choices, ("Eerder opgeslagen", tuple(extra))]
field.initial = [ field.initial = [
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
] ]
@@ -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),
]
+15
View File
@@ -16,6 +16,16 @@ from .defaults import (
default_weights, 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): class SearchProfile(TimeStampedModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -29,6 +39,10 @@ class SearchProfile(TimeStampedModel):
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, 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) home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
max_distance_km = models.PositiveIntegerField(default=45) 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) desired_titles = models.JSONField(default=list, blank=True)
excluded_titles = models.JSONField(default=list, blank=True) excluded_titles = models.JSONField(default=list, blank=True)
@@ -105,6 +119,7 @@ class SearchProfile(TimeStampedModel):
if self.home_longitude is not None if self.home_longitude is not None
else None, else None,
"max_distance_km": self.max_distance_km, "max_distance_km": self.max_distance_km,
"experience_years": self.experience_years,
"desired_titles": self.desired_titles, "desired_titles": self.desired_titles,
"excluded_titles": self.excluded_titles, "excluded_titles": self.excluded_titles,
"desired_skills": self.desired_skills, "desired_skills": self.desired_skills,
+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)
+17
View File
@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView from django.views.generic import ListView, UpdateView
from apps.jobs.models import JobPosting, ScoreRun
from .forms import SearchProfileForm from .forms import SearchProfileForm
from .models import SearchProfile from .models import SearchProfile
from .services import resolve_profile_home_location, save_profile_revision from .services import resolve_profile_home_location, save_profile_revision
@@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
def get_queryset(self): def get_queryset(self):
return SearchProfile.objects.filter(user=self.request.user) 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): def form_valid(self, form):
response = super().form_valid(form) response = super().form_valid(form)
location_result = resolve_profile_home_location(self.object) location_result = resolve_profile_home_location(self.object)
+13 -1
View File
@@ -9,13 +9,15 @@ from .base import ExtractedJob, ExtractionResult, FieldEvidence
LABEL_PATTERNS = { LABEL_PATTERNS = {
"location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I), "location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I),
"date_posted": re.compile(r"^(publicatiedatum|geplaatst|date posted|published)\s*:?$", re.I),
"employment_type": re.compile(r"^(dienstverband|contract|employment type)\s*:?$", re.I),
"employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I), "employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I),
} }
class GenericHtmlAdapter: class GenericHtmlAdapter:
parser_key = "generic-html" parser_key = "generic-html"
parser_version = "1.0.0" parser_version = "1.1.0"
@staticmethod @staticmethod
def _meta(soup: BeautifulSoup, *names: str) -> str: def _meta(soup: BeautifulSoup, *names: str) -> str:
@@ -59,6 +61,8 @@ class GenericHtmlAdapter:
soup, LABEL_PATTERNS["employer"] soup, LABEL_PATTERNS["employer"]
) )
location = self._label_value(soup, LABEL_PATTERNS["location"]) location = self._label_value(soup, LABEL_PATTERNS["location"])
date_posted = self._label_value(soup, LABEL_PATTERNS["date_posted"])
employment_type = self._label_value(soup, LABEL_PATTERNS["employment_type"])
main = soup.find("main") or soup.find("article") or soup.body main = soup.find("main") or soup.find("article") or soup.body
description_html = str(main) if main else "" description_html = str(main) if main else ""
description_text = ( description_text = (
@@ -74,6 +78,12 @@ class GenericHtmlAdapter:
FieldEvidence("location_text", "html-label", 0.62, location[:240]), FieldEvidence("location_text", "html-label", 0.62, location[:240]),
FieldEvidence("description", "html-main", 0.70, description_text[:300]), FieldEvidence("description", "html-main", 0.70, description_text[:300]),
] ]
if date_posted:
evidence.append(FieldEvidence("date_posted", "html-label", 0.62, date_posted[:40]))
if employment_type:
evidence.append(
FieldEvidence("employment_types", "html-label", 0.62, employment_type[:120])
)
job = ExtractedJob( job = ExtractedJob(
url=job_url, url=job_url,
title=title, title=title,
@@ -81,6 +91,8 @@ class GenericHtmlAdapter:
location_text=location, location_text=location,
description_html=description_html, description_html=description_html,
description_text=description_text, description_text=description_text,
date_posted=date_posted,
employment_types=[employment_type] if employment_type else [],
raw={"generic_html": True}, raw={"generic_html": True},
evidence=evidence, evidence=evidence,
) )
+38 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import ipaddress
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -53,6 +54,38 @@ class FetchTimeoutError(FetchError):
pass pass
def _connected_peer_ip(
response: httpx.Response,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Return the actual remote peer when httpcore exposes its network stream."""
stream = response.extensions.get("network_stream")
if stream is None or not hasattr(stream, "get_extra_info"):
return None
try:
server_address = stream.get_extra_info("server_addr")
raw_address = server_address[0] if server_address else None
return ipaddress.ip_address(raw_address) if raw_address else None
except (IndexError, TypeError, ValueError, OSError):
return None
def _validate_connected_peer(
response: httpx.Response,
*,
before: ValidatedUrl,
after: ValidatedUrl,
) -> None:
peer_ip = _connected_peer_ip(response)
if peer_ip is not None:
if not peer_ip.is_global:
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
return
# Mock/custom transports do not always expose the peer socket. Keep the conservative
# DNS-overlap fallback for those transports.
if not set(before.addresses).intersection(after.addresses):
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
def parse_retry_after(value: str | None) -> int | None: def parse_retry_after(value: str | None) -> int | None:
if not value: if not value:
return None return None
@@ -127,26 +160,22 @@ def fetch_url(
verify=trusted_tls_context(), verify=trusted_tls_context(),
) )
current_url = url current_url = url
previous_validation: ValidatedUrl | None = None
try: try:
for _ in range(settings.FETCHER_MAX_REDIRECTS + 1): for _ in range(settings.FETCHER_MAX_REDIRECTS + 1):
validation = validate_public_url( validation = validate_public_url(
current_url, current_url,
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS, allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
) )
if (
previous_validation is not None
and validation.hostname == previous_validation.hostname
and not set(validation.addresses).intersection(previous_validation.addresses)
):
raise FetchError("DNS-rebindcontrole faalde bij redirect naar dezelfde host.")
response = http_client.get(current_url, headers=headers) response = http_client.get(current_url, headers=headers)
post_validation = validate_public_url( post_validation = validate_public_url(
str(response.url) if response.url else current_url, str(response.url) if response.url else current_url,
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS, allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
) )
if not set(validation.addresses).intersection(set(post_validation.addresses)): _validate_connected_peer(
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") response,
before=validation,
after=post_validation,
)
if response.status_code in {301, 302, 303, 307, 308}: if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location") location = response.headers.get("location")
if not location: if not location:
@@ -155,7 +184,6 @@ def fetch_url(
next_decision = assess_url(current_url, source=source) next_decision = assess_url(current_url, source=source)
if not next_decision.allowed: if not next_decision.allowed:
raise PolicyBlockedError(next_decision.reason) raise PolicyBlockedError(next_decision.reason)
previous_validation = validation
continue continue
if response.status_code == 304: if response.status_code == 304:
return FetchedDocument(url, current_url, 304, dict(response.headers), b"") return FetchedDocument(url, current_url, 304, dict(response.headers), b"")
+9 -3
View File
@@ -4,6 +4,7 @@ from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.db.models import Prefetch
from django.utils import timezone from django.utils import timezone
from apps.sources.models import Source, SourcePolicyReview, SourceRun from apps.sources.models import Source, SourcePolicyReview, SourceRun
@@ -214,12 +215,17 @@ def _determine_health_action(runs: list[SourceRun]) -> tuple[str | None, str | N
def collect_source_health( def collect_source_health(
*, runs_to_consider: int = SOURCE_HEALTH_RUN_WINDOW *, runs_to_consider: int = SOURCE_HEALTH_RUN_WINDOW
) -> list[SourceHealth]: ) -> list[SourceHealth]:
sources = Source.objects.order_by("name").all() sources = Source.objects.prefetch_related(
Prefetch(
"runs",
queryset=SourceRun.objects.order_by("-started_at")[:runs_to_consider],
to_attr="health_runs",
)
).order_by("name")
rows: list[SourceHealth] = [] rows: list[SourceHealth] = []
for source in sources: for source in sources:
run_queryset = SourceRun.objects.filter(source=source).order_by("-started_at") runs = source.health_runs
runs = list(run_queryset[:runs_to_consider])
monitored_runs = len(runs) monitored_runs = len(runs)
success_runs = [run for run in runs if run.status == SourceRun.Status.SUCCESS] success_runs = [run for run in runs if run.status == SourceRun.Status.SUCCESS]
+13 -3
View File
@@ -4,7 +4,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q from django.db.models import Count, Prefetch, Q
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@@ -17,7 +17,7 @@ from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rat
from apps.profiles.models import SearchProfile from apps.profiles.models import SearchProfile
from .forms import MailboxConnectionForm, ManualImportForm from .forms import MailboxConnectionForm, ManualImportForm
from .models import MailboxConnection, Source, SourcePolicyReview from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun
from .platforms import PLATFORM_ALERTS from .platforms import PLATFORM_ALERTS
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
@@ -27,7 +27,14 @@ from .tasks import fetch_source, poll_mailbox
def _source_list_queryset(): def _source_list_queryset():
return ( return (
Source.objects.prefetch_related("policy_reviews") Source.objects.prefetch_related(
"policy_reviews",
Prefetch(
"runs",
queryset=SourceRun.objects.order_by("-started_at")[:1],
to_attr="latest_runs",
),
)
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED) .exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
.filter( .filter(
Q(metadata__hidden_from_source_list__isnull=True) Q(metadata__hidden_from_source_list__isnull=True)
@@ -69,6 +76,9 @@ def _manual_import_context(
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(), "active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
"source_total_count": base_queryset.count(), "source_total_count": base_queryset.count(),
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(), "review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
"failed_source_count": Source.objects.filter(
Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0)
).count(),
"watchlist_sources": watchlist_queryset, "watchlist_sources": watchlist_queryset,
"watchlist_count": watchlist_queryset.count(), "watchlist_count": watchlist_queryset.count(),
"manual_import_form": form, "manual_import_form": form,
+745
View File
@@ -0,0 +1,745 @@
{
"configuration": {
"concurrency": 1,
"dataset": "fixtures/benchmark/quality_benchmark.json",
"iterations": 3,
"jobs": 250,
"warmup_runs": 1
},
"database_identity": {
"engine": "django.db.backends.postgresql",
"host": "127.0.0.1",
"name_prefix": "vacatureradar_bench_"
},
"iterations": [
{
"dataset_version": "vr116-2026-07-21",
"dedupe": {
"case_reports": [
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-infra",
"expected_match_present": true,
"id": "D-001",
"is_expected_match": true,
"matched": true,
"matched_job_id": "267f0fd5-4543-404c-acd1-c6f6ee72e4c0",
"query": "D-001"
},
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-network",
"expected_match_present": true,
"id": "D-002",
"is_expected_match": true,
"matched": true,
"matched_job_id": "6a196e1a-bed6-44d0-94df-c2578c367673",
"query": "D-002"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-003",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-003"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-004",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-004"
}
],
"false_merges": 0,
"false_negative": 0,
"false_positive": 0,
"missed_merges": 0,
"precision": 1.0,
"recall": 1.0,
"seed_jobs": 2,
"status": "passed",
"true_negative": 2,
"true_positive": 2
},
"hardware": {
"cpu_count": 20,
"debug_mode": true,
"platform": "Linux-6.12.54-Unraid-x86_64-with-glibc2.41",
"python": "3.13.14",
"release": "6.12.54-Unraid"
},
"jobs_processed": 250,
"nfr_007": {
"measured": false,
"p95_ms": null,
"passed": false
},
"parser": {
"cases": [
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "jsonld-jobposting",
"expected_parser_version": "1.0.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_jsonld_job.html",
"id": "P-001",
"parser_key": "jsonld-jobposting",
"parser_version": "1.0.0",
"passed": true,
"warnings": []
},
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.1.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_generic_job.html",
"id": "P-002",
"parser_key": "generic-html",
"parser_version": "1.1.0",
"passed": true,
"warnings": []
}
],
"coverage_ratio": 1.0,
"covered_fields": 12,
"expected_fields": 12,
"status": "passed",
"unknown_data_ratio": 0.0
},
"performance": {
"avg_scores_detail_ms": 50.936,
"avg_scores_import_ms": 30.859,
"avg_scores_list_ms": 104.346,
"avg_scores_rescore_ms": 14.893,
"dashboard": {
"detail_p95_ms": 55.28,
"detail_query_count": 25,
"detail_query_types": {
"SELECT": 25
},
"list_p95_ms": 135.076,
"list_query_count": 76,
"list_query_types": {
"SELECT": 76
},
"timing_count": 50
},
"import": {
"duration_ms_total": 7714.791,
"p50_ms": 29.722,
"p95_ms": 39.37,
"query_count": 3264,
"query_types": {
"INSERT": 1003,
"RELEASE": 253,
"SAVEPOINT": 253,
"SELECT": 1256,
"UPDATE": 499
},
"timing_count": 250
},
"jobs": 1,
"performance": {
"memory_current_mb": 12.418,
"memory_peak_mb": 17.349,
"nfr_007": true,
"p50_ms": 30.306,
"p95_ms": 94.573,
"total_duration_ms": 11611.716
},
"query_count": 3370,
"rescore": {
"duration_ms_total": 14.893,
"p50_ms": 14.893,
"p95_ms": 14.893,
"query_count": 5,
"query_types": {
"INSERT": 1,
"SELECT": 4
},
"timing_count": 1
},
"source_run_id": 1,
"top_n": 25
},
"ranking": {
"churn_ratio": 0.0,
"post_top_n": [
"8f49a32b-f425-425e-ac47-fbd1e1ab020f",
"8302cd44-569c-4109-83dd-87663d053200",
"fb63a999-5aa0-4912-8d06-400f852676cc",
"1476c46b-84ce-4151-b777-9dac8b533a8c",
"518e4b70-6742-43d2-bd39-90b3af026d89",
"9fa543b2-d5c9-47e1-8cba-ed1e1bfbfb86",
"5759f1c8-4974-4333-98c4-2ab0aec97918",
"77565fbe-27ea-43ee-bcb7-a218b073af9f",
"a9f67c53-f22d-4d72-be23-2ef95eb49443",
"ff866750-238b-4eb6-a9b7-7cacd8c6b9d0",
"8470bf3b-1760-4d4c-8755-387fcc5c92e8",
"293b9d4a-be83-4c72-b08e-02d6170ac405",
"d00b4e66-dfd1-45e8-af78-b643f59b5327",
"de6cbb4d-5225-4975-91f3-23b280492598",
"bb5bfca7-38d3-4639-83bd-da639ce2b4b5",
"d9a6079c-eb37-47e0-b588-28f87ebc7411",
"2d83e2f0-9a87-40a7-89cc-2f0689f52f18",
"e985ad1c-5bd1-4a29-b683-f1be109998ba",
"081bdfa8-0dfc-43d1-9b85-44cfe72892c1",
"79b5af36-6f76-4e35-af5c-ed3ca755b3ac",
"9c7d00f4-d307-42f3-b6d4-f95b2a6f7597",
"1053c44f-6f09-4a1e-b43f-f858fcb2441c",
"5f5005e9-6fe6-435a-992f-46440a803197",
"a9fe6c71-e1e1-423f-9811-5318c0b49688",
"f06e60ed-6d6d-44ef-b2f9-3ef57dc94cdf"
],
"pre_top_n": [
"8f49a32b-f425-425e-ac47-fbd1e1ab020f",
"8302cd44-569c-4109-83dd-87663d053200",
"fb63a999-5aa0-4912-8d06-400f852676cc",
"1476c46b-84ce-4151-b777-9dac8b533a8c",
"518e4b70-6742-43d2-bd39-90b3af026d89",
"9fa543b2-d5c9-47e1-8cba-ed1e1bfbfb86",
"5759f1c8-4974-4333-98c4-2ab0aec97918",
"77565fbe-27ea-43ee-bcb7-a218b073af9f",
"a9f67c53-f22d-4d72-be23-2ef95eb49443",
"ff866750-238b-4eb6-a9b7-7cacd8c6b9d0",
"8470bf3b-1760-4d4c-8755-387fcc5c92e8",
"293b9d4a-be83-4c72-b08e-02d6170ac405",
"d00b4e66-dfd1-45e8-af78-b643f59b5327",
"de6cbb4d-5225-4975-91f3-23b280492598",
"bb5bfca7-38d3-4639-83bd-da639ce2b4b5",
"d9a6079c-eb37-47e0-b588-28f87ebc7411",
"2d83e2f0-9a87-40a7-89cc-2f0689f52f18",
"e985ad1c-5bd1-4a29-b683-f1be109998ba",
"081bdfa8-0dfc-43d1-9b85-44cfe72892c1",
"79b5af36-6f76-4e35-af5c-ed3ca755b3ac",
"9c7d00f4-d307-42f3-b6d4-f95b2a6f7597",
"1053c44f-6f09-4a1e-b43f-f858fcb2441c",
"5f5005e9-6fe6-435a-992f-46440a803197",
"a9fe6c71-e1e1-423f-9811-5318c0b49688",
"f06e60ed-6d6d-44ef-b2f9-3ef57dc94cdf"
],
"seed_jobs_count": 120,
"status": "passed",
"top_n": 25,
"unknown_data_ratio": 0.25,
"unknown_fields": {
"analysis_features": 0,
"description_text": 30,
"employment_types": 0,
"raw_location": 0
}
},
"status": "passed",
"timestamp_utc": "2026-07-29T16:36:43.297692+00:00",
"top_n": 25
},
{
"dataset_version": "vr116-2026-07-21",
"dedupe": {
"case_reports": [
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-infra",
"expected_match_present": true,
"id": "D-001",
"is_expected_match": true,
"matched": true,
"matched_job_id": "b4c42d9c-22c7-4e89-b88d-b8734e2d7e07",
"query": "D-001"
},
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-network",
"expected_match_present": true,
"id": "D-002",
"is_expected_match": true,
"matched": true,
"matched_job_id": "537f63ab-1ea0-45fc-b356-e734a33a99be",
"query": "D-002"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-003",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-003"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-004",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-004"
}
],
"false_merges": 0,
"false_negative": 0,
"false_positive": 0,
"missed_merges": 0,
"precision": 1.0,
"recall": 1.0,
"seed_jobs": 2,
"status": "passed",
"true_negative": 2,
"true_positive": 2
},
"hardware": {
"cpu_count": 20,
"debug_mode": true,
"platform": "Linux-6.12.54-Unraid-x86_64-with-glibc2.41",
"python": "3.13.14",
"release": "6.12.54-Unraid"
},
"jobs_processed": 250,
"nfr_007": {
"measured": false,
"p95_ms": null,
"passed": false
},
"parser": {
"cases": [
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "jsonld-jobposting",
"expected_parser_version": "1.0.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_jsonld_job.html",
"id": "P-001",
"parser_key": "jsonld-jobposting",
"parser_version": "1.0.0",
"passed": true,
"warnings": []
},
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.1.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_generic_job.html",
"id": "P-002",
"parser_key": "generic-html",
"parser_version": "1.1.0",
"passed": true,
"warnings": []
}
],
"coverage_ratio": 1.0,
"covered_fields": 12,
"expected_fields": 12,
"status": "passed",
"unknown_data_ratio": 0.0
},
"performance": {
"avg_scores_detail_ms": 55.155,
"avg_scores_import_ms": 40.941,
"avg_scores_list_ms": 101.206,
"avg_scores_rescore_ms": 16.1,
"dashboard": {
"detail_p95_ms": 60.679,
"detail_query_count": 25,
"detail_query_types": {
"SELECT": 25
},
"list_p95_ms": 126.661,
"list_query_count": 76,
"list_query_types": {
"SELECT": 76
},
"timing_count": 50
},
"import": {
"duration_ms_total": 10235.21,
"p50_ms": 33.657,
"p95_ms": 68.212,
"query_count": 3264,
"query_types": {
"INSERT": 1003,
"RELEASE": 253,
"SAVEPOINT": 253,
"SELECT": 1256,
"UPDATE": 499
},
"timing_count": 250
},
"jobs": 1,
"performance": {
"memory_current_mb": 4.49,
"memory_peak_mb": 22.389,
"nfr_007": true,
"p50_ms": 34.958,
"p95_ms": 96.727,
"total_duration_ms": 14160.325
},
"query_count": 3370,
"rescore": {
"duration_ms_total": 16.1,
"p50_ms": 16.1,
"p95_ms": 16.1,
"query_count": 5,
"query_types": {
"INSERT": 1,
"SELECT": 4
},
"timing_count": 1
},
"source_run_id": 1,
"top_n": 25
},
"ranking": {
"churn_ratio": 0.0,
"post_top_n": [
"733b522b-382a-4a20-b046-3174b574a456",
"21473419-9a52-465d-90d0-da3dba7d87f4",
"e97f2b86-717e-4c9a-9d29-ba40f3198809",
"f9ee509b-fc89-4d81-8341-5b4ca48a51a1",
"f89072f9-b298-4751-b6d6-a3878e54ef18",
"f02c6911-d536-40d1-8d8d-3302392452e7",
"fb8f2233-2810-4e0e-9977-bebdcc230aef",
"8a71292f-bba7-4166-a7b8-714f9147f343",
"9cecf422-fd8e-4b40-9529-8e001af3df8b",
"80d26bff-3fae-4222-b6d8-25b47631a38b",
"d4475ebf-0969-4023-b6e2-e54df6dac7d8",
"0b33971f-c114-42a6-89d4-043055cce4d4",
"57c2ebf0-6a3c-4233-b1d4-f15bf3dfe087",
"bfe61184-136a-4f3f-8d12-4cee2109aa76",
"5bb9b9ea-1227-4bbf-b8e9-15de052ed584",
"9f361ba8-1e9e-4134-bda6-a7c78b6c4162",
"6b7b0dce-036d-475d-81f3-6ed8fcb98c5f",
"6c104672-11a2-4ee1-8200-f558d2d3eb0a",
"09b629be-5012-403f-b93f-b212d9f46b4c",
"46981d16-38d1-46a6-9755-c6d31b5adaf4",
"ae01edcc-fe42-4674-97ed-cdeec737ad9a",
"131a755e-4c6e-480c-81c4-6f2fe1afb069",
"7eba23c8-02ef-41a0-af54-d24689877f38",
"81cf6658-2035-4aba-97d9-4032085fba04",
"5a0b33ac-4b1e-454d-94b6-ddf5fc0945aa"
],
"pre_top_n": [
"733b522b-382a-4a20-b046-3174b574a456",
"21473419-9a52-465d-90d0-da3dba7d87f4",
"e97f2b86-717e-4c9a-9d29-ba40f3198809",
"f9ee509b-fc89-4d81-8341-5b4ca48a51a1",
"f89072f9-b298-4751-b6d6-a3878e54ef18",
"f02c6911-d536-40d1-8d8d-3302392452e7",
"fb8f2233-2810-4e0e-9977-bebdcc230aef",
"8a71292f-bba7-4166-a7b8-714f9147f343",
"9cecf422-fd8e-4b40-9529-8e001af3df8b",
"80d26bff-3fae-4222-b6d8-25b47631a38b",
"d4475ebf-0969-4023-b6e2-e54df6dac7d8",
"0b33971f-c114-42a6-89d4-043055cce4d4",
"57c2ebf0-6a3c-4233-b1d4-f15bf3dfe087",
"bfe61184-136a-4f3f-8d12-4cee2109aa76",
"5bb9b9ea-1227-4bbf-b8e9-15de052ed584",
"9f361ba8-1e9e-4134-bda6-a7c78b6c4162",
"6b7b0dce-036d-475d-81f3-6ed8fcb98c5f",
"6c104672-11a2-4ee1-8200-f558d2d3eb0a",
"09b629be-5012-403f-b93f-b212d9f46b4c",
"46981d16-38d1-46a6-9755-c6d31b5adaf4",
"ae01edcc-fe42-4674-97ed-cdeec737ad9a",
"131a755e-4c6e-480c-81c4-6f2fe1afb069",
"7eba23c8-02ef-41a0-af54-d24689877f38",
"81cf6658-2035-4aba-97d9-4032085fba04",
"5a0b33ac-4b1e-454d-94b6-ddf5fc0945aa"
],
"seed_jobs_count": 120,
"status": "passed",
"top_n": 25,
"unknown_data_ratio": 0.25,
"unknown_fields": {
"analysis_features": 0,
"description_text": 30,
"employment_types": 0,
"raw_location": 0
}
},
"status": "passed",
"timestamp_utc": "2026-07-29T16:37:02.017739+00:00",
"top_n": 25
},
{
"dataset_version": "vr116-2026-07-21",
"dedupe": {
"case_reports": [
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-infra",
"expected_match_present": true,
"id": "D-001",
"is_expected_match": true,
"matched": true,
"matched_job_id": "bcb6a67c-1945-4006-9f01-b332822877ee",
"query": "D-001"
},
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-network",
"expected_match_present": true,
"id": "D-002",
"is_expected_match": true,
"matched": true,
"matched_job_id": "bc389f7a-f049-4daa-b01e-b6bab77c81c9",
"query": "D-002"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-003",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-003"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-004",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-004"
}
],
"false_merges": 0,
"false_negative": 0,
"false_positive": 0,
"missed_merges": 0,
"precision": 1.0,
"recall": 1.0,
"seed_jobs": 2,
"status": "passed",
"true_negative": 2,
"true_positive": 2
},
"hardware": {
"cpu_count": 20,
"debug_mode": true,
"platform": "Linux-6.12.54-Unraid-x86_64-with-glibc2.41",
"python": "3.13.14",
"release": "6.12.54-Unraid"
},
"jobs_processed": 250,
"nfr_007": {
"measured": false,
"p95_ms": null,
"passed": false
},
"parser": {
"cases": [
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "jsonld-jobposting",
"expected_parser_version": "1.0.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_jsonld_job.html",
"id": "P-001",
"parser_key": "jsonld-jobposting",
"parser_version": "1.0.0",
"passed": true,
"warnings": []
},
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.1.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_generic_job.html",
"id": "P-002",
"parser_key": "generic-html",
"parser_version": "1.1.0",
"passed": true,
"warnings": []
}
],
"coverage_ratio": 1.0,
"covered_fields": 12,
"expected_fields": 12,
"status": "passed",
"unknown_data_ratio": 0.0
},
"performance": {
"avg_scores_detail_ms": 50.504,
"avg_scores_import_ms": 36.681,
"avg_scores_list_ms": 102.301,
"avg_scores_rescore_ms": 19.548,
"dashboard": {
"detail_p95_ms": 54.227,
"detail_query_count": 25,
"detail_query_types": {
"SELECT": 25
},
"list_p95_ms": 129.536,
"list_query_count": 76,
"list_query_types": {
"SELECT": 76
},
"timing_count": 50
},
"import": {
"duration_ms_total": 9170.368,
"p50_ms": 35.32,
"p95_ms": 49.306,
"query_count": 3264,
"query_types": {
"INSERT": 1003,
"RELEASE": 253,
"SAVEPOINT": 253,
"SELECT": 1256,
"UPDATE": 499
},
"timing_count": 250
},
"jobs": 1,
"performance": {
"memory_current_mb": 12.332,
"memory_peak_mb": 16.658,
"nfr_007": true,
"p50_ms": 36.098,
"p95_ms": 93.787,
"total_duration_ms": 13010.048
},
"query_count": 3370,
"rescore": {
"duration_ms_total": 19.548,
"p50_ms": 19.548,
"p95_ms": 19.548,
"query_count": 5,
"query_types": {
"INSERT": 1,
"SELECT": 4
},
"timing_count": 1
},
"source_run_id": 1,
"top_n": 25
},
"ranking": {
"churn_ratio": 0.0,
"post_top_n": [
"6758530f-71d8-4e3d-9732-7e1001eefd13",
"94176301-843f-4074-abdf-fd2c76b76d36",
"492a021b-a00d-47ff-b236-86ef6a88a2a6",
"c6227dbc-82c4-4c4b-8aec-99714d5adc53",
"523a1940-cabc-48b3-abab-bc74f18d4ee4",
"ca18c4cc-b796-4ebf-9591-c335bc072f57",
"c6a495ac-564a-4aee-a509-081100ddf0a8",
"0cf16e0f-5ffc-455b-b488-deb19b0e0fea",
"b906c635-a818-4db7-b639-dc6e745ef345",
"edce515a-e697-4925-b73f-5605b1236727",
"1f800e1f-ff44-4548-91c7-0e9f97af40e2",
"0d3caa61-dab1-49d1-8af3-21cb605e9b41",
"e6f0d2ea-6dfc-4559-8aa9-80799402e8f1",
"a5d682ee-6fac-4ae9-921d-7e883db86d49",
"543f63fe-f7fd-4302-9414-95536f5fabcc",
"b97df085-3019-45cb-aee6-66edb6602df9",
"733c02de-73dc-4967-9e34-c62dcdd98e4f",
"f0178164-2535-4e02-91c7-7a6920ab6df3",
"4bb41af9-5387-4d0c-a62b-37f7ff713dfe",
"ea01b4ee-5a92-4df2-8896-bd391b5c661b",
"f08116ab-fa5d-45e7-a579-d9c2cad0d63f",
"3c4feccb-a4d1-4cd1-b814-1a2d8bfd9da1",
"69088d9a-0de5-4bbe-b8bf-abd733838ed6",
"e567d05d-8ed4-4f0f-80fb-1e6f0f77f377",
"9f03de2f-4c1e-4cc5-9d65-bbcd3b42a029"
],
"pre_top_n": [
"6758530f-71d8-4e3d-9732-7e1001eefd13",
"94176301-843f-4074-abdf-fd2c76b76d36",
"492a021b-a00d-47ff-b236-86ef6a88a2a6",
"c6227dbc-82c4-4c4b-8aec-99714d5adc53",
"523a1940-cabc-48b3-abab-bc74f18d4ee4",
"ca18c4cc-b796-4ebf-9591-c335bc072f57",
"c6a495ac-564a-4aee-a509-081100ddf0a8",
"0cf16e0f-5ffc-455b-b488-deb19b0e0fea",
"b906c635-a818-4db7-b639-dc6e745ef345",
"edce515a-e697-4925-b73f-5605b1236727",
"1f800e1f-ff44-4548-91c7-0e9f97af40e2",
"0d3caa61-dab1-49d1-8af3-21cb605e9b41",
"e6f0d2ea-6dfc-4559-8aa9-80799402e8f1",
"a5d682ee-6fac-4ae9-921d-7e883db86d49",
"543f63fe-f7fd-4302-9414-95536f5fabcc",
"b97df085-3019-45cb-aee6-66edb6602df9",
"733c02de-73dc-4967-9e34-c62dcdd98e4f",
"f0178164-2535-4e02-91c7-7a6920ab6df3",
"4bb41af9-5387-4d0c-a62b-37f7ff713dfe",
"ea01b4ee-5a92-4df2-8896-bd391b5c661b",
"f08116ab-fa5d-45e7-a579-d9c2cad0d63f",
"3c4feccb-a4d1-4cd1-b814-1a2d8bfd9da1",
"69088d9a-0de5-4bbe-b8bf-abd733838ed6",
"e567d05d-8ed4-4f0f-80fb-1e6f0f77f377",
"9f03de2f-4c1e-4cc5-9d65-bbcd3b42a029"
],
"seed_jobs_count": 120,
"status": "passed",
"top_n": 25,
"unknown_data_ratio": 0.25,
"unknown_fields": {
"analysis_features": 0,
"description_text": 30,
"employment_types": 0,
"raw_location": 0
}
},
"status": "passed",
"timestamp_utc": "2026-07-29T16:37:19.113439+00:00",
"top_n": 25
}
],
"schema_version": "1.0",
"status": "passed",
"summary": {
"duration_ms_mean": 9040.123,
"memory_peak_mb_mean": 18.799,
"p50_ms_mean": 32.9,
"p95_ms_mean": 52.296,
"query_count_mean": 3264,
"query_count_total_mean": 3370,
"throughput_per_second": 28.031
},
"warmup_status": "passed"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+141 -14
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import ipaddress
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -29,6 +30,63 @@ def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int
return value return value
def env_cidrs(name: str, default: str = "") -> list[str]:
networks: list[str] = []
for value in env_list(name, default):
try:
networks.append(str(ipaddress.ip_network(value, strict=False)))
except ValueError as exc:
raise ImproperlyConfigured(f"{name} bevat een ongeldig IP-netwerk: {value}") from exc
return networks
def merge_unique(*groups: list[str]) -> list[str]:
result: list[str] = []
seen: set[str] = set()
for group in groups:
for value in group:
key = value.casefold()
if key not in seen:
seen.add(key)
result.append(value)
return result
def normalize_public_base_url(value: str) -> tuple[str, str]:
raw = value.strip().rstrip("/")
if not raw:
return "", ""
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ImproperlyConfigured("PUBLIC_BASE_URL moet een geldige http(s)-URL zijn.")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ImproperlyConfigured(
"PUBLIC_BASE_URL mag geen credentials, query of fragment bevatten."
)
if parsed.path not in {"", "/"}:
raise ImproperlyConfigured("PUBLIC_BASE_URL mag geen applicatiepad bevatten.")
try:
port = parsed.port
except ValueError as exc:
raise ImproperlyConfigured("PUBLIC_BASE_URL bevat een ongeldige poort.") from exc
host = parsed.hostname.casefold()
display_host = f"[{host}]" if ":" in host else host
default_port = 443 if parsed.scheme == "https" else 80
port_suffix = f":{port}" if port and port != default_port else ""
return f"{parsed.scheme}://{display_host}{port_suffix}", host
def configured_public_base_url() -> tuple[str, str]:
raw = os.getenv("PUBLIC_BASE_URL", "").strip()
if not raw:
app_host = os.getenv("APP_HOST", "").strip()
if app_host:
scheme = os.getenv("APP_SCHEME", "https").strip().lower() or "https"
public_port = os.getenv("APP_PUBLIC_PORT", "").strip()
raw = f"{scheme}://{app_host}{f':{public_port}' if public_port else ''}"
return normalize_public_base_url(raw)
def _validate_production_security( def _validate_production_security(
*, *,
debug: bool, debug: bool,
@@ -38,6 +96,9 @@ def _validate_production_security(
session_cookie_secure: bool, session_cookie_secure: bool,
csrf_cookie_secure: bool, csrf_cookie_secure: bool,
secure_ssl_redirect: bool, secure_ssl_redirect: bool,
public_base_url: str = "",
demo_mode_enabled: bool = False,
demo_read_only: bool = True,
) -> None: ) -> None:
if debug: if debug:
return return
@@ -49,6 +110,8 @@ def _validate_production_security(
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.") raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
if not allowed_hosts: if not allowed_hosts:
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.") raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.")
if any(host.strip() == "*" for host in allowed_hosts):
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie geen wildcard bevatten.")
if not csrf_trusted_origins: if not csrf_trusted_origins:
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.") raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins): if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins):
@@ -57,13 +120,26 @@ def _validate_production_security(
raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.") raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.")
if not secure_ssl_redirect: if not secure_ssl_redirect:
raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.") raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.")
if public_base_url and not public_base_url.startswith("https://"):
raise ImproperlyConfigured("PUBLIC_BASE_URL moet HTTPS gebruiken in productie.")
if demo_mode_enabled and not demo_read_only:
raise ImproperlyConfigured("Een publieke productiedemo moet alleen-lezen zijn.")
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
DEBUG = env_bool("DJANGO_DEBUG", True) DEBUG = env_bool("DJANGO_DEBUG", True)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver") PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url()
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS") ALLOWED_HOSTS = merge_unique(
env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver"),
[PUBLIC_HOST] if PUBLIC_HOST else [],
)
CSRF_TRUSTED_ORIGINS = merge_unique(
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
[PUBLIC_BASE_URL] if PUBLIC_BASE_URL else [],
)
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels") TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.14").strip() or "0.3.14"
LANGUAGE_CODE = "nl-be" LANGUAGE_CODE = "nl-be"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
@@ -91,6 +167,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"apps.core.middleware.DemoReadOnlyMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.SecurityHeadersMiddleware", "apps.core.middleware.SecurityHeadersMiddleware",
] ]
@@ -190,6 +267,30 @@ LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "dashboard:today" LOGIN_REDIRECT_URL = "dashboard:today"
LOGOUT_REDIRECT_URL = "login" LOGOUT_REDIRECT_URL = "login"
# Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback.
AUTHENTICATION_BACKENDS = [
"apps.core.auth_backends.EmailOrUsernameModelBackend",
"django.contrib.auth.backends.ModelBackend",
]
PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600)))
# Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd.
ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False)
ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "")
ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "")
ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "")
ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
# Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet.
# Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie.
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", DEBUG)
DEMO_READ_ONLY = env_bool("DEMO_READ_ONLY", True)
DEMO_SESSION_SECONDS = env_int_range("DEMO_SESSION_SECONDS", 3600, minimum=300, maximum=86400)
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>") DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
EMAIL_HOST = os.getenv("SMTP_HOST", "") EMAIL_HOST = os.getenv("SMTP_HOST", "")
@@ -200,33 +301,52 @@ EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False) _PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://")
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False) SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS)
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS)
CSRF_COOKIE_SAMESITE = "Lax" CSRF_COOKIE_SAMESITE = "Lax"
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", False) SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_HTTPS)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") TRUST_PROXY_HEADERS = env_bool("TRUST_PROXY_HEADERS", bool(PUBLIC_BASE_URL))
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if TRUST_PROXY_HEADERS else None
USE_X_FORWARDED_HOST = env_bool("USE_X_FORWARDED_HOST", False)
TRUSTED_PROXY_CIDRS = env_cidrs("TRUSTED_PROXY_CIDRS")
# Interne containerprobes blijven HTTP; publieke applicatieroutes worden wel omgeleid.
SECURE_REDIRECT_EXEMPT = [r"^health/(?:live|ready)/$"]
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0")) SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True) SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False) SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
USE_X_FORWARDED_HOST = True
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY" X_FRAME_OPTIONS = "DENY"
SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False)
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "vacatureradar-rate-limit",
}
}
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
CACHE_URL = os.getenv("CACHE_URL", "" if DEBUG else REDIS_URL).strip()
_IS_TESTING = bool(os.getenv("PYTEST_CURRENT_TEST") or "pytest" in os.sys.modules)
if CACHE_URL and not _IS_TESTING:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": CACHE_URL,
"KEY_PREFIX": "vacatureradar",
"TIMEOUT": 300,
}
}
else:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "vacatureradar-local",
}
}
HEALTHCHECK_REQUIRE_CACHE = env_bool("HEALTHCHECK_REQUIRE_CACHE", not DEBUG)
CELERY_BROKER_URL = REDIS_URL CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL CELERY_RESULT_BACKEND = REDIS_URL
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False) CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
CELERY_TASK_EAGER_PROPAGATES = True CELERY_TASK_EAGER_PROPAGATES = True
CELERY_TASK_ACKS_LATE = True CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_DEFAULT_QUEUE = "default"
CELERY_TASK_TIME_LIMIT = 300 CELERY_TASK_TIME_LIMIT = 300
CELERY_TASK_SOFT_TIME_LIMIT = 270 CELERY_TASK_SOFT_TIME_LIMIT = 270
CELERY_TIMEZONE = TIME_ZONE CELERY_TIMEZONE = TIME_ZONE
@@ -249,6 +369,10 @@ if DEBUG and not MAILBOX_CREDENTIAL_KEYS:
] ]
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
"runtime-heartbeat": {
"task": "apps.core.tasks.record_runtime_heartbeat",
"schedule": 60.0,
},
"schedule-mailbox-polls": { "schedule-mailbox-polls": {
"task": "apps.sources.tasks.schedule_mailbox_polls", "task": "apps.sources.tasks.schedule_mailbox_polls",
"schedule": 300.0, "schedule": 300.0,
@@ -334,6 +458,9 @@ _validate_production_security(
session_cookie_secure=SESSION_COOKIE_SECURE, session_cookie_secure=SESSION_COOKIE_SECURE,
csrf_cookie_secure=CSRF_COOKIE_SECURE, csrf_cookie_secure=CSRF_COOKIE_SECURE,
secure_ssl_redirect=SECURE_SSL_REDIRECT, secure_ssl_redirect=SECURE_SSL_REDIRECT,
public_base_url=PUBLIC_BASE_URL,
demo_mode_enabled=DEMO_MODE_ENABLED,
demo_read_only=DEMO_READ_ONLY,
) )
LOGGING = { LOGGING = {
+39 -1
View File
@@ -5,10 +5,23 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView
from apps.core.views import SecurityAwareLoginView from apps.core.views import (
SecurityAwareLoginView,
demo_login,
entra_callback,
entra_login,
robots_txt,
)
urlpatterns = [ urlpatterns = [
path(
"favicon.ico",
RedirectView.as_view(url=f"{settings.STATIC_URL}favicon.svg", permanent=True),
name="favicon",
),
path("robots.txt", robots_txt, name="robots-txt"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path( path(
"login/", "login/",
@@ -16,6 +29,31 @@ urlpatterns = [
name="login", name="login",
), ),
path("logout/", auth_views.LogoutView.as_view(), name="logout"), path("logout/", auth_views.LogoutView.as_view(), name="logout"),
# Wachtwoordherstel (Django's ingebouwde flow, eigen templates).
path(
"wachtwoord/herstellen/",
auth_views.PasswordResetView.as_view(),
name="password_reset",
),
path(
"wachtwoord/herstellen/verzonden/",
auth_views.PasswordResetDoneView.as_view(),
name="password_reset_done",
),
path(
"wachtwoord/herstellen/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm",
),
path(
"wachtwoord/herstellen/klaar/",
auth_views.PasswordResetCompleteView.as_view(),
name="password_reset_complete",
),
# Entra ID SSO en demo-toegang.
path("auth/entra/login/", entra_login, name="entra-login"),
path("auth/entra/callback/", entra_callback, name="entra-callback"),
path("auth/demo/", demo_login, name="demo-login"),
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")), path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")), path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")), path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
BIN
View File
Binary file not shown.
+90
View File
@@ -0,0 +1,90 @@
# Productievoorbeeld — kopieer nooit letterlijk zonder alle CHANGE_ME-waarden te vervangen.
# Voor de Unraid AIO-container staat het echte bestand standaard op:
# /mnt/user/appdata/vacatureradar/source/.env
DJANGO_SECRET_KEY=CHANGE_ME_generate_at_least_50_random_characters
DJANGO_DEBUG=0
PUBLIC_BASE_URL=https://vacatureradar.example.be
DJANGO_ALLOWED_HOSTS=vacatureradar.example.be,127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be
DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.14
POSTGRES_DB=vacatureradar
POSTGRES_USER=vacatureradar
POSTGRES_PASSWORD=CHANGE_ME_unique_database_password
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
DATABASE_URL=
REDIS_URL=redis://127.0.0.1:6379/0
CACHE_URL=redis://127.0.0.1:6379/1
HEALTHCHECK_REQUIRE_CACHE=1
CELERY_TASK_ALWAYS_EAGER=0
VACATURERADAR_ADMIN_USERNAME=admin
VACATURERADAR_ADMIN_EMAIL=
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
VACATURERADAR_AUTO_BOOTSTRAP=1
# Alleen inschakelen wanneer een publieke gastdemo gewenst is. De middleware blokkeert mutaties.
DEMO_MODE_ENABLED=0
DEMO_READ_ONLY=1
DEMO_SESSION_SECONDS=3600
DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local
TRUST_PROXY_HEADERS=1
USE_X_FORWARDED_HOST=0
# Optioneel: het exacte IP/CIDR van Nginx Proxy Manager. Zonder waarde wordt X-Forwarded-For
# niet gebruikt voor client-IP-rate-limiting, maar X-Forwarded-Proto blijft wel nodig voor HTTPS.
TRUSTED_PROXY_CIDRS=
SESSION_COOKIE_SECURE=1
CSRF_COOKIE_SECURE=1
SECURE_SSL_REDIRECT=1
# Start met 300; verhoog pas nadat HTTPS en subdomeinen gecontroleerd zijn.
SECURE_HSTS_SECONDS=300
SECURE_HSTS_INCLUDE_SUBDOMAINS=1
SECURE_HSTS_PRELOAD=0
SEARCH_ENGINE_INDEXING_ENABLED=0
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS=12
MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS=120
MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS=300
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DEFAULT_FROM_EMAIL=VacatureRadar <vacatureradar@localhost>
DIGEST_RECIPIENT=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_USE_TLS=1
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
FETCHER_USER_AGENT=VacatureRadar/0.1 (+personal-use; contact=local-admin)
FETCHER_TIMEOUT_SECONDS=20
FETCHER_MAX_BYTES=3000000
FETCHER_MAX_REDIRECTS=5
FETCHER_ALLOW_NONSTANDARD_PORTS=0
SOURCE_POLICY_MODE=strict
OLLAMA_ENABLED=0
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=
OLLAMA_TIMEOUT_SECONDS=60
ENTRA_ID_ENABLED=0
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=
ENTRA_TENANT_ID=
ENTRA_REDIRECT_URI=
ENTRA_ALLOWED_DOMAINS=
ENTRA_SCOPES=User.Read
+8 -1
View File
@@ -1,5 +1,12 @@
# Unraid-installatiebestanden # Unraid-installatiebestanden
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md). De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Deze bouwt één Dockerman-container via `Dockerfile.unraid`; interne processen worden door Supervisor bewaakt. Alle paden zijn relatief aan de map met het composebestand, dus je kopieert de projectmap naar de server en start daar:
```bash
cp .env.unraid.example .env # eenmalig, daarna alle CHANGE_ME-waarden invullen
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
```
Volg voor de volledige procedure [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up. Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up.
+58
View File
@@ -0,0 +1,58 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[program:postgres]
command=/usr/lib/postgresql/17/bin/postgres -D /app/local/postgres-aio17
user=postgres
priority=10
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:redis]
command=/usr/bin/redis-server --dir /app/local/redis-aio --save 60 1 --loglevel warning --protected-mode yes --bind 127.0.0.1
user=redis
priority=20
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:web]
command=/app/.venv/bin/gunicorn config.wsgi:application --bind 0.0.0.0:8080 --workers 2 --threads 4 --timeout 90 --no-control-socket
directory=/app
user=app
priority=40
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:worker]
command=/app/.venv/bin/celery -A config worker -l INFO -Q high,default,low --concurrency=2
directory=/app
user=app
priority=50
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:scheduler]
command=/app/.venv/bin/celery -A config beat -l INFO --schedule /tmp/celerybeat-schedule
directory=/app
user=app
priority=60
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+42 -69
View File
@@ -1,76 +1,49 @@
# Compose-bron van waarheid met paden voor een Unraid-installatie. # Eén Dockerman-container met intern bewaakte PostgreSQL-, Redis-, web- en Celery-processen.
# Lees deployment/unraid/README.md vóór gebruik en wijzig secrets. #
# Kopieer de projectmap naar de server en start vanuit die map:
# cp .env.unraid.example .env # eenmalig, daarna alle CHANGE_ME-waarden invullen
# docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
#
# Alle paden zijn relatief aan deze map, zodat de map overal kan staan.
name: vacatureradar name: vacatureradar
x-app: &app
image: ghcr.io/CHANGE_ME/vacatureradar:latest
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=128m
- /var/tmp:rw,noexec,nosuid,size=128m
- /app/.cache:rw,noexec,nosuid,size=128m
env_file:
- /mnt/user/appdata/vacatureradar/config/.env
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
- /mnt/user/appdata/vacatureradar/media:/app/media
- /mnt/user/appdata/vacatureradar/logs:/app/logs
- /mnt/user/appdata/vacatureradar/local:/app/local
networks: [internal]
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
services: services:
web: app:
<<: *app build:
ports: ["1226:8080"] context: .
worker: dockerfile: Dockerfile.unraid
<<: *app image: vacatureradar:unraid
container_name: VacatureRadar
env_file:
- ${VACATURERADAR_ENV_FILE:-.env}
environment: environment:
VACATURERADAR_RUN_MIGRATIONS: "0" POSTGRES_HOST: 127.0.0.1
cap_drop: POSTGRES_PORT: "5432"
- ALL REDIS_URL: redis://127.0.0.1:6379/0
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"] CACHE_URL: redis://127.0.0.1:6379/1
scheduler: HEALTHCHECK_REQUIRE_CACHE: "1"
<<: *app # Alleen gebruikt bij een lege datadir; laat leeg wanneer je niet migreert.
environment: VACATURERADAR_RESTORE_DUMP: ${VACATURERADAR_RESTORE_DUMP:-}
VACATURERADAR_RUN_MIGRATIONS: "0"
cap_drop:
- ALL
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
postgres:
image: postgres:17.5-bookworm
restart: unless-stopped restart: unless-stopped
env_file: [/mnt/user/appdata/vacatureradar/config/.env] security_opt:
volumes: [/mnt/user/appdata/vacatureradar/postgres:/var/lib/postgresql/data] - no-new-privileges:true
networks: [internal] stop_grace_period: 30s
ports:
- "${VACATURERADAR_HOST_PORT:-1226}:8080"
volumes:
- ./local/media:/app/media
- ./local/logs:/app/logs
- ./local:/app/local
labels:
net.unraid.docker.managed: dockerman
net.unraid.docker.webui: http://[IP]:[PORT:8080]/
# Dockerman downloadt iconen server-side zonder [IP]/[PORT]-substitutie.
net.unraid.docker.icon: http://127.0.0.1:1226/static/img/vacatureradar-docker.png
org.opencontainers.image.title: VacatureRadar
org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U vacatureradar -d vacatureradar"] test: ["CMD", "python", "scripts/healthcheck.py", "http://127.0.0.1:8080/health/ready/"]
interval: 10s interval: 30s
timeout: 5s timeout: 5s
retries: 10 retries: 5
redis: start_period: 90s
image: redis:7.4.2-alpine
restart: unless-stopped
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes: [/mnt/user/appdata/vacatureradar/redis:/data]
networks: [internal]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
ollama:
image: ollama/ollama:0.9.6
profiles: ["ai"]
restart: unless-stopped
volumes: [/mnt/user/appdata/vacatureradar/ollama:/root/.ollama]
networks: [internal]
networks:
internal:
driver: bridge
+8 -1
View File
@@ -11,8 +11,11 @@ x-app: &app
- /var/tmp:rw,noexec,nosuid,size=128m - /var/tmp:rw,noexec,nosuid,size=128m
- /app/.cache:rw,noexec,nosuid,size=128m - /app/.cache:rw,noexec,nosuid,size=128m
env_file: env_file:
- .env - ${VACATURERADAR_ENV_FILE:-.env}
restart: unless-stopped restart: unless-stopped
environment:
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
volumes: volumes:
@@ -43,6 +46,8 @@ services:
<<: *app <<: *app
environment: environment:
VACATURERADAR_RUN_MIGRATIONS: "0" VACATURERADAR_RUN_MIGRATIONS: "0"
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
cap_drop: cap_drop:
- ALL - ALL
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"] command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
@@ -57,6 +62,8 @@ services:
<<: *app <<: *app
environment: environment:
VACATURERADAR_RUN_MIGRATIONS: "0" VACATURERADAR_RUN_MIGRATIONS: "0"
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
cap_drop: cap_drop:
- ALL - ALL
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"] command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
+865 -1
View File
@@ -1,6 +1,6 @@
schema_version: 1 schema_version: 1
project: VacatureRadar project: VacatureRadar
updated_at: '2026-07-22' updated_at: '2026-07-29'
execution_policy: execution_policy:
order: file-order, then first ready task whose dependencies are done order: file-order, then first ready task whose dependencies are done
no_product_questions: true no_product_questions: true
@@ -1444,6 +1444,679 @@ tasks:
note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke note: GeoNames België postcodedataset 2026-04-03 onder CC BY 4.0 gereviewd, checksums vastgelegd, 2781 unieke
rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores rijen gevalideerd en 5562 lookuprecords geïmporteerd; 2400 Mol opgelost naar 51.1919, 5.1166 en actieve profielscores
herberekend. herberekend.
- id: VR-206
title: Premium Stitch-cockpit en deterministische IT-focus
status: done
priority: P0
requirement_ids:
- PR-040
- PR-050
- PR-051
- NFR-004
- NFR-008
depends_on:
- VR-119
- VR-120
- VR-205
summary: Audit de volledige gebruikersflow in code en browser, voorkom dat niet-IT-vacatures de dagelijkse radar
vervuilen en benut de Stitch-compositie veel dominanter op mobiel, desktop en widescreen.
acceptance_criteria:
- Een deterministische en uitlegbare IT-relevantieregel heeft voorrang op scoring en AI; niet-IT-vacatures zijn
standaard afwezig uit Vandaag en de vacaturefeed maar blijven via een expliciete archiefkeuze inspecteerbaar.
- De vacaturefeed gebruikt echte score-evidence, bruikbare relevantie-/match-/sorteerfilters en toont standaard
de meest relevante IT-kansen in plaats van louter de nieuwste bronrecords.
- Dashboard, vacaturelijst en vacaturedetail nemen de bruikbare Stitch-panelhiërarchie, informatiedichtheid en
actiepatronen over zonder fictieve metrics, quick-apply of externe frontendafhankelijkheden.
- De shell en kernlayouts schalen inhoudelijk tussen 320 en 2560 px; widescreen voegt bruikbare kolommen/panelen
toe en laat de inhoud niet op een smalle 1280px-strook staan.
- Focus, headings, labels, lege toestanden, dark/light contrast, mobiele navigatie en reduced motion blijven toegankelijk;
kernroutes hebben geen horizontale paginaoverflow of browserconsolefouten.
verification:
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
- ./scripts/codex_verify.sh
- Browsermatrix op 2560x1440, 1920x1080, 1440x900, 1024x768, 390x844 en 320x800
primary_paths:
- apps/jobs/services/relevance.py
- apps/jobs/services/scoring.py
- apps/jobs/views.py
- templates/dashboard/today.html
- templates/jobs/list.html
- templates/jobs/detail.html
- static/css/app.css
- docs/design/STITCH_INTEGRATION_PLAN.md
result:
completed_at: '2026-07-22'
note: 'Volledige code- en browseraudit: deterministische IT-focus, laatste gebruikersscore, premium Stitch-feed/detail
en adaptieve 320-2560px-layout; 225 tests groen en browsermatrix geslaagd.'
- id: VR-207
title: Cv-gestuurd field- en infrastructuurprofiel
status: done
priority: P0
requirement_ids:
- PR-002
- PR-040
- PR-041
- NFR-002
- NFR-004
depends_on:
- VR-206
summary: Breid de begeleide profielconfiguratie uit voor een ervaren IT field-/infrastructuurprofiel en voorkom
dat een brede, relevante skillset de matchscore kunstmatig verlaagt. Persoonlijke cv-data blijft uitsluitend
in de gebruikersdatabase en komt niet in repositoryfixtures of documentatie.
acceptance_criteria:
- Rollen en skills zijn in begrijpelijke categorieën selecteerbaar voor field service, infrastructuur, Microsoft
365/endpoint, netwerk/security, back-up/monitoring, virtualisatie, VoIP en automation.
- Relevante IT-ervaring is via een selectielijst configureerbaar en wordt deterministisch vergeleken met expliciet
gevraagde ervaringsjaren; onbekende ervaring leidt niet tot uitsluiting.
- Ambigue field-/implementatietitels gelden alleen als IT wanneer de vacaturetekst tevens een sterk technisch
contextsignaal bevat; niet-IT field service blijft gefilterd.
- Meer relevante cv-skills aanvinken verwatert de skillscore niet onbeperkt en niet-vermelde voorkeurskills worden
niet als bewezen tekort gepresenteerd.
- Bestaande vrije profielwaarden blijven selecteerbaar en persoonlijke gegevens uit cv's worden niet gecommit.
verification:
- uv run pytest tests/unit/test_relevance.py tests/unit/test_scoring.py tests/unit/test_profiles.py tests/integration/test_profile_preferences.py
- uv run python manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
primary_paths:
- apps/profiles/forms.py
- apps/profiles/models.py
- apps/profiles/taxonomy.py
- apps/jobs/services/relevance.py
- apps/jobs/services/scoring.py
- templates/profiles/edit.html
- static/css/app.css
result:
completed_at: '2026-07-22'
note: Cv-gestuurde gegroepeerde profielconfiguratie, ervaringsband, begrensde skill-evidence, contextgebonden
fieldrelevantie en lokale profielrescore geverifieerd; 232 tests groen en browsercontrole op ultrawide/390
px geslaagd.
- id: VR-208
title: Skillsvraag- en leerkansenradar zonder ervaringsscore
status: done
priority: P0
requirement_ids:
- PR-002
- PR-040
- PR-041
- PR-043
- PR-050
- NFR-004
depends_on:
- VR-207
summary: Maak ervaringsduur volledig informatief en bouw uit de actuele, relevante vacatures een uitlegbare marktvraaganalyse
met profieldekking, skillsfilters en concrete bijleerkansen.
acceptance_criteria:
- Ervaringsjaren en senioriteitslabels hebben geen invloed op score, aanbeveling of harde uitsluiting; de profielwaarde
is uitsluitend informatieve context en bestaande senioriteitsgewichten worden veilig genegeerd en opgeruimd.
- De analyse gebruikt alleen actieve IT-vacatures die voor het actieve profiel niet hard uitgesloten zijn en blijft
strikt gebruikersgescopeerd.
- Een deterministische, uitbreidbare skillcatalogus toont per technologie of capability in hoeveel vacatures een
expliciet tekstsignaal voorkomt en onderscheidt profieldekking van mogelijke leer-/bijscholingskansen.
- Het profiel biedt extra relevante skills als gegroepeerde checkboxes; de inzichtspagina biedt categorie- en
dekkingsfilters en links naar de onderliggende vacatures.
- Feiten, tekstinferenties en onbekende gegevens zijn in de Nederlandstalige UI duidelijk onderscheiden; lege
toestanden en layouts werken van 320 tot 2560 px zonder horizontale paginaoverflow.
verification:
- uv run pytest tests/unit/test_scoring.py tests/unit/test_skill_demand.py tests/integration/test_skill_insights.py
tests/integration/test_profile_preferences.py
- uv run python manage.py makemigrations --check --dry-run
- ./scripts/codex_verify.sh
- Browsercontrole op 2560x1440, 1440x900 en 390x844
primary_paths:
- apps/jobs/services/scoring.py
- apps/jobs/services/skill_demand.py
- apps/jobs/views.py
- apps/profiles/taxonomy.py
- templates/jobs/skill_insights.html
- static/css/app.css
- docs/architecture/SCORING_ENGINE.md
result:
completed_at: '2026-07-22'
note: Ervarings- en senioriteitsinvloed uit score en learning verwijderd; gebruikersgescopeerde Skillsradar
met actuele bronselectie, vraagbewijs, profieldekking, leerkansen, uitgebreide skillfilters en 2560/1440/390-browsercontrole
voltooid.
- id: VR-209
title: Stitch frontendcontract, audit en cockpit-shell
status: done
priority: P0
requirement_ids:
- PR-040
- PR-050
- PR-051
- NFR-004
- NFR-008
depends_on:
- VR-208
summary: Leg de volledige negen-schermenaudit en fidelitymatrix vast en vervang het globale design system, de
shell, navigatie en generieke browserstates door de Stitch Intelligence Cockpit.
acceptance_criteria:
- Contract en integratieplan bevatten alle negen schermen, afmetingen, componenten, route/data-mapping, fidelitydoelen,
responsive/accessibilityrisico's en legacy-opruiming.
- De enige productieshell gebruikt de lokale Stitch-tokens, compacte navigatie, contextbar, mobile drawer, skiplink,
focusstaten en echte statusdata zonder externe runtimeassets.
- Login, meldingen, lege toestanden en generieke foutstates gebruiken dezelfde visuele taal en blijven zonder
JavaScript bruikbaar.
verification:
- uv run pytest tests/integration/test_views.py tests/e2e/test_vr114_browser_and_accessibility.py
- uv run ruff check .
primary_paths:
- docs/design/STITCH_FRONTEND_CONTRACT.md
- docs/design/STITCH_INTEGRATION_PLAN.md
- templates/base.html
- static/css/app.css
- static/js/app.js
result:
completed_at: '2026-07-22'
note: Volledige negen-schermenaudit, fidelitymatrix, bindend frontendcontract en nieuwe lokale Stitch-cockpit-shell
met gesplitst design system, mobile navigatie en generieke foutstates; relevante tests groen.
- id: VR-210
title: Stitch dashboard en vacature-intelligence
status: done
priority: P0
requirement_ids:
- PR-040
- PR-041
- PR-050
- NFR-004
depends_on:
- VR-209
summary: Vervang dashboard, vacatureverkenner en detail door de echte Stitch-composities met database-aggregaties,
uitlegbare scores, provenance en veilige acties.
acceptance_criteria:
- Dashboard toont echte dag-, match-, filter-, duplicaat-, sollicitatie-, activiteit- en brongezondheidsdata met
beste matches als dominante feed.
- De explorer heeft serverfilters, actieve chips, paginering en een brede master/detailcompositie die querystring
en browser-back behoudt.
- Het detail toont scorebewijs, inhoud, provenance, versies, duplicaten, sollicitatiestatus en een responsive
actiedock; ervaring telt nergens als scorefactor en externe links blijven veilig.
verification:
- uv run pytest tests/integration/test_views.py tests/unit/test_scoring.py tests/unit/test_relevance.py
primary_paths:
- apps/core/views.py
- apps/jobs/views.py
- apps/jobs/services/cockpit.py
- templates/dashboard/today.html
- templates/jobs/list.html
- templates/jobs/detail.html
result:
completed_at: '2026-07-22'
note: Dashboard, vacatureverkenner en detail volledig naar Stitch-composities vervangen met echte cockpitaggregaties,
laatste profielscore, master/detailselectie, provenance, versies, duplicaten en veilige acties; relevante
scoring/relevance/viewtests groen.
- id: VR-211
title: Stitch pipeline, radarprofiel en brongezondheid
status: done
priority: P0
requirement_ids:
- PR-002
- PR-050
- PR-051
- NFR-004
depends_on:
- VR-210
summary: Vervang sollicitaties, zoekprofiel en bronnen door de functionele Stitch-pipeline, radarconfigurator
en source-healthinterface.
acceptance_criteria:
- Sollicitaties bieden kanban en lijstfallback, gebruikersgescopeerde POST-statuswijziging, deadlines, contact-
en timelinecontext.
- Het zoekprofiel groepeert echte selecties, toont revisie-informatie en een server-rendered preview uit de laatste
echte match.
- Bronnen tonen policy, runs, fout/succes, volgende planning, parser, tellingen, retry en bestaande beveiligde
import-/mailboxflows.
verification:
- uv run pytest tests/integration/test_applications.py tests/integration/test_profile_preferences.py tests/integration/test_sources_manual_import.py
tests/integration/test_source_health.py
primary_paths:
- apps/jobs/views.py
- apps/profiles/views.py
- apps/sources/views.py
- templates/applications
- templates/profiles
- templates/sources/list.html
result:
completed_at: '2026-07-22'
note: Stitch-kanban met gescopeerde auditbare status-POST, dossiereditor en tijdlijn; gegroepeerde radarconfigurator
met echte scorepreview/revisies; health-first bronnenconsole met policy/runbewijs, retry, mailbox- en importflows.
Relevante tests groen.
- id: VR-212
title: Stitch automation, activiteit en werkgeversintelligence
status: done
priority: P0
requirement_ids:
- PR-040
- PR-050
- PR-051
- NFR-002
- NFR-004
depends_on:
- VR-211
summary: Bouw de ontbrekende activiteit- en werkgeversroutes en vervang systeemstatus door een echte afgeleide
automationcockpit.
acceptance_criteria:
- Automation toont echte pipeline-, run-, score-, notificatie-, scheduling-, database- en configuratiestatus en
labelt niet-beschikbare infrastructuur eerlijk.
- Activiteit combineert betrouwbare, gebruikersgescopeerde gebeurtenissen in een filterbare, gelinkte en gepagineerde
tijdlijn.
- Werkgevers index/detail aggregeren echte werkgever-, vacature-, score-, bron- en sollicitatiedata zonder CRM-
of marktclaims te verzinnen.
verification:
- uv run pytest tests/integration/test_intelligence_views.py tests/integration/test_views.py
primary_paths:
- apps/jobs/services/activity.py
- apps/jobs/services/employer_intelligence.py
- apps/jobs/views.py
- apps/jobs/urls.py
- apps/core/views.py
- templates/activity
- templates/employers
- templates/system/status.html
result:
completed_at: '2026-07-22'
note: Echte automationcockpit met 24u-pipeline en eerlijke runtimeconfiguratie; gepagineerde/filterbare activiteitreadservice;
werkgeversindex/detail met vacature-, score-, bron- en dossieraggregaties. Nieuwe routes, templates en scope-tests
groen.
- id: VR-213
title: Stitch responsive validatie en legacyverwijdering
status: done
priority: P0
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-212
summary: Rond de high-fidelity replacement af met browsermatrix, accessibility, elf screenshots, legacy-audit,
documentatie en volledige regressiegate.
acceptance_criteria:
- Alle negen schermen en kernflows zijn visueel en functioneel gecontroleerd tussen 360 en 1440 px, met aanvullende
ultrawidecontrole en schone browserconsole.
- Elf verplichte validatiescreenshots bestaan; majeure afwijkingen, accessibility- en querybevindingen staan in
het eindrapport.
- Oude visuele selectors, dode partials/JS en parallelle shellmarkup zijn verwijderd; de Stitch-frontend is de
enige normale productiefrontend.
- De volledige verificatiegate slaagt en backlog, projectstatus, traceability, README en changelog zijn bijgewerkt.
verification:
- ./scripts/codex_verify.sh
- Browsermatrix 1440x900, 1280x800, 1024x768, 768x1024, 390x844 en 360x800
primary_paths:
- static/css/app.css
- static/js/app.js
- tests/e2e
- artifacts/visual-validation
- docs/design/STITCH_INTEGRATION_REPORT.md
result:
completed_at: '2026-07-22'
note: Alle negen Stitch-schermen zijn browsergevalideerd van 360 tot 2560 px; elf screenshots, accessibility-/legacy-/queryaudit
en documentatie afgerond. Volledige gate groen met 248 tests, 2 optionele skips en 84,40% coverage.
- id: VR-214
title: Herstel het Unraid Dockerman-containericoon
status: done
priority: P1
requirement_ids:
- NFR-009
depends_on:
- VR-213
summary: Vervang de fragiele runtime-SVG voor de Dockerman-tegel door een lokale rasterasset en dek het volledige
Unraid-label- en packagingcontract af.
acceptance_criteria:
- Dockerman verwijst via de gepubliceerde webpoort naar een lokale PNG die door collectstatic in de image wordt
opgenomen.
- De browserfavicon blijft lokaal en ongewijzigd beschikbaar.
- Een regressietest controleert de exacte WebUI- en icoonlabels, PNG-signatuur en verwachte afmetingen.
- Het Unraid-runbook beschrijft het cachegedrag en de veilige refreshstap.
verification:
- uv run pytest tests/unit/test_deployment_configuration.py tests/integration/test_views.py
- ./scripts/codex_verify.sh
primary_paths:
- docker-compose.unraid.yml
- static/img/vacatureradar-docker.png
- tests/unit/test_deployment_configuration.py
- docs/operations/UNRAID_DEPLOYMENT.md
result:
completed_at: '2026-07-25'
note: Dockerman-icoon vervangen door lokale 256x256 PNG via exact Unraid-poortlabel; URL, PNG-signatuur en afmetingen
regressiegedekt. Volledige gate groen met 264 tests en 83,47% dekking. Live Unraid-image sha256:799825df1a343b6bee43695850454a5fdc977b5af3366077e6ac6aaeeb94f6f9
gebouwd en gerecreate; container healthy, readiness/liveness groen en PNG via LAN HTTP 200 (7111 bytes).
- id: VR-215
title: Corrigeer Dockermans server-side icoondownload
status: done
priority: P1
requirement_ids:
- NFR-009
depends_on:
- VR-214
summary: Gebruik voor het Dockerman-icoon een concrete server-side URL omdat Unraid placeholders alleen voor WebUI-links
vervangt en niet voor icoondownloads.
acceptance_criteria:
- Het icoonlabel bevat geen IP- of poortplaceholder en is vanaf de Unraid-host bereikbaar.
- Dockerman maakt `VacatureRadar-icon.png` in zijn eigen icooncache aan.
- De Docker-tegel gebruikt de gecachte PNG terwijl WebUI-poortsubstitutie behouden blijft.
- De regressietest en het Unraid-runbook leggen dit onderscheid vast.
verification:
- uv run pytest tests/unit/test_deployment_configuration.py
- Live Unraid-recreate, cachecontrole en HTTP-smoke
- ./scripts/codex_verify.sh
primary_paths:
- docker-compose.unraid.yml
- tests/unit/test_deployment_configuration.py
- docs/operations/UNRAID_DEPLOYMENT.md
result:
completed_at: '2026-07-25'
note: Dockerman gebruikt nu concrete server-side loopback-URL zonder placeholders; live Unraid-container healthy,
HTTP-smoke groen, persistente en RAM-cache bevatten geldige 256x256 PNG en docker.json verwijst naar VacatureRadar-icon.png.
Gericht 5 tests en volledige gate groen met 264 tests en 83,44% dekking.
- id: VR-216
title: Premium UX-verfijning met rustige visuele hiërarchie
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-215
summary: Verfijn de bestaande Stitch-interface naar een premium, rustige cockpit met minder visuele ruis, selectiever
kleurgebruik en sterkere informatiehiërarchie zonder server-rendered flows te wijzigen.
acceptance_criteria:
- Donker en licht thema gebruiken gelaagde oppervlakken, zachtere scheidingen en cyan alleen voor primaire signalen
en acties; navigatie en labels gebruiken leesbare humane typografie.
- Dashboard, verkenner, detail, pipeline en profiel hebben een herkenbare eigen compositie met consistente componenten,
zonder nepdata of dode bediening.
- Mobiele KPI's staan compact boven de vouw, filters blijven bruikbaar en de pipeline scrolt intern zonder pagina-overflow
tussen 360 en 1440 px.
- Browsercontrole op 390 en 1280 px dekt donker en licht thema, kernflows en een schone console.
verification:
- uv run pytest tests/e2e/test_vr114_browser_and_accessibility.py tests/integration/test_views.py
- ./scripts/codex_verify.sh
- Browsermatrix 390x844 en 1280x800 in donker en licht thema
primary_paths:
- static/css
- templates/dashboard/today.html
- templates/jobs/list.html
- templates/applications/list.html
- templates/profiles/list.html
- docs/design/STITCH_INTEGRATION_REPORT.md
result:
completed_at: '2026-07-25'
note: Premium calm-intelligence-laag voor dark/light, compacte mobiele KPI's, responsieve filtercommandobar,
intern scrollende pipeline en breed profiel. Bestaande login-CSP-fout opgelost met lokale externe JS. Browsermatrix
390/1280 zonder overflow en schone console; volledige gate groen met 264 tests, 2 optionele skips en 83,44%
dekking.
- id: VR-217
title: Borg Unraid-authenticatie en voeg interactieve radarillustraties toe
status: done
priority: P1
requirement_ids:
- NFR-008
- NFR-009
depends_on:
- VR-216
summary: Maak het bestaande SSH-deploymentprofiel expliciet en voeg een lokale, toegankelijke interactieve radarillustratie
toe op inhoudelijk passende cockpitstates zonder echte data of kernflows te vervangen.
acceptance_criteria:
- Deploymentdocumentatie en helper valideren de geconfigureerde Unraid-hostalias en falen met een concrete herstelmelding
wanneer de verkeerde SSH-identiteit wordt gekozen.
- Een herbruikbare lokale radarillustratie versterkt profiel- en lege radarstates met pointer-/focusrespons en
bevat geen externe asset, fictieve metric of betekenisdragende informatie.
- De illustratie degradeert zonder JavaScript, respecteert prefers-reduced-motion en blijft verborgen voor hulptechnologie;
kernacties en tekst blijven zelfstandig bruikbaar.
- Browsercontrole op 390 en 1280 px in donker en licht thema toont geen overflow, consolefout of concurrerende
visuele hiërarchie; de live Unraid-container is na deployment healthy.
verification:
- uv run pytest tests/unit/test_deployment_configuration.py tests/e2e/test_vr114_browser_and_accessibility.py
- Browsermatrix 390x844 en 1280x800 in donker en licht thema
- Live SSH-, containerhealth- en HTTP-smoke via de geconfigureerde hostalias
- ./scripts/codex_verify.sh
primary_paths:
- scripts/deploy_unraid.ps1
- docs/operations/UNRAID_DEPLOYMENT.md
- templates/components/radar_illustration.html
- templates/profiles/list.html
- static/css/premium.css
- static/js/app.js
result:
completed_at: '2026-07-26'
note: Unraid-authenticatie hersteld via gevalideerde hostalias unraid-itworx met root en dedicated sleutel;
fail-fast deployhelper toegevoegd. Interactieve lokale SVG-radar met pointerparallax, dark/light en reduced-motionfallback
toegevoegd. Browsermatrix 390/1280 zonder overflow of consolefouten; 265 tests groen. Live image sha256:4d30e01e778777d5a2cde4339a9b2ad006edb25cae387293737420152d65069d
healthy en readiness/CSS/JS HTTP 200.
- id: VR-218
title: Personaliseer merkidentiteit en verfijn de globale UX
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-217
summary: Vervang het generieke favicon door een herkenbaar radarsymbool, voeg configureerbare eigenaarssignatuur
toe en verfijn shell, oriëntatie, navigatie en micro-interacties tot een coherente persoonlijke premiumervaring.
acceptance_criteria:
- Favicon en hoofdlogo delen één scherp, lokaal en op 16 px herkenbaar radarsymbool met passende light/dark browsermetadata
en zonder externe assets.
- De shell en authenticatieschermen tonen VacatureRadar als product en een configureerbare eigenaarssignatuur
met veilige standaardwaarde Jens; deze branding wordt niet uit gebruikersdata of nepdata afgeleid.
- De topbar geeft compacte paginaoriëntatie, navigatie-/zoekinteracties hebben duidelijkere focus- en hoverstates
en mobiele bruikbaarheid blijft intact zonder extra bediening of JavaScriptafhankelijkheid.
- Browsercontrole op dashboard, verkenner, profiel en login bij 390 en 1280 px in donker en licht thema toont
geen overflow, consolefout of onduidelijke hiërarchie; live container en assets zijn healthy na deployment.
verification:
- uv run pytest tests/e2e/test_vr114_browser_and_accessibility.py tests/integration/test_auth_login.py tests/integration/test_views.py
- Browsermatrix 390x844 en 1280x800 in donker en licht thema
- Live favicon-, logo-, readiness- en containerhealth-smoke
- ./scripts/codex_verify.sh
primary_paths:
- apps/core/context_processors.py
- static/favicon.svg
- static/img/vacatureradar-logo.svg
- templates/base.html
- templates/registration
- static/css/premium.css
- static/css/auth.css
result:
completed_at: '2026-07-26'
note: Persoonlijke Jens-branding, vernieuwd radarbeeldmerk en favicon, contextuele bovenbalk en verfijnde navigatie/zoekfocus
zijn regressiegedekt, responsief in de browser gevalideerd en live uitgerold als healthy image sha256:5f25f00c9b7efb166587901ff39b02c56c98fb9946ddef0a4546a22f18dc0802.
- id: VR-219
title: Herstel ultrawide uitlijning en versterk persoonlijke merklock-up
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
depends_on:
- VR-218
summary: Breng de cockpit op brede schermen terug naar één voorspelbaar horizontaal grid en maak de persoonlijke
Jens-signatuur tot een herkenbaar onderdeel van de productbranding in plaats van losse metadata.
acceptance_criteria:
- Header, filters, hoofdpanelen en onderliggende secties delen op desktop en ultrawide hetzelfde startpunt en
dezelfde contentbreedte; er ontstaat geen grote toevallige lege linkerkolom.
- Verkenner en automatisering benutten 1920- en 3840-pixelbreedtes evenwichtig zonder uitgerekte leesregels, losgekoppelde
panelen of documentoverflow.
- Sidebar en bovenbalk tonen één consistente VacatureRadar-by-Jens-merklock-up; authenticatie en mobiele shell
behouden dezelfde eigenaarssignatuur zonder gebruikersnaam als merk te presenteren.
- Browsercontrole op 390, 1280, 1920 en 3840 px bevestigt consistente ankers, bruikbare paneelverhoudingen, schone
console en intacte toetsenbord-/mobiele flows.
verification:
- Browsermatrix dashboard, verkenner, automatisering en login op 390/1280/1920/3840 px
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py
- ./scripts/codex_verify.sh
primary_paths:
- templates/base.html
- static/css/layout.css
- static/css/premium.css
- static/css/responsive.css
result:
completed_at: '2026-07-26'
note: Ultrawide hoofdsecties delen één links verankerd 2800px-grid; explorer- en automationverhoudingen zijn
begrensd en Personal intelligence by Jens is de consistente lock-up. Browsermatrix 390/1280/1920/3840 schoon;
267 tests groen; live image sha256:86836f82cc543a7adb414e47b9667e9123bb9ceba10d629a53086273aaaae03f healthy
met readiness en asset-smokes.
- id: VR-220
title: Integreer de officiële ITWorx-merkidentiteit
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-219
summary: Vervang de tijdelijke tekstuele Jens-branding door de door de eigenaar aangeleverde officiële ITWorx.tech-wordmark
en cloud-check-identiteit, met VacatureRadar herkenbaar als product binnen dat merk.
acceptance_criteria:
- Sidebar, login en herstelpagina's gebruiken de transparante officiële ITWorx.tech-wordmark uit het aangeleverde
asset pack; VacatureRadar blijft duidelijk als productnaam aanwezig.
- Browserfavicon en app-/Dockericoon gebruiken de officiële cloud-check-symboliek in een compacte transparante,
lokaal gehoste en voor klein formaat geoptimaliseerde variant.
- Merkassets hebben betekenisvolle alt-/toegankelijkheidslabels, laden zonder externe runtimebron en blijven scherp
en leesbaar in donker/licht thema en op mobiel.
- Browsercontrole op login, dashboard en verkenner bij 390, 1280 en 1920 px toont correcte assets, geen overflow
of consolefouten; de live container levert brandingassets en readiness met HTTP 200.
verification:
- Controleer afmetingen, transparantie en bestandsgrootte van de afgeleide merkassets
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py tests/unit/test_deployment_configuration.py
- Browsermatrix 390/1280/1920 voor login, dashboard en verkenner
- Live asset-, favicon-, readiness- en containerhealth-smoke
- ./scripts/codex_verify.sh
primary_paths:
- static/img/itworx-wordmark.png
- static/img/itworx-app-icon.png
- static/img/vacatureradar-docker.png
- templates/base.html
- templates/registration
- static/css/premium.css
- static/css/auth.css
result:
completed_at: '2026-07-26'
note: Officiële aangeleverde ITWorx.tech-wordmark geïntegreerd in sidebar, topbar, login en herstel; cloud-check
exact afgeleid voor 512px favicon/app-icon en 256px Dockermanicoon. Browsermatrix 390/1280/1920 schoon, 267
tests groen en live image sha256:daf34407813b37cdf2d0fc65a482ded65375f89eedd3f0144a8c0ce206d41565 healthy
met readiness/PNG-smokes.
- id: VR-221
title: Scheid appmerk van ontwikkelaarsbranding
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-220
summary: Herstel VacatureRadar als zelfstandig productmerk met een nieuw professioneel radarlogo en favicon, en
plaats de aangeleverde ITWorx.tech-identiteit uitsluitend als subtiele ontwikkelaarscredit.
acceptance_criteria:
- VacatureRadar gebruikt een zelfstandig, vectorvriendelijk beeldmerk dat radar, detectie en een gevonden kans
combineert, scherp blijft op 16 px en visueel niet met het ITWorx-cloud-check-logo concurreert.
- Sidebar, login, favicon en Dockerman tonen het VacatureRadar-productmerk; ITWorx.tech staat alleen op geschikte
secundaire plaatsen als `Ontwikkeld door ITWorx.tech`.
- De ontwikkelaarscredit gebruikt de officiële aangeleverde ITWorx-wordmark lokaal en blijft ondergeschikt, toegankelijk
en leesbaar in donker/licht thema en op mobiel.
- Browsercontrole op login, dashboard en verkenner bij 390, 1280 en 1920 px toont correcte merkhiërarchie, geladen
assets, geen overflow of consolefouten; live container en alle assets zijn healthy.
verification:
- Inspecteer VacatureRadar-SVG op viewBox, elementeenvoud en klein-formaat-signatuur
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py tests/unit/test_deployment_configuration.py
- Browsermatrix 390/1280/1920 voor login, dashboard en verkenner
- Live logo-, favicon-, ontwikkelaarswordmark-, readiness- en containerhealth-smoke
- ./scripts/codex_verify.sh
primary_paths:
- static/favicon.svg
- static/img/vacatureradar-logo.svg
- static/img/vacatureradar-docker.png
- static/img/itworx-wordmark.png
- templates/base.html
- templates/registration
- static/css/premium.css
- static/css/auth.css
result:
completed_at: '2026-07-26'
note: VacatureRadar heeft een zelfstandig professioneel open-radar-logo, SVG-favicon en 256px Dockermanicoon;
officiële ITWorx.tech-wordmark staat alleen als Ontwikkeld door-credit. Twee beeldconcepten verkend, finale
geometrie handmatig als vijf-elementen-SVG gebouwd. Browsermatrix 390/1280/1920 schoon, 267 tests groen en
live image sha256:4184aeff33759b7a205637b0a0333b751b599141fe691a61965d2f1c56ca984a healthy met alle asset-smokes.
- id: VR-222
title: Voer een integrale premium visuele audit en herstelroadmap uit
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-221
summary: Audit de volledige cockpit als één designsysteem en herstel merkhiërarchie, uitlijning, informatiedichtheid,
interactiedoelen, responsief gedrag en motion tot een consistente premium productervaring.
acceptance_criteria:
- Een gedateerd auditrapport bevat geprioriteerde bevindingen, concreet gevolg, herstelactie en volledige roadmap
voor alle hoofdschermen, login en herstel.
- Topbar en hoofdcontent delen voorspelbare horizontale ankers; dashboard en activiteit zijn aantoonbaar compacter
zonder echte informatie te verbergen of controles onbruikbaar te maken.
- VacatureRadar blijft het enige primaire appmerk; ITWorx.tech staat uitsluitend als subtiele ontwikkelaarscredit
en geen concept- of chroma-keybeeld wordt als productasset geleverd.
- Controls, focus, reduced motion, dark/light en 390/768/1280/1440/1920/3840 px zijn gecontroleerd zonder overflow,
consolefouten of regressie in de server-rendered kernflow.
- De live container is healthy en levert het finale logo, favicon en de relevante CSS met HTTP 200.
verification:
- Browsermatrix voor alle hoofdschermen plus login in dark/light en 390/768/1280/1440/1920/3840 px
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py
- ./scripts/codex_verify.sh
- Live readiness-, containerhealth- en assetsmokes
primary_paths:
- docs/design/VISUAL_AUDIT_2026-07-26.md
- templates/base.html
- templates/dashboard/today.html
- templates/activity/list.html
- static/css/premium.css
- static/css/pages.css
- static/css/responsive.css
result:
completed_at: '2026-07-26'
note: 'Integrale audit en roadmap uitgevoerd: gedeeld contentframe, vijf dashboardmatches, compacte activiteit,
subtiele ITWorx-credit, grotere klikdoelen, reduced-motion, CSS-cacheversies en herstelde mobiele login. Browsermatrix
50 checks plus light/login schoon; 268 tests groen, 2 skips, 83,45% dekking. Live image sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70
healthy; readiness en assets HTTP 200.'
- id: VR-223
title: Verfijn transparant productlogo en professionele widescreenshell
status: done
priority: P1
requirement_ids:
- NFR-004
- NFR-008
- NFR-009
depends_on:
- VR-222
summary: Herstel de resterende merk- en shellfrictie uit de gebruikersscreenshot met een transparant cleaner productlogo,
één rustige sidebarfooter zonder dubbele accountidentiteit en beter benutte widescreenruimte.
acceptance_criteria:
- Het VacatureRadar-logo heeft geen donkere tegelachtergrond en blijft als eenvoudige vector scherp en herkenbaar
op dark/light, login, sidebar en klein formaat; favicon en Dockerman blijven doelgeschikt.
- De sidebar toont onderaan één samenhangende status-/ontwikkelaarszone en herhaalt de demo- of accountidentiteit
niet; accountacties blijven bereikbaar via het bestaande topbarmenu.
- De cockpit benut 1920, 2560 en 3840 px evenwichtiger zonder uitgerekte leesregels, losstaande topbaracties of
horizontale documentoverflow; mobiel en laptop blijven intact.
- Een gerichte vervolgcheck identificeert en herstelt bijkomende zichtbare shellproblemen, inclusief light/dark,
korte viewport en toetsenbord-/interactiestates.
- De live container is healthy en levert logo, favicon, CSS en readiness met HTTP 200.
verification:
- Browsermatrix dashboard, verkenner, automatisering, login op 390/1280/1920/2560/3840 px en korte sidebarviewport
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py tests/unit/test_deployment_configuration.py
- ./scripts/codex_verify.sh
- Live readiness-, containerhealth- en assetsmokes
primary_paths:
- static/img/vacatureradar-logo.svg
- static/css/layout.css
- static/css/premium.css
- static/css/responsive.css
- templates/base.html
result:
completed_at: '2026-07-27'
note: Transparant vier-elementenlogo zonder donkere tegel; één compacte sidebarfooter zonder dubbele accountidentiteit;
monochrome dark-mode ITWorx-credit en widescreenframes van 2200/2480 px. Browsermatrix 18 shellchecks plus
dark/light/login schoon; volledige gate tweemaal groen met 268 tests, 2 skips en 83,45% dekking. Live image
sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921 healthy; readiness en assets HTTP
200.
- id: VR-301 - id: VR-301
title: Multi-tenant SaaS, teams en billing title: Multi-tenant SaaS, teams en billing
status: deferred status: deferred
@@ -1460,6 +2133,40 @@ tasks:
- Niet uitvoeren binnen huidige scope. - Niet uitvoeren binnen huidige scope.
primary_paths: primary_paths:
- docs/product/SCOPE_AND_NON_GOALS.md - docs/product/SCOPE_AND_NON_GOALS.md
- id: VR-224
title: Maak de repository-README functioneel en user-first met actuele screenshots
status: done
priority: P1
requirement_ids:
- NFR-008
- NFR-009
depends_on:
- VR-223
summary: Herbouw de repository-ingang rond het gebruikersprobleem, de dagelijkse workflow en een veilige quickstart,
ondersteund door compacte actuele productbeelden uit de echte server-rendered cockpit.
acceptance_criteria:
- De README begint bij doel, doelgroep en dagelijkse gebruikersflow; installatie, beheer, architectuur en agentoverdracht
volgen in een duidelijke progressieve volgorde zonder bestaande essentiële informatie te verliezen.
- Minstens vier actuele screenshots tonen verschillende functionele hoofdschermen, desktop en mobiel en dark/light;
ze bevatten geen secrets of persoonlijke gegevens en zijn lokaal, geoptimaliseerd en correct gelinkt.
- Quickstarts, poorten, lokale credentials, veiligheidsgrenzen en operationele documentatielinks zijn aantoonbaar
correct en maken duidelijk wat demo-, ontwikkel- en productiegebruik betekent.
- README-links en repositorydocumentatie valideren, de beelden zijn visueel gecontroleerd zonder overflow of consolefouten
en de volledige kwaliteitsgate slaagt.
verification:
- Browsercontrole van de vastgelegde routes en viewports
- python scripts/validate_repository.py
- ./scripts/codex_verify.sh
primary_paths:
- README.md
- docs/screenshots/readme/
- docs/quality/TRACEABILITY_MATRIX.md
- docs/ai/PROJECT_STATE.md
result:
completed_at: '2026-07-27'
note: User-first README met gecontroleerde quickstarts en vier actuele dark/light desktop-/mobielscreenshots;
browsermatrix zonder overflow of consolewaarschuwingen, repositoryvalidatie groen en volledige gate met 268
tests, 2 skips en 83,48% dekking.
- id: VR-302 - id: VR-302
title: Automatisch sollicitaties invullen of versturen title: Automatisch sollicitaties invullen of versturen
status: deferred status: deferred
@@ -1495,3 +2202,160 @@ tasks:
primary_paths: primary_paths:
- docs/api/openapi.yaml - docs/api/openapi.yaml
- docs/architecture/adrs/0008-server-rendered-design.md - docs/architecture/adrs/0008-server-rendered-design.md
- id: VR-225
title: Hard publieke reverse-proxydeployment en gasttoegang uit
status: done
priority: P0
requirement_ids:
- NFR-001
- NFR-006
- NFR-009
depends_on:
- VR-224
summary: Maak de bestaande persoonlijke installatie veilig en reproduceerbaar bereikbaar achter Nginx Proxy Manager,
herstel publieke host- en CSRF-configuratie en voorkom dat een gedeeld demoaccount persistente data wijzigt.
acceptance_criteria:
- Eén publieke HTTPS-URL configureert hostvalidatie, CSRF, secure cookies, SSL-detectie en redirect zonder de
interne containerpoort in de externe origin op te nemen.
- Productie start fail-fast bij debugmodusgerelateerde onveiligheid, een zwakke secret, onveilige cookies, ontbrekende
HTTPS-origin of een schrijfbare publieke demo.
- Login- en importlimieten gebruiken in productie een gedeelde cache; forwarded client-IPs worden alleen vanaf
expliciet vertrouwde proxy-CIDRs geaccepteerd.
- Het gastaccount is server-side alleen-lezen, heeft een begrensde sessie, kan afmelden en toont de beperking
zichtbaar.
- Unraid-envpad, Nginx Proxy Manager, healthchecks, noindex, heruitrol en terugdraaien zijn exact gedocumenteerd.
verification:
- PYTHONPATH=/mnt/data/vr_windows_site/site-packages pytest -q
- python manage.py check
- python manage.py makemigrations --check --dry-run
- python scripts/validate_repository.py
- bash -n scripts/deploy_docker.sh scripts/entrypoint.sh scripts/unraid_aio_entrypoint.sh
- Live Nginx-, TLS-, CSRF-, Dockerhealth- en restore-smoke op de doelserver
primary_paths:
- config/settings.py
- apps/core/middleware.py
- apps/core/network.py
- apps/core/health.py
- scripts/configure_public_url.py
- docker-compose.unraid.yml
- docs/operations/NGINX_PROXY_MANAGER.md
- docs/quality/PRODUCTION_AUDIT_2026-07-27.md
result:
completed_at: '2026-07-27'
note: Publieke host/CSRF/HTTPS-configuratie, gedeelde Redis-rate-limiting, proxy-IPvertrouwen, cache-readiness,
noindex en read-only gastdemo geïmplementeerd. 295 tests groen met 2 correcte Playwrightskips; Django check,
migratiecheck, repositoryvalidatie, YAML-, syntax- en packagechecks groen. Live servervalidatie blijft extern.
- id: VR-226
title: Verdiep de user-first README met een volledige productgalerij
status: done
priority: P1
requirement_ids:
- NFR-006
- NFR-008
- NFR-009
depends_on:
- VR-225
summary: Maak de productpresentatie overtuigender met een logisch opgebouwde, actuele galerij die de volledige
gebruikersreis toont zonder de README traag, repetitief of marketingmatig te maken.
acceptance_criteria:
- De README bevat minstens acht actuele beelden die dashboard, zoeken/analyse, skills, sollicitatieopvolging,
profiel, bronnen, automatisering en mobiel gebruik als één samenhangende productreis tonen.
- Desktopbeelden gebruiken een consistente professionele viewport en rustige uitsnede; dark/light en mobiel zijn
doelbewust verdeeld, zonder overgangsoverlays, overflow, consolefouten, secrets of persoonlijke gegevens.
- De galerij heeft korte taakgerichte captions, een scanbare compositie en compacte lokale assets; duplicatieve
beelden of losse decoratieve screenshots worden verwijderd of vervangen.
- De eerder aangetroffen VR-225-baselinefouten in lint en platformafhankelijke bestandsmoduscontrole zijn hersteld;
repositoryvalidatie en de volledige kwaliteitsgate slagen.
verification:
- Browsercontrole van alle vastgelegde routes op desktop en mobiel in dark/light
- uv run pytest tests/unit/test_public_configuration.py
- python scripts/validate_repository.py
- ./scripts/codex_verify.sh
primary_paths:
- README.md
- docs/screenshots/readme/
- scripts/configure_public_url.py
- tests/unit/test_public_configuration.py
- docs/quality/TRACEABILITY_MATRIX.md
- docs/ai/PROJECT_STATE.md
result:
completed_at: '2026-07-28'
note: README verdiept tot een klikbare user-first productreis met negen actuele beelden op 1920x1080 en 390x844
in dark/light; alle routes zonder overflow of consolewaarschuwingen. VR-225-lint en Windows-modusassertie
hersteld; 296 tests groen, 2 skips en 83,44% dekking.
- id: VR-227
title: Professionaliseer VacatureRadar integraal voor release 0.3.13
status: done
priority: P0
requirement_ids:
- NFR-001
- NFR-006
- NFR-007
- NFR-008
- NFR-009
- NFR-010
depends_on:
- VR-226
summary: Voer de integrale responsive, UX-, accessibility-, performance-, observability-, security- en operationsroadmap
uit en vervang optionele browsercontrole door een verplichte releasegate.
acceptance_criteria:
- Hoofdcontent en topbar delen een gecentreerd adaptief frame; gastbanner, sticky elementen en pipeline blijven
bruikbaar van 390 tot 3440 px zonder documentoverflow.
- De vacatureflow is begrensd gepagineerd, globale zoekcopy is eerlijk, mobiel pipelinegebruik is verticaal en
demomutaties veroorzaken geen controlmuur.
- Playwright Chromium draait verplicht in CI op zes viewports, faalt op console/page errors en controleert focus,
kernreis en documentoverflow zonder skips.
- Automatisering toont een echte worker/beat-heartbeat en markeert niet-meetbare queuediepte eerlijk; benchmarkparserdekking
is volledig en reproduceerbaar.
- Baseline, performance, architectuur, operations, testing, traceability, externe acties en finale acceptance
zijn actueel en de volledige repositorygate slaagt.
verification:
- python -m scripts.benchmark --quick --skip-performance --json
- python -m pytest tests/e2e/test_vr114_browser_and_accessibility.py
- python -m pytest tests/unit/test_runtime_observability.py tests/unit/test_generic_html_adapter.py
- docker compose config
- ./scripts/codex_verify.sh
primary_paths:
- static/css/
- static/js/app.js
- templates/base.html
- apps/core/services/automation.py
- apps/core/tasks.py
- tests/e2e/test_vr114_browser_and_accessibility.py
- docs/audit/
- USER_INPUT_REQUIRED.md
result:
completed_at: '2026-07-29'
note: Release 0.3.13 professionaliseert responsive layout, mobiele gastflow, begrensde vacature-UX, verplichte
zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, parserbenchmark, authdekking, SBOM en integrale
documentatie; volledige gate 312 tests groen, 84,16% dekking.
- id: VR-228
title: Optimaliseer de importpipeline voor release 0.3.14
status: ready
priority: P0
requirement_ids:
- NFR-007
- NFR-008
- NFR-009
depends_on:
- VR-227
summary: Profileer de PostgreSQL-import reproduceerbaar, verwijder aantoonbare N+1- en overbodige writes en lever
de geverifieerde optimalisatie veilig op zonder wijziging aan deduplicatie, scoring of datakwaliteit.
acceptance_criteria:
- Een fail-closed runner meet een disposable PostgreSQL-database met vaste fixture, warm-up en drie herhalingen.
- De geoptimaliseerde import reduceert queryvolume en doorlooptijd aantoonbaar tegenover 0.3.13 en rapporteert
p50, p95, throughput, geheugen en queryfamilies.
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische
ScoreRuns plus veldprovenance blijven intact.
- Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie
bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid.
verification:
- python -m scripts.benchmark_import --jobs 250 --iterations 3
- uv run pytest tests/integration/test_pipeline.py tests/unit/test_import_benchmark.py
- ./scripts/codex_verify.sh
primary_paths:
- apps/jobs/services/pipeline.py
- scripts/benchmark.py
- scripts/benchmark_import.py
- tests/integration/test_pipeline.py
- docs/audit/IMPORT_PERFORMANCE_FINAL.md
+234 -8
View File
@@ -1,7 +1,7 @@
# Projectstatus # Projectstatus
- Laatst bijgewerkt: 2026-07-22 - Laatst bijgewerkt: 2026-07-29
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames - Repositoryversie: 0.3.13 integrale professionalisering
- Uitvoeringsmodus: autonome backlog - Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next` - Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -14,11 +14,187 @@ De repository bevat een uitvoerbare Django-MVP met:
- JSON-LD-, HTML-, RSS/Atom- en e-mailadapters; - JSON-LD-, HTML-, RSS/Atom- en e-mailadapters;
- normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies; - normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies;
- deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary; - deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary;
- responsive dark/light cockpitdashboard, vacaturelijst/detail, sollicitatiepipeline, profiel-, bron- en systeemschermen; - volledige responsive dark/light Stitch-cockpit met negen functionele hoofdschermen, skillsradar, login en foutstates;
- feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox; - feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox;
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts; - Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
- offline unit-, integratie- en securitytests. - offline unit-, integratie- en securitytests.
## Laatste productiehardening
Productieoplevering 0.3.13 op 2026-07-29:
- basiscommit `5e2453c` en hardeningcommit `a4a71eb` naar `origin/main` gepusht en op Unraid werkelijk gebouwd als finale image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
- migrations, 148 static files, poort 1226, mounts, restartpolicy, PostgreSQL, Redis, Gunicorn, Celery worker/beat en readiness live gevalideerd, inclusief containerrestart;
- publieke HTTP→HTTPS, proxy/securityheaders, login-/gastflow, kernroutes, dark/light, 390/1280/3440 px en browserconsole live groen;
- productiecustomdump en media gehasht, naar een geïsoleerde database hersteld en via migrations, recordtellingen en afzonderlijke Gunicorn-readiness bewezen;
- vorige image `sha256:1851863be947b1e8b504e61e73b89d09c1dff704257b48856c775bb55f741459` live healthy teruggezet, waarna 0.3.13 opnieuw healthy is uitgerold;
- importprofiling overschreed opnieuw 9.000 queries; veilige optimalisatie vereist eerst een geïsoleerde benchmarkdatabase en blijft als performancevervolg gedocumenteerd.
VR-227 op 2026-07-29:
- globaal contentframe en topbar zijn gecentreerd tot ultrawide; de gastbanner staat in de normale hoofdflow en de mobiele pipeline is verticaal;
- vacaturepaginering is begrensd tot twintig resultaten, zoekscope is eerlijk en recente zoektermen blijven lokaal tot vijf items beperkt;
- Playwright Chromium is een verplichte CI-gate op zes viewports en faalt op focusverlies, overflow, console- en page-errors; de volledige gebruikersreis draait zonder skips;
- cachegebaseerde worker/beat-heartbeat vervangt de vroegere ongemeten runtimeclaim; onbekende queuediepte blijft expliciet onbekend;
- generieke HTML-extractie ondersteunt publicatiedatum en dienstverband; benchmarkkwaliteitsrun behaalt 12/12 velden, precision/recall 1,0 en nul rankingchurn;
- authbackenddekking bedraagt 97%, Entra 86% en totale branch-aware dekking 84,16%; volledige gate groen met 312 tests, 0 failures en 0 skips;
- CI genereert een CycloneDX-SBOM. Docker kon lokaal niet worden uitgevoerd omdat de binary ontbreekt; live en externe restpunten staan uitsluitend in `USER_INPUT_REQUIRED.md`.
Volgende uitvoerbare taak: geen na afronding van VR-227.
VR-225 op 2026-07-27:
- `PUBLIC_BASE_URL` stuurt publieke host-, origin- en HTTPS-instellingen aan en voorkomt de gemelde `DisallowedHost` achter Nginx Proxy Manager;
- publieke debugmodus, zwakke secrets, onveilige cookies, ontbrekende HTTPS-origin en een schrijfbare productiedemo worden fail-fast geweigerd;
- login- en importrate-limiting delen Redis over alle workers; forwarded client-IPs gelden alleen vanaf expliciet vertrouwde proxy-CIDRs;
- de gastdemo is server-side alleen-lezen, heeft een begrensde sessie en toont die beperking zichtbaar in de cockpit;
- interne healthchecks blijven HTTP-bruikbaar onder SSL redirect en readiness verifieert optioneel database plus cache;
- Unraid, publieke poort, env-pad, Nginx Proxy Manager en productie-env zijn als één reproduceerbaar deploymentcontract gedocumenteerd;
- de volledige offline suite is groen met 296 tests en 2 correcte Playwrightskips; Docker-, publieke TLS- en live Unraid-smokes blijven doelservervalidatie.
## Laatste frontendvervanging
VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
- één nieuwe lokale Stitch-shell en design system zonder CDN of clientframework;
- echte dashboard-/vacature-intelligence, sollicitatiepipeline, profielconfigurator en source-healthinterface;
- nieuwe activiteit- en werkgeversroutes/read models en een eerlijke automationcockpit;
- standaard IT-relevantie, persoonlijke scorecontext en user-scoped mutaties blijven intact;
- browsermatrix van 360 tot 2560 px, elf actuele screenshots, schone console en geen documentoverflow;
- bronhealth-N+1 verwijderd: twee queries voor alle bron/runmetingen, 23 totale queries voor het representatieve automationscherm.
Het volledige scherm- en afwijkingsrapport staat in `docs/design/STITCH_INTEGRATION_REPORT.md`.
## Laatste onderhoudsaudit
VR-226 op 2026-07-28:
- README-presentatie verdiept van vier losse beelden naar een klikbare productreis met negen actuele screenshots: overzicht, zoeken, analyse, skills, werkgevers, sollicitaties, bronnen, automatisering en mobiel profiel;
- alle desktopbeelden opnieuw vastgelegd op 1920×1080, doelbewust verdeeld over dark/light en gecombineerd met 390×844 mobiel; geen overgangsoverlays, horizontale overflow, secrets of consolewaarschuwingen;
- de onrustige mobiele dashboardcapture is afgekeurd en verwijderd; de volledige galerij blijft lokaal en samen circa 1,1 MB;
- vier aangetroffen VR-225-lintproblemen en de ongeldige POSIX-modusassertie op Windows zijn hersteld; volledige gate groen met 296 geslaagde tests, 2 optionele skips en 83,44% branch-aware dekking. Volgende uitvoerbare taak: geen.
VR-224 op 2026-07-27:
- README volledig herordend rond doelgroep, dagelijkse workflow, concrete productwaarde, veilige quickstart en eerste configuratie; architectuur, beheer en Codex-overdracht volgen pas daarna;
- vier actuele lokale screenshots toegevoegd voor dagoverzicht, vacatureverkenner, automatisering en mobiel zoekprofiel, samen minder dan 330 kB en zonder secrets of persoonlijke gegevens;
- browsercontrole op 1600×1000 en 390×844 in dark/light toont geen horizontale overflow of consolewaarschuwingen; repositorylinks en configuratiedocumentatie valideren;
- volledige gate groen met 268 geslaagde tests, 2 optionele skips en 83,48% branch-aware dekking. Volgende uitvoerbare taak: geen.
VR-223 op 2026-07-27:
- VacatureRadar-logo verfijnd tot een transparante vier-elementenradar zonder donkere tegel, rand of schaduw; login en sidebar delen dezelfde schaalbare SVG;
- dubbele demo-/accountidentiteit uit de sidebar verwijderd; radarstatus en een themabewuste transparante ITWorx-credit vormen één compacte footer, terwijl accountmenu en afmelden in de topbar intact blijven;
- widescreenframe groeit naar 2.200 px en op 4K naar 2.480 px; browsermatrix van 18 shellcombinaties op 390/1280/1920/2560/3840 px en 1280×600 toont geen overlap, overflow of consolewaarschuwing;
- dark/light en mobiele login bevestigen een transparante logoachtergrond, correcte wordmarkbehandeling en intacte kerninteracties.
- gerichte regressies: 30 tests groen; volledige gate tweemaal groen met 268 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- live image `sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921` healthy uitgerold; readiness, favicon, transparant productlogo en CSS 0.3.11 leveren HTTP 200. Volgende uitvoerbare taak: geen.
VR-222 op 2026-07-26:
- integrale visuele audit van tien hoofdroutes plus login/herstel vastgelegd in `docs/design/VISUAL_AUDIT_2026-07-26.md`, met geprioriteerde bevindingen, gevolgen en uitgevoerde roadmap;
- topbar en hoofdcontent delen één begrensd contentframe; het dagoverzicht toont maximaal vijf kernmatches en daalde op 1440 px van 2.399 naar 1.838 px;
- activiteitstijdlijn, ontwikkelaarscredit, secundaire klikdoelen en reduced-motion zijn verfijnd; CSS-imports gebruiken een gedeelde cacheversie zodat een release werkelijk alle stijllagen ververst;
- mobiele loginbotsing, foutieve oog-/demo-iconen en dubbele herstel-link zijn opgelost; VacatureRadar blijft productmerk en ITWorx.tech uitsluitend ontwikkelaarscredit;
- browsercontrole: 50 combinaties over tien routes en 390/768/1280/1920/3840 px, plus light-theme en login, zonder overflow of consolewaarschuwingen;
- volledige gate tweemaal groen met 268 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking; live image `sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70` healthy uitgerold en readiness, favicon, productlogo, ontwikkelaarswordmark plus CSS 0.3.9 leveren HTTP 200. Volgende uitvoerbare taak: geen.
VR-221 op 2026-07-26:
- merkarchitectuur gecorrigeerd: ITWorx.tech is de ontwikkelaar, VacatureRadar het zelfstandige productmerk;
- twee logo-concepten zijn met de ingebouwde beeldgeneratieflow verkend; de eenvoudigste radar-/kansrichting is handmatig vertaald naar een exact vijf-elementen-SVG met open cyan radarboog, scanstraal en emerald kanssignaal;
- sidebar, bovenbalk, login, favicon en Dockerman gebruiken het VacatureRadar-logo; de officiële ITWorx.tech-wordmark staat alleen als `Ontwikkeld door`-credit in secundaire zones;
- appmetadata heet opnieuw uitsluitend `VacatureRadar`; het ongebruikte ITWorx-appicoon is verwijderd en bron-/assetdocumentatie is bijgewerkt;
- browsermatrix op 390, 1280 en 1920 px toont correcte hiërarchie, geen overflow en een schone console; 29 gerichte regressies en de volledige gate zijn groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- live image `sha256:4184aeff33759b7a205637b0a0333b751b599141fe691a61965d2f1c56ca984a` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en favicon, productlogo, Dockermanicoon plus ITWorx-ontwikkelaarscredit live gevalideerd. Volgende uitvoerbare taak: geen.
VR-220 op 2026-07-26:
- “eigen branding” is gecorrigeerd naar de door de eigenaar aangeleverde officiële ITWorx.tech-identiteit in plaats van een tijdelijke tekstuele Jens-signatuur;
- sidebar, bovenbalk, login en wachtwoordherstel gebruiken de transparante opgeschoonde ITWorx.tech-wordmark; VacatureRadar blijft herkenbaar als product en intelligence cockpit;
- favicon/app-icon (512×512) en Dockermanicoon (256×256) zijn exact uit het officiële cloud-check-symbool gecropt en Lanczos geschaald, zonder generatieve wijziging;
- merkassets zijn lokaal, transparant, geoptimaliseerd en met herkomst vastgelegd in `static/img/README.md`;
- browsercontrole op 390, 1280 en 1920 px toont correcte assets, geen overflow en een schone console; 29 gerichte regressies en de volledige gate zijn groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- de deployhelper negeert nu veilig verwijderde indexpaden; de twee vervangen SVG-assets zijn expliciet uit de gevalideerde remote sourceboom verwijderd en de uiteindelijke image bevat 147 in plaats van 149 static files;
- live image `sha256:daf34407813b37cdf2d0fc65a482ded65375f89eedd3f0144a8c0ce206d41565` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en wordmark-, app-icon- en Dockermanassets leveren `image/png` met HTTP 200. Volgende uitvoerbare taak: geen.
VR-219 op 2026-07-26:
- de door gebruikersscreenshots aangetoonde ultrawide-uitlijningsfout is hersteld: directe hoofdsecties zijn niet langer afzonderlijk gecentreerd op 1860 px, maar delen één links verankerd grid tot 2800 px;
- op 3840 px beginnen heading, filters, explorer, systeemstatus, pipeline, KPI's en automationdetail op x=296; dezelfde gridbreedte schaalt zonder overflow naar 1920, 1280 en 390 px;
- de resultatenkolom en automationlograil hebben begrensde ultrawide-verhoudingen zodat primaire inhoud niet onnodig uitrekt;
- sidebar, bovenbalk, login en herstelpagina's gebruiken consistent `Personal intelligence by Jens` en `Jens Intelligence`, los van de accountgebruikersnaam;
- browsermatrix schoon zonder consolewaarschuwingen; gerichte regressies 23 tests groen en volledige gate groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- live image `sha256:86836f82cc543a7adb414e47b9667e9123bb9ceba10d629a53086273aaaae03f` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en de nieuwe branding plus 2800px-grid-CSS live gevalideerd. Volgende uitvoerbare taak: geen.
VR-218 op 2026-07-26:
- favicon, login en applicatieshell delen één verfijnd lokaal radarbeeldmerk met een sterker silhouet op klein formaat;
- de persoonlijke signatuur `Jens · private intelligence` is zichtbaar op publieke en ingelogde schermen en via `VACATURERADAR_OWNER_NAME` configureerbaar;
- de desktopbovenbalk toont eigenaar plus actuele routecontext; navigatie- en zoekfocus zijn verfijnd en de aanvullende context verdwijnt doelbewust op mobiel;
- gerichte regressies: Ruff groen, 29 tests geslaagd en 2 optionele skips; browsercontrole van dashboard, verkenner, profiel en login op 1280×850 en 390×844 zonder documentoverflow of consolewaarschuwingen;
- live image `sha256:5f25f00c9b7efb166587901ff39b02c56c98fb9946ddef0a4546a22f18dc0802` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en persoonlijke loginbranding plus SVG-favicon live gevalideerd;
- volledige gate groen met 266 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking; volgende uitvoerbare taak: geen, alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
VR-217 op 2026-07-26:
- de vermeende Unraid-authenticatiefout bleek een clientprofielkeuze: rechtstreeks IP gebruikte gebruiker `jens`, terwijl `unraid-itworx` correct `root`, `IdentitiesOnly` en de afzonderlijke deploysleutel selecteert;
- `scripts/deploy_unraid.sh` valideert de hostalias, synchroniseert uitsluitend niet-genegeerde projectbestanden en combineert build, containerhealth en readiness-smoke;
- het zoekprofiel heeft een lokale interactieve SVG-radar met scansweep, signalen, pointerparallax en statische reduced-motionvariant zonder fictieve data;
- browsercontrole op 1280×800 en 390×844 in licht en donker toont geen documentoverflow of consolefouten; de illustratie blijft decoratief en staat mobiel na de primaire actie;
- volledige gate groen met 265 tests, 2 optionele skips en 83,44% branch-aware dekking;
- live image `sha256:4d30e01e778777d5a2cde4339a9b2ad006edb25cae387293737420152d65069d` gebouwd en via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en de nieuwe CSS-/JS-assets via LAN HTTP 200;
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
VR-216 op 2026-07-26:
- premium “calm intelligence”-laag toegevoegd met zachtere scheidingen, spaarzamer cyan, humane navigatietypografie en duidelijkere elevatie in donker en licht thema;
- dashboard-KPI's staan mobiel compact 2×2, filters vormen een responsieve commandobar, pipelinekolommen scrollen intern en het profiel gebruikt een brede compositie;
- de browsermatrix controleerde 1280×800 en 390×844 zonder documentoverflow; dashboard, verkenner, pipeline, profiel en beide thema's zijn visueel beoordeeld;
- een bestaande CSP-consolefout op login is opgelost door de interactie naar een lokale externe JavaScriptasset te verplaatsen en regressiegedekt;
- gerichte verificatie: Ruff groen, 22 tests geslaagd en 2 optionele Playwright-skips; volledige gate groen met 264 tests, 2 optionele skips en 83,44% branch-aware dekking;
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
VR-215 op 2026-07-26:
- Dockermans eigen implementatie bleek `[IP]`/`[PORT]` uitsluitend voor WebUI-links
te vervangen en niet vóór een icoondownload; het eerdere label kon daardoor de
cache niet vullen;
- het live icoonlabel gebruikt nu de server-side loopback-URL op hostpoort 1226;
- de Unraid-host bevat zowel de persistente als actuele RAM-cache
`VacatureRadar-icon.png`, en Dockermans gegenereerde `docker.json` verwijst naar
`/state/plugins/dynamix.docker.manager/images/VacatureRadar-icon.png`;
- de gerecreate container is healthy en de PNG-/readiness-smokes slagen;
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en
uitgestelde productkeuzes resteren.
VR-214 op 2026-07-25:
- het ontbrekende Unraid/Dockerman-tegelicoon is hersteld met een lokale 256×256 PNG op
`/static/img/vacatureradar-docker.png`; de browserfavicon blijft de bestaande SVG;
- de volledige WebUI- en icoonlabels, PNG-signatuur en afmetingen zijn regressiegedekt;
- `collectstatic` kopieert de asset naar de productie-staticroot;
- de volledige `scripts/codex_verify.sh`-gate slaagde via Git Bash met 264 tests,
2 optionele Playwright-skips en 83,47% branch-aware codedekking;
- de Unraid-image is daarna live gebouwd als
`sha256:799825df1a343b6bee43695850454a5fdc977b5af3366077e6ac6aaeeb94f6f9`;
de gerecreate container was healthy, liveness/readiness slaagden en het
Dockerman-label wees naar de lokale PNG, die via LAN HTTP 200 met
`Content-Type: image/png` en 7.111 bytes leverde;
- er zijn tijdens de volledige lint-, systeem-, migratie-, test-, ledger- en
repositorycontrole geen andere releaseblokkerende regressies gevonden;
- volgende uitvoerbare taak: geen; alleen de bestaande externe blokkades en
uitgestelde productkeuzes resteren.
VR-209213-verificatie op 2026-07-22:
- 248 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,40% branch-aware codedekking;
- Ruff, Django system check, migratiecheck, backlogvalidatie en repository-/documentvalidatie geslaagd via `scripts/codex_verify.sh`;
- alle negen routes in de geïntegreerde browser gecontroleerd zonder consolewaarschuwingen, kapotte assets of documentoverflow;
- browsermatrix: 360×800, 390×844, 768×1024, 1024×768, 1280×800, 1440×900, 1920×1080 en 2560×1440;
- elf actuele beelden onder `artifacts/visual-validation/`; artifacts en Stitch-referenties blijven buiten de productieimage;
- volgende uitvoerbare taak: geen; alleen de bestaande externe credentials-/infrastructuurblokkades en uitgestelde productkeuzes resteren.
## Laatste geverifieerde baseline ## Laatste geverifieerde baseline
Release-audit op 2026-07-21: Release-audit op 2026-07-21:
@@ -30,6 +206,7 @@ Release-audit op 2026-07-21:
- backlog- en repositoryvalidatie geslaagd; - backlog- en repositoryvalidatie geslaagd;
- de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry; - de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry;
- releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole; - releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole;
- de productie-fetcher valideert het verbonden peer-IP, zodat legitieme CDN-DNS-rotatie bij DAF en Xplore Group niet als rebind wordt afgewezen terwijl private peers geblokkeerd blijven;
- een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd; - een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd;
- deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan. - deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan.
@@ -119,25 +296,75 @@ VR-126-herverificatie op 2026-07-22:
VR-201/VR-205-herverificatie op 2026-07-22: VR-201/VR-205-herverificatie op 2026-07-22:
- 215 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd; - 218 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,42% branch-aware codedekking; - 84,46% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`; - Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166; - GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166;
- Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus; - Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus;
- twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben; - twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben;
- Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten; - Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten;
- de Unraid-deploy gebruikt expliciet Celery-queue `default`, overeenkomstig de queues die de productie-worker consumeert;
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`. - taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
VR-206-herverificatie op 2026-07-22:
- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
VR-207-herverificatie op 2026-07-22:
- 232 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,59% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- het profiel bevat gegroepeerde field-/infrastructuur-, Microsoft/endpoint-, netwerk/security-, back-up/monitoring-, virtualisatie-, VoIP- en scriptingkeuzes plus een ervaringsband;
- de skillcomponent bereikt zijn maximum bij vier aangetroffen cv-skills en presenteert niet-vermelde voorkeurskills niet meer als tekort;
- ambigue field-/implementatietitels vereisen een onafhankelijk IT-contextsignaal en titeluitsluitingen respecteren woordgrenzen en eenvoudige Engelse meervouden;
- de lokale actieve auditprofielen zijn zonder persoonsgegevens in Git afgestemd op 1014 jaar ervaring, 27 relevante roltitels en 46 cv-skills; na herscoring blijven 14 plausibele field-/infrastructuurkansen zichtbaar;
- geïntegreerde browsercontrole bevestigde de categorie-editor op ultrawide en 390 px, één mobiele categoriekolom, geldige profielselecties en geen horizontale paginaoverflow.
VR-208-herverificatie op 2026-07-22:
- 240 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,73% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- ervaringsduur en senioriteitslabels hebben geen invloed meer op deterministische score, AI-score, aanbeveling, uitsluiting of feedbacklearning; migratie 0006 ruimt het oude gewicht op;
- de persoonlijke Skillsradar analyseert alleen actieve IT-vacatures met een niet-verborgen score voor de actuele profielversie en laat records van uitsluitend uitgeschakelde bronnen buiten beschouwing;
- de lokale radar toont 9 onderzochte vacatures, 5 herkenbare vraagsignalen, 2 gedekte skills en 3 mogelijke leerkansen zonder een tekstsignaal als harde eis te presenteren;
- geïntegreerde browsercontrole bevestigde filters, 88 profielskillcheckboxes, één `h1`, lege console en geen horizontale paginaoverflow op 2560×1440, 1440×900 en 390×844.
## Deploymentstatus ## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht. - `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` met PostgreSQL, Redis, web, worker en scheduler. - De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd. - De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
- De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie. - De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie.
- De huidige uitrol is alleen geschikt voor het beheerde LAN. Publieke productie vereist eerst een gekozen domein, reverse proxy/TLS en `DJANGO_DEBUG=0` met secure cookies. - Deze historische LAN-beperking is op 2026-07-29 opgeheven: het publieke domein, reverse proxy/TLS, `DJANGO_DEBUG=0` en secure cookies zijn live gevalideerd.
## Laatste uitgevoerde backlogtaak ## Laatste uitgevoerde backlogtaak
- 2026-07-22: `VR-208` voltooid.
- Ervaringsjaren zijn alleen nog profielcontext; alle score-, recommendation-, concern-, evidence- en learninginvloed is verwijderd.
- Een gebruikersgescopeerde vraaganalyse brengt actuele skillfrequentie, bewijskwaliteit, profieldekking en niet-blokkerende leer-/bijscholingskansen in kaart.
- De profielcatalogus en nieuwe responsieve Skillsradar ondersteunen Microsoft, netwerk/security, monitoring/platform, ITSM en digitale adoptie met begeleide filters.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-207` voltooid.
- De twee aangeleverde cv's zijn visueel en tekstueel lokaal beoordeeld; alleen afgeleide technische profielkeuzes zijn in de lokale gebruikersdatabase opgeslagen.
- Relevante ervaring, rollen, skills en doelgerichte uitsluitingen zijn toegepast en alle 336 actieve vacatures zijn opnieuw gescoord voor beide lokale auditprofielen.
- De profiel- en scorelogica is regressiegedekt; afwezige cv-skills verlagen een breed profiel niet onbeperkt en worden niet als bewezen tekort getoond.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-206` voltooid.
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
- De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
- Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-201` en `VR-205` voltooid. - 2026-07-22: `VR-201` en `VR-205` voltooid.
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd. - De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief. - Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
@@ -319,7 +546,6 @@ Deze blokkeren onafhankelijke code niet:
- Unraid-domein, DNS, TLS en reverse proxy; - Unraid-domein, DNS, TLS en reverse proxy;
- containerregistry en credentials; - containerregistry en credentials;
- actieve/voor deze repository geregistreerde Gitea Actions-runner; - actieve/voor deze repository geregistreerde Gitea Actions-runner;
- keuze/verkrijging van een gelicenseerde actuele Belgische postcode-/gemeentedataset.
## Bekende verificatiebeperking van de aangeleverde basis ## Bekende verificatiebeperking van de aangeleverde basis
+9 -6
View File
@@ -24,6 +24,7 @@ Ondersteund of gepland:
- uitgesloten regio/gemeente; - uitgesloten regio/gemeente;
- afstand boven maximum, behalve remote; - afstand boven maximum, behalve remote;
- uitgesloten verplichte skill; - uitgesloten verplichte skill;
- een titelgestuurde IT-grens; ambigue field-/implementatietitels vereisen daarnaast een sterk IT-contextsignaal in de vacaturetekst;
- expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd; - expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd;
- verlopen of niet-actieve vacature buiten de ranking. - verlopen of niet-actieve vacature buiten de ranking.
@@ -35,15 +36,16 @@ Standaardgewichten, totaal 100:
| Component | Gewicht | Kernsignalen | | Component | Gewicht | Kernsignalen |
|---|---:|---| |---|---:|---|
| Content | 25 | titelovereenkomst, taken, weinig ongewenste support | | Content | 30 | titelovereenkomst, taken, weinig ongewenste support |
| Skills | 20 | gewenste skills in expliciete velden/tekst | | Skills | 25 | gewenste skills in expliciete velden/tekst |
| Locatie | 15 | afstand, remote/hybrid, voorkeursregio | | Locatie | 20 | afstand, remote/hybrid, voorkeursregio |
| Voorwaarden | 10 | toegestane contract-/werkvorm | | Voorwaarden | 10 | toegestane contract-/werkvorm |
| Werkgever | 10 | directe bron versus recruiter | | Werkgever | 10 | directe bron versus recruiter |
| Senioriteit | 10 | gevraagde ervaring versus profiel | | Voorkeuren | 5 | publieke sector en andere soft signals |
| Voorkeuren | 10 | publieke sector en andere soft signals |
Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som. Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som.
- De profielervaring is uitsluitend informatieve context. Gevraagde ervaringsjaren en senioriteitslabels veranderen de score, aanbeveling en uitsluitingen nooit.
- Een breed cv-profiel wordt niet bestraft omdat veel relevante skills zijn geselecteerd: maximaal vier aangetroffen profielskills volstaan voor de volledige skillcomponent. Niet-aangetroffen voorkeurskills blijven evidence, maar worden niet als bewezen tekort of concern gepresenteerd.
- AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0. - AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0.
- De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden. - De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden.
@@ -91,6 +93,7 @@ Een score bewaart:
Copyregels: Copyregels:
- zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is; - zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is;
- presenteer niet-vermelde cv-skills niet als tekort; alleen expliciet aangetroffen skills zijn positieve evidence;
- label inference als inference; - label inference als inference;
- toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar; - toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar;
- geef geen kanspercentage op aanwerving zonder gevalideerd model. - geef geen kanspercentage op aanwerving zonder gevalideerd model.
@@ -119,7 +122,7 @@ Offline evaluatie is beschikbaar via `scripts/feedback_learning_report.py` met f
Toegestaan: Toegestaan:
- support-/consultancy-/travelratio schatten; - support-/consultancy-/travelratio schatten;
- senioritylabel; - senioritylabel als informatieve extractie, zonder score-invloed;
- korte Nederlandse samenvatting; - korte Nederlandse samenvatting;
- evidencefragmenten selecteren; - evidencefragmenten selecteren;
- ambiguïteitswarnings. - ambiguïteitswarnings.
+46
View File
@@ -0,0 +1,46 @@
# Professionalisering baseline
- Datum: 2026-07-29
- Branch: `main`
- Versie: 0.3.12
- Opdracht: volledige autonome professionalisering van VacatureRadar
## Architectuur
VacatureRadar is een Django 5.2 server-rendered modulaire monoliet op Python 3.13. PostgreSQL is de productiedatabase; Redis ondersteunt cache, gedeelde rate limiting en Celery. Een afzonderlijke worker en scheduler verwerken bronruns, lifecycle en notificaties. Externe bronnen lopen via policy-, robots-, TLS- en SSRF-validatie en adapters leveren genormaliseerde extractieresultaten zonder databasewrites.
De frontend bestaat uit Django-templates, lokale CSS en beperkte lokale JavaScript. Er is geen clientframework of externe runtimeasset. Authenticatie ondersteunt lokale accounts, een optionele Entra-boundary en een server-side alleen-lezen gastdemo.
## Baselinevalidatie
`./scripts/codex_verify.sh` start schoon: Ruff, Django system check en migratiecheck slagen. De bestaande suite bevat 298 tests. In de laatst volledig afgeronde gate slaagden 296 tests en werden twee browsercases overgeslagen omdat Playwright lokaal niet geïnstalleerd was; branch-aware dekking bedroeg 83,44%.
CI voert lint, tests, secret scanning, dependency scanning, een productie-imagebuild en containerscan uit. De browserdependency wordt wel gesynchroniseerd, maar Chromium wordt nog niet geïnstalleerd en de E2E-test staat omgevingsafhankelijke skips toe.
## Werkende onderdelen
- Negen functionele hoofdschermen, login en wachtwoordherstel.
- Deterministische relevantie, scoring, dedupe en lifecycle vóór optionele AI.
- Read-only gastdemo met server-side mutatiebescherming.
- Bronbeleid, sanitization, SSRF-, TLS- en redirectbescherming.
- Docker-, Unraid-, reverse-proxy-, health-, back-up- en restorevoorzieningen.
- Dark/light theme, toetsenbordfocus, reduced-motionbasis en mobiele hoofdnavigatie.
## Belangrijkste risico's bij start
1. De premium CSS overschrijft het gecentreerde layoutcontract met links verankerde content. Op 3440 px blijft ongeveer 900 px ongebruikte ruimte rechts.
2. De gastbanner begint mobiel boven de vaste topbar en is daardoor gedeeltelijk onzichtbaar.
3. Verplichte browser- en accessibilitytests kunnen stilzwijgend skippen.
4. De vacatureverkenner rendert een zeer lange resultaatpagina; selectie- en scrollcontext zijn beperkt.
5. Bronnen en automatisering tonen veel operationele controls en informatie tegelijk.
6. Kritieke dekking is relatief laag in auth, Entra, core views en task orchestration.
7. Enkele modules zijn groter dan de repositoryrichtlijn en combineren meerdere verantwoordelijkheden.
8. Live TLS-, reverse-proxy-, worker-, scheduler-, back-up- en restoreacceptatie blijft doelomgevingafhankelijk.
## Eerste uitvoeringsvolgorde
1. Responsive layout en gastbanner.
2. Niet-skipbare browser-/accessibilitymatrix.
3. Dagelijkse kernflows en taakgerichte schermcomposities.
4. Kritieke tests, observability, container/security en herstelvalidatie.
5. Integrale eindacceptatie en live restpunten.
+18
View File
@@ -0,0 +1,18 @@
# Finale acceptance 0.3.13
Status: productie geaccepteerd op 2026-07-29.
## Bewijs
- `./scripts/codex_verify.sh`: 312 geslaagd, 0 mislukt, 0 overgeslagen, 84,16% branch-aware dekking.
- Benchmarkkwaliteit: 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en rankingchurn 0,0.
- Unraid Docker 27.5.1 en Compose 2.40.3 bouwden image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`; migrations, 148 static files, manifestgeneratie, healthcheck en restart slaagden.
- HTTP redirect naar HTTPS, OpenResty/TLS, HSTS/securityheaders, `DEBUG=False`, secure session-/CSRF-cookies en geen `DisallowedHost` zijn publiek bewezen.
- Login en gastknop, alleen-lezen gasttoegang, dashboard, zoek/filterlijst, detail, pipeline, bronnen en automatisering werkten live.
- Dark/light en 390/1280/3440 px hadden nul horizontale overflow en nul browserconsole-errors; de mobiele gastbanner bleef in de flow.
- PostgreSQL custom dump plus media/configbewijs is naar een geïsoleerde database hersteld; migrations, exacte recordtellingen en een aparte applicatiestart slaagden.
- Vorige image `sha256:1851863be947b1e8b504e61e73b89d09c1dff704257b48856c775bb55f741459` is live healthy gestart en daarna opnieuw vervangen door 0.3.13, eveneens healthy.
## Uitkomst
Er bestaan geen bekende P0- of P1-releaseblokkers. Alleen optionele Entra- en mailboxcredentials blijven extern. De importqueryoptimalisatie blijft een meetbaar performancevervolg en geen correctness- of releaseblokker.
+14
View File
@@ -0,0 +1,14 @@
# Importperformancebaseline 0.3.13
Gemeten op 2026-07-29 in een disposable PostgreSQL-database op Unraid (Linux 6.12.54, Python 3.13.14, 20 CPU's). De vaste fixture bevat 250 geldige HTML-documenten met dezelfde externe vacature-identiteit: één create en 249 idempotente duplicate-updates. Concurrency is bewust 1; netwerk, Celery, Ollama en mail zijn uit de meting gehouden.
| Metriek | 0.3.13 |
|---|---:|
| Importduur | 16.358,158 ms |
| p50 per document | 63,034 ms |
| p95 per document | 79,871 ms |
| Throughput | 15,283 documenten/s |
| Importqueries | >9.000 (loggerlimiet bereikt) |
| Piekgeheugen | 26,582 MB |
Correctheidsankers: parserdekking 12/12, dedupeprecision 1,0, deduperecall 1,0 en rankingchurn 0. De overschrijding van 9.000 queries is als ondergrens gerapporteerd, niet als exact getal.
+14
View File
@@ -0,0 +1,14 @@
# Finale importperformancemeting 0.3.14
De officiële runner gebruikt een database met verplichte prefix `vacatureradar_bench_`, weigert SQLite, externe databasehosts en publieke productiedomeinen, migreert en flusht de disposable database, voert één warm-up uit en meet daarna drie iteraties van 250 documenten. Het machineleesbare bewijs staat in `artifacts/import-performance-report.json`.
| Metriek | 0.3.13 | 0.3.14 gemiddeld | Verbetering |
|---|---:|---:|---:|
| Importduur | 16.358,158 ms | 9.040,123 ms | 44,74% lager |
| p50 | 63,034 ms | 32,900 ms | 47,80% lager |
| p95 | 79,871 ms | 52,296 ms | 34,53% lager |
| Throughput | 15,283/s | 28,031/s | 83,42% hoger |
| Importqueries | >9.000 | 3.264 | minstens 63,73% lager |
| Piekgeheugen | 26,582 MB | 18,799 MB | 29,28% lager |
Alle drie iteraties zijn gelijk voor queryvolume en queryfamilies. De kwaliteitsankers bleven parser 12/12, precision 1,0, recall 1,0 en rankingchurn 0. De fixture resulteert per iteratie in één vacature, één versie, behouden veldprovenance, 250 historische ScoreRuns en 249 idempotente duplicates.
@@ -0,0 +1,10 @@
# Importperformancetraceability
| Acceptatie | Implementatie | Bewijs |
|---|---|---|
| Veilige isolatie | `scripts/benchmark_import.py`, `scripts/run_import_benchmark.sh` | negatieve identitytests en disposable PostgreSQL-run |
| Meetbare reductie | run-local caches, gebundelde provenance en score-snapshotcopy | baseline/finaal rapport en machine-JSON |
| Geen functionele regressie | bestaande dedupe/scoringregels ongewijzigd | pipeline-, dedupe- en benchmarktests |
| Historiek en provenance | scorecopy per import, evidence bulk sync | integratietest met 20 herhaalde documenten |
| Queryregressie voorkomen | execute-wrapper en budgettest | stabiel 3.264 queries per 250; minder dan 300 per 20 in test |
| Releasekwaliteit | VR-228, gates, acceptance en operationsdocs | `docs/ai/PROJECT_STATE.md` en `FINAL_ACCEPTANCE.md` |
+12
View File
@@ -0,0 +1,12 @@
# Importqueryanalyse 0.3.14
De hot path liep van `process_raw_document` via parsing en `persist_draft` naar employer-resolutie, deduplicatie, alias/provenance, versiebeheer en scoring. Profiling wees vier dominante oorzaken aan:
1. Dezelfde werkgever en actieve profielen werden voor elk document opnieuw opgehaald.
2. Veldprovenance gebruikte per veld een afzonderlijke `update_or_create`.
3. Een inhoudelijk ongewijzigde waarneming schreef opnieuw een versie en berekende alle scores volledig.
4. `last_changed` verschoof ten onrechte bij alleen een nieuwe `last_seen`-waarneming.
De oplossing blijft binnen de modulaire monoliet en transacties: een import-run krijgt een lokale `PersistenceContext`, provenance wordt per alias gelezen en gebundeld geschreven, versies ontstaan alleen bij inhoudelijke wijziging en een ongewijzigde job kopieert het laatste ongewijzigde scoresnapshot. Daardoor blijft iedere `ScoreRun` historisch aanwezig, terwijl deterministische scoring niet opnieuw wordt uitgevoerd. De cache leeft nooit buiten één seriële import-run en verandert workerisolatie of deduplicatieregels niet.
De database-executieteller rapporteert per 250 items exact: 1.256 SELECT, 1.003 INSERT, 499 UPDATE, 253 SAVEPOINT en 253 RELEASE; totaal 3.264 importexecuties. De drie officiële iteraties geven identieke queryfamilies.
+34
View File
@@ -0,0 +1,34 @@
# Performanceaudit 2026-07-29
## Importoptimalisatie 0.3.14
De eerder geregistreerde importhotspot is opgelost en reproduceerbaar gemeten in een disposable PostgreSQL-database. Tegenover de 0.3.13-baseline daalt de gemiddelde 250-itemimport van 16.358,158 naar 9.040,123 ms; p95 daalt van 79,871 naar 52,296 ms en queryvolume van meer dan 9.000 naar exact 3.264. Zie `IMPORT_PERFORMANCE_BASELINE.md`, `IMPORT_QUERY_ANALYSIS.md` en `IMPORT_PERFORMANCE_FINAL.md`.
## Baseline
- Lokale quickbenchmark: 250 invoeritems op Windows 11, Python 3.13, 16 CPU-threads.
- Import: p50 348,879 ms; p95 825,107 ms; totaal 125.982,74 ms.
- Dashboardlijst: p95 1.128,986 ms in de synthetische benchmark.
- Detail: p95 101,754 ms.
- Rescore: 53,284 ms voor de overgebleven unieke job in deze quickrun.
- Piekgeheugen: 105,588 MB.
- Frontend: lokale CSS en JavaScript, zonder framework of externe runtimeassets.
De eerste run ontdekte een echte benchmarkfout: de generieke HTML-fixture leverde slechts vier van zes verwachte velden. De adapter en fixture ondersteunen nu ook gelabelde publicatiedatum en dienstverband. De herhaalde kwaliteitsrun behaalt 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en nul rankingchurn.
## Wijzigingen
- Vacaturepaginering verlaagd van 30 naar 20 kaarten per pagina.
- Ultrawideframe gecentreerd zonder extra clientcode.
- Mobiele pipeline gebruikt een verticale grid en veroorzaakt geen horizontale documentoverflow.
- Runtimeheartbeat gebruikt de bestaande cache en voegt geen netwerkprobe aan paginarendering toe.
## Resterende risico's
- De synthetische quickimport overschrijdt de historische NFR-007-p95-doelwaarde; profiling van het volledige 25.000-itempad blijft zinvol vóór opschaling.
- De quickbenchmark meldt meer dan 7.900 databasequeries voor 250 importpogingen. Correctheid en idempotentie zijn goed, maar bulkimport is de eerstvolgende optimalisatiekandidaat.
- Queuediepte is met de generieke Django-cachebackend niet betrouwbaar meetbaar en wordt daarom eerlijk als onbekend getoond.
## Productiereleaseprofiling
De releaseherhaling overschreed opnieuw Django's queryloglimiet van 9.000 queries voordat de 250-itemrun afrondde. Een aansluitende herhaalrun produceerde door achtergebleven benchmarkidentiteiten geen nieuwe vacatures; database-isolatie via de lokale `.env` activeerde vervolgens terecht de productiedriver. Daardoor is geen betrouwbare nawaarde beschikbaar en is geen risicovolle pipelinebulkrefactor in de productieoplevering opgenomen. Vervolg: geef de benchmark een expliciete tijdelijke database en profileer `process_extraction_batch` per queryfamilie; behoud idempotentie, dedupeprecision/recall 1,0 en nul rankingchurn.
+19
View File
@@ -0,0 +1,19 @@
# Professionaliseringstraceability
| Roadmaponderdeel | Implementatie | Bewijs | Status |
|---|---|---|---|
| Responsive frame | `premium.css`, `layout.css` | zes Playwrightviewports, overflowassertie | implemented and verified |
| Mobiele gastbanner | `base.html`, `components.css` | integratietest en mobiele E2E | implemented and verified |
| Sticky/mobiele pipeline | `responsive.css`, `premium.css` | mobiele kernreis | implemented and verified |
| Verplichte browsergate | CI, E2E-suite | Chromiuminstallatie, console/page-errorchecks | implemented and verified |
| Vacatureflow | `JobListView`, `jobs/list.html` | view- en E2E-tests | implemented and verified |
| Zoekscope en recente termen | `base.html`, `app.js` | lokale, begrensde browseropslag | implemented and verified |
| Democontrols | read-only middleware en responsive CSS | auth- en E2E-tests | implemented and verified |
| Runtimeobservability | `apps/core/tasks.py`, automationservice | runtime-observabilitytests | implemented and verified |
| Parser/performance | generic HTML-adapter en benchmarkfixture | benchmark 12/12, precision/recall 1,0 | implemented and verified |
| Container/security | bestaande Docker/CI-hardening | live imagebuild, migrations, restart en readiness | implemented and live verified |
| Live TLS/proxy | productieconfiguratie en OpenResty | HTTP→HTTPS, HSTS, securityheaders en browserflow | implemented and live verified |
| Back-up/restore | custom dump, media en runbook | geïsoleerde restore, exacte tellingen en aparte applicatiestart | implemented and live verified |
| Rollback | immutable vorige en huidige imagetags | oude image healthy, daarna 0.3.13 opnieuw healthy | implemented and live verified |
| Externe identity/mail | versleutelde configuratieboundary | `USER_INPUT_REQUIRED.md` | optional external action |
| Importperformance 0.3.14 | run-lokale caches, evidence bulk sync en scorecopy | geïsoleerde 3-run PostgreSQL-meting en regressietests | implemented and verified |
+28 -59
View File
@@ -1,73 +1,42 @@
# Toegankelijkheid # Toegankelijkheid
Doel: WCAG 2.2 AA voor de kernflows. Doel: WCAG 2.2 AA voor alle kernflows van de server-rendered Stitch-cockpit.
## Semantiek ## Productiecontract
- één `h1` per pagina, hiërarchische headings; - Skiplink, `aside`/`nav`, `header`, één `main` en één `h1` per pagina.
- navigatie in `<nav>` met duidelijke accessible name; - Headingniveaus volgen de visuele hiërarchie; tabulaire data gebruikt echte headers.
- lijsten/tabellen voor werkelijke lijst-/tabulaire data; - Formulieren hebben expliciete labels, hints, gekoppelde errors en servervalidatie.
- formulieren met expliciete `<label>`; - Status gebruikt tekst naast kleur; score en datakwaliteit zijn afzonderlijk benoemd.
- statusmetadata als definition list waar passend; - Icon-only controls hebben een toegankelijke naam; lokale decoratieve SVG's zijn verborgen.
- externe links herkenbaar in tekst/accessible label. - Externe vacaturetekst is gesanitized; links gebruiken veilige attributen en geen verborgen automatische actie.
## Toetsenbord ## Toetsenbord en progressive enhancement
Alle functies moeten zonder muis werken. Focusvolgorde volgt visuele volgorde. `:focus-visible` heeft een contrastvolle ring. Geen keyboard traps. Na server-POST landt focus logisch via heading/message; toekomstige modals herstellen focus. Alle routes, filters, feedback, dossiers, statuswijzigingen en profiel-/bronforms werken zonder JavaScript. JavaScript verbetert uitsluitend drawer, disclosure, viewtoggle, zoekfocus en thema. De mobiele drawer beheert `aria-expanded`, focus en sluitactie. Focus is zichtbaar; er zijn geen tijdslimieten of keyboard traps. `prefers-reduced-motion` reduceert alle transities.
## Kleur en contrast ## Contrast, zoom en reflow
- bodytekst minimaal 4,5:1; De donkere en lichte tokensets bewaren tekst-, focus- en componentcontrast. Metadata is gedempt maar niet de enige informatiedrager. Content heeft geen vaste teksthoogte. Op 360 px en bij 200% zoom blijft het document binnen het viewport; alleen intrinsiek brede pipeline-/tabeldata gebruikt een gelabelde interne scroller.
- grote tekst minimaal 3:1;
- focus en componentgrenzen minimaal 3:1 waar vereist;
- status nooit alleen via kleur;
- light/dark afzonderlijk toetsen.
## Zoom en reflow ## Browservalidatie VR-213
- bruikbaar op 320 CSS px breed; Op 22 juli 2026 zijn de negen hoofdervaringen met echte lokale data gecontroleerd:
- 200% zoom zonder verlies van acties/tekst;
- geen vaste hoogte voor tekstkaarten;
- lange URL's en woorden breken;
- horizontale scroll alleen voor intrinsiek brede data met alternatief.
## Forms en fouten - viewports: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
- aanvullende widescreencontrole: 1920×1080 en 2560×1440;
- op iedere gecontroleerde route: één `h1`, skiplink, hoofdlandmark, geen kapotte images en geen documentbrede overflow;
- mobiele sidebar is gesloten buiten gebruik, opent met `aria-expanded=true` en sluit terug naar `false`;
- dark/light wisselt met de correcte actienaam; de initiële naam volgt nu ook een opgeslagen voorkeur;
- pipeline en automationtabellen scrollen lokaal zonder de pagina te verbreden;
- browserconsole bevatte geen errors of warnings.
- foutsummary bovenaan bij meerdere fouten; De elf bewijsscreenshots staan onder `artifacts/visual-validation/`. De optionele pytest-Playwrightvariant blijft lokaal overgeslagen wanneer Playwright niet geïnstalleerd is; de HTML/a11y-probe en de geïntegreerde Codex-browseraudit vormen samen het uitgevoerde bewijs.
- inline fout bij veld;
- `aria-invalid` en `aria-describedby`;
- voorbeeld en vereiste syntax als hint;
- geen placeholder als enige label;
- validatie niet alleen on blur.
## Dynamiek ## Handmatige reviewchecklist
- server-rendering heeft geen verborgen loading state; 1. Tabvolgorde en zichtbare focus op dashboard, detail, profiel en pipeline.
- async updates gebruiken live regions spaarzaam; 2. Screenreader-smoke voor shelllandmarks, scorelabels en formuliererrors.
- successmessages niet alleen tijdelijk visueel; 3. Dark/light contrast en status zonder kleur.
- motion respecteert reduced motion; 4. 200% zoom, lange titels, lege/unknown states en 360 px reflow.
- tijdslimieten bestaan niet in gebruikersflows. 5. Fout/success na POST en terugkeer naar een logisch heading- of messagepunt.
## Vacaturetekst
Gesanitized HTML behoudt headings, paragraphs, lists en links waar veilig. Verwijder scripts, forms, iframes, eventhandlers en gevaarlijke URL-schema's. Voeg waar nodig `rel="noopener noreferrer"` toe aan externe links.
## Testen
Per zichtbare taak:
1. keyboard-only;
2. browser zoom/reflow;
3. automatische HTML/accessibilityscan waar beschikbaar;
4. screenreader smoke test voor dashboard, vacaturedetail en profiel;
5. contrastcontrole;
6. test met lange/lege/unknown content.
## VR-118 browservalidatie
- Dashboard gecontroleerd op 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800.
- Op alle zes viewports: exact één `h1`, geen documentbrede horizontale overflow en de juiste desktop/mobile navigatiestatus.
- Mobiel menu opent met `aria-expanded=true`, sluit met Escape/sluitactie en is buiten de focus-/accessibilityflow wanneer gesloten.
- Themawisselaar blijft bruikbaar wanneer browseropslag niet beschikbaar is; de UI geeft dan geen consolefout.
- Dashboard, vacaturelijst/detail, sollicitatiepipeline, profielen/editor, bronnen en systeem zijn met echte seeded data en zonder externe assets gevalideerd.
- Screenshots staan onder `docs/design/validation/` en zijn via `.dockerignore` uitgesloten van de productiecontainer.
+42 -113
View File
@@ -1,129 +1,58 @@
# Design system # VacatureRadar design system
> Geactualiseerd voor VR-118. De volledige Stitch-mapping en bewuste afwijkingen staan in Dit document beschrijft de enige normale productiefrontend vanaf versie 0.3.0. De visuele bron van waarheid is de Stitch Intelligence Cockpit; het bindende contract en de schermmapping staan in `STITCH_FRONTEND_CONTRACT.md` en `STITCH_INTEGRATION_REPORT.md`.
> `docs/design/STITCH_INTEGRATION_PLAN.md`.
## 1. Merkgevoel ## Visuele identiteit
Rustig, betrouwbaar, helder en technisch precies. De interface voelt als een professionele intelligence-cockpit: De standaardervaring is een donkere, technische navycockpit. Signal Cyan markeert focus en primaire acties, Emerald aantoonbaar positieve of gezonde status, Intelligence Indigo automatisering en Amber/Coral aandacht of fout. Tonale oppervlakken en fijne borders vormen de hiërarchie; glows zijn beperkt tot focus en sterke signalen.
donker marineblauw als standaard, signaalcyaan voor primaire interactie en tonale lagen in plaats van zware schaduwen.
De lichte variant behoudt dezelfde hiërarchie. Geen confetti, fictieve grafieken, eindeloze badges of agressieve notificatiekleuren.
## 2. Visuele tokens De productietokens staan uitsluitend in `static/css/tokens.css`:
De implementatie staat in `static/css/app.css`. Gebruik semantische custom properties in plaats van losse kleuren in componenten. - oppervlakken: `--surface-lowest`, `--surface`, `--surface-low`, `--surface-container`, `--surface-high`, `--surface-highest`, `--surface-bright`;
- tekst: `--text`, `--text-strong`, `--text-muted`, `--text-faint`;
- signalen: `--cyan`, `--cyan-dim`, `--emerald`, `--indigo`, `--amber`, `--coral`, `--danger`;
- structuur: `--outline`, `--outline-strong`, `--sidebar-width`, `--topbar-height`, `--content-gutter`, radii en `--shadow-signal`.
Geïmplementeerde tokenrollen omvatten onder meer: De light variant gebruikt dezelfde semantische tokens en informatiehiërarchie. Component-CSS bevat geen eigen hardcoded palette; uitzonderingen zijn alleen documentmetadata zoals `theme-color` en e-mailtemplates buiten de cockpit.
```css ## Typografie en iconen
--bg
--bg-deep
--surface
--surface-low
--surface-high
--surface-bright
--text
--text-soft
--muted
--border
--border-strong
--primary
--primary-strong
--positive
--warning
--danger
--signal-glow
--radius-sm
--radius
```
Donkere modus is de productstandaard. De gebruiker kan lokaal wisselen via `data-theme`; opslag is progressief en - Headlines: lokale systeemstack die Geist benadert (`Segoe UI Variable Display`, Aptos Display, Segoe UI).
mag in een afgeschermde browsercontext uitvallen zonder de bediening te breken. - Body: `Segoe UI Variable Text`, Aptos en systeemfallbacks.
- Technische metadata: Cascadia Mono, SFMono/Consolas.
- Geen externe font- of iconrequest; iconen komen uit één lokale inline SVG-sprite.
- Score, datum en metadata blijven tekstueel leesbaar en steunen nooit alleen op kleur of vorm.
## 3. Typografie ## Layoutsysteem
- systeemfontstack, geen externe fontrequest; - Vaste technische sidebar van 256 px en contexttopbar op desktop.
- basis 16px; - Flexibel datacanvas met een maximum van 2240 px; op 1920/2560 px benutten feeds en intelligencepanelen de extra breedte.
- body line-height ongeveer 1,55; - Bij 1180 px stapelen de zwaarste rails; bij 820 px wordt de sidebar een drawer met mobiele tabbar; bij 560 px worden cards, forms en intelligenceblokken éénkoloms.
- compacte metadata 0,80,9rem maar nooit onder bruikbare leesgrootte; - Kanban, pipeline en datatabellen mogen alleen binnen een gelabelde lokale container horizontaal scrollen. Het document zelf heeft geen horizontale overflow.
- titels met duidelijke schaal, niet alleen gewicht; - Sticky actions houden rekening met de mobiele tabbar en bedekken geen essentiële informatie.
- cijfers voor scores mogen tabular nums gebruiken.
## 4. Spacing en layout ## Kerncomponenten
- 4/8px-gebaseerde schaal; - `cockpit-sidebar`, `cockpit-topbar`, `mobile-tabbar`: globale shell en navigatie.
- contentcontainer maximaal 1600px zodat de cockpit op brede schermen bruikbaar blijft; - `cockpit-heading`, `section-heading`, `eyebrow`, `section-kicker`: informatiehiërarchie.
- kaarten met consistente padding 2024px desktop, 16px mobiel; - `stat-card`, `panel`, `tonal-panel`, `result-card`: tonale contentlagen.
- minimaal 44×44px touch targets voor primaire interactie; - `pill-*`, `status-beacon`: status met zichtbare tekst naast kleur.
- vaste zijbalk van 272px boven 900px, toegankelijke uitschuifnavigatie daaronder; - `intelligence-score`, `hero-score`, `score-components`: match en datakwaliteit als afzonderlijke begrippen.
- grids breken onder 1180px, 900px en 680px logisch af; - `filter-console`, `active-filters`: server-side filters en deelbare querystrings.
- vaste/sticky elementen mogen content niet bedekken. - `pipeline-board`, `activity-timeline`, `data-table-wrap`: taakgerichte datapatronen met toegankelijke fallback.
- `form-section`, `choice-field`, `profile-preview`: echte Django-formulieren met begeleide selecties en server-rendered bewijs.
- `empty-state`, `toast-stack`, fouttemplates: consistente lege, success-, fout- en onbekende staten.
## 5. Componenten ## Interactie
### Button Vanilla JavaScript is alleen progressive enhancement voor drawer, disclosures, viewtoggle, zoekshortcut en thema. Alle links, filters, formulieren, statuswijzigingen en mutaties werken server-side. Motion is functioneel, kort en uitgeschakeld via `prefers-reduced-motion`.
Varianten: ## Reviewchecklist
- primary — één primaire actie per context; - één duidelijke primaire taak en één `h1` per pagina;
- secondary — normale navigatie/opslaan; - echte data of expliciet “Niet gemeten/Onbekend/Niet geconfigureerd”;
- ghost — lage nadruk; - geen controls zonder serveractie;
- danger — destructief met bevestiging; - consistente focusring, tekstlabels en minimaal bruikbare touch targets;
- compact icon+label — vacaturefeedback. - reflow zonder documentoverflow op 360 px en dynamische benutting tot 2560 px;
- lange Nederlandse labels, werkgeversnamen en URL's breken veilig;
States: hover, focus-visible, active, disabled, busy. Disabled blijft leesbaar en verklaarbaar. - geen externe runtimeassets, oude shell of parallel frontendpad.
### Badge/status pill
Bevat altijd tekst. Rollen: strong, possible, weak, hidden, active, review, deny, quarantined, expired. Gebruik niet alleen rood/groen.
### Card
Semantische header/body/footer. Hele kaart niet klikbaar wanneer er meerdere interne acties zijn; titel/link is de primaire link.
### Score
Score = getal/100 + recommendationtekst. Confidence wordt als “Datakwaliteit hoog/middel/laag” of exact percentage in detail getoond. Geen cirkeldiagram nodig voor één waarde.
### Form
- label boven veld;
- hint vóór fout;
- fout gekoppeld via `aria-describedby`;
- required expliciet;
- JSON-achtige lijstvelden uiteindelijk vervangen door chips/multi-select;
- opslaan bevestigt, maar verliest scroll/focus niet onnodig.
### Alert
Info/success/warning/error met icoon, titel en tekst. Alerts zijn `role=status` of `role=alert` afhankelijk van urgentie.
### Table/list
Kolomkoppen semantisch; op mobiel cards of stacked definition list. Actiekolom niet te breed.
## 6. Motion
- alleen functionele transities 120200ms;
- respecteer `prefers-reduced-motion`;
- geen automatische carrousels, pulserende scores of decoratieve parallax.
## 7. Iconen
Gebruik eenvoudige inline SVG's met `aria-hidden=true` naast zichtbare tekst. Geen externe iconfont. Een icon-only button vereist accessible label/tooltips.
## 8. Datavisualisatie
Gebruik balken voor scorecomponenten met numerieke tekst. Kleur ondersteunt, maar label/waarde draagt betekenis. Grafieken zijn niet nodig zolang data laag-volume is.
## 9. Designreviewchecklist
- primaire taak binnen één scherm duidelijk;
- geen ruwe technische identifiers in dagelijkse UI;
- empty/error/loading states aanwezig;
- focusvolgorde logisch;
- contrast en zoom 200% gecontroleerd;
- mobiel 320px breed bruikbaar;
- lange titels/werkgevers/URL's breken veilig;
- Nederlands consistent;
- scoreverklaring niet verstopt achter kleur.
+50
View File
@@ -0,0 +1,50 @@
# Stitch frontendcontract
## Bindende productregel
De normale VacatureRadar-productiefrontend is de functionele vertaling van de negen schermen uit `docs/design/stitch-reference/`. `screen.png` bepaalt compositie en visuele uitkomst, `code.html` verduidelijkt structuur en interacties en `intelligence_cockpit/DESIGN.md` bepaalt de visuele tokens. De voormalige MVP-frontend heeft geen behoudsprioriteit.
Backend-, beveiligings- en toegankelijkheidsregels blijven altijd leidend. Een Stitch-element wordt alleen aangepast wanneer echte data ontbreekt, een actie onveilig of niet ondersteund is, Nederlandse content meer ruimte vraagt of WCAG 2.2 AA dat vereist. Zo'n afwijking wordt in `STITCH_INTEGRATION_REPORT.md` gemotiveerd.
## Verplichte visuele taal
- Donkere navy cockpit met `#060e20`, `#0b1326`, `#131b2e`, `#171f33`, `#222a3d` en `#2d3449` als tonale lagen.
- Signal Cyan `#00f5ff` voor primaire acties en focus, Emerald `#4edea3` voor aantoonbaar gezonde/positieve status, indigo voor automation en koraal/amber voor fout of aandacht.
- Compacte technische zijbalk, contextuele topbar, brede datacanvas en adaptieve intelligence-rails.
- Geist-achtige systeemheadline, Inter-achtige systeembody en JetBrains Mono-achtige systeemmono; geen externe fontrequest.
- 8px basisritme, 16/24/32px stackafstand, fijne borders, kleine radii en terughoudende glows. Tonale hiërarchie vervangt zware shadows.
- Lokale inline SVG-iconen. Geen Material Symbols-, Tailwind-, font- of andere runtime-CDN.
## Functioneel contract
- Alle cijfers zijn database-aggregaties of tonen expliciet `Nog niet gemeten`, `Onbekend` of `Niet geconfigureerd`.
- Alle mutaties zijn POST + CSRF, gebruikersgescopeerd en gebruiken bestaande services waar bedrijfsbeslissingen nodig zijn.
- Vacature-HTML is alleen de gesanitized backendwaarde; externe links gebruiken `noopener noreferrer nofollow` en openen uitsluitend na een expliciete gebruikersactie.
- Geen automatische sollicitatie, platformloginbot, CAPTCHA-omzeiling, drag-and-drop zonder persistente fallback of control zonder werkend effect.
- Named URLs, querystrings, browser-back, paginering en kernflows werken zonder JavaScript. JavaScript verbetert alleen navigatie, disclosures, tabs, dialogen, viewtoggles en micro-interacties.
## Schermcontract
De volgende pagina's zijn verplicht en gebruiken echte data:
1. Dashboard: dagconclusie, matches, filters/deduplicatie, sollicitatieopvolging, activiteit en bronstatus.
2. Vacatureverkenner: master/detail op breed scherm, server-side filters, scorebewijs en snelle veilige acties.
3. Vacaturedetail: jobhero, score-intelligence, inhoud, provenance, versies, duplicaten en sticky acties.
4. Sollicitatiepipeline: toegankelijke kanban en lijstfallback met persistente statuswijziging.
5. Zoekprofiel: gegroepeerde radarconfigurator met echte server-rendered preview en revisie-informatie.
6. Bronnen: brongezondheid, policies, runtelemetrie, retry/review en beveiligde imports/mailboxen.
7. Automatisering: echte pipeline-, queue-, database-, notificatie- en bronstatus zonder gesimuleerde liveclaims.
8. Activiteit: gepagineerde, filterbare tijdlijn uit bestaande auditwaardige modellen.
9. Werkgevers: index/detail uit genormaliseerde werkgever-, vacature-, score-, bron- en sollicitatiedata.
Ook login, foutpagina's, lege toestanden en generieke formulieren gebruiken dezelfde visuele taal.
## Responsive en toegankelijkheid
- Desktop volgt de Stitch-silhouette op 1440×900 en 1280×800; 1920/2560px gebruikt extra kolommen of ruimte en geen smalle gecentreerde strook.
- Op 1024/768px worden rails logisch gestapeld. Op 390/360/320px wordt de zijbalk een focusveilige drawer, tabellen krijgen kaart- of horizontale scrollpresentatie en sticky acties blijven bruikbaar.
- Eén `h1`, geldige landmarks, skiplink, zichtbare `:focus-visible`, tekst naast statuskleur, expliciete labels/fouten, 44px touchdoelen waar haalbaar, reduced motion en bruikbaarheid bij 200% zoom.
## Afrondingscontract
Een frontendfase is pas klaar na relevante tests en een diffreview. De volledige vervanging vereist elf browserbeelden onder `artifacts/visual-validation/`, een schone console op de kernroutes, een legacyzoekaudit, `scripts/codex_verify.sh`, bijgewerkte backlog/traceability/projectstatus en `STITCH_INTEGRATION_REPORT.md`. Er bestaat daarna geen feature flag of alternatieve normale route naar de oude frontend.
+54 -48
View File
@@ -1,68 +1,74 @@
# Stitch-integratieplan # Stitch-integratieplan
## Doel en bronhiërarchie ## Auditbaseline
Deze integratie vertaalt het Stitch-project **VacatureRadar Intelligence Cockpit** naar de bestaande Django-applicatie. De functionele repository blijft leidend voor routes, data, beveiliging, gebruikersscope en acties. Stitch levert de visuele taal en compositie, niet de domeinwaarheid. De ontvangen ZIP `stitch_vacatureradar_intelligence_cockpit.zip` is geïnventariseerd en bytegelijk aan de blijvende referenties onder `docs/design/stitch-reference/`. Er is geen afzonderlijke aanvullende `design.md` aangetroffen. De referenties worden niet als runtimefrontend geserveerd en zijn via `.dockerignore` uit de productieimage gehouden.
De volledige ontvangen referentie staat in `docs/design/stitch-reference/`. Deze map en de validatiescreenshots zijn uitgesloten van de productiecontainer via `.dockerignore`; er worden geen externe Tailwind-, font-, icoon-, afbeelding- of JavaScript-CDN's aan de applicatie toegevoegd. | Stitch-scherm | Afmeting | Dominante compositie | Productieroute / template |
| --- | ---: | --- | --- |
| Dashboard overzicht | 1480×1600 | KPI-strip, brede matchfeed, actie-/activiteitsrail | `/` · `dashboard/today.html` |
| Nieuwe matches | 1600×1550 | filterbar, masterlijst en geselecteerde intelligence | `/jobs/` · `jobs/list.html` |
| Vacature intelligence detail | 1600×1525 | hero, scorepaneel, inhoudtabs, intelligence-rail en actiedock | `/jobs/<uuid>/` · `jobs/detail.html` |
| Sollicitatie pipeline | 1600×1280 | horizontale kanban met lijsttoggle | `/jobs/applications/` · `applications/list.html` |
| Zoekprofiel editor | 1600×1280 | configuratiestappen met vaste radarpreview | `/profiles/<id>/` · `profiles/edit.html` |
| Bronnenbeheer & gezondheid | 1600×1340 | bronstatusrijen, disclosures en integriteitspanelen | `/sources/` · `sources/list.html` |
| Automatisering centrum | 1540×1600 | pipelinefasen, operationele KPI's, brontabel en lograil | `/system/` · `system/status.html` |
| Activiteitenlogboek | 1600×1441 | filterbare technische tijdlijn met samenvatting | `/jobs/activity/` · `activity/list.html` |
| Werkgevers intelligence | 1600×931 | employer table met intelligence-detailrail | `/jobs/employers/` en `/jobs/employers/<id>/` · employer templates |
## VR-119: compositie als primaire bron ## Tokens en herbruikbare bouwstenen
Na productfeedback is de integratie aangescherpt: Stitch bepaalt nu niet alleen kleur, vorm en typografie, maar ook de dominante schermcompositie en informatiedichtheid. Alleen aantoonbaar onveilige of fictieve onderdelen worden vervangen door een functioneel equivalent met echte repositorydata. De negen HTML-referenties gebruiken dezelfde palette- en typografieschaal: darkest/surface `#060e20`/`#0b1326`, vijf tonale containers, Signal Cyan `#00f5ff`, Emerald `#4edea3`, lichte hoofdtekst `#dae2fd`, gedempte tekst `#b9caca`, koraal `#ffb4ab`, 8px basisritme en 28px radii. Headings zijn 20/32px, body 14/16px en metadata 12/13px mono. Productie vertaalt dit naar CSS custom properties en lokale SVG's.
- De utilitybar neemt de brede zoekbalk en directe profielactie over. Herbruikbare componenten: app-shell, nav-link, contextbar, page-heading, KPI-tile, tonal-panel, status-chip, score-ring/tile, data-table, intelligence-rail, timeline-event, filterbar/drawer, disclosure, empty state, form-section, sticky action dock en mobile bottom navigation. De componenten dragen semantiek; paginatemplates leveren alleen domeindata en compositie.
- Het dashboard gebruikt een enkelkoloms matchfeed met vaste radar-/actierail; één resultaat blijft daardoor een volwaardig primair signaal.
- De vacaturelijst toont een dichte intelligencefeed met de laatste gebruikersgescopeerde score via databaseannotaties.
- Het detail combineert scorehero, drie echte kernmetingen, analyse en een vaste menselijke actierail.
- Pipeline, profieleditor, bronkaarten en systeemtelemetrie volgen de Stitch-kolommen en panelhiërarchie.
- Handmatige import blijft volledig beschikbaar, maar staat compact achter een disclosure zodat brongezondheid visueel voorrang krijgt.
De bedoelde “circa 90% Stitch” slaat hiermee op overgenomen visuele structuur en interactiehiërarchie, niet op het kopiëren van fictieve content, externe assets, auto-apply of routes waarvoor het domeinmodel geen betrouwbare data bezit. ## Data- en routeaudit
## Visuele richting | Component | Echte databron / service | Ontbrekend of aangepaste presentatie |
| --- | --- | --- |
| Dag-KPI's en beste matches | `JobPosting`, laatste `ScoreRun`, `Application`, `SourceRun`; nieuwe cockpit-queryservice | Geen fictieve trendpercentages; onbekende meting krijgt tekststatus. |
| Verkenner | `JobPosting` met laatste profielscore, relevance-query, filters en paginering | Stitch quick-apply wordt detail/dossier/externe bronactie. |
| Detailintelligence | `ScoreRun`, `JobSourceAlias`, `FieldProvenance`, `JobVersion`, `Feedback`, `Application` | Geen ervarings-/senioriteitsscore; geen kaart of salaris wanneer onbekend. |
| Pipeline | gebruikersgescopeerde `Application` en `ApplicationTimelineEvent` | Geen drag-and-dropvereiste; statuswijziging krijgt toegankelijke POST-selectie. |
| Radarconfiguratie | `SearchProfileForm`, profielrevisies, laatste scores | Preview is server-rendered uit laatste echte match; geen demo-vacature. |
| Brongezondheid | `Source`, laatste `SourceRun`, `collect_source_health`, mailboxverbindingen | Geen algemene healthyclaim zonder runbewijs; technische fouttekst begrensd. |
| Automatisering | readiness, `SourceRun`, jobs, scores, notificatie-outbox en configuratie | Uptime/worker/AI/browserstatus alleen indien afleidbaar, anders niet geconfigureerd. |
| Activiteit | nieuwe queryservice over `SourceRun`, `JobVersion`, `Feedback`, `ApplicationTimelineEvent`, outbox en `ProfileRevision` | Modellen vormen één read-only tijdlijn; geen nieuw generiek auditmodel nodig. |
| Werkgevers | nieuwe queryservice over `Employer`, jobs, scores, aliases, sources en applications | Sector/locatie/notities alleen indien opgeslagen; geen CRM- of contactpersoonfinder. |
- Donkere cockpit als standaard: diep marineblauw, tonale panelen, fijne slate-randen, signaalcyaan voor primaire interactie, emerald voor aantoonbaar gezonde status en koraal/amber voor fouten of aandacht. De twee nieuwe domeinervaringen worden read-only geïmplementeerd in `apps/jobs/services/activity.py` en `apps/jobs/services/employer_intelligence.py`. Dit houdt aggregatielogica uit views en vermijdt een migratie. Querysets gebruiken annotaties, `select_related`, `prefetch_related` en begrensde lijsten om N+1 te voorkomen.
- Een volwaardige lichte variant met dezelfde semantische tokens en contrastverhoudingen.
- Systeemfonts als lokale, snelle fallback voor Geist/Inter; monospace systeemfonts voor metadata. Geen netwerkfonts.
- Een vaste desktopzijbalk en compacte utilitybar, op mobiel een toegankelijke uitschuifnavigatie met overlay en focusbehoud.
- Dichte informatieweergave met een consistente 8px-ritmiek, zonder decoratieve data die niet uit het domeinmodel komt.
## Schermmapping ## Fidelitymatrix
| Stitch-scherm | Django-route/template | Integratie | Bewuste aanpassing | | Scherm | Stitch-element | Echte implementatie | Fidelitydoel | Noodzakelijke aanpassing | Verificatie |
| --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| Dashboard Overzicht | `/`, `dashboard/today.html` | KPI-strip, sterke-matchkaarten, bronstatus en snelle route-acties | Geen verzonnen trendgrafiek, notificaties of scannerstatus; alleen actuele databasecijfers. | | Dashboard | vier KPI's + matchfeed + rail | actuele job/score/source/application-aggregaties | zeer hoog | trends alleen bij meetbaar tijdvenster | desktop/mobile screenshot + contexttests |
| Nieuwe Matches | `/jobs/`, `jobs/list.html` | Filterwerkbalk en responsive vacature-intelligentielijst | Geen client-side master/detail-SPA; detail blijft een eigen, deelbare route. | | Nieuwe matches | split explorer | gepagineerde serverlijst met geselecteerd echt resultaat | zeer hoog | URL blijft deelbaar; geen SPA-state | filters/back/pagination tests + screenshot |
| Vacature Intelligentie Detail | `/jobs/<uuid>/`, `jobs/detail.html` | Scorehero, componentanalyse, bronhistorie en sticky acties | Geen quick/direct apply, logo of kaart zonder betrouwbare brondata. Externe vacature opent alleen expliciet in een nieuw tabblad. | | Detail | hero, score rail, tabs, dock | scorebewijs, provenance, versies, veilige acties | hoog | geen nepbeeld, quick apply of ervaringseffect | safe-link/sanitization tests + screenshots |
| Sollicitatie Pipeline | `/jobs/applications/`, `applications/list.html` | Statuskolommen op breed scherm, lineaire kaarten op smal scherm | Alleen bestaande dossierstatussen en gebruikersgescopeerde data. Geen drag-and-drop zonder backendcontract. | | Pipeline | kanban + lijsttoggle | statuskolommen en echte POST statusselectie | zeer hoog | geen drag-and-drop als enige invoer | POST/user-scope tests + screenshot |
| Zoekprofiel Editor | `/profiles/` en `/profiles/<id>/` | Profielsummary, gegroepeerde editor, hulp- en risicotekst | Geen live scorepreview die servervalidatie kan tegenspreken. JSON-velden blijven functioneel maar krijgen duidelijke uitleg. | | Zoekprofiel | secties + live preview | gegroepeerde widgets en laatste echte match | hoog | preview ververst na server-save | form/revision tests + screenshot |
| Bronnenbeheer & Gezondheid | `/sources/`, `sources/list.html` | KPI's, importpaneel, bronkaarten, uitklapbare runinformatie en bulkacties | Bestaande policy-, SSRF-, rate-limit- en CSRF-flows blijven intact. Geen algemene “gezond”-claim zonder readinessdata. | | Bronnen | health rows + details | bronkaart/tabel, runbewijs, retry en imports | zeer hoog | policy/securityflows blijven zichtbaar | retry/policy tests + screenshot |
| Automatisering Centrum | `/system/`, `system/status.html` | Readiness, echte bron-/vacaturestatus en veilige beheeropdrachten | Geen gefingeerde worker uptime, latency, cronprogressie of logregels. | | Automatisering | pipeline en logs | afgeleide fasestatus, run- en outboxtelemetrie | hoog | onbekende infrastructuur eerlijk gelabeld | view-context tests + screenshot |
| Activiteitenlogboek | geen zelfstandige route | Recente, betrouwbare gebruikersactiviteit wordt op dashboard/dossiers getoond waar het model die al bezit | Geen samengestelde auditfeed: de huidige modellen hebben geen uniform, volledig gebruikersgescopeerd eventcontract. Dit wordt niet geforceerd. | | Activiteit | filtertijdlijn | gepagineerde multi-model read service | zeer hoog | alleen objecttypen met betrouwbare auditdata | filter/scope/pagination tests + screenshot |
| Werkgevers Intelligentie | geen zelfstandige route | Werkgever blijft context op vacature- en sollicitatiekaarten | Geen “intelligence”-pagina zolang contact-, reputatie- en aggregatievelden niet betrouwbaar bestaan; dit voorkomt fictieve inzichten. | | Werkgevers | tabel + drawer | index met geselecteerde detailrail en aparte detail-URL | zeer hoog | geen AI-contactfinder of verzonnen sector | aggregate/scope/empty tests + screenshot |
## Componenten en templatestructuur ## Responsive strategie en risico's
- `base.html`: skiplink, merkblok, utilitybar, desktop/mobile navigatie, gebruikerspaneel, meldingen en themawisselaar. De desktop shell gebruikt een 256px sidebar en een flexibele canvas tot ultrawide. Dashboard, explorer en detail schakelen bij 11801360px van twee/three-column naar gestapeld; op ultrawide worden rails vast en feeds ruimer. Kanban behoudt gecontroleerde horizontale inhoudscroll, niet paginascroll. Tabellen krijgen bij mobiel semantische kaartregels of een gelabelde scrollcontainer. De profielpreview wordt onder de editor geplaatst; de vacatureselectie opent als volledige detailkaart.
- `templates/components/`: herbruikbare SVG-iconen, paginakop, lege toestand, statuschip en vacaturekaart waar template-includes de duplicatie daadwerkelijk verlagen.
- `static/css/app.css`: uitsluitend semantische tokens en componentklassen; dark/light via `data-theme`, met systeemvoorkeur als fallback.
- `static/js/app.js`: alleen navigatie, details-disclosures en themaopslag. Alle kernacties blijven normale links of formulieren.
## Functionele en veiligheidsgrenzen Belangrijkste accessibilityrisico's uit het prototype zijn icon-only controls, kleur-only status, lage metadata-contrastwaarden, vaste hoogtes, horizontale kanban en JavaScript-only toggles. Productie voegt namen, zichtbare statustekst, voldoende contrast, natuurlijke hoogte, keyboardbediening, serverfallbacks, focusmanagement en `prefers-reduced-motion` toe.
- Alle mutaties blijven POST + CSRF en gebruiken de bestaande services/views. ## Migratievolgorde en legacy-opruiming
- Sanitized vacature-HTML blijft het enige HTML-fragment dat met `safe` wordt gerenderd.
- Externe URL's behouden `noopener noreferrer nofollow` en worden nooit automatisch geopend.
- Bronimport, retry en bulkreview veranderen inhoudelijk niet.
- Er worden geen secrets, persoonsgegevens, nepstatistieken, externe assets of nieuwe productieafhankelijkheden toegevoegd.
## Responsive en toegankelijkheid 1. Contract, tokens, icon sprite en volledig nieuwe shell.
2. Dashboard, explorer en detail plus benodigde querycontext.
3. Pipeline, zoekprofiel en bronnen zonder bestaande POST/contracts te breken.
4. Automatisering, activiteit en werkgevers met nieuwe read services/routes.
5. Login, fouttemplates, mobile/adaptive lagen, browsercontrole en accessibility.
6. Verwijder oude selectors, dubbele cards/panels, achterhaalde JS en screenshots die niet tot de huidige validatie horen; zoek alle productietemplates na op de oude shell.
- Validatiebreedtes: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800. `templates/base.html`, alle niet-e-mailtemplates onder `templates/`, `static/css/app.css` en `static/js/app.js` worden vervangen of fundamenteel herschikt. E-mailtemplates blijven buiten de cockpit omdat zij geen browserfrontend zijn. Named routes en bestaande formulier-/securitycontracts blijven behouden.
- Eén `h1` per pagina, semantische landmarks, zichtbare focus, toetsenbordbediening, 44px touchdoelen waar praktisch, tekstlabels naast statuskleur en een robuuste skiplink.
- Tabellen schakelen op smalle schermen naar kaartachtige rijen of blijven gecontroleerd horizontaal scrolbaar wanneer kolomvergelijking essentieel is.
- Animatie is subtiel en volledig uitgeschakeld bij `prefers-reduced-motion`.
## Verificatie ## Gate en artifacts
Na elke implementatiefase worden formattering, Ruff en relevante tests uitgevoerd. De afronding vereist `scripts/codex_verify.sh`, responsieve browserflows, console-/netwerkcontrole en screenshots per hoofdroute in beide thema's waar dat het ontwerp valideert. Per fase: Ruff format/check, relevante tests, diffreview en logische commit. Eindmatrix: 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800; aanvullende 1920/2560px ultrawidecontrole. De elf verplichte beelden komen onder `artifacts/visual-validation/`; het eindrapport legt fidelity, afwijkingen, consolebevindingen, accessibility, queries en testresultaten vast.
+130
View File
@@ -0,0 +1,130 @@
# Stitch-integratierapport
## Resultaat
VacatureRadar 0.3.0 gebruikt één normale productiefrontend: de functionele Django-vertaling van de negen Stitch Intelligence Cockpit-schermen. De voormalige MVP-shell, monolithische stylesheet en pagina-indelingen zijn vervangen. Backendregels, named routes, user scoping, CSRF, sanitization, bronbeleid en servervalidatie blijven leidend.
Startcommit: `029df89`. Branch: `codex/stitch-frontend-replacement`.
## Schermmapping en fidelity
| Stitch-scherm | Productie-implementatie | Fidelity | Bewuste afwijking |
| --- | --- | --- | --- |
| Dashboard overzicht | `/` · `templates/dashboard/today.html` | Zeer hoog | Geen fictieve trends; dagconclusie en KPI's zijn echte aggregaties. |
| Nieuwe matches | `/jobs/` · `templates/jobs/list.html` | Zeer hoog | Server-side master/detail en paginering vervangen prototype-state. |
| Vacature intelligence detail | `/jobs/<uuid>/` · `templates/jobs/detail.html` | Hoog | Geen quick apply, kaart of salaris wanneer backenddata ontbreekt. |
| Sollicitatie pipeline | `/jobs/applications/` · applicationtemplates | Zeer hoog | Select+POST en lijstfallback in plaats van drag-only kanban. |
| Zoekprofiel editor | `/profiles/<id>/` · profieltemplates | Hoog | Preview gebruikt laatste echte score en ververst na server-save. |
| Bronnenbeheer & gezondheid | `/sources/` · `templates/sources/list.html` | Zeer hoog | Veiligheids-, import- en mailboxflows blijven zichtbaar en progressief. |
| Automatisering centrum | `/system/` · `templates/system/status.html` | Hoog | Worker/AI/browserstatus is eerlijk onbekend of niet geconfigureerd. |
| Activiteitenlogboek | `/jobs/activity/` · `templates/activity/list.html` | Zeer hoog | Multi-model read service; geen nieuw fictief auditmodel. |
| Werkgevers intelligence | `/jobs/employers/` en detail · employertemplates | Zeer hoog | Geen verzonnen sector, contactfinder of CRM-data. |
Ook login, 403/404/500, messages, empty states, dossieredit en skillsradar gebruiken dezelfde componenttaal.
## Implementatie
### Templates en assets
Vervangen of fundamenteel herschikt: `base.html`, dashboard, jobs list/detail/skills, applications list/edit, profiles list/edit, sources list, system status en registration login. Nieuw: activity, employer index/detail en generieke fouttemplates.
`static/css/app.css` is alleen de ingang voor `tokens.css`, `base.css`, `layout.css`, `components.css`, `pages.css` en `responsive.css`. `static/js/app.js` bevat alleen progressive enhancement. Iconografie is één lokale SVG-sprite. Er zijn geen externe fonts, iconfonts, Tailwind/CDN of clientframeworks.
### Services, views en routes
- `apps/jobs/services/cockpit.py`: dashboard- en vacature-intelligence.
- `apps/jobs/services/activity.py`: begrensde, user-safe multi-model tijdlijn.
- `apps/jobs/services/employer_intelligence.py`: werkgeveraggregaties en detailreadmodel.
- `apps/core/services/automation.py`: eerlijke operationele cockpit.
- `apps/jobs/services/applications.py`: auditbare statuswijziging via POST.
- Nieuwe named routes: `jobs:activity`, `jobs:employers`, `jobs:employer-detail`, `jobs:application-status`.
Er waren geen nieuwe modellen of migraties nodig. Bestaande bronhealth is geoptimaliseerd met een per-bron geslicete prefetch.
## Echte data en security
Alle KPI's komen uit `JobPosting`, laatste `ScoreRun`, `Application`, `Source`, `SourceRun`, outbox- of profieldata. Persoonlijke feedback, dossiers, notificaties en profielrevisies zijn gebruikersgescopeerd. Vacature-HTML blijft de gesanitized backendwaarde. Externe links en POST-acties behouden bestaande securitycontracts. Er is geen automatische sollicitatie, platformloginbot, CAPTCHA-omzeiling of directe denylistcrawl toegevoegd.
## Responsive en browservalidatie
Gecontroleerd met de echte app en representatieve lokale fixtures op 360×800, 390×844, 768×1024, 1024×768, 1280×800 en 1440×900; dashboard, explorer en werkgevers ook op 1920×1080 en 2560×1440. Alle negen schermen zijn op desktop en 390 px doorlopen.
Resultaat:
- geen documentbrede horizontale overflow;
- mobiele sidebar en tabbar functioneren;
- profile preview respecteert de gridkolom;
- brede automationpipeline en tabellen scrollen alleen intern;
- geen kapotte images;
- geen browserconsole-errors of warnings;
- dark/light actielabel en opgeslagen voorkeur zijn consistent.
Screenshots:
- `artifacts/visual-validation/dashboard-desktop.png`
- `artifacts/visual-validation/matches-desktop.png`
- `artifacts/visual-validation/job-detail-desktop.png`
- `artifacts/visual-validation/applications-desktop.png`
- `artifacts/visual-validation/profile-desktop.png`
- `artifacts/visual-validation/sources-desktop.png`
- `artifacts/visual-validation/automation-desktop.png`
- `artifacts/visual-validation/activity-desktop.png`
- `artifacts/visual-validation/employers-desktop.png`
- `artifacts/visual-validation/dashboard-mobile.png`
- `artifacts/visual-validation/job-detail-mobile.png`
De afbeeldingen en Stitch-referenties zijn via `.dockerignore` uit de productieimage gehouden.
## Accessibility
De shell heeft skiplink en expliciete landmarks; iedere hoofdroute heeft één `h1`. Status gebruikt tekst, formulieren behouden echte labels/servererrors, mutaties werken zonder JavaScript en motion respecteert reduced motion. De drawer is met toetsenbord-/ARIA-state getest. Wide data heeft focusbare lokale scrollcontainers. De HTML/a11y-probe slaagt; de optionele externe pytest-Playwrightvariant is alleen beschikbaar wanneer Playwright lokaal geïnstalleerd is.
## Performance
Representatieve volledige paginaquerymetingen: dashboard 16, explorer 17, detail 15, pipeline 5, profiel 6, bronnen 14, activiteit 10 en werkgevers 14. Automation daalde door het verwijderen van de bronhealth-N+1 van 63 naar 23 queries. `collect_source_health()` is regressiegedekt op exact twee queries voor meerdere bronnen. Grote tijdlijnen en detailsets zijn begrensd; lijsten gebruiken annotaties, `select_related` en prefetching.
## Legacy-audit
Er is geen oude shell, alternatieve feature flag of parallelle frontendroute. Oude componentselectors, kleuren en monolithische pagina-opbouw zijn verwijderd. Alleen HTML-e-mailtemplates behouden bewust hun zelfstandige inline mailstijl; zij zijn geen browserfrontend. Bestaande historische validatiebeelden blijven documentair bewijs, maar worden niet gerenderd of in de productiecontainer opgenomen.
## Verificatie en beperkingen
De volledige `scripts/codex_verify.sh`-gate is zowel vóór als na ledgerafronding groen: Ruff, Django system check, `makemigrations --check --dry-run`, 248 geslaagde tests, 2 optioneel overgeslagen Playwrightvarianten, 84,40% branch-aware coverage, backlogvalidatie en repository-/documentvalidatie.
Bekende externe beperkingen wijzigen de frontend niet: live mailbox-/SMTP-credentials en productie-infrastructuur blijven afzonderlijke `blocked-external` taken. Niet-bestaande backenddata wordt bewust als onbekend getoond.
## Premium verfijning (VR-216)
De high-fidelity cockpit is na een senior UX-audit verfijnd volgens het principe “calm intelligence”. De onderliggende Stitch-informatiearchitectuur en alle server-rendered flows blijven intact; de visuele laag vermindert gelijkwaardige kaders en reserveert cyan voor primaire acties, actieve navigatie en betekenisvolle signalen. Navigatie, labels en hulptekst gebruiken humane typografie, terwijl cijfers en technische statusinformatie hun compacte technische karakter behouden.
Dashboard-KPI's staan mobiel in een compact 2×2-raster zodat de eerste matches binnen de eerste viewport beginnen. De vacaturefilters gedragen zich als één responsieve commandobar, pipelinekolommen hebben een stabiele interne scroll en scroll-snap, en het zoekprofiel gebruikt een brede samengestelde kaart in plaats van een klein eiland in lege ruimte. Het lichte thema heeft meer tonale diepte en minder grijze vlakheid.
De geïntegreerde browsercontrole dekte dashboard, verkenner, pipeline en profiel op 1280×800, dashboard en verkenner op 390×844, en het dashboard in donker en licht thema. De gemeten documentbreedte bleef binnen de viewport; op 390 px gebruikte het dashboard twee KPI-kolommen en begon de matchsectie op 550 px. De loginconsole is schoon nadat de voorheen inline logininteractie naar `static/js/login.js` is verplaatst conform de bestaande CSP.
### Interactieve illustratielaag
Het zoekprofiel gebruikt een herbruikbare lokale SVG-radar als projectillustratie. Scan, signalen en orbitale details bewegen langzaam; pointerbeweging geeft maximaal tien pixels parallax. De visual is `aria-hidden`, bevat geen metric of statusclaim en laat alle profielinformatie en acties zelfstandig intact. Zonder JavaScript blijft de compositie bruikbaar en `prefers-reduced-motion: reduce` verwijdert animatie en parallax volledig. Op 390 px staat de illustratie onder de primaire profielactie, zodat zij de kernflow niet uit de eerste leesvolgorde verdringt.
## Persoonlijke merklaag (VR-218)
Favicon, login en shell gebruiken één compact radarbeeldmerk met een donker fundament, cyan scansignaal en emerald detectiepunt. De tekstsignatuur noemt standaard `Jens · private intelligence`; `VACATURERADAR_OWNER_NAME` maakt deze eigenaar expliciet configureerbaar zonder templatefork. De bovenbalk combineert eigenaar en routecontext met de bestaande zoekactie, zodat gebruikers sneller herkennen in welke werkruimte en flow zij zich bevinden.
De actieve navigatie, merklock-up en zoekfocus hebben preciezere gewicht-, contrast- en motionstates gekregen. Op smalle viewports verdwijnt de aanvullende werkruimtecontext zodat zoeken en primaire navigatie voorrang houden. De browsercontrole van dashboard, verkenner, profiel en login op 1280×850 en 390×844 bevestigt een schone console, geladen lokale assets en geen documentoverflow.
## Ultrawide gridcorrectie (VR-219)
De eerdere premiumlaag centreerde ieder direct hoofdonderdeel afzonderlijk op maximaal 1860 px. Op een 3840-pixelviewport ontstond daardoor links van de inhoud ruim 1100 px lege ruimte, terwijl de vaste bovenbalk een ander anker gebruikte. Alle directe hoofdsecties delen nu één links verankerde contentzone van maximaal 2800 px. Op 3840 px beginnen heading, filters, explorer, systeemstatus, pipeline, KPI's en automationdetail exact op x=296; op 1920 px vullen zij de beschikbare 1576 px en op 390 px de beschikbare 350 px.
De verkenner begrenst zijn resultatenkolom op ultrawide tot 880 px en geeft de resterende ruimte aan vacature-intelligence. De automationlograil blijft tussen 340 en 430 px, zodat tabellen en logs een voorspelbare verhouding houden. De merklock-up is tegelijk geharmoniseerd tot `VacatureRadar — Personal intelligence by Jens` en de desktopwerkruimte tot `Jens Intelligence`; login en herstelpagina's gebruiken dezelfde eigenaarssignatuur.
## Officiële ITWorx-merklaag (VR-220)
De projecteigenaar verduidelijkte dat “eigen branding” verwijst naar de officiële ITWorx.tech-identiteit. De tijdelijke tekstuele Jens-lock-up is daarom vervangen door de transparante, opgeschoonde wordmark uit het aangeleverde `ITWorx_Logo_Asset_Pack_2026-07-25.zip`. In de sidebar staat ITWorx.tech als hoofdmerk met `VacatureRadar · intelligence cockpit` als productdescriptor; de bovenbalk combineert ITWorx.tech met de bestaande routecontext. Login en wachtwoordherstel tonen eerst de officiële wordmark en vervolgens VacatureRadar als product.
Het favicon/app-icon en Dockermanicoon zijn deterministische crops van het cloud-check-symbool uit dezelfde goedgekeurde wordmark, op respectievelijk 512×512 en 256×256. Er is geen nieuw beeldmerk gegenereerd of nagetekend. Alle assets zijn lokaal, transparant en gedocumenteerd in `static/img/README.md`. De browsermatrix op 390, 1280 en 1920 px bevestigt correcte wordmarkverhoudingen, geladen PNG-assets, geen documentoverflow en een schone console.
## Gecorrigeerde merkarchitectuur (VR-221)
Een volgende verduidelijking maakte duidelijk dat ITWorx.tech de ontwikkelaar is en niet het productmerk. VacatureRadar gebruikt daarom opnieuw een zelfstandig beeldmerk, nu herontworpen als een open cyan radarboog, heldere scanstraal en één emerald kanssignaal op een compact navy appvlak. Twee beeldgeneratieconcepten verkenden de vormtaal; de geselecteerde eenvoudige richting is vervolgens bewust handmatig als vijf-elementen-SVG opgebouwd voor exacte geometrie, schaalbaarheid en herkenning op faviconformaat.
Sidebar, bovenbalk, login, favicon en Dockerman tonen VacatureRadar als primair merk. De officiële aangeleverde ITWorx.tech-wordmark staat alleen nog in een rustige `Ontwikkeld door`-credit tussen radarstatus en account, en onder authenticatiekaarten. De credit gebruikt een lokaal asset en concurreert niet met de producttitel. Browsercontrole op 390, 1280 en 1920 px bevestigt correcte hiërarchie, geladen logo's, geen overflow en een schone console.
+39 -137
View File
@@ -1,165 +1,67 @@
# UX-specificatie # UX-specificatie
## 1. Ervaringsprincipe ## Ervaringsprincipe
VacatureRadar voelt niet als een zoekmachine maar als een rustige persoonlijke inbox. De gebruiker ziet eerst beslisbare informatie en pas daarna technische details. Bronbeheer en parserproblemen blijven beschikbaar, maar domineren de dagelijkse flow niet. VacatureRadar is een persoonlijke intelligence-cockpit: eerst beslisbare informatie, daarna bewijs en technische details. Het product claimt alleen wat uit echte vacatures, scores, bronruns en gebruikersacties kan worden afgeleid. Ervaring en senioriteitslabels zijn context, nooit een blokkerende scorefactor.
## 2. Informatiearchitectuur ## Informatiearchitectuur
Hoofdnavigatie: De desktopnavigatie groepeert:
1. **Vandaag** — beste matches en kernstatus; 1. Overzicht;
2. **Vacatures** — volledige doorzoekbare lijst; 2. Nieuwe matches en het volledige vacaturearchief;
3. **Sollicitaties** — dossiers en opvolging; 3. Skillsradar;
4. **Profiel** — voorkeuren en drempels; 4. Sollicitatiepipeline;
5. **Bronnen** — source health en retries; 5. Werkgeversradar;
6. **Systeem** — health, versies en beheercontext. 6. Zoekprofiel;
7. Bronnen;
8. Automatisering;
9. Activiteit.
Op mobiel wordt dit een compacte uitklapbare navigatie; primaire vacatureacties blijven binnen duimbereik. Mobiel blijven Overzicht, Matches, Pipeline en Profiel in de vaste tabbar; alle routes blijven bereikbaar via de toegankelijke drawer.
## 3. Dashboard “Vandaag ## Schermgedrag
### Doel ### Overzicht
Binnen 30 seconden weten of er iets relevants is. Toont echte dag-KPI's, beste recente radarselectie, dagconclusie, open opvolging, recente auditgebeurtenissen en bronstatus. Beste matches zijn visueel dominant; onbekende trends worden niet gesimuleerd.
### Volgorde ### Vacatureverkenner en detail
1. paginatitel + korte samenvatting; De verkenner combineert serverfilters, sortering, actieve filterchips, paginering en op breed scherm een geselecteerd intelligencepaneel. Standaard worden alleen aantoonbaar IT-relevante, niet hard uitgesloten vacatures getoond. Het volledige bronarchief blijft expliciet bereikbaar.
2. KPI's: nieuwe vandaag, actieve vacatures, open dossiers, ongezonde bronnen;
3. kaarten, descending score;
4. lege toestand met concrete uitleg;
5. subtiele link naar alle vacatures/bronnen.
### Matchkaart Het detail scheidt matchscore, recommendation en datakwaliteit; toont componentbewijs, positieve signalen, concerns, skills, vacaturetekst, provenance, bronnen, versies en dossierstatus. Externe bronlinks zijn veilig en nooit een automatische sollicitatie.
Boven de vouw: ### Sollicitatiepipeline
- recommendationlabel; Kanban is de visuele standaard; de lijstweergave en statusselectie zijn de toegankelijke fallback. Statuswijzigingen zijn echte CSRF-beveiligde POST-acties en schrijven een tijdlijnevent. Dossieredit toont snapshot, contact, notities, documentenstatus en activiteit.
- score als `86/100` en confidence apart;
- functietitel;
- werkgever;
- locatie + werkvorm;
- maximaal twee kernpluspunten;
- maximaal één belangrijkste concern;
- acties: bekijken, interessant, bewaren, verbergen, gesolliciteerd.
Regels: ### Zoekprofiel en skillsradar
- geen score zonder label/uitleg; De configurator groepeert profielbasis, rollen, technologiesignalen, regio, werkvorm en overige voorkeuren. Postcode volstaat; gemeente en coördinaten komen uit de gelicenseerde lokale dataset. Rollen en skills zijn selecties/checkboxes, geen ruwe JSON. De preview gebruikt de laatste echte score en revisiehistoriek.
- onbekende data als “Niet vermeld” of “Nog niet berekend”;
- hard uitgesloten jobs niet op Vandaag;
- dubbele aliassen niet als aparte kaart.
## 4. Vacaturelijst De skillsradar toont vraagvolume, profieldekking, expliciet skillveldbewijs versus tekstsignalen en niet-blokkerende leerkansen.
Filters: ### Bronnen en automatisering
- vrije tekst; Bronnen tonen policy en operationele status apart, plus laatste run, volgende planning, parser, tellingen, retry, imports en gebruikersgescopeerde platformmailboxen. Directe crawling van denylistplatformen blijft verboden.
- status;
- recommendation/scoreband;
- werkvorm;
- regio;
- werkgeverstype;
- feedbackstatus.
Desktop gebruikt een compacte filterbalk; mobiel een disclosurepanel. Filters blijven in querystring zodat bookmarks en back navigation werken. Automatisering toont de echte 24-uursketen, database/readiness, bronruns, scoring en notificaties. Worker-, AI- of browserstatus wordt als niet gemeten/niet geconfigureerd gelabeld wanneer geen betrouwbare probe bestaat.
Tabel of kaarten kiezen op breedte. Geen horizontale scroll voor kernvelden. ### Activiteit en werkgevers
## 5. Vacaturedetail Activiteit is een gepagineerde, filterbare tijdlijn uit bronruns, vacatureversies, feedback, dossiers, notificaties en profielrevisies. Persoonlijke events zijn gebruikersgescopeerd.
Secties: Werkgevers combineert genormaliseerde werkgever-, vacature-, score-, bron- en dossierdata in een index/detailervaring. Sector, contact of marktclaims worden niet verzonnen.
1. titel, werkgever, status, canonical link; ## States en copy
2. score-overzicht met componenten;
3. “Waarom aanbevolen” en aandachtspunten;
4. harde uitsluitingen indien via directe link geopend;
5. vacaturetekst (gesanitized);
6. voorwaarden/skills/locatie;
7. bronnen en provenance;
8. wijzigingshistorie;
9. sollicitatiestatus/notities.
Bronbewijs wordt technisch maar scanbaar getoond: veld, methode, confidence, excerpt en bron. - Empty: verklaart waarom en biedt één relevante vervolgstap.
- Unknown: “Niet vermeld”, “Niet gemeten” of “Niet geconfigureerd”.
- Error/partial/stale: toont impact en een veilige retry waar ondersteund, nooit een stacktrace.
- Success: servermessage in een `aria-live` toast en blijvende nieuwe toestand.
- Copy is Nederlands, direct en niet-oordelend: “Mogelijke match” en “Niet teruggevonden” in plaats van absolute geschiktheidsclaims.
## 6. Profielwizard/editor ## Responsiveness
De eerste onboarding wordt in maximaal vijf logische stappen opgezet: Desktop bewaart de Stitch-kolomverhoudingen. Tablet stapelt intelligence-rails onder de hoofdtaak. Mobiel herschikt kaarten en forms; wide data gebruikt een lokale scroller. De browsermatrix omvat 360×800, 390×844, 768×1024, 1024×768, 1280×800, 1440×900 en aanvullende 1920/2560px-controles.
1. rollen en ongewenste titels;
2. skills en ongewenste verplichte skills;
3. regio, afstand en werkvorm;
4. contract en employerpreferences;
5. digest en samenvatting.
De bestaande editor mag één pagina blijven voor MVP, maar groepeert velden visueel en geeft voorbeeldsyntax. JSON mag in de interne modelvorm bestaan; de eindgebruiker ziet uiteindelijk chips/checkboxes, geen ruwe JSON.
Opslaan toont:
- welke harde regels actief zijn;
- scoregewichten in begrijpelijke labels;
- profielversie;
- mogelijkheid om learning uit/reset te zetten.
## 7. Bronnen
Bronkaart/-rij toont:
- naam en domein;
- type;
- policy en operationele status apart;
- laatste succes/fout;
- volgende run;
- parser/confidence;
- laatste tellingen;
- retryknop indien policy niet deny.
Kleur is nooit de enige indicator. `deny`, `review`, `quarantined` hebben tekst en icoon/patroon.
## 8. Sollicitaties
Kanban is niet noodzakelijk. Een duidelijke lijst met status, werkgever, functie, applied date, follow-up date en eerstvolgende actie is beter voor laag volume.
Dossierdetail/editor bevat:
- immutable vacaturesnapshot;
- huidige bronlink/status;
- contact;
- notities;
- documentenmanifest;
- tijdlijn van statuswijzigingen (backlog);
- opvolgdatum.
## 9. Meldingen
Digestcopy:
```text
2 sterke matches
4 mogelijke matches
11 automatisch uitgesloten
1 opgeslagen vacature sluit binnenkort
```
Elke vacature heeft titel, werkgever, locatie, score, twee redenen, één aandachtspunt en originele link. Geen volledige vacaturetekst in mail.
## 10. States
Iedere pagina specificeert:
- loading: alleen nodig bij async enhancement; serverrender toont normale response;
- empty: leg uit waarom en geef één relevante actie;
- error: concrete fout + veilige retry, geen stacktrace;
- partial: bron/resultaten beschikbaar met warning;
- stale: toon laatste succesvolle tijd en dat vernieuwing faalde;
- unauthorized: login en geen informatielek.
## 11. Copyrichtlijnen
- Nederlands, direct en niet-oordelend;
- “Mogelijke match” in plaats van “Je bent geschikt”;
- “Niet teruggevonden” in plaats van “Je mist”;
- “Rechtstreekse werkgeversbron” in plaats van absolute garantie;
- percentages alleen wanneer betekenis duidelijk is;
- technische bronfout in beheer, eenvoudige impacttekst op dashboard.
+57
View File
@@ -0,0 +1,57 @@
# Visuele audit en herstelroadmap
Datum: 26 juli 2026
Scope: alle tien primaire cockpitroutes, login en wachtwoordherstel; dark/light; 390, 768, 1280, 1440, 1920 en 3840 px.
## Ontwerpoordeel
VacatureRadar heeft een onderscheidende, geloofwaardige dark-intelligencebasis. De cyan/emerald-signalen, technische typografie en datagedreven panelen passen bij het product. De vorige iteraties hebben echter lokale oplossingen op elkaar gestapeld. Daardoor zijn merkhiërarchie, informatiedichtheid en horizontale ankers niet overal één systeem. De premiumkans zit vooral in rust, reductie en voorspelbaarheid, niet in meer decoratie.
## Bevindingen
| Prioriteit | Bevinding | Gevolg | Herstel |
| --- | --- | --- | --- |
| P0 | De roze conceptafbeelding was een beeldgeneratie-werkachtergrond en had nooit als logoresultaat gepresenteerd mogen worden. | Merkverwarring en vertrouwensverlies. | Alleen het lokaal gecontroleerde VacatureRadar-radarlogo wordt als productasset gebruikt; geen gegenereerde conceptplaat wordt geleverd of gedeployed. |
| P1 | De bovenbalk en hoofdcontent hadden verschillende maximale breedtes op ultrawide. | Acties leken los te staan van de pagina en oogbewegingen werden te lang. | Eén gedeeld contentframe voor topbar en hoofdsecties, met een begrensde ultrawidevariant. |
| P1 | Het dashboard toonde tot acht vrijwel gelijkwaardige vacaturekaarten en werd 2.399 px hoog op 1440×900. | Een dagoverzicht voelde als een tweede resultatenlijst. | Beperk het overzicht tot vijf besliskaarten; de verkenner blijft de volledige rangschikking. |
| P1 | De activiteitstijdlijn gebruikte 256 px per gebeurtenis en werd 5.901 px hoog. | Lage informatiedichtheid, veel scrollen en zwakke scanbaarheid. | Compactere marker, kaart, detailzone en verticale tussenruimte zonder informatieverlies. |
| P1 | De ITWorx-credit stond in een wit, omkaderd vlak met relatief groot woordmerk. | De ontwikkelaarscredit concurreerde visueel met het productmerk en oogde als advertentie. | Maak de credit randloos, kleiner, minder verzadigd en secundair; VacatureRadar blijft primair. |
| P1 | Meerdere tekstlinks hadden een klikhoogte rond 26 px. | Zwakkere muis- en touchbruikbaarheid, ondanks semantisch correcte links. | Secundaire actielinks krijgen minimaal 38 px interactiehoogte; primaire controls blijven 44 px. |
| P2 | Op 1440 px gebruikt de filterconsole twee functionele rijen, maar de visuele groepering is zwak. | Zoeken, verfijnen en toepassen lezen als losse velden. | Behoud responsieve wrapping, maar versterk consolekaart, vaste gaps en consistente controlhoogtes. |
| P2 | Dashboardkaarten hadden veel interne verticale ruimte en sterke schaduwen. | De feed werd zwaarder dan de intelligence-rail. | Compactere kaartpadding en gaps; schaduw uitsluitend als elevatiesignaal. |
| P2 | De profielpagina benut slechts circa 1.040 px van 1.121 px beschikbare content. | Een enkel profiel voelt onbedoeld klein. | Bewuste brede compositie behouden, met illustratie als ondersteunend element en duidelijke primaire actie. |
| P2 | Systeemstatus en acht pipelinefasen zijn op laptopbreedte informatief maar erg dicht. | Kleine labels vertragen scanbaarheid. | Pipeline behoudt interne horizontale overflow op klein scherm; statusmetadata groepeert in consistente kolommen. |
| P2 | De UI bevat veel uppercase labels van 810 px. | Technische sfeer, maar zwakkere leesbaarheid op compacte of geschaalde displays. | Uppercase alleen voor korte metadata; body- en actietekst blijven minimaal 1215 px. |
| P2 | Bewegingsreductie dekte de radarillustratie, niet alle transities en entreeanimaties. | Onvolledige reduced-motionervaring. | Globale gereduceerde motion voor animatie, transitie en smooth scrolling. |
| P3 | De accountnaam verschijnt zowel in de sidebar als in het menu; de topbar herhaalt productnaam en paginacontext. | Lichte redundantie, vooral op smaller desktop. | Sidebar blijft identiteit en navigatie; topbar blijft context, zoeken en systeemacties met compactere lock-up. |
| P3 | Lange beheerpaginas zijn inhoudelijk correct maar hebben weinig lokale navigatie. | Gebruikers verliezen sneller hun positie in bronnen en activiteit. | Compacte rijen, sticky context waar aanwezig en paginering blijven leidend; geen verborgen inhoud. |
## Uitgevoerde roadmap
1. **Merkdiscipline** — productlogo en favicon strikt scheiden van ITWorx-ontwikkelaarsbranding; credit visueel terugplaatsen.
2. **Layoutfundament** — één maximaal contentframe voor topbar en pagina, met voorspelbare ankers van laptop tot ultrawide.
3. **Informatiereductie** — dashboard terugbrengen tot vijf kernmatches en activiteit compacter maken.
4. **Interactie en toegankelijkheid** — secundaire klikdoelen vergroten en reduced-motion volledig maken.
5. **Responsieve verfijning** — alle kernroutes controleren op mobiel, laptop, desktop en ultrawide, in licht en donker.
6. **Regressie en release** — templatecontracten, volledige gate, live containerhealth en assetsmokes uitvoeren.
## Acceptatiebewijs
- Browsermatrix: 50 combinaties over tien ingelogde routes op 390, 768, 1280, 1920 en 3840 px; geen documentoverflow of consolewaarschuwingen.
- Licht thema: dashboard en mobiele verkenner gecontroleerd; correcte thematokens, geen overflow en intacte filters.
- Authenticatie: login op 390 en 1440 px; één herstel-link, gescheiden labels, zelfstandig gerenderde lijniconen en correcte merkhiërarchie.
- Meetbare reductie: dashboard 2.399 → 1.838 px op 1440 px; kernmatches 7/8 → 5; activiteitgebeurtenis 268 → 242 px na cacheverversing.
- Repositorygate: Ruff, Django check, migratiecheck, 268 tests geslaagd, 2 optionele browsertests overgeslagen en 83,45% branch-aware dekking.
- Live release: image `sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70` is healthy; readiness, favicon, productlogo, ITWorx-wordmark en CSS 0.3.9 antwoorden met HTTP 200.
## Vervolgcorrectie VR-223 — 27 juli 2026
De gebruikersscreenshot maakte zichtbaar dat de eerdere oplossing onderaan de sidebar nog te veel losse identiteitselementen stapelde en dat het productlogo door zijn donkere tegel zwaarder oogde dan de rest van de interface.
- het productlogo is teruggebracht tot vier transparante vectorvormen: open radarboog, scanwijzer, kern en kanssignaal;
- de sidebar bevat geen tweede accountkaart meer; profielstatus en ontwikkelaarscredit vormen één rustige footer en accountacties blijven in het topbarmenu;
- de ITWorx-wordmark heeft op dark mode geen witte stickerachtergrond meer maar een subtiele monochrome behandeling; light mode behoudt de officiële kleuren;
- het contentframe groeit gecontroleerd naar 2.200 px op widescreen en 2.480 px op 4K, zonder onbeperkte kaart- of leesregelbreedtes;
- 18 shellcombinaties over dashboard, verkenner en automatisering op 390, 1280, 1920, 2560 en 3840 px plus 1280×600 hadden geen overlap, overflow of consolemelding;
- login en sidebar zijn aanvullend in dark/light gecontroleerd: transparante logoachtergrond, één zichtbare accountlocatie en correcte themabehandeling.
- live release `sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921` is healthy; readiness, favicon, transparant productlogo en CSS 0.3.11 antwoorden met HTTP 200.
+65
View File
@@ -0,0 +1,65 @@
# VR-206 volledige UX-, logica- en browseraudit
Datum: 2026-07-22
## Uitgangssituatie
De productiecontrole toonde 242 actieve vacatures. De standaardlijst sorteerde hoofdzakelijk op
`first_seen` en liet elke actieve vacature zien. Daardoor stonden onder meer facilitaire techniek,
logopedie, vroedkunde en niet-IT-onderzoek tussen de persoonlijke IT-resultaten. Op 2560 px bleef
de inhoud bovendien op ongeveer 1280 px begrensd, waardoor de Stitch-cockpit een brede lege zone
overliet.
## Bevindingen en oplossing
| Prioriteit | Bevinding | Oplossing |
| --- | --- | --- |
| P0 | Niet-IT-vacatures bereikten Vandaag en de standaardfeed. | Titelgestuurde, deterministische IT-classificatie vóór score/AI; niet-IT blijft alleen in het expliciete volledige archief. |
| P0 | Vandaag gebruikte niet overal de laatste gebruikersgescopeerde score en open dossiercijfers waren niet volledig gebruikersgescopeerd. | Laatste score per vacature/profiel via subquery; profiel, scores en dossiers zijn aan de aangemelde gebruiker gebonden. |
| P1 | Verborgen/hard uitgesloten IT-vacatures stonden hoog door hun ruwe score. | Nieuwe standaardkeuze `Radarselectie` laat verborgen resultaten weg; `Verborgen` en `Alle IT-scores` blijven bewuste inspectiekeuzes. |
| P1 | De lijst sorteerde op bronrecency in plaats van persoonlijke besliswaarde. | Standaard matchscore aflopend, met alternatieven voor nieuwste en sluitingsdatum. |
| P1 | Stitch werd visueel gebruikt, maar ultrawide bleef een smalle strook. | Adaptieve contentbreedte tot 2240 px, tweekoloms feeds vanaf 1850 px en context-/actierails op breed desktop. |
| P1 | Vacaturekaarten boden weinig scorebewijs. | Score, datakwaliteit, eerste pluspunt, eerste aandachtspunt, status, werkmodel en directe actie staan in de feed. |
| P2 | Confidencewaarden wisselden tussen breuken en percentages. | Alle dagelijkse score- en extractiekwaliteit wordt als percentage getoond. |
| P2 | Inline feedback accepteerde een willekeurige `next`-URL. | Terugkeer alleen naar dezelfde host, met negatieve regressietest. |
| P2 | De bronpagina liep op 320 px uit door statuschips naast lange sectiekoppen. | Sectiekoppen stapelen onder 680 px; de 320 px matrix heeft geen horizontale overflow. |
| P2 | Sommige browsers vroegen `/favicon.ico` en kregen een 404. | Lokale permanente fallback naar het bestaande SVG-favicon. |
## Relevantieregel
De classifier is bewust conservatief en titelgestuurd. Een beschrijving die toevallig “digitale
systemen” vermeldt, maakt een zorg- of facilitaire functie niet relevant. Duidelijke IT-families
zoals infrastructuur, netwerk, cloud, security, software, data, applicatiebeheer en workplace
worden toegelaten. Commerciële of recruitmentcontext die IT-woorden bevat, wordt expliciet
afgewezen. Dezelfde regexcontracten worden in Python en in de databasequery gebruikt.
Op de lokale auditdataset met 336 actieve records bleven 108 titelmatig aantoonbare IT-vacatures
over en werden 228 niet-IT-records uit de standaardfocus gehouden. Na uitsluiting van verborgen
matches bevatte de persoonlijke radarselectie 102 kaarten. Het volledige archief blijft 336
records bevatten: er wordt geen bronhistoriek gewist.
## Stitch-vertaling
- Dashboard: KPI-strip, beslisbare matchfeed en vaste radar-/actierail.
- Vacaturelijst: brede commandofilter, compacte evidencekaarten, scorehiërarchie en radarcontext.
- Detail: scorehero, intelligence-strip, bronhistorie en menselijke actierail.
- Shell: vaste desktopnavigatie, compacte utilitybar, lokale iconen en mobiele drawer.
- Ultrawide: twee inhoudskolommen plus context; geen kunstmatig uitgerekte tekstregels of fictieve grafieken.
Alle cijfers en signalen komen uit de database. Quick apply, automatische sollicitatie,
platformloginbots, fictieve trendgrafieken en externe frontendassets blijven bewust afwezig.
## Browsermatrix
Handmatig gecontroleerd in de geïntegreerde browser op 320×800, 390×844, 1024×768, 1440×900,
1920×1080 en 2560×1440. De zes kernroutes (`/`, `/jobs/`, `/jobs/applications/`, `/profiles/`,
`/sources/`, `/system/`) hebben één `h1`, gelabelde formuliervelden en geen horizontale
paginaoverflow. De vacaturefeed schakelt van één kolom naar twee kolommen op ultrawide en gebruikt
daar 2240 px van de beschikbare ruimte. Mobiele navigatie opent en sluit met correcte
`aria-expanded`-status.
## Resterend operationeel punt
Na deployment moet de bestaande productie-inventaris opnieuw gescoord worden zodat opgeslagen
score-evidence eveneens het nieuwe `it_relevance`-blok bevat. De viewfilter werkt onmiddellijk en
onafhankelijk daarvan; het herscoren verbetert de uitleg en blijvende `hidden`-status.
+136
View File
@@ -0,0 +1,136 @@
# Nginx Proxy Manager en publiek HTTPS-domein
Deze handleiding publiceert VacatureRadar achter Nginx Proxy Manager zonder de Django-container rechtstreeks op internet bloot te stellen.
## Waarom `DisallowedHost` verschijnt
Django weigert onbekende `Host`-headers bewust. Een publieke hostnaam moet daarom expliciet overeenkomen met:
- `PUBLIC_BASE_URL`;
- `DJANGO_ALLOWED_HOSTS`;
- `DJANGO_CSRF_TRUSTED_ORIGINS`.
De applicatie leidt de host en CSRF-origin automatisch af uit `PUBLIC_BASE_URL`, maar de expliciete lijsten blijven ondersteund voor LAN- en beheerhosts.
Een publieke Django-debugpagina is een afzonderlijk beveiligingsprobleem. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; debugpagina's tonen interne paden en implementatiedetails.
## VacatureRadar configureren
Voer dit uit in de huidige bronmap op Unraid:
```bash
cd /mnt/user/appdata/VacatureRadar
python scripts/configure_public_url.py \
https://vacatureradar.itworx.tech \
--env-file /mnt/user/appdata/VacatureRadar/.env \
--cache-url redis://127.0.0.1:6379/1
```
De helper:
- bewaart bestaande secrets en databasewachtwoorden;
- zet `DJANGO_DEBUG=0`;
- voegt de host en CSRF-origin toe;
- activeert secure cookies en HTTPS-redirect;
- zet een conservatieve HSTS-periode;
- activeert reverse-proxyherkenning;
- maakt de gastdemo alleen-lezen;
- gebruikt Redis voor gedeelde rate limiting en readiness;
- schrijft het bestand atomair met modus `0600`.
Hermaak daarna de container. Een gewone restart laadt gewijzigde environmentvariabelen niet altijd opnieuw:
```bash
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
```
Voor een andere omgeving kan `deployment/production.env.example` als checklist worden gebruikt.
## Proxy Host in Nginx Proxy Manager
Gebruik voor de Proxy Host:
| Instelling | Waarde |
|---|---|
| Domain Names | `vacatureradar.itworx.tech` |
| Scheme | `http` |
| Forward Hostname / IP | het LAN-IP of de Docker-hostnaam van VacatureRadar |
| Forward Port | `1226` |
| Cache Assets | uit, tenzij expliciet getest |
| Block Common Exploits | aan |
| Websockets Support | niet vereist |
Op het tabblad **SSL**:
- vraag een geldig certificaat aan;
- activeer Force SSL;
- activeer HTTP/2;
- activeer HSTS pas nadat HTTPS correct werkt;
- activeer HSTS subdomains alleen wanneer alle subdomeinen HTTPS ondersteunen.
Nginx Proxy Manager stuurt de noodzakelijke proxyheaders normaal al door. Gebruik bij een aangepaste configuratie minstens:
```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
```
Overschrijf deze headers aan de proxyzijde; laat een internetclient ze niet vrij bepalen.
## Correcte client-IP-rate-limiting
VacatureRadar vertrouwt `X-Forwarded-For` alleen wanneer de directe afzender in `TRUSTED_PROXY_CIDRS` staat. Zonder deze instelling blijft rate limiting veilig op het proxy-IP werken, maar worden bezoekers niet individueel onderscheiden.
Bepaal het proxy-IP of Docker-netwerk en voeg alleen dat bereik toe, bijvoorbeeld:
```bash
python scripts/configure_public_url.py \
https://vacatureradar.itworx.tech \
--env-file /mnt/user/appdata/VacatureRadar/.env \
--cache-url redis://127.0.0.1:6379/1 \
--trusted-proxy-cidr 172.18.0.0/16
```
Gebruik geen brede private range zoals `10.0.0.0/8` of `172.16.0.0/12` zonder aantoonbare noodzaak.
## Validatie
Controleer eerst intern:
```bash
curl -fsS http://127.0.0.1:1226/health/live/
curl -fsS http://127.0.0.1:1226/health/ready/
```
Controleer daarna publiek:
```bash
curl -I https://vacatureradar.itworx.tech/
curl -fsS https://vacatureradar.itworx.tech/robots.txt
```
Verwacht:
- geen `DisallowedHost`;
- geen Django-debugpagina;
- een redirect naar login of HTTP 200 na authenticatie;
- `Strict-Transport-Security` zodra HSTS actief is;
- `X-Content-Type-Options: nosniff`;
- `X-Frame-Options: DENY`;
- `Content-Security-Policy`;
- standaard `X-Robots-Tag: noindex, nofollow, noarchive` voor een persoonlijke installatie.
Controleer ook een echte login, logout en één CSRF-beschermde actie. Alleen een succesvolle GET bewijst niet dat proxy-HTTPS en CSRF correct zijn.
## Terugdraaien
Bij een probleem:
1. zet de Proxy Host tijdelijk op access-list/VPN-only of offline;
2. herstel de vorige `.env` uit de beheerde back-up;
3. hermaak de container;
4. controleer intern `/health/ready/`;
5. publiceer pas opnieuw nadat login en CSRF via HTTPS slagen.
+87 -41
View File
@@ -1,26 +1,54 @@
# Unraid-deployment # Unraid-deployment
Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van waarheid. Unraid-installaties verschillen in gebruikte Compose Manager, reverse proxy en shares; behoud de hieronder genoemde volumes, secrets en netwerkgrenzen ook wanneer de UI de services afzonderlijk aanmaakt. Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van waarheid. De Unraid-variant bundelt web, worker, scheduler, PostgreSQL en Redis bewust in één Dockerman-container met Supervisor als procesbewaker. De gewone `docker-compose.yml` behoudt de gescheiden productieprocessen voor andere hosts.
## Vooraf ## Vooraf
Maak deze directories: Alle paden in `docker-compose.unraid.yml` zijn relatief aan de map waarin dat bestand staat. Je kopieert de projectmap dus naar een plek naar keuze; de aanbevolen locatie blijft:
```text ```text
/mnt/user/appdata/vacatureradar/config /mnt/user/appdata/VacatureRadar
/mnt/user/appdata/vacatureradar/media
/mnt/user/appdata/vacatureradar/logs
/mnt/user/appdata/vacatureradar/postgres
/mnt/user/appdata/vacatureradar/redis
/mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI
/mnt/user/backups/vacatureradar
``` ```
De container maakt `local/`, `local/media`, `local/logs`, `local/postgres-aio17`, `local/redis-aio` en `local/backups` bij de eerste start zelf aan en zet de juiste eigenaar. Voor back-ups blijft `/mnt/user/backups/vacatureradar` de aanbevolen doelmap.
Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd. Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd.
## Manueel kopiëren en uitrollen
Wanneer je niet via `scripts/deploy_unraid.sh` werkt maar de map met de hand kopieert (rsync, SMB-share, ZIP):
```bash
cd /mnt/user/appdata/VacatureRadar
cp .env.unraid.example .env # alleen de eerste keer
nano .env # alle CHANGE_ME-waarden vervangen
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
docker compose -f docker-compose.unraid.yml logs -f
```
Kopieer `local/` niet mee vanaf je werkstation: die map bevat de databasedirectory van de server. Kopieer `.env` evenmin over een bestaand serverbestand heen.
Een afwijkende hostpoort zet je in `.env` via `VACATURERADAR_HOST_PORT`; standaard is dat `1226`.
## Dockerman
De container verschijnt als **VacatureRadar** en bevat metadata voor:
- WebUI: `http://[IP]:1226/`;
- icoon: het lokale 256×256 VacatureRadar-PNG op `/static/img/vacatureradar-docker.png`;
- één herstart- en healthcheckpunt voor alle interne processen.
Dockerman haalt het tegelicoon via de gepubliceerde webpoort op en cachet het. De
PNG is daarom een afzonderlijke rasterasset met een stabiel pad; de SVG-favicon
blijft voor browsers behouden. Het icoonlabel gebruikt bewust de concrete
server-side URL `http://127.0.0.1:1226/...`: Dockerman vervangt `[IP]` en
`[PORT:...]` alleen voor de WebUI, niet voordat het een icoon downloadt. Na een
upgrade waarbij het oude icoon nog zichtbaar is, herlaad de Docker-pagina zodat
Dockerman de lokale cache opbouwt.
## Imagekeuze ## Imagekeuze
De Unraid-compose bevat bewust `ghcr.io/CHANGE_ME/vacatureradar:latest`. Codex kan code, Dockerfile en CI afwerken zonder registrycredentials, maar voor productie moet een van deze paden worden gekozen: De Unraid-compose bouwt lokaal met `Dockerfile.unraid` en tagt de image als `vacatureradar:unraid`. Pin voor reproduceerbare releases aanvullend een commitgebonden tag.
1. bouw lokaal op de Unraid-host en vervang `image:` door een vaste lokale tag; 1. bouw lokaal op de Unraid-host en vervang `image:` door een vaste lokale tag;
2. publiceer vanuit CI naar een private registry en pin op een immutable digest; 2. publiceer vanuit CI naar een private registry en pin op een immutable digest;
@@ -30,50 +58,68 @@ Gebruik in productie geen zwevende `latest` zonder gecontroleerd rollbackpad.
## Configuratiebestand ## Configuratiebestand
Kopieer of genereer `.env` naar: `docker-compose.unraid.yml` leest `.env` naast het composebestand zelf:
De deploy-helper `scripts/deploy_docker.sh` vult ontbrekende sleutels op basis van veilige defaults.
Zet voor publieke productie vooraf de gewenste variabelen:
```text ```text
/mnt/user/appdata/vacatureradar/config/.env <projectmap>/.env
APP_HOST=jobs.example.be
APP_SCHEME=https
DJANGO_DEBUG=0
SESSION_COOKIE_SECURE=1
CSRF_COOKIE_SECURE=1
SECURE_SSL_REDIRECT=1
``` ```
Gebruik daarna de helper (zie hieronder). Een afwijkende locatie kan vóór Compose via `VACATURERADAR_ENV_FILE` worden ingesteld. Start van [`.env.unraid.example`](../../.env.unraid.example) voor een all-in-one container die rechtstreeks op poort 1226 draait, of van [`deployment/production.env.example`](../../deployment/production.env.example) zodra er een reverse proxy met TLS voor staat. Kopieer nooit ongewijzigde `CHANGE_ME`-waarden.
Configureer een bestaand bestand voor de publieke URL zonder secrets te overschrijven:
```bash
cd /mnt/user/appdata/VacatureRadar
python scripts/configure_public_url.py \
https://vacatureradar.example.be \
--env-file .env \
--cache-url redis://127.0.0.1:6379/1
```
Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs. Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs.
## Eerste uitrol ## Eerste uitrol
1. Controleer alle `CHANGE_ME`-waarden. 1. Controleer alle `CHANGE_ME`-waarden en maak een back-up van een bestaande `.env`.
2. Gebruik de deploy-helper voor een snelle eerste start: 2. Start of hermaak de Unraid-container vanuit de bronmap:
```bash ```bash
cd /app/VacatureRadar cd /mnt/user/appdata/VacatureRadar
APP_HOST=jobs.example.be APP_SCHEME=https DJANGO_DEBUG=0 \ docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \
bash scripts/deploy_docker.sh
``` ```
3. Start eerst PostgreSQL en Redis. 3. De entrypoint valideert de productieconfiguratie, initialiseert PostgreSQL, voert migraties uit en start alle processen onder Supervisor.
4. Start web; de entrypoint voert migraties uit. 4. Controleer intern `GET /health/live/` en `GET /health/ready/`.
5. Start worker en scheduler. 5. Voer alleen wanneer bootstrap niet automatisch is ingeschakeld eenmalig uit:
6. Controleer `GET /health/live/` en `GET /health/ready/`.
6. Voer eenmalig in de webcontainer uit:
```bash ```bash
python manage.py bootstrap_instance docker exec -it VacatureRadar python manage.py bootstrap_instance
python manage.py collectstatic --noinput
``` ```
7. Meld lokaal aan en wijzig het bootstrapwachtwoord. 6. Meld lokaal aan en wijzig het bootstrapwachtwoord.
8. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd. 7. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd.
## Beheerverbinding en heruitrol
Gebruik vanaf een beheerwerkstation altijd een benoemde SSH-hostalias met een afzonderlijke deploysleutel. Een rechtstreekse verbinding naar het IP-adres kan de lokale standaardgebruiker en standaardsleutel kiezen en daardoor ten onrechte als een server-side authenticatiefout lijken.
```sshconfig
Host unraid-itworx
HostName 192.168.10.150
Port 22
User root
IdentityFile ~/.ssh/itworx_unraid_deploy
IdentitiesOnly yes
```
Controleer de verbinding zonder wachtwoordprompt en voer daarna de beveiligde synchronisatie, imagebuild, containervervanging en health-smoke uit:
```bash
ssh -o BatchMode=yes unraid-itworx 'id -un && docker info >/dev/null'
bash scripts/deploy_unraid.sh
```
`deploy_unraid.sh` weigert een IP-adres, niet-rootprofiel of generieke standaardsleutel. Alleen door Git gekende of niet-genegeerde projectbestanden worden verpakt; `.env`, `local/`, databasebestanden en andere genegeerde runtimegegevens worden niet verzonden. De helper wacht op Docker-health en controleert `/health/ready/` voordat hij succes meldt. Een afwijkende veilige alias kan expliciet via `UNRAID_SSH_HOST` worden ingesteld.
## Releaseverificatie en upgradepad ## Releaseverificatie en upgradepad
@@ -99,7 +145,7 @@ Expose de applicatie niet rechtstreeks op internet. Plaats Nginx Proxy Manager,
- optioneel extra access control/VPN voor persoonlijke installatie; - optioneel extra access control/VPN voor persoonlijke installatie;
- geen publieke toegang tot PostgreSQL, Redis of Ollama. - geen publieke toegang tot PostgreSQL, Redis of Ollama.
Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. Volg voor host-, CSRF-, proxyheader- en TLS-instellingen de [Nginx Proxy Manager-handleiding](NGINX_PROXY_MANAGER.md).
## Mailbox ## Mailbox
@@ -144,10 +190,10 @@ Pin een modelnaam in `OLLAMA_MODEL`; zonder model blijft `OLLAMA_ENABLED=0`. Ste
2. Lees `CHANGELOG.md` en de releaseartefacten. 2. Lees `CHANGELOG.md` en de releaseartefacten.
3. Als de release nog niet geverifieerd is, voer `./scripts/release_verify.sh` lokaal uit. 3. Als de release nog niet geverifieerd is, voer `./scripts/release_verify.sh` lokaal uit.
4. Pull/bouw de nieuwe immutable image. 4. Pull/bouw de nieuwe immutable image.
5. Stop scheduler en worker, daarna web. 5. Stop de appcontainer.
6. Start web en laat migraties uitvoeren. 6. Start de nieuwe appcontainer en laat de entrypoint migraties uitvoeren.
7. Controleer health, login en release-smoke-rapport. 7. Controleer health, login en release-smoke-rapport.
8. Start worker en scheduler. 8. Controleer in de containerlogs dat web, worker en scheduler actief zijn.
9. Bewaar de vorige image/digest totdat één volledige schedulercyclus goed verliep. 9. Bewaar de vorige image/digest totdat één volledige schedulercyclus goed verliep.
## Rollback ## Rollback
+1 -1
View File
@@ -97,7 +97,7 @@ De IDs zijn stabiel en worden gebruikt in tests en traceability.
### Matching en ranking ### Matching en ranking
- **PR-040** — Harde regels worden vóór scoring toegepast en zijn uitlegbaar. - **PR-040** — Harde regels worden vóór scoring toegepast en zijn uitlegbaar.
- **PR-041** — Scorecomponenten omvatten inhoud, skills, locatie, voorwaarden, werkgever, senioriteit en voorkeuren. - **PR-041** — Scorecomponenten omvatten inhoud, skills, locatie, voorwaarden, werkgever en voorkeuren. Ervaringsduur en senioriteitslabels blijven uitsluitend informatieve context en beïnvloeden score, aanbeveling of uitsluiting niet.
- **PR-042** — Onbekende data verlaagt confidence en wordt niet automatisch als negatief feit behandeld. - **PR-042** — Onbekende data verlaagt confidence en wordt niet automatisch als negatief feit behandeld.
- **PR-043** — Iedere score bewaart componenten, positives, concerns, uitsluitingen en evidence. - **PR-043** — Iedere score bewaart componenten, positives, concerns, uitsluitingen en evidence.
- **PR-044** — Profielversie en model-/promptversie worden aan een score gekoppeld. - **PR-044** — Profielversie en model-/promptversie worden aan een score gekoppeld.
@@ -0,0 +1,52 @@
# Productie-audit — 27 juli 2026
## Samenvatting
VacatureRadar heeft al een volwassen functionele en visuele basis. De audit vond geen noodzaak voor een nieuwe frontendarchitectuur; de belangrijkste professionaliseringswinst zat in de grens tussen Django, Nginx Proxy Manager en de Unraid-runtime.
De gerapporteerde `DisallowedHost` was een symptoom van ontbrekende publieke hostconfiguratie. De publiek zichtbare Django-debugpagina maakte tegelijk duidelijk dat de runtime nog ontwikkelinstellingen gebruikte. Deze release behandelt daarom hostvalidatie, HTTPS-detectie, CSRF, gedeelde rate limiting, readiness en gasttoegang als één productiecontract.
## Bevindingen en herstel
| Prioriteit | Bevinding | Risico | Herstel |
|---|---|---|---|
| Kritiek | Publieke host ontbrak in `ALLOWED_HOSTS` | onbeschikbare publieke route | `PUBLIC_BASE_URL` leidt host en CSRF-origin af; configuratiehelper toegevoegd |
| Kritiek | `DEBUG=True` was publiek zichtbaar | lek van interne paden en implementatiedetails | productiehelper zet debug uit; productiecheck stopt bij onveilige configuratie |
| Hoog | HTTPS-origin kon de interne poort `1226` bevatten | CSRF-fouten achter poort 443 | publieke en interne poort zijn gescheiden in deployscripts |
| Hoog | Secure redirect kon interne healthchecks omleiden | container blijft unhealthy na HTTPS-hardening | alleen `/health/live/` en `/health/ready/` zijn intern vrijgesteld |
| Hoog | Loginrate-limiting gebruikte proceslokale cache | limiet verschilde per Gunicornworker | Redis-cache als gedeelde productiestandaard en readinessdependency |
| Hoog | `X-Forwarded-For` werd zonder proxyvertrouwen gebruikt | IP-spoofing en omzeilbare rate limiting | forwarded chain alleen bij expliciet vertrouwde proxy-CIDR |
| Hoog | Gedeelde demo kon mutaties uitvoeren | bezoekers beïnvloeden elkaars demo en persistente data | server-side read-only middleware, begrensde sessie en zichtbare UI-status |
| Middel | Unraid-documentatie wees naar een andere `.env` dan Compose | wijzigingen werden niet geladen | pad gestandaardiseerd op `source/.env`; override blijft mogelijk |
| Middel | Deployhelper schreef een custom `.env`, maar Compose kon alsnog de standaardfile lezen | productie-instellingen werden stil genegeerd | geselecteerd env-bestand wordt nu voor Compose-interpolatie én containeromgeving gebruikt |
| Middel | AIO Compose interpoleerde databasewachtwoord buiten `env_file` | custom env-pad kon een kapotte URL leveren | database-URL wordt uitsluitend in de containerentrypoint opgebouwd |
| Middel | Persoonlijke installatie kon geïndexeerd worden | onbedoelde vindbaarheid van login en productmetadata | `robots.txt` en `X-Robots-Tag` standaard op noindex |
| Middel | Runtime startte zonder Django deployment check | foutieve securityconfiguratie werd laat ontdekt | `manage.py check --deploy` vóór migraties en processtart |
| Laag | Project- en assetversies liepen uiteen | cache- en release-identiteit onduidelijk | releaseversie gecentraliseerd op `0.3.12` |
| Laag | Overdrachtsarchief bevatte lokale Windows/runtimeartefacten | onnodige omvang en risico op secrets of stale state | bestaande schone packager en integriteitsmanifest verplicht gebruikt |
## Bewust niet gewijzigd
- De server-rendered Django-architectuur blijft behouden.
- De Stitch Intelligence Cockpit blijft de productiefrontend; er is geen SPA of extern runtime-CDN toegevoegd.
- De containerpoort blijft op Unraid bereikbaar voor Nginx Proxy Manager. Netwerksegmentatie of firewalling blijft een live infrastructuurtaak.
- HSTS start conservatief op 300 seconden. Verhoog pas na een geslaagde publieke login-, CSRF- en subdomeincontrole.
- Zoekmachine-indexering blijft standaard uit omdat dit een persoonlijke installatie is.
## Live controles na deployment
1. Hermaak de container met de bestaande `.env`.
2. Controleer intern beide health-endpoints.
3. Controleer publiek dat HTTP naar HTTPS gaat.
4. Controleer login en logout.
5. Voer één echte CSRF-beschermde actie uit met het beheerdersaccount.
6. Controleer dat het demoaccount geen mutatie kan uitvoeren.
7. Controleer securityheaders en afwezigheid van een debugpagina.
8. Controleer in Nginx Proxy Manager dat alleen de proxy de hostpoort kan bereiken.
9. Maak een back-up en voer een herstel-smoke uit vóór verdere bronautomatisering.
## Resterende externe punten
- De echte Nginx Proxy Manager-configuratie en het proxy-IP/CIDR zijn niet in de repository zichtbaar.
- De live Unraid-container, database en Redis zijn in deze auditomgeving niet bereikbaar.
- Een volledige Dockerbuild, publieke TLS-smoke en hersteltest moeten daarom op de doelserver worden uitgevoerd.

Some files were not shown because too many files have changed in this diff Show More