Author SHA1 Message Date
Jens be0fbaa53c fix: hold candidate traffic and workers until migration commit
Managed validation / full (pull_request) Successful in 3m41s
2026-09-09 23:21:53 +02:00
Jens a0c0ee26ca docs: record safe legacy migration data contract
Managed validation / full (pull_request) Successful in 3m38s
2026-09-09 22:25:49 +02:00
Jens 3e637a8429 ops: define clone-only verified legacy migration 2026-09-09 22:24:32 +02:00
Jens 2d1964106b Merge pull request 'fix: use local calendar dates in demo seed' (#4) from fix/demo-seed-local-calendar-date-20260903 into main
Unraid autoredeploy / Deploy vacatureradar (push) Failing after 14s
2026-09-03 00:10:15 +00:00
NuklearRabbit 7c4652ac49 fix: use local calendar dates in demo seed
Managed validation / full (pull_request) Successful in 3m2s
2026-09-03 01:11:07 +02:00
Jens c855253072 Merge pull request 'hygiene: prepare public release' (#3) from chore/public-release-hygiene-20260902 into main
Unraid autoredeploy / Deploy vacatureradar (push) Failing after 15s
2026-09-02 21:44:28 +00:00
NuklearRabbit f20b7de053 hygiene: prepare VacatureRadar for public release
Managed validation / full (pull_request) Successful in 3m1s
2026-09-02 23:37:30 +02:00
Jens afa7474094 Merge pull request 'docs: record VacatureRadar release-history blocker' (#2) from chatgpt/repo-hygiene-vacatureradar into main
Reviewed-on: #2
2026-09-01 16:12:25 +00:00
Jens 256cb98695 ci: install pinned browser without apt
Managed validation / full (pull_request) Successful in 3m27s
2026-09-01 18:07:35 +02:00
Jens 1cd6fc11a4 ci: use Node-capable Playwright runner image
Managed validation / full (pull_request) Failing after 4m23s
2026-09-01 17:47:37 +02:00
Jens 9643916f16 ci: run browser gate in pinned Playwright image
Managed validation / full (pull_request) Failing after 53s
2026-09-01 17:30:15 +02:00
Jens f52a4ef3cf ci: bound Playwright browser provisioning
Managed validation / full (pull_request) Failing after 1m22s
2026-09-01 17:16:53 +02:00
Jens ebdf3f4f91 ci: provision required Playwright browser gate
Managed validation / full (pull_request) Failing after 1h14m10s
2026-09-01 15:11:27 +02:00
Jens ed8f59439d docs: record VacatureRadar history blocker
Managed validation / full (pull_request) Canceled after 0s
2026-08-31 09:30:36 +02:00
Jens 5c287f2459 Merge pull request '[skip ci] Centralize Gitea Actions runner' (#1) from codex/centralize-gitea-runner into main 2026-08-27 23:52:39 +02:00
NuklearRabbit fdb71081e0 chore(actions): use central Unraid runner [skip ci] 2026-08-27 23:44:30 +02:00
Jens 751bf8be16 ci: align managed validation contract [skip ci] 2026-08-27 07:41:15 +02:00
Jens e738cba3a3 ci: add managed validation contract [skip ci] 2026-08-27 06:29:06 +02:00
NuklearRabbit 4c00ccaa39 ci: add scoped Unraid autoredeploy 2026-08-26 02:51:35 +02:00
JensandClaude Fable 5 36f41ba43a ci(security): pin trivy-action to an existing tag (v0.30.0) so the gate can resolve on Gitea Actions
The first real platform runs (2026-08-17, new instance runner) failed at
'Unable to resolve 0.30.0: reference not found' - the tag exists only as v0.30.0.
Where present, the trufflehog GitHub Action (which fails under the act runner) is
replaced by the pinned trufflehog binary in filesystem mode next to gitleaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 14:13:22 +02:00
Jens 61502548d4 fix(security): bump cryptography 49.0.0 -> 50.0.0 (CVE-2026-69247)
Verified: 332 tests pass (325 unit/integration + 7 E2E/accessibility
with Playwright chromium), ruff clean. Identified via ITWorx
security-hardening programme Trivy scan.
2026-08-15 20:28:44 +02:00
Jens 7b6f6d43fc Close 0.3.17 operational follow-ups 2026-08-12 15:12:51 +02:00
Jens 4822ee6880 Record verified 0.3.17 production release
deploy / deploy (push) Waiting to run
2026-08-12 13:50:01 +02:00
Jens 3cfb2260cc Fix immutable release bootstrap for 0.3.17
release / verify-publish (push) Successful in 6m13s
deploy / deploy (push) Canceled after 0s
2026-08-12 13:35:02 +02:00
Jens b5045cf487 Document 0.3.16 deployment evidence
deploy / deploy (push) Canceled after 0s
2026-08-12 12:04:50 +02:00
Jens a6c8a79e31 Harden public demo for 0.3.16
release / verify-publish (push) Failing after 6s
deploy / deploy (push) Canceled after 0s
2026-08-12 11:42:00 +02:00
Jens 2419e6bf01 Update README for 0.3.15 release
deploy / deploy (push) Canceled after 0s
2026-08-12 11:00:50 +02:00
Jens 3ab9127548 Record live mail and registry acceptance
deploy / deploy (push) Canceled after 0s
2026-07-30 02:53:38 +02:00
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
Jens 8648b4f4d1 deploy: consolidate Unraid stack in Dockerman
deploy / deploy (push) Canceled after 0s
2026-07-25 23:17:01 +02:00
Jens bdf44eec04 Refresh 2026-07-24 22:46:37 +02:00
Jens 0101660bd7 docs: complete Stitch frontend replacement 2026-07-22 20:48:48 +02:00
Jens 5f435d030e perf: eliminate source health N plus one queries 2026-07-22 20:37:01 +02:00
Jens ee915742bf fix: validate responsive Stitch cockpit 2026-07-22 20:32:38 +02:00
Jens 4acd81afb5 feat: finish Stitch skills and authentication screens 2026-07-22 20:21:55 +02:00
Jens afe18e5b96 feat: add automation activity and employer intelligence 2026-07-22 20:17:20 +02:00
Jens 4fa2a34447 feat: replace pipeline profile and source health 2026-07-22 20:10:34 +02:00
Jens 3371455255 feat: replace dashboard and vacancy intelligence 2026-07-22 19:56:15 +02:00
Jens 09af98193f feat: establish Stitch cockpit frontend contract 2026-07-22 19:48:36 +02:00
Jens a30ecbc553 feat: personalize scoring and add skills radar 2026-07-22 19:33:29 +02:00
Jens 029df89265 feat: ship premium IT-focused vacancy radar
deploy / deploy (push) Canceled after 0s
2026-07-22 15:16:15 +02:00
Jens 7d3d026d1d fix: validate connected peer for rotating CDNs
deploy / deploy (push) Canceled after 0s
2026-07-22 05:56:30 +02:00
Jens e234641c36 fix: route celery tasks to consumed queue
deploy / deploy (push) Canceled after 0s
2026-07-22 05:43:08 +02:00
219 changed files with 13914 additions and 2452 deletions
+1
View File
@@ -12,3 +12,4 @@ docs/reference/*.docx
docs/reference/*.pdf
docs/design/stitch-reference
docs/design/validation
artifacts
+37
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())"
@@ -51,11 +62,37 @@ OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=
OLLAMA_TIMEOUT_SECONDS=60
# Microsoft Entra ID (Azure AD) SSO. Laat ENTRA_ID_ENABLED=0 om SSO uit te zetten.
# Registreer een app in Entra ID en zet de redirect-URI op <host>/auth/entra/callback/.
ENTRA_ID_ENABLED=0
ENTRA_CLIENT_ID=
ENTRA_CLIENT_SECRET=CHANGE_ME_entra_client_secret
ENTRA_TENANT_ID=
# Optioneel: overschrijf de redirect-URI (anders automatisch afgeleid van de host).
ENTRA_REDIRECT_URI=
# Optioneel: beperk tot bepaalde e-maildomeinen, komma-gescheiden (bv. organisatie.nl).
ENTRA_ALLOWED_DOMAINS=
ENTRA_SCOPES=User.Read
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
DEMO_MODE_ENABLED=1
DEMO_READ_ONLY=1
DEMO_SESSION_SECONDS=3600
DEMO_USERNAME=demo
DEMO_EMAIL=demo@vacatureradar.local
# Beveiliging
# 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=vacatureradar.local,127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local: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
-62
View File
@@ -1,62 +0,0 @@
name: deploy
on:
push:
branches:
- main
- master
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Push-to-deploy op server
run: |
set -euo pipefail
if [ -z "${DEPLOY_COMPOSE_FILE:-}" ]; then
DEPLOY_COMPOSE_FILE="docker-compose.yml"
fi
if [ -z "${APP_HOST:-}" ]; then
APP_HOST="$(hostname -f 2>/dev/null || hostname)"
fi
if [ -z "${APP_SCHEME:-}" ]; then
APP_SCHEME="http"
fi
if [ -z "${DJANGO_DEBUG:-}" ]; then
if [ "${APP_SCHEME}" = "https" ]; then
DJANGO_DEBUG="0"
else
DJANGO_DEBUG="1"
fi
fi
export APP_HOST
export APP_SCHEME
export DJANGO_DEBUG
export DEPLOY_COMPOSE_FILE
if [ -n "${APP_ORIGINS:-}" ]; then
export APP_ORIGINS
fi
if [ -n "${APP_HOSTS:-}" ]; then
export APP_HOSTS
fi
if [ -n "${APP_PORT:-}" ]; then
export APP_PORT
fi
bash scripts/deploy_docker.sh "${DEPLOY_COMPOSE_FILE}" .env
+135
View File
@@ -0,0 +1,135 @@
name: Managed validation
on:
pull_request:
workflow_dispatch:
inputs:
profile:
description: Allowlisted validation profile
required: true
default: full
type: choice
options: [test, lint, typecheck, build, security, full]
permissions:
contents: read
concurrency:
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
full:
name: full
# Public fork code must never execute automatically on the private runner.
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
runs-on: ubuntu-latest
timeout-minutes: 30
container:
# Gitea's JavaScript actions execute inside the job container, so use
# the version-matched Playwright image that includes Node as well as the
# pinned Chromium browser and native runtime libraries.
image: mcr.microsoft.com/playwright:v1.54.0-noble
options: --ipc=host
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Provision the supported Python runtime
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13.2'
- name: Validate repository with a bounded profile
shell: bash
env:
REQUESTED_PROFILE: ${{ inputs.profile }}
run: |
set -euo pipefail
profile="${REQUESTED_PROFILE:-full}"
case "${profile}" in
test|lint|typecheck|build|security|full) ;;
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
esac
git diff --check
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
echo "Unresolved merge markers detected" >&2
exit 1
fi
if [[ -f pyproject.toml || -f requirements.txt ]]; then
# Compile only tracked Python sources. Running compileall after a
# Node install would otherwise traverse node_modules and turn a
# lightweight baseline into a large runner workload.
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
if [[ -f uv.lock ]]; then
python -m venv "${RUNNER_TEMP}/managed-uv"
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
"${managed_uv}" sync --locked --extra browser
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
# The image supplies the native browser libraries. Install
# only the lockfile-matched browser payload into an explicit
# job-local path; never invoke Playwright's apt workflow.
export PLAYWRIGHT_BROWSERS_PATH="${RUNNER_TEMP}/managed-playwright"
timeout --foreground 5m "${managed_uv}" run playwright install chromium
timeout --foreground 20m "${managed_uv}" run python -m pytest
fi
fi
if [[ "${profile}" == lint || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
"${managed_uv}" run python -m ruff check .
fi
fi
elif [[ -f requirements.txt ]]; then
python -m venv "${RUNNER_TEMP}/managed-python"
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
"${managed_python}" -m pytest
fi
fi
fi
fi
# Prepare Python before invoking Node scripts. Polyglot repositories
# commonly delegate their test script to Python and need the managed
# virtual environment to be active first.
if [[ -f package.json ]]; then
corepack enable
if [[ -f pnpm-lock.yaml ]]; then
pnpm install --frozen-lockfile
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
elif [[ -f package-lock.json ]]; then
npm ci
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
npm run --if-present typecheck
fi
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
fi
fi
if [[ -f go.mod ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
go test ./...
fi
fi
if [[ -f Cargo.toml ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
cargo test --locked
fi
fi
if compgen -G '*.sln' >/dev/null; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
dotnet test --configuration Release
fi
fi
+59
View File
@@ -0,0 +1,59 @@
name: release
on:
push:
tags:
- "v*.*.*"
jobs:
verify-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pinned uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.12"
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
+31
View File
@@ -0,0 +1,31 @@
name: Unraid autoredeploy
on:
push:
branches: [main]
paths-ignore:
- ".gitea/**"
- "docs/**"
- "**/*.md"
workflow_dispatch:
concurrency:
group: unraid-production-vacatureradar
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy vacatureradar
runs-on: unraid-deploy
timeout-minutes: 180
steps:
- name: Deploy exact Gitea revision
run: |
set -euo pipefail
docker exec gitea-deploy-control \
/opt/gitea-deploy/deploy.py deploy \
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
+23 -2
View File
@@ -24,13 +24,15 @@ 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:
path: ./
extra_args: --only-verified
- name: Scan project dependencies and lockfiles (high/critical)
uses: aquasecurity/trivy-action@0.30.0
uses: aquasecurity/trivy-action@v0.30.0
with:
scan-type: fs
scan-ref: .
@@ -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
@@ -57,12 +66,24 @@ jobs:
- name: Build production image
run: docker build --pull --tag vacatureradar:ci .
- name: Scan container image for high/critical vulnerabilities
uses: aquasecurity/trivy-action@0.30.0
uses: aquasecurity/trivy-action@v0.30.0
with:
image-ref: vacatureradar:ci
format: table
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: true
- name: Generate CycloneDX software bill of materials
uses: aquasecurity/trivy-action@v0.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
+7
View File
@@ -28,6 +28,13 @@ Lees niet blind alle referentiedocumenten. Gebruik `docs/quality/TRACEABILITY_MA
- Geen productieafhankelijkheid toevoegen zonder duidelijke noodzaak, lockfile-update, tests en documentatie.
- Geen secrets, tokens, echte e-mailadressen of persoonlijke gegevens committen.
## Frontendcontract
- De productiefrontend volgt `docs/design/STITCH_FRONTEND_CONTRACT.md`; Stitch is de visuele bron van waarheid, de Django-backend blijft leidend voor werking en data.
- Gebruik uitsluitend echte of eerlijk als onbekend gemarkeerde data. Geen demo-KPI's, nepstatussen, dode controls, auto-apply of externe runtimeassets.
- Kernflows blijven server-rendered en bruikbaar zonder JavaScript; afwijkingen voor security, toegankelijkheid en ontbrekende data worden gedocumenteerd.
- Na een frontendmigratie blijft geen parallelle legacy-shell, ongebruikte componentstijl of verborgen oude UI bestaan.
## Codeconventies
- Python 3.12/3.13; type hints op publieke services.
+12
View File
@@ -0,0 +1,12 @@
# Architectuur
VacatureRadar gebruikt een Django-modulaire monoliet met PostgreSQL, Redis en Celery. Bedrijfsregels staan in domeinservices, views en tasks orkestreren en bronadapters leveren `ExtractionResult` zonder databasewrites.
Lees verder:
- [Systeemarchitectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
- [Datastroom](docs/architecture/DATA_FLOW.md)
- [Datamodel](docs/architecture/DATA_MODEL.md)
- [Bronadapters](docs/architecture/SOURCE_ADAPTERS.md)
- [Securityarchitectuur](docs/architecture/SECURITY_ARCHITECTURE.md)
- [Scoring](docs/architecture/SCORING_ENGINE.md)
+7
View File
@@ -0,0 +1,7 @@
# Back-up en herstel
De uitvoerbare procedure, retentie, veilige testrestore en vergelijkingsstappen staan in [docs/operations/BACKUP_RESTORE.md](docs/operations/BACKUP_RESTORE.md). Een productieback-up geldt pas als bewezen nadat hij naar een geïsoleerd doel is hersteld en vergeleken.
Op 2026-07-29 is de productieback-up werkelijk gevalideerd. Het bewijs staat in `/mnt/user/appdata/VacatureRadar/backups/release-20260729T151304Z` en bevat een PostgreSQL custom dump, media-archief, composebestand, env-fingerprint, recordtellingen en `SHA256SUMS`. De dump is als normale applicatierol hersteld naar de geïsoleerde database `vacatureradar_restore_20260729`; migrations, kernrecordaantallen en een afzonderlijke Gunicorn-readinessprobe slaagden. Het testdoel is pas daarna verwijderd.
Maak alleen de lege restoredatabase met de PostgreSQL-beheerrol en voer `pg_restore` als `vacatureradar` uit. Herstellen als beheerrol met `--no-owner` laat tabellen anders aan de verkeerde rol toebehoren.
+146 -1
View File
@@ -2,6 +2,149 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
## 0.3.17 - 2026-08-12
### Hersteld
- De Gitea-releasebootstrap gebruikt een immutable `setup-uv`-commit en een exacte
uv-versie; downloads lopen via de Astral-mirror en zijn niet langer afhankelijk
van de gelimiteerde GitHub Releases-API op het gedeelde runner-IP.
- De achterhaalde `main`-push-to-deployworkflow is verwijderd; alleen een
semantische tag kan nog een immutable releaseimage publiceren.
- De Unraid Docker-host gebruikt een conflictvrije `/24`-address pool met ruimte
voor 256 tijdelijke en toekomstige netwerken.
## 0.3.16 - 2026-08-12
### Hersteld
- De publieke demo herstelt bij iedere nieuwe demosessie zes actuele fictieve vacatures, hun laatste scores en twee toekomstige opvolgmomenten zonder duplicaten of onbeperkte scorehistoriek.
- Negen fictieve `example.test`-platformmailboxen maken de mailboxflow zichtbaar zonder echte accounts, credentials of externe polls.
- Mailboxingress wordt uitsluitend door de mailboxscheduler verwerkt; e-mailbronnen komen niet meer in URL-fetchplanning, dashboardscanstatus of de algemene bronrunactiviteit terecht.
- Statuscopy onderscheidt een actief zoekprofiel van operationele brongezondheid en het lege loginwachtwoordveld lijkt niet langer vooraf ingevuld.
## 0.3.14 - 2026-07-29
### Gewijzigd
- De importpipeline hergebruikt werkgevers en actieve profielen binnen één run, synchroniseert provenance gebundeld en vermijdt volledige rescoring van inhoudelijk ongewijzigde vacatures terwijl ScoreRun-historiek behouden blijft.
- `last_changed` wijzigt alleen nog bij een inhoudelijke vacaturewijziging; een nieuwe waarneming actualiseert uitsluitend `last_seen`.
- Een fail-closed PostgreSQL-runner levert één warm-up, drie metingen, p50/p95, throughput, geheugen en queryfamilies als machineleesbaar rapport.
### Performance
- De officiële 250-itemmeting is gemiddeld 44,74% sneller, gebruikt minstens 63,73% minder queries en verwerkt 83,42% meer documenten per seconde dan 0.3.13.
## Niet uitgebracht
### Toegevoegd
- `PUBLIC_BASE_URL` als één productiebron voor hostvalidatie, CSRF-origin, secure cookies en HTTPS-redirect.
- Een idempotente `configure_public_url.py`-helper voor bestaande `.env`-bestanden achter Nginx Proxy Manager.
- Een concrete Nginx Proxy Manager-runbook, productie-envvoorbeeld, publieke `robots.txt` en cache-readinesscheck.
- Server-side alleen-lezenbescherming en begrensde sessies voor de gedeelde gastdemo.
### Gewijzigd
- De repository-README volgt nu de gebruikersreis van doel en dagelijkse workflow naar quickstart, configuratie, veiligheidsgrenzen, beheer en pas daarna architectuur en agentoverdracht.
- Negen actuele, compacte productbeelden vertellen nu de volledige gebruikersreis: dagoverzicht, vacatureverkenner, beslisanalyse, Skillsradar, werkgevers, sollicitaties, bronnen, automatisering en mobiel zoekprofiel.
- De README-galerij gebruikt taakgerichte captions, klikbare beelden, een consistente 1920×1080-desktopviewport en een gebalanceerde dark/light-compositie.
- Het VacatureRadar-productlogo is hertekend als transparante vier-elementenradar zonder donkere tegel, rand of schaduw; login en sidebar gebruiken hetzelfde cleaner beeldmerk.
- De sidebarfooter combineert radarstatus en een transparante, themabewuste ITWorx-credit; de dubbele demo-/accountidentiteit is verwijderd en blijft uitsluitend via het topbarmenu bereikbaar.
- Widescreencontent schaalt nu gecontroleerd naar 2.200 px en op 4K naar 2.480 px, terwijl dashboardverhoudingen, korte viewports en mobiele layouts begrensd blijven.
- De volledige visuele audit is vertaald naar één gedeeld contentframe voor topbar en pagina, een dashboard met maximaal vijf kernmatches en een compactere activiteitstijdlijn.
- Secundaire actielinks hebben grotere klikdoelen, reduced motion dekt alle animaties en geïmporteerde stylesheets delen voortaan expliciet de releasecacheversie.
- De ultrawide cockpit gebruikt één begrensd, links verankerd grid; headers, filters, pipeline, KPI's en detailpanelen delen daardoor op brede schermen hetzelfde startpunt zonder uitgerekte leesregels.
- VacatureRadar heeft een zelfstandig open-radar-beeldmerk met scanstraal en kanssignaal voor shell, login, favicon en Dockerman; de officiële ITWorx.tech-wordmark staat uitsluitend nog als subtiele `Ontwikkeld door`-credit.
- VacatureRadar gebruikt nu één scherper radarbeeldmerk voor favicon, login en applicatieshell; de persoonlijke eigenaar is via `VACATURERADAR_OWNER_NAME` configureerbaar.
- De bovenbalk geeft naast zoeken ook eigenaar en actuele werkcontext aan; navigatie-, focus- en hoverstates zijn verfijnd zonder de compacte mobiele cockpit te verzwaren.
- Het zoekprofiel bevat een lokale interactieve radarillustratie met pointerparallax, een rustige scansweep, light/dark-kleuren en een statische reduced-motionvariant; de illustratie is decoratief en introduceert geen fictieve data.
- De Unraid-heruitrol gebruikt een gevalideerde SSH-hostalias met afzonderlijke deploysleutel en voert synchronisatie, build, healthwait en readiness-smoke als één reproduceerbaar commando uit.
- De cockpit heeft een premium “calm intelligence”-laag met zachtere oppervlaktescheidingen, selectief cyan, humane navigatietypografie en een duidelijkere componenthiërarchie in donker en licht thema.
- Mobiele kerncijfers staan compact in een 2×2-raster; vacaturefilters vormen een responsieve commandobar en de kanbanpipeline scrolt per kolom zonder documentoverflow.
- Dashboardkaarten, vacature-intelligence en het zoekprofiel gebruiken rustigere composities met minder decoratieve randen en beter gedoseerde elevatie.
### Hersteld
- De productieconfiguratiehelper voldoet opnieuw aan Ruff en de `.env`-bestandmodustest houdt rekening met Windows, waar POSIX-modusbits niet afdwingbaar zijn.
- Publieke Nginx-hostnamen veroorzaken niet langer `DisallowedHost` wanneer `PUBLIC_BASE_URL` correct staat.
- HTTPS-deployment gebruikt de externe poort in plaats van de interne applicatiepoort voor CSRF en publieke URLs.
- Interne healthchecks blijven bereikbaar bij `SECURE_SSL_REDIRECT=1`; productie-rate-limiting gebruikt een gedeelde Redis-cache.
- Forwarded client-IPheaders worden alleen vertrouwd vanaf expliciet ingestelde proxy-CIDRs.
- Productiechecks kunnen de URL-configuratie laden voordat een staticfilesmanifest aanwezig is.
- De optionele Playwrightfallback voert geen synchrone databasecall meer uit binnen een async context.
- Op mobiel overlappen `Wachtwoord` en `Wachtwoord vergeten?` niet meer; loginiconen zijn zelfstandige lijn-SVG's en de dubbele herstel-link is verwijderd.
- De Unraid-synchelper negeert verwijderde indexpaden bij een release vanuit een geldige dirty worktree; vervangen merkassets blokkeren de archiefopbouw daardoor niet meer.
- Rechtstreekse SSH naar het Unraid-IP koos lokaal de verkeerde gebruiker en identiteit; het gedocumenteerde aliasprofiel selecteert nu reproduceerbaar `root` met de afzonderlijke deploysleutel.
- Inloginteracties laden nu als lokale externe JavaScriptasset; de eigen Content Security Policy blokkeert wachtwoordweergave en submitfeedback niet langer.
- Het Unraid/Dockerman-containericoon gebruikt nu een lokale 256×256 PNG via de gepubliceerde webpoort in plaats van de fragiele SVG-favicon-URL.
- De icoon-URL gebruikt een concrete server-side loopback-URL; Dockermans niet-ondersteunde `[IP]`/`[PORT]`-placeholders verhinderden anders het vullen van de icooncache.
- De deploymentregressietest controleert de volledige WebUI-/icoonlabels en valideert dat de verpakte icoonasset werkelijk een PNG van 256×256 pixels is.
## 0.3.0 — 2026-07-22
### Gewijzigd
- De voormalige MVP-frontend is volledig vervangen door één high-fidelity, server-rendered Stitch Intelligence Cockpit met nieuwe lokale tokens, shell, SVG-iconen, responsive lagen en progressive enhancement.
- Alle negen Stitch-schermen zijn functioneel gekoppeld: dashboard, vacatureverkenner/detail, sollicitatiepipeline, zoekprofiel, brongezondheid, automatisering, activiteit en werkgeversintelligence.
- Login, skillsradar, dossieredit, foutpagina's, empty states en messages gebruiken dezelfde componenttaal; de normale productiefrontend heeft geen legacyflag of parallelle shell.
- Nieuwe read services leveren echte dashboard-, vacature-, activiteit-, werkgever- en automationdata; vacaturefilters tonen standaard alleen aantoonbaar IT-relevante kansen.
- Sollicitatiestatus is een gebruikersgescopeerde CSRF-POST met audittrail. Niet-bestaande runtime- of marktdata blijft expliciet onbekend in plaats van gesimuleerd.
- Bronhealth gebruikt één geslicete prefetch voor alle bronnen; het automatiseringsscherm daalde in de representatieve dataset van 63 naar 23 queries.
### Verificatie
- De geïntegreerde Codex-browser controleerde alle negen schermen op desktop en 390 px, plus kernflows op 360, 768, 1024, 1280, 1440, 1920 en 2560 px zonder documentoverflow, kapotte assets of consolewaarschuwingen.
- Elf screenshots staan onder `artifacts/visual-validation/`; dark/light, mobiele drawer en toegankelijke actielabels zijn functioneel getest.
- Gerichte regressies, Ruff, Django system check, migratiecheck en de queryregressietest zijn groen; de volledige gate-uitkomst staat in `docs/ai/PROJECT_STATE.md`.
## 0.2.13 — 2026-07-22
### Gewijzigd
- Ervaringsjaren en senioriteitslabels zijn volledig uit de deterministische én AI-score gehaald; de profielwaarde blijft uitsluitend informatieve context en bestaande senioriteitsgewichten worden via migratie verwijderd.
- De nieuwe persoonlijke Skillsradar analyseert alleen actieve, IT-relevante, niet-uitgesloten vacatures van de huidige profielversie en negeert vacatures waarvan alle bronaliassen uitgeschakeld zijn.
- Per technologie of capability toont de radar vacaturevolume, profieldekking, skillveldbewijs versus tekstinferentie, onderliggende vacatures en een niet-blokkerende mogelijke leer-/bijscholingsstap.
- Categorie- en dekkingsfilters maken de marktvraag doorzoekbaar; de profielcheckboxes zijn uitgebreid met actuele Microsoft-, netwerk/security-, observability-, platform-, ITSM- en adoptieskills.
- De Stitch-cockpit gebruikt een aparte vraag-/leerkansencompositie met adaptieve kaarten, context-rail en tweekoloms skillfeed op ultrawide.
### Verificatie
- 240 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,73% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsercontrole bevestigde filtering en bronselectie plus layouts op 2560×1440, 1440×900 en 390×844 zonder horizontale paginaoverflow of consolewaarschuwingen.
## 0.2.12 — 2026-07-22
### Gewijzigd
- De zoekprofieleditor biedt gegroepeerde selecties voor field service, infrastructuur, Modern Workplace, netwerk/security, back-up/monitoring, virtualisatie, VoIP en scripting.
- Relevante IT-ervaring is configureerbaar als ervaringsband en wordt alleen met expliciet gevraagde vacaturejaren vergeleken.
- Een breed cv-profiel verwatert de skillscore niet onbeperkt: vier aangetroffen skills volstaan voor de volledige skillcomponent en niet-vermelde voorkeurskills zijn geen bewezen tekort.
- Ambigue field-/implementatietitels vereisen naast de titel een sterk IT-contextsignaal in de vacaturetekst; korte en meervoudige uitsluittermen gebruiken woordgrenzen.
- De mobiele categoriekaarten stapelen op één kolom en behouden de Sticky-opslagactie zonder horizontale paginaoverflow.
### Verificatie
- 232 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,59% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsercontrole bevestigde de gegroepeerde editor op ultrawide en 390 px, de ervaringsband, lokale profielselecties en afwezigheid van horizontale paginaoverflow.
- Persoonlijke cv-inhoud is alleen gebruikt om de lokale gebruikersprofielen af te stellen en is niet in code, fixtures, tests of documentatie opgenomen.
## 0.2.11 — 2026-07-22
### Gewijzigd
- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
### Verificatie
- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
## 0.2.10 — 2026-07-22
### Gewijzigd
@@ -11,12 +154,14 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl
- Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren.
- De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans.
- HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass.
- Celery gebruikt expliciet queue `default`, zodat bronruns en andere standaardtaken daadwerkelijk door de productie-worker worden geconsumeerd.
- De fetcher controleert het werkelijk verbonden peer-IP en accepteert daardoor veilige publieke CDN-rotatie zonder de SSRF-blokkade voor private adressen te versoepelen.
### Verificatie
- De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan.
- GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord.
- 215 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,42% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- 218 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,46% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
- De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
## 0.2.9 — 2026-07-22
+5
View File
@@ -0,0 +1,5 @@
# Huidige toestand
VacatureRadar 0.3.14 is een single-user-first, server-rendered vacature-intelligencecockpit. De actuele uitvoeringsstatus, laatste gate en externe restpunten staan in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md). Productgebruik en installatie beginnen in [README.md](README.md).
Release 0.3.14 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
+41
View File
@@ -0,0 +1,41 @@
# 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 \
UV_LINK_MODE=copy \
UV_DEFAULT_INDEX=https://pypi.org/simple \
PATH="/app/.venv/bin:$PATH"
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates libpq5 postgresql redis-server supervisor \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.10.0 /uv /uvx /bin/
WORKDIR /app
RUN addgroup --system app \
&& adduser --system --ingroup app --home /app --no-create-home app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project
COPY . .
RUN uv sync --frozen --no-dev \
&& chmod +x scripts/*.sh \
&& DJANGO_DEBUG=0 \
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
PUBLIC_BASE_URL=https://build.invalid \
python manage.py collectstatic --noinput \
&& mkdir -p /app/media /app/logs /app/local /var/log/supervisor \
&& chown -R app:app /app
EXPOSE 8080
ENTRYPOINT ["/app/scripts/unraid_aio_entrypoint.sh"]
+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
+196 -131
View File
@@ -1,195 +1,260 @@
# VacatureRadar
<p align="center">
<img src="static/img/vacatureradar-logo.svg" width="88" alt="VacatureRadar-logo">
</p>
VacatureRadar is een persoonlijke, vrijwel autonome vacature-assistent voor toegestane publieke werkgeversbronnen, publieke ATS-pagina's, RSS/Atom en vacaturemails. De applicatie verzamelt vacatures, normaliseert en dedupliceert ze, past harde filters toe, berekent een uitlegbare matchscore en bundelt de beste resultaten in een dagelijkse digest.
<h1 align="center">VacatureRadar</h1>
De repository bevat zowel een **werkende MVP-kern** als de volledige product-, architectuur-, UX-, beveiligings-, operationele en agentische overdrachtsdocumentatie om het project verder autonoom af te bouwen.
<p align="center"><strong>Van vacature-overload naar een kleine, uitlegbare selectie die bij jou past.</strong></p>
## Huidige status
VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-paginas, feeds en vacaturemails, verwijdert dubbels en irrelevante resultaten en rangschikt de overblijvende kansen tegen jouw zoekprofiel. Jij behoudt altijd de controle: VacatureRadar helpt zoeken en opvolgen, maar solliciteert nooit automatisch.
Reeds geïmplementeerd:
**Actuele release: 0.3.17.** De volledige P0/P1-releasebasis en de publieke
demo-hardening zijn afgerond: 328 tests zijn groen, Microsoft 365 verwerkt platformmails via
app-only OAuth/XOAUTH2. Release 0.3.17 herstelt bovendien de reproduceerbare
private-registrypublicatie zonder afhankelijkheid van de GitHub Releases-API.
Het controleerbare acceptatiebewijs staat in
[Finale acceptance](docs/audit/FINAL_ACCEPTANCE.md).
- Django 5.2-modulaire monoliet met Nederlandstalige responsive dark/light 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 en sollicitatiedossiers;
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
- Docker Compose voor lokaal gebruik en een Unraid-variant;
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
![Dagoverzicht van VacatureRadar in licht thema](docs/screenshots/readme/dashboard-light.png)
Nog niet als productievoltooid beschouwd:
## Voor wie is dit?
- live broninventaris en bronvoorwaarden per concrete werkgever;
- een gekozen actuele geodataset met licentie;
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry.
VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installatie die:
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.
- 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.
Release-productiepad:
## Zo gebruik je VacatureRadar
- `./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. **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.
## Snelste start voor Codex
## Wat zit erin?
Open de uitgepakte repository als project en geef Codex deze opdracht:
| 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 afzonderlijke mailboxkoppelingen voor negen jobplatformen |
| Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend |
```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.
```
## Bekijk de volledige productflow
De repository-instructies staan in [`AGENTS.md`](AGENTS.md). De handmatige startpagina staat in [`CODEX_START_HERE.md`](CODEX_START_HERE.md).
De beelden komen uit een gesaneerde lokale demo-omgeving. De getoonde aantallen
zijn een momentopname van die demo en bewijzen geen productie-status; daarvoor
geldt uitsluitend de gedateerde [finale acceptance](docs/audit/FINAL_ACCEPTANCE.md).
Klik op een beeld voor de volledige resolutie.
## Lokale quickstart
### 1. Vind de relevante kansen
Vereisten: Python 3.12 of 3.13 en `uv`.
De verkenner brengt zoeken, filters, gerangschikte resultaten en de belangrijkste matchcontext samen. Zo hoef je niet tussen lijst- en detailpaginas te blijven wisselen.
<p align="center">
<a href="docs/screenshots/readme/matches-dark.png"><img src="docs/screenshots/readme/matches-dark.png" alt="Vacatureverkenner met gerangschikte matches en directe intelligence"></a>
</p>
### 2. Begrijp waarom een vacature past
Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broninformatie afzonderlijk zichtbaar. Ervaring en senioriteitslabels worden als context getoond, niet als verborgen scorefactor.
<p align="center">
<a href="docs/screenshots/readme/job-analysis-dark.png"><img src="docs/screenshots/readme/job-analysis-dark.png" alt="Uitlegbare vacatureanalyse met scorecomponenten en besliscontext"></a>
</p>
### 3. Bouw markt- en werkgeversinzicht op
<table>
<tr>
<td width="50%"><strong>Skillsradar</strong><br><sub>Vergelijk aantoonbare marktvraag met je profiel, zonder ontbrekende skills als afwijzingsregel te behandelen.</sub></td>
<td width="50%"><strong>Werkgeversradar</strong><br><sub>Bekijk werkgevers, actieve IT-vacatures, bronnen en eerdere dossiers in één genormaliseerd overzicht.</sub></td>
</tr>
<tr>
<td><a href="docs/screenshots/readme/skills-light.png"><img src="docs/screenshots/readme/skills-light.png" alt="Skillsradar met profieldekking en actuele marktvraag"></a></td>
<td><a href="docs/screenshots/readme/employers-light.png"><img src="docs/screenshots/readme/employers-light.png" alt="Werkgeversradar met vacature- en broninformatie"></a></td>
</tr>
</table>
### 4. Volg trajecten op en houd bronnen gezond
<table>
<tr>
<td width="50%"><strong>Sollicitatiepipeline</strong><br><sub>Bewaar status, deadlines, notities en tijdlijn per dossier; de uiteindelijke sollicitatie blijft altijd handmatig.</sub></td>
<td width="50%"><strong>Bronnenbeheer</strong><br><sub>Zie policyreview, laatste scans, volgende runs, brongezondheid en de status van iedere platformmailbox 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. Koppel optioneel afzonderlijke vacaturemailboxen voor VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad en Robert Half. 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:
Voor Microsoft 365 gebruikt VacatureRadar uitsluitend app-only OAuth/XOAUTH2
voor IMAP en SMTP; Basic Auth en Microsoft 365-app-wachtwoorden worden niet
ondersteund. Beperk de Entra-serviceprincipal tot de bedoelde mailbox en geef
alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`. De exacte configuratie- en
rotatiestappen staan in het [operationele runbook](docs/operations/RUNBOOK.md).
- checkout van de repo op de runner
- `bash scripts/deploy_docker.sh` op de server
- automatische healthcheck + `collectstatic`
## Eerlijke grenzen
De workflow verwacht geen host/SSH-repo secrets uit het Actions-configuratiescherm.
VacatureRadar:
Zie [Gitea push-to-deploy](docs/operations/GITEA_DEPLOYMENT.md) voor de exacte serverconfiguratie.
- 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.
## Zelf een schone overdrachts-ZIP maken
Lees vóór productiegebruik [SECURITY.md](SECURITY.md) en het [threat model](docs/quality/THREAT_MODEL.md).
## 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
Productiereleases worden door Gitea Actions gebouwd, gescand en als private
containerimage gepubliceerd. Pin Unraid op `versie@sha256:digest`, bewaar de
vorige digest voor rollback en gebruik nooit alleen een veranderlijke tag als
productiereferentie.
- [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.
+8 -3
View File
@@ -30,11 +30,16 @@ 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
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documenteer intern:
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Meld een
kwetsbaarheid privé via `security@itworx.tech` en vermeld:
1. component en versie/commit;
2. reproduceerbare stappen met synthetische data;
@@ -42,7 +47,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`.
+45
View File
@@ -0,0 +1,45 @@
"""Authenticatie-backends voor VacatureRadar.
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
gebruikersnaam te breken.
"""
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q
class EmailOrUsernameModelBackend(ModelBackend):
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
def authenticate(self, request, username=None, password=None, **kwargs):
user_model = get_user_model()
if username is None:
username = kwargs.get(user_model.USERNAME_FIELD)
if username is None or password is None:
return None
try:
user = user_model.objects.get(
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username}) | Q(email__iexact=username)
)
except user_model.DoesNotExist:
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
user_model().set_password(password)
return None
except user_model.MultipleObjectsReturned:
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
user = (
user_model.objects.filter(**{f"{user_model.USERNAME_FIELD}__iexact": username})
.order_by("id")
.first()
)
if user is None:
return None
if user.check_password(password) and self.user_can_authenticate(user):
return user
return None
+25 -1
View File
@@ -2,15 +2,35 @@ from __future__ import annotations
from typing import Any
from 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.2.2",
"app_version": settings.VACATURERADAR_VERSION,
"app_owner_name": settings.VACATURERADAR_OWNER_NAME,
"page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"),
}
if request.user.is_authenticated:
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
+102
View File
@@ -0,0 +1,102 @@
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
bestaande tests en de kernapplicatie volledig werken.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
class EntraConfigError(RuntimeError):
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
@dataclass(frozen=True)
class EntraConfig:
client_id: str
client_secret: str
tenant_id: str
scopes: list[str]
allowed_domains: tuple[str, ...]
redirect_uri: str | None
@property
def authority(self) -> str:
return f"https://login.microsoftonline.com/{self.tenant_id}"
def entra_enabled() -> bool:
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
def load_config() -> EntraConfig:
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
if not (client_id and client_secret and tenant_id):
raise EntraConfigError(
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
)
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
allowed = tuple(
d.lower().lstrip("@") for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", []) if d
)
return EntraConfig(
client_id=client_id,
client_secret=client_secret,
tenant_id=tenant_id,
scopes=scopes,
allowed_domains=allowed,
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
)
def _build_app(config: EntraConfig):
try:
import msal # lazy import: alleen nodig als SSO aan staat
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
raise EntraConfigError(
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
"om Entra ID SSO te gebruiken."
) from exc
return msal.ConfidentialClientApplication(
client_id=config.client_id,
client_credential=config.client_secret,
authority=config.authority,
)
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
app = _build_app(config)
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
"""Wissel de teruggekeerde code in voor tokens en claims."""
app = _build_app(config)
return app.acquire_token_by_auth_code_flow(flow, auth_response)
def extract_identity(token_result: dict) -> tuple[str, str]:
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
claims = token_result.get("id_token_claims") or {}
email = (
claims.get("preferred_username") or claims.get("email") or claims.get("upn") or ""
).strip()
name = (claims.get("name") or "").strip()
return email, name
def domain_allowed(config: EntraConfig, email: str) -> bool:
if not config.allowed_domains:
return True
domain = email.rsplit("@", 1)[-1].lower()
return domain in config.allowed_domains
+33
View File
@@ -0,0 +1,33 @@
"""Formulieren voor authenticatie."""
from __future__ import annotations
from django.contrib.auth.forms import AuthenticationForm
from django.utils.translation import gettext_lazy as _
class EmailAuthenticationForm(AuthenticationForm):
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
"""
error_messages = {
**AuthenticationForm.error_messages,
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
field = self.fields["username"]
field.label = _("E-mailadres")
field.widget.attrs.update(
{
"type": "email",
"autocomplete": "username",
"autocapitalize": "none",
"spellcheck": "false",
"placeholder": "naam@organisatie.nl",
}
)
+18 -2
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
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)
+79 -2
View File
@@ -2,11 +2,86 @@ 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
from scripts.migration_worker_gate import hold_active
class MigrationMaintenanceMiddleware:
"""Block candidate traffic before durable migration activation, including GET writes."""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
try:
held = hold_active()
except (OSError, RuntimeError):
held = True
health = request.method in {"GET", "HEAD"} and request.path_info in {
"/health/ready/", "/health/live/",
}
if held and not health:
response = JsonResponse({"error": "migration_maintenance"}, status=503)
response["Retry-After"] = "30"
response["Cache-Control"] = "no-store"
return response
return self.get_response(request)
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 +108,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)
+1
View File
@@ -0,0 +1 @@
"""Read services for the application cockpit."""
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.core.cache import cache
from django.db.models import Count, Q, Sum
from django.utils import timezone
from apps.jobs.models import JobPosting, ScoreRun
from apps.jobs.services.relevance import it_relevance_query
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..health import readiness
from ..tasks import RUNTIME_HEARTBEAT_KEY
def _runtime_heartbeat_state() -> dict[str, Any]:
if settings.CELERY_TASK_ALWAYS_EAGER:
return {"label": "Eager/lokaal", "healthy": True, "observed_at": None}
raw_timestamp = cache.get(RUNTIME_HEARTBEAT_KEY)
if not raw_timestamp:
return {"label": "Geen recente heartbeat", "healthy": False, "observed_at": None}
try:
observed_at = timezone.datetime.fromisoformat(str(raw_timestamp))
if timezone.is_naive(observed_at):
observed_at = timezone.make_aware(observed_at)
except (TypeError, ValueError):
return {"label": "Ongeldige heartbeat", "healthy": False, "observed_at": None}
age_seconds = max(0, int((timezone.now() - observed_at).total_seconds()))
return {
"label": f"Actief · {age_seconds}s geleden"
if age_seconds <= 150
else "Heartbeat verouderd",
"healthy": age_seconds <= 150,
"observed_at": observed_at,
}
def build_automation_cockpit() -> dict[str, Any]:
"""Build an honest operational read model without probing unsafe hidden endpoints."""
since = timezone.now() - timedelta(hours=24)
recent_runs = SourceRun.objects.filter(started_at__gte=since)
run_metrics = recent_runs.aggregate(
discovered=Sum("discovered_count"),
extracted=Sum("extracted_count"),
created=Sum("created_count"),
updated=Sum("updated_count"),
duplicates=Sum("duplicate_count"),
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
)
notification_counts = {
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
"sent_24h": DigestOutbox.objects.filter(
status=DigestOutbox.Status.SENT, sent_at__gte=since
).count()
+ ReminderOutbox.objects.filter(
status=ReminderOutbox.Status.SENT, sent_at__gte=since
).count(),
}
source_total = Source.objects.count()
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
phase_rows = [
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
{
"key": "extract",
"label": "Extraheren",
"value": run_metrics["extracted"] or 0,
"unit": "items",
},
{
"key": "normalize",
"label": "Normaliseren",
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
"unit": "records",
},
{
"key": "dedupe",
"label": "Dedupliceren",
"value": run_metrics["duplicates"] or 0,
"unit": "herkend",
},
{
"key": "filter",
"label": "IT-filter",
"value": active_jobs.exclude(it_relevance_query()).count(),
"unit": "uit beeld",
},
{
"key": "analyze",
"label": "Analyseren",
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
"unit": "scores / 24u",
},
{
"key": "notify",
"label": "Notificeren",
"value": notification_counts["sent_24h"],
"unit": "verzonden / 24u",
},
]
runtime_heartbeat = _runtime_heartbeat_state()
return {
"health": readiness(),
"source_total": source_total,
"source_active": source_active,
"run_metrics": run_metrics,
"run_count": recent_runs.count(),
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
"review_jobs": ScoreRun.objects.filter(
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
job__status=JobPosting.Status.ACTIVE,
).count(),
"notification_counts": notification_counts,
"phase_rows": phase_rows,
"recent_runs": list(
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
),
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
"queue_state": "Eager/lokaal"
if settings.CELERY_TASK_ALWAYS_EAGER
else "Diepte niet beschikbaar",
"runtime_heartbeat": runtime_heartbeat,
"ai_state": "Geconfigureerd"
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
else "Niet geconfigureerd",
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
"browser_state": "Geen aparte browserextractor geconfigureerd",
}
+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
+181 -46
View File
@@ -1,24 +1,55 @@
from __future__ import annotations
import logging
from django.conf import settings
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.db.models import Count, Q
from django.http import JsonResponse
from django.utils import timezone
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
from django.views.decorators.http import require_POST
from django.views.generic import TemplateView
from apps.core import entra as entra_sso
from apps.core.forms import EmailAuthenticationForm
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
from apps.jobs.models import Application, JobPosting, ScoreRun
from apps.sources.models import Source
from apps.jobs.services.cockpit import build_dashboard_cockpit
from apps.profiles.models import SearchProfile
from apps.sources.services.health import collect_source_health
from .health import readiness
from .services.automation import build_automation_cockpit
logger = logging.getLogger(__name__)
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
SESSION_ENTRA_FLOW = "entra_auth_flow"
SESSION_ENTRA_NEXT = "entra_next"
def _safe_next(request, candidate: str | None) -> str | None:
if candidate and url_has_allowed_host_and_scheme(
candidate,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return candidate
return None
class SecurityAwareLoginView(LoginView):
template_name = "registration/login.html"
authentication_form = EmailAuthenticationForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["entra_enabled"] = entra_sso.entra_enabled()
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
return context
@staticmethod
def _remaining_minutes(seconds: int) -> str:
@@ -69,6 +100,147 @@ class SecurityAwareLoginView(LoginView):
return super().form_valid(form)
def _split_name(full_name: str) -> tuple[str, str]:
parts = full_name.split()
if not parts:
return "", ""
if len(parts) == 1:
return parts[0][:150], ""
return parts[0][:150], " ".join(parts[1:])[:150]
def _unique_username(base: str) -> str:
user_model = get_user_model()
base = (base or "gebruiker")[:140].strip() or "gebruiker"
candidate = base
suffix = 1
while user_model.objects.filter(username__iexact=candidate).exists():
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def _get_or_create_sso_user(email: str, full_name: str):
user_model = get_user_model()
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
first, last = _split_name(full_name)
if user is None:
user = user_model(
username=_unique_username(email.split("@", 1)[0]),
email=email,
first_name=first,
last_name=last,
)
user.set_unusable_password()
user.save()
elif full_name and not (user.first_name or user.last_name):
user.first_name, user.last_name = first, last
user.save(update_fields=["first_name", "last_name"])
return user
@require_POST
def entra_login(request):
"""Start de Entra ID authorization-code flow."""
if not entra_sso.entra_enabled():
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
return redirect("login")
try:
config = entra_sso.load_config()
redirect_uri = config.redirect_uri or request.build_absolute_uri(reverse("entra-callback"))
flow = entra_sso.build_auth_flow(config, redirect_uri)
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
request.session[SESSION_ENTRA_FLOW] = flow
next_url = _safe_next(request, request.POST.get("next"))
if next_url:
request.session[SESSION_ENTRA_NEXT] = next_url
return redirect(flow["auth_uri"])
def entra_callback(request):
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
if not entra_sso.entra_enabled():
return redirect("login")
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
if not flow:
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
return redirect("login")
try:
config = entra_sso.load_config()
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
except entra_sso.EntraConfigError as exc:
logger.warning("Entra ID niet beschikbaar: %s", exc)
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
return redirect("login")
if "error" in result:
logger.warning("Entra ID-fout: %s", result.get("error_description") or result.get("error"))
messages.error(request, "Inloggen via Entra ID is mislukt.")
return redirect("login")
email, name = entra_sso.extract_identity(result)
if not email:
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
return redirect("login")
if not entra_sso.domain_allowed(config, email):
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
return redirect("login")
user = _get_or_create_sso_user(email, name)
login(request, user, backend=SSO_BACKEND)
clear_rate_limit(request, namespace="login")
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
@require_POST
def demo_login(request):
"""Log in als afgeschermd demo-account."""
if not getattr(settings, "DEMO_MODE_ENABLED", False):
messages.error(request, "De demomodus is niet ingeschakeld.")
return redirect("login")
user_model = get_user_model()
user, created = user_model.objects.get_or_create(
username=settings.DEMO_USERNAME,
defaults={
"email": settings.DEMO_EMAIL,
"first_name": "Demo",
"last_name": "Gebruiker",
},
)
if created:
user.set_unusable_password()
user.save()
try:
from apps.jobs.services.demo_seed import seed_demo_environment
seed_demo_environment(user)
except Exception:
# De demo mag nooit de login blokkeren.
logger.exception("Kon de demo-omgeving niet seeden")
login(request, user, backend=SSO_BACKEND)
request.session.set_expiry(settings.DEMO_SESSION_SECONDS)
next_url = _safe_next(request, request.POST.get("next"))
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
def robots_txt(request):
if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
content = "User-agent: *\nAllow: /\n"
else:
content = "User-agent: *\nDisallow: /\n"
return HttpResponse(content, content_type="text/plain; charset=utf-8")
def health_live(request):
return JsonResponse({"ok": True, "service": "vacatureradar"})
@@ -83,44 +255,9 @@ class TodayView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer", "profile")
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
.order_by("-score", "-created_at")
)
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
seen: set[str] = set()
cards = []
for score in latest_scores[:250]:
key = str(score.job_id)
if key in seen:
continue
seen.add(key)
cards.append(score)
if len(cards) >= 20:
break
context.update(
{
"score_cards": cards,
"priority_applications": Application.objects.filter(user=self.request.user)
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
.select_related("job", "job__employer")
.order_by("follow_up_date", "-updated_at")[:3],
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
"new_today": JobPosting.objects.filter(
first_seen__date=timezone.localdate()
).count(),
"applications_open": Application.objects.exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
).count(),
"source_counts": Source.objects.exclude(
domain="jobs.example.org", status=Source.Status.DISABLED
).aggregate(
total=Count("id"),
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
),
}
)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context.update(build_dashboard_cockpit(self.request.user, profile))
context["active_profile"] = profile
return context
@@ -129,8 +266,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["health"] = readiness()
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
context.update(build_automation_cockpit())
context["source_health"] = collect_source_health()
return context
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from django.core.paginator import Page, Paginator
from django.urls import reverse
from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import ProfileRevision
from apps.sources.models import Source, SourceRun
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
@dataclass(frozen=True)
class ActivityEvent:
occurred_at: datetime
event_type: str
title: str
detail: str
tone: str
url: str
object_label: str
ACTIVITY_FILTERS = (
("all", "Alle activiteit"),
("source", "Bronruns"),
("job", "Vacatures"),
("application", "Sollicitaties"),
("feedback", "Feedback"),
("notification", "Notificaties"),
("profile", "Profiel"),
)
def _source_events() -> list[ActivityEvent]:
events = []
runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
for run in runs.select_related("source").order_by("-started_at")[:100]:
detail = (
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
f"{run.duplicate_count} duplicaat"
)
if run.error_category:
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
events.append(
ActivityEvent(
run.started_at,
"source",
f"Bronrun {run.get_status_display().lower()}",
detail,
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
reverse("sources:list") + f"#source-details-{run.source_id}",
run.source.name,
)
)
return events
def _job_events() -> list[ActivityEvent]:
return [
ActivityEvent(
version.created_at,
"job",
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
", ".join(version.changed_fields)
if version.changed_fields
else "Bronmoment vastgelegd",
"neutral",
reverse("jobs:detail", kwargs={"pk": version.job_id}),
version.job.original_title,
)
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
]
def build_activity_page(
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
) -> Page:
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
events: list[ActivityEvent] = []
if event_type in {"all", "source"}:
events.extend(_source_events())
if event_type in {"all", "job"}:
events.extend(_job_events())
if event_type in {"all", "feedback"}:
for item in (
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
):
events.append(
ActivityEvent(
item.created_at,
"feedback",
item.get_action_display(),
item.reason or "Gebruikersactie opgeslagen",
"neutral",
reverse("jobs:detail", kwargs={"pk": item.job_id}),
item.job.original_title,
)
)
if event_type in {"all", "application"}:
for item in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:100]
):
events.append(
ActivityEvent(
item.created_at,
"application",
item.get_event_type_display(),
"Sollicitatiedossier bijgewerkt",
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
item.application.job.original_title,
)
)
if event_type in {"all", "notification"}:
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
events.append(
ActivityEvent(
item.created_at,
"notification",
f"{label} {item.get_status_display().lower()}",
item.subject,
"positive" if item.status == item.Status.SENT else "attention",
reverse("dashboard:system"),
label,
)
)
if event_type in {"all", "profile"}:
for item in (
ProfileRevision.objects.filter(profile__user=user)
.select_related("profile")
.order_by("-created_at")[:60]
):
events.append(
ActivityEvent(
item.created_at,
"profile",
f"Zoekprofiel versie {item.version}",
item.reason or "Profielconfiguratie opgeslagen",
"neutral",
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
item.profile.name,
)
)
normalized_query = query.strip().casefold()
if normalized_query:
events = [
item
for item in events
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
]
events.sort(key=lambda item: item.occurred_at, reverse=True)
return Paginator(events, per_page).get_page(page)
+25
View File
@@ -274,6 +274,31 @@ def track_application_changes(
return events
def change_application_status(*, application: Application, user, status: str) -> Application:
"""Persist an accessible pipeline status change and its audit event."""
valid_statuses = {value for value, _label in Application.Status.choices}
if status not in valid_statuses:
raise ValueError("Ongeldige sollicitatiestatus.")
previous = application.status
if previous == status:
return application
with transaction.atomic():
application.status = status
update_fields = ["status", "updated_at"]
if status == Application.Status.APPLIED and application.applied_at is None:
application.applied_at = timezone.now()
update_fields.append("applied_at")
application.save(update_fields=update_fields)
_record_timeline_event(
application=application,
user=user,
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
metadata={"from": previous, "to": status},
)
return application
def build_print_html(application: Application) -> str:
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
job = application.job
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
from typing import Any
from django.contrib.auth import get_user_model
from django.db.models import (
CharField,
Count,
DateTimeField,
DecimalField,
F,
JSONField,
OuterRef,
Q,
QuerySet,
Subquery,
)
from django.utils import timezone
from apps.profiles.models import SearchProfile
from apps.sources.models import Source, SourceRun
from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun
from .relevance import it_relevance_query
User = get_user_model()
def annotate_latest_profile_score(
queryset: QuerySet[JobPosting], profile: SearchProfile | None
) -> QuerySet[JobPosting]:
"""Annotate jobs with one deterministic latest score for the active profile."""
if profile is None:
return queryset
latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at")
return queryset.annotate(
match_score=Subquery(
latest.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest.values("recommendation")[:1], output_field=CharField()
),
match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()),
match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()),
match_hard_exclusions=Subquery(
latest.values("hard_exclusions")[:1], output_field=JSONField()
),
match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()),
)
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
activity: list[dict[str, Any]] = []
source_runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
for run in source_runs.select_related("source").order_by("-started_at")[:4]:
activity.append(
{
"occurred_at": run.started_at,
"kind": "source",
"title": f"Bronrun {run.get_status_display().lower()}",
"detail": run.source.name,
"tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention",
}
)
for event in (
ApplicationTimelineEvent.objects.filter(user=user)
.select_related("application__job")
.order_by("-created_at")[:4]
):
activity.append(
{
"occurred_at": event.created_at,
"kind": "application",
"title": event.get_event_type_display(),
"detail": event.application.job.original_title,
"tone": "neutral",
}
)
return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5]
def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]:
"""Build truthful dashboard metrics and bounded read lists."""
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
active_it_jobs = active_jobs.filter(it_relevance_query())
today = timezone.localdate()
latest_scores = ScoreRun.objects.none()
if profile is not None:
latest_score_id = (
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
.order_by("-created_at")
.values("pk")[:1]
)
latest_scores = (
ScoreRun.objects.select_related("job", "job__employer")
.filter(
profile=profile,
pk=Subquery(latest_score_id),
job__status=JobPosting.Status.ACTIVE,
recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
],
hard_exclusions=[],
)
.filter(it_relevance_query("job__"))
)
sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
source_counts = sources.aggregate(
total=Count("id"),
active=Count("id", filter=Q(status=Source.Status.ACTIVE)),
attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
)
open_applications = Application.objects.filter(user=user).exclude(
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
)
new_today = active_it_jobs.filter(first_seen__date=today).count()
strong_today = latest_scores.filter(
recommendation=ScoreRun.Recommendation.STRONG,
job__first_seen__date=today,
).count()
filtered_count = active_jobs.exclude(it_relevance_query()).count()
duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count()
if strong_today:
day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht."
elif new_today:
day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)."
else:
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
return {
# Het dagoverzicht blijft een beslissingsscherm; de volledige rangschikking
# hoort in de verkenner. Vijf kaarten houden de primaire taak scanbaar.
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:5]),
"priority_applications": list(
open_applications.select_related("job", "job__employer").order_by(
F("follow_up_date").asc(nulls_last=True), "-updated_at"
)[:4]
),
"active_jobs": active_it_jobs.count(),
"filtered_non_it_jobs": filtered_count,
"duplicate_jobs": duplicate_count,
"new_today": new_today,
"strong_today": strong_today,
"applications_open": open_applications.count(),
"source_counts": source_counts,
"recent_activity": _recent_dashboard_activity(user),
"day_conclusion": day_conclusion,
"latest_scan": SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
.order_by("-started_at")
.first(),
}
def build_job_intelligence(
*, job: JobPosting, profile: SearchProfile | None, user: User
) -> dict[str, Any]:
"""Return the bounded, user-scoped read model for explorer and detail."""
score = (
ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first()
if profile
else None
)
return {
"score": score,
"application": Application.objects.filter(user=user, job=job).first(),
"duplicate_count": job.duplicates.count(),
"source_count": job.source_aliases.count(),
"version_count": job.versions.count(),
"latest_versions": list(job.versions.all()[:5]),
"provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]),
}
+313
View File
@@ -0,0 +1,313 @@
"""Vult een fictieve demo-omgeving voor het demo-account.
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
niet opnieuw geseed.
"""
from __future__ import annotations
import hashlib
from datetime import timedelta
from decimal import Decimal
from django.utils import timezone
from apps.profiles.models import SearchProfile
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
# skills_preferred, score, recommendation, positives, concerns)
_DEMO_JOBS = [
(
"Infrastructure Engineer",
"CloudNexus",
"cloudnexus.example",
"Hasselt",
"Limburg",
"hybrid",
["Microsoft 365", "VMware", "Azure"],
["Terraform"],
Decimal("92.4"),
"strong",
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
[],
),
(
"Cloud Architect",
"Skylink Solutions",
"skylink.example",
"Remote",
"Vlaanderen",
"remote",
["Azure", "Kubernetes"],
["Bicep", "Landing Zones"],
Decimal("88.1"),
"strong",
["Volledig remote", "Azure-zwaartepunt sluit aan"],
["Iets seniorder dan gevraagd"],
),
(
"Systeembeheerder Microsoft 365",
"Gemeente Genk",
"genk.example",
"Genk",
"Limburg",
"on_site",
["Microsoft 365", "Active Directory"],
["Intune"],
Decimal("83.6"),
"strong",
["Publieke sector met stabiliteit", "M365-focus"],
["Volledig op locatie"],
),
(
"DevOps Engineer",
"Datastroom BV",
"datastroom.example",
"Leuven",
"Vlaams-Brabant",
"hybrid",
["CI/CD", "Docker", "Azure"],
["GitLab", "Observability"],
Decimal("77.9"),
"possible",
["Moderne toolchain", "Hybride"],
["Reistijd naar Leuven"],
),
(
"Network Engineer",
"Noorder Telecom",
"noordertelecom.example",
"Antwerpen",
"Antwerpen",
"on_site",
["Cisco", "Netwerkbeheer"],
["Fortinet"],
Decimal("70.4"),
"possible",
["Stevige netwerkrol"],
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
),
(
"IT Support Specialist",
"MediCare Groep",
"medicare.example",
"Diepenbeek",
"Limburg",
"on_site",
["Servicedesk", "Windows"],
["ITIL"],
Decimal("62.3"),
"weak",
["Dichtbij"],
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
),
]
def _digest(*parts: str) -> str:
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
def demo_environment_seeded(user) -> bool:
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
if profile is None:
return False
from apps.jobs.models import ScoreRun
return ScoreRun.objects.filter(profile=profile).exists()
def seed_demo_environment(user) -> None:
"""Maak of herstel een fictieve, tijdsbestendige demo-omgeving voor ``user``."""
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
from apps.sources.models import MailboxConnection, Source, SourceRun
from apps.sources.platforms import PLATFORM_ALERTS
profile, _ = SearchProfile.objects.get_or_create(
user=user,
name=_DEMO_PROFILE_NAME,
defaults={
"is_active": True,
"home_municipality": "Hasselt",
"home_latitude": Decimal("50.930700"),
"home_longitude": Decimal("5.332500"),
"max_distance_km": 60,
"experience_years": 5,
"learning_enabled": True,
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
"excluded_titles": ["sales", "recruiter"],
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
"allowed_employment_types": ["full_time", "permanent"],
"preferred_workplace": ["hybrid", "remote", "on_site"],
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
},
)
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
if not profile.is_active:
profile.is_active = True
profile.save(update_fields=["is_active"])
now = timezone.now()
today = timezone.localdate(now)
workplace_map = {
"hybrid": JobPosting.Workplace.HYBRID,
"remote": JobPosting.Workplace.REMOTE,
"on_site": JobPosting.Workplace.ON_SITE,
}
created_jobs: list = []
for index, (
title,
employer_name,
domain,
municipality,
region,
workplace,
req_skills,
pref_skills,
score,
recommendation,
positives,
concerns,
) in enumerate(_DEMO_JOBS):
employer, _ = Employer.objects.get_or_create(
normalized_name=employer_name.lower(),
defaults={
"name": employer_name,
"domain": domain,
"is_direct_employer": True,
"is_recruiter": False,
"confidence": 0.92,
},
)
slug = _digest("demo-job", str(index), title, employer_name)
job, _ = JobPosting.objects.update_or_create(
canonical_key=slug,
defaults={
"employer": employer,
"original_title": title,
"normalized_title": title.lower(),
"job_family": "infrastructure",
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
"content_hash": _digest("demo-content", slug),
"description_text": (
f"{title} bij {employer_name} in {municipality}. "
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
),
"raw_location": f"{municipality}, {region}",
"region": region,
"municipality": municipality,
"workplace_type": workplace_map[workplace],
"employment_types": ["full_time", "permanent"],
"skills_required": req_skills,
"skills_preferred": pref_skills,
"direct_employer": True,
"recruiter": False,
"extraction_confidence": 0.9,
"analysis_features": {
"support_ratio": 0.1,
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
"experience_years_max": 6,
},
"status": JobPosting.Status.ACTIVE,
"first_seen": now - timedelta(days=index + 1),
"last_seen": now,
"date_posted": now - timedelta(days=index + 1),
},
)
created_jobs.append(job)
ScoreRun.objects.filter(
job=job,
profile=profile,
model_version="demo-seed",
).delete()
ScoreRun.objects.create(
job=job,
profile=profile,
profile_version=profile.version,
score=score,
confidence=Decimal("0.78"),
recommendation=recommendation,
components={
"content": float(score) * 0.45,
"skills": float(score) * 0.30,
"location": float(score) * 0.25,
},
positives=positives,
concerns=concerns,
hard_exclusions=[],
evidence={"demo": True},
model_version="demo-seed",
prompt_version="1.0.0",
created_at=now - timedelta(hours=index),
)
# Een kleine sollicitatiepijplijn voor de demo.
if created_jobs:
Application.objects.update_or_create(
user=user,
job=created_jobs[0],
defaults={
"status": Application.Status.INTERVIEW,
"applied_at": now - timedelta(days=6),
"follow_up_date": today + timedelta(days=2),
"contact_name": "Sofie Vandael",
"contact_email": "sofie.vandael@cloudnexus.example",
"notes": "Eerste gesprek gepland. Demo-data.",
},
)
if len(created_jobs) > 3:
Application.objects.update_or_create(
user=user,
job=created_jobs[3],
defaults={
"status": Application.Status.APPLIED,
"applied_at": now - timedelta(days=2),
"follow_up_date": today + timedelta(days=5),
"notes": "Sollicitatie verstuurd. Demo-data.",
},
)
# Platformmailboxen zijn zichtbare ingressconfiguratie, geen crawlbare URL-bronnen.
# De demo gebruikt uitsluitend fictieve adressen en wordt door de scheduler overgeslagen.
for platform in PLATFORM_ALERTS:
MailboxConnection.objects.update_or_create(
user=user,
platform=platform,
defaults={
"provider": MailboxConnection.Provider.OUTLOOK,
"username": "alerts@example.test",
"mailbox": f"VacatureRadar/{platform}",
"enabled": True,
"poll_interval_minutes": MailboxConnection.PollInterval.THIRTY_MINUTES,
"next_poll_at": now + timedelta(minutes=30),
"last_polled_at": now - timedelta(minutes=5),
"last_success_at": now - timedelta(minutes=5),
"last_error_category": "",
"last_error_message": "",
"lease_token": "",
"lease_expires_at": None,
},
)
source, _ = Source.objects.update_or_create(
source_type=Source.Type.EMAIL,
domain=f"{platform}.mailbox.local",
defaults={
"name": f"{PLATFORM_ALERTS[platform].label} vacaturemailbox",
"base_url": "",
"status": Source.Status.ACTIVE,
"policy": Source.Policy.ALLOW,
"policy_reason": "Mailboxingress; niet via URL-fetch gepland.",
"parser_key": "email-alert",
"failure_count": 0,
"last_failure_at": None,
"next_run_at": None,
"metadata": {"demo": True, "mailbox_ingress": True},
},
)
SourceRun.objects.filter(source=source).delete()
@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from django.db.models import Avg, Count, Max, Q, QuerySet
from apps.profiles.models import SearchProfile
from apps.sources.models import Source
from ..models import Application, Employer, JobPosting, ScoreRun
from .cockpit import annotate_latest_profile_score
from .relevance import it_relevance_query
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
return Employer.objects.annotate(
active_vacancies=Count(
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
),
relevant_vacancies=Count(
"jobs",
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
distinct=True,
),
previous_applications=Count(
"jobs__applications",
filter=Q(jobs__applications__user=user),
distinct=True,
),
source_count=Count("jobs__source_aliases__source", distinct=True),
latest_job_at=Max("jobs__first_seen"),
average_match=Avg("jobs__scores__score", filter=score_filter),
)
def filter_employers(
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
) -> QuerySet[Employer]:
if query.strip():
queryset = queryset.filter(
Q(name__icontains=query.strip())
| Q(domain__icontains=query.strip())
| Q(jobs__raw_location__icontains=query.strip())
)
if channel == "direct":
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(is_recruiter=True)
if active_only:
queryset = queryset.filter(active_vacancies__gt=0)
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
def build_employer_detail(
*, employer: Employer, user, profile: SearchProfile | None
) -> dict[str, Any]:
jobs = annotate_latest_profile_score(
employer.jobs.select_related("employer").all(), profile
).order_by("-first_seen")
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
locations = list(
employer.jobs.exclude(raw_location="")
.order_by("raw_location")
.values_list("raw_location", flat=True)
.distinct()[:8]
)
return {
"employer": employer,
"recent_jobs": list(jobs[:12]),
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
"applications": list(
Application.objects.filter(user=user, job__employer=employer)
.select_related("job")
.order_by("-updated_at")[:8]
),
"sources": list(sources[:10]),
"locations": locations,
"match_history": list(
ScoreRun.objects.filter(profile=profile, job__employer=employer)
.select_related("job")
.order_by("-created_at")[:20]
)
if profile
else [],
}
-1
View File
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
"location",
"conditions",
"employer",
"seniority",
"preferences",
}
+8
View File
@@ -49,13 +49,21 @@ EMPLOYMENT_MAP = {
"vast": "permanent",
}
TITLE_FAMILIES = {
"it field engineer": "field-support",
"field service engineer": "field-support",
"it engineer": "infrastructure",
"system engineer": "infrastructure",
"system network engineer": "infrastructure",
"system administrator": "infrastructure",
"systeembeheerder": "infrastructure",
"infrastructure engineer": "infrastructure",
"network engineer": "network",
"network administrator": "network",
"netwerkbeheerder": "network",
"workplace engineer": "workplace",
"support engineer": "support",
"it technician": "support",
"pc technician": "support",
"helpdesk": "support",
"developer": "software-development",
"data engineer": "data",
+148 -23
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(
job=job,
source_alias=alias,
field_name=evidence.field_name,
extraction_method=evidence.method,
defaults={
"confidence": _confidence(evidence.confidence),
"evidence_excerpt": evidence.evidence[:1000],
"parser_version": parser_version,
},
)
JobVersion.objects.get_or_create(
_sync_evidence(
job=job,
content_hash=job.content_hash,
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
alias=alias,
evidence_items=draft.evidence,
parser_version=parser_version,
)
for profile in SearchProfile.objects.filter(is_active=True):
score_and_save(job, profile)
if created or substantive_change:
JobVersion.objects.get_or_create(
job=job,
content_hash=job.content_hash,
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
)
if context is not None:
if context.active_profiles is None:
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
profiles = context.active_profiles
else:
profiles = SearchProfile.objects.filter(is_active=True)
for profile in profiles:
score = score_and_save(job, profile)
if context is not None:
context.latest_scores[(job.pk, profile.pk)] = score
else:
if context is not None:
if context.active_profiles is None:
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
profiles = context.active_profiles
else:
profiles = SearchProfile.objects.filter(is_active=True)
for profile in profiles:
cache_key = (job.pk, profile.pk)
previous = context.latest_scores.get(cache_key) if context is not None else None
if previous is None:
previous = (
ScoreRun.objects.filter(job=job, profile=profile)
.order_by("-created_at")
.first()
)
if previous is None or previous.profile_version != profile.version:
score = score_and_save(job, profile)
else:
score = _copy_score(previous)
if context is not None:
context.latest_scores[cache_key] = score
return job, decision, created
@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
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from django.db.models import Q
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
IT_TITLE_TERMS = (
"it",
"ict",
"informatica",
"software",
"development engineer",
"devops",
"secops",
"cloud",
"data engineer",
"data scientist",
"data steward",
"database",
"dba",
"business intelligence",
"bi analyst",
"system engineer",
"systems engineer",
"system network engineer",
"system administrator",
"systeembeheer",
"infrastructure engineer",
"infrastructuur engineer",
"network engineer",
"network administrator",
"network architect",
"netwerkbeheer",
"netwerk engineer",
"cybersecurity",
"cyber security",
"security engineer",
"security architect",
"security analyst",
"information security",
"informatiebeveiliging",
"workplace engineer",
"endpoint engineer",
"microsoft 365 engineer",
"m365 engineer",
"intune engineer",
"modern workplace",
"digital workplace",
"service desk",
"servicedesk",
"helpdesk",
"support engineer",
"support specialist",
"it technician",
"pc technician",
"application lead",
"application engineer",
"application manager",
"application specialist",
"applicatiebeheer",
"platform engineer",
"platform expert",
"solution architect",
"solutions architect",
"technical architect",
"technisch architect",
"functional analyst",
"functioneel analist",
"business analyst",
"product owner",
"web developer",
"mobile developer",
"low-code developer",
"low code developer",
"api developer",
"idm developer",
"power platform",
"forgerock",
"embedded",
"c#",
"front end",
"frontend",
"back end",
"backend",
"full stack",
"fullstack",
"php",
".net",
"java",
"python",
"powershell",
"azure",
"aws",
"microsoft 365",
"linux",
"windows server",
"kubernetes",
"docker",
"terraform",
"erp",
"sap",
"machine learning",
"ai engineer",
"artificial intelligence",
"image processing",
"computer vision",
"qa engineer",
"test automation",
)
# These engineering titles occur both in IT and in unrelated technical sectors. They are
# accepted only when the title supplies the role context and the description independently
# supplies a strong IT signal. A description signal by itself remains insufficient.
CONTEXTUAL_IT_TITLE_TERMS = (
"field engineer",
"field service engineer",
"implementation engineer",
"implementation consultant",
"service engineer",
)
IT_DESCRIPTION_TERMS = (
"microsoft 365",
"m365",
"windows server",
"active directory",
"entra id",
"intune",
"autopilot",
"exchange online",
"sharepoint",
"vmware",
"proxmox",
"hyper-v",
"networking",
"netwerkbeheer",
"tcp/ip",
"vlan",
"vpn",
"dhcp",
"dns",
"firewall",
"switches",
"routers",
"workstations",
"desktops",
"laptops",
"voip",
"3cx",
"powershell",
)
# These phrases can contain IT vocabulary while describing commercial, recruitment,
# or educational-design work. They therefore override positive title signals.
NON_IT_TITLE_TERMS = (
"business developer",
"business development",
"it recruitment",
"it recruiter",
"learning designer",
)
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
def _term_pattern(term: str) -> str:
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
return rf"(?<!\w){escaped}(?!\w)"
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
CONTEXTUAL_IT_TITLE_PATTERN = (
"(?:" + "|".join(_term_pattern(term) for term in CONTEXTUAL_IT_TITLE_TERMS) + ")"
)
IT_DESCRIPTION_PATTERN = (
"(?:" + "|".join(_term_pattern(term) for term in IT_DESCRIPTION_TERMS) + ")"
)
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
_CONTEXTUAL_IT_TITLE_RE = re.compile(CONTEXTUAL_IT_TITLE_PATTERN, re.IGNORECASE)
_IT_DESCRIPTION_RE = re.compile(IT_DESCRIPTION_PATTERN, re.IGNORECASE)
@dataclass(frozen=True)
class ItRelevanceAssessment:
relevant: bool
signals: tuple[str, ...]
reason: str
def assess_it_relevance(title: str, description: str = "") -> ItRelevanceAssessment:
normalized_title = normalize_token(title)
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
if blocked_signal:
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
)
signals = tuple(
term
for term in IT_TITLE_TERMS
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
)
if signals:
return ItRelevanceAssessment(
relevant=True,
signals=signals[:4],
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
)
contextual_title = _CONTEXTUAL_IT_TITLE_RE.search(normalized_title)
if contextual_title:
description_signal = _IT_DESCRIPTION_RE.search(normalize_token(description))
if description_signal:
return ItRelevanceAssessment(
relevant=True,
signals=(contextual_title.group(0), description_signal.group(0)),
reason=(
"IT-context bevestigd via ambigue functietitel en vacaturetekst: "
f"{contextual_title.group(0)}, {description_signal.group(0)}."
),
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Ambigue technische functietitel zonder aantoonbare IT-context.",
)
return ItRelevanceAssessment(
relevant=False,
signals=(),
reason="Geen aantoonbaar IT-signaal in de functietitel.",
)
def profile_requires_it_focus(profile: SearchProfile) -> bool:
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
normalized = normalize_token(configured)
return any(
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
for term in IT_PROFILE_TERMS
)
def it_relevance_query(prefix: str = "") -> Q:
"""Return the database equivalent of the conservative title-led classifier."""
clear_title = Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN})
contextual_title = Q(**{f"{prefix}original_title__iregex": CONTEXTUAL_IT_TITLE_PATTERN}) & Q(
**{f"{prefix}description_text__iregex": IT_DESCRIPTION_PATTERN}
)
return (clear_title | contextual_title) & ~Q(
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
)
+36 -23
View File
@@ -14,6 +14,8 @@ from apps.jobs.services.geocoding import resolve_cached_location
from apps.profiles.models import SearchProfile
from .normalization import normalize_token
from .relevance import assess_it_relevance, profile_requires_it_focus
from .skill_terms import contains_term, skill_is_present
@dataclass(frozen=True)
@@ -51,6 +53,7 @@ class DistanceAssessment:
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
AI_MAX_WEIGHT = 20.0
SKILL_MATCH_TARGET = 4
def _similarity(left: str, right: str) -> float:
@@ -112,6 +115,14 @@ def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
)
def _contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
return contains_term(text, term, allow_plural=allow_plural)
def _skill_is_present(skill: str, text: str) -> bool:
return skill_is_present(skill, text)
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
if not desired:
@@ -119,9 +130,10 @@ def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str
text = normalize_token(
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
)
present = sorted(skill for skill in desired if skill and skill in text)
present = sorted(skill for skill in desired if _skill_is_present(skill, text))
missing = sorted(desired - set(present))
return len(present) / len(desired), present, missing
evidence_target = min(SKILL_MATCH_TARGET, len(desired))
return min(1.0, len(present) / evidence_target), present, missing
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
@@ -172,7 +184,7 @@ def _hard_exclusions(
configured_terms = list(profile.excluded_titles)
configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
for term in configured_terms:
if normalize_token(term) and normalize_token(term) in title:
if _contains_term(title, normalize_token(term), allow_plural=True):
reasons.append(f"Uitgesloten titelterm: {term}")
excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
@@ -227,6 +239,9 @@ def _hard_exclusions(
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
if conflicts:
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
it_relevance = assess_it_relevance(job.original_title, job.description_text)
if profile_requires_it_focus(profile) and not it_relevance.relevant:
reasons.append(it_relevance.reason)
return reasons, distance.distance_confidence
@@ -244,20 +259,10 @@ def _ai_feature_score(features: dict[str, Any]) -> float:
support_ratio = float(features.get("support_ratio") or 0.0)
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
travel_ratio = float(features.get("travel_ratio") or 0.0)
seniority = str(features.get("seniority") or "").strip().lower()
seniority_boost = {
"junior": 0.0,
"medior": 0.08,
"senior": 0.12,
"lead": 0.14,
"expert": 0.16,
"unknown": 0.03,
"": 0.03,
}.get(seniority, 0.05)
score = (
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
)
return max(0.0, min(1.0, score + seniority_boost))
return max(0.0, min(1.0, score))
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
@@ -292,6 +297,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
distance = _distance(job, profile)
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
it_relevance = assess_it_relevance(job.original_title, job.description_text)
title_fit = _title_fit(job, profile)
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
features = job.analysis_features or {}
@@ -321,12 +327,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
else:
conditions_fit = 0.65
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
experience_years = features.get("experience_years_max")
seniority_fit = (
0.75
if experience_years is None
else max(0.25, 1.0 - max(0, int(experience_years) - 5) * 0.1)
)
if profile.preferred_workplace:
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
else:
@@ -342,7 +342,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
"location": location_fit,
"conditions": conditions_fit,
"employer": employer_fit,
"seniority": seniority_fit,
"preferences": preference_fit,
}
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
@@ -381,6 +380,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns: list[str] = []
if title_fit >= 0.75:
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
if it_relevance.relevant:
positives.append("IT-focus bevestigd via de functietitel.")
if present_skills:
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
if job.direct_employer and not job.recruiter:
@@ -402,8 +403,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
)
if support_ratio >= 0.5:
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
if missing_skills:
concerns.append("Niet duidelijk teruggevonden: " + ", ".join(missing_skills[:6]))
if (
distance.exact_distance_km is None
and distance.has_distance_data
@@ -435,6 +434,20 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
concerns=concerns,
hard_exclusions=exclusions,
evidence={
"it_relevance": {
"relevant": it_relevance.relevant,
"signals": list(it_relevance.signals),
"reason": it_relevance.reason,
},
"skills": {
"matched": present_skills,
"configured_count": len(present_skills) + len(missing_skills),
"match_target": min(
SKILL_MATCH_TARGET,
len(present_skills) + len(missing_skills),
),
"not_observed": missing_skills,
},
"distance_km": distance.exact_distance_km,
"distance_has_data": distance.has_distance_data,
"distance_exact": distance.exact_distance_km is not None,
+252
View File
@@ -0,0 +1,252 @@
from __future__ import annotations
from dataclasses import dataclass
from django.db.models import CharField, Exists, OuterRef, Q, QuerySet, Subquery
from django.utils.text import slugify
from apps.jobs.models import JobPosting, JobSourceAlias, ScoreRun
from apps.profiles.models import SearchProfile
from apps.profiles.taxonomy import SKILL_CHOICES
from apps.sources.models import Source
from .normalization import normalize_token
from .relevance import it_relevance_query
from .skill_terms import canonical_skill_key, skill_is_present
@dataclass(frozen=True)
class SkillDefinition:
key: str
label: str
category: str
category_key: str
@dataclass(frozen=True)
class SkillJobExample:
id: str
title: str
employer: str
@dataclass(frozen=True)
class SkillDemandItem:
key: str
label: str
category: str
category_key: str
vacancy_count: int
share_percent: int
explicit_count: int
inferred_count: int
covered: bool
learning_focus: str
search_term: str
examples: tuple[SkillJobExample, ...]
@dataclass(frozen=True)
class SkillDemandReport:
total_jobs: int
jobs_with_signals: int
signal_count: int
covered_count: int
gap_count: int
coverage_percent: int
items: tuple[SkillDemandItem, ...]
categories: tuple[tuple[str, str], ...]
selected_category: str
selected_coverage: str
def skill_catalog() -> tuple[SkillDefinition, ...]:
return tuple(
SkillDefinition(
key=normalize_token(value),
label=label,
category=category,
category_key=slugify(category),
)
for category, choices in SKILL_CHOICES
for value, label in choices
)
def relevant_jobs_for_profile(profile: SearchProfile) -> QuerySet[JobPosting]:
latest_score = ScoreRun.objects.filter(
job=OuterRef("pk"),
profile=profile,
profile_version=profile.version,
).order_by("-created_at")
source_aliases = JobSourceAlias.objects.filter(job=OuterRef("pk"))
active_source_aliases = source_aliases.filter(source__status=Source.Status.ACTIVE)
return (
JobPosting.objects.select_related("employer")
.filter(status=JobPosting.Status.ACTIVE)
.filter(it_relevance_query())
.annotate(
profile_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
has_source_alias=Exists(source_aliases),
has_active_source_alias=Exists(active_source_aliases),
)
.filter(
profile_recommendation__in=(
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
)
)
.filter(Q(has_source_alias=False) | Q(has_active_source_alias=True))
.order_by("-first_seen")
)
def _structured_skill_text(job: JobPosting) -> str:
return normalize_token(
" ".join(str(value) for value in [*job.skills_required, *job.skills_preferred])
)
def _inferred_skill_text(job: JobPosting) -> str:
requirements = " ".join(str(value) for value in job.requirements)
return normalize_token(f"{job.original_title} {requirements} {job.description_text}")
def _learning_focus(definition: SkillDefinition) -> str:
focuses = {
"Microsoft & endpoint": (
"Oefen een praktische beheer- of migratiecase en leg de relevante "
"Microsoft Learn-modules vast."
),
"Netwerk & security": (
"Bouw een kleine labcase rond configuratie, troubleshooting en beveiligde toegang."
),
"Back-up & monitoring": (
"Oefen detectie, herstel en rapportering met een reproduceerbare homelabcase."
),
"Virtualisatie & platform": (
"Maak een deployment- of platformlab en documenteer beschikbaarheid en herstel."
),
"IT-servicemanagement": (
"Koppel de methodiek aan een concrete incident-, problem- of changecase."
),
"Digitale werkplek & adoptie": (
"Werk een kleine governance- of adoptiecase uit met meetbare gebruikersimpact."
),
"Automation & scripting": (
"Automatiseer één herkenbare beheertaak en publiceer een veilig voorbeeldscript."
),
"VoIP & telefonie": "Simuleer configuratie en troubleshooting in een kleine telefoniecase.",
"Field service & uitvoering": (
"Documenteer een end-to-end interventie: diagnose, oplossing en overdracht."
),
}
return focuses.get(
definition.category,
"Maak een kleine praktijkcase en leg vast welke vacature-eis je ermee kunt aantonen.",
)
def _profile_skill_keys(profile: SearchProfile, catalog: tuple[SkillDefinition, ...]) -> set[str]:
catalog_keys = {definition.key for definition in catalog}
return {
canonical
for value in profile.desired_skills
if (canonical := canonical_skill_key(str(value), catalog_keys)) is not None
}
def build_skill_demand_report(
profile: SearchProfile,
*,
category: str = "all",
coverage: str = "all",
jobs: QuerySet[JobPosting] | list[JobPosting] | None = None,
) -> SkillDemandReport:
catalog = skill_catalog()
valid_categories = {definition.category_key for definition in catalog}
selected_category = category if category in valid_categories else "all"
selected_coverage = coverage if coverage in {"all", "gap", "covered"} else "all"
job_list = list(relevant_jobs_for_profile(profile) if jobs is None else jobs)
profile_skills = _profile_skill_keys(profile, catalog)
items: list[SkillDemandItem] = []
jobs_with_signals: set[str] = set()
for definition in catalog:
examples: list[SkillJobExample] = []
explicit_count = 0
inferred_count = 0
for job in job_list:
explicit = skill_is_present(definition.key, _structured_skill_text(job))
inferred = skill_is_present(definition.key, _inferred_skill_text(job))
if not explicit and not inferred:
continue
if explicit:
explicit_count += 1
else:
inferred_count += 1
jobs_with_signals.add(str(job.pk))
if len(examples) < 3:
examples.append(
SkillJobExample(
id=str(job.pk),
title=job.original_title,
employer=job.employer_name,
)
)
vacancy_count = explicit_count + inferred_count
if vacancy_count == 0:
continue
items.append(
SkillDemandItem(
key=definition.key,
label=definition.label,
category=definition.category,
category_key=definition.category_key,
vacancy_count=vacancy_count,
share_percent=round(vacancy_count / len(job_list) * 100) if job_list else 0,
explicit_count=explicit_count,
inferred_count=inferred_count,
covered=definition.key in profile_skills,
learning_focus=_learning_focus(definition),
search_term=definition.key,
examples=tuple(examples),
)
)
ranked = sorted(
items,
key=lambda item: (-item.vacancy_count, -item.explicit_count, item.label.casefold()),
)
covered_count = sum(item.covered for item in ranked)
gap_count = len(ranked) - covered_count
filtered = tuple(
item
for item in ranked
if (selected_category == "all" or item.category_key == selected_category)
and (
selected_coverage == "all"
or (selected_coverage == "covered" and item.covered)
or (selected_coverage == "gap" and not item.covered)
)
)
categories = tuple(
(slugify(category_label), category_label)
for category_label, _choices in SKILL_CHOICES
if any(item.category == category_label for item in ranked)
)
return SkillDemandReport(
total_jobs=len(job_list),
jobs_with_signals=len(jobs_with_signals),
signal_count=len(ranked),
covered_count=covered_count,
gap_count=gap_count,
coverage_percent=round(covered_count / len(ranked) * 100) if ranked else 0,
items=filtered,
categories=categories,
selected_category=selected_category,
selected_coverage=selected_coverage,
)
+87
View File
@@ -0,0 +1,87 @@
from __future__ import annotations
import re
from apps.jobs.services.normalization import normalize_token
SKILL_ALIASES: dict[str, tuple[str, ...]] = {
"microsoft 365": ("m365", "office 365", "o365"),
"microsoft teams": ("ms teams",),
"entra id": ("azure active directory", "azure ad"),
"intune": ("microsoft intune", "endpoint manager"),
"autopilot": ("windows autopilot",),
"group policy": ("group policy object", "gpo"),
"dynamics 365": ("microsoft dynamics 365", "d365"),
"microsoft copilot": ("copilot for microsoft 365", "m365 copilot"),
"microsoft defender": ("defender for endpoint", "microsoft defender for endpoint"),
"microsoft sentinel": ("azure sentinel", "sentinel siem"),
"conditional access": ("voorwaardelijke toegang",),
"networking": ("network", "netwerk", "lan", "wan"),
"routing": ("routering",),
"switching": ("network switches", "switches"),
"sd-wan": ("sd wan",),
"firewalls": ("firewall",),
"fortinet": ("fortigate",),
"palo alto": ("palo alto networks",),
"wi-fi": ("wifi", "wireless"),
"backup and restore": ("backup", "back-up", "restore"),
"disaster recovery": ("business continuity", "bcp", "dr plan"),
"monitoring": ("infrastructure monitoring", "system monitoring"),
"observability": ("telemetry", "tracing"),
"high availability": ("hoogbeschikbaarheid", "ha architecture"),
"vmware": ("vsphere", "esxi"),
"ci/cd": ("continuous integration", "continuous delivery", "ci cd"),
"deployment": ("deployments", "software deployment", "uitrol"),
"reliability": ("platform reliability", "site reliability", "sre"),
"scalability": ("scalable", "schaalbaarheid"),
"voip": ("voice over ip", "telefonie", "telephony", "3cx", "innovaphone"),
"teams telephony": ("teams phone", "teams telefonie", "teams voice"),
"onsite support": ("on-site support", "support op locatie", "field support"),
"second line support": ("second-line support", "2nd line", "tweedelijnssupport"),
"installations": ("installatie", "installaties", "roll-out", "rollout"),
"migrations": ("migratie", "migraties"),
"technical documentation": ("technische documentatie",),
"customer support": ("klantondersteuning", "user support"),
"incident management": ("incidentbeheer", "incident response", "escalations"),
"problem management": ("probleembeheer", "root cause analysis"),
"change management": ("wijzigingsbeheer", "organizational change"),
"itil": ("itil 4", "it service management"),
"servicenow": ("service now",),
"jira service management": ("jira service desk", "jsm"),
"digital workplace": ("digitale werkplek",),
"m365 governance": ("microsoft 365 governance", "office 365 governance"),
"document management": ("documentbeheer", "document management system", "dms"),
"data governance": ("data governance", "datagovernance"),
"user adoption": ("gebruikersadoptie", "technology adoption"),
"training and workshops": ("user training", "workshops", "training geven"),
"customer experience": ("customer satisfaction", "csat", "nps"),
}
def contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
normalized_term = normalize_token(term)
if not normalized_term:
return False
pattern = re.escape(normalized_term).replace(r"\ ", r"[\s/_-]+")
if allow_plural and normalized_term[-1].isalpha() and not normalized_term.endswith("s"):
pattern += "s?"
return re.search(rf"(?<!\w){pattern}(?!\w)", text, re.IGNORECASE) is not None
def skill_terms(skill: str) -> tuple[str, ...]:
normalized = normalize_token(skill)
return (normalized, *SKILL_ALIASES.get(normalized, ()))
def skill_is_present(skill: str, text: str) -> bool:
return any(contains_term(text, candidate) for candidate in skill_terms(skill))
def canonical_skill_key(value: str, catalog_keys: set[str]) -> str | None:
normalized = normalize_token(value)
if normalized in catalog_keys:
return normalized
for key in catalog_keys:
if normalized in {normalize_token(term) for term in SKILL_ALIASES.get(key, ())}:
return key
return None
+10
View File
@@ -1,20 +1,30 @@
from django.urls import path
from .views import (
ActivityListView,
ApplicationListView,
ApplicationUpdateView,
EmployerDetailView,
EmployerListView,
JobDetailView,
JobListView,
SkillInsightsView,
application_delete,
application_export,
application_print,
application_status,
job_feedback,
)
urlpatterns = [
path("", JobListView.as_view(), name="list"),
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
path("activity/", ActivityListView.as_view(), name="activity"),
path("employers/", EmployerListView.as_view(), name="employers"),
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
path("applications/", ApplicationListView.as_view(), name="applications"),
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
path("applications/<int:pk>/status/", application_status, name="application-status"),
path("applications/<int:pk>/export/", application_export, name="application-export"),
path("applications/<int:pk>/print/", application_print, name="application-print"),
path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
+247 -36
View File
@@ -3,55 +3,173 @@ from __future__ import annotations
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
from django.db.models import Count, F, Q
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, ListView, UpdateView
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
from apps.profiles.models import SearchProfile
from .forms import ApplicationForm
from .models import Application, Feedback, JobPosting, ScoreRun
from .models import Application, Employer, Feedback, JobPosting, ScoreRun
from .services.activity import ACTIVITY_FILTERS, build_activity_page
from .services.applications import (
build_application_export,
build_print_html,
change_application_status,
delete_application_dossier,
track_application_changes,
)
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
from .services.employer_intelligence import (
build_employer_detail,
employer_index_queryset,
filter_employers,
)
from .services.feedback import record_feedback
from .services.relevance import it_relevance_query
from .services.skill_demand import build_skill_demand_report
class SkillInsightsView(LoginRequiredMixin, TemplateView):
template_name = "jobs/skill_insights.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["active_profile"] = profile
context["report"] = (
build_skill_demand_report(
profile,
category=self.request.GET.get("category", "all"),
coverage=self.request.GET.get("coverage", "all"),
)
if profile
else None
)
return context
class ActivityListView(LoginRequiredMixin, TemplateView):
template_name = "activity/list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
event_type = self.request.GET.get("type", "all")
valid_types = {value for value, _label in ACTIVITY_FILTERS}
if event_type not in valid_types:
event_type = "all"
context.update(
{
"event_page": build_activity_page(
user=self.request.user,
event_type=event_type,
query=self.request.GET.get("q", ""),
page=self.request.GET.get("page", 1),
),
"activity_filters": ACTIVITY_FILTERS,
"current_type": event_type,
}
)
return context
class EmployerListView(LoginRequiredMixin, ListView):
model = Employer
template_name = "employers/list.html"
context_object_name = "employers"
paginate_by = 40
def get_queryset(self):
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
return filter_employers(
employer_index_queryset(user=self.request.user, profile=self.profile),
query=self.request.GET.get("q", ""),
channel=self.request.GET.get("channel", ""),
active_only=self.request.GET.get("active") == "1",
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
employers = list(context["employers"])
selected_id = self.request.GET.get("selected", "")
selected = next((item for item in employers if str(item.pk) == selected_id), None)
if selected is None and employers:
selected = employers[0]
context.update(
{
"selected_employer": selected,
"selected_detail": build_employer_detail(
employer=selected, user=self.request.user, profile=self.profile
)
if selected
else None,
"active_profile": self.profile,
"employer_total": Employer.objects.count(),
"employer_active_total": Employer.objects.filter(
jobs__status=JobPosting.Status.ACTIVE
)
.distinct()
.count(),
}
)
return context
class EmployerDetailView(LoginRequiredMixin, DetailView):
model = Employer
template_name = "employers/detail.html"
context_object_name = "employer"
def get_queryset(self):
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
return employer_index_queryset(user=self.request.user, profile=self.profile)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
build_employer_detail(
employer=self.object, user=self.request.user, profile=self.profile
)
)
context["active_profile"] = self.profile
return context
class JobListView(LoginRequiredMixin, ListView):
model = JobPosting
template_name = "jobs/list.html"
context_object_name = "jobs"
paginate_by = 30
paginate_by = 20
VALID_RECOMMENDATIONS = {
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
ScoreRun.Recommendation.HIDDEN,
}
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
VALID_SORTS = {"match", "newest", "closing"}
def get_queryset(self):
queryset = JobPosting.objects.select_related("employer").all()
queryset = JobPosting.objects.select_related("employer").annotate(
duplicate_count=Count("duplicates", distinct=True)
)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
if profile:
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
"-created_at"
)
queryset = queryset.annotate(
match_score=Subquery(
latest_score.values("score")[:1],
output_field=DecimalField(max_digits=5, decimal_places=2),
),
match_confidence=Subquery(
latest_score.values("confidence")[:1],
output_field=DecimalField(max_digits=4, decimal_places=3),
),
match_recommendation=Subquery(
latest_score.values("recommendation")[:1], output_field=CharField()
),
)
queryset = annotate_latest_profile_score(queryset, profile)
query = self.request.GET.get("q", "").strip()
status = self.request.GET.get("status", "active").strip()
workplace = self.request.GET.get("workplace", "").strip()
channel = self.request.GET.get("channel", "").strip()
focus = self.request.GET.get("focus", "it").strip()
recommendation = self.request.GET.get("match", "radar").strip()
sort = self.request.GET.get("sort", "match").strip()
if recommendation not in self.VALID_MATCH_FILTERS:
recommendation = "radar"
if query:
queryset = queryset.filter(
Q(original_title__icontains=query)
@@ -63,7 +181,68 @@ class JobListView(LoginRequiredMixin, ListView):
queryset = queryset.filter(status=status)
if workplace:
queryset = queryset.filter(workplace_type=workplace)
return queryset.order_by("-first_seen")
if channel == "direct":
queryset = queryset.filter(direct_employer=True, recruiter=False)
elif channel == "recruiter":
queryset = queryset.filter(recruiter=True)
if focus != "all":
queryset = queryset.filter(it_relevance_query())
if profile and recommendation == "radar":
queryset = queryset.filter(
Q(
match_recommendation__in=[
ScoreRun.Recommendation.STRONG,
ScoreRun.Recommendation.POSSIBLE,
ScoreRun.Recommendation.WEAK,
]
)
| Q(match_recommendation__isnull=True)
)
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
queryset = queryset.filter(match_recommendation=recommendation)
if sort not in self.VALID_SORTS:
sort = "match"
if sort == "newest":
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
if sort == "closing":
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
it_jobs = active_jobs.filter(it_relevance_query())
context.update(
{
"active_profile": SearchProfile.objects.filter(
user=self.request.user, is_active=True
).first(),
"active_job_count": active_jobs.count(),
"it_job_count": it_jobs.count(),
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
"current_focus": self.request.GET.get("focus", "it"),
"current_match": (
self.request.GET.get("match", "radar")
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
else "radar"
),
"current_sort": self.request.GET.get("sort", "match"),
"current_channel": self.request.GET.get("channel", ""),
}
)
jobs = list(context["jobs"])
selected_id = self.request.GET.get("selected", "")
selected_job = next((job for job in jobs if str(job.pk) == selected_id), None)
if selected_job is None and jobs:
selected_job = jobs[0]
context["selected_job"] = selected_job
if selected_job is not None:
context["selected_intelligence"] = build_job_intelligence(
job=selected_job,
profile=context["active_profile"],
user=self.request.user,
)
return context
class JobDetailView(LoginRequiredMixin, DetailView):
@@ -79,16 +258,9 @@ class JobDetailView(LoginRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
context["score"] = (
ScoreRun.objects.filter(job=self.object, profile=profile)
.order_by("-created_at")
.first()
if profile
else None
context.update(
build_job_intelligence(job=self.object, profile=profile, user=self.request.user)
)
context["application"] = Application.objects.filter(
user=self.request.user, job=self.object
).first()
context["latest_feedback"] = Feedback.objects.filter(
user=self.request.user, job=self.object
).first()
@@ -113,7 +285,13 @@ def job_feedback(request, pk):
)
messages.success(request, "Actie opgeslagen.")
next_url = request.POST.get("next")
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
if next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
):
return redirect(next_url)
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
class ApplicationListView(LoginRequiredMixin, ListView):
@@ -122,8 +300,10 @@ class ApplicationListView(LoginRequiredMixin, ListView):
context_object_name = "applications"
def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related(
"job", "job__employer"
return (
Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
)
def get_context_data(self, **kwargs):
@@ -138,6 +318,16 @@ class ApplicationListView(LoginRequiredMixin, ListView):
}
for status, label in Application.Status.choices
]
context["status_choices"] = Application.Status.choices
context["open_count"] = sum(
item.status not in {Application.Status.REJECTED, Application.Status.WITHDRAWN}
for item in applications
)
context["follow_up_count"] = sum(
bool(item.follow_up_date and item.follow_up_date <= timezone.localdate())
for item in applications
)
context["today"] = timezone.localdate()
return context
@@ -147,7 +337,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
template_name = "applications/edit.html"
def get_queryset(self):
return Application.objects.filter(user=self.request.user).select_related("job")
return (
Application.objects.filter(user=self.request.user)
.select_related("job", "job__employer")
.prefetch_related("timeline_events")
)
def form_valid(self, form):
previous = (
@@ -185,6 +379,23 @@ def application_export(request, pk: int):
return response
@login_required
@require_POST
def application_status(request, pk: int):
application = get_object_or_404(Application, pk=pk, user=request.user)
try:
change_application_status(
application=application,
user=request.user,
status=request.POST.get("status", ""),
)
except ValueError as exc:
messages.error(request, str(exc))
else:
messages.success(request, "Pipelinestatus bijgewerkt.")
return redirect("jobs:applications")
@login_required
def application_print(request, pk: int):
application = get_object_or_404(
+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
+4 -5
View File
@@ -3,13 +3,12 @@ from __future__ import annotations
def default_weights() -> dict[str, float]:
return {
"content": 25.0,
"skills": 20.0,
"location": 15.0,
"content": 30.0,
"skills": 25.0,
"location": 20.0,
"conditions": 10.0,
"employer": 10.0,
"seniority": 10.0,
"preferences": 10.0,
"preferences": 5.0,
"ai": 0.0,
}
+24 -56
View File
@@ -5,55 +5,12 @@ import re
from django import forms
from .models import SearchProfile
TITLE_CHOICES = (
("system engineer", "System engineer"),
("infrastructure engineer", "Infrastructure engineer"),
("cloud engineer", "Cloud engineer"),
("devops engineer", "DevOps engineer"),
("network engineer", "Network engineer"),
("security engineer", "Security engineer"),
("workplace engineer", "Workplace engineer"),
("support engineer", "Support engineer"),
("software engineer", "Software engineer"),
("data engineer", "Data engineer"),
("solution architect", "Solution architect"),
("project manager", "Projectmanager"),
)
EXCLUDED_TITLE_CHOICES = (
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("callcenter", "Callcenter"),
("stage", "Stage"),
("student", "Studentenjob"),
)
SKILL_CHOICES = (
("azure", "Microsoft Azure"),
("aws", "AWS"),
("microsoft 365", "Microsoft 365"),
("active directory", "Active Directory"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("linux", "Linux"),
("windows server", "Windows Server"),
("networking", "Netwerken"),
("security", "Security"),
("python", "Python"),
("powershell", "PowerShell"),
("terraform", "Terraform"),
("docker", "Docker"),
("kubernetes", "Kubernetes"),
("ci/cd", "CI/CD"),
)
EXCLUDED_SKILL_CHOICES = (
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
("night shift", "Nachtwerk"),
from .taxonomy import (
EXCLUDED_SKILL_CHOICES,
EXCLUDED_TITLE_CHOICES,
SKILL_CHOICES,
TITLE_CHOICES,
iter_choices,
)
EMPLOYMENT_CHOICES = (
@@ -91,27 +48,31 @@ class SearchProfileForm(forms.ModelForm):
label="Gewenste functietitels",
required=False,
choices=TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple,
help_text="Selecteer alle rollen die bij je zoekrichting passen.",
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Selecteer alle rollen die bij je zoekrichting passen; de beste titelmatch telt.",
)
excluded_titles = forms.MultipleChoiceField(
label="Uitgesloten functietitels",
required=False,
choices=EXCLUDED_TITLE_CHOICES,
widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
)
desired_skills = forms.MultipleChoiceField(
label="Gewenste skills",
required=False,
choices=SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
help_text=(
"Kies je herkenbare cv-skills ruim. Maximaal vier aangetroffen skills volstaan voor "
"de volledige skillcomponent; extra keuzes verwateren je score niet."
),
)
excluded_skills = forms.MultipleChoiceField(
label="Uitgesloten kenmerken",
required=False,
choices=EXCLUDED_SKILL_CHOICES,
widget=forms.CheckboxSelectMultiple,
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
)
allowed_employment_types = forms.MultipleChoiceField(
label="Toegestane contractvormen",
@@ -145,6 +106,7 @@ class SearchProfileForm(forms.ModelForm):
"is_active",
"home_postal_code",
"max_distance_km",
"experience_years",
"desired_titles",
"excluded_titles",
"desired_skills",
@@ -163,6 +125,7 @@ class SearchProfileForm(forms.ModelForm):
"is_active": "Actief profiel",
"home_postal_code": "Thuispostcode",
"max_distance_km": "Maximale afstand (km)",
"experience_years": "Relevante IT-ervaring",
"recommendation_threshold": "Aanbevelingsdrempel",
"top_match_threshold": "Topmatchdrempel",
"digest_time": "Tijdstip dagelijkse samenvatting",
@@ -173,6 +136,10 @@ class SearchProfileForm(forms.ModelForm):
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
"lokale geodata beschikbaar is."
),
"experience_years": (
"Optionele profielcontext. Deze waarde telt nooit mee voor je matchscore en "
"kan een vacature niet uitsluiten."
),
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
}
widgets = {
@@ -203,14 +170,15 @@ class SearchProfileForm(forms.ModelForm):
field = self.fields[field_name]
stored = getattr(self.instance, field_name, [])
existing_by_casefold = {
str(value).casefold(): str(value) for value, _label in field.choices
str(value).casefold(): str(value) for value, _label in iter_choices(field.choices)
}
extra = [
(str(value), str(value))
for value in stored
if str(value).casefold() not in existing_by_casefold
]
field.choices = [*field.choices, *extra]
if extra:
field.choices = [*field.choices, ("Eerder opgeslagen", tuple(extra))]
field.initial = [
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.16 on 2026-07-22 13:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0004_reminder_settings'),
]
operations = [
migrations.AddField(
model_name='searchprofile',
name='experience_years',
field=models.PositiveSmallIntegerField(choices=[(0, 'Niet ingesteld'), (1, 'Minder dan 2 jaar'), (3, '2 tot 4 jaar'), (5, '5 tot 7 jaar'), (8, '8 tot 9 jaar'), (10, '10 tot 14 jaar'), (15, '15 jaar of meer')], default=0),
),
]
@@ -0,0 +1,22 @@
from django.db import migrations
def remove_seniority_weight(apps, schema_editor):
search_profile = apps.get_model("profiles", "SearchProfile")
for profile in search_profile.objects.all().iterator():
weights = dict(profile.weights or {})
if "seniority" not in weights:
continue
weights.pop("seniority", None)
profile.weights = weights
profile.save(update_fields=["weights"])
class Migration(migrations.Migration):
dependencies = [
("profiles", "0005_searchprofile_experience_years"),
]
operations = [
migrations.RunPython(remove_seniority_weight, migrations.RunPython.noop),
]
+15
View File
@@ -16,6 +16,16 @@ from .defaults import (
default_weights,
)
EXPERIENCE_YEARS_CHOICES = (
(0, "Niet ingesteld"),
(1, "Minder dan 2 jaar"),
(3, "2 tot 4 jaar"),
(5, "5 tot 7 jaar"),
(8, "8 tot 9 jaar"),
(10, "10 tot 14 jaar"),
(15, "15 jaar of meer"),
)
class SearchProfile(TimeStampedModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
@@ -29,6 +39,10 @@ class SearchProfile(TimeStampedModel):
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
max_distance_km = models.PositiveIntegerField(default=45)
experience_years = models.PositiveSmallIntegerField(
choices=EXPERIENCE_YEARS_CHOICES,
default=0,
)
desired_titles = models.JSONField(default=list, blank=True)
excluded_titles = models.JSONField(default=list, blank=True)
@@ -105,6 +119,7 @@ class SearchProfile(TimeStampedModel):
if self.home_longitude is not None
else None,
"max_distance_km": self.max_distance_km,
"experience_years": self.experience_years,
"desired_titles": self.desired_titles,
"excluded_titles": self.excluded_titles,
"desired_skills": self.desired_skills,
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
from collections.abc import Iterable, Iterator, Sequence
Choice = tuple[str, str]
ChoiceGroup = tuple[str, tuple[Choice, ...]]
TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Field service & support",
(
("it field engineer", "IT Field Engineer"),
("field service engineer", "Field Service Engineer"),
("it engineer", "IT Engineer"),
("it support engineer", "IT Support Engineer"),
("support engineer", "Support Engineer"),
("onsite support engineer", "On-site Support Engineer"),
("service desk engineer", "Service Desk Engineer"),
("technical support engineer", "Technical Support Engineer"),
("it technician", "IT Technician"),
("pc technician", "PC Technician"),
),
),
(
"Systemen & infrastructuur",
(
("system engineer", "System Engineer"),
("system network engineer", "System & Network Engineer"),
("infrastructure engineer", "Infrastructure Engineer"),
("system administrator", "System Administrator"),
("systeembeheerder", "Systeembeheerder"),
("network engineer", "Network Engineer"),
("network administrator", "Network Administrator"),
("netwerkbeheerder", "Netwerkbeheerder"),
("infrastructure consultant", "Infrastructure Consultant"),
("implementation engineer", "Implementation Engineer"),
),
),
(
"Modern Workplace & endpoint",
(
("workplace engineer", "Workplace Engineer"),
("modern workplace engineer", "Modern Workplace Engineer"),
("microsoft 365 engineer", "Microsoft 365 Engineer"),
("microsoft 365 consultant", "Microsoft 365 Consultant"),
("modern workplace consultant", "Modern Workplace Consultant"),
("endpoint engineer", "Endpoint Engineer"),
("intune engineer", "Intune Engineer"),
),
),
(
"Andere IT-richtingen",
(
("cloud engineer", "Cloud Engineer"),
("devops engineer", "DevOps Engineer"),
("security engineer", "Security Engineer"),
("platform engineer", "Platform Engineer"),
("software engineer", "Software Engineer"),
("data engineer", "Data Engineer"),
("solution architect", "Solution Architect"),
("project manager", "IT-projectmanager"),
),
),
)
EXCLUDED_TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Commercieel & werving",
(
("sales", "Sales"),
("recruiter", "Recruiter"),
("account manager", "Accountmanager"),
("business developer", "Business Developer"),
("callcenter", "Callcenter"),
),
),
(
"Software, data & analyse",
(
("software developer", "Software Developer"),
("software engineer", "Software Engineer"),
("web developer", "Web Developer"),
("developer", "Developmentfuncties (breed)"),
("software", "Softwarefuncties (breed)"),
("backend", "Backendfuncties"),
("frontend", "Frontendfuncties"),
("php", "PHP-ontwikkeling"),
("data engineer", "Data Engineer"),
("data scientist", "Data Scientist"),
("data", "Datafuncties (breed)"),
("ai engineer", "AI Engineer"),
("ai", "AI-functies (breed)"),
("machine learning", "Machine Learning"),
("functional analyst", "Functional Analyst"),
("functioneel analist", "Functioneel Analist"),
("business analyst", "Business Analyst"),
),
),
(
"Management & starters",
(
("product owner", "Product Owner"),
("project manager", "Projectmanager"),
("architect", "Architectrollen"),
("service delivery manager", "Service Delivery Manager"),
("team lead", "Teamlead"),
("head of", "Head-of-rollen"),
("procurement", "Procurement"),
("stage", "Stage"),
("internship", "Internship"),
("student", "Studentenjob"),
),
),
(
"Niet-IT-techniek",
(
("electromechanical", "Elektromechanica"),
("maintenance technician", "Onderhoudstechnieker"),
("process engineer", "Process Engineer"),
("automation engineer", "Automation Engineer"),
),
),
(
"Onderzoek & beeldverwerking",
(
("research", "Onderzoeksfuncties"),
("scientist", "Scientist"),
("image processing", "Beeldverwerking"),
("remote sensing", "Remote sensing"),
),
),
(
"Cloud & businessplatformen",
(
("cloud", "Cloudfuncties (breed)"),
("devops", "DevOps"),
("azure consultant", "Azure Consultant"),
("sap", "SAP"),
("power platform", "Power Platform"),
),
),
(
"Onderwijs",
(
("lecturer", "Lecturer"),
("lector", "Lector"),
("docent", "Docent"),
("gastdocent", "Gastdocent"),
),
),
)
SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Field service & uitvoering",
(
("troubleshooting", "Troubleshooting"),
("onsite support", "On-site support"),
("hardware", "Hardware & randapparatuur"),
("installations", "Installaties & roll-outs"),
("migrations", "Migraties"),
("technical documentation", "Technische documentatie"),
("customer support", "Klantondersteuning"),
("second line support", "Tweede- en derdelijnssupport"),
("customer experience", "Klanttevredenheid / CSAT"),
),
),
(
"Microsoft & endpoint",
(
("microsoft 365", "Microsoft 365"),
("exchange online", "Exchange Online"),
("sharepoint", "SharePoint"),
("microsoft teams", "Microsoft Teams"),
("entra id", "Entra ID"),
("intune", "Microsoft Intune"),
("autopilot", "Windows Autopilot"),
("windows server", "Windows Server"),
("active directory", "Active Directory"),
("group policy", "Group Policy / GPO"),
("dynamics 365", "Microsoft Dynamics 365"),
("microsoft copilot", "Microsoft Copilot"),
("microsoft defender", "Microsoft Defender"),
("microsoft sentinel", "Microsoft Sentinel"),
("conditional access", "Conditional Access"),
),
),
(
"Netwerk & security",
(
("networking", "Netwerken"),
("tcp/ip", "TCP/IP"),
("vlan", "VLAN"),
("vpn", "VPN"),
("dhcp", "DHCP"),
("dns", "DNS"),
("firewalls", "Firewalls"),
("watchguard", "WatchGuard"),
("cisco", "Cisco"),
("ubiquiti", "Ubiquiti"),
("wi-fi", "Wi-Fi"),
("routing", "Routing"),
("switching", "Switching"),
("sd-wan", "SD-WAN"),
("fortinet", "Fortinet / FortiGate"),
("palo alto", "Palo Alto Networks"),
("aruba", "HPE Aruba"),
("meraki", "Cisco Meraki"),
("sophos", "Sophos"),
("zero trust", "Zero Trust"),
),
),
(
"Back-up & monitoring",
(
("backup and restore", "Back-up & restore"),
("veeam", "Veeam"),
("windows server backup", "Windows Server Backup"),
("synology", "Synology"),
("zabbix", "Zabbix"),
("monitoring", "Infrastructuurmonitoring"),
("observability", "Observability"),
("high availability", "High availability"),
("disaster recovery", "Disaster recovery"),
),
),
(
"Virtualisatie & platform",
(
("vmware", "VMware"),
("proxmox", "Proxmox"),
("hyper-v", "Hyper-V"),
("docker", "Docker"),
("azure", "Microsoft Azure"),
("aws", "AWS"),
("linux", "Linux"),
("kubernetes", "Kubernetes"),
("terraform", "Terraform"),
("ci/cd", "CI/CD"),
("deployment", "Deployment & uitrol"),
("reliability", "Platform reliability"),
("scalability", "Schaalbaarheid"),
),
),
(
"VoIP & telefonie",
(
("voip", "VoIP"),
("3cx", "3CX"),
("innovaphone", "Innovaphone"),
("teams telephony", "Teams-telefonie"),
("yealink", "Yealink"),
("snom", "Snom"),
),
),
(
"Automation & scripting",
(
("powershell", "PowerShell"),
("bash", "Bash"),
("python", "Python"),
("c#", "C#"),
),
),
(
"IT-servicemanagement",
(
("itil", "ITIL / IT-servicemanagement"),
("incident management", "Incidentmanagement"),
("problem management", "Problemmanagement"),
("change management", "Changemanagement"),
("servicenow", "ServiceNow"),
("jira service management", "Jira Service Management"),
),
),
(
"Digitale werkplek & adoptie",
(
("digital workplace", "Digital Workplace"),
("m365 governance", "Microsoft 365-governance"),
("document management", "Documentmanagement"),
("data governance", "Datagovernance"),
("user adoption", "Gebruikersadoptie"),
("training and workshops", "Training & workshops"),
),
),
)
EXCLUDED_SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
(
"Commerciële voorwaarden",
(
("cold calling", "Cold calling"),
("door to door", "Deur-aan-deurverkoop"),
("commission only", "Alleen commissieloon"),
),
),
(
"Werkregeling",
(
("night shift", "Nachtwerk"),
("rotating shifts", "Ploegendienst"),
("on-call duty", "Structurele wachtdienst"),
),
),
)
def iter_choices(choices: Iterable[Choice | tuple[str, Sequence[Choice]]]) -> Iterator[Choice]:
"""Flatten Django optgroups while retaining compatibility with legacy flat choices."""
for value, label_or_choices in choices:
if isinstance(label_or_choices, tuple | list):
yield from iter_choices(label_or_choices)
else:
yield str(value), str(label_or_choices)
+17
View File
@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views.generic import ListView, UpdateView
from apps.jobs.models import JobPosting, ScoreRun
from .forms import SearchProfileForm
from .models import SearchProfile
from .services import resolve_profile_home_location, save_profile_revision
@@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
def get_queryset(self):
return SearchProfile.objects.filter(user=self.request.user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["preview_score"] = (
ScoreRun.objects.filter(
profile=self.object,
job__status=JobPosting.Status.ACTIVE,
hard_exclusions=[],
)
.select_related("job", "job__employer")
.order_by("-created_at")
.first()
)
context["recent_revisions"] = list(self.object.revisions.all()[:4])
return context
def form_valid(self, form):
response = super().form_valid(form)
location_result = resolve_profile_home_location(self.object)
+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(
+44 -10
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import hashlib
import ipaddress
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
@@ -53,6 +54,43 @@ class FetchTimeoutError(FetchError):
pass
def _connected_peer_ip(
response: httpx.Response,
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
"""Return the actual remote peer when httpcore exposes its network stream."""
stream = response.extensions.get("network_stream")
if stream is None or not hasattr(stream, "get_extra_info"):
return None
try:
server_address = stream.get_extra_info("server_addr")
raw_address = server_address[0] if server_address else None
return ipaddress.ip_address(raw_address) if raw_address else None
except (IndexError, TypeError, ValueError, OSError):
return None
def _validate_connected_peer(
response: httpx.Response,
*,
before: ValidatedUrl,
after: ValidatedUrl,
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 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.")
def parse_retry_after(value: str | None) -> int | None:
if not value:
return None
@@ -127,26 +165,23 @@ def fetch_url(
verify=trusted_tls_context(),
)
current_url = url
previous_validation: ValidatedUrl | None = None
try:
for _ in range(settings.FETCHER_MAX_REDIRECTS + 1):
validation = validate_public_url(
current_url,
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
)
if (
previous_validation is not None
and validation.hostname == previous_validation.hostname
and not set(validation.addresses).intersection(previous_validation.addresses)
):
raise FetchError("DNS-rebindcontrole faalde bij redirect naar dezelfde host.")
response = http_client.get(current_url, headers=headers)
post_validation = validate_public_url(
str(response.url) if response.url else current_url,
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
)
if not set(validation.addresses).intersection(set(post_validation.addresses)):
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
_validate_connected_peer(
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")
if not location:
@@ -155,7 +190,6 @@ def fetch_url(
next_decision = assess_url(current_url, source=source)
if not next_decision.allowed:
raise PolicyBlockedError(next_decision.reason)
previous_validation = validation
continue
if response.status_code == 304:
return FetchedDocument(url, current_url, 304, dict(response.headers), b"")
+9 -3
View File
@@ -4,6 +4,7 @@ from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime, timedelta
from django.db.models import Prefetch
from django.utils import timezone
from apps.sources.models import Source, SourcePolicyReview, SourceRun
@@ -214,12 +215,17 @@ def _determine_health_action(runs: list[SourceRun]) -> tuple[str | None, str | N
def collect_source_health(
*, runs_to_consider: int = SOURCE_HEALTH_RUN_WINDOW
) -> list[SourceHealth]:
sources = Source.objects.order_by("name").all()
sources = Source.objects.prefetch_related(
Prefetch(
"runs",
queryset=SourceRun.objects.order_by("-started_at")[:runs_to_consider],
to_attr="health_runs",
)
).order_by("name")
rows: list[SourceHealth] = []
for source in sources:
run_queryset = SourceRun.objects.filter(source=source).order_by("-started_at")
runs = list(run_queryset[:runs_to_consider])
runs = source.health_runs
monitored_runs = len(runs)
success_runs = [run for run in runs if run.status == SourceRun.Status.SUCCESS]
+13 -2
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,7 +47,13 @@ def poll_imap_mailbox(
"failed": 0,
}
try:
client.login(connection.username, password)
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":
raise RuntimeError("IMAP-mailbox kon niet worden geopend")
+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.")
+3 -1
View File
@@ -188,7 +188,8 @@ def schedule_due_sources() -> dict[str, int]:
now = timezone.now()
due = Source.objects.filter(
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
).filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
).exclude(source_type=Source.Type.EMAIL)
due = due.filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
ids = list(due.values_list("id", flat=True).distinct()[:200])
for source_id in ids:
fetch_source.delay(source_id)
@@ -200,6 +201,7 @@ def schedule_mailbox_polls() -> dict[str, int]:
now = timezone.now()
due_ids = list(
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
.exclude(user__username=settings.DEMO_USERNAME)
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
.values_list("id", flat=True)[:100]
)
+13 -3
View File
@@ -4,7 +4,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.db.models import Count, Prefetch, Q
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -17,7 +17,7 @@ from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rat
from apps.profiles.models import SearchProfile
from .forms import MailboxConnectionForm, ManualImportForm
from .models import MailboxConnection, Source, SourcePolicyReview
from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun
from .platforms import PLATFORM_ALERTS
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
@@ -27,7 +27,14 @@ from .tasks import fetch_source, poll_mailbox
def _source_list_queryset():
return (
Source.objects.prefetch_related("policy_reviews")
Source.objects.prefetch_related(
"policy_reviews",
Prefetch(
"runs",
queryset=SourceRun.objects.order_by("-started_at")[:1],
to_attr="latest_runs",
),
)
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
.filter(
Q(metadata__hidden_from_source_list__isnull=True)
@@ -69,6 +76,9 @@ def _manual_import_context(
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
"source_total_count": base_queryset.count(),
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
"failed_source_count": Source.objects.filter(
Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0)
).count(),
"watchlist_sources": watchlist_queryset,
"watchlist_count": watchlist_queryset.count(),
"manual_import_form": form,
+745
View File
@@ -0,0 +1,745 @@
{
"configuration": {
"concurrency": 1,
"dataset": "fixtures/benchmark/quality_benchmark.json",
"iterations": 3,
"jobs": 250,
"warmup_runs": 1
},
"database_identity": {
"engine": "django.db.backends.postgresql",
"host": "127.0.0.1",
"name_prefix": "vacatureradar_bench_"
},
"iterations": [
{
"dataset_version": "vr116-2026-07-21",
"dedupe": {
"case_reports": [
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-infra",
"expected_match_present": true,
"id": "D-001",
"is_expected_match": true,
"matched": true,
"matched_job_id": "267f0fd5-4543-404c-acd1-c6f6ee72e4c0",
"query": "D-001"
},
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-network",
"expected_match_present": true,
"id": "D-002",
"is_expected_match": true,
"matched": true,
"matched_job_id": "6a196e1a-bed6-44d0-94df-c2578c367673",
"query": "D-002"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-003",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-003"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-004",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-004"
}
],
"false_merges": 0,
"false_negative": 0,
"false_positive": 0,
"missed_merges": 0,
"precision": 1.0,
"recall": 1.0,
"seed_jobs": 2,
"status": "passed",
"true_negative": 2,
"true_positive": 2
},
"hardware": {
"cpu_count": 20,
"debug_mode": true,
"platform": "Linux-6.12.54-Unraid-x86_64-with-glibc2.41",
"python": "3.13.14",
"release": "6.12.54-Unraid"
},
"jobs_processed": 250,
"nfr_007": {
"measured": false,
"p95_ms": null,
"passed": false
},
"parser": {
"cases": [
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "jsonld-jobposting",
"expected_parser_version": "1.0.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_jsonld_job.html",
"id": "P-001",
"parser_key": "jsonld-jobposting",
"parser_version": "1.0.0",
"passed": true,
"warnings": []
},
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.1.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_generic_job.html",
"id": "P-002",
"parser_key": "generic-html",
"parser_version": "1.1.0",
"passed": true,
"warnings": []
}
],
"coverage_ratio": 1.0,
"covered_fields": 12,
"expected_fields": 12,
"status": "passed",
"unknown_data_ratio": 0.0
},
"performance": {
"avg_scores_detail_ms": 50.936,
"avg_scores_import_ms": 30.859,
"avg_scores_list_ms": 104.346,
"avg_scores_rescore_ms": 14.893,
"dashboard": {
"detail_p95_ms": 55.28,
"detail_query_count": 25,
"detail_query_types": {
"SELECT": 25
},
"list_p95_ms": 135.076,
"list_query_count": 76,
"list_query_types": {
"SELECT": 76
},
"timing_count": 50
},
"import": {
"duration_ms_total": 7714.791,
"p50_ms": 29.722,
"p95_ms": 39.37,
"query_count": 3264,
"query_types": {
"INSERT": 1003,
"RELEASE": 253,
"SAVEPOINT": 253,
"SELECT": 1256,
"UPDATE": 499
},
"timing_count": 250
},
"jobs": 1,
"performance": {
"memory_current_mb": 12.418,
"memory_peak_mb": 17.349,
"nfr_007": true,
"p50_ms": 30.306,
"p95_ms": 94.573,
"total_duration_ms": 11611.716
},
"query_count": 3370,
"rescore": {
"duration_ms_total": 14.893,
"p50_ms": 14.893,
"p95_ms": 14.893,
"query_count": 5,
"query_types": {
"INSERT": 1,
"SELECT": 4
},
"timing_count": 1
},
"source_run_id": 1,
"top_n": 25
},
"ranking": {
"churn_ratio": 0.0,
"post_top_n": [
"8f49a32b-f425-425e-ac47-fbd1e1ab020f",
"8302cd44-569c-4109-83dd-87663d053200",
"fb63a999-5aa0-4912-8d06-400f852676cc",
"1476c46b-84ce-4151-b777-9dac8b533a8c",
"518e4b70-6742-43d2-bd39-90b3af026d89",
"9fa543b2-d5c9-47e1-8cba-ed1e1bfbfb86",
"5759f1c8-4974-4333-98c4-2ab0aec97918",
"77565fbe-27ea-43ee-bcb7-a218b073af9f",
"a9f67c53-f22d-4d72-be23-2ef95eb49443",
"ff866750-238b-4eb6-a9b7-7cacd8c6b9d0",
"8470bf3b-1760-4d4c-8755-387fcc5c92e8",
"293b9d4a-be83-4c72-b08e-02d6170ac405",
"d00b4e66-dfd1-45e8-af78-b643f59b5327",
"de6cbb4d-5225-4975-91f3-23b280492598",
"bb5bfca7-38d3-4639-83bd-da639ce2b4b5",
"d9a6079c-eb37-47e0-b588-28f87ebc7411",
"2d83e2f0-9a87-40a7-89cc-2f0689f52f18",
"e985ad1c-5bd1-4a29-b683-f1be109998ba",
"081bdfa8-0dfc-43d1-9b85-44cfe72892c1",
"79b5af36-6f76-4e35-af5c-ed3ca755b3ac",
"9c7d00f4-d307-42f3-b6d4-f95b2a6f7597",
"1053c44f-6f09-4a1e-b43f-f858fcb2441c",
"5f5005e9-6fe6-435a-992f-46440a803197",
"a9fe6c71-e1e1-423f-9811-5318c0b49688",
"f06e60ed-6d6d-44ef-b2f9-3ef57dc94cdf"
],
"pre_top_n": [
"8f49a32b-f425-425e-ac47-fbd1e1ab020f",
"8302cd44-569c-4109-83dd-87663d053200",
"fb63a999-5aa0-4912-8d06-400f852676cc",
"1476c46b-84ce-4151-b777-9dac8b533a8c",
"518e4b70-6742-43d2-bd39-90b3af026d89",
"9fa543b2-d5c9-47e1-8cba-ed1e1bfbfb86",
"5759f1c8-4974-4333-98c4-2ab0aec97918",
"77565fbe-27ea-43ee-bcb7-a218b073af9f",
"a9f67c53-f22d-4d72-be23-2ef95eb49443",
"ff866750-238b-4eb6-a9b7-7cacd8c6b9d0",
"8470bf3b-1760-4d4c-8755-387fcc5c92e8",
"293b9d4a-be83-4c72-b08e-02d6170ac405",
"d00b4e66-dfd1-45e8-af78-b643f59b5327",
"de6cbb4d-5225-4975-91f3-23b280492598",
"bb5bfca7-38d3-4639-83bd-da639ce2b4b5",
"d9a6079c-eb37-47e0-b588-28f87ebc7411",
"2d83e2f0-9a87-40a7-89cc-2f0689f52f18",
"e985ad1c-5bd1-4a29-b683-f1be109998ba",
"081bdfa8-0dfc-43d1-9b85-44cfe72892c1",
"79b5af36-6f76-4e35-af5c-ed3ca755b3ac",
"9c7d00f4-d307-42f3-b6d4-f95b2a6f7597",
"1053c44f-6f09-4a1e-b43f-f858fcb2441c",
"5f5005e9-6fe6-435a-992f-46440a803197",
"a9fe6c71-e1e1-423f-9811-5318c0b49688",
"f06e60ed-6d6d-44ef-b2f9-3ef57dc94cdf"
],
"seed_jobs_count": 120,
"status": "passed",
"top_n": 25,
"unknown_data_ratio": 0.25,
"unknown_fields": {
"analysis_features": 0,
"description_text": 30,
"employment_types": 0,
"raw_location": 0
}
},
"status": "passed",
"timestamp_utc": "2026-07-29T16:36:43.297692+00:00",
"top_n": 25
},
{
"dataset_version": "vr116-2026-07-21",
"dedupe": {
"case_reports": [
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-infra",
"expected_match_present": true,
"id": "D-001",
"is_expected_match": true,
"matched": true,
"matched_job_id": "b4c42d9c-22c7-4e89-b88d-b8734e2d7e07",
"query": "D-001"
},
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-network",
"expected_match_present": true,
"id": "D-002",
"is_expected_match": true,
"matched": true,
"matched_job_id": "537f63ab-1ea0-45fc-b356-e734a33a99be",
"query": "D-002"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-003",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-003"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-004",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-004"
}
],
"false_merges": 0,
"false_negative": 0,
"false_positive": 0,
"missed_merges": 0,
"precision": 1.0,
"recall": 1.0,
"seed_jobs": 2,
"status": "passed",
"true_negative": 2,
"true_positive": 2
},
"hardware": {
"cpu_count": 20,
"debug_mode": true,
"platform": "Linux-6.12.54-Unraid-x86_64-with-glibc2.41",
"python": "3.13.14",
"release": "6.12.54-Unraid"
},
"jobs_processed": 250,
"nfr_007": {
"measured": false,
"p95_ms": null,
"passed": false
},
"parser": {
"cases": [
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "jsonld-jobposting",
"expected_parser_version": "1.0.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_jsonld_job.html",
"id": "P-001",
"parser_key": "jsonld-jobposting",
"parser_version": "1.0.0",
"passed": true,
"warnings": []
},
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.1.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_generic_job.html",
"id": "P-002",
"parser_key": "generic-html",
"parser_version": "1.1.0",
"passed": true,
"warnings": []
}
],
"coverage_ratio": 1.0,
"covered_fields": 12,
"expected_fields": 12,
"status": "passed",
"unknown_data_ratio": 0.0
},
"performance": {
"avg_scores_detail_ms": 55.155,
"avg_scores_import_ms": 40.941,
"avg_scores_list_ms": 101.206,
"avg_scores_rescore_ms": 16.1,
"dashboard": {
"detail_p95_ms": 60.679,
"detail_query_count": 25,
"detail_query_types": {
"SELECT": 25
},
"list_p95_ms": 126.661,
"list_query_count": 76,
"list_query_types": {
"SELECT": 76
},
"timing_count": 50
},
"import": {
"duration_ms_total": 10235.21,
"p50_ms": 33.657,
"p95_ms": 68.212,
"query_count": 3264,
"query_types": {
"INSERT": 1003,
"RELEASE": 253,
"SAVEPOINT": 253,
"SELECT": 1256,
"UPDATE": 499
},
"timing_count": 250
},
"jobs": 1,
"performance": {
"memory_current_mb": 4.49,
"memory_peak_mb": 22.389,
"nfr_007": true,
"p50_ms": 34.958,
"p95_ms": 96.727,
"total_duration_ms": 14160.325
},
"query_count": 3370,
"rescore": {
"duration_ms_total": 16.1,
"p50_ms": 16.1,
"p95_ms": 16.1,
"query_count": 5,
"query_types": {
"INSERT": 1,
"SELECT": 4
},
"timing_count": 1
},
"source_run_id": 1,
"top_n": 25
},
"ranking": {
"churn_ratio": 0.0,
"post_top_n": [
"733b522b-382a-4a20-b046-3174b574a456",
"21473419-9a52-465d-90d0-da3dba7d87f4",
"e97f2b86-717e-4c9a-9d29-ba40f3198809",
"f9ee509b-fc89-4d81-8341-5b4ca48a51a1",
"f89072f9-b298-4751-b6d6-a3878e54ef18",
"f02c6911-d536-40d1-8d8d-3302392452e7",
"fb8f2233-2810-4e0e-9977-bebdcc230aef",
"8a71292f-bba7-4166-a7b8-714f9147f343",
"9cecf422-fd8e-4b40-9529-8e001af3df8b",
"80d26bff-3fae-4222-b6d8-25b47631a38b",
"d4475ebf-0969-4023-b6e2-e54df6dac7d8",
"0b33971f-c114-42a6-89d4-043055cce4d4",
"57c2ebf0-6a3c-4233-b1d4-f15bf3dfe087",
"bfe61184-136a-4f3f-8d12-4cee2109aa76",
"5bb9b9ea-1227-4bbf-b8e9-15de052ed584",
"9f361ba8-1e9e-4134-bda6-a7c78b6c4162",
"6b7b0dce-036d-475d-81f3-6ed8fcb98c5f",
"6c104672-11a2-4ee1-8200-f558d2d3eb0a",
"09b629be-5012-403f-b93f-b212d9f46b4c",
"46981d16-38d1-46a6-9755-c6d31b5adaf4",
"ae01edcc-fe42-4674-97ed-cdeec737ad9a",
"131a755e-4c6e-480c-81c4-6f2fe1afb069",
"7eba23c8-02ef-41a0-af54-d24689877f38",
"81cf6658-2035-4aba-97d9-4032085fba04",
"5a0b33ac-4b1e-454d-94b6-ddf5fc0945aa"
],
"pre_top_n": [
"733b522b-382a-4a20-b046-3174b574a456",
"21473419-9a52-465d-90d0-da3dba7d87f4",
"e97f2b86-717e-4c9a-9d29-ba40f3198809",
"f9ee509b-fc89-4d81-8341-5b4ca48a51a1",
"f89072f9-b298-4751-b6d6-a3878e54ef18",
"f02c6911-d536-40d1-8d8d-3302392452e7",
"fb8f2233-2810-4e0e-9977-bebdcc230aef",
"8a71292f-bba7-4166-a7b8-714f9147f343",
"9cecf422-fd8e-4b40-9529-8e001af3df8b",
"80d26bff-3fae-4222-b6d8-25b47631a38b",
"d4475ebf-0969-4023-b6e2-e54df6dac7d8",
"0b33971f-c114-42a6-89d4-043055cce4d4",
"57c2ebf0-6a3c-4233-b1d4-f15bf3dfe087",
"bfe61184-136a-4f3f-8d12-4cee2109aa76",
"5bb9b9ea-1227-4bbf-b8e9-15de052ed584",
"9f361ba8-1e9e-4134-bda6-a7c78b6c4162",
"6b7b0dce-036d-475d-81f3-6ed8fcb98c5f",
"6c104672-11a2-4ee1-8200-f558d2d3eb0a",
"09b629be-5012-403f-b93f-b212d9f46b4c",
"46981d16-38d1-46a6-9755-c6d31b5adaf4",
"ae01edcc-fe42-4674-97ed-cdeec737ad9a",
"131a755e-4c6e-480c-81c4-6f2fe1afb069",
"7eba23c8-02ef-41a0-af54-d24689877f38",
"81cf6658-2035-4aba-97d9-4032085fba04",
"5a0b33ac-4b1e-454d-94b6-ddf5fc0945aa"
],
"seed_jobs_count": 120,
"status": "passed",
"top_n": 25,
"unknown_data_ratio": 0.25,
"unknown_fields": {
"analysis_features": 0,
"description_text": 30,
"employment_types": 0,
"raw_location": 0
}
},
"status": "passed",
"timestamp_utc": "2026-07-29T16:37:02.017739+00:00",
"top_n": 25
},
{
"dataset_version": "vr116-2026-07-21",
"dedupe": {
"case_reports": [
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-infra",
"expected_match_present": true,
"id": "D-001",
"is_expected_match": true,
"matched": true,
"matched_job_id": "bcb6a67c-1945-4006-9f01-b332822877ee",
"query": "D-001"
},
{
"decision": "exact_external_id",
"decision_similarity": 1.0,
"expected_match": "seed-network",
"expected_match_present": true,
"id": "D-002",
"is_expected_match": true,
"matched": true,
"matched_job_id": "bc389f7a-f049-4daa-b01e-b6bab77c81c9",
"query": "D-002"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-003",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-003"
},
{
"decision": "new",
"decision_similarity": 0.0,
"expected_match": null,
"expected_match_present": false,
"id": "D-004",
"is_expected_match": false,
"matched": false,
"matched_job_id": null,
"query": "D-004"
}
],
"false_merges": 0,
"false_negative": 0,
"false_positive": 0,
"missed_merges": 0,
"precision": 1.0,
"recall": 1.0,
"seed_jobs": 2,
"status": "passed",
"true_negative": 2,
"true_positive": 2
},
"hardware": {
"cpu_count": 20,
"debug_mode": true,
"platform": "Linux-6.12.54-Unraid-x86_64-with-glibc2.41",
"python": "3.13.14",
"release": "6.12.54-Unraid"
},
"jobs_processed": 250,
"nfr_007": {
"measured": false,
"p95_ms": null,
"passed": false
},
"parser": {
"cases": [
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "jsonld-jobposting",
"expected_parser_version": "1.0.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_jsonld_job.html",
"id": "P-001",
"parser_key": "jsonld-jobposting",
"parser_version": "1.0.0",
"passed": true,
"warnings": []
},
{
"coverage_ratio": 1.0,
"covered_fields": 6,
"expected_fields": 6,
"expected_job_count": 1,
"expected_parser_key": "generic-html",
"expected_parser_version": "1.1.0",
"extracted": 1,
"fixture": "fixtures/pages/sample_generic_job.html",
"id": "P-002",
"parser_key": "generic-html",
"parser_version": "1.1.0",
"passed": true,
"warnings": []
}
],
"coverage_ratio": 1.0,
"covered_fields": 12,
"expected_fields": 12,
"status": "passed",
"unknown_data_ratio": 0.0
},
"performance": {
"avg_scores_detail_ms": 50.504,
"avg_scores_import_ms": 36.681,
"avg_scores_list_ms": 102.301,
"avg_scores_rescore_ms": 19.548,
"dashboard": {
"detail_p95_ms": 54.227,
"detail_query_count": 25,
"detail_query_types": {
"SELECT": 25
},
"list_p95_ms": 129.536,
"list_query_count": 76,
"list_query_types": {
"SELECT": 76
},
"timing_count": 50
},
"import": {
"duration_ms_total": 9170.368,
"p50_ms": 35.32,
"p95_ms": 49.306,
"query_count": 3264,
"query_types": {
"INSERT": 1003,
"RELEASE": 253,
"SAVEPOINT": 253,
"SELECT": 1256,
"UPDATE": 499
},
"timing_count": 250
},
"jobs": 1,
"performance": {
"memory_current_mb": 12.332,
"memory_peak_mb": 16.658,
"nfr_007": true,
"p50_ms": 36.098,
"p95_ms": 93.787,
"total_duration_ms": 13010.048
},
"query_count": 3370,
"rescore": {
"duration_ms_total": 19.548,
"p50_ms": 19.548,
"p95_ms": 19.548,
"query_count": 5,
"query_types": {
"INSERT": 1,
"SELECT": 4
},
"timing_count": 1
},
"source_run_id": 1,
"top_n": 25
},
"ranking": {
"churn_ratio": 0.0,
"post_top_n": [
"6758530f-71d8-4e3d-9732-7e1001eefd13",
"94176301-843f-4074-abdf-fd2c76b76d36",
"492a021b-a00d-47ff-b236-86ef6a88a2a6",
"c6227dbc-82c4-4c4b-8aec-99714d5adc53",
"523a1940-cabc-48b3-abab-bc74f18d4ee4",
"ca18c4cc-b796-4ebf-9591-c335bc072f57",
"c6a495ac-564a-4aee-a509-081100ddf0a8",
"0cf16e0f-5ffc-455b-b488-deb19b0e0fea",
"b906c635-a818-4db7-b639-dc6e745ef345",
"edce515a-e697-4925-b73f-5605b1236727",
"1f800e1f-ff44-4548-91c7-0e9f97af40e2",
"0d3caa61-dab1-49d1-8af3-21cb605e9b41",
"e6f0d2ea-6dfc-4559-8aa9-80799402e8f1",
"a5d682ee-6fac-4ae9-921d-7e883db86d49",
"543f63fe-f7fd-4302-9414-95536f5fabcc",
"b97df085-3019-45cb-aee6-66edb6602df9",
"733c02de-73dc-4967-9e34-c62dcdd98e4f",
"f0178164-2535-4e02-91c7-7a6920ab6df3",
"4bb41af9-5387-4d0c-a62b-37f7ff713dfe",
"ea01b4ee-5a92-4df2-8896-bd391b5c661b",
"f08116ab-fa5d-45e7-a579-d9c2cad0d63f",
"3c4feccb-a4d1-4cd1-b814-1a2d8bfd9da1",
"69088d9a-0de5-4bbe-b8bf-abd733838ed6",
"e567d05d-8ed4-4f0f-80fb-1e6f0f77f377",
"9f03de2f-4c1e-4cc5-9d65-bbcd3b42a029"
],
"pre_top_n": [
"6758530f-71d8-4e3d-9732-7e1001eefd13",
"94176301-843f-4074-abdf-fd2c76b76d36",
"492a021b-a00d-47ff-b236-86ef6a88a2a6",
"c6227dbc-82c4-4c4b-8aec-99714d5adc53",
"523a1940-cabc-48b3-abab-bc74f18d4ee4",
"ca18c4cc-b796-4ebf-9591-c335bc072f57",
"c6a495ac-564a-4aee-a509-081100ddf0a8",
"0cf16e0f-5ffc-455b-b488-deb19b0e0fea",
"b906c635-a818-4db7-b639-dc6e745ef345",
"edce515a-e697-4925-b73f-5605b1236727",
"1f800e1f-ff44-4548-91c7-0e9f97af40e2",
"0d3caa61-dab1-49d1-8af3-21cb605e9b41",
"e6f0d2ea-6dfc-4559-8aa9-80799402e8f1",
"a5d682ee-6fac-4ae9-921d-7e883db86d49",
"543f63fe-f7fd-4302-9414-95536f5fabcc",
"b97df085-3019-45cb-aee6-66edb6602df9",
"733c02de-73dc-4967-9e34-c62dcdd98e4f",
"f0178164-2535-4e02-91c7-7a6920ab6df3",
"4bb41af9-5387-4d0c-a62b-37f7ff713dfe",
"ea01b4ee-5a92-4df2-8896-bd391b5c661b",
"f08116ab-fa5d-45e7-a579-d9c2cad0d63f",
"3c4feccb-a4d1-4cd1-b814-1a2d8bfd9da1",
"69088d9a-0de5-4bbe-b8bf-abd733838ed6",
"e567d05d-8ed4-4f0f-80fb-1e6f0f77f377",
"9f03de2f-4c1e-4cc5-9d65-bbcd3b42a029"
],
"seed_jobs_count": 120,
"status": "passed",
"top_n": 25,
"unknown_data_ratio": 0.25,
"unknown_fields": {
"analysis_features": 0,
"description_text": 30,
"employment_types": 0,
"raw_location": 0
}
},
"status": "passed",
"timestamp_utc": "2026-07-29T16:37:19.113439+00:00",
"top_n": 25
}
],
"schema_version": "1.0",
"status": "passed",
"summary": {
"duration_ms_mean": 9040.123,
"memory_peak_mb_mean": 18.799,
"p50_ms_mean": 32.9,
"p95_ms_mean": 52.296,
"query_count_mean": 3264,
"query_count_total_mean": 3370,
"throughput_per_second": 28.031
},
"warmup_status": "passed"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

+148 -14
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.17").strip() or "0.3.17"
LANGUAGE_CODE = "nl-be"
USE_I18N = True
USE_TZ = True
@@ -83,6 +159,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
"apps.core.middleware.MigrationMaintenanceMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
@@ -91,6 +168,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",
]
@@ -190,6 +268,30 @@ LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "dashboard:today"
LOGOUT_REDIRECT_URL = "login"
# Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback.
AUTHENTICATION_BACKENDS = [
"apps.core.auth_backends.EmailOrUsernameModelBackend",
"django.contrib.auth.backends.ModelBackend",
]
PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600)))
# Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd.
ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False)
ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "")
ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "")
ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "")
ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
# Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet.
# Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie.
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", DEBUG)
DEMO_READ_ONLY = env_bool("DEMO_READ_ONLY", True)
DEMO_SESSION_SECONDS = env_int_range("DEMO_SESSION_SECONDS", 3600, minimum=300, maximum=86400)
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
EMAIL_HOST = os.getenv("SMTP_HOST", "")
@@ -197,36 +299,61 @@ 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)
CELERY_TASK_EAGER_PROPAGATES = True
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_DEFAULT_QUEUE = "default"
CELERY_TASK_TIME_LIMIT = 300
CELERY_TASK_SOFT_TIME_LIMIT = 270
CELERY_TIMEZONE = TIME_ZONE
@@ -249,6 +376,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,
@@ -334,6 +465,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 = {
+39 -1
View File
@@ -5,10 +5,23 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from django.views.generic import RedirectView
from apps.core.views import SecurityAwareLoginView
from apps.core.views import (
SecurityAwareLoginView,
demo_login,
entra_callback,
entra_login,
robots_txt,
)
urlpatterns = [
path(
"favicon.ico",
RedirectView.as_view(url=f"{settings.STATIC_URL}favicon.svg", permanent=True),
name="favicon",
),
path("robots.txt", robots_txt, name="robots-txt"),
path("admin/", admin.site.urls),
path(
"login/",
@@ -16,6 +29,31 @@ urlpatterns = [
name="login",
),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
# Wachtwoordherstel (Django's ingebouwde flow, eigen templates).
path(
"wachtwoord/herstellen/",
auth_views.PasswordResetView.as_view(),
name="password_reset",
),
path(
"wachtwoord/herstellen/verzonden/",
auth_views.PasswordResetDoneView.as_view(),
name="password_reset_done",
),
path(
"wachtwoord/herstellen/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(),
name="password_reset_confirm",
),
path(
"wachtwoord/herstellen/klaar/",
auth_views.PasswordResetCompleteView.as_view(),
name="password_reset_complete",
),
# Entra ID SSO en demo-toegang.
path("auth/entra/login/", entra_login, name="entra-login"),
path("auth/entra/callback/", entra_callback, name="entra-callback"),
path("auth/demo/", demo_login, name="demo-login"),
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
+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.17
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`. 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.
+58
View File
@@ -0,0 +1,58 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/tmp/supervisord.pid
[program:postgres]
command=/usr/lib/postgresql/17/bin/postgres -D /app/local/postgres-aio17
user=postgres
priority=10
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:redis]
command=/usr/bin/redis-server --dir /app/local/redis-aio --save 60 1 --loglevel warning --protected-mode yes --bind 127.0.0.1
user=redis
priority=20
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:web]
command=/app/.venv/bin/gunicorn config.wsgi:application --bind 0.0.0.0:8080 --workers 2 --threads 4 --timeout 90 --no-control-socket
directory=/app
user=app
priority=40
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:worker]
command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py worker
directory=/app
user=app
priority=50
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:scheduler]
command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py scheduler
directory=/app
user=app
priority=60
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+35
View File
@@ -0,0 +1,35 @@
# Append after docker-compose.unraid.yml. Operator-only migration prerequisite;
# every path must be a verified cold clone, never the original live data.
# Also pass MIGRATION_ENV_FILE as Compose --env-file for interpolation parity.
name: vacatureradar-managed
services:
app:
network_mode: ${MIGRATION_NETWORK:?exact original network required}
labels:
io.itworx.migration-attempt: ${MIGRATION_ATTEMPT:?journaled migration attempt required}
image: ${MIGRATION_IMAGE:?set an attempt-specific candidate image}
env_file:
- ${VACATURERADAR_ENV_FILE:?set the protected external runtime env file}
build:
labels:
org.opencontainers.image.source: jens/vacatureradar
org.opencontainers.image.revision: ${MIGRATION_SOURCE_REVISION:?full verified Git commit required}
org.opencontainers.image.source-tree: ${MIGRATION_SOURCE_TREE:?verified Git tree required}
org.opencontainers.image.version: ${MIGRATION_BUILD_ID:?broker build ID required}
org.opencontainers.image.created: ${MIGRATION_BUILD_DATE:?broker UTC build date required}
volumes:
- type: bind
source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}/media
target: /app/media
bind:
create_host_path: false
- type: bind
source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}/logs
target: /app/logs
bind:
create_host_path: false
- type: bind
source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}
target: /app/local
bind:
create_host_path: false
+42 -69
View File
@@ -1,76 +1,49 @@
# Compose-bron van waarheid met paden voor een Unraid-installatie.
# Lees deployment/unraid/README.md vóór gebruik en wijzig secrets.
# 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
x-app: &app
image: ghcr.io/CHANGE_ME/vacatureradar:latest
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=128m
- /var/tmp:rw,noexec,nosuid,size=128m
- /app/.cache:rw,noexec,nosuid,size=128m
env_file:
- /mnt/user/appdata/vacatureradar/config/.env
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
- /mnt/user/appdata/vacatureradar/media:/app/media
- /mnt/user/appdata/vacatureradar/logs:/app/logs
- /mnt/user/appdata/vacatureradar/local:/app/local
networks: [internal]
depends_on:
postgres: {condition: service_healthy}
redis: {condition: service_healthy}
services:
web:
<<: *app
ports: ["1226:8080"]
worker:
<<: *app
app:
build:
context: .
dockerfile: Dockerfile.unraid
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
container_name: VacatureRadar
env_file:
- ${VACATURERADAR_ENV_FILE:-.env}
environment:
VACATURERADAR_RUN_MIGRATIONS: "0"
cap_drop:
- ALL
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
scheduler:
<<: *app
environment:
VACATURERADAR_RUN_MIGRATIONS: "0"
cap_drop:
- ALL
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
postgres:
image: postgres:17.5-bookworm
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: "5432"
REDIS_URL: redis://127.0.0.1:6379/0
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
env_file: [/mnt/user/appdata/vacatureradar/config/.env]
volumes: [/mnt/user/appdata/vacatureradar/postgres:/var/lib/postgresql/data]
networks: [internal]
security_opt:
- no-new-privileges:true
stop_grace_period: 30s
ports:
- "${VACATURERADAR_HOST_PORT:-1226}:8080"
volumes:
- ./local/media:/app/media
- ./local/logs:/app/logs
- ./local:/app/local
labels:
net.unraid.docker.managed: dockerman
net.unraid.docker.webui: http://[IP]:[PORT:8080]/
# Dockerman downloadt iconen server-side zonder [IP]/[PORT]-substitutie.
net.unraid.docker.icon: http://127.0.0.1:1226/static/img/vacatureradar-docker.png
org.opencontainers.image.title: VacatureRadar
org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt
healthcheck:
test: ["CMD-SHELL", "pg_isready -U vacatureradar -d vacatureradar"]
interval: 10s
test: ["CMD", "python", "scripts/healthcheck.py", "http://127.0.0.1:8080/health/ready/"]
interval: 30s
timeout: 5s
retries: 10
redis:
image: redis:7.4.2-alpine
restart: unless-stopped
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumes: [/mnt/user/appdata/vacatureradar/redis:/data]
networks: [internal]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
ollama:
image: ollama/ollama:0.9.6
profiles: ["ai"]
restart: unless-stopped
volumes: [/mnt/user/appdata/vacatureradar/ollama:/root/.ollama]
networks: [internal]
networks:
internal:
driver: bridge
retries: 5
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"]
+40
View File
@@ -0,0 +1,40 @@
# Gecontroleerde migratie van legacy deployments
`docker-compose.migration.yml` is een optionele operatoroverride, geen wijziging
aan de standaarddeployment. Gebruik deze alleen na de expliciete migratieprocedure
in ProjectBrain `scripts/legacy-deployment-migration/`.
De operator valideert de exacte Git-revisie en imageprovenance, stopt de oude
container en maakt een gecontroleerde koude kopie van de volledige `local`-map.
`MIGRATION_LOCAL_ROOT` verwijst uitsluitend naar die kopie. Media, logs en de
PostgreSQL-data houden hun geneste mountrelatie. De originele data, image en
container blijven bewaard voor rollback; startupmigraties mogen ze niet wijzigen.
Alle migratievariabelen zijn verplicht, paden worden niet automatisch aangemaakt,
en een attemptlabel bindt de nieuwe container aan precies één hersteltransactie.
De operator controleert gemergde Compose-mounts, gezondheid, HTTP en de echte
brokerreceipt voordat de configuratie definitief wordt overgezet.
De override is offline getest met echte Compose-rendering en ontbrekende
invoervariabelen. Toevoeging van dit bestand bewijst geen uitgevoerde productiemigratie.
# Outbound worker hold
Before starting a migration candidate, create `.migration-worker-hold` in its
cloned `/app/local` directory. The supervised Celery worker and scheduler wait
without consuming tasks until the operator removes that exact attempt-owned
file after the deployment commit. Normal startup is unchanged when it is absent.
Do not put the marker into the original data. This is a startup gate, not a
control for pausing an already-running worker. Recovery before commit retains
the hold and candidate data; recovery after commit resumes activation and must
never revert to stale original data after outbound work has been released.
The first Django middleware also returns 503 (no-store) for every ordinary
request while held, including GET requests. Only exact GET/HEAD requests to
`/health/ready/` and `/health/live/` pass. Marker inspection errors fail closed.
The migration helper creates a nonce-bound marker only in the cold clone and
removes it with directory fsync after a durable `committed` journal. Recovery
after that boundary may resume activation but can never restore old data.
`MIGRATION_NETWORK` is required and must equal the inspected existing
`vacatureradar_default` network. The managed Compose project does not move the
application to a newly-created network.
+9
View File
@@ -0,0 +1,9 @@
# Repository sanitation status
The accidental deployment log containing live root-SSH/container output was removed from the current tree. Active Unraid examples now use a neutral local hostname and generic appdata/backup paths rather than the operator's private host and storage layout.
## HISTORY_REWRITE_REQUIRED
Earlier commits contain the removed `deploy-log.txt` object, including private deployment commands, host information and runtime/database output. Earlier `.env.unraid.example`, `BACKUP_RESTORE.md` and `DEPLOYMENT.md` revisions also contain the original private host and appdata/backup paths.
Before public review, scan all refs for credentials, SSH targets, personal applicant/job data, production databases, media, backups, logs, archives and large objects. Review fixtures and screenshots for names, contact details, application history and other personal data. No history was rewritten during this campaign.
+1019 -12
View File
File diff suppressed because it is too large Load Diff
+381 -15
View File
@@ -1,7 +1,19 @@
# Projectstatus
- Laatst bijgewerkt: 2026-07-22
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
## Gecontroleerde migratie — 2026-09-09
VR-232 voegt een startup-hold voor Celery worker/beat toe tijdens de expliciet
goedgekeurde legacy-migratie. Alleen de kandidaatdatakopie krijgt een marker;
uitgaand werk wordt pas na een geverifieerde deploymentcommit vrijgegeven.
De eerste middleware blokkeert ook gewone webverzoeken tot commit; alleen exacte
GET/HEAD-healthroutes blijven beschikbaar. De netwerkoverride behoudt het bestaande
netwerk. Lokaal slagen 344 tests inclusief browsercontroles (84,07% coverage).
De volledige `scripts/codex_verify.sh`-gate is groen: Ruff, Django-checks,
migratiecontrole, tests, taakledger en repositoryvalidatie zijn geslaagd.
Live migratie is niet uitgevoerd. Bestaande productietaken zijn niet gewijzigd.
- Laatst bijgewerkt: 2026-08-12
- Repositoryversie: 0.3.17 immutable releaseherstel
- Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -14,11 +26,310 @@ De repository bevat een uitvoerbare Django-MVP met:
- JSON-LD-, HTML-, RSS/Atom- en e-mailadapters;
- normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies;
- deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary;
- responsive dark/light cockpitdashboard, vacaturelijst/detail, sollicitatiepipeline, profiel-, bron- en systeemschermen;
- volledige responsive dark/light Stitch-cockpit met negen functionele hoofdschermen, skillsradar, login en foutstates;
- feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox;
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
- offline unit-, integratie- en securitytests.
## Laatste documentatie-update
Visuele demo-hardening `VR-229` op 2026-08-12:
- iedere publieke demosessie herstelt idempotent zes actuele fictieve vacatures,
de laatste scores, twee toekomstige opvolgmomenten en negen geïsoleerde
`example.test`-platformmailboxkoppelingen;
- mailboxbronnen worden niet meer door de URL-fetchscheduler gepland, de
publieke demo wordt uitgesloten van echte mailboxpolling en historische
mailboxpolicyfouten vervuilen dashboard en activiteit niet langer;
- navigatie en dashboard onderscheiden nu expliciet een actief zoekprofiel van
bron- of systeemgezondheid; het lege loginwachtwoordveld heeft geen misleidende
mask-placeholder meer;
- visuele audit: negen kernroutes groen in dark/light op 390×844, 1280×600,
1440×900 en 3440×1440 zonder overflow, heading- of consolefouten;
- gerichte regressies: 51 tests groen; volledige gate: 327 tests, 0 failures,
0 skips en 84,04% branch-aware dekking;
- commit `a6c8a79` en tag `v0.3.16` zijn naar Gitea gepusht; vóór uitrol is onder
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.16-20260812T094608Z`
een checksum-geldige database- en configuratieback-up gemaakt;
- Unraid draait healthy zonder restarts op lokaal gebouwde image
`sha256:c98001c063e8358dc59dfc175c574187b36021dd5a81f44e3ba982a28c9f9341`;
een live sessiesmoke bevestigde dashboard 200, zes demomatches, negen
platformmailboxen, actieve ingress, nul oude policyfouten en geen
wachtwoord-maskplaceholder;
- `VR-230` herstelde de Gitea-releaseworkflow door `setup-uv` op immutable commit
`0880764` en uv op 0.11.12 te pinnen; de Astral-mirror verwijdert de eerdere
GitHub Releases-API-rate-limit;
- vier lege, verweesde Docker-netwerken zijn gecontroleerd verwijderd nadat de
runner geen subnet meer kon reserveren; actieve productie- en compose-netwerken
bleven onaangeroerd;
- Gitea Actions-run 3062 publiceerde 0.3.17 met digest
`sha256:98f4b33db353c192967013818f7c82c4f171921f3d300645908d52680fde209f`
en artifact `release-evidence-v0.3.17` met SPDX-SBOM, checksums en release notes;
- een dedicated `read:package`-token geeft Unraid alleen pulltoegang. De
pre-deployback-up staat onder
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.17-20260812T114510Z`;
- Unraid is live op de immutable digest gepind: container healthy, nul restarts,
interne liveness/readiness groen, publieke readiness 200 en runtimeversie 0.3.17.
- `VR-231` verwijderde de oude `main`-push-to-deployworkflow en annuleerde de
laatste onbeperkt wachtende run 3065; alleen semantische tags publiceren nog;
- Jobat is via de bestaande OAuth/IMAP-taak geforceerd herhaald: 61 duplicaten
correct herkend, nul fouten en de foutstatus volledig gewist;
- Unraid gebruikt persistent `10.200.0.0/16,size=24`; een live proefnetwerk kreeg
`10.200.1.0/24` en werd daarna verwijderd. Na de gecontroleerde Docker-herstart
zijn alle 127 vooraf actieve containers hersteld; VacatureRadar, Grimmory en
MeTube zijn healthy en er zijn geen unhealthy of herstartende containers;
- serverback-ups bewaren de eerdere `docker.cfg`, de running-containersnapshot,
de Grimmory-template en de Gitea-database vóór de begrensde runannulering.
Volgende uitvoerbare backlogtaak: geen.
README-update op 2026-07-30:
- de repository-ingang benoemt release 0.3.15, de groene 324-testgate, de live
M365 app-only OAuth/XOAUTH2-flow en de immutable private Gitea-registryrelease;
- eerste configuratie noemt alle negen ondersteunde jobplatformen en verwijst
expliciet naar het M365 least-privilege- en rotatiepad in het runbook;
- de bestaande negen screenshots zijn visueel herbeoordeeld. De layout is nog
actueel; de bronafbeelding toont bewust een gesaneerde demo zonder actieve
ingress en is daarom als demo-momentopname verduidelijkt, niet vervangen door
een productiebeeld met accountgegevens;
- eindverificatie: volledige gate groen met 324 tests, 0 failures, 0 skips en
84,07% branch-aware dekking.
## 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.
Productieoplevering 0.3.15 op 2026-07-30:
- `VR-202` is live afgerond met een dedicated alias, geïsoleerde Exchange-map en
inboxregel, app-only OAuth/XOAUTH2 voor IMAP en SMTP en negen platformconnecties;
- dezelfde echte testmail werd per connectie eenmaal geïmporteerd en bij herhaling
als duplicate herkend; alle ingestelde intervallen zetten `next_poll_at` correct
en de scheduler plande geen connectie te vroeg;
- de productie-Fernet-sleutel werd live naar een nieuwe primaire MultiFernet-sleutel
geroteerd; een tijdelijke versleutelde custom credential bleef ontsleutelbaar en
is na de proef verwijderd;
- één echte digest werd verzonden en de outbox eindigde op `sent`; productie-logs
bevatten geen OAuth-secret, bearer-token of mailpayload;
- `VR-204` publiceerde `v0.3.15` naar de private Gitea-registry en Unraid is gepind
op `sha256:5a6dc253db7a60c42c0c806ae084ddc6ee7c98db5db71a857423b894a3662ba5`;
- commitlabel, digest, SPDX-SBOM, checksums en release notes zijn checksum-geldig
opgeslagen als Gitea Actions-artifact `release-evidence-v0.3.15`;
- pull, interne en publieke readiness, rollback naar `0.3.14` en herstel naar de
immutable `0.3.15`-digest zijn live geslaagd;
- lokale en Gitea-releasegate zijn groen met 324 tests, 0 failures, 0 skips en
84,05% branch-aware dekking.
Volgende uitvoerbare taak: geen; alle P0- en P1-releasepunten zijn afgerond.
## 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` is volledig afgerond; Basic Auth is 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:
- één nieuwe lokale Stitch-shell en design system zonder CDN of clientframework;
- echte dashboard-/vacature-intelligence, sollicitatiepipeline, profielconfigurator en source-healthinterface;
- nieuwe activiteit- en werkgeversroutes/read models en een eerlijke automationcockpit;
- standaard IT-relevantie, persoonlijke scorecontext en user-scoped mutaties blijven intact;
- browsermatrix van 360 tot 2560 px, elf actuele screenshots, schone console en geen documentoverflow;
- bronhealth-N+1 verwijderd: twee queries voor alle bron/runmetingen, 23 totale queries voor het representatieve automationscherm.
Het volledige scherm- en afwijkingsrapport staat in `docs/design/STITCH_INTEGRATION_REPORT.md`.
## Laatste onderhoudsaudit
VR-226 op 2026-07-28:
- README-presentatie verdiept van vier losse beelden naar een klikbare productreis met negen actuele screenshots: overzicht, zoeken, analyse, skills, werkgevers, sollicitaties, bronnen, automatisering en mobiel profiel;
- alle desktopbeelden opnieuw vastgelegd op 1920×1080, doelbewust verdeeld over dark/light en gecombineerd met 390×844 mobiel; geen overgangsoverlays, horizontale overflow, secrets of consolewaarschuwingen;
- de onrustige mobiele dashboardcapture is afgekeurd en verwijderd; de volledige galerij blijft lokaal en samen circa 1,1 MB;
- vier aangetroffen VR-225-lintproblemen en de ongeldige POSIX-modusassertie op Windows zijn hersteld; volledige gate groen met 296 geslaagde tests, 2 optionele skips en 83,44% branch-aware dekking. Volgende uitvoerbare taak: geen.
VR-224 op 2026-07-27:
- README volledig herordend rond doelgroep, dagelijkse workflow, concrete productwaarde, veilige quickstart en eerste configuratie; architectuur, beheer en Codex-overdracht volgen pas daarna;
- vier actuele lokale screenshots toegevoegd voor dagoverzicht, vacatureverkenner, automatisering en mobiel zoekprofiel, samen minder dan 330 kB en zonder secrets of persoonlijke gegevens;
- browsercontrole op 1600×1000 en 390×844 in dark/light toont geen horizontale overflow of consolewaarschuwingen; repositorylinks en configuratiedocumentatie valideren;
- volledige gate groen met 268 geslaagde tests, 2 optionele skips en 83,48% branch-aware dekking. Volgende uitvoerbare taak: geen.
VR-223 op 2026-07-27:
- VacatureRadar-logo verfijnd tot een transparante vier-elementenradar zonder donkere tegel, rand of schaduw; login en sidebar delen dezelfde schaalbare SVG;
- dubbele demo-/accountidentiteit uit de sidebar verwijderd; radarstatus en een themabewuste transparante ITWorx-credit vormen één compacte footer, terwijl accountmenu en afmelden in de topbar intact blijven;
- widescreenframe groeit naar 2.200 px en op 4K naar 2.480 px; browsermatrix van 18 shellcombinaties op 390/1280/1920/2560/3840 px en 1280×600 toont geen overlap, overflow of consolewaarschuwing;
- dark/light en mobiele login bevestigen een transparante logoachtergrond, correcte wordmarkbehandeling en intacte kerninteracties.
- gerichte regressies: 30 tests groen; volledige gate tweemaal groen met 268 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- live image `sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921` healthy uitgerold; readiness, favicon, transparant productlogo en CSS 0.3.11 leveren HTTP 200. Volgende uitvoerbare taak: geen.
VR-222 op 2026-07-26:
- integrale visuele audit van tien hoofdroutes plus login/herstel vastgelegd in `docs/design/VISUAL_AUDIT_2026-07-26.md`, met geprioriteerde bevindingen, gevolgen en uitgevoerde roadmap;
- topbar en hoofdcontent delen één begrensd contentframe; het dagoverzicht toont maximaal vijf kernmatches en daalde op 1440 px van 2.399 naar 1.838 px;
- activiteitstijdlijn, ontwikkelaarscredit, secundaire klikdoelen en reduced-motion zijn verfijnd; CSS-imports gebruiken een gedeelde cacheversie zodat een release werkelijk alle stijllagen ververst;
- mobiele loginbotsing, foutieve oog-/demo-iconen en dubbele herstel-link zijn opgelost; VacatureRadar blijft productmerk en ITWorx.tech uitsluitend ontwikkelaarscredit;
- browsercontrole: 50 combinaties over tien routes en 390/768/1280/1920/3840 px, plus light-theme en login, zonder overflow of consolewaarschuwingen;
- volledige gate tweemaal groen met 268 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking; live image `sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70` healthy uitgerold en readiness, favicon, productlogo, ontwikkelaarswordmark plus CSS 0.3.9 leveren HTTP 200. Volgende uitvoerbare taak: geen.
VR-221 op 2026-07-26:
- merkarchitectuur gecorrigeerd: ITWorx.tech is de ontwikkelaar, VacatureRadar het zelfstandige productmerk;
- twee logo-concepten zijn met de ingebouwde beeldgeneratieflow verkend; de eenvoudigste radar-/kansrichting is handmatig vertaald naar een exact vijf-elementen-SVG met open cyan radarboog, scanstraal en emerald kanssignaal;
- sidebar, bovenbalk, login, favicon en Dockerman gebruiken het VacatureRadar-logo; de officiële ITWorx.tech-wordmark staat alleen als `Ontwikkeld door`-credit in secundaire zones;
- appmetadata heet opnieuw uitsluitend `VacatureRadar`; het ongebruikte ITWorx-appicoon is verwijderd en bron-/assetdocumentatie is bijgewerkt;
- browsermatrix op 390, 1280 en 1920 px toont correcte hiërarchie, geen overflow en een schone console; 29 gerichte regressies en de volledige gate zijn groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- live image `sha256:4184aeff33759b7a205637b0a0333b751b599141fe691a61965d2f1c56ca984a` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en favicon, productlogo, Dockermanicoon plus ITWorx-ontwikkelaarscredit live gevalideerd. Volgende uitvoerbare taak: geen.
VR-220 op 2026-07-26:
- “eigen branding” is gecorrigeerd naar de door de eigenaar aangeleverde officiële ITWorx.tech-identiteit in plaats van een tijdelijke tekstuele Jens-signatuur;
- sidebar, bovenbalk, login en wachtwoordherstel gebruiken de transparante opgeschoonde ITWorx.tech-wordmark; VacatureRadar blijft herkenbaar als product en intelligence cockpit;
- favicon/app-icon (512×512) en Dockermanicoon (256×256) zijn exact uit het officiële cloud-check-symbool gecropt en Lanczos geschaald, zonder generatieve wijziging;
- merkassets zijn lokaal, transparant, geoptimaliseerd en met herkomst vastgelegd in `static/img/README.md`;
- browsercontrole op 390, 1280 en 1920 px toont correcte assets, geen overflow en een schone console; 29 gerichte regressies en de volledige gate zijn groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- de deployhelper negeert nu veilig verwijderde indexpaden; de twee vervangen SVG-assets zijn expliciet uit de gevalideerde remote sourceboom verwijderd en de uiteindelijke image bevat 147 in plaats van 149 static files;
- live image `sha256:daf34407813b37cdf2d0fc65a482ded65375f89eedd3f0144a8c0ce206d41565` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en wordmark-, app-icon- en Dockermanassets leveren `image/png` met HTTP 200. Volgende uitvoerbare taak: geen.
VR-219 op 2026-07-26:
- de door gebruikersscreenshots aangetoonde ultrawide-uitlijningsfout is hersteld: directe hoofdsecties zijn niet langer afzonderlijk gecentreerd op 1860 px, maar delen één links verankerd grid tot 2800 px;
- op 3840 px beginnen heading, filters, explorer, systeemstatus, pipeline, KPI's en automationdetail op x=296; dezelfde gridbreedte schaalt zonder overflow naar 1920, 1280 en 390 px;
- de resultatenkolom en automationlograil hebben begrensde ultrawide-verhoudingen zodat primaire inhoud niet onnodig uitrekt;
- sidebar, bovenbalk, login en herstelpagina's gebruiken consistent `Personal intelligence by Jens` en `Jens Intelligence`, los van de accountgebruikersnaam;
- browsermatrix schoon zonder consolewaarschuwingen; gerichte regressies 23 tests groen en volledige gate groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
- live image `sha256:86836f82cc543a7adb414e47b9667e9123bb9ceba10d629a53086273aaaae03f` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en de nieuwe branding plus 2800px-grid-CSS live gevalideerd. Volgende uitvoerbare taak: geen.
VR-218 op 2026-07-26:
- favicon, login en applicatieshell delen één verfijnd lokaal radarbeeldmerk met een sterker silhouet op klein formaat;
- de persoonlijke signatuur `Jens · private intelligence` is zichtbaar op publieke en ingelogde schermen en via `VACATURERADAR_OWNER_NAME` configureerbaar;
- de desktopbovenbalk toont eigenaar plus actuele routecontext; navigatie- en zoekfocus zijn verfijnd en de aanvullende context verdwijnt doelbewust op mobiel;
- gerichte regressies: Ruff groen, 29 tests geslaagd en 2 optionele skips; browsercontrole van dashboard, verkenner, profiel en login op 1280×850 en 390×844 zonder documentoverflow of consolewaarschuwingen;
- live image `sha256:5f25f00c9b7efb166587901ff39b02c56c98fb9946ddef0a4546a22f18dc0802` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en persoonlijke loginbranding plus SVG-favicon live gevalideerd;
- volledige gate groen met 266 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking; volgende uitvoerbare taak: geen, alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
VR-217 op 2026-07-26:
- de vermeende Unraid-authenticatiefout bleek een clientprofielkeuze: rechtstreeks IP gebruikte gebruiker `jens`, terwijl `unraid-itworx` correct `root`, `IdentitiesOnly` en de afzonderlijke deploysleutel selecteert;
- `scripts/deploy_unraid.sh` valideert de hostalias, synchroniseert uitsluitend niet-genegeerde projectbestanden en combineert build, containerhealth en readiness-smoke;
- het zoekprofiel heeft een lokale interactieve SVG-radar met scansweep, signalen, pointerparallax en statische reduced-motionvariant zonder fictieve data;
- browsercontrole op 1280×800 en 390×844 in licht en donker toont geen documentoverflow of consolefouten; de illustratie blijft decoratief en staat mobiel na de primaire actie;
- volledige gate groen met 265 tests, 2 optionele skips en 83,44% branch-aware dekking;
- live image `sha256:4d30e01e778777d5a2cde4339a9b2ad006edb25cae387293737420152d65069d` gebouwd en via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en de nieuwe CSS-/JS-assets via LAN HTTP 200;
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
VR-216 op 2026-07-26:
- premium “calm intelligence”-laag toegevoegd met zachtere scheidingen, spaarzamer cyan, humane navigatietypografie en duidelijkere elevatie in donker en licht thema;
- dashboard-KPI's staan mobiel compact 2×2, filters vormen een responsieve commandobar, pipelinekolommen scrollen intern en het profiel gebruikt een brede compositie;
- de browsermatrix controleerde 1280×800 en 390×844 zonder documentoverflow; dashboard, verkenner, pipeline, profiel en beide thema's zijn visueel beoordeeld;
- een bestaande CSP-consolefout op login is opgelost door de interactie naar een lokale externe JavaScriptasset te verplaatsen en regressiegedekt;
- gerichte verificatie: Ruff groen, 22 tests geslaagd en 2 optionele Playwright-skips; volledige gate groen met 264 tests, 2 optionele skips en 83,44% branch-aware dekking;
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
VR-215 op 2026-07-26:
- Dockermans eigen implementatie bleek `[IP]`/`[PORT]` uitsluitend voor WebUI-links
te vervangen en niet vóór een icoondownload; het eerdere label kon daardoor de
cache niet vullen;
- het live icoonlabel gebruikt nu de server-side loopback-URL op hostpoort 1226;
- de Unraid-host bevat zowel de persistente als actuele RAM-cache
`VacatureRadar-icon.png`, en Dockermans gegenereerde `docker.json` verwijst naar
`/state/plugins/dynamix.docker.manager/images/VacatureRadar-icon.png`;
- de gerecreate container is healthy en de PNG-/readiness-smokes slagen;
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en
uitgestelde productkeuzes resteren.
VR-214 op 2026-07-25:
- het ontbrekende Unraid/Dockerman-tegelicoon is hersteld met een lokale 256×256 PNG op
`/static/img/vacatureradar-docker.png`; de browserfavicon blijft de bestaande SVG;
- de volledige WebUI- en icoonlabels, PNG-signatuur en afmetingen zijn regressiegedekt;
- `collectstatic` kopieert de asset naar de productie-staticroot;
- de volledige `scripts/codex_verify.sh`-gate slaagde via Git Bash met 264 tests,
2 optionele Playwright-skips en 83,47% branch-aware codedekking;
- de Unraid-image is daarna live gebouwd als
`sha256:799825df1a343b6bee43695850454a5fdc977b5af3366077e6ac6aaeeb94f6f9`;
de gerecreate container was healthy, liveness/readiness slaagden en het
Dockerman-label wees naar de lokale PNG, die via LAN HTTP 200 met
`Content-Type: image/png` en 7.111 bytes leverde;
- er zijn tijdens de volledige lint-, systeem-, migratie-, test-, ledger- en
repositorycontrole geen andere releaseblokkerende regressies gevonden;
- volgende uitvoerbare taak: geen; alleen de bestaande externe blokkades en
uitgestelde productkeuzes resteren.
VR-209213-verificatie op 2026-07-22:
- 248 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,40% branch-aware codedekking;
- Ruff, Django system check, migratiecheck, backlogvalidatie en repository-/documentvalidatie geslaagd via `scripts/codex_verify.sh`;
- alle negen routes in de geïntegreerde browser gecontroleerd zonder consolewaarschuwingen, kapotte assets of documentoverflow;
- browsermatrix: 360×800, 390×844, 768×1024, 1024×768, 1280×800, 1440×900, 1920×1080 en 2560×1440;
- elf actuele beelden onder `artifacts/visual-validation/`; artifacts en Stitch-referenties blijven buiten de productieimage;
- volgende uitvoerbare taak: geen; alleen de bestaande externe credentials-/infrastructuurblokkades en uitgestelde productkeuzes resteren.
## Laatste geverifieerde baseline
Release-audit op 2026-07-21:
@@ -30,6 +341,7 @@ Release-audit op 2026-07-21:
- backlog- en repositoryvalidatie geslaagd;
- de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry;
- releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole;
- de productie-fetcher valideert het verbonden peer-IP, zodat legitieme CDN-DNS-rotatie bij DAF en Xplore Group niet als rebind wordt afgewezen terwijl private peers geblokkeerd blijven;
- een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd;
- deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan.
@@ -119,25 +431,78 @@ VR-126-herverificatie op 2026-07-22:
VR-201/VR-205-herverificatie op 2026-07-22:
- 215 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,42% branch-aware codedekking;
- 218 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,46% branch-aware codedekking;
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166;
- Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus;
- twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben;
- Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten;
- de Unraid-deploy gebruikt expliciet Celery-queue `default`, overeenkomstig de queues die de productie-worker consumeert;
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
VR-206-herverificatie op 2026-07-22:
- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
VR-207-herverificatie op 2026-07-22:
- 232 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,59% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- het profiel bevat gegroepeerde field-/infrastructuur-, Microsoft/endpoint-, netwerk/security-, back-up/monitoring-, virtualisatie-, VoIP- en scriptingkeuzes plus een ervaringsband;
- de skillcomponent bereikt zijn maximum bij vier aangetroffen cv-skills en presenteert niet-vermelde voorkeurskills niet meer als tekort;
- ambigue field-/implementatietitels vereisen een onafhankelijk IT-contextsignaal en titeluitsluitingen respecteren woordgrenzen en eenvoudige Engelse meervouden;
- de lokale actieve auditprofielen zijn zonder persoonsgegevens in Git afgestemd op 1014 jaar ervaring, 27 relevante roltitels en 46 cv-skills; na herscoring blijven 14 plausibele field-/infrastructuurkansen zichtbaar;
- geïntegreerde browsercontrole bevestigde de categorie-editor op ultrawide en 390 px, één mobiele categoriekolom, geldige profielselecties en geen horizontale paginaoverflow.
VR-208-herverificatie op 2026-07-22:
- 240 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
- 84,73% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
- ervaringsduur en senioriteitslabels hebben geen invloed meer op deterministische score, AI-score, aanbeveling, uitsluiting of feedbacklearning; migratie 0006 ruimt het oude gewicht op;
- de persoonlijke Skillsradar analyseert alleen actieve IT-vacatures met een niet-verborgen score voor de actuele profielversie en laat records van uitsluitend uitgeschakelde bronnen buiten beschouwing;
- de lokale radar toont 9 onderzochte vacatures, 5 herkenbare vraagsignalen, 2 gedekte skills en 3 mogelijke leerkansen zonder een tekstsignaal als harde eis te presenteren;
- geïntegreerde browsercontrole bevestigde filters, 88 profielskillcheckboxes, één `h1`, lege console en geen horizontale paginaoverflow op 2560×1440, 1440×900 en 390×844.
## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` met PostgreSQL, Redis, web, worker en scheduler.
- De applicatie draait op de Unraid-server via de geconfigureerde private host en poort `1226` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
- De 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.
- Historisch bewijs (2026-07-22, inmiddels vervangen): de self-hosted runner was
toen niet actief en die sessie gebruikte een directe SSH-deploy. Sinds 0.3.17
is uitsluitend de taggestuurde immutable registryrelease operationeel; een
gewone push naar `main` deployt bewust niet.
- 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
- 2026-07-22: `VR-208` voltooid.
- Ervaringsjaren zijn alleen nog profielcontext; alle score-, recommendation-, concern-, evidence- en learninginvloed is verwijderd.
- Een gebruikersgescopeerde vraaganalyse brengt actuele skillfrequentie, bewijskwaliteit, profieldekking en niet-blokkerende leer-/bijscholingskansen in kaart.
- De profielcatalogus en nieuwe responsieve Skillsradar ondersteunen Microsoft, netwerk/security, monitoring/platform, ITSM en digitale adoptie met begeleide filters.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-207` voltooid.
- De twee aangeleverde cv's zijn visueel en tekstueel lokaal beoordeeld; alleen afgeleide technische profielkeuzes zijn in de lokale gebruikersdatabase opgeslagen.
- Relevante ervaring, rollen, skills en doelgerichte uitsluitingen zijn toegepast en alle 336 actieve vacatures zijn opnieuw gescoord voor beide lokale auditprofielen.
- De profiel- en scorelogica is regressiegedekt; afwezige cv-skills verlagen een breed profiel niet onbeperkt en worden niet als bewezen tekort getoond.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-206` voltooid.
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
- De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
- Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
- 2026-07-22: `VR-201` en `VR-205` voltooid.
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
@@ -308,22 +673,23 @@ Een toekomstige agent moet altijd de actuele waarheid opnieuw bepalen met:
## Nog te bouwen
Momenteel staan er geen `ready` taken meer over; het volgende te behandelen item is `VR-202` als `blocked-external` voor live mailbox-/SMTP-credentials en echte jobalertmails van de gekozen platformen.
Momenteel staan er geen `ready` taken of P0/P1-releaseblokkers meer over.
## Externe blokkades
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;
- keuze/verkrijging van een gelicenseerde actuele Belgische postcode-/gemeentedataset.
## 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
+9 -6
View File
@@ -24,6 +24,7 @@ Ondersteund of gepland:
- uitgesloten regio/gemeente;
- afstand boven maximum, behalve remote;
- uitgesloten verplichte skill;
- een titelgestuurde IT-grens; ambigue field-/implementatietitels vereisen daarnaast een sterk IT-contextsignaal in de vacaturetekst;
- expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd;
- verlopen of niet-actieve vacature buiten de ranking.
@@ -35,15 +36,16 @@ Standaardgewichten, totaal 100:
| Component | Gewicht | Kernsignalen |
|---|---:|---|
| Content | 25 | titelovereenkomst, taken, weinig ongewenste support |
| Skills | 20 | gewenste skills in expliciete velden/tekst |
| Locatie | 15 | afstand, remote/hybrid, voorkeursregio |
| Content | 30 | titelovereenkomst, taken, weinig ongewenste support |
| Skills | 25 | gewenste skills in expliciete velden/tekst |
| Locatie | 20 | afstand, remote/hybrid, voorkeursregio |
| Voorwaarden | 10 | toegestane contract-/werkvorm |
| Werkgever | 10 | directe bron versus recruiter |
| Senioriteit | 10 | gevraagde ervaring versus profiel |
| Voorkeuren | 10 | publieke sector en andere soft signals |
| Voorkeuren | 5 | publieke sector en andere soft signals |
Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som.
- De profielervaring is uitsluitend informatieve context. Gevraagde ervaringsjaren en senioriteitslabels veranderen de score, aanbeveling en uitsluitingen nooit.
- Een breed cv-profiel wordt niet bestraft omdat veel relevante skills zijn geselecteerd: maximaal vier aangetroffen profielskills volstaan voor de volledige skillcomponent. Niet-aangetroffen voorkeurskills blijven evidence, maar worden niet als bewezen tekort of concern gepresenteerd.
- AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0.
- De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden.
@@ -91,6 +93,7 @@ Een score bewaart:
Copyregels:
- zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is;
- presenteer niet-vermelde cv-skills niet als tekort; alleen expliciet aangetroffen skills zijn positieve evidence;
- label inference als inference;
- toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar;
- geef geen kanspercentage op aanwerving zonder gevalideerd model.
@@ -119,7 +122,7 @@ Offline evaluatie is beschikbaar via `scripts/feedback_learning_report.py` met f
Toegestaan:
- support-/consultancy-/travelratio schatten;
- senioritylabel;
- senioritylabel als informatieve extractie, zonder score-invloed;
- korte Nederlandse samenvatting;
- evidencefragmenten selecteren;
- ambiguïteitswarnings.
+46
View File
@@ -0,0 +1,46 @@
# Professionalisering baseline
- Datum: 2026-07-29
- Branch: `main`
- Versie: 0.3.12
- Opdracht: volledige autonome professionalisering van VacatureRadar
## Architectuur
VacatureRadar is een Django 5.2 server-rendered modulaire monoliet op Python 3.13. PostgreSQL is de productiedatabase; Redis ondersteunt cache, gedeelde rate limiting en Celery. Een afzonderlijke worker en scheduler verwerken bronruns, lifecycle en notificaties. Externe bronnen lopen via policy-, robots-, TLS- en SSRF-validatie en adapters leveren genormaliseerde extractieresultaten zonder databasewrites.
De frontend bestaat uit Django-templates, lokale CSS en beperkte lokale JavaScript. Er is geen clientframework of externe runtimeasset. Authenticatie ondersteunt lokale accounts, een optionele Entra-boundary en een server-side alleen-lezen gastdemo.
## Baselinevalidatie
`./scripts/codex_verify.sh` start schoon: Ruff, Django system check en migratiecheck slagen. De bestaande suite bevat 298 tests. In de laatst volledig afgeronde gate slaagden 296 tests en werden twee browsercases overgeslagen omdat Playwright lokaal niet geïnstalleerd was; branch-aware dekking bedroeg 83,44%.
CI voert lint, tests, secret scanning, dependency scanning, een productie-imagebuild en containerscan uit. De browserdependency wordt wel gesynchroniseerd, maar Chromium wordt nog niet geïnstalleerd en de E2E-test staat omgevingsafhankelijke skips toe.
## Werkende onderdelen
- Negen functionele hoofdschermen, login en wachtwoordherstel.
- Deterministische relevantie, scoring, dedupe en lifecycle vóór optionele AI.
- Read-only gastdemo met server-side mutatiebescherming.
- Bronbeleid, sanitization, SSRF-, TLS- en redirectbescherming.
- Docker-, Unraid-, reverse-proxy-, health-, back-up- en restorevoorzieningen.
- Dark/light theme, toetsenbordfocus, reduced-motionbasis en mobiele hoofdnavigatie.
## Belangrijkste risico's bij start
1. De premium CSS overschrijft het gecentreerde layoutcontract met links verankerde content. Op 3440 px blijft ongeveer 900 px ongebruikte ruimte rechts.
2. De gastbanner begint mobiel boven de vaste topbar en is daardoor gedeeltelijk onzichtbaar.
3. Verplichte browser- en accessibilitytests kunnen stilzwijgend skippen.
4. De vacatureverkenner rendert een zeer lange resultaatpagina; selectie- en scrollcontext zijn beperkt.
5. Bronnen en automatisering tonen veel operationele controls en informatie tegelijk.
6. Kritieke dekking is relatief laag in auth, Entra, core views en task orchestration.
7. Enkele modules zijn groter dan de repositoryrichtlijn en combineren meerdere verantwoordelijkheden.
8. Live TLS-, reverse-proxy-, worker-, scheduler-, back-up- en restoreacceptatie blijft doelomgevingafhankelijk.
## Eerste uitvoeringsvolgorde
1. Responsive layout en gastbanner.
2. Niet-skipbare browser-/accessibilitymatrix.
3. Dagelijkse kernflows en taakgerichte schermcomposities.
4. Kritieke tests, observability, container/security en herstelvalidatie.
5. Integrale eindacceptatie en live restpunten.
+39
View File
@@ -0,0 +1,39 @@
# Finale acceptance 0.3.17
Status: productie geaccepteerd op 2026-08-12.
## 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`.
- M365 IMAP en SMTP werken app-only via OAuth/XOAUTH2; negen geïsoleerde
platformconnecties importeerden één live testmail idempotent en één testdigest
eindigde met outboxstatus `sent`.
- Gitea Actions publiceerde `v0.3.15` met immutable digest
`sha256:5a6dc253db7a60c42c0c806ae084ddc6ee7c98db5db71a857423b894a3662ba5`.
Het artifact bevat een geverifieerde SPDX-SBOM, checksums en release notes.
- Unraid is op die digest gepind; pull, health/readiness, rollback naar `0.3.14`
en herstel naar `0.3.15` zijn live geslaagd.
- Gitea Actions-run 3062 publiceerde `v0.3.17` nadat de releasebootstrap op een
immutable `setup-uv`-commit en uv 0.11.12 werd gepind. De uv-download gebruikt
de Astral-mirror en vereist geen GitHub Releases-API.
- Het checksum-geldige artifact `release-evidence-v0.3.17` bevat image-digest,
SPDX-SBOM, checksums en release notes. De gepubliceerde digest is
`sha256:98f4b33db353c192967013818f7c82c4f171921f3d300645908d52680fde209f`.
- De checksum-geldige pre-deployback-up staat onder
`local/backups/release-0.3.17-20260812T114510Z`. Unraid gebruikt een beperkt
`read:package`-token en is op de volledige registrydigest gepind.
- De 0.3.17-container is healthy met nul restarts; interne liveness en readiness,
database, cache en publieke readiness zijn groen en de runtime meldt 0.3.17.
## Uitkomst
Er bestaan geen bekende P0- of P1-releaseblokkers.
+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.

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