Author SHA1 Message Date
Jens db9f340130 Use Gitea-compatible artifact upload
release / verify-publish (push) Successful in 5m34s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:31:30 +02:00
Jens aa1e6e50c1 Generate release SBOM with Syft
release / verify-publish (push) Failing after 5m33s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:25:07 +02:00
Jens 7991b04b35 Install browser dependencies for release gate
release / verify-publish (push) Failing after 5m11s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:18:35 +02:00
Jens 8073533c16 Provision uv in release workflow
release / verify-publish (push) Failing after 1m48s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:14:37 +02:00
Jens db7d96a546 Fix release verification invocation
release / verify-publish (push) Failing after 4s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:13:26 +02:00
Jens 6da5d75acd Remove personal address from environment example
deploy / deploy (push) Canceled after 0s
release / verify-publish (push) Failing after 11m34s
2026-07-30 02:00:23 +02:00
Jens 67be350283 Harden live mail OAuth and immutable releases
deploy / deploy (push) Canceled after 0s
2026-07-30 02:00:09 +02:00
Jens 1397f085bd docs: record 0.3.14 production acceptance
deploy / deploy (push) Canceled after 0s
2026-07-29 19:07:05 +02:00
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
132 changed files with 6332 additions and 884 deletions
+19
View File
@@ -1,9 +1,13 @@
# Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME.
DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value
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_CSRF_TRUSTED_ORIGINS=http://localhost:1226
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.
POSTGRES_DB=vacatureradar
@@ -13,6 +17,9 @@ POSTGRES_HOST=postgres
POSTGRES_PORT=5432
DATABASE_URL=
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
# Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat.
@@ -29,6 +36,10 @@ SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_USE_TLS=1
M365_TENANT_ID=
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_MAILBOX_USER=
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
@@ -66,14 +77,22 @@ 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
# 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
CSRF_COOKIE_SECURE=0
SECURE_SSL_REDIRECT=0
SECURE_HSTS_SECONDS=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
+113
View File
@@ -0,0 +1,113 @@
# 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=admin@example.invalid
VACATURERADAR_ADMIN_EMAIL=admin@example.invalid
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
M365_TENANT_ID=
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_MAILBOX_USER=
# 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
+57
View File
@@ -0,0 +1,57 @@
name: release
on:
push:
tags:
- "v*.*.*"
jobs:
verify-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.13"
- name: Install verification browser
run: |
uv sync --extra browser
uv run playwright install --with-deps chromium
- name: Verify release
run: bash ./scripts/codex_verify.sh
- name: Build immutable image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
registry="gitea.itworx.tech"
image="${registry}/jens/vacatureradar:${version}"
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
docker build --file Dockerfile.unraid \
--build-arg "VCS_REF=${GITHUB_SHA}" \
--build-arg "APP_VERSION=${version}" \
--tag "$image" .
docker push "$image"
docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
anchore/syft:v1.33.0 \
"docker:$image" -o spdx-json > sbom.spdx.json
cat > RELEASE_NOTES.md <<EOF
# VacatureRadar ${version}
Immutable release image built from commit ${GITHUB_SHA}.
The accompanying digest, SPDX SBOM and checksums are release evidence.
EOF
sha256sum image-digest.txt sbom.spdx.json RELEASE_NOTES.md > SHA256SUMS
- name: Publish release evidence
uses: actions/upload-artifact@v3
with:
name: release-evidence-${{ github.ref_name }}
path: |
image-digest.txt
sbom.spdx.json
SHA256SUMS
RELEASE_NOTES.md
+21
View File
@@ -24,6 +24,8 @@ jobs:
run: python -m pip install --disable-pip-version-check uv==0.10.0
- name: Sync locked dependencies
run: uv sync --frozen --all-groups
- name: Install Chromium for mandatory browser tests
run: uv run playwright install --with-deps chromium
- name: Secret scan
uses: trufflesecurity/trufflehog@v3.79.0
with:
@@ -47,6 +49,13 @@ jobs:
IMAP_ENABLED: "0"
OLLAMA_ENABLED: "0"
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:
runs-on: ubuntu-latest
@@ -64,5 +73,17 @@ jobs:
severity: HIGH,CRITICAL
exit-code: "1"
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
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
+1
View File
@@ -17,6 +17,7 @@ local/
.env
.env.*
!.env.example
!.env.unraid.example
secrets/
# IDE / OS
+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.
+58
View File
@@ -2,6 +2,64 @@
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
+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 draait publiek als image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`. Voor de vaste 250-itemimport daalt de gemiddelde duur met 44,74% en het queryvolume met minstens 63,73%, met behoud van parser-, dedupe-, ranking-, provenance- en scorehistoriekcontracten. Alleen optionele externe identity- en mailboxcredentials staan nog in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md).
+27
View File
@@ -0,0 +1,27 @@
# 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.14
- Actieve image: `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`; container healthy, nul restarts en publieke readiness HTTP 200.
- De versievariabele is 0.3.14, alle migrations zijn toegepast en de gastkernroutes leveren HTTP 200.
- Back-up: `backups/release-0.3.14-20260729T164500Z`; rollback: tag `vacatureradar:rollback-0.3.13`, image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`.
- Rollback vereist geen datamigratie: retag de rollbackimage naar `vacatureradar:unraid` en recreate alleen service `app` met `--no-build`.
## 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 . .
RUN uv sync --frozen --no-dev \
&& 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 \
&& chown -R app:app /app \
&& chmod -R 0775 /app/media /app/logs /app/local /app/staticfiles /app/.venv
+10 -1
View File
@@ -1,6 +1,12 @@
# syntax=docker/dockerfile:1.7
FROM python:3.13-slim-trixie
ARG VCS_REF=unknown
ARG APP_VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.version=$APP_VERSION
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
@@ -24,7 +30,10 @@ RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev \
&& 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 /var/log/supervisor \
&& chown -R app:app /app
+14
View File
@@ -0,0 +1,14 @@
# 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
Release 0.3.14 draait healthy op image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`, zonder containerrestarts. Publieke readiness en zeven gastkernroutes antwoorden 200. De begrensde productie-importsmoke is volledig teruggerold. Performancebewaking kan worden herhaald met `scripts/run_import_benchmark.sh`; de runner weigert productiedatabases en externe hosts.
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
+171 -137
View File
@@ -1,205 +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 presenteert vacatures, skillsvraag, werkgevers, opvolging en bronstatus in een Stitch-gebaseerde Intelligence Cockpit.
<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 de volledige Nederlandstalige, responsive dark/light Stitch Intelligence Cockpit;
- 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, auditbare sollicitatiepipeline en activiteitstijdlijn;
- werkgeversintelligence, persoonlijke skillsradar en eerlijke operationele automatiseringsstatus;
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
- Docker Compose voor lokaal gebruik en een Unraid-variant;
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
## Voor wie is dit?
Nog niet als productievoltooid beschouwd:
VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installatie die:
- periodieke menselijke voorwaardenreview voor bijkomende live werkgeversbronnen;
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry.
- meerdere vacaturebronnen wil volgen zonder iedere site dagelijks te openen;
- alleen aantoonbaar relevante vacatures wil beoordelen;
- 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
## Frontend en visuele validatie
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.
De enige normale browserfrontend is de server-rendered Stitch Intelligence Cockpit. Alle negen referentieschermen zijn aan echte Django-data gekoppeld; er is geen SPA, runtime-CDN, demo-KPI of oude parallelle shell. De ontwerpcontracten en gemotiveerde afwijkingen staan in:
## Wat zit erin?
- [`STITCH_FRONTEND_CONTRACT.md`](docs/design/STITCH_FRONTEND_CONTRACT.md);
- [`STITCH_INTEGRATION_PLAN.md`](docs/design/STITCH_INTEGRATION_PLAN.md);
- [`STITCH_INTEGRATION_REPORT.md`](docs/design/STITCH_INTEGRATION_REPORT.md).
| 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 |
De elf desktop-/mobielbeelden staan in `artifacts/visual-validation/` en zijn uitgesloten van de productiecontainer. De browsermatrix bestrijkt 360 tot 2560 px.
## Bekijk de volledige productflow
Release-productiepad:
De beelden komen uit de lokale demo-omgeving. De getoonde cijfers zijn omgevingsdata, geen marketingclaims. Klik op een beeld voor de volledige resolutie.
- `./scripts/release_verify.sh` bouwt de smoke-, backup/restore- en artifactcontrole;
- [`docs/operations/UNRAID_DEPLOYMENT.md`](docs/operations/UNRAID_DEPLOYMENT.md) bevat een expliciet upgrade/rollbackpad met releaseartefacten.
### 1. Vind de relevante kansen
## Snelste start voor Codex
De verkenner brengt zoeken, filters, gerangschikte resultaten en de belangrijkste matchcontext samen. Zo hoef je niet tussen lijst- en detailpaginas te blijven wisselen.
Open de uitgepakte repository als project en geef Codex deze opdracht:
<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>
```text
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.
```
### 2. Begrijp waarom een vacature past
De repository-instructies staan in [`AGENTS.md`](AGENTS.md). De handmatige startpagina staat in [`CODEX_START_HERE.md`](CODEX_START_HERE.md).
Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broninformatie afzonderlijk zichtbaar. Ervaring en senioriteitslabels worden als context getoond, niet als verborgen scorefactor.
## Lokale quickstart
<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>
Vereisten: Python 3.12 of 3.13 en `uv`.
### 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
./scripts/codex_bootstrap.sh
uv run python manage.py runserver 0.0.0.0:8080
```
Het bootstrap-script:
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:
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:
```text
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
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
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
```text
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/`
- `http://127.0.0.1:1226/health/live/`
## Eerste configuratie
Zet voor publieke productie:
- `APP_HOST=<jouw-domein>`
- `APP_SCHEME=https`
- `DJANGO_DEBUG=0`
- `SESSION_COOKIE_SECURE=1`
- `CSRF_COOKIE_SECURE=1`
- `SECURE_SSL_REDIRECT=1`
Na de eerste login:
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
- `bash scripts/deploy_docker.sh` op de server
- automatische healthcheck + `collectstatic`
VacatureRadar:
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
uv run python scripts/package_project.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
```text
apps/ Django-domeinmodules
core/ dashboard, health en beveiligingsheaders
profiles/ zoekprofielen en revisies
sources/ bronbeleid, fetcher, adapters en IMAP
jobs/ normalisatie, dedupe, scoring en sollicitaties
notifications/ digest-outbox en verzending
config/ Django/Celery-configuratie
config-data/ voorbeeldprofielen en bronbeleid
fixtures/ deterministische parser- en integratietestdata
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
apps/ Django-domeinen en bedrijfsservices
config/ Django- en Celery-configuratie
config-data/ Bronbeleid en gesaneerde voorbeeldconfiguratie
fixtures/ Deterministische externe testformaten
templates/ + static/ Server-rendered productinterface
docs/ Product-, ontwerp-, security- en operationele documentatie
deployment/unraid/ Unraid-installatiebestanden
scripts/ Bootstrap, verificatie, deployment, back-up en beheer
tests/ Unit-, integratie-, security- en browsertests
```
## 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:
- logt niet automatisch in op vacaturesites;
- omzeilt geen CAPTCHA, rate limit of toegangscontrole;
- 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)
- [Bijdragen](CONTRIBUTING.md)
- [Projectstart voor Codex](CODEX_START_HERE.md)
- [Architectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
- [UX- en designcontract](docs/design/STITCH_FRONTEND_CONTRACT.md)
- [Teststrategie](docs/quality/TEST_STRATEGY.md)
- [Traceabilitymatrix](docs/quality/TRACEABILITY_MATRIX.md)
- [Oorspronkelijk functioneel en technisch ontwerp](docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.pdf)
- [Verificatierapport van de aangeleverde basis](VERIFICATION_REPORT.md)
- [Actuele projectstatus](docs/ai/PROJECT_STATE.md)
- [Machineleesbare backlog](docs/ai/BACKLOG.yaml)
## 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;
- geen secrets in logs of databasefixtures;
- 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
@@ -42,7 +46,7 @@ Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documente
4. voorgestelde mitigatie;
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
+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
- Omgeving: Linux, Python 3.13.5, uv 0.10.0
- Projectversie: 0.1.0
- Doel: aantonen dat de ZIP een reproduceerbare, werkende MVP-basis en autonome overdracht bevat
- Datum: 27 juli 2026
- Onderwerp: productiehardening, Nginx Proxy Manager, gastmodus en overdrachts-ZIP
- Branch: `fix/public-production-hardening`
## Uitgevoerde volledige gate
## Gecontroleerd
Commando:
### Applicatie en regressies
```bash
./scripts/codex_verify.sh
```text
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 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.
### Django en data
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
./scripts/codex_bootstrap.sh
```
## Belangrijkste herstelde productieproblemen
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;
- 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`.
## Omgevingsbeperking
## 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.
- 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.
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.
## 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
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.
Zie `docs/operations/NGINX_PROXY_MANAGER.md` en `docs/quality/PRODUCTION_AUDIT_2026-07-27.md`.
+2 -5
View File
@@ -24,8 +24,7 @@ class EmailOrUsernameModelBackend(ModelBackend):
try:
user = user_model.objects.get(
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username})
| Q(email__iexact=username)
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.
@@ -34,9 +33,7 @@ class EmailOrUsernameModelBackend(ModelBackend):
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}
)
user_model.objects.filter(**{f"{user_model.USERNAME_FIELD}__iexact": username})
.order_by("id")
.first()
)
+25 -1
View File
@@ -2,15 +2,35 @@ from __future__ import annotations
from typing import Any
from django.conf import settings
from django.http import HttpRequest
from apps.profiles.models import SearchProfile
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 = {
"app_name": "VacatureRadar",
"app_version": "0.3.0",
"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:
context["navigation_profile"] = (
@@ -18,4 +38,8 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]:
.only("id", "name")
.first()
)
context["demo_read_only"] = bool(
getattr(settings, "DEMO_READ_ONLY", True)
and request.user.username == settings.DEMO_USERNAME
)
return context
+2 -7
View File
@@ -45,9 +45,7 @@ def load_config() -> EntraConfig:
)
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
allowed = tuple(
d.lower().lstrip("@")
for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", [])
if d
d.lower().lstrip("@") for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", []) if d
)
return EntraConfig(
client_id=client_id,
@@ -91,10 +89,7 @@ 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 ""
claims.get("preferred_username") or claims.get("email") or claims.get("upn") or ""
).strip()
name = (claims.get("name") or "").strip()
return email, name
+18 -2
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass
from django.conf import settings
from django.core.cache import cache
from django.db import connections
from django.db.utils import OperationalError
from django.db.utils import DatabaseError
@dataclass(frozen=True)
@@ -23,7 +26,20 @@ def readiness() -> HealthResult:
cursor.execute("SELECT 1")
cursor.fetchone()
checks["database"] = "ok"
except OperationalError as exc:
except DatabaseError as exc:
ok = False
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)
+55 -2
View File
@@ -2,11 +2,62 @@ from __future__ import annotations
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:
"""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:
self.get_response = get_response
@@ -33,4 +84,6 @@ class SecurityHeadersMiddleware:
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
)
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
+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.utils import timezone
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"
from apps.core.network import client_ip
def _identity_for_user(request: HttpRequest) -> str:
@@ -21,7 +16,7 @@ def _identity_for_user(request: HttpRequest) -> str:
username = (
(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)
+30 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -14,6 +15,32 @@ 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]:
@@ -84,6 +111,7 @@ def build_automation_cockpit() -> dict[str, Any]:
"unit": "verzonden / 24u",
},
]
runtime_heartbeat = _runtime_heartbeat_state()
return {
"health": readiness(),
"source_total": source_total,
@@ -103,7 +131,8 @@ def build_automation_cockpit() -> dict[str, Any]:
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
"queue_state": "Eager/lokaal"
if settings.CELERY_TASK_ALWAYS_EAGER
else "Geconfigureerd, worker niet gemeten",
else "Diepte niet beschikbaar",
"runtime_heartbeat": runtime_heartbeat,
"ai_state": "Geconfigureerd"
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
else "Niet geconfigureerd",
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import base64
from django.conf import settings
class Microsoft365OAuthError(RuntimeError):
"""Safe OAuth boundary error that never includes tokens or client secrets."""
def acquire_exchange_access_token(*, client_factory=None) -> str:
tenant_id = settings.M365_TENANT_ID.strip()
client_id = settings.M365_CLIENT_ID.strip()
client_secret = settings.M365_CLIENT_SECRET.strip()
if not all((tenant_id, client_id, client_secret)):
raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.")
if client_factory is None:
import msal
client_factory = msal.ConfidentialClientApplication
factory = client_factory
client = factory(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret,
)
result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE])
token = result.get("access_token") if isinstance(result, dict) else None
if not isinstance(token, str) or not token:
category = "unknown"
if isinstance(result, dict):
category = str(result.get("error") or "unknown")[:80]
raise Microsoft365OAuthError(
f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})."
)
return token
def xoauth2_bytes(username: str, access_token: str) -> bytes:
if not username or "\x00" in username or "\r" in username or "\n" in username:
raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.")
return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode()
def xoauth2_b64(username: str, access_token: str) -> str:
return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii")
+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
+12 -7
View File
@@ -7,7 +7,7 @@ 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.views import LoginView
from django.http import JsonResponse
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
@@ -147,9 +147,7 @@ def entra_login(request):
return redirect("login")
try:
config = entra_sso.load_config()
redirect_uri = config.redirect_uri or request.build_absolute_uri(
reverse("entra-callback")
)
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)
@@ -183,9 +181,7 @@ def entra_callback(request):
return redirect("login")
if "error" in result:
logger.warning(
"Entra ID-fout: %s", result.get("error_description") or result.get("error")
)
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")
@@ -232,10 +228,19 @@ def demo_login(request):
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):
return JsonResponse({"ok": True, "service": "vacatureradar"})
+3 -1
View File
@@ -137,7 +137,9 @@ def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[s
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
return {
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:8]),
# 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"
+142 -17
View File
@@ -1,12 +1,21 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field as dataclass_field
from decimal import Decimal
from urllib.parse import urlsplit
from django.db import IntegrityError, transaction
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.sources.adapters.base import FieldEvidence
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:
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()
domain = draft.employer_domain.strip()
if not name and not domain:
return None
display_name = name or domain
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)
employer, _ = Employer.objects.get_or_create(
normalized_name=normalized,
@@ -61,6 +84,8 @@ def resolve_employer(draft: CanonicalJobDraft) -> Employer | None:
changed.extend(["is_recruiter", "is_direct_employer"])
if changed:
employer.save(update_fields=[*changed, "updated_at"])
if context is not None:
context.employers[cache_key] = employer
return employer
@@ -162,12 +187,81 @@ def _apply_draft(
if direct and canonical_url and job.canonical_url != canonical_url:
job.canonical_url = 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()
changed.append("last_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
def persist_draft(
draft: CanonicalJobDraft,
@@ -176,14 +270,16 @@ def persist_draft(
parser_key: str,
parser_version: str,
extraction_confidence: float,
context: PersistenceContext | None = None,
) -> tuple[JobPosting, DedupeDecision, bool]:
source = document.source
employer = resolve_employer(draft)
employer = resolve_employer(draft, context=context)
decision = find_existing_job(draft, source=source)
direct = _source_is_direct(source, draft)
if decision.resolved_direct:
direct = True
created = False
substantive_change = False
if decision.job is None:
try:
@@ -240,6 +336,7 @@ def persist_draft(
if extraction_confidence > float(job.extraction_confidence):
job.extraction_confidence = _confidence(extraction_confidence)
changed.append("extraction_confidence")
substantive_change = any(field not in {"last_seen", "last_changed"} for field in changed)
if changed:
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"]
)
for evidence in draft.evidence:
FieldProvenance.objects.update_or_create(
_sync_evidence(
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,
},
alias=alias,
evidence_items=draft.evidence,
parser_version=parser_version,
)
if created or substantive_change:
JobVersion.objects.get_or_create(
job=job,
content_hash=job.content_hash,
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
)
for profile in SearchProfile.objects.filter(is_active=True):
score_and_save(job, profile)
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
@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)
document.parser_key = result.parser_key
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_version=result.parser_version,
extraction_confidence=result.confidence,
context=context,
)
if was_created:
created += 1
+1 -1
View File
@@ -144,7 +144,7 @@ class JobListView(LoginRequiredMixin, ListView):
model = JobPosting
template_name = "jobs/list.html"
context_object_name = "jobs"
paginate_by = 30
paginate_by = 20
VALID_RECOMMENDATIONS = {
ScoreRun.Recommendation.STRONG,
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import smtplib
from email.utils import parseaddr
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.sources.services.tls import trusted_tls_context
class Microsoft365OAuthEmailBackend(BaseEmailBackend):
"""Send mail through Exchange Online with app-only SASL XOAUTH2."""
def send_messages(self, email_messages) -> int:
if not email_messages:
return 0
sent = 0
try:
token = acquire_exchange_access_token()
username = settings.M365_MAILBOX_USER.strip()
if not username:
raise Microsoft365OAuthError(
"M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer."
)
with smtplib.SMTP(
settings.EMAIL_HOST or "smtp.office365.com",
settings.EMAIL_PORT,
timeout=settings.M365_SMTP_TIMEOUT_SECONDS,
) as client:
client.ehlo()
client.starttls(context=trusted_tls_context())
client.ehlo()
code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}")
if code != 235:
raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.")
for message in email_messages:
recipients = message.recipients()
if not recipients:
continue
sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1]
client.sendmail(
sender or username,
recipients,
message.message().as_bytes(linesep="\r\n"),
)
sent += 1
except Exception:
if self.fail_silently:
return sent
raise
return sent
+13 -1
View File
@@ -9,13 +9,15 @@ from .base import ExtractedJob, ExtractionResult, FieldEvidence
LABEL_PATTERNS = {
"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),
}
class GenericHtmlAdapter:
parser_key = "generic-html"
parser_version = "1.0.0"
parser_version = "1.1.0"
@staticmethod
def _meta(soup: BeautifulSoup, *names: str) -> str:
@@ -59,6 +61,8 @@ class GenericHtmlAdapter:
soup, LABEL_PATTERNS["employer"]
)
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
description_html = str(main) if main else ""
description_text = (
@@ -74,6 +78,12 @@ class GenericHtmlAdapter:
FieldEvidence("location_text", "html-label", 0.62, location[:240]),
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(
url=job_url,
title=title,
@@ -81,6 +91,8 @@ class GenericHtmlAdapter:
location_text=location,
description_html=description_html,
description_text=description_text,
date_posted=date_posted,
employment_types=[employment_type] if employment_type else [],
raw={"generic_html": True},
evidence=evidence,
)
+6 -2
View File
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
password = forms.CharField(
required=False,
label="App-wachtwoord",
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.",
help_text=(
"Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt "
"de centrale OAuth-configuratie."
),
widget=forms.PasswordInput(
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
render_value=False,
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
data = super().clean()
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
data["custom_host"] = ""
if not self.instance.pk and not data.get("password"):
uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK
if not self.instance.pk and not uses_oauth and not data.get("password"):
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
return data
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sources", "0007_alter_mailboxconnection_platform")]
operations = [
migrations.AlterField(
model_name="mailboxconnection",
name="encrypted_password",
field=models.TextField(blank=True),
)
]
+1 -1
View File
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
custom_host = models.CharField(max_length=255, blank=True)
port = models.PositiveIntegerField(default=993)
username = models.CharField(max_length=320)
encrypted_password = models.TextField()
encrypted_password = models.TextField(blank=True)
mailbox = models.CharField(max_length=255, default="INBOX")
enabled = models.BooleanField(default=True)
poll_interval_minutes = models.PositiveIntegerField(
+7 -1
View File
@@ -74,14 +74,19 @@ def _validate_connected_peer(
*,
before: ValidatedUrl,
after: ValidatedUrl,
require_peer: bool,
) -> 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
if require_peer:
raise FetchError(
"Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd."
)
# Mock/custom transports do not always expose the peer socket. Keep the conservative
# DNS-overlap fallback for those transports.
# DNS-overlap fallback only for explicitly injected test/custom clients.
if not set(before.addresses).intersection(after.addresses):
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
@@ -175,6 +180,7 @@ def fetch_url(
response,
before=validation,
after=post_validation,
require_peer=own_client,
)
if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location")
+12 -1
View File
@@ -7,6 +7,7 @@ from typing import Any
from django.conf import settings
from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes
from apps.sources.models import EmailMessageRecord, MailboxConnection
from apps.sources.services.email_import import ingest_email, message_identity
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
if client_factory is None:
client_factory = imaplib.IMAP4_SSL
host_validator(f"https://{connection.imap_host}")
password = decrypt_mailbox_password(connection)
password = (
""
if connection.provider == MailboxConnection.Provider.OUTLOOK
else decrypt_mailbox_password(connection)
)
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
if client_factory is imaplib.IMAP4_SSL:
client_options["ssl_context"] = trusted_tls_context()
@@ -42,6 +47,12 @@ def poll_imap_mailbox(
"failed": 0,
}
try:
if connection.provider == MailboxConnection.Provider.OUTLOOK:
access_token = acquire_exchange_access_token()
client.authenticate(
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
)
else:
client.login(connection.username, password)
status, _ = client.select(connection.mailbox, readonly=True)
if status != "OK":
+7 -2
View File
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
def rotate_mailbox_credentials() -> int:
"""Re-encrypt every mailbox password with the first configured key."""
rotated = 0
for connection in MailboxConnection.objects.select_for_update().all():
for connection in MailboxConnection.objects.select_for_update().exclude(
provider=MailboxConnection.Provider.OUTLOOK
):
password = decrypt_mailbox_password(connection)
connection.encrypted_password = encrypt_mailbox_password(password)
connection.save(update_fields=["encrypted_password", "updated_at"])
@@ -79,7 +81,10 @@ def save_mailbox_connection(
"poll_interval_minutes",
):
setattr(connection, field, cleaned_data[field])
if password:
uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK
if uses_oauth:
connection.encrypted_password = ""
elif password:
connection.encrypted_password = encrypt_mailbox_password(password)
elif not connection.encrypted_password:
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
+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"
}
+127 -17
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import base64
import hashlib
import ipaddress
import os
from pathlib import Path
from urllib.parse import urlparse
@@ -29,6 +30,63 @@ def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int
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(
*,
debug: bool,
@@ -38,6 +96,9 @@ def _validate_production_security(
session_cookie_secure: bool,
csrf_cookie_secure: bool,
secure_ssl_redirect: bool,
public_base_url: str = "",
demo_mode_enabled: bool = False,
demo_read_only: bool = True,
) -> None:
if debug:
return
@@ -49,6 +110,8 @@ def _validate_production_security(
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
if not allowed_hosts:
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:
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
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.")
if not secure_ssl_redirect:
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")
DEBUG = env_bool("DJANGO_DEBUG", True)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url()
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")
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"
USE_I18N = True
USE_TZ = True
@@ -91,6 +167,7 @@ MIDDLEWARE = [
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"apps.core.middleware.DemoReadOnlyMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.SecurityHeadersMiddleware",
]
@@ -206,9 +283,11 @@ 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: logt in als afgeschermd gastaccount met een fictieve, gevulde
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is.
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True)
# 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")
@@ -219,30 +298,54 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
M365_TENANT_ID = os.getenv("M365_TENANT_ID", "")
M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "")
M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "")
M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER)
M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default"
M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120)
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False)
_PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://")
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS)
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS)
CSRF_COOKIE_SAMESITE = "Lax"
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", False)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_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_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
USE_X_FORWARDED_HOST = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
X_FRAME_OPTIONS = "DENY"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "vacatureradar-rate-limit",
}
}
SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False)
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_RESULT_BACKEND = REDIS_URL
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
@@ -272,6 +375,10 @@ if DEBUG and not MAILBOX_CREDENTIAL_KEYS:
]
CELERY_BEAT_SCHEDULE = {
"runtime-heartbeat": {
"task": "apps.core.tasks.record_runtime_heartbeat",
"schedule": 60.0,
},
"schedule-mailbox-polls": {
"task": "apps.sources.tasks.schedule_mailbox_polls",
"schedule": 300.0,
@@ -357,6 +464,9 @@ _validate_production_security(
session_cookie_secure=SESSION_COOKIE_SECURE,
csrf_cookie_secure=CSRF_COOKIE_SECURE,
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 = {
+3 -2
View File
@@ -4,7 +4,6 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.templatetags.static import static as static_url
from django.urls import include, path
from django.views.generic import RedirectView
@@ -13,14 +12,16 @@ from apps.core.views import (
demo_login,
entra_callback,
entra_login,
robots_txt,
)
urlpatterns = [
path(
"favicon.ico",
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
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(
"login/",
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
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. 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.
+21 -10
View File
@@ -1,4 +1,10 @@
# Eén Dockerman-container met intern bewaakte PostgreSQL-, Redis-, web- en Celery-processen.
#
# 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
services:
@@ -6,28 +12,33 @@ services:
build:
context: .
dockerfile: Dockerfile.unraid
image: vacatureradar:unraid
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
container_name: VacatureRadar
env_file:
- /mnt/user/appdata/vacatureradar/source/.env
- ${VACATURERADAR_ENV_FILE:-.env}
environment:
DATABASE_URL: postgresql://vacatureradar:${POSTGRES_PASSWORD}@127.0.0.1:5432/vacatureradar
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: "5432"
REDIS_URL: redis://127.0.0.1:6379/0
VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump
CACHE_URL: redis://127.0.0.1:6379/1
HEALTHCHECK_REQUIRE_CACHE: "1"
# Alleen gebruikt bij een lege datadir; laat leeg wanneer je niet migreert.
VACATURERADAR_RESTORE_DUMP: ${VACATURERADAR_RESTORE_DUMP:-}
restart: unless-stopped
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
ports:
- "1226:8080"
- "${VACATURERADAR_HOST_PORT:-1226}:8080"
volumes:
- /mnt/user/appdata/vacatureradar/source/local/media:/app/media
- /mnt/user/appdata/vacatureradar/source/local/logs:/app/logs
- /mnt/user/appdata/vacatureradar/source/local:/app/local
- ./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]/
net.unraid.docker.icon: http://[IP]:[PORT:8080]/static/favicon.svg
# 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:
@@ -35,4 +46,4 @@ services:
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
start_period: 90s
+8 -1
View File
@@ -11,8 +11,11 @@ x-app: &app
- /var/tmp:rw,noexec,nosuid,size=128m
- /app/.cache:rw,noexec,nosuid,size=128m
env_file:
- .env
- ${VACATURERADAR_ENV_FILE:-.env}
restart: unless-stopped
environment:
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
security_opt:
- no-new-privileges:true
volumes:
@@ -43,6 +46,8 @@ services:
<<: *app
environment:
VACATURERADAR_RUN_MIGRATIONS: "0"
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
cap_drop:
- ALL
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
@@ -57,6 +62,8 @@ services:
<<: *app
environment:
VACATURERADAR_RUN_MIGRATIONS: "0"
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
cap_drop:
- ALL
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
+579 -7
View File
@@ -1,6 +1,6 @@
schema_version: 1
project: VacatureRadar
updated_at: '2026-07-22'
updated_at: '2026-07-29'
execution_policy:
order: file-order, then first ready task whose dependencies are done
no_product_questions: true
@@ -1356,22 +1356,25 @@ tasks:
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
acceptance_criteria:
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie.
- Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt
app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord.
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
scheduler gerespecteerd.
- SMTP verstuurt één testdigest en outboxstatus klopt.
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de
database aanwezig en sleutelrotatie is live getest.
- OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele
Gmail/custom-app-wachtwoorden zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
verification:
- Volg Unraid mailboxchecklist
- Inspecteer logs op afwezigheid van secret/payload
primary_paths:
- /mnt/user/appdata/vacatureradar/config/.env
- Bronnen -> Platformmailboxen
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker.
external_blocker: De dedicated productie-alias, Entra-appregistratie, admin consent en Exchange-mailboxscope zijn
op 2026-07-30 live ingericht; resterend zijn de productie-uitrol en echte platformalertmails voor de
import-/digestproef.
- id: VR-203
title: Productiedomein, TLS en reverse proxy activeren
status: blocked-external
status: done
priority: P0
requirement_ids:
- NFR-001
@@ -1393,7 +1396,11 @@ tasks:
primary_paths:
- Unraid reverse proxy
- /mnt/user/appdata/vacatureradar/config/.env
external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy.
result:
completed_at: '2026-07-29'
note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes
en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend
containerintern bereikbaar.
- id: VR-204
title: Immutable containerimage publiceren
status: blocked-external
@@ -1748,6 +1755,375 @@ tasks:
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
title: Multi-tenant SaaS, teams en billing
status: deferred
@@ -1764,6 +2140,40 @@ tasks:
- Niet uitvoeren binnen huidige scope.
primary_paths:
- 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
title: Automatisch sollicitaties invullen of versturen
status: deferred
@@ -1799,3 +2209,165 @@ tasks:
primary_paths:
- docs/api/openapi.yaml
- 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: done
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
result:
completed_at: '2026-07-29'
note: 'Release 0.3.14: geïsoleerde PostgreSQL-benchmark reduceert importduur 44,74% en queries minstens 63,73%;
twee volledige gates met 318 tests groen; productie healthy op image sha256:1ee84241, transactionele importsmoke
teruggerold en rollbackimage 0.3.13 beschikbaar.'
+205 -5
View File
@@ -1,7 +1,7 @@
# Projectstatus
- Laatst bijgewerkt: 2026-07-25
- Repositoryversie: 0.3.0 volledige Stitch Intelligence Cockpit
- Laatst bijgewerkt: 2026-07-29
- Repositoryversie: 0.3.14 importperformance
- Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -19,6 +19,80 @@ De repository bevat een uitvoerbare Django-MVP met:
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
- offline unit-, integratie- en securitytests.
## Laatste productiehardening
Productieoplevering 0.3.14 op 2026-07-29:
- VR-228 en commit `cbd7220` zijn via `origin/main` uitgerold als image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`;
- de geïsoleerde PostgreSQL-runner mat na één warm-up drie identieke queryprofielen: 3.264 importqueries, gemiddeld 9.040,123 ms, p50 32,900 ms, p95 52,296 ms, 28,031 documenten/s en 18,799 MB piekgeheugen;
- tegenover 0.3.13 is de import 44,74% sneller, gebruikt hij minstens 63,73% minder queries en stijgt throughput 83,42%; parser 12/12, precision/recall 1,0 en rankingchurn 0 bleven behouden;
- beide volledige gates waren groen met elk 318 tests, 0 failures, 0 skips en 84,20% dekking;
- de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
- productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd.
Volgende uitvoerbare taak: geen na afronding van VR-228. Alleen de optionele
mailbox-/identityconfiguratie (`VR-202`) en publicatie naar een externe private
registry (`VR-204`) blijven extern geblokkeerd.
## Laatste risicohardening
Live mailboxvoorbereiding op 2026-07-30:
- Exchange Online bevestigde dat de dedicated VacatureRadar-alias aan de productie-mailbox is toegevoegd;
- de alias is niet primair gemaakt en bestaande adressen zijn ongewijzigd gebleven;
- de Entra-appregistratie, admin consent en Exchange-mailboxscope zijn live ingericht
met app-only OAuth; tijdelijke bootstraprechten en de bootstrap-app zijn na afloop verwijderd;
- `VR-202` resteert alleen nog op de productie-uitrol en echte platformalert-/digestproeven;
Basic Auth wordt niet geactiveerd;
- de volledige lokale gate is groen met 324 tests, 0 failures, 0 skips en 84,05%
branch-aware dekking.
Risicohardening op 2026-07-29:
- de productiefetcher blokkeert nu fail-closed wanneer de HTTP-transportlaag het
werkelijk verbonden peer-IP niet beschikbaar stelt; ieder niet-publiek peer-IP
bleef al geblokkeerd;
- alleen expliciet geïnjecteerde test/custom clients mogen nog de begrensde
DNS-overlapfallback gebruiken; een negatieve regressietest dekt de
productiegrens;
- `VR-203` is administratief afgerond op basis van het bestaande live bewijs in
`docs/audit/FINAL_ACCEPTANCE.md`: publieke HTTP→HTTPS, TLS/securityheaders,
veilige cookies, CSRF-gastlogin, kernroutes en readiness zijn aantoonbaar
geslaagd;
- verificatie: Ruff groen, 8 gerichte fetchertests geslaagd en de volledige gate
groen met 319 tests, 0 failures, 0 skips en 84,21% branch-aware dekking.
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:
@@ -32,6 +106,127 @@ VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
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;
@@ -188,7 +383,7 @@ VR-208-herverificatie op 2026-07-22:
- 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 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
@@ -389,13 +584,18 @@ Deze blokkeren onafhankelijke code niet:
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
- live IMAP/SMTP-account en app-password;
- Unraid-domein, DNS, TLS en reverse proxy;
- containerregistry en credentials;
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
## Bekende verificatiebeperking van de aangeleverde basis
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn
geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is
aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op
het begeleide profiel en de live vacaturefeed en VR-124 op de acht
platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow
zijn op 2026-07-29 live geaccepteerd en vastgelegd in
`docs/audit/FINAL_ACCEPTANCE.md`.
## Hervatten
+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.
+20
View File
@@ -0,0 +1,20 @@
# Finale acceptance 0.3.14
Status: productie geaccepteerd op 2026-07-29.
## Bewijs
- `./scripts/codex_verify.sh` is tweemaal uitgevoerd: telkens 318 geslaagd, 0 mislukt, 0 overgeslagen en 84,20% branch-aware dekking.
- Geïsoleerde importbenchmark: één warm-up plus drie PostgreSQL-runs; gemiddeld 3.264 queries, 9.040,123 ms, p50 32,900 ms, p95 52,296 ms, 28,031 documenten/s en 18,799 MB piekgeheugen.
- Benchmarkkwaliteit: 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en rankingchurn 0,0.
- Unraid bouwde image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`; migrations, 148 static files, healthcheck en zero-restartstart slaagden.
- HTTP redirect naar HTTPS, OpenResty/TLS, HSTS/securityheaders, `DEBUG=False`, secure session-/CSRF-cookies en geen `DisallowedHost` zijn publiek bewezen.
- De live loginpagina is visueel en semantisch in Chrome gecontroleerd. De CSRF-beveiligde gastlogin en dashboard, vacaturelijst, skills, sollicitaties, profielen, bronnen en systeemstatus antwoordden daarna allemaal 200.
- 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.
- Een productie-importsmoke extraheerde en creëerde één geldige job in een expliciete transactie; rollback werd bevestigd en liet geen smokedata achter.
- De checksum-geldige pre-releaseback-up staat onder `backups/release-0.3.14-20260729T164500Z`; rollbacktag `vacatureradar:rollback-0.3.13` bewaart image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`.
## Uitkomst
Er bestaan geen bekende P0- of P1-releaseblokkers. Alleen optionele Entra- en mailboxcredentials blijven extern; zij raken de kernrelease niet.
+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 |
+36
View File
@@ -92,3 +92,39 @@ Er is geen oude shell, alternatieve feature flag of parallelle frontendroute. Ou
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.
+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.
+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.
+5
View File
@@ -77,6 +77,11 @@ celery -A config inspect reserved
## Digest ontbreekt of dubbel
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`,
`M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en
`EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`.
Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope;
Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund.
- Controleer `DigestOutbox` op datum en status.
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
+78 -36
View File
@@ -4,28 +4,48 @@ Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van
## 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
/mnt/user/appdata/vacatureradar/config
/mnt/user/appdata/vacatureradar/media
/mnt/user/appdata/vacatureradar/logs
/mnt/user/appdata/vacatureradar/source/local/postgres-aio17
/mnt/user/appdata/vacatureradar/source/local/redis-aio
/mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI
/mnt/user/backups/vacatureradar
/mnt/user/appdata/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.
## 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 VacatureRadar-favicon op `/static/favicon.svg`;
- 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
De Unraid-compose bouwt lokaal met `Dockerfile.unraid` en tagt de image als `vacatureradar:unraid`. Pin voor reproduceerbare releases aanvullend een commitgebonden tag.
@@ -38,48 +58,68 @@ Gebruik in productie geen zwevende `latest` zonder gecontroleerd rollbackpad.
## Configuratiebestand
Kopieer of genereer `.env` naar:
De deploy-helper `scripts/deploy_docker.sh` vult ontbrekende sleutels op basis van veilige defaults.
Zet voor publieke productie vooraf de gewenste variabelen:
`docker-compose.unraid.yml` leest `.env` naast het composebestand zelf:
```text
/mnt/user/appdata/vacatureradar/config/.env
APP_HOST=jobs.example.be
APP_SCHEME=https
DJANGO_DEBUG=0
SESSION_COOKIE_SECURE=1
CSRF_COOKIE_SECURE=1
SECURE_SSL_REDIRECT=1
<projectmap>/.env
```
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.
## Eerste uitrol
1. Controleer alle `CHANGE_ME`-waarden.
2. Gebruik de deploy-helper voor een snelle eerste start:
1. Controleer alle `CHANGE_ME`-waarden en maak een back-up van een bestaande `.env`.
2. Start of hermaak de Unraid-container vanuit de bronmap:
```bash
cd /app/VacatureRadar
APP_HOST=jobs.example.be APP_SCHEME=https DJANGO_DEBUG=0 \
SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \
bash scripts/deploy_docker.sh
cd /mnt/user/appdata/VacatureRadar
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
```
3. Start de ene appcontainer; de entrypoint initialiseert PostgreSQL, voert migraties uit en start daarna alle processen onder Supervisor.
6. Controleer `GET /health/live/` en `GET /health/ready/`.
6. Voer eenmalig in de webcontainer uit:
3. De entrypoint valideert de productieconfiguratie, initialiseert PostgreSQL, voert migraties uit en start alle processen onder Supervisor.
4. Controleer intern `GET /health/live/` en `GET /health/ready/`.
5. Voer alleen wanneer bootstrap niet automatisch is ingeschakeld eenmalig uit:
```bash
python manage.py bootstrap_instance
python manage.py collectstatic --noinput
docker exec -it VacatureRadar python manage.py bootstrap_instance
```
7. Meld lokaal aan en wijzig het bootstrapwachtwoord.
8. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd.
6. Meld lokaal aan en wijzig het bootstrapwachtwoord.
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
@@ -105,7 +145,7 @@ Expose de applicatie niet rechtstreeks op internet. Plaats Nginx Proxy Manager,
- optioneel extra access control/VPN voor persoonlijke installatie;
- 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
@@ -124,7 +164,9 @@ IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
```
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:<versie>@sha256:<digest>` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`.
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
@@ -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.
+13 -3
View File
@@ -48,7 +48,7 @@ Django/Celery
|---|---|---|---|---|
| T-01 | SSRF via bron- of vacature-URL | toegang tot LAN, metadata of beheerinterfaces | schema/host/IP/poortcontrole, DNS-resolutie, redirecthercontrole, denylist, geen credentials in URL | `tests/security/test_url_security.py`, `test_policy.py`, fetchertests |
| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert DNS opnieuw; het werkelijk verbonden peer-IP wordt als publiek gecontroleerd wanneer de transportlaag dit exposeert, anders geldt conservatieve DNS-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests |
| T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders | `tests/security/test_sanitize.py`, viewtests |
| T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders en uitsluitend lokale externe scripts zonder `unsafe-inline` | `tests/security/test_sanitize.py`, auth- en viewtests |
| T-04 | Prompt injection in vacature | AI verandert beleid of voert tools uit | vacaturetekst in data-envelope, schema-output, evidence-validatie, cachefouten met foutcategorie, geen toolrechten, deterministische harde regels, AI optioneel | AI-tests, ADR-0003, `VR-109` evaluaties |
| T-05 | Malafide/oversized document | geheugen/CPU/diskuitputting | contenttype-, byte-, timeout- en redirectlimieten; korte retentie | fetchertests, source-runmetrics |
| T-06 | Crawlstorm of ban | externe overlast, IP-blokkade | expliciet allowbeleid, per-origin minimuminterval, concurrencylimiet, leasegebaseerde taakclaim met TTL + crash recovery, conditional requests, backoff, quarantaine | tasktests; `VR-103`, `VR-112` |
@@ -81,6 +81,8 @@ Django/Celery
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
| T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` |
| T-43 | Gedeeld demoaccount wijzigt persistente data | bezoekers beïnvloeden elkaars ervaring of echte data | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, zichtbare UI-status en productiecheck | `tests/integration/test_auth_login.py`, `tests/unit/test_settings_security.py`, `VR-225` |
## Misbruikscenario's
@@ -113,8 +115,11 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
## Resterende risico's
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau.
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import.
- De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden
peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet
geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens
mag daarom niet voor productie-fetches worden gebruikt.
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR.
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
- Een persoonlijke Unraid-host blijft afhankelijk van correcte netwerksegmentatie, patching en back-ups door de beheerder.
@@ -148,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
- Verificatie: `tests/integration/test_source_health.py`
# Microsoft 365 OAuth
- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten.
- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret.
- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only.
+19 -5
View File
@@ -39,7 +39,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
| PR-050 | I | dashboard/list/skillsradar + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, skill-insighttests, VR-119/206/208 browsermatrix | VR-114/118/119/206/208 e2e/a11y |
| PR-050 | I | dashboard/list/skillsradar + Stitch-dominante cockpitshell/feed/rail, schaalbaar widescreenframe en auditgedreven informatiedichtheid | viewtests, skill-insighttests, VR-119/206/208/222/223 browsermatrix | VR-114/118/119/206/208/222/223 e2e/a11y |
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
@@ -56,15 +56,15 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| ID | Status | Bewijs | Vervolgtaak |
|---|---|---|---|
| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` |
| NFR-001 | I | policy-, URL- en fetchersecuritytests plus publieke host-, proxyvertrouwen-, demo- en gedeelde rate-limit-hardening | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `tests/integration/test_auth_login.py`, VR-225 |
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
| NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence |
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
| NFR-006 | I | offline pytest + coveragegate, inclusief platformbewuste productieconfiguratietests | VR-114 e2e; `tests/unit/test_public_configuration.py`; VR-225/226 |
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer en elf actuele screenshots | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `artifacts/visual-validation/`, VR-209213 |
| NFR-009 | I | lokale SQLite en Docker/Unraidconfig, release-smoke en restore | VR-117 afgerond |
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, negen beelden in `docs/screenshots/readme/`, VR-209224/226 |
| NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, publieke URL-helper, Nginx Proxy Manager-runbook, gevalideerde SSH-deployalias, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, `tests/unit/test_public_configuration.py`, VR-117, VR-217/218/225 afgerond |
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
## Systeemacceptatie
@@ -84,6 +84,8 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze matrix, relevante acceptatiecriteria en tests bij. Een status wordt alleen naar **I** gezet wanneer de code én het genoemde bewijs bestaan.
## Aanvullende traceability-opmerking
- VR-228 levert release 0.3.14 met een fail-closed disposable PostgreSQL-benchmark, run-lokale importcaches, gebundelde veldprovenance en ongewijzigde score-snapshotcopy. De officiële drie iteraties bewijzen 3.264 queries per 250 items, parser 12/12, dedupeprecision/recall 1,0 en nul rankingchurn; bewijs staat in `docs/audit/IMPORT_PERFORMANCE_*.md` en het machineleesbare artifact.
- VR-227 professionaliseert release 0.3.13 integraal: gecentreerd ultrawideframe, mobiele gastbanner en verticale pipeline, begrensde vacaturepaginering, verplichte zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, volledige generieke parserbenchmark en SBOM-generatie. Bewijs staat in `docs/audit/`, de E2E-/auth-/runtime-/adaptertests en `USER_INPUT_REQUIRED.md`.
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
@@ -102,3 +104,15 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-207 breidt het profiel uit met gegroepeerde field-/infrastructuurrollen, cv-skills en een ervaringsband. Skill-evidence is op vier treffers begrensd, afwezigheid wordt niet als tekort gepresenteerd en ambigue fieldtitels vereisen een onafhankelijk IT-contextsignaal. Persoonlijke cv-waarden staan uitsluitend in de lokale gebruikersdatabase; repositorytests en documentatie blijven gesaneerd.
- VR-208 maakt die ervaringsband uitsluitend informatief en verwijdert ook senioriteitsinvloed uit de AI-component en feedbacklearning. De gebruikersgescopeerde Skillsradar analyseert actuele niet-uitgesloten IT-vacatures van actieve bronnen, onderscheidt skillveldbewijs van tekstinferentie en toont profieldekking en mogelijke leerkansen met categorie-/dekkingsfilters.
- VR-209213 vervangen de volledige browserfrontend door de negen Stitch Intelligence Cockpit-composities. De nieuwe shell, dashboard/vacature-intelligence, pipeline/profiel/bronnen, automation/activiteit/werkgevers en responsive validatie zijn gekoppeld aan echte read models en serveracties. Bewijs staat in `docs/design/STITCH_INTEGRATION_REPORT.md`, route-/scope-/querytests en de elf beelden onder `artifacts/visual-validation/`.
- VR-214 herstelt het ontbrekende Dockerman-tegelicoon met een lokale 256×256 PNG, een exact Unraid-poortlabel en een regressietest die URL, PNG-signatuur en afmetingen controleert.
- VR-215 corrigeert het Dockerman-downloadcontract: het icoonlabel gebruikt een concrete loopback-URL omdat Dockerman placeholders alleen voor WebUI resolveert; live bewijs omvat de aangemaakte `VacatureRadar-icon.png`-cache.
- VR-216 verfijnt de Stitch-cockpit naar “calm intelligence”: minder visuele ruis, selectief cyan, compactere mobiele KPI's, responsieve filters, intern scrollende pipeline en een CSP-veilige logininteractie.
- VR-217 borgt de werkende Unraid-hostalias en afzonderlijke deploysleutel in een fail-fast releasehelper en voegt een lokale decoratieve radarillustratie met pointerrespons en reduced-motionfallback toe.
- VR-218 verenigt favicon, login en shell onder één radarbeeldmerk, maakt de persoonlijke eigenaarssignatuur configureerbaar en voegt contextuele oriëntatie aan de bovenbalk toe zonder mobiele overflow.
- VR-219 verankert alle hoofdsecties op één ultrawide grid, begrenst paneelverhoudingen voor leesbaarheid en maakt `Personal intelligence by Jens` tot de consistente productlock-up op publieke en ingelogde schermen.
- VR-220 vervangt die tijdelijke tekstlock-up door de aangeleverde officiële ITWorx.tech-wordmark en een exact daaruit afgeleid cloud-check-appicoon voor browser, shell en Dockerman; VacatureRadar blijft de productnaam.
- VR-221 corrigeert de merkarchitectuur: VacatureRadar krijgt een zelfstandig radar-/kansbeeldmerk voor alle productcontactpunten en ITWorx.tech blijft uitsluitend zichtbaar als officiële secundaire ontwikkelaarscredit.
- VR-224 maakt de repository-ingang user-first: de README volgt de dagelijkse productflow, bevat gecontroleerde lokale quickstarts en gebruikt vier actuele dark/light- en desktop/mobielscreenshots zonder secrets of externe assets.
- VR-225 hardent de publieke installatie met `PUBLIC_BASE_URL`, exacte host/CSRF-afleiding, veilige proxy-HTTPS-detectie, vertrouwde client-IPchains, gedeelde Redis-rate-limiting, cache-readiness en een server-side alleen-lezen gastdemo. Bewijs staat in de productie-audit, Nginx-runbook en de settings-, network-, health-, auth- en deploymenttests.
- VR-226 verdiept de README tot een visuele productreis met negen actuele beelden op consistente desktop- en mobiele viewports. Dezelfde taak herstelt de VR-225-lintbaseline en maakt de POSIX-bestandsmodusassertie terecht platformafhankelijk.
+5
View File
@@ -0,0 +1,5 @@
# VacatureRadar 0.3.14
Deze release versnelt herhaalde vacature-imports zonder deduplicatie, scoring of gebruikersdata te veranderen. Een geïsoleerde PostgreSQL-benchmark bewijst 44,74% kortere importduur, minstens 63,73% minder databasequeries en 83,42% hogere throughput tegenover 0.3.13. De runner, regressiebudgetten en queryfamilierapportage maken deze winst reproduceerbaar.
Upgrade vereist geen schemawijziging. Voer zoals altijd `migrate --noinput` uit; rollback kan naar de bewaarde immutable 0.3.13-image zonder dataconversie.
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+1 -1
View File
@@ -23,7 +23,7 @@
"url": "https://careers.example.net/jobs/workplace-engineer",
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.0.0",
"expected_parser_version": "1.1.0",
"expected_fields": [
"title",
"employer_name",
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="nl"><head><title>Workplace Engineer - Example Health</title><meta property="og:site_name" content="Example Health"><link rel="canonical" href="https://careers.example.net/jobs/workplace-engineer"></head><body><main><h1>Workplace Engineer</h1><dl><dt>Locatie</dt><dd>Genk, Limburg</dd></dl><section><h2>Jouw rol</h2><p>Je beheert endpoints, Microsoft Intune en Microsoft 365. De functie bevat occasionele tweedelijnssupport en hybride werk.</p></section></main></body></html>
<!doctype html><html lang="nl"><head><title>Workplace Engineer - Example Health</title><meta property="og:site_name" content="Example Health"><link rel="canonical" href="https://careers.example.net/jobs/workplace-engineer"></head><body><main><h1>Workplace Engineer</h1><dl><dt>Locatie</dt><dd>Genk, Limburg</dd><dt>Publicatiedatum</dt><dd>2026-07-21</dd><dt>Dienstverband</dt><dd>Voltijds</dd></dl><section><h2>Jouw rol</h2><p>Je beheert endpoints, Microsoft Intune en Microsoft 365. De functie bevat occasionele tweedelijnssupport en hybride werk.</p></section></main></body></html>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "vacatureradar"
version = "0.1.0"
version = "0.3.14"
description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails."
readme = "README.md"
requires-python = ">=3.12,<3.14"
+32 -14
View File
@@ -20,7 +20,6 @@ import django
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.utils import timezone
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
@@ -29,7 +28,7 @@ django.setup()
from apps.jobs.models import Employer, JobPosting, JobSourceAlias, ScoreRun # noqa: E402
from apps.jobs.services.dedupe import find_existing_job # noqa: E402
from apps.jobs.services.normalization import CanonicalJobDraft, normalize_token # noqa: E402
from apps.jobs.services.pipeline import process_raw_document # noqa: E402
from apps.jobs.services.pipeline import PersistenceContext, process_raw_document # noqa: E402
from apps.jobs.services.scoring import calculate_score, rescore_jobs_with_profiles # noqa: E402
from apps.profiles.models import SearchProfile # noqa: E402
from apps.sources.adapters.registry import registry # noqa: E402
@@ -575,15 +574,25 @@ def _clean_benchmark(artifacts: BenchmarkArtifact) -> None:
get_user_model().objects.filter(pk=artifacts.user_id).delete()
class QueryCounter:
"""Count database executions without Django's bounded debug-query log."""
def __init__(self) -> None:
self.count = 0
self.types: dict[str, int] = {}
def __call__(self, execute: Any, sql: str, params: Any, many: bool, context: Any) -> Any:
self.count += 1
query_type = sql.lstrip().split(None, 1)[0].upper() if sql.strip() else "UNKNOWN"
self.types[query_type] = self.types.get(query_type, 0) + 1
return execute(sql, params, many, context)
@contextmanager
def _query_capture() -> Any:
original_force_debug_cursor = connection.force_debug_cursor
connection.force_debug_cursor = True
try:
with CaptureQueriesContext(connection) as captured:
yield captured
finally:
connection.force_debug_cursor = original_force_debug_cursor
counter = QueryCounter()
with connection.execute_wrapper(counter):
yield counter
def run_performance_benchmark(
@@ -619,6 +628,7 @@ def run_performance_benchmark(
}
tracemalloc.start()
import_timings: list[float] = []
persistence_context = PersistenceContext()
rescore_timings: list[float] = []
list_timings: list[float] = []
detail_timings: list[float] = []
@@ -646,9 +656,10 @@ def run_performance_benchmark(
retain_until=timezone.now() + timezone.timedelta(days=7),
)
start = perf_counter()
process_raw_document(document)
process_raw_document(document, context=persistence_context)
import_timings.append((perf_counter() - start) * 1000)
import_query_count = len(captured_import.captured_queries)
import_query_count = captured_import.count
import_query_types = captured_import.types
query_count_total += import_query_count
candidates = list(
@@ -666,7 +677,8 @@ def run_performance_benchmark(
batch_start = perf_counter()
rescore_jobs_with_profiles(batch, profile_id=profile.id)
rescore_timings.append((perf_counter() - batch_start) * 1000)
rescore_query_count = len(captured_rescore.captured_queries)
rescore_query_count = captured_rescore.count
rescore_query_types = captured_rescore.types
query_count_total += rescore_query_count
with _query_capture() as captured_list:
@@ -695,8 +707,10 @@ def run_performance_benchmark(
)
detail_timings.append((perf_counter() - start) * 1000)
list_query_count = len(captured_list.captured_queries)
detail_query_count = len(captured_detail.captured_queries)
list_query_count = captured_list.count
detail_query_count = captured_detail.count
list_query_types = captured_list.types
detail_query_types = captured_detail.types
query_count_total += list_query_count + detail_query_count
current_bytes, peak_bytes = tracemalloc.get_traced_memory()
@@ -740,6 +754,7 @@ def run_performance_benchmark(
"p50_ms": timing_stats["import_p50_ms"],
"p95_ms": timing_stats["import_p95_ms"],
"query_count": import_query_count,
"query_types": import_query_types,
"timing_count": len(import_timings),
},
"rescore": {
@@ -747,11 +762,14 @@ def run_performance_benchmark(
"p50_ms": timing_stats["rescore_p50_ms"],
"p95_ms": timing_stats["rescore_p95_ms"],
"query_count": rescore_query_count,
"query_types": rescore_query_types,
"timing_count": len(rescore_timings),
},
"dashboard": {
"list_query_count": list_query_count,
"detail_query_count": detail_query_count,
"list_query_types": list_query_types,
"detail_query_types": detail_query_types,
"list_p95_ms": timing_stats["list_p95_ms"],
"detail_p95_ms": timing_stats["detail_p95_ms"],
"timing_count": len(list_timings) + len(detail_timings),
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from statistics import mean
from typing import Any
from urllib.parse import urlparse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
import django
django.setup()
from django.conf import settings # noqa: E402
from django.core.management import call_command # noqa: E402
from scripts.benchmark import DEFAULT_DATASET, run_benchmark_report # noqa: E402
SAFE_DATABASE_PREFIX = "vacatureradar_bench_"
SAFE_PUBLIC_HOSTS = {"", "benchmark.invalid", "localhost", "127.0.0.1"}
class UnsafeBenchmarkEnvironment(RuntimeError):
pass
def validate_benchmark_identity(database: dict[str, Any], public_base_url: str) -> None:
engine = str(database.get("ENGINE", ""))
name = str(database.get("NAME", ""))
host = str(database.get("HOST", ""))
public_host = (urlparse(public_base_url).hostname or "").casefold()
if engine != "django.db.backends.postgresql":
raise UnsafeBenchmarkEnvironment("De importbenchmark vereist PostgreSQL.")
if not name.startswith(SAFE_DATABASE_PREFIX):
raise UnsafeBenchmarkEnvironment(
f"Onveilige databasenaam {name!r}; verwacht prefix {SAFE_DATABASE_PREFIX!r}."
)
if host not in {"127.0.0.1", "localhost", "postgres"}:
raise UnsafeBenchmarkEnvironment(f"Onveilige benchmarkdatabasehost {host!r}.")
if public_host not in SAFE_PUBLIC_HOSTS:
raise UnsafeBenchmarkEnvironment(
f"Publieke host {public_host!r} is niet toegestaan voor een benchmark."
)
def _summary(reports: list[dict[str, Any]]) -> dict[str, float]:
performance = [report["performance"] for report in reports]
imports = [item["import"] for item in performance]
return {
"query_count_mean": round(mean(item["query_count"] for item in imports), 3),
"query_count_total_mean": round(mean(item["query_count"] for item in performance), 3),
"duration_ms_mean": round(mean(item["duration_ms_total"] for item in imports), 3),
"p50_ms_mean": round(mean(item["p50_ms"] for item in imports), 3),
"p95_ms_mean": round(mean(item["p95_ms"] for item in imports), 3),
"throughput_per_second": round(
mean(
report["jobs_processed"] / (item["duration_ms_total"] / 1000)
for report, item in zip(reports, imports, strict=True)
),
3,
),
"memory_peak_mb_mean": round(
mean(item["performance"]["memory_peak_mb"] for item in performance), 3
),
}
def run_isolated_benchmark(
*, dataset: Path, jobs: int, iterations: int, output: Path
) -> dict[str, Any]:
validate_benchmark_identity(settings.DATABASES["default"], settings.PUBLIC_BASE_URL)
if iterations < 1:
raise ValueError("iterations moet minstens 1 zijn.")
call_command("migrate", interactive=False, verbosity=0)
call_command("flush", interactive=False, verbosity=0)
warmup = run_benchmark_report(dataset, quick=True, jobs=jobs)
measured: list[dict[str, Any]] = []
for _ in range(iterations):
call_command("flush", interactive=False, verbosity=0)
measured.append(run_benchmark_report(dataset, quick=True, jobs=jobs))
report = {
"schema_version": "1.0",
"database_identity": {
"engine": settings.DATABASES["default"]["ENGINE"],
"name_prefix": SAFE_DATABASE_PREFIX,
"host": settings.DATABASES["default"]["HOST"],
},
"configuration": {
"jobs": jobs,
"iterations": iterations,
"warmup_runs": 1,
"concurrency": 1,
"dataset": str(dataset),
},
"warmup_status": warmup["status"],
"summary": _summary(measured),
"iterations": measured,
"status": "passed" if all(item["status"] == "passed" for item in measured) else "failed",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return report
def main() -> int:
parser = argparse.ArgumentParser(description="Fail-closed geïsoleerde importbenchmark")
parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
parser.add_argument("--jobs", type=int, default=250)
parser.add_argument("--iterations", type=int, default=3)
parser.add_argument(
"--output", type=Path, default=Path("artifacts/import-performance-report.json")
)
args = parser.parse_args()
report = run_isolated_benchmark(
dataset=args.dataset, jobs=args.jobs, iterations=args.iterations, output=args.output
)
print(json.dumps(report, indent=2, sort_keys=True))
return 0 if report["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import os
import tempfile
from pathlib import Path
from urllib.parse import urlparse
class ConfigurationError(ValueError):
pass
def normalize_public_url(value: str) -> tuple[str, str]:
raw = value.strip().rstrip("/")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ConfigurationError(
"Gebruik een volledige http(s)-URL, bijvoorbeeld https://jobs.example.be"
)
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigurationError(
"De publieke URL mag geen credentials, query of fragment bevatten."
)
if parsed.path not in {"", "/"}:
raise ConfigurationError(
"De applicatie moet op de root van het domein gepubliceerd worden."
)
try:
port = parsed.port
except ValueError as exc:
raise ConfigurationError("De publieke 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 parse_env(text: str) -> tuple[list[str], dict[str, str]]:
lines = text.splitlines()
values: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
values[key.strip()] = value.strip()
return lines, values
def merge_csv(existing: str, additions: list[str]) -> str:
result: list[str] = []
seen: set[str] = set()
for item in [*existing.split(","), *additions]:
value = item.strip()
if value and value.casefold() not in seen:
seen.add(value.casefold())
result.append(value)
return ",".join(result)
def render_env(lines: list[str], updates: dict[str, str]) -> str:
rendered: list[str] = []
written: set[str] = set()
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in updates:
rendered.append(f"{key}={updates[key]}")
written.add(key)
continue
rendered.append(line)
missing = [key for key in updates if key not in written]
if missing:
if rendered and rendered[-1].strip():
rendered.append("")
rendered.append("# Publieke URL en reverse-proxyhardening")
rendered.extend(f"{key}={updates[key]}" for key in missing)
return "\n".join(rendered).rstrip() + "\n"
def build_updates(
current: dict[str, str],
public_url: str,
*,
hsts_seconds: int,
cache_url: str | None,
trusted_proxy_cidrs: list[str],
) -> dict[str, str]:
origin, host = normalize_public_url(public_url)
if not origin.startswith("https://"):
raise ConfigurationError("Een publieke productie-URL moet HTTPS gebruiken.")
updates = {
"PUBLIC_BASE_URL": origin,
"DJANGO_DEBUG": "0",
"DJANGO_ALLOWED_HOSTS": merge_csv(
current.get("DJANGO_ALLOWED_HOSTS", ""), [host, "127.0.0.1", "localhost"]
),
"DJANGO_CSRF_TRUSTED_ORIGINS": merge_csv(
current.get("DJANGO_CSRF_TRUSTED_ORIGINS", ""), [origin]
),
"SESSION_COOKIE_SECURE": "1",
"CSRF_COOKIE_SECURE": "1",
"SECURE_SSL_REDIRECT": "1",
"SECURE_HSTS_SECONDS": str(hsts_seconds),
"TRUST_PROXY_HEADERS": "1",
"DEMO_READ_ONLY": "1",
"SEARCH_ENGINE_INDEXING_ENABLED": "0",
"HEALTHCHECK_REQUIRE_CACHE": "1",
}
if cache_url:
updates["CACHE_URL"] = cache_url
if trusted_proxy_cidrs:
updates["TRUSTED_PROXY_CIDRS"] = merge_csv(
current.get("TRUSTED_PROXY_CIDRS", ""), trusted_proxy_cidrs
)
return updates
def configure_env(
env_file: Path,
public_url: str,
*,
hsts_seconds: int = 300,
cache_url: str | None = None,
trusted_proxy_cidrs: list[str] | None = None,
) -> list[str]:
if hsts_seconds < 0:
raise ConfigurationError("HSTS-seconden mogen niet negatief zijn.")
original = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
lines, current = parse_env(original)
updates = build_updates(
current,
public_url,
hsts_seconds=hsts_seconds,
cache_url=cache_url,
trusted_proxy_cidrs=trusted_proxy_cidrs or [],
)
output = render_env(lines, updates)
env_file.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(prefix=f".{env_file.name}.", dir=env_file.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(output)
os.chmod(temporary_name, 0o600)
os.replace(temporary_name, env_file)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return list(updates)
def main() -> int:
parser = argparse.ArgumentParser(
description="Configureer VacatureRadar veilig voor een publieke reverse-proxy-URL."
)
parser.add_argument("public_url", help="Bijvoorbeeld https://vacatureradar.example.be")
parser.add_argument("--env-file", type=Path, default=Path(".env"))
parser.add_argument(
"--hsts-seconds",
type=int,
default=300,
help="Start conservatief; verhoog na succesvolle HTTPS-validatie.",
)
parser.add_argument("--cache-url", help="Bij Unraid AIO: redis://127.0.0.1:6379/1")
parser.add_argument(
"--trusted-proxy-cidr",
action="append",
default=[],
help="Optioneel vertrouwd proxy-IP/CIDR voor correcte client-IP-rate-limiting.",
)
args = parser.parse_args()
try:
changed = configure_env(
args.env_file,
args.public_url,
hsts_seconds=args.hsts_seconds,
cache_url=args.cache_url,
trusted_proxy_cidrs=args.trusted_proxy_cidr,
)
except (OSError, ConfigurationError) as exc:
parser.error(str(exc))
print(f"Bijgewerkt: {args.env_file}")
print("Veilig ingestelde sleutels: " + ", ".join(changed))
print("Hermaak de container; een gewone restart laadt gewijzigde environment niet opnieuw.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+82 -46
View File
@@ -36,9 +36,10 @@ generate_secret() {
fi
}
ensure_env() {
write_env() {
local key="$1"
local value="$2"
local force="${3:-0}"
local existing_line
local existing_value
@@ -49,7 +50,7 @@ ensure_env() {
fi
existing_value="${existing_line#*=}"
if [[ -n "$existing_value" && \
if [[ "$force" != "1" && -n "$existing_value" && \
"$existing_value" != CHANGE_ME* && \
"$existing_value" != dev-only-change-me && \
"$existing_value" != codex-local-only ]]; then
@@ -66,12 +67,17 @@ ensure_env() {
fi
done < "$env_file"
mv "$temp_env" "$env_file"
chmod 600 "$env_file"
}
app_host_was_explicit=0
[[ -n "${APP_HOST:-}" ]] && app_host_was_explicit=1
app_host="${APP_HOST:-${HOSTNAME:-localhost}}"
app_scheme="${APP_SCHEME:-http}"
app_public_port="${APP_PUBLIC_PORT:-}"
app_hostnames="${APP_HOSTS:-$app_host}"
app_origin="${APP_ORIGINS:-}"
app_base_url=""
# De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd
# toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld.
@@ -81,29 +87,43 @@ for required_host in 127.0.0.1 localhost; do
fi
done
if [[ -z "$app_origin" ]]; then
if [[ -z "$app_public_port" ]]; then
if [[ "$app_scheme" == "https" ]]; then
if [[ "$app_port" == "443" ]]; then
app_origin="https://$app_host"
app_public_port="443"
else
app_origin="https://$app_host:$app_port"
fi
else
app_origin="http://$app_host:$app_port"
app_public_port="$app_port"
fi
fi
if [[ ( "$app_scheme" == "https" && "$app_public_port" == "443" ) || \
( "$app_scheme" == "http" && "$app_public_port" == "80" ) ]]; then
app_base_url="${app_scheme}://${app_host}"
else
app_base_url="${app_scheme}://${app_host}:${app_public_port}"
fi
if [[ -z "$app_origin" ]]; then
app_origin="$app_base_url"
fi
secret_key="$(generate_secret)"
postgres_password="${POSTGRES_PASSWORD:-$(generate_secret)}"
admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}"
db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar"
debug="${DJANGO_DEBUG:-1}"
debug="${DJANGO_DEBUG:-}"
if [[ -z "$debug" ]]; then
if [[ "$app_host_was_explicit" == "1" && "$app_scheme" == "https" ]]; then
debug="0"
else
debug="1"
fi
fi
if [[ "$debug" == "0" ]]; then
session_secure="${SESSION_COOKIE_SECURE:-1}"
csrf_secure="${CSRF_COOKIE_SECURE:-1}"
ssl_redirect="${SECURE_SSL_REDIRECT:-1}"
hsts_seconds="${SECURE_HSTS_SECONDS:-31536000}"
hsts_seconds="${SECURE_HSTS_SECONDS:-300}"
else
session_secure="${SESSION_COOKIE_SECURE:-0}"
csrf_secure="${CSRF_COOKIE_SECURE:-0}"
@@ -111,37 +131,51 @@ else
hsts_seconds="${SECURE_HSTS_SECONDS:-0}"
fi
ensure_env "DJANGO_SECRET_KEY" "$secret_key"
ensure_env "DJANGO_DEBUG" "$debug"
ensure_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames"
ensure_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin"
ensure_env "DJANGO_TIME_ZONE" "Europe/Brussels"
ensure_env "POSTGRES_DB" "vacatureradar"
ensure_env "POSTGRES_USER" "vacatureradar"
ensure_env "POSTGRES_PASSWORD" "$postgres_password"
ensure_env "POSTGRES_HOST" "postgres"
ensure_env "POSTGRES_PORT" "5432"
ensure_env "DATABASE_URL" "$db_url"
ensure_env "REDIS_URL" "redis://redis:6379/0"
ensure_env "CELERY_TASK_ALWAYS_EAGER" "0"
ensure_env "VACATURERADAR_ADMIN_USERNAME" "admin"
ensure_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
ensure_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
ensure_env "SESSION_COOKIE_SECURE" "$session_secure"
ensure_env "CSRF_COOKIE_SECURE" "$csrf_secure"
ensure_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
ensure_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
ensure_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
ensure_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
ensure_env "SOURCE_POLICY_MODE" "strict"
ensure_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
ensure_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
ensure_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
ensure_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
ensure_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
ensure_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
write_env "DJANGO_SECRET_KEY" "$secret_key"
write_env "DJANGO_DEBUG" "$debug" "$app_host_was_explicit"
write_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" "$app_host_was_explicit"
write_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" "$app_host_was_explicit"
if [[ "$app_host_was_explicit" == "1" ]]; then
write_env "PUBLIC_BASE_URL" "$app_base_url" 1
if [[ "$app_scheme" == "https" ]]; then
write_env "TRUST_PROXY_HEADERS" "1" 1
else
write_env "TRUST_PROXY_HEADERS" "0" 1
fi
write_env "USE_X_FORWARDED_HOST" "0" 1
fi
write_env "DJANGO_TIME_ZONE" "Europe/Brussels"
write_env "POSTGRES_DB" "vacatureradar"
write_env "POSTGRES_USER" "vacatureradar"
write_env "POSTGRES_PASSWORD" "$postgres_password"
write_env "POSTGRES_HOST" "postgres"
write_env "POSTGRES_PORT" "5432"
write_env "DATABASE_URL" "$db_url"
write_env "REDIS_URL" "redis://redis:6379/0"
write_env "CACHE_URL" "redis://redis:6379/1"
write_env "HEALTHCHECK_REQUIRE_CACHE" "1"
write_env "CELERY_TASK_ALWAYS_EAGER" "0"
write_env "VACATURERADAR_ADMIN_USERNAME" "admin"
write_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
write_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
write_env "SESSION_COOKIE_SECURE" "$session_secure"
write_env "CSRF_COOKIE_SECURE" "$csrf_secure"
write_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
write_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
write_env "DEMO_READ_ONLY" "1"
write_env "SEARCH_ENGINE_INDEXING_ENABLED" "0"
write_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
write_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
write_env "SOURCE_POLICY_MODE" "strict"
write_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
write_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
write_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
write_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
write_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
write_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
docker compose -f "$compose_file" up -d --build
export VACATURERADAR_ENV_FILE="$env_file"
docker compose --env-file "$env_file" -f "$compose_file" up -d --build
if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
for i in {1..30}; do
@@ -157,14 +191,16 @@ if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
done
fi
cat <<"EOF"
cat <<EOF
Deploy klaargezet.
Applicatie:
- open http://127.0.0.1:1226/
- admin: admin / waarde uit $env_file
- lokaal: http://127.0.0.1:${app_port}/
- publiek: ${app_base_url}
- admin: admin / waarde uit ${env_file}
Volgende stappen:
- Zet APP_HOST en APP_SCHEME=HTTPS voor publieke domeinstandaard.
- Zet APP_ORIGINS en DJANGO_DEBUG=0 voor echte productie.
- Gebruik APP_HOST=<domein> APP_SCHEME=https DJANGO_DEBUG=0 voor publieke productie.
- APP_PORT is de interne hostpoort; APP_PUBLIC_PORT is optioneel en standaard 443 bij HTTPS.
- Hermaak de container na iedere environmentwijziging.
EOF
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
unraid_host="${UNRAID_SSH_HOST:-unraid-itworx}"
remote_root="${UNRAID_APP_ROOT:-/mnt/user/appdata/VacatureRadar}"
archive_name="vacatureradar-deploy-$$.tar.gz"
local_archive="$(mktemp -t "${archive_name}.XXXXXX")"
trap 'rm -f "$local_archive"' EXIT
ssh_config="$(ssh -G "$unraid_host" 2>/dev/null)"
ssh_user="$(printf '%s\n' "$ssh_config" | awk '$1 == "user" { print $2; exit }')"
identity_file="$(printf '%s\n' "$ssh_config" | awk '$1 == "identityfile" { print $2; exit }')"
if [[ "$unraid_host" == *.* ]] || [[ "$ssh_user" != "root" ]] || [[ "$identity_file" == *"id_ed25519" ]]; then
echo "Onveilig of onvolledig Unraid-SSH-profiel: '$unraid_host'." >&2
echo "Gebruik een Host-alias met User root, IdentitiesOnly yes en een afzonderlijke deploysleutel." >&2
echo "Verwacht lokaal standaard: Host unraid-itworx / IdentityFile ~/.ssh/itworx_unraid_deploy" >&2
exit 2
fi
ssh -o BatchMode=yes -o ConnectTimeout=8 "$unraid_host" "test \"\$(id -un)\" = root && docker info >/dev/null"
git ls-files --cached --others --exclude-standard -z \
| while IFS= read -r -d '' tracked_path; do
[[ -e "$tracked_path" ]] && printf '%s\0' "$tracked_path"
done \
| tar --null --files-from=- --create --gzip --file="$local_archive"
scp -q "$local_archive" "$unraid_host:/tmp/$archive_name"
ssh "$unraid_host" "set -eu
mkdir -p '$remote_root'
tar -xzf '/tmp/$archive_name' -C '$remote_root'
rm -f '/tmp/$archive_name'
cd '$remote_root'
docker compose -f docker-compose.unraid.yml up -d --build
timeout 120 sh -c 'until docker inspect VacatureRadar --format=\"{{.State.Health.Status}}\" 2>/dev/null | grep -qx healthy; do sleep 3; done'
curl --fail --silent --show-error http://127.0.0.1:1226/health/ready/ >/dev/null
docker ps --filter name=VacatureRadar --format '{{.Names}} {{.Status}} {{.Image}}'"
+2
View File
@@ -4,6 +4,8 @@ set -eu
mkdir -p /app/media /app/logs /app/local
mkdir -p /tmp/celery
python manage.py check --deploy
if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then
python manage.py migrate --noinput
+4
View File
@@ -53,4 +53,8 @@ if [[ -n "${APP_PORT:-}" ]]; then
export APP_PORT
fi
if [[ -n "${APP_PUBLIC_PORT:-}" ]]; then
export APP_PUBLIC_PORT
fi
bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
: "${DATABASE_URL:?Stel DATABASE_URL in op een afzonderlijke vacatureradar_bench_* PostgreSQL-database.}"
export PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-https://benchmark.invalid}"
export DJANGO_DEBUG=0
export DJANGO_SECRET_KEY="${DJANGO_SECRET_KEY:-benchmark-only-secret-key-not-for-production-000000000000000000}"
export CELERY_TASK_ALWAYS_EAGER=1
export OLLAMA_ENABLED=0
export EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
if command -v uv >/dev/null 2>&1; then
exec uv run python -m scripts.benchmark_import "$@"
fi
exec .venv/bin/python -m scripts.benchmark_import "$@"
+5 -3
View File
@@ -3,13 +3,14 @@ set -euo pipefail
pg_data=/app/local/postgres-aio17
redis_data=/app/local/redis-aio
pg_bin="$(dirname "$(readlink -f "$(command -v pg_config)")")"
pg_bin="$(pg_config --bindir)"
fresh_database=0
mkdir -p "$pg_data" "$redis_data" /app/media /app/logs /tmp/celery
mkdir -p "$pg_data" "$redis_data" /app/media /app/logs /app/local/backups /tmp/celery
chown -R postgres:postgres "$pg_data"
chown -R redis:redis "$redis_data"
chown -R app:app /app/media /app/logs /tmp/celery
chown -R app:app /app/media /app/logs /app/local/backups /tmp/celery
chmod 0700 "$pg_data"
if [[ ! -s "$pg_data/PG_VERSION" ]]; then
runuser -u postgres -- "$pg_bin/initdb" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256
@@ -37,6 +38,7 @@ fi
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
runuser -u app -- python manage.py check --deploy
runuser -u app -- python manage.py migrate --noinput
if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then
runuser -u app -- python manage.py bootstrap_instance || true
+7
View File
@@ -13,16 +13,21 @@ MARKDOWN_LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
REQUIRED_ENV_KEYS = {
"DJANGO_SECRET_KEY",
"DJANGO_DEBUG",
"PUBLIC_BASE_URL",
"DJANGO_ALLOWED_HOSTS",
"DATABASE_URL",
"POSTGRES_PASSWORD",
"REDIS_URL",
"CACHE_URL",
"HEALTHCHECK_REQUIRE_CACHE",
"SOURCE_POLICY_MODE",
"MAILBOX_CREDENTIAL_KEYS",
"IMAP_CONNECT_TIMEOUT_SECONDS",
"IMAP_MAX_MESSAGES_PER_POLL",
"IMAP_MAX_MESSAGE_BYTES",
"OLLAMA_ENABLED",
"DEMO_READ_ONLY",
"TRUST_PROXY_HEADERS",
}
REQUIRED_DOCS = {
"README.md",
@@ -34,6 +39,8 @@ REQUIRED_DOCS = {
"docs/quality/THREAT_MODEL.md",
"docs/quality/TRACEABILITY_MATRIX.md",
"docs/operations/UNRAID_DEPLOYMENT.md",
"docs/operations/NGINX_PROXY_MANAGER.md",
"deployment/production.env.example",
"docs/api/openapi.yaml",
".agents/skills/vacatureradar-maintainer/SKILL.md",
}
+9 -6
View File
@@ -1,6 +1,9 @@
@import url("tokens.css");
@import url("base.css");
@import url("layout.css");
@import url("components.css");
@import url("pages.css");
@import url("responsive.css");
/* Houd ook geïmporteerde stylesheets versieerbaar; alleen app.css voorzien van
een querystring ververst anders niet noodzakelijk de browsercache. */
@import url("tokens.css?v=0.3.13");
@import url("base.css?v=0.3.13");
@import url("layout.css?v=0.3.13");
@import url("components.css?v=0.3.13");
@import url("pages.css?v=0.3.13");
@import url("responsive.css?v=0.3.13");
@import url("premium.css?v=0.3.13");
+45 -7
View File
@@ -125,11 +125,29 @@ body.auth-page {
.auth-brand-logo {
display: block;
width: 64px;
height: 64px;
width: 72px;
height: 72px;
margin-bottom: 16px;
border-radius: 16px;
box-shadow: 0 10px 24px rgba(0, 124, 131, 0.28), 0 2px 6px rgba(18, 38, 43, 0.08);
border-radius: 0;
box-shadow: none;
}
.auth-shell > .developer-credit {
display: flex;
align-items: center;
gap: 9px;
justify-content: center;
margin: 18px auto 0;
color: var(--on-surface-variant);
font: 700 9px/1 var(--font-sans);
letter-spacing: .1em;
text-transform: uppercase;
}
.auth-shell > .developer-credit img {
display: block;
width: 113px;
height: auto;
}
.auth-brand-name {
@@ -142,9 +160,12 @@ body.auth-page {
}
.auth-brand-tagline {
margin: 4px 0 0;
font-size: 14px;
line-height: 20px;
margin: 6px 0 0;
font-size: 11px;
line-height: 18px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--on-surface-variant);
}
@@ -220,6 +241,7 @@ body.auth-page {
letter-spacing: 0.02em;
color: var(--primary);
text-decoration: none;
white-space: nowrap;
}
.field-link:hover {
@@ -289,6 +311,22 @@ body.auth-page {
height: 18px;
}
.pw-toggle .icon[hidden] {
display: none;
}
@media (max-width: 480px) {
.field-label-row {
align-items: flex-start;
flex-direction: column;
gap: 5px;
}
.field-link {
font-size: 10px;
}
}
.field-error {
margin-top: 6px;
font-size: 12px;
+22
View File
@@ -65,3 +65,25 @@
.auth-card { width: min(460px, 100%); padding: 30px; border: 1px solid color-mix(in srgb, var(--cyan) 25%, var(--outline)); background: color-mix(in srgb, var(--surface-container) 94%, transparent); box-shadow: 0 30px 90px rgba(0,0,0,.3), var(--shadow-signal); }
.auth-brand { display: flex; align-items: flex-start; gap: 15px; margin-bottom: 26px; }
.auth-brand .brand-radar, .auth-brand .brand-mark { display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; border: 1px solid var(--cyan); color: var(--cyan); }
/* Public demo state ------------------------------------------------------- */
.demo-mode-banner {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding: 11px 14px;
border: 1px solid color-mix(in srgb, var(--cyan) 42%, var(--outline));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--cyan) 9%, var(--surface-low));
color: var(--text);
}
.demo-mode-banner .icon { flex: 0 0 auto; color: var(--cyan); }
.demo-mode-banner span { display: grid; gap: 2px; }
.demo-mode-banner strong { font-size: .82rem; letter-spacing: .04em; text-transform: uppercase; }
.demo-mode-banner small { color: var(--text-muted); }
[data-demo-read-only="true"] [data-demo-disabled="true"] {
cursor: not-allowed;
filter: saturate(.45);
opacity: .62;
}
+3 -5
View File
@@ -13,15 +13,13 @@
.nav-item.is-active { background: color-mix(in srgb, var(--cyan) 12%, var(--surface-container)); color: var(--text-strong); }
.nav-item.is-active::after { position: absolute; inset: 0 -1px 0 auto; width: 2px; content: ""; background: var(--cyan); box-shadow: 0 0 12px var(--cyan); }
.nav-item.is-active .icon { color: var(--cyan); }
.sidebar-radar-card { display: flex; align-items: center; gap: 10px; margin: 0 12px 12px; padding: 12px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
.sidebar-footer { margin-top: auto; padding: 13px 18px 15px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); }
.sidebar-radar-card { display: flex; align-items: center; gap: 9px; padding-bottom: 11px; }
.sidebar-radar-card strong, .sidebar-radar-card small { display: block; }
.sidebar-radar-card strong { font-size: 12px; }
.sidebar-radar-card small { margin-top: 3px; font: 10px/1.2 var(--font-mono); }
.sidebar-account { display: flex; align-items: center; gap: 10px; padding: 15px 18px; border-top: 1px solid color-mix(in srgb, var(--outline) 28%, transparent); background: var(--surface-low); }
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-account small { font: 10px var(--font-mono); text-transform: uppercase; }
.cockpit-workspace { min-height: 100vh; margin-left: var(--sidebar-width); }
.cockpit-topbar { position: fixed; inset: 0 0 auto var(--sidebar-width); z-index: 40; display: flex; height: var(--topbar-height); align-items: center; justify-content: space-between; gap: 20px; padding: 0 var(--content-gutter); border-bottom: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(18px); }
.cockpit-topbar { position: fixed; inset: 0 0 auto var(--sidebar-width); z-index: 40; display: flex; height: var(--topbar-height); align-items: center; padding: 0 var(--content-gutter); border-bottom: 1px solid color-mix(in srgb, var(--outline) 32%, transparent); background: color-mix(in srgb, var(--surface) 88%, transparent); backdrop-filter: blur(18px); }
.topbar-start, .topbar-actions { display: flex; min-width: 0; align-items: center; gap: 12px; }
.topbar-start { flex: 1; }
.cockpit-main { width: 100%; min-height: 100vh; padding: calc(var(--topbar-height) + 36px) var(--content-gutter) 56px; }
+6 -6
View File
@@ -300,18 +300,18 @@
.platform-grid { margin-top: 18px; }
.platform-grid article { align-items: flex-start; flex-direction: column; }
.activity-filterbar { display: grid; grid-template-columns: minmax(260px, 1fr) 220px auto; align-items: end; gap: 10px; margin-bottom: 28px; padding: 14px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
.activity-timeline { position: relative; display: grid; gap: 16px; padding-left: 78px; }
.activity-timeline::before { position: absolute; inset: 0 auto 0 28px; width: 1px; content: ""; background: linear-gradient(var(--cyan), var(--outline) 70%, transparent); opacity: .45; }
.activity-timeline { position: relative; display: grid; gap: 10px; padding-left: 62px; }
.activity-timeline::before { position: absolute; inset: 0 auto 0 22px; width: 1px; content: ""; background: linear-gradient(var(--cyan), var(--outline) 70%, transparent); opacity: .45; }
.activity-event { position: relative; }
.activity-marker { position: absolute; top: 0; left: -78px; display: grid; width: 58px; height: 58px; place-items: center; border: 1px solid var(--outline); background: var(--surface-low); color: var(--text-muted); }
.activity-marker { position: absolute; top: 0; left: -62px; display: grid; width: 44px; height: 44px; place-items: center; border: 1px solid var(--outline); border-radius: 10px; background: var(--surface-low); color: var(--text-muted); }
.activity-marker.is-positive { border-color: color-mix(in srgb, var(--emerald) 45%, var(--outline)); color: var(--emerald); }
.activity-marker.is-attention { border-color: color-mix(in srgb, var(--coral) 45%, var(--outline)); color: var(--coral); }
.activity-card { padding: 20px; border: 1px solid color-mix(in srgb, var(--outline) 44%, transparent); background: var(--surface-container); }
.activity-card { padding: 16px 18px; border: 1px solid color-mix(in srgb, var(--outline) 44%, transparent); border-radius: 12px; background: var(--surface-container); }
.activity-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.activity-card-head h2 { margin: 9px 0 0; }
.activity-card-head h2 { margin: 6px 0 0; font-size: 18px; }
.activity-card-head time { color: var(--text-faint); font: 11px var(--font-mono); }
.activity-object { color: var(--text-muted); }
.decision-note { margin: 14px 0; padding: 12px 14px; border-left: 2px solid var(--cyan); background: var(--surface-high); }
.decision-note { margin: 10px 0; padding: 9px 12px; border-left: 2px solid var(--cyan); background: var(--surface-high); }
.decision-note p { margin: 7px 0 0; }
.employer-kpis { display: flex; gap: 8px; }
.employer-kpis > div { min-width: 130px; padding: 11px 13px; border: 1px solid color-mix(in srgb, var(--outline) 42%, transparent); background: var(--surface-low); }
+244
View File
@@ -0,0 +1,244 @@
/* VR-216: premium refinement layer — calm hierarchy over decorative chrome. */
:root {
--surface-raised: color-mix(in srgb, var(--surface-container) 96%, white);
--divider: color-mix(in srgb, var(--outline) 29%, transparent);
--divider-strong: color-mix(in srgb, var(--outline) 46%, transparent);
--shadow-card: 0 18px 48px rgba(0, 0, 0, .13);
--radius-sm: 7px;
--radius-md: 12px;
--radius-lg: 18px;
--content-frame: 1720px;
}
:root[data-theme="light"] {
--surface-lowest: #e9f0f1;
--surface: #f4f7f7;
--surface-low: #edf2f2;
--surface-container: #fbfdfd;
--surface-high: #e3ebeb;
--surface-highest: #d6e2e2;
--outline: #aebfc0;
--shadow-card: 0 18px 45px rgba(31, 54, 57, .08);
}
body::before {
background:
radial-gradient(circle at 82% -8%, color-mix(in srgb, var(--cyan) 7%, transparent), transparent 33rem),
radial-gradient(circle at 24% 108%, color-mix(in srgb, var(--indigo) 4%, transparent), transparent 38rem);
}
.cockpit-sidebar,
.cockpit-topbar,
.sidebar-heading,
.mobile-tabbar { border-color: var(--divider); }
.cockpit-sidebar { background: color-mix(in srgb, var(--surface-lowest) 97%, black); }
.cockpit-main { padding-top: calc(var(--topbar-height) + 42px); }
.cockpit-main > * { width: min(100%, var(--content-frame)); margin-inline: auto; }
.topbar-inner { display: flex; width: min(100%, var(--content-frame)); height: 100%; margin-inline: auto; align-items: center; justify-content: space-between; gap: 20px; }
.brand-radar { flex: 0 0 auto; width: 42px; height: 42px; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
.brand-lockup { display: grid; min-width: 0; gap: 4px; }
.brand-lockup strong { color: var(--text-strong); font: 740 17px/1 var(--font-headline); letter-spacing: -.025em; }
.cockpit-brand small { margin: 0; color: var(--text-faint); font-size: 8px; font-weight: 700; letter-spacing: .09em; }
.developer-credit { display: flex; align-items: center; gap: 7px; color: var(--text-faint); font: 650 7px/1 var(--font-body); letter-spacing: .08em; text-transform: uppercase; }
.developer-credit img { display: block; width: 92px; height: auto; opacity: .82; }
.sidebar-footer > .developer-credit { padding-top: 10px; border-top: 1px solid var(--divider); }
.sidebar-footer > .developer-credit img { width: 82px; padding: 0; background: transparent; filter: brightness(0) invert(1); opacity: .58; }
:root[data-theme="light"] .sidebar-footer > .developer-credit img { filter: saturate(.78); opacity: .76; }
.workspace-identity { display: grid; min-width: 176px; padding-right: 18px; border-right: 1px solid var(--divider); line-height: 1.1; }
.workspace-identity span { color: var(--text-strong); font-size: 14px; font-weight: 740; letter-spacing: -.015em; }
.workspace-identity small { margin-top: 5px; color: var(--text-faint); font: 650 9px var(--font-body); letter-spacing: .075em; text-transform: uppercase; }
.nav-caption,
.nav-item,
.mobile-tabbar a,
.nav-caption { font-weight: 700; letter-spacing: .11em; }
.nav-item { min-height: 46px; border-radius: 9px; font-size: 14px; font-weight: 560; letter-spacing: -.005em; }
.nav-item:hover { transform: none; background: color-mix(in srgb, var(--surface-high) 70%, transparent); }
.nav-item.is-active { background: var(--surface-high); box-shadow: inset 0 0 0 1px var(--divider); }
.nav-item.is-active::after { inset: 9px auto 9px 0; width: 3px; border-radius: 0 3px 3px 0; box-shadow: none; }
.nav-item .icon { transition: color .16s ease, transform .16s ease; }
.nav-item:hover .icon { transform: scale(1.06); color: var(--text-strong); }
.nav-item.is-active span { font-weight: 680; }
.cockpit-heading { margin-bottom: 30px; }
.cockpit-heading h1 { max-width: 900px; }
.cockpit-heading > div:first-child > p:last-child { font-size: 15px; line-height: 1.65; }
.eyebrow,
.section-kicker,
.kpi-label { color: var(--text-faint); font-family: var(--font-body); font-weight: 700; letter-spacing: .105em; }
.section-kicker { margin-bottom: 7px; }
.section-link { display: inline-flex; min-height: 38px; align-items: center; padding: 5px 2px; }
.button { min-height: 44px; border-radius: 9px; }
.button-primary { box-shadow: 0 8px 24px color-mix(in srgb, var(--cyan) 13%, transparent); }
.button-secondary,
.button-ghost { border-color: var(--divider-strong); }
.button-ghost { background: color-mix(in srgb, var(--surface-high) 78%, transparent); }
.icon-button { border-radius: 9px; }
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
select,
textarea,
.command-search { border-color: var(--divider-strong); border-radius: 9px; background: color-mix(in srgb, var(--surface-low) 90%, transparent); }
.command-search { transition: width .2s ease, border-color .16s ease, background .16s ease, box-shadow .16s ease; }
.command-search:focus-within { width: min(620px, 65vw); border-color: color-mix(in srgb, var(--cyan) 58%, var(--outline)); background: var(--surface-container); box-shadow: 0 0 0 3px color-mix(in srgb, var(--cyan) 8%, transparent); }
.field-label,
label > span:first-child { font-family: var(--font-body); font-weight: 700; letter-spacing: .065em; }
.panel,
.tonal-panel,
.job-card,
.result-card,
.source-card,
.health-card,
.profile-card,
.form-panel {
border-color: var(--divider);
background: var(--surface-raised);
box-shadow: 0 1px 0 color-mix(in srgb, white 2%, transparent);
}
.strong-match { border-color: var(--divider); box-shadow: inset 3px 0 var(--cyan-dim), var(--shadow-card); }
.panel-subtle { background: var(--surface-low); box-shadow: none; }
.stats-grid { gap: 14px; margin-bottom: 32px; }
.stat-card {
min-height: 122px;
padding: 19px 20px;
border: 0;
border-radius: var(--radius-md);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
}
.stat-card::after { display: none; }
.stat-card.signal { background: linear-gradient(135deg, color-mix(in srgb, var(--cyan) 10%, var(--surface-raised)), var(--surface-raised) 72%); box-shadow: inset 3px 0 var(--cyan), var(--shadow-card); }
.stat-card strong { margin-top: 15px; font-size: 36px; }
.stat-card small { font-family: var(--font-body); font-size: 12px; }
.dashboard-grid { gap: 28px; }
.dashboard-rail { gap: 18px; }
.rail-panel { border: 0; box-shadow: var(--shadow-card); }
.rail-panel:not(.radar-summary) { background: color-mix(in srgb, var(--surface-raised) 76%, var(--surface-low)); box-shadow: none; }
.job-card { transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; }
.job-card:hover { transform: translateY(-2px); border-color: var(--divider-strong); box-shadow: var(--shadow-card); }
.dashboard-matches .job-feed { gap: 12px; }
.dashboard-matches .feed-card { min-height: 0; }
.dashboard-matches .job-card-main { padding: 18px 20px; }
.filter-console {
grid-template-columns: minmax(280px, 1.8fr) repeat(5, minmax(126px, .72fr)) auto;
gap: 12px;
padding: 16px;
border: 0;
border-radius: var(--radius-md);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
}
.active-filters { margin-bottom: 18px; }
.explorer-shell { gap: 18px; }
.explorer-card { border-color: var(--divider); border-radius: var(--radius-md); background: var(--surface-raised); }
.explorer-card:hover { border-color: var(--divider-strong); background: color-mix(in srgb, var(--surface-raised) 96%, var(--cyan)); }
.explorer-card.is-selected { border-color: var(--divider-strong); box-shadow: inset 3px 0 var(--cyan); }
.explorer-intelligence { border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
.intelligence-panel { border-color: var(--divider); }
.pipeline-summary { gap: 12px; }
.pipeline-summary article { border: 0; border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: var(--shadow-card); }
.pipeline-board { grid-auto-columns: minmax(300px, 1fr); gap: 16px; scroll-snap-type: x proximity; scrollbar-gutter: stable; }
.pipeline-column { min-height: 420px; padding: 14px; border: 0; border-radius: var(--radius-md); background: color-mix(in srgb, var(--surface-low) 83%, transparent); scroll-snap-align: start; }
.pipeline-column-header { position: sticky; top: 0; z-index: 1; padding: 4px 2px 12px; background: inherit; font-family: var(--font-body); }
.application-card { border-color: var(--divider); border-radius: 10px; background: var(--surface-raised); box-shadow: 0 10px 28px rgba(0, 0, 0, .08); }
.pipeline-empty { border-color: var(--divider); border-radius: 9px; }
.profile-index-grid { grid-template-columns: minmax(0, 1040px); }
.profile-index-card { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(250px, .5fr); padding: 0; border: 0; border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: var(--shadow-card); }
.profile-index-card.strong-match { box-shadow: inset 3px 0 var(--emerald), var(--shadow-card); }
.profile-index-content { min-width: 0; padding: 24px; }
.profile-index-card .intel-facts { margin: 22px -24px; grid-template-columns: repeat(4, minmax(0, 1fr)); border-color: var(--divider); }
.profile-index-card .page-actions { margin-top: 22px; }
.profile-index-art { position: relative; display: grid; min-height: 320px; overflow: hidden; place-items: center; border-left: 1px solid var(--divider); background: linear-gradient(145deg, color-mix(in srgb, var(--cyan) 4%, var(--surface-low)), var(--surface-low)); }
.radar-illustration { --radar-x: 0px; --radar-y: 0px; position: relative; width: min(290px, 92%); aspect-ratio: 1; color: var(--cyan-dim); transform: translate(var(--radar-x), var(--radar-y)); transition: transform .32s cubic-bezier(.2,.8,.2,1); }
.radar-illustration svg { position: relative; z-index: 1; overflow: visible; filter: drop-shadow(0 18px 36px color-mix(in srgb, var(--cyan) 8%, transparent)); }
.radar-illustration-grid { stroke: color-mix(in srgb, var(--outline) 52%, transparent); stroke-width: 1; }
.radar-illustration-sweep { transform-origin: 160px 160px; }
.radar-illustration-signals circle { fill: var(--emerald); filter: drop-shadow(0 0 7px var(--emerald)); }
.radar-illustration-core { fill: var(--cyan); filter: drop-shadow(0 0 10px var(--cyan)); }
.radar-illustration-orbit { position: absolute; z-index: 0; inset: 13%; border: 1px solid color-mix(in srgb, var(--cyan) 15%, transparent); border-radius: 50%; }
.radar-illustration-orbit::after { position: absolute; top: 12%; left: 12%; width: 7px; height: 7px; content: ""; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 14px var(--cyan); }
.radar-illustration .orbit-two { inset: 24%; transform: rotate(132deg); border-color: color-mix(in srgb, var(--emerald) 13%, transparent); }
.illustrated-empty .radar-illustration { width: 180px; margin-bottom: 8px; }
.pill { border-color: transparent; border-radius: 999px; }
.empty-state { border-color: var(--divider-strong); border-radius: var(--radius-md); }
@media (min-width: 1800px) {
:root { --content-frame: 2200px; }
.explorer-shell { grid-template-columns: minmax(650px, 880px) minmax(760px, 1fr); }
.automation-layout { grid-template-columns: minmax(0, 1fr) minmax(340px, 430px); }
}
@media (min-width: 2800px) {
:root { --content-frame: 2480px; }
.dashboard-grid { grid-template-columns: minmax(0, 1fr) minmax(380px, 460px); }
}
@media (max-width: 1450px) {
.filter-console { grid-template-columns: minmax(250px, 1.5fr) repeat(3, minmax(135px, 1fr)); }
.filter-console > :nth-last-child(-n+3) { grid-row: 2; }
}
@media (max-width: 1180px) {
.filter-console { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.filter-console > * { grid-row: auto !important; }
}
@media (max-width: 820px) {
.cockpit-main { padding-top: calc(var(--topbar-height) + 30px); }
.mobile-tabbar a { font-family: var(--font-body); font-weight: 650; }
.filter-console { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 13px; }
.filter-console .grow { grid-column: span 2; }
.pipeline-board { width: 100%; grid-auto-columns: auto; grid-auto-flow: row; grid-template-columns: 1fr; overflow-x: visible; }
.profile-index-card .intel-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.profile-index-card { grid-template-columns: 1fr; }
.profile-index-art { min-height: 230px; border-top: 1px solid var(--divider); border-left: 0; }
.profile-index-art .radar-illustration { width: 220px; }
.command-search:focus-within { width: min(100%, 510px); }
.topbar-inner { gap: 8px; }
}
@media (max-width: 560px) {
.demo-mode-banner { align-items: flex-start; gap: 10px; margin-bottom: 20px; padding: 10px 12px; }
.demo-mode-banner strong { font-size: .72rem; }
.demo-mode-banner small { font-size: .78rem; line-height: 1.45; }
.cockpit-heading { margin-bottom: 22px; }
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-bottom: 24px; }
.stat-card { min-height: 92px; padding: 13px; }
.stat-card strong { margin: 9px 0 2px; font-size: 28px; }
.stat-card small { overflow: hidden; font-size: 10px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.kpi-label { min-height: 22px; margin-bottom: 0; font-size: 9px; line-height: 1.2; }
.filter-console { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filter-console .grow { grid-column: 1 / -1; }
.filter-console .button { grid-column: 1 / -1; }
.profile-index-card { padding: 18px; }
.profile-index-card .intel-facts { margin: 18px -18px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: no-preference) {
@keyframes premium-enter { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
.stats-grid > *, .dashboard-grid, .explorer-shell, .pipeline-board, .profile-index-grid { animation: premium-enter .34s ease-out both; }
.radar-illustration-sweep { animation: radar-scan 7s linear infinite; }
.radar-illustration-signals circle { animation: radar-signal 2.8s ease-in-out infinite alternate; }
.radar-illustration-signals circle:nth-child(2) { animation-delay: -.9s; }
.radar-illustration-signals circle:nth-child(3) { animation-delay: -1.7s; }
.radar-illustration .orbit-one { animation: radar-orbit 12s linear infinite; }
.radar-illustration .orbit-two { animation: radar-orbit 17s linear infinite reverse; }
@keyframes radar-scan { to { transform: rotate(360deg); } }
@keyframes radar-signal { to { opacity: .38; transform: scale(.72); transform-origin: center; } }
@keyframes radar-orbit { to { transform: rotate(360deg); } }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
.radar-illustration { transform: none !important; transition: none; }
}
+3
View File
@@ -50,6 +50,8 @@
.mobile-tabbar a { display: grid; min-height: 66px; place-items: center; align-content: center; gap: 4px; color: var(--text-faint); font: 10px var(--font-mono); }
.mobile-tabbar a.is-active { color: var(--cyan); }
.mobile-tabbar .icon { width: 19px; height: 19px; }
.pipeline-board { width: 100%; grid-auto-columns: auto; grid-auto-flow: row; grid-template-columns: 1fr; overflow-x: visible; }
.pipeline-column { min-height: 0; }
.page-header, .cockpit-heading, .section-heading { align-items: flex-start; flex-direction: column; }
.page-header { margin-bottom: 20px; }
.form-grid, .source-details-grid, .import-layout, .mailbox-form-grid, .system-hero { grid-template-columns: 1fr; }
@@ -88,6 +90,7 @@
.topbar-actions { gap: 3px; }
.stats-grid, .health-grid, .filter-console, .command-panel, .system-counts { grid-template-columns: 1fr; }
.filter-console .grow { grid-column: auto; }
[data-demo-read-only="true"] button[data-demo-disabled="true"] { display: none; }
.stat-card { min-height: 105px; }
.job-card-main, .result-body, .panel, .tonal-panel, .health-card, .form-panel, .form-section, .detail-section { padding: 16px; }
.job-card-head, .result-heading { align-items: flex-start; flex-direction: column; }
+6 -7
View File
@@ -1,8 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#00f5ff"/>
<g fill="none" stroke="#003739" stroke-linecap="round" stroke-linejoin="round" stroke-width="4">
<circle cx="32" cy="32" r="20"/>
<circle cx="32" cy="32" r="11"/>
<path d="m32 32 15-15M32 8v5M8 32h5M32 51v5M51 32h5"/>
</g>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="VacatureRadar">
<rect x="2" y="2" width="60" height="60" rx="15" fill="#071327"/>
<circle cx="32" cy="32" r="18" fill="none" stroke="#19d3e6" stroke-width="5" stroke-linecap="round" stroke-dasharray="94 20" transform="rotate(38 32 32)"/>
<path d="M32 32 45 18" fill="none" stroke="#7df5ff" stroke-width="5" stroke-linecap="round"/>
<circle cx="32" cy="32" r="4" fill="#19d3e6"/>
<circle cx="20" cy="22" r="4" fill="#45d69b" stroke="#071327" stroke-width="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 363 B

After

Width:  |  Height:  |  Size: 560 B

+7
View File
@@ -0,0 +1,7 @@
# Lokale merkassets
`itworx-wordmark.png` is de transparante, opgeschoonde officiële ITWorx.tech-wordmark uit `ITWorx_Logo_Asset_Pack_2026-07-25.zip`, door de projecteigenaar aangeleverd op 2026-07-26. Deze wordmark wordt uitsluitend gebruikt voor de secundaire credit `Ontwikkeld door ITWorx.tech`; ITWorx is niet het productmerk.
`vacatureradar-logo.svg` is het zelfstandige productbeeldmerk: een open radarboog, scanstraal en één gevonden kans. Het concept is met de ingebouwde beeldgeneratieflow verkend en vervolgens handmatig als eenvoudig schaalbaar SVG opgebouwd. `favicon.svg` gebruikt exact dezelfde geometrie en `vacatureradar-docker.png` is de 256×256 rastervariant.
Deze assets zijn uitsluitend voor de eigen VacatureRadar-installatie en worden niet als generiek herbruikbare of publiek gelicenseerde merkset aangeboden.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

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