Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ee115406c | ||
|
|
3a5620796f | ||
|
|
38e87678e1 | ||
|
|
2d1964106b | ||
|
|
7c4652ac49 | ||
|
|
c855253072 | ||
|
|
f20b7de053 | ||
|
|
afa7474094 | ||
|
|
256cb98695 | ||
|
|
1cd6fc11a4 | ||
|
|
9643916f16 | ||
|
|
f52a4ef3cf | ||
|
|
ebdf3f4f91 | ||
|
|
ed8f59439d | ||
|
|
5c287f2459 | ||
|
|
fdb71081e0 | ||
|
|
751bf8be16 | ||
|
|
e738cba3a3 | ||
|
|
4c00ccaa39 | ||
|
|
36f41ba43a | ||
|
|
61502548d4 | ||
|
|
7b6f6d43fc | ||
|
|
4822ee6880 | ||
|
|
3cfb2260cc | ||
|
|
b5045cf487 | ||
|
|
a6c8a79e31 | ||
|
|
2419e6bf01 | ||
|
|
3ab9127548 | ||
|
|
db9f340130 | ||
|
|
aa1e6e50c1 | ||
|
|
7991b04b35 | ||
|
|
8073533c16 | ||
|
|
db7d96a546 | ||
|
|
6da5d75acd | ||
|
|
67be350283 | ||
|
|
1397f085bd | ||
|
|
cbd7220d8d | ||
|
|
0d953173a8 | ||
|
|
a4a71ebc23 | ||
|
|
5e2453c29a | ||
|
|
78fcf4fa5f | ||
|
|
61349e9b75 | ||
|
|
7caea3a1ee | ||
|
|
d28fd05bf6 | ||
|
|
6a505047f5 | ||
|
|
695b553672 | ||
|
|
f36af616a1 | ||
|
|
fd5b50b8fe | ||
|
|
2a32e9fec9 | ||
|
|
eea74f97ef | ||
|
|
6c09d16a02 |
@@ -1,9 +1,13 @@
|
|||||||
# Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME.
|
# Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME.
|
||||||
DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value
|
DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value
|
||||||
DJANGO_DEBUG=1
|
DJANGO_DEBUG=1
|
||||||
|
# Leeg voor lokaal gebruik. In productie: https://vacatureradar.example.be
|
||||||
|
PUBLIC_BASE_URL=
|
||||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
|
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
|
||||||
DJANGO_TIME_ZONE=Europe/Brussels
|
DJANGO_TIME_ZONE=Europe/Brussels
|
||||||
|
VACATURERADAR_OWNER_NAME=Jens
|
||||||
|
VACATURERADAR_VERSION=0.3.14
|
||||||
|
|
||||||
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
|
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
|
||||||
POSTGRES_DB=vacatureradar
|
POSTGRES_DB=vacatureradar
|
||||||
@@ -13,6 +17,9 @@ POSTGRES_HOST=postgres
|
|||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
# Gedeelde cache voor rate limiting en readiness; gebruik een afzonderlijke Redis-database.
|
||||||
|
CACHE_URL=redis://redis:6379/1
|
||||||
|
HEALTHCHECK_REQUIRE_CACHE=1
|
||||||
CELERY_TASK_ALWAYS_EAGER=0
|
CELERY_TASK_ALWAYS_EAGER=0
|
||||||
|
|
||||||
# Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat.
|
# Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat.
|
||||||
@@ -29,6 +36,10 @@ SMTP_PORT=587
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_USE_TLS=1
|
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.
|
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
|
||||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
@@ -66,14 +77,22 @@ ENTRA_SCOPES=User.Read
|
|||||||
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
|
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
|
||||||
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
|
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
|
||||||
DEMO_MODE_ENABLED=1
|
DEMO_MODE_ENABLED=1
|
||||||
|
DEMO_READ_ONLY=1
|
||||||
|
DEMO_SESSION_SECONDS=3600
|
||||||
DEMO_USERNAME=demo
|
DEMO_USERNAME=demo
|
||||||
DEMO_EMAIL=demo@vacatureradar.local
|
DEMO_EMAIL=demo@vacatureradar.local
|
||||||
|
|
||||||
# Beveiliging
|
# Beveiliging
|
||||||
|
# Zet TRUST_PROXY_HEADERS alleen aan achter een beheerde proxy die Host en X-Forwarded-Proto overschrijft.
|
||||||
|
TRUST_PROXY_HEADERS=0
|
||||||
|
USE_X_FORWARDED_HOST=0
|
||||||
|
# Alleen nodig wanneer client-IPheaders voor rate limiting worden vertrouwd.
|
||||||
|
TRUSTED_PROXY_CIDRS=
|
||||||
SESSION_COOKIE_SECURE=0
|
SESSION_COOKIE_SECURE=0
|
||||||
CSRF_COOKIE_SECURE=0
|
CSRF_COOKIE_SECURE=0
|
||||||
SECURE_SSL_REDIRECT=0
|
SECURE_SSL_REDIRECT=0
|
||||||
SECURE_HSTS_SECONDS=0
|
SECURE_HSTS_SECONDS=0
|
||||||
|
SEARCH_ENGINE_INDEXING_ENABLED=0
|
||||||
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
|
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
|
||||||
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||||
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
|
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
name: Managed validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
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: linux-validation
|
||||||
|
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
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify-publish:
|
||||||
|
runs-on: linux-validation
|
||||||
|
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
|
||||||
@@ -24,13 +24,15 @@ jobs:
|
|||||||
run: python -m pip install --disable-pip-version-check uv==0.10.0
|
run: python -m pip install --disable-pip-version-check uv==0.10.0
|
||||||
- name: Sync locked dependencies
|
- name: Sync locked dependencies
|
||||||
run: uv sync --frozen --all-groups
|
run: uv sync --frozen --all-groups
|
||||||
|
- name: Install Chromium for mandatory browser tests
|
||||||
|
run: uv run playwright install --with-deps chromium
|
||||||
- name: Secret scan
|
- name: Secret scan
|
||||||
uses: trufflesecurity/trufflehog@v3.79.0
|
uses: trufflesecurity/trufflehog@v3.79.0
|
||||||
with:
|
with:
|
||||||
path: ./
|
path: ./
|
||||||
extra_args: --only-verified
|
extra_args: --only-verified
|
||||||
- name: Scan project dependencies and lockfiles (high/critical)
|
- name: Scan project dependencies and lockfiles (high/critical)
|
||||||
uses: aquasecurity/trivy-action@0.30.0
|
uses: aquasecurity/trivy-action@v0.30.0
|
||||||
with:
|
with:
|
||||||
scan-type: fs
|
scan-type: fs
|
||||||
scan-ref: .
|
scan-ref: .
|
||||||
@@ -47,6 +49,13 @@ jobs:
|
|||||||
IMAP_ENABLED: "0"
|
IMAP_ENABLED: "0"
|
||||||
OLLAMA_ENABLED: "0"
|
OLLAMA_ENABLED: "0"
|
||||||
run: ./scripts/codex_verify.sh
|
run: ./scripts/codex_verify.sh
|
||||||
|
- name: Upload browser failure artifacts
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: browser-failure-artifacts
|
||||||
|
path: artifacts/browser-tests/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
container:
|
container:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -57,12 +66,24 @@ jobs:
|
|||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build --pull --tag vacatureradar:ci .
|
run: docker build --pull --tag vacatureradar:ci .
|
||||||
- name: Scan container image for high/critical vulnerabilities
|
- name: Scan container image for high/critical vulnerabilities
|
||||||
uses: aquasecurity/trivy-action@0.30.0
|
uses: aquasecurity/trivy-action@v0.30.0
|
||||||
with:
|
with:
|
||||||
image-ref: vacatureradar:ci
|
image-ref: vacatureradar:ci
|
||||||
format: table
|
format: table
|
||||||
severity: HIGH,CRITICAL
|
severity: HIGH,CRITICAL
|
||||||
exit-code: "1"
|
exit-code: "1"
|
||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
|
- name: Generate CycloneDX software bill of materials
|
||||||
|
uses: aquasecurity/trivy-action@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
|
- name: Inspect image configuration
|
||||||
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
|
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ local/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.unraid.example
|
||||||
secrets/
|
secrets/
|
||||||
|
|
||||||
# IDE / OS
|
# IDE / OS
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -2,6 +2,85 @@
|
|||||||
|
|
||||||
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
||||||
|
|
||||||
|
## 0.3.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 URL’s.
|
||||||
|
- 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-CIDR’s.
|
||||||
|
- 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
|
## 0.3.0 — 2026-07-22
|
||||||
|
|
||||||
### Gewijzigd
|
### Gewijzigd
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -26,7 +26,10 @@ RUN uv sync --frozen --no-dev --no-install-project
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN uv sync --frozen --no-dev \
|
RUN uv sync --frozen --no-dev \
|
||||||
&& chmod +x scripts/*.sh \
|
&& chmod +x scripts/*.sh \
|
||||||
&& python manage.py collectstatic --noinput \
|
&& DJANGO_DEBUG=0 \
|
||||||
|
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
|
||||||
|
PUBLIC_BASE_URL=https://build.invalid \
|
||||||
|
python manage.py collectstatic --noinput \
|
||||||
&& mkdir -p /app/media /app/logs /app/local \
|
&& mkdir -p /app/media /app/logs /app/local \
|
||||||
&& chown -R app:app /app \
|
&& chown -R app:app /app \
|
||||||
&& chmod -R 0775 /app/media /app/logs /app/local /app/staticfiles /app/.venv
|
&& chmod -R 0775 /app/media /app/logs /app/local /app/staticfiles /app/.venv
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM python:3.13-slim-trixie
|
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 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
UV_COMPILE_BYTECODE=1 \
|
UV_COMPILE_BYTECODE=1 \
|
||||||
@@ -24,7 +30,10 @@ RUN uv sync --frozen --no-dev --no-install-project
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN uv sync --frozen --no-dev \
|
RUN uv sync --frozen --no-dev \
|
||||||
&& chmod +x scripts/*.sh \
|
&& chmod +x scripts/*.sh \
|
||||||
&& python manage.py collectstatic --noinput \
|
&& DJANGO_DEBUG=0 \
|
||||||
|
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
|
||||||
|
PUBLIC_BASE_URL=https://build.invalid \
|
||||||
|
python manage.py collectstatic --noinput \
|
||||||
&& mkdir -p /app/media /app/logs /app/local /var/log/supervisor \
|
&& mkdir -p /app/media /app/logs /app/local /var/log/supervisor \
|
||||||
&& chown -R app:app /app
|
&& chown -R app:app /app
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,205 +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 presenteert vacatures, skillsvraag, werkgevers, opvolging en bronstatus in een Stitch-gebaseerde Intelligence Cockpit.
|
<h1 align="center">VacatureRadar</h1>
|
||||||
|
|
||||||
De repository bevat zowel een **werkende MVP-kern** als de volledige product-, architectuur-, UX-, beveiligings-, operationele en agentische overdrachtsdocumentatie om het project verder autonoom af te bouwen.
|
<p align="center"><strong>Van vacature-overload naar een kleine, uitlegbare selectie die bij jou past.</strong></p>
|
||||||
|
|
||||||
## Huidige status
|
VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-pagina’s, 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 de volledige Nederlandstalige, responsive dark/light Stitch Intelligence Cockpit;
|

|
||||||
- PostgreSQL in productie en SQLite voor lokale ontwikkeling;
|
|
||||||
- Celery-worker en scheduler via Redis;
|
|
||||||
- veilige HTTP-fetcher met domeinbeleid, redirecthercontrole, contentlimieten en SSRF-blokkering;
|
|
||||||
- JSON-LD `JobPosting`, generieke HTML-, RSS/Atom- en vacaturemailparsers;
|
|
||||||
- normalisatie, herkomstbewijs, vacatureversies en idempotente opslag;
|
|
||||||
- exacte en fuzzy deduplicatie;
|
|
||||||
- deterministische kenmerken, harde uitsluitregels en uitlegbare scoring;
|
|
||||||
- zoekprofielen met revisiehistoriek en begrensde feedbackaanpassing;
|
|
||||||
- dagelijkse digests, vacaturelevenscyclus, auditbare sollicitatiepipeline en activiteitstijdlijn;
|
|
||||||
- werkgeversintelligence, persoonlijke skillsradar en eerlijke operationele automatiseringsstatus;
|
|
||||||
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
|
|
||||||
- Docker Compose voor lokaal gebruik en een Unraid-variant;
|
|
||||||
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
|
|
||||||
|
|
||||||
Nog niet als productievoltooid beschouwd:
|
## Voor wie is dit?
|
||||||
|
|
||||||
- periodieke menselijke voorwaardenreview voor bijkomende live werkgeversbronnen;
|
VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installatie die:
|
||||||
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry.
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## Frontend en visuele validatie
|
## Zo gebruik je VacatureRadar
|
||||||
|
|
||||||
De enige normale browserfrontend is de server-rendered Stitch Intelligence Cockpit. Alle negen referentieschermen zijn aan echte Django-data gekoppeld; er is geen SPA, runtime-CDN, demo-KPI of oude parallelle shell. De ontwerpcontracten en gemotiveerde afwijkingen staan in:
|
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.
|
||||||
|
|
||||||
- [`STITCH_FRONTEND_CONTRACT.md`](docs/design/STITCH_FRONTEND_CONTRACT.md);
|
## Wat zit erin?
|
||||||
- [`STITCH_INTEGRATION_PLAN.md`](docs/design/STITCH_INTEGRATION_PLAN.md);
|
|
||||||
- [`STITCH_INTEGRATION_REPORT.md`](docs/design/STITCH_INTEGRATION_REPORT.md).
|
|
||||||
|
|
||||||
De elf desktop-/mobielbeelden staan in `artifacts/visual-validation/` en zijn uitgesloten van de productiecontainer. De browsermatrix bestrijkt 360 tot 2560 px.
|
| 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 |
|
||||||
|
|
||||||
Release-productiepad:
|
## Bekijk de volledige productflow
|
||||||
|
|
||||||
- `./scripts/release_verify.sh` bouwt de smoke-, backup/restore- en artifactcontrole;
|
De beelden komen uit een gesaneerde lokale demo-omgeving. De getoonde aantallen
|
||||||
- [`docs/operations/UNRAID_DEPLOYMENT.md`](docs/operations/UNRAID_DEPLOYMENT.md) bevat een expliciet upgrade/rollbackpad met releaseartefacten.
|
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.
|
||||||
|
|
||||||
## Snelste start voor Codex
|
### 1. Vind de relevante kansen
|
||||||
|
|
||||||
Open de uitgepakte repository als project en geef Codex deze opdracht:
|
De verkenner brengt zoeken, filters, gerangschikte resultaten en de belangrijkste matchcontext samen. Zo hoef je niet tussen lijst- en detailpagina’s te blijven wisselen.
|
||||||
|
|
||||||
```text
|
<p align="center">
|
||||||
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.
|
<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>
|
||||||
|
|
||||||
De repository-instructies staan in [`AGENTS.md`](AGENTS.md). De handmatige startpagina staat in [`CODEX_START_HERE.md`](CODEX_START_HERE.md).
|
### 2. Begrijp waarom een vacature past
|
||||||
|
|
||||||
## Lokale quickstart
|
Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broninformatie afzonderlijk zichtbaar. Ervaring en senioriteitslabels worden als context getoond, niet als verborgen scorefactor.
|
||||||
|
|
||||||
Vereisten: Python 3.12 of 3.13 en `uv`.
|
<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
|
```bash
|
||||||
./scripts/codex_bootstrap.sh
|
./scripts/codex_bootstrap.sh
|
||||||
uv run python manage.py runserver 0.0.0.0:8080
|
uv run python manage.py runserver 0.0.0.0:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
Het bootstrap-script:
|
Open daarna [http://127.0.0.1:8080](http://127.0.0.1:8080). Wanneer het bootstrap-script de ontwikkelomgeving heeft aangemaakt, kun je lokaal aanmelden met:
|
||||||
|
|
||||||
1. maakt alleen wanneer nodig een lokale `.env` aan;
|
|
||||||
2. installeert exact de gelockte dependencies;
|
|
||||||
3. voert migraties uit;
|
|
||||||
4. maakt demo-inhoud aan;
|
|
||||||
5. draait lint, systeemchecks, migratiecheck, tests en taakledgervalidatie.
|
|
||||||
|
|
||||||
Bij een door het script aangemaakte ontwikkelomgeving is de tijdelijke login:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
admin / codex-local-only
|
admin / codex-local-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Gebruik die nooit buiten lokale ontwikkeling.
|
Het bootstrap-script maakt zo nodig `.env`, installeert de gelockte dependencies, voert migraties uit, laadt veilige demodata en draait de kwaliteitsgates. Deze tijdelijke login is uitsluitend voor lokale ontwikkeling; wijzig of verwijder hem voordat de installatie vanaf een ander toestel bereikbaar is.
|
||||||
|
|
||||||
## Docker Compose
|
### Optie 2 — Docker Compose
|
||||||
|
|
||||||
|
Vereisten: Docker met Compose v2 en Bash.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
|
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
De webinterface luistert op `0.0.0.0:1226` (containerpoort `8080`). Voor een one-shot deploy kun je nu draaien:
|
Open daarna [http://127.0.0.1:1226](http://127.0.0.1:1226). Het deployscript vult ontbrekende lokale `.env`-waarden met sterke gegenereerde defaults, start PostgreSQL, Redis, Django, de worker en scheduler en controleert de health-endpoints.
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
|
http://127.0.0.1:1226/health/ready/
|
||||||
|
http://127.0.0.1:1226/health/live/
|
||||||
```
|
```
|
||||||
|
|
||||||
De script zet ontbrekende `.env`-waarden automatisch met sterke defaults (secrets, database-wachtwoord, admin-credentials, ports) en start direct de volledige Docker-stack. Daarna:
|
Gebruik voor een bestaande installatie geen nieuwe gegenereerde `.env`: bewaar de huidige secrets en databasegegevens. Zie [lokale ontwikkeling](docs/operations/LOCAL_DEVELOPMENT.md) of [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) voor het volledige pad.
|
||||||
|
|
||||||
- `http://127.0.0.1:1226/health/ready/`
|
## Eerste configuratie
|
||||||
- `http://127.0.0.1:1226/health/live/`
|
|
||||||
|
|
||||||
Zet voor publieke productie:
|
Na de eerste login:
|
||||||
- `APP_HOST=<jouw-domein>`
|
|
||||||
- `APP_SCHEME=https`
|
|
||||||
- `DJANGO_DEBUG=0`
|
|
||||||
- `SESSION_COOKIE_SECURE=1`
|
|
||||||
- `CSRF_COOKIE_SECURE=1`
|
|
||||||
- `SECURE_SSL_REDIRECT=1`
|
|
||||||
|
|
||||||
De script blijft geen handmatige invulstappen voor compose vereisen; pas deze omgevingsvariabelen aan vóór eerste deploy.
|
1. Open **Zoekprofiel** en stel regio, radius, rollen, skills en werkvoorkeuren in.
|
||||||
|
2. Open **Bronnen** en activeer alleen bronnen waarvan beleid en voorwaarden beoordeeld zijn.
|
||||||
|
3. 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
|
## Eerlijke grenzen
|
||||||
- `bash scripts/deploy_docker.sh` op de server
|
|
||||||
- automatische healthcheck + `collectstatic`
|
|
||||||
|
|
||||||
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
|
```bash
|
||||||
uv run python scripts/package_project.py ../VacatureRadar_Project.zip
|
uv run python scripts/package_project.py ../VacatureRadar_Project.zip
|
||||||
uv run python scripts/verify_package.py ../VacatureRadar_Project.zip
|
uv run python scripts/verify_package.py ../VacatureRadar_Project.zip
|
||||||
```
|
```
|
||||||
|
|
||||||
Het packaging-script sluit `.env`, virtuele omgevingen, caches, lokale databases, logs, media en back-ups uit. In de ZIP staat `PROJECT_MANIFEST.json` met grootte en SHA-256 per bestand.
|
|
||||||
|
|
||||||
## Kwaliteitsgates
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/codex_verify.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
De gate vereist:
|
|
||||||
|
|
||||||
- Ruff zonder bevindingen;
|
|
||||||
- een geldige Django-configuratie;
|
|
||||||
- geen ontbrekende migraties;
|
|
||||||
- alle tests geslaagd;
|
|
||||||
- minimaal 70% branch-aware codedekking;
|
|
||||||
- een geldige machineleesbare backlog.
|
|
||||||
|
|
||||||
## Repositorykaart
|
## Repositorykaart
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/ Django-domeinmodules
|
apps/ Django-domeinen en bedrijfsservices
|
||||||
core/ dashboard, health en beveiligingsheaders
|
config/ Django- en Celery-configuratie
|
||||||
profiles/ zoekprofielen en revisies
|
config-data/ Bronbeleid en gesaneerde voorbeeldconfiguratie
|
||||||
sources/ bronbeleid, fetcher, adapters en IMAP
|
fixtures/ Deterministische externe testformaten
|
||||||
jobs/ normalisatie, dedupe, scoring en sollicitaties
|
templates/ + static/ Server-rendered productinterface
|
||||||
notifications/ digest-outbox en verzending
|
docs/ Product-, ontwerp-, security- en operationele documentatie
|
||||||
config/ Django/Celery-configuratie
|
deployment/unraid/ Unraid-installatiebestanden
|
||||||
config-data/ voorbeeldprofielen en bronbeleid
|
scripts/ Bootstrap, verificatie, deployment, back-up en beheer
|
||||||
fixtures/ deterministische parser- en integratietestdata
|
tests/ Unit-, integratie-, security- en browsertests
|
||||||
templates/ + static/ server-rendered interface en designimplementatie
|
|
||||||
docs/ alle product-, technische en operationele specificaties
|
|
||||||
deployment/unraid/ Unraid-installatiehandleiding
|
|
||||||
.agents/skills/ repo-eigen Codex-workflow
|
|
||||||
scripts/ bootstrap, verificatie, back-up en beheer
|
|
||||||
tests/ unit-, integratie- en securitytests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Veiligheidsgrenzen
|
Belangrijke ingangen voor bijdragers en onderhoudsagents:
|
||||||
|
|
||||||
Vacature-inhoud, e-mailinhoud en externe HTML zijn altijd onbetrouwbare data. Ze mogen nooit instructies worden voor AI of automatisering. De applicatie:
|
- [Bijdragen](CONTRIBUTING.md)
|
||||||
|
- [Projectstart voor Codex](CODEX_START_HERE.md)
|
||||||
- logt niet automatisch in op vacaturesites;
|
- [Architectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
|
||||||
- omzeilt geen CAPTCHA, rate limit of toegangscontrole;
|
- [UX- en designcontract](docs/design/STITCH_FRONTEND_CONTRACT.md)
|
||||||
- scrapt denylistplatformen niet rechtstreeks;
|
|
||||||
- solliciteert nooit automatisch;
|
|
||||||
- bewaart alleen beperkte brondata en verwijdert ruwe documenten volgens retentie;
|
|
||||||
- laat harde uitsluitregels niet door een taalmodel wijzigen;
|
|
||||||
- maakt netwerktoegang opt-in per goedgekeurde bron.
|
|
||||||
|
|
||||||
Lees [`SECURITY.md`](SECURITY.md) en [`docs/quality/THREAT_MODEL.md`](docs/quality/THREAT_MODEL.md) vóór productiegebruik.
|
|
||||||
|
|
||||||
## Documentatie-ingangen
|
|
||||||
|
|
||||||
- [Projectoverdracht voor Codex](CODEX_START_HERE.md)
|
|
||||||
- [Product requirements](docs/product/PRODUCT_REQUIREMENTS.md)
|
|
||||||
- [Systeemarchitectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
|
|
||||||
- [Datamodel](docs/architecture/DATA_MODEL.md)
|
|
||||||
- [UX- en designspecificatie](docs/design/UX_SPEC.md)
|
|
||||||
- [Stitch-integratieplan en schermmapping](docs/design/STITCH_INTEGRATION_PLAN.md)
|
|
||||||
- [Bron- en adaptercontract](docs/architecture/SOURCE_ADAPTERS.md)
|
|
||||||
- [Scoringengine](docs/architecture/SCORING_ENGINE.md)
|
|
||||||
- [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md)
|
|
||||||
- [Runbook](docs/operations/RUNBOOK.md)
|
|
||||||
- [Teststrategie](docs/quality/TEST_STRATEGY.md)
|
- [Teststrategie](docs/quality/TEST_STRATEGY.md)
|
||||||
- [Traceabilitymatrix](docs/quality/TRACEABILITY_MATRIX.md)
|
- [Actuele projectstatus](docs/ai/PROJECT_STATE.md)
|
||||||
- [Oorspronkelijk functioneel en technisch ontwerp](docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.pdf)
|
- [Machineleesbare backlog](docs/ai/BACKLOG.yaml)
|
||||||
- [Verificatierapport van de aangeleverde basis](VERIFICATION_REPORT.md)
|
|
||||||
|
|
||||||
## Licentie
|
## Licentie
|
||||||
|
|
||||||
MIT voor de broncode. Controleer voor iedere externe vacaturebron afzonderlijk de voorwaarden, robotsregels, databankrechten en toepasselijke wetgeving. Dit project levert technisch bronbeleid, maar geen juridisch advies.
|
De broncode valt onder de [MIT-licentie](LICENSE). Controleer voor iedere externe vacaturebron afzonderlijk de voorwaarden, robotsregels, databankrechten en toepasselijke wetgeving. Het project levert technisch bronbeleid, geen juridisch advies.
|
||||||
|
|||||||
@@ -30,11 +30,16 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea
|
|||||||
- minimale ruwe-dataretentie;
|
- minimale ruwe-dataretentie;
|
||||||
- geen secrets in logs of databasefixtures;
|
- geen secrets in logs of databasefixtures;
|
||||||
- AI uitgeschakeld als veilige standaard;
|
- AI uitgeschakeld als veilige standaard;
|
||||||
- menselijke bevestiging voor iedere sollicitatie.
|
- menselijke bevestiging voor iedere sollicitatie;
|
||||||
|
- een publieke URL die exact in host- en CSRF-validatie zit;
|
||||||
|
- `DEBUG=False`, secure cookies en correcte proxy-HTTPS-detectie;
|
||||||
|
- forwarded client-IPheaders alleen vanaf expliciet vertrouwde proxy-CIDR's;
|
||||||
|
- een gedeeld gastaccount uitsluitend in server-side read-onlymodus.
|
||||||
|
|
||||||
## Kwetsbaarheid melden
|
## Kwetsbaarheid melden
|
||||||
|
|
||||||
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;
|
1. component en versie/commit;
|
||||||
2. reproduceerbare stappen met synthetische data;
|
2. reproduceerbare stappen met synthetische data;
|
||||||
@@ -42,7 +47,7 @@ Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documente
|
|||||||
4. voorgestelde mitigatie;
|
4. voorgestelde mitigatie;
|
||||||
5. eventuele rotatie- of verwijderacties.
|
5. eventuele rotatie- of verwijderacties.
|
||||||
|
|
||||||
Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen.
|
Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Publieke reverse-proxyproblemen worden behandeld volgens `docs/operations/NGINX_PROXY_MANAGER.md`.
|
||||||
|
|
||||||
## Geen securityclaims
|
## Geen securityclaims
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,102 +1,68 @@
|
|||||||
# Verificatierapport van de aangeleverde projectbasis
|
# Verificatierapport — VacatureRadar 0.3.12
|
||||||
|
|
||||||
- Datum: 2026-07-20
|
- Datum: 27 juli 2026
|
||||||
- Omgeving: Linux, Python 3.13.5, uv 0.10.0
|
- Onderwerp: productiehardening, Nginx Proxy Manager, gastmodus en overdrachts-ZIP
|
||||||
- Projectversie: 0.1.0
|
- Branch: `fix/public-production-hardening`
|
||||||
- Doel: aantonen dat de ZIP een reproduceerbare, werkende MVP-basis en autonome overdracht bevat
|
|
||||||
|
|
||||||
## Uitgevoerde volledige gate
|
## Gecontroleerd
|
||||||
|
|
||||||
Commando:
|
### Applicatie en regressies
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
./scripts/codex_verify.sh
|
296 tests geslaagd
|
||||||
|
2 optionele Playwrighttests overgeslagen omdat de browserbinary niet lokaal aanwezig was
|
||||||
|
83,43% branch-aware codedekking; vereiste drempel 70%
|
||||||
```
|
```
|
||||||
|
|
||||||
Resultaat:
|
De afzonderlijke HTML- en accessibilityprobe bleef actief. De Playwrightfallback is hersteld zodat een geïnstalleerde Pythonmodule zonder browserbinary geen synchrone databasecall meer binnen de Playwright-eventloop uitvoert.
|
||||||
|
|
||||||
- Ruff: geslaagd;
|
### Django en data
|
||||||
- Django system check: geslaagd, 0 issues;
|
|
||||||
- migratiecontrole: geslaagd, geen ontbrekende migraties;
|
|
||||||
- pytest: 43/43 tests geslaagd;
|
|
||||||
- branch-aware codedekking: 80,42% (`apps` en `config`), drempel 70%;
|
|
||||||
- machineleesbare backlog: geldig, 35 taken en acyclische dependencies;
|
|
||||||
- lokale Markdownlinks, YAML/Compose/OpenAPI, `.env.example` en Codex-skillfrontmatter: geldig.
|
|
||||||
|
|
||||||
De tests omvatten unit-, integratie- en securitypaden voor parsers, URL-security, fetchbeleid, sanitization, normalisatie, dedupe, scoring, profiles, e-mailimport, notificaties, lifecycle, tasks en webviews.
|
- `python manage.py check`: 0 issues;
|
||||||
|
- `python manage.py makemigrations --check --dry-run`: geen wijzigingen;
|
||||||
|
- productie-settings met `PUBLIC_BASE_URL=https://vacatureradar.itworx.tech`: laden correct;
|
||||||
|
- `manage.py check --deploy`: geen errors; HSTS-preload blijft bewust uit en geeft daarom alleen de verwachte Django-waarschuwing;
|
||||||
|
- publieke host en HTTPS-origin worden aantoonbaar aan `ALLOWED_HOSTS` en `CSRF_TRUSTED_ORIGINS` toegevoegd.
|
||||||
|
|
||||||
## Clean-room ZIP-proef
|
### Repository en deployment
|
||||||
|
|
||||||
De gegenereerde ZIP is in een lege directory uitgepakt. Voor de start is gecontroleerd dat `.env`, `.venv` en `local/db.sqlite3` niet aanwezig waren. Daarna is uitgevoerd:
|
- repositoryvalidator: geslaagd;
|
||||||
|
- taakledger: geldig, 64 taken, 0 uitvoerbare ready-taken;
|
||||||
|
- Docker Compose, Unraid Compose en backlog-YAML: parseerbaar;
|
||||||
|
- shellsyntax van deploy- en entrypoints: geldig;
|
||||||
|
- Pythoncompilecheck: geslaagd;
|
||||||
|
- `git diff --check`: geslaagd;
|
||||||
|
- packagevalidatie en manifestcontrole: geslaagd na definitieve packaging.
|
||||||
|
|
||||||
```bash
|
## Belangrijkste herstelde productieproblemen
|
||||||
./scripts/codex_bootstrap.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Resultaat:
|
- `DisallowedHost` voor de publieke Nginx-host;
|
||||||
|
- publieke Django-debugmodus;
|
||||||
|
- verkeerde externe CSRF-origin met interne poort `1226`;
|
||||||
|
- healthchecks die door HTTPS-redirect konden falen;
|
||||||
|
- proceslokale rate limiting bij meerdere Gunicornworkers;
|
||||||
|
- onvoorwaardelijk vertrouwen in `X-Forwarded-For`;
|
||||||
|
- schrijfbare gedeelde demo;
|
||||||
|
- afwijkende Unraid `.env`-paden;
|
||||||
|
- database-URL-interpolatie buiten het Unraid-`env_file`;
|
||||||
|
- URL-import die zonder staticfilesmanifest kon falen tijdens deployment checks;
|
||||||
|
- stale project-/assetversie en verouderd verificatierapport.
|
||||||
|
|
||||||
- nieuwe virtuele omgeving uit `uv.lock` opgebouwd;
|
## Omgevingsbeperking
|
||||||
- alle migraties op een lege SQLite-database toegepast;
|
|
||||||
- lokaal beheeraccount, standaardprofiel en één demovacature aangemaakt;
|
|
||||||
- volledige kwaliteitsgate opnieuw geslaagd met 43 tests en 80,42% dekking;
|
|
||||||
- ontwikkelserver gestart;
|
|
||||||
- `/health/live/` gaf `200` met `ok: true`;
|
|
||||||
- `/health/ready/` gaf `200` met databasecheck `ok`;
|
|
||||||
- `/login/` gaf `200` met de Nederlandstalige titel `Aanmelden · VacatureRadar`.
|
|
||||||
|
|
||||||
## Offline/reproduceerbare eigenschappen
|
De normale `uv sync --frozen` kon in deze uitvoeringsomgeving geen packages downloaden door DNS-uitval. Voor de regressies zijn de pure Python-packages uit de door de gebruiker aangeleverde gelockte Windows-omgeving gecombineerd met reeds aanwezige Linux-wheels. Daardoor konden Django, pytest en de volledige offline suite betrouwbaar draaien.
|
||||||
|
|
||||||
- De standaardtests doen geen live internetrequests.
|
De Ruff-Linuxbinary en Playwright-browser konden zonder netwerk niet worden geïnstalleerd. Pythoncompile, alle tests, Djangochecks, lijnlengtecontrole op gewijzigde Pythonbestanden en `git diff --check` zijn wel uitgevoerd. Een Dockerdaemon en de live Unraid/Nginx-omgeving waren niet beschikbaar.
|
||||||
- IMAP en Ollama staan standaard uit.
|
|
||||||
- Celery draait in tests eager en vereist geen Redis.
|
|
||||||
- SQLite wordt voor lokale bootstrap/tests gebruikt; productieconfiguratie ondersteunt PostgreSQL.
|
|
||||||
- Externe bronformaten worden via gesaneerde fixtures getest.
|
|
||||||
- `.venv`, caches, `.env`, lokale database, logs, media en back-ups worden door het packaging-script uitgesloten.
|
|
||||||
|
|
||||||
## Autonome overdracht
|
## Nog op de doelserver uitvoeren
|
||||||
|
|
||||||
Gevalideerd:
|
1. bestaande `.env` back-uppen;
|
||||||
|
2. `scripts/configure_public_url.py` uitvoeren;
|
||||||
|
3. container met `--force-recreate` herbouwen;
|
||||||
|
4. interne liveness/readiness controleren;
|
||||||
|
5. publieke TLS-, login-, logout- en CSRF-smoke uitvoeren;
|
||||||
|
6. demo-read-only controleren;
|
||||||
|
7. exacte Nginx Proxy Manager-CIDR instellen wanneer individuele client-IP-rate-limiting gewenst is;
|
||||||
|
8. databaseback-up en herstel-smoke uitvoeren.
|
||||||
|
|
||||||
```bash
|
Zie `docs/operations/NGINX_PROXY_MANAGER.md` en `docs/quality/PRODUCTION_AUDIT_2026-07-27.md`.
|
||||||
python scripts/backlog.py validate
|
|
||||||
python scripts/backlog.py next
|
|
||||||
```
|
|
||||||
|
|
||||||
Ledgerstatus bij oplevering:
|
|
||||||
|
|
||||||
- 10 taken `done`: de werkende MVP- en overdrachtsbasis;
|
|
||||||
- 17 taken `ready`: uitvoerbare product-, hardening- en releasestappen;
|
|
||||||
- 5 taken `blocked-external`: uitsluitend live bronreview, accounts, domein/registry of bulkdataset;
|
|
||||||
- 3 taken `deferred`: multi-tenant SaaS, automatisch solliciteren en algemene publieke API.
|
|
||||||
|
|
||||||
Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, accepted ADRs, een repo-eigen skill en een startprompt. Een externe blokkade mag alleen het live restpunt blokkeren; interface, tests, mocks en documentatie moeten eerst worden voltooid.
|
|
||||||
|
|
||||||
## Niet in deze omgeving uitgevoerd
|
|
||||||
|
|
||||||
Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving.
|
|
||||||
|
|
||||||
Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`.
|
|
||||||
|
|
||||||
## VR-118 — Stitch Intelligence Cockpit (2026-07-21)
|
|
||||||
|
|
||||||
Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd.
|
|
||||||
|
|
||||||
Uitgevoerde browsermatrix:
|
|
||||||
|
|
||||||
- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
|
|
||||||
- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat;
|
|
||||||
- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem;
|
|
||||||
- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline;
|
|
||||||
- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix.
|
|
||||||
|
|
||||||
Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer.
|
|
||||||
|
|
||||||
Volledige afrondingsgate:
|
|
||||||
|
|
||||||
- Ruff: geslaagd;
|
|
||||||
- Django system check: geslaagd;
|
|
||||||
- migratiecontrole: geen wijzigingen;
|
|
||||||
- pytest: 146 geslaagd, 2 optionele Playwrightvarianten overgeslagen met geslaagde HTML/a11y-fallback;
|
|
||||||
- branch-aware codedekking: 82,00%, boven de vereiste 70%;
|
|
||||||
- taakledger: 36 taken, waarvan 28 done, 5 blocked-external, 3 deferred en 0 ready;
|
|
||||||
- repositorydocumentatie en configuratie: geldig.
|
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ class EmailOrUsernameModelBackend(ModelBackend):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
user = user_model.objects.get(
|
user = user_model.objects.get(
|
||||||
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username})
|
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username}) | Q(email__iexact=username)
|
||||||
| Q(email__iexact=username)
|
|
||||||
)
|
)
|
||||||
except user_model.DoesNotExist:
|
except user_model.DoesNotExist:
|
||||||
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
|
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
|
||||||
@@ -34,9 +33,7 @@ class EmailOrUsernameModelBackend(ModelBackend):
|
|||||||
except user_model.MultipleObjectsReturned:
|
except user_model.MultipleObjectsReturned:
|
||||||
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
|
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
|
||||||
user = (
|
user = (
|
||||||
user_model.objects.filter(
|
user_model.objects.filter(**{f"{user_model.USERNAME_FIELD}__iexact": username})
|
||||||
**{f"{user_model.USERNAME_FIELD}__iexact": username}
|
|
||||||
)
|
|
||||||
.order_by("id")
|
.order_by("id")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,15 +2,35 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
|
|
||||||
|
|
||||||
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
||||||
|
match = request.resolver_match
|
||||||
|
route_key = (match.namespace, match.url_name) if match else (None, None)
|
||||||
|
page_contexts = {
|
||||||
|
("dashboard", "today"): "Dagoverzicht",
|
||||||
|
("dashboard", "system"): "Automatisering",
|
||||||
|
("jobs", "list"): "Vacatureverkenner",
|
||||||
|
("jobs", "detail"): "Vacatureanalyse",
|
||||||
|
("jobs", "applications"): "Sollicitatiepipeline",
|
||||||
|
("jobs", "application-edit"): "Sollicitatiedossier",
|
||||||
|
("jobs", "skill-insights"): "Skillsradar",
|
||||||
|
("jobs", "activity"): "Activiteit",
|
||||||
|
("jobs", "employers"): "Werkgevers",
|
||||||
|
("jobs", "employer-detail"): "Werkgeveranalyse",
|
||||||
|
("profiles", "list"): "Zoekprofiel",
|
||||||
|
("profiles", "edit"): "Radarconfiguratie",
|
||||||
|
("sources", "list"): "Bronbeheer",
|
||||||
|
}
|
||||||
context = {
|
context = {
|
||||||
"app_name": "VacatureRadar",
|
"app_name": "VacatureRadar",
|
||||||
"app_version": "0.3.0",
|
"app_version": settings.VACATURERADAR_VERSION,
|
||||||
|
"app_owner_name": settings.VACATURERADAR_OWNER_NAME,
|
||||||
|
"page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"),
|
||||||
}
|
}
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
context["navigation_profile"] = (
|
context["navigation_profile"] = (
|
||||||
@@ -18,4 +38,8 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
|||||||
.only("id", "name")
|
.only("id", "name")
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
context["demo_read_only"] = bool(
|
||||||
|
getattr(settings, "DEMO_READ_ONLY", True)
|
||||||
|
and request.user.username == settings.DEMO_USERNAME
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|||||||
@@ -45,9 +45,7 @@ def load_config() -> EntraConfig:
|
|||||||
)
|
)
|
||||||
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
|
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
|
||||||
allowed = tuple(
|
allowed = tuple(
|
||||||
d.lower().lstrip("@")
|
d.lower().lstrip("@") for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", []) if d
|
||||||
for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", [])
|
|
||||||
if d
|
|
||||||
)
|
)
|
||||||
return EntraConfig(
|
return EntraConfig(
|
||||||
client_id=client_id,
|
client_id=client_id,
|
||||||
@@ -91,10 +89,7 @@ def extract_identity(token_result: dict) -> tuple[str, str]:
|
|||||||
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
|
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
|
||||||
claims = token_result.get("id_token_claims") or {}
|
claims = token_result.get("id_token_claims") or {}
|
||||||
email = (
|
email = (
|
||||||
claims.get("preferred_username")
|
claims.get("preferred_username") or claims.get("email") or claims.get("upn") or ""
|
||||||
or claims.get("email")
|
|
||||||
or claims.get("upn")
|
|
||||||
or ""
|
|
||||||
).strip()
|
).strip()
|
||||||
name = (claims.get("name") or "").strip()
|
name = (claims.get("name") or "").strip()
|
||||||
return email, name
|
return email, name
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
from django.db.utils import OperationalError
|
from django.db.utils import DatabaseError
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -23,7 +26,20 @@ def readiness() -> HealthResult:
|
|||||||
cursor.execute("SELECT 1")
|
cursor.execute("SELECT 1")
|
||||||
cursor.fetchone()
|
cursor.fetchone()
|
||||||
checks["database"] = "ok"
|
checks["database"] = "ok"
|
||||||
except OperationalError as exc:
|
except DatabaseError as exc:
|
||||||
ok = False
|
ok = False
|
||||||
checks["database"] = f"error:{exc.__class__.__name__}"
|
checks["database"] = f"error:{exc.__class__.__name__}"
|
||||||
|
|
||||||
|
if getattr(settings, "HEALTHCHECK_REQUIRE_CACHE", False):
|
||||||
|
key = f"health:{secrets.token_hex(8)}"
|
||||||
|
try:
|
||||||
|
cache.set(key, "ok", timeout=10)
|
||||||
|
if cache.get(key) != "ok":
|
||||||
|
raise RuntimeError("cache roundtrip failed")
|
||||||
|
cache.delete(key)
|
||||||
|
checks["cache"] = "ok"
|
||||||
|
except Exception as exc: # Health boundary: report category, never payload/details.
|
||||||
|
ok = False
|
||||||
|
checks["cache"] = f"error:{exc.__class__.__name__}"
|
||||||
|
|
||||||
return HealthResult(ok=ok, checks=checks)
|
return HealthResult(ok=ok, checks=checks)
|
||||||
|
|||||||
@@ -2,11 +2,86 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
|
||||||
|
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:
|
class SecurityHeadersMiddleware:
|
||||||
"""Kleine CSP zonder externe assets; vacature-HTML wordt bovendien gesanitized."""
|
"""Small CSP without external assets; vacancy HTML is sanitized separately."""
|
||||||
|
|
||||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
@@ -33,4 +108,6 @@ class SecurityHeadersMiddleware:
|
|||||||
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
|
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
|
||||||
)
|
)
|
||||||
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
|
if not getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
|
||||||
|
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -6,12 +6,7 @@ from django.core.cache import cache
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.core.network import client_ip
|
||||||
def _client_ip(request: HttpRequest) -> str:
|
|
||||||
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "").strip()
|
|
||||||
if forwarded_for:
|
|
||||||
return forwarded_for.split(",")[0].strip()
|
|
||||||
return request.META.get("REMOTE_ADDR", "unknown").strip() or "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def _identity_for_user(request: HttpRequest) -> str:
|
def _identity_for_user(request: HttpRequest) -> str:
|
||||||
@@ -21,7 +16,7 @@ def _identity_for_user(request: HttpRequest) -> str:
|
|||||||
username = (
|
username = (
|
||||||
(request.POST.get("username", "") if request.method == "POST" else "").strip().lower()
|
(request.POST.get("username", "") if request.method == "POST" else "").strip().lower()
|
||||||
)
|
)
|
||||||
return f"anon:{username or _client_ip(request)}"
|
return f"anon:{username or client_ip(request)}"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models import Count, Q, Sum
|
from django.db.models import Count, Q, Sum
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -14,6 +15,32 @@ from apps.profiles.models import SearchProfile
|
|||||||
from apps.sources.models import Source, SourceRun
|
from apps.sources.models import Source, SourceRun
|
||||||
|
|
||||||
from ..health import readiness
|
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]:
|
def build_automation_cockpit() -> dict[str, Any]:
|
||||||
@@ -84,6 +111,7 @@ def build_automation_cockpit() -> dict[str, Any]:
|
|||||||
"unit": "verzonden / 24u",
|
"unit": "verzonden / 24u",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
runtime_heartbeat = _runtime_heartbeat_state()
|
||||||
return {
|
return {
|
||||||
"health": readiness(),
|
"health": readiness(),
|
||||||
"source_total": source_total,
|
"source_total": source_total,
|
||||||
@@ -103,7 +131,8 @@ def build_automation_cockpit() -> dict[str, Any]:
|
|||||||
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
||||||
"queue_state": "Eager/lokaal"
|
"queue_state": "Eager/lokaal"
|
||||||
if settings.CELERY_TASK_ALWAYS_EAGER
|
if settings.CELERY_TASK_ALWAYS_EAGER
|
||||||
else "Geconfigureerd, worker niet gemeten",
|
else "Diepte niet beschikbaar",
|
||||||
|
"runtime_heartbeat": runtime_heartbeat,
|
||||||
"ai_state": "Geconfigureerd"
|
"ai_state": "Geconfigureerd"
|
||||||
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
||||||
else "Niet geconfigureerd",
|
else "Niet geconfigureerd",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -7,7 +7,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth import get_user_model, login
|
from django.contrib.auth import get_user_model, login
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.auth.views import LoginView
|
from django.contrib.auth.views import LoginView
|
||||||
from django.http import JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
@@ -147,9 +147,7 @@ def entra_login(request):
|
|||||||
return redirect("login")
|
return redirect("login")
|
||||||
try:
|
try:
|
||||||
config = entra_sso.load_config()
|
config = entra_sso.load_config()
|
||||||
redirect_uri = config.redirect_uri or request.build_absolute_uri(
|
redirect_uri = config.redirect_uri or request.build_absolute_uri(reverse("entra-callback"))
|
||||||
reverse("entra-callback")
|
|
||||||
)
|
|
||||||
flow = entra_sso.build_auth_flow(config, redirect_uri)
|
flow = entra_sso.build_auth_flow(config, redirect_uri)
|
||||||
except entra_sso.EntraConfigError as exc:
|
except entra_sso.EntraConfigError as exc:
|
||||||
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||||
@@ -183,9 +181,7 @@ def entra_callback(request):
|
|||||||
return redirect("login")
|
return redirect("login")
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
logger.warning(
|
logger.warning("Entra ID-fout: %s", result.get("error_description") or result.get("error"))
|
||||||
"Entra ID-fout: %s", result.get("error_description") or result.get("error")
|
|
||||||
)
|
|
||||||
messages.error(request, "Inloggen via Entra ID is mislukt.")
|
messages.error(request, "Inloggen via Entra ID is mislukt.")
|
||||||
return redirect("login")
|
return redirect("login")
|
||||||
|
|
||||||
@@ -232,10 +228,19 @@ def demo_login(request):
|
|||||||
logger.exception("Kon de demo-omgeving niet seeden")
|
logger.exception("Kon de demo-omgeving niet seeden")
|
||||||
|
|
||||||
login(request, user, backend=SSO_BACKEND)
|
login(request, user, backend=SSO_BACKEND)
|
||||||
|
request.session.set_expiry(settings.DEMO_SESSION_SECONDS)
|
||||||
next_url = _safe_next(request, request.POST.get("next"))
|
next_url = _safe_next(request, request.POST.get("next"))
|
||||||
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def robots_txt(request):
|
||||||
|
if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
|
||||||
|
content = "User-agent: *\nAllow: /\n"
|
||||||
|
else:
|
||||||
|
content = "User-agent: *\nDisallow: /\n"
|
||||||
|
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||||
|
|
||||||
|
|
||||||
def health_live(request):
|
def health_live(request):
|
||||||
return JsonResponse({"ok": True, "service": "vacatureradar"})
|
return JsonResponse({"ok": True, "service": "vacatureradar"})
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||||
from apps.profiles.models import ProfileRevision
|
from apps.profiles.models import ProfileRevision
|
||||||
from apps.sources.models import SourceRun
|
from apps.sources.models import Source, SourceRun
|
||||||
|
|
||||||
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||||
|
|
||||||
@@ -37,7 +37,8 @@ ACTIVITY_FILTERS = (
|
|||||||
|
|
||||||
def _source_events() -> list[ActivityEvent]:
|
def _source_events() -> list[ActivityEvent]:
|
||||||
events = []
|
events = []
|
||||||
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]:
|
runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||||
|
for run in runs.select_related("source").order_by("-started_at")[:100]:
|
||||||
detail = (
|
detail = (
|
||||||
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||||
f"{run.duplicate_count} duplicaat"
|
f"{run.duplicate_count} duplicaat"
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ def annotate_latest_profile_score(
|
|||||||
|
|
||||||
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
||||||
activity: list[dict[str, Any]] = []
|
activity: list[dict[str, Any]] = []
|
||||||
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]:
|
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(
|
activity.append(
|
||||||
{
|
{
|
||||||
"occurred_at": run.started_at,
|
"occurred_at": run.started_at,
|
||||||
@@ -137,7 +138,9 @@ def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[s
|
|||||||
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
|
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:8]),
|
# Het dagoverzicht blijft een beslissingsscherm; de volledige rangschikking
|
||||||
|
# hoort in de verkenner. Vijf kaarten houden de primaire taak scanbaar.
|
||||||
|
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:5]),
|
||||||
"priority_applications": list(
|
"priority_applications": list(
|
||||||
open_applications.select_related("job", "job__employer").order_by(
|
open_applications.select_related("job", "job__employer").order_by(
|
||||||
F("follow_up_date").asc(nulls_last=True), "-updated_at"
|
F("follow_up_date").asc(nulls_last=True), "-updated_at"
|
||||||
@@ -152,7 +155,9 @@ def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[s
|
|||||||
"source_counts": source_counts,
|
"source_counts": source_counts,
|
||||||
"recent_activity": _recent_dashboard_activity(user),
|
"recent_activity": _recent_dashboard_activity(user),
|
||||||
"day_conclusion": day_conclusion,
|
"day_conclusion": day_conclusion,
|
||||||
"latest_scan": SourceRun.objects.order_by("-started_at").first(),
|
"latest_scan": SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||||
|
.order_by("-started_at")
|
||||||
|
.first(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -122,11 +122,10 @@ def demo_environment_seeded(user) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def seed_demo_environment(user) -> None:
|
def seed_demo_environment(user) -> None:
|
||||||
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``."""
|
"""Maak of herstel een fictieve, tijdsbestendige demo-omgeving voor ``user``."""
|
||||||
if demo_environment_seeded(user):
|
|
||||||
return
|
|
||||||
|
|
||||||
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
|
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(
|
profile, _ = SearchProfile.objects.get_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
@@ -153,6 +152,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
profile.save(update_fields=["is_active"])
|
profile.save(update_fields=["is_active"])
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
today = timezone.localdate(now)
|
||||||
workplace_map = {
|
workplace_map = {
|
||||||
"hybrid": JobPosting.Workplace.HYBRID,
|
"hybrid": JobPosting.Workplace.HYBRID,
|
||||||
"remote": JobPosting.Workplace.REMOTE,
|
"remote": JobPosting.Workplace.REMOTE,
|
||||||
@@ -185,7 +185,7 @@ def seed_demo_environment(user) -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
slug = _digest("demo-job", str(index), title, employer_name)
|
slug = _digest("demo-job", str(index), title, employer_name)
|
||||||
job, job_created = JobPosting.objects.get_or_create(
|
job, _ = JobPosting.objects.update_or_create(
|
||||||
canonical_key=slug,
|
canonical_key=slug,
|
||||||
defaults={
|
defaults={
|
||||||
"employer": employer,
|
"employer": employer,
|
||||||
@@ -221,6 +221,11 @@ def seed_demo_environment(user) -> None:
|
|||||||
)
|
)
|
||||||
created_jobs.append(job)
|
created_jobs.append(job)
|
||||||
|
|
||||||
|
ScoreRun.objects.filter(
|
||||||
|
job=job,
|
||||||
|
profile=profile,
|
||||||
|
model_version="demo-seed",
|
||||||
|
).delete()
|
||||||
ScoreRun.objects.create(
|
ScoreRun.objects.create(
|
||||||
job=job,
|
job=job,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
@@ -244,26 +249,65 @@ def seed_demo_environment(user) -> None:
|
|||||||
|
|
||||||
# Een kleine sollicitatiepijplijn voor de demo.
|
# Een kleine sollicitatiepijplijn voor de demo.
|
||||||
if created_jobs:
|
if created_jobs:
|
||||||
Application.objects.get_or_create(
|
Application.objects.update_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
job=created_jobs[0],
|
job=created_jobs[0],
|
||||||
defaults={
|
defaults={
|
||||||
"status": Application.Status.INTERVIEW,
|
"status": Application.Status.INTERVIEW,
|
||||||
"applied_at": now - timedelta(days=6),
|
"applied_at": now - timedelta(days=6),
|
||||||
"follow_up_date": (now + timedelta(days=2)).date(),
|
"follow_up_date": today + timedelta(days=2),
|
||||||
"contact_name": "Sofie Vandael",
|
"contact_name": "Sofie Vandael",
|
||||||
"contact_email": "sofie.vandael@cloudnexus.example",
|
"contact_email": "sofie.vandael@cloudnexus.example",
|
||||||
"notes": "Eerste gesprek gepland. Demo-data.",
|
"notes": "Eerste gesprek gepland. Demo-data.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if len(created_jobs) > 3:
|
if len(created_jobs) > 3:
|
||||||
Application.objects.get_or_create(
|
Application.objects.update_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
job=created_jobs[3],
|
job=created_jobs[3],
|
||||||
defaults={
|
defaults={
|
||||||
"status": Application.Status.APPLIED,
|
"status": Application.Status.APPLIED,
|
||||||
"applied_at": now - timedelta(days=2),
|
"applied_at": now - timedelta(days=2),
|
||||||
"follow_up_date": (now + timedelta(days=5)).date(),
|
"follow_up_date": today + timedelta(days=5),
|
||||||
"notes": "Sollicitatie verstuurd. Demo-data.",
|
"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()
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field as dataclass_field
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.jobs.models import Employer, FieldProvenance, JobPosting, JobSourceAlias, JobVersion
|
from apps.jobs.models import (
|
||||||
|
Employer,
|
||||||
|
FieldProvenance,
|
||||||
|
JobPosting,
|
||||||
|
JobSourceAlias,
|
||||||
|
JobVersion,
|
||||||
|
ScoreRun,
|
||||||
|
)
|
||||||
from apps.profiles.models import SearchProfile
|
from apps.profiles.models import SearchProfile
|
||||||
from apps.sources.adapters.base import FieldEvidence
|
from apps.sources.adapters.base import FieldEvidence
|
||||||
from apps.sources.adapters.registry import registry
|
from apps.sources.adapters.registry import registry
|
||||||
@@ -29,17 +38,31 @@ RECRUITER_TERMS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PersistenceContext:
|
||||||
|
"""Run-scoped reference cache; never shared between workers or imports."""
|
||||||
|
|
||||||
|
employers: dict[tuple[str, str], Employer | None] = dataclass_field(default_factory=dict)
|
||||||
|
active_profiles: list[SearchProfile] | None = None
|
||||||
|
latest_scores: dict[tuple[object, int], ScoreRun] = dataclass_field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
def _confidence(value: float) -> Decimal:
|
def _confidence(value: float) -> Decimal:
|
||||||
return Decimal(str(max(0.0, min(1.0, value))))
|
return Decimal(str(max(0.0, min(1.0, value))))
|
||||||
|
|
||||||
|
|
||||||
def resolve_employer(draft: CanonicalJobDraft) -> Employer | None:
|
def resolve_employer(
|
||||||
|
draft: CanonicalJobDraft, *, context: PersistenceContext | None = None
|
||||||
|
) -> Employer | None:
|
||||||
name = draft.employer_name.strip()
|
name = draft.employer_name.strip()
|
||||||
domain = draft.employer_domain.strip()
|
domain = draft.employer_domain.strip()
|
||||||
if not name and not domain:
|
if not name and not domain:
|
||||||
return None
|
return None
|
||||||
display_name = name or domain
|
display_name = name or domain
|
||||||
normalized = normalize_token(display_name)
|
normalized = normalize_token(display_name)
|
||||||
|
cache_key = (normalized, domain)
|
||||||
|
if context is not None and cache_key in context.employers:
|
||||||
|
return context.employers[cache_key]
|
||||||
recruiter = any(term in normalized for term in RECRUITER_TERMS)
|
recruiter = any(term in normalized for term in RECRUITER_TERMS)
|
||||||
employer, _ = Employer.objects.get_or_create(
|
employer, _ = Employer.objects.get_or_create(
|
||||||
normalized_name=normalized,
|
normalized_name=normalized,
|
||||||
@@ -61,6 +84,8 @@ def resolve_employer(draft: CanonicalJobDraft) -> Employer | None:
|
|||||||
changed.extend(["is_recruiter", "is_direct_employer"])
|
changed.extend(["is_recruiter", "is_direct_employer"])
|
||||||
if changed:
|
if changed:
|
||||||
employer.save(update_fields=[*changed, "updated_at"])
|
employer.save(update_fields=[*changed, "updated_at"])
|
||||||
|
if context is not None:
|
||||||
|
context.employers[cache_key] = employer
|
||||||
return employer
|
return employer
|
||||||
|
|
||||||
|
|
||||||
@@ -162,12 +187,81 @@ def _apply_draft(
|
|||||||
if direct and canonical_url and job.canonical_url != canonical_url:
|
if direct and canonical_url and job.canonical_url != canonical_url:
|
||||||
job.canonical_url = canonical_url
|
job.canonical_url = canonical_url
|
||||||
changed.append("canonical_url")
|
changed.append("canonical_url")
|
||||||
if changed and "last_changed" not in changed:
|
substantive_changes = [field for field in changed if field != "last_seen"]
|
||||||
|
if substantive_changes and "last_changed" not in changed:
|
||||||
job.last_changed = timezone.now()
|
job.last_changed = timezone.now()
|
||||||
changed.append("last_changed")
|
changed.append("last_changed")
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_evidence(
|
||||||
|
*,
|
||||||
|
job: JobPosting,
|
||||||
|
alias: JobSourceAlias,
|
||||||
|
evidence_items: list[FieldEvidence],
|
||||||
|
parser_version: str,
|
||||||
|
) -> None:
|
||||||
|
existing = {
|
||||||
|
(item.field_name, item.extraction_method): item
|
||||||
|
for item in FieldProvenance.objects.filter(job=job, source_alias=alias)
|
||||||
|
}
|
||||||
|
creates: list[FieldProvenance] = []
|
||||||
|
updates: list[FieldProvenance] = []
|
||||||
|
for evidence in evidence_items:
|
||||||
|
key = (evidence.field_name, evidence.method)
|
||||||
|
values = {
|
||||||
|
"confidence": _confidence(evidence.confidence),
|
||||||
|
"evidence_excerpt": evidence.evidence[:1000],
|
||||||
|
"parser_version": parser_version,
|
||||||
|
}
|
||||||
|
current = existing.get(key)
|
||||||
|
if current is None:
|
||||||
|
creates.append(
|
||||||
|
FieldProvenance(
|
||||||
|
job=job,
|
||||||
|
source_alias=alias,
|
||||||
|
field_name=evidence.field_name,
|
||||||
|
extraction_method=evidence.method,
|
||||||
|
**values,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
changed = False
|
||||||
|
for field_name, value in values.items():
|
||||||
|
if getattr(current, field_name) != value:
|
||||||
|
setattr(current, field_name, value)
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
current.updated_at = timezone.now()
|
||||||
|
updates.append(current)
|
||||||
|
if creates:
|
||||||
|
FieldProvenance.objects.bulk_create(creates, batch_size=250)
|
||||||
|
if updates:
|
||||||
|
FieldProvenance.objects.bulk_update(
|
||||||
|
updates,
|
||||||
|
["confidence", "evidence_excerpt", "parser_version", "updated_at"],
|
||||||
|
batch_size=250,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_score(score: ScoreRun) -> ScoreRun:
|
||||||
|
return ScoreRun.objects.create(
|
||||||
|
job=score.job,
|
||||||
|
profile=score.profile,
|
||||||
|
profile_version=score.profile_version,
|
||||||
|
score=score.score,
|
||||||
|
confidence=score.confidence,
|
||||||
|
recommendation=score.recommendation,
|
||||||
|
components=score.components,
|
||||||
|
positives=score.positives,
|
||||||
|
concerns=score.concerns,
|
||||||
|
hard_exclusions=score.hard_exclusions,
|
||||||
|
evidence=score.evidence,
|
||||||
|
model_version=score.model_version,
|
||||||
|
prompt_version=score.prompt_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def persist_draft(
|
def persist_draft(
|
||||||
draft: CanonicalJobDraft,
|
draft: CanonicalJobDraft,
|
||||||
@@ -176,14 +270,16 @@ def persist_draft(
|
|||||||
parser_key: str,
|
parser_key: str,
|
||||||
parser_version: str,
|
parser_version: str,
|
||||||
extraction_confidence: float,
|
extraction_confidence: float,
|
||||||
|
context: PersistenceContext | None = None,
|
||||||
) -> tuple[JobPosting, DedupeDecision, bool]:
|
) -> tuple[JobPosting, DedupeDecision, bool]:
|
||||||
source = document.source
|
source = document.source
|
||||||
employer = resolve_employer(draft)
|
employer = resolve_employer(draft, context=context)
|
||||||
decision = find_existing_job(draft, source=source)
|
decision = find_existing_job(draft, source=source)
|
||||||
direct = _source_is_direct(source, draft)
|
direct = _source_is_direct(source, draft)
|
||||||
if decision.resolved_direct:
|
if decision.resolved_direct:
|
||||||
direct = True
|
direct = True
|
||||||
created = False
|
created = False
|
||||||
|
substantive_change = False
|
||||||
|
|
||||||
if decision.job is None:
|
if decision.job is None:
|
||||||
try:
|
try:
|
||||||
@@ -240,6 +336,7 @@ def persist_draft(
|
|||||||
if extraction_confidence > float(job.extraction_confidence):
|
if extraction_confidence > float(job.extraction_confidence):
|
||||||
job.extraction_confidence = _confidence(extraction_confidence)
|
job.extraction_confidence = _confidence(extraction_confidence)
|
||||||
changed.append("extraction_confidence")
|
changed.append("extraction_confidence")
|
||||||
|
substantive_change = any(field not in {"last_seen", "last_changed"} for field in changed)
|
||||||
if changed:
|
if changed:
|
||||||
job.save(update_fields=list(dict.fromkeys([*changed, "updated_at"])))
|
job.save(update_fields=list(dict.fromkeys([*changed, "updated_at"])))
|
||||||
|
|
||||||
@@ -284,31 +381,58 @@ def persist_draft(
|
|||||||
update_fields=["last_seen", "raw_document", "payload", "is_canonical", "updated_at"]
|
update_fields=["last_seen", "raw_document", "payload", "is_canonical", "updated_at"]
|
||||||
)
|
)
|
||||||
|
|
||||||
for evidence in draft.evidence:
|
_sync_evidence(
|
||||||
FieldProvenance.objects.update_or_create(
|
|
||||||
job=job,
|
job=job,
|
||||||
source_alias=alias,
|
alias=alias,
|
||||||
field_name=evidence.field_name,
|
evidence_items=draft.evidence,
|
||||||
extraction_method=evidence.method,
|
parser_version=parser_version,
|
||||||
defaults={
|
|
||||||
"confidence": _confidence(evidence.confidence),
|
|
||||||
"evidence_excerpt": evidence.evidence[:1000],
|
|
||||||
"parser_version": parser_version,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if created or substantive_change:
|
||||||
JobVersion.objects.get_or_create(
|
JobVersion.objects.get_or_create(
|
||||||
job=job,
|
job=job,
|
||||||
content_hash=job.content_hash,
|
content_hash=job.content_hash,
|
||||||
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
|
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
|
||||||
)
|
)
|
||||||
for profile in SearchProfile.objects.filter(is_active=True):
|
if context is not None:
|
||||||
score_and_save(job, profile)
|
if context.active_profiles is None:
|
||||||
|
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
|
||||||
|
profiles = context.active_profiles
|
||||||
|
else:
|
||||||
|
profiles = SearchProfile.objects.filter(is_active=True)
|
||||||
|
for profile in profiles:
|
||||||
|
score = score_and_save(job, profile)
|
||||||
|
if context is not None:
|
||||||
|
context.latest_scores[(job.pk, profile.pk)] = score
|
||||||
|
else:
|
||||||
|
if context is not None:
|
||||||
|
if context.active_profiles is None:
|
||||||
|
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
|
||||||
|
profiles = context.active_profiles
|
||||||
|
else:
|
||||||
|
profiles = SearchProfile.objects.filter(is_active=True)
|
||||||
|
for profile in profiles:
|
||||||
|
cache_key = (job.pk, profile.pk)
|
||||||
|
previous = context.latest_scores.get(cache_key) if context is not None else None
|
||||||
|
if previous is None:
|
||||||
|
previous = (
|
||||||
|
ScoreRun.objects.filter(job=job, profile=profile)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if previous is None or previous.profile_version != profile.version:
|
||||||
|
score = score_and_save(job, profile)
|
||||||
|
else:
|
||||||
|
score = _copy_score(previous)
|
||||||
|
if context is not None:
|
||||||
|
context.latest_scores[cache_key] = score
|
||||||
return job, decision, created
|
return job, decision, created
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def process_raw_document(document: RawDocument) -> dict[str, int | str | list[str]]:
|
def process_raw_document(
|
||||||
|
document: RawDocument, *, context: PersistenceContext | None = None
|
||||||
|
) -> dict[str, int | str | list[str]]:
|
||||||
result = registry.extract(document)
|
result = registry.extract(document)
|
||||||
document.parser_key = result.parser_key
|
document.parser_key = result.parser_key
|
||||||
document.parser_version = result.parser_version
|
document.parser_version = result.parser_version
|
||||||
@@ -341,6 +465,7 @@ def process_raw_document(document: RawDocument) -> dict[str, int | str | list[st
|
|||||||
parser_key=result.parser_key,
|
parser_key=result.parser_key,
|
||||||
parser_version=result.parser_version,
|
parser_version=result.parser_version,
|
||||||
extraction_confidence=result.confidence,
|
extraction_confidence=result.confidence,
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
if was_created:
|
if was_created:
|
||||||
created += 1
|
created += 1
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class JobListView(LoginRequiredMixin, ListView):
|
|||||||
model = JobPosting
|
model = JobPosting
|
||||||
template_name = "jobs/list.html"
|
template_name = "jobs/list.html"
|
||||||
context_object_name = "jobs"
|
context_object_name = "jobs"
|
||||||
paginate_by = 30
|
paginate_by = 20
|
||||||
|
|
||||||
VALID_RECOMMENDATIONS = {
|
VALID_RECOMMENDATIONS = {
|
||||||
ScoreRun.Recommendation.STRONG,
|
ScoreRun.Recommendation.STRONG,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,13 +9,15 @@ from .base import ExtractedJob, ExtractionResult, FieldEvidence
|
|||||||
|
|
||||||
LABEL_PATTERNS = {
|
LABEL_PATTERNS = {
|
||||||
"location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I),
|
"location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I),
|
||||||
|
"date_posted": re.compile(r"^(publicatiedatum|geplaatst|date posted|published)\s*:?$", re.I),
|
||||||
|
"employment_type": re.compile(r"^(dienstverband|contract|employment type)\s*:?$", re.I),
|
||||||
"employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I),
|
"employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class GenericHtmlAdapter:
|
class GenericHtmlAdapter:
|
||||||
parser_key = "generic-html"
|
parser_key = "generic-html"
|
||||||
parser_version = "1.0.0"
|
parser_version = "1.1.0"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _meta(soup: BeautifulSoup, *names: str) -> str:
|
def _meta(soup: BeautifulSoup, *names: str) -> str:
|
||||||
@@ -59,6 +61,8 @@ class GenericHtmlAdapter:
|
|||||||
soup, LABEL_PATTERNS["employer"]
|
soup, LABEL_PATTERNS["employer"]
|
||||||
)
|
)
|
||||||
location = self._label_value(soup, LABEL_PATTERNS["location"])
|
location = self._label_value(soup, LABEL_PATTERNS["location"])
|
||||||
|
date_posted = self._label_value(soup, LABEL_PATTERNS["date_posted"])
|
||||||
|
employment_type = self._label_value(soup, LABEL_PATTERNS["employment_type"])
|
||||||
main = soup.find("main") or soup.find("article") or soup.body
|
main = soup.find("main") or soup.find("article") or soup.body
|
||||||
description_html = str(main) if main else ""
|
description_html = str(main) if main else ""
|
||||||
description_text = (
|
description_text = (
|
||||||
@@ -74,6 +78,12 @@ class GenericHtmlAdapter:
|
|||||||
FieldEvidence("location_text", "html-label", 0.62, location[:240]),
|
FieldEvidence("location_text", "html-label", 0.62, location[:240]),
|
||||||
FieldEvidence("description", "html-main", 0.70, description_text[:300]),
|
FieldEvidence("description", "html-main", 0.70, description_text[:300]),
|
||||||
]
|
]
|
||||||
|
if date_posted:
|
||||||
|
evidence.append(FieldEvidence("date_posted", "html-label", 0.62, date_posted[:40]))
|
||||||
|
if employment_type:
|
||||||
|
evidence.append(
|
||||||
|
FieldEvidence("employment_types", "html-label", 0.62, employment_type[:120])
|
||||||
|
)
|
||||||
job = ExtractedJob(
|
job = ExtractedJob(
|
||||||
url=job_url,
|
url=job_url,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -81,6 +91,8 @@ class GenericHtmlAdapter:
|
|||||||
location_text=location,
|
location_text=location,
|
||||||
description_html=description_html,
|
description_html=description_html,
|
||||||
description_text=description_text,
|
description_text=description_text,
|
||||||
|
date_posted=date_posted,
|
||||||
|
employment_types=[employment_type] if employment_type else [],
|
||||||
raw={"generic_html": True},
|
raw={"generic_html": True},
|
||||||
evidence=evidence,
|
evidence=evidence,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
|
|||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label="App-wachtwoord",
|
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(
|
widget=forms.PasswordInput(
|
||||||
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
||||||
render_value=False,
|
render_value=False,
|
||||||
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
|
|||||||
data = super().clean()
|
data = super().clean()
|
||||||
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
||||||
data["custom_host"] = ""
|
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.")
|
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
|
||||||
return data
|
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),
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
|
|||||||
custom_host = models.CharField(max_length=255, blank=True)
|
custom_host = models.CharField(max_length=255, blank=True)
|
||||||
port = models.PositiveIntegerField(default=993)
|
port = models.PositiveIntegerField(default=993)
|
||||||
username = models.CharField(max_length=320)
|
username = models.CharField(max_length=320)
|
||||||
encrypted_password = models.TextField()
|
encrypted_password = models.TextField(blank=True)
|
||||||
mailbox = models.CharField(max_length=255, default="INBOX")
|
mailbox = models.CharField(max_length=255, default="INBOX")
|
||||||
enabled = models.BooleanField(default=True)
|
enabled = models.BooleanField(default=True)
|
||||||
poll_interval_minutes = models.PositiveIntegerField(
|
poll_interval_minutes = models.PositiveIntegerField(
|
||||||
|
|||||||
@@ -74,14 +74,19 @@ def _validate_connected_peer(
|
|||||||
*,
|
*,
|
||||||
before: ValidatedUrl,
|
before: ValidatedUrl,
|
||||||
after: ValidatedUrl,
|
after: ValidatedUrl,
|
||||||
|
require_peer: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
peer_ip = _connected_peer_ip(response)
|
peer_ip = _connected_peer_ip(response)
|
||||||
if peer_ip is not None:
|
if peer_ip is not None:
|
||||||
if not peer_ip.is_global:
|
if not peer_ip.is_global:
|
||||||
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
||||||
return
|
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
|
# Mock/custom transports do not always expose the peer socket. Keep the conservative
|
||||||
# DNS-overlap fallback for those transports.
|
# DNS-overlap fallback only for explicitly injected test/custom clients.
|
||||||
if not set(before.addresses).intersection(after.addresses):
|
if not set(before.addresses).intersection(after.addresses):
|
||||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||||
|
|
||||||
@@ -175,6 +180,7 @@ def fetch_url(
|
|||||||
response,
|
response,
|
||||||
before=validation,
|
before=validation,
|
||||||
after=post_validation,
|
after=post_validation,
|
||||||
|
require_peer=own_client,
|
||||||
)
|
)
|
||||||
if response.status_code in {301, 302, 303, 307, 308}:
|
if response.status_code in {301, 302, 303, 307, 308}:
|
||||||
location = response.headers.get("location")
|
location = response.headers.get("location")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from django.conf import settings
|
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.models import EmailMessageRecord, MailboxConnection
|
||||||
from apps.sources.services.email_import import ingest_email, message_identity
|
from apps.sources.services.email_import import ingest_email, message_identity
|
||||||
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
||||||
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
|
|||||||
if client_factory is None:
|
if client_factory is None:
|
||||||
client_factory = imaplib.IMAP4_SSL
|
client_factory = imaplib.IMAP4_SSL
|
||||||
host_validator(f"https://{connection.imap_host}")
|
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}
|
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
|
||||||
if client_factory is imaplib.IMAP4_SSL:
|
if client_factory is imaplib.IMAP4_SSL:
|
||||||
client_options["ssl_context"] = trusted_tls_context()
|
client_options["ssl_context"] = trusted_tls_context()
|
||||||
@@ -42,6 +47,12 @@ def poll_imap_mailbox(
|
|||||||
"failed": 0,
|
"failed": 0,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
if connection.provider == MailboxConnection.Provider.OUTLOOK:
|
||||||
|
access_token = acquire_exchange_access_token()
|
||||||
|
client.authenticate(
|
||||||
|
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
|
||||||
|
)
|
||||||
|
else:
|
||||||
client.login(connection.username, password)
|
client.login(connection.username, password)
|
||||||
status, _ = client.select(connection.mailbox, readonly=True)
|
status, _ = client.select(connection.mailbox, readonly=True)
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
|
|||||||
def rotate_mailbox_credentials() -> int:
|
def rotate_mailbox_credentials() -> int:
|
||||||
"""Re-encrypt every mailbox password with the first configured key."""
|
"""Re-encrypt every mailbox password with the first configured key."""
|
||||||
rotated = 0
|
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)
|
password = decrypt_mailbox_password(connection)
|
||||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||||
connection.save(update_fields=["encrypted_password", "updated_at"])
|
connection.save(update_fields=["encrypted_password", "updated_at"])
|
||||||
@@ -79,7 +81,10 @@ def save_mailbox_connection(
|
|||||||
"poll_interval_minutes",
|
"poll_interval_minutes",
|
||||||
):
|
):
|
||||||
setattr(connection, field, cleaned_data[field])
|
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)
|
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||||
elif not connection.encrypted_password:
|
elif not connection.encrypted_password:
|
||||||
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ def schedule_due_sources() -> dict[str, int]:
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
due = Source.objects.filter(
|
due = Source.objects.filter(
|
||||||
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
|
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])
|
ids = list(due.values_list("id", flat=True).distinct()[:200])
|
||||||
for source_id in ids:
|
for source_id in ids:
|
||||||
fetch_source.delay(source_id)
|
fetch_source.delay(source_id)
|
||||||
@@ -200,6 +201,7 @@ def schedule_mailbox_polls() -> dict[str, int]:
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
due_ids = list(
|
due_ids = list(
|
||||||
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
|
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))
|
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
|
||||||
.values_list("id", flat=True)[:100]
|
.values_list("id", flat=True)[:100]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -29,6 +30,63 @@ def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def env_cidrs(name: str, default: str = "") -> list[str]:
|
||||||
|
networks: list[str] = []
|
||||||
|
for value in env_list(name, default):
|
||||||
|
try:
|
||||||
|
networks.append(str(ipaddress.ip_network(value, strict=False)))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ImproperlyConfigured(f"{name} bevat een ongeldig IP-netwerk: {value}") from exc
|
||||||
|
return networks
|
||||||
|
|
||||||
|
|
||||||
|
def merge_unique(*groups: list[str]) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for group in groups:
|
||||||
|
for value in group:
|
||||||
|
key = value.casefold()
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_public_base_url(value: str) -> tuple[str, str]:
|
||||||
|
raw = value.strip().rstrip("/")
|
||||||
|
if not raw:
|
||||||
|
return "", ""
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise ImproperlyConfigured("PUBLIC_BASE_URL moet een geldige http(s)-URL zijn.")
|
||||||
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"PUBLIC_BASE_URL mag geen credentials, query of fragment bevatten."
|
||||||
|
)
|
||||||
|
if parsed.path not in {"", "/"}:
|
||||||
|
raise ImproperlyConfigured("PUBLIC_BASE_URL mag geen applicatiepad bevatten.")
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ImproperlyConfigured("PUBLIC_BASE_URL bevat een ongeldige poort.") from exc
|
||||||
|
host = parsed.hostname.casefold()
|
||||||
|
display_host = f"[{host}]" if ":" in host else host
|
||||||
|
default_port = 443 if parsed.scheme == "https" else 80
|
||||||
|
port_suffix = f":{port}" if port and port != default_port else ""
|
||||||
|
return f"{parsed.scheme}://{display_host}{port_suffix}", host
|
||||||
|
|
||||||
|
|
||||||
|
def configured_public_base_url() -> tuple[str, str]:
|
||||||
|
raw = os.getenv("PUBLIC_BASE_URL", "").strip()
|
||||||
|
if not raw:
|
||||||
|
app_host = os.getenv("APP_HOST", "").strip()
|
||||||
|
if app_host:
|
||||||
|
scheme = os.getenv("APP_SCHEME", "https").strip().lower() or "https"
|
||||||
|
public_port = os.getenv("APP_PUBLIC_PORT", "").strip()
|
||||||
|
raw = f"{scheme}://{app_host}{f':{public_port}' if public_port else ''}"
|
||||||
|
return normalize_public_base_url(raw)
|
||||||
|
|
||||||
|
|
||||||
def _validate_production_security(
|
def _validate_production_security(
|
||||||
*,
|
*,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
@@ -38,6 +96,9 @@ def _validate_production_security(
|
|||||||
session_cookie_secure: bool,
|
session_cookie_secure: bool,
|
||||||
csrf_cookie_secure: bool,
|
csrf_cookie_secure: bool,
|
||||||
secure_ssl_redirect: bool,
|
secure_ssl_redirect: bool,
|
||||||
|
public_base_url: str = "",
|
||||||
|
demo_mode_enabled: bool = False,
|
||||||
|
demo_read_only: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
if debug:
|
if debug:
|
||||||
return
|
return
|
||||||
@@ -49,6 +110,8 @@ def _validate_production_security(
|
|||||||
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
|
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
|
||||||
if not allowed_hosts:
|
if not allowed_hosts:
|
||||||
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.")
|
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.")
|
||||||
|
if any(host.strip() == "*" for host in allowed_hosts):
|
||||||
|
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie geen wildcard bevatten.")
|
||||||
if not csrf_trusted_origins:
|
if not csrf_trusted_origins:
|
||||||
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
|
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
|
||||||
if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins):
|
if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins):
|
||||||
@@ -57,13 +120,26 @@ def _validate_production_security(
|
|||||||
raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.")
|
raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.")
|
||||||
if not secure_ssl_redirect:
|
if not secure_ssl_redirect:
|
||||||
raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.")
|
raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.")
|
||||||
|
if public_base_url and not public_base_url.startswith("https://"):
|
||||||
|
raise ImproperlyConfigured("PUBLIC_BASE_URL moet HTTPS gebruiken in productie.")
|
||||||
|
if demo_mode_enabled and not demo_read_only:
|
||||||
|
raise ImproperlyConfigured("Een publieke productiedemo moet alleen-lezen zijn.")
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||||
DEBUG = env_bool("DJANGO_DEBUG", True)
|
DEBUG = env_bool("DJANGO_DEBUG", True)
|
||||||
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver")
|
PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url()
|
||||||
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
|
ALLOWED_HOSTS = merge_unique(
|
||||||
|
env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver"),
|
||||||
|
[PUBLIC_HOST] if PUBLIC_HOST else [],
|
||||||
|
)
|
||||||
|
CSRF_TRUSTED_ORIGINS = merge_unique(
|
||||||
|
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
|
||||||
|
[PUBLIC_BASE_URL] if PUBLIC_BASE_URL else [],
|
||||||
|
)
|
||||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
||||||
|
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
|
||||||
|
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.17").strip() or "0.3.17"
|
||||||
LANGUAGE_CODE = "nl-be"
|
LANGUAGE_CODE = "nl-be"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
@@ -83,6 +159,7 @@ INSTALLED_APPS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"apps.core.middleware.MigrationMaintenanceMiddleware",
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
@@ -91,6 +168,7 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"apps.core.middleware.DemoReadOnlyMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
"apps.core.middleware.SecurityHeadersMiddleware",
|
"apps.core.middleware.SecurityHeadersMiddleware",
|
||||||
]
|
]
|
||||||
@@ -206,9 +284,11 @@ ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
|
|||||||
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
|
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
|
||||||
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
|
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
|
||||||
|
|
||||||
# Demo-toegang: logt in als afgeschermd gastaccount met een fictieve, gevulde
|
# Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet.
|
||||||
# demo-omgeving. Standaard aan zodat de knop 'Bekijk demo' zichtbaar is.
|
# Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie.
|
||||||
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", True)
|
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_USERNAME = os.getenv("DEMO_USERNAME", "demo")
|
||||||
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
|
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
|
||||||
|
|
||||||
@@ -219,30 +299,54 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
|
|||||||
EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
|
EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||||
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
|
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_HTTPONLY = True
|
||||||
SESSION_COOKIE_SAMESITE = "Lax"
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
|
_PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://")
|
||||||
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False)
|
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS)
|
||||||
|
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS)
|
||||||
CSRF_COOKIE_SAMESITE = "Lax"
|
CSRF_COOKIE_SAMESITE = "Lax"
|
||||||
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", False)
|
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_HTTPS)
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
TRUST_PROXY_HEADERS = env_bool("TRUST_PROXY_HEADERS", bool(PUBLIC_BASE_URL))
|
||||||
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if TRUST_PROXY_HEADERS else None
|
||||||
|
USE_X_FORWARDED_HOST = env_bool("USE_X_FORWARDED_HOST", False)
|
||||||
|
TRUSTED_PROXY_CIDRS = env_cidrs("TRUSTED_PROXY_CIDRS")
|
||||||
|
# Interne containerprobes blijven HTTP; publieke applicatieroutes worden wel omgeleid.
|
||||||
|
SECURE_REDIRECT_EXEMPT = [r"^health/(?:live|ready)/$"]
|
||||||
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
|
||||||
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
|
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
|
||||||
USE_X_FORWARDED_HOST = True
|
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||||
X_FRAME_OPTIONS = "DENY"
|
X_FRAME_OPTIONS = "DENY"
|
||||||
|
SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False)
|
||||||
|
|
||||||
|
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 = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
"LOCATION": "vacatureradar-rate-limit",
|
"LOCATION": "vacatureradar-local",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
HEALTHCHECK_REQUIRE_CACHE = env_bool("HEALTHCHECK_REQUIRE_CACHE", not DEBUG)
|
||||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
|
||||||
CELERY_BROKER_URL = REDIS_URL
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
CELERY_RESULT_BACKEND = REDIS_URL
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
|
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
|
||||||
@@ -272,6 +376,10 @@ if DEBUG and not MAILBOX_CREDENTIAL_KEYS:
|
|||||||
]
|
]
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"runtime-heartbeat": {
|
||||||
|
"task": "apps.core.tasks.record_runtime_heartbeat",
|
||||||
|
"schedule": 60.0,
|
||||||
|
},
|
||||||
"schedule-mailbox-polls": {
|
"schedule-mailbox-polls": {
|
||||||
"task": "apps.sources.tasks.schedule_mailbox_polls",
|
"task": "apps.sources.tasks.schedule_mailbox_polls",
|
||||||
"schedule": 300.0,
|
"schedule": 300.0,
|
||||||
@@ -357,6 +465,9 @@ _validate_production_security(
|
|||||||
session_cookie_secure=SESSION_COOKIE_SECURE,
|
session_cookie_secure=SESSION_COOKIE_SECURE,
|
||||||
csrf_cookie_secure=CSRF_COOKIE_SECURE,
|
csrf_cookie_secure=CSRF_COOKIE_SECURE,
|
||||||
secure_ssl_redirect=SECURE_SSL_REDIRECT,
|
secure_ssl_redirect=SECURE_SSL_REDIRECT,
|
||||||
|
public_base_url=PUBLIC_BASE_URL,
|
||||||
|
demo_mode_enabled=DEMO_MODE_ENABLED,
|
||||||
|
demo_read_only=DEMO_READ_ONLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.conf import settings
|
|||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.templatetags.static import static as static_url
|
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
@@ -13,14 +12,16 @@ from apps.core.views import (
|
|||||||
demo_login,
|
demo_login,
|
||||||
entra_callback,
|
entra_callback,
|
||||||
entra_login,
|
entra_login,
|
||||||
|
robots_txt,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
"favicon.ico",
|
"favicon.ico",
|
||||||
RedirectView.as_view(url=static_url("favicon.svg"), permanent=True),
|
RedirectView.as_view(url=f"{settings.STATIC_URL}favicon.svg", permanent=True),
|
||||||
name="favicon",
|
name="favicon",
|
||||||
),
|
),
|
||||||
|
path("robots.txt", robots_txt, name="robots-txt"),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path(
|
path(
|
||||||
"login/",
|
"login/",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# Unraid-installatiebestanden
|
# Unraid-installatiebestanden
|
||||||
|
|
||||||
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Deze bouwt één Dockerman-container via `Dockerfile.unraid`; interne processen worden door Supervisor bewaakt. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
|
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Deze bouwt één Dockerman-container via `Dockerfile.unraid`; interne processen worden door Supervisor bewaakt. Alle paden zijn relatief aan de map met het composebestand, dus je kopieert de projectmap naar de server en start daar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.unraid.example .env # eenmalig, daarna alle CHANGE_ME-waarden invullen
|
||||||
|
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
Volg voor de volledige procedure [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
|
||||||
|
|
||||||
Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up.
|
Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:worker]
|
[program:worker]
|
||||||
command=/app/.venv/bin/celery -A config worker -l INFO -Q high,default,low --concurrency=2
|
command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py worker
|
||||||
directory=/app
|
directory=/app
|
||||||
user=app
|
user=app
|
||||||
priority=50
|
priority=50
|
||||||
@@ -47,7 +47,7 @@ stderr_logfile=/dev/stderr
|
|||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
[program:scheduler]
|
[program:scheduler]
|
||||||
command=/app/.venv/bin/celery -A config beat -l INFO --schedule /tmp/celerybeat-schedule
|
command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py scheduler
|
||||||
directory=/app
|
directory=/app
|
||||||
user=app
|
user=app
|
||||||
priority=60
|
priority=60
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
# Eén Dockerman-container met intern bewaakte PostgreSQL-, Redis-, web- en Celery-processen.
|
# 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
|
name: vacatureradar
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -6,28 +12,33 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.unraid
|
dockerfile: Dockerfile.unraid
|
||||||
image: vacatureradar:unraid
|
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
|
||||||
container_name: VacatureRadar
|
container_name: VacatureRadar
|
||||||
env_file:
|
env_file:
|
||||||
- /mnt/user/appdata/vacatureradar/source/.env
|
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://vacatureradar:${POSTGRES_PASSWORD}@127.0.0.1:5432/vacatureradar
|
|
||||||
POSTGRES_HOST: 127.0.0.1
|
POSTGRES_HOST: 127.0.0.1
|
||||||
|
POSTGRES_PORT: "5432"
|
||||||
REDIS_URL: redis://127.0.0.1:6379/0
|
REDIS_URL: redis://127.0.0.1:6379/0
|
||||||
VACATURERADAR_RESTORE_DUMP: /app/local/backups/aio-migration.dump
|
CACHE_URL: redis://127.0.0.1:6379/1
|
||||||
|
HEALTHCHECK_REQUIRE_CACHE: "1"
|
||||||
|
# Alleen gebruikt bij een lege datadir; laat leeg wanneer je niet migreert.
|
||||||
|
VACATURERADAR_RESTORE_DUMP: ${VACATURERADAR_RESTORE_DUMP:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
stop_grace_period: 30s
|
||||||
ports:
|
ports:
|
||||||
- "1226:8080"
|
- "${VACATURERADAR_HOST_PORT:-1226}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/user/appdata/vacatureradar/source/local/media:/app/media
|
- ./local/media:/app/media
|
||||||
- /mnt/user/appdata/vacatureradar/source/local/logs:/app/logs
|
- ./local/logs:/app/logs
|
||||||
- /mnt/user/appdata/vacatureradar/source/local:/app/local
|
- ./local:/app/local
|
||||||
labels:
|
labels:
|
||||||
net.unraid.docker.managed: dockerman
|
net.unraid.docker.managed: dockerman
|
||||||
net.unraid.docker.webui: http://[IP]:[PORT:8080]/
|
net.unraid.docker.webui: http://[IP]:[PORT:8080]/
|
||||||
net.unraid.docker.icon: http://[IP]:[PORT:8080]/static/favicon.svg
|
# Dockerman downloadt iconen server-side zonder [IP]/[PORT]-substitutie.
|
||||||
|
net.unraid.docker.icon: http://127.0.0.1:1226/static/img/vacatureradar-docker.png
|
||||||
org.opencontainers.image.title: VacatureRadar
|
org.opencontainers.image.title: VacatureRadar
|
||||||
org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt
|
org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -35,4 +46,4 @@ services:
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 60s
|
start_period: 90s
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ x-app: &app
|
|||||||
- /var/tmp:rw,noexec,nosuid,size=128m
|
- /var/tmp:rw,noexec,nosuid,size=128m
|
||||||
- /app/.cache:rw,noexec,nosuid,size=128m
|
- /app/.cache:rw,noexec,nosuid,size=128m
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
|
||||||
|
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
volumes:
|
volumes:
|
||||||
@@ -43,6 +46,8 @@ services:
|
|||||||
<<: *app
|
<<: *app
|
||||||
environment:
|
environment:
|
||||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
VACATURERADAR_RUN_MIGRATIONS: "0"
|
||||||
|
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
|
||||||
|
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
|
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
|
||||||
@@ -57,6 +62,8 @@ services:
|
|||||||
<<: *app
|
<<: *app
|
||||||
environment:
|
environment:
|
||||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
VACATURERADAR_RUN_MIGRATIONS: "0"
|
||||||
|
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
|
||||||
|
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
|
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
schema_version: 1
|
schema_version: 1
|
||||||
project: VacatureRadar
|
project: VacatureRadar
|
||||||
updated_at: '2026-07-22'
|
updated_at: '2026-08-12'
|
||||||
execution_policy:
|
execution_policy:
|
||||||
order: file-order, then first ready task whose dependencies are done
|
order: file-order, then first ready task whose dependencies are done
|
||||||
no_product_questions: true
|
no_product_questions: true
|
||||||
@@ -1341,7 +1341,7 @@ tasks:
|
|||||||
gebruikt strikt de gepinde certifi-bundel zonder bypass.
|
gebruikt strikt de gepinde certifi-bundel zonder bypass.
|
||||||
- id: VR-202
|
- id: VR-202
|
||||||
title: Live IMAP- en SMTP-credentials configureren
|
title: Live IMAP- en SMTP-credentials configureren
|
||||||
status: blocked-external
|
status: done
|
||||||
priority: P0
|
priority: P0
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- PR-016
|
- PR-016
|
||||||
@@ -1356,22 +1356,27 @@ tasks:
|
|||||||
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
|
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
|
||||||
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
|
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
|
||||||
acceptance_criteria:
|
acceptance_criteria:
|
||||||
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie.
|
- Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt
|
||||||
|
app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord.
|
||||||
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
|
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
|
||||||
scheduler gerespecteerd.
|
scheduler gerespecteerd.
|
||||||
- SMTP verstuurt één testdigest en outboxstatus klopt.
|
- SMTP verstuurt één testdigest en outboxstatus klopt.
|
||||||
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de
|
- OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele Gmail/custom-app-wachtwoorden
|
||||||
database aanwezig en sleutelrotatie is live getest.
|
zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
|
||||||
verification:
|
verification:
|
||||||
- Volg Unraid mailboxchecklist
|
- Volg Unraid mailboxchecklist
|
||||||
- Inspecteer logs op afwezigheid van secret/payload
|
- Inspecteer logs op afwezigheid van secret/payload
|
||||||
primary_paths:
|
primary_paths:
|
||||||
- /mnt/user/appdata/vacatureradar/config/.env
|
- /mnt/user/appdata/VacatureRadar/.env
|
||||||
- Bronnen -> Platformmailboxen
|
- Bronnen -> Platformmailboxen
|
||||||
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker.
|
result:
|
||||||
|
completed_at: '2026-07-30'
|
||||||
|
note: M365 app-only OAuth/XOAUTH2 live ingericht met dedicated alias/map en Exchange-regel; negen platformmailboxen
|
||||||
|
importeerden dezelfde live testmail idempotent, schedulerstatus was correct, testdigest stond sent en logs
|
||||||
|
bleven payload-/secretvrij.
|
||||||
- id: VR-203
|
- id: VR-203
|
||||||
title: Productiedomein, TLS en reverse proxy activeren
|
title: Productiedomein, TLS en reverse proxy activeren
|
||||||
status: blocked-external
|
status: done
|
||||||
priority: P0
|
priority: P0
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- NFR-001
|
- NFR-001
|
||||||
@@ -1393,10 +1398,14 @@ tasks:
|
|||||||
primary_paths:
|
primary_paths:
|
||||||
- Unraid reverse proxy
|
- Unraid reverse proxy
|
||||||
- /mnt/user/appdata/vacatureradar/config/.env
|
- /mnt/user/appdata/vacatureradar/config/.env
|
||||||
external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy.
|
result:
|
||||||
|
completed_at: '2026-07-29'
|
||||||
|
note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes
|
||||||
|
en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend
|
||||||
|
containerintern bereikbaar.
|
||||||
- id: VR-204
|
- id: VR-204
|
||||||
title: Immutable containerimage publiceren
|
title: Immutable containerimage publiceren
|
||||||
status: blocked-external
|
status: done
|
||||||
priority: P1
|
priority: P1
|
||||||
requirement_ids:
|
requirement_ids:
|
||||||
- NFR-009
|
- NFR-009
|
||||||
@@ -1414,9 +1423,12 @@ tasks:
|
|||||||
- Pull image op schone host
|
- Pull image op schone host
|
||||||
- Start smoke stack en rollback naar vorige digest
|
- Start smoke stack en rollback naar vorige digest
|
||||||
primary_paths:
|
primary_paths:
|
||||||
- .github/workflows/release.yml
|
- .gitea/workflows/release.yml
|
||||||
- docker-compose.unraid.yml
|
- docker-compose.unraid.yml
|
||||||
external_blocker: Vereist registrykeuze, account en credentials.
|
result:
|
||||||
|
completed_at: '2026-07-30'
|
||||||
|
note: Gitea private registry publiceerde v0.3.15 als immutable digest met commitlabel, SPDX-SBOM, checksums
|
||||||
|
en release notes; Unraid pull, digest-pin, healthy smoke en rollback naar 0.3.14 plus herstel zijn live geslaagd.
|
||||||
- id: VR-205
|
- id: VR-205
|
||||||
title: Gelicenseerde Belgische geodatabron laden
|
title: Gelicenseerde Belgische geodatabron laden
|
||||||
status: done
|
status: done
|
||||||
@@ -1748,6 +1760,375 @@ tasks:
|
|||||||
completed_at: '2026-07-22'
|
completed_at: '2026-07-22'
|
||||||
note: Alle negen Stitch-schermen zijn browsergevalideerd van 360 tot 2560 px; elf screenshots, accessibility-/legacy-/queryaudit
|
note: Alle negen Stitch-schermen zijn browsergevalideerd van 360 tot 2560 px; elf screenshots, accessibility-/legacy-/queryaudit
|
||||||
en documentatie afgerond. Volledige gate groen met 248 tests, 2 optionele skips en 84,40% coverage.
|
en documentatie afgerond. Volledige gate groen met 248 tests, 2 optionele skips en 84,40% coverage.
|
||||||
|
- id: VR-214
|
||||||
|
title: Herstel het Unraid Dockerman-containericoon
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-213
|
||||||
|
summary: Vervang de fragiele runtime-SVG voor de Dockerman-tegel door een lokale rasterasset en dek het volledige
|
||||||
|
Unraid-label- en packagingcontract af.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Dockerman verwijst via de gepubliceerde webpoort naar een lokale PNG die door collectstatic in de image wordt
|
||||||
|
opgenomen.
|
||||||
|
- De browserfavicon blijft lokaal en ongewijzigd beschikbaar.
|
||||||
|
- Een regressietest controleert de exacte WebUI- en icoonlabels, PNG-signatuur en verwachte afmetingen.
|
||||||
|
- Het Unraid-runbook beschrijft het cachegedrag en de veilige refreshstap.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_deployment_configuration.py tests/integration/test_views.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- docker-compose.unraid.yml
|
||||||
|
- static/img/vacatureradar-docker.png
|
||||||
|
- tests/unit/test_deployment_configuration.py
|
||||||
|
- docs/operations/UNRAID_DEPLOYMENT.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-25'
|
||||||
|
note: Dockerman-icoon vervangen door lokale 256x256 PNG via exact Unraid-poortlabel; URL, PNG-signatuur en afmetingen
|
||||||
|
regressiegedekt. Volledige gate groen met 264 tests en 83,47% dekking. Live Unraid-image sha256:799825df1a343b6bee43695850454a5fdc977b5af3366077e6ac6aaeeb94f6f9
|
||||||
|
gebouwd en gerecreate; container healthy, readiness/liveness groen en PNG via LAN HTTP 200 (7111 bytes).
|
||||||
|
- id: VR-215
|
||||||
|
title: Corrigeer Dockermans server-side icoondownload
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-214
|
||||||
|
summary: Gebruik voor het Dockerman-icoon een concrete server-side URL omdat Unraid placeholders alleen voor WebUI-links
|
||||||
|
vervangt en niet voor icoondownloads.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Het icoonlabel bevat geen IP- of poortplaceholder en is vanaf de Unraid-host bereikbaar.
|
||||||
|
- Dockerman maakt `VacatureRadar-icon.png` in zijn eigen icooncache aan.
|
||||||
|
- De Docker-tegel gebruikt de gecachte PNG terwijl WebUI-poortsubstitutie behouden blijft.
|
||||||
|
- De regressietest en het Unraid-runbook leggen dit onderscheid vast.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_deployment_configuration.py
|
||||||
|
- Live Unraid-recreate, cachecontrole en HTTP-smoke
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- docker-compose.unraid.yml
|
||||||
|
- tests/unit/test_deployment_configuration.py
|
||||||
|
- docs/operations/UNRAID_DEPLOYMENT.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-25'
|
||||||
|
note: Dockerman gebruikt nu concrete server-side loopback-URL zonder placeholders; live Unraid-container healthy,
|
||||||
|
HTTP-smoke groen, persistente en RAM-cache bevatten geldige 256x256 PNG en docker.json verwijst naar VacatureRadar-icon.png.
|
||||||
|
Gericht 5 tests en volledige gate groen met 264 tests en 83,44% dekking.
|
||||||
|
- id: VR-216
|
||||||
|
title: Premium UX-verfijning met rustige visuele hiërarchie
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-215
|
||||||
|
summary: Verfijn de bestaande Stitch-interface naar een premium, rustige cockpit met minder visuele ruis, selectiever
|
||||||
|
kleurgebruik en sterkere informatiehiërarchie zonder server-rendered flows te wijzigen.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Donker en licht thema gebruiken gelaagde oppervlakken, zachtere scheidingen en cyan alleen voor primaire signalen
|
||||||
|
en acties; navigatie en labels gebruiken leesbare humane typografie.
|
||||||
|
- Dashboard, verkenner, detail, pipeline en profiel hebben een herkenbare eigen compositie met consistente componenten,
|
||||||
|
zonder nepdata of dode bediening.
|
||||||
|
- Mobiele KPI's staan compact boven de vouw, filters blijven bruikbaar en de pipeline scrolt intern zonder pagina-overflow
|
||||||
|
tussen 360 en 1440 px.
|
||||||
|
- Browsercontrole op 390 en 1280 px dekt donker en licht thema, kernflows en een schone console.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/e2e/test_vr114_browser_and_accessibility.py tests/integration/test_views.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Browsermatrix 390x844 en 1280x800 in donker en licht thema
|
||||||
|
primary_paths:
|
||||||
|
- static/css
|
||||||
|
- templates/dashboard/today.html
|
||||||
|
- templates/jobs/list.html
|
||||||
|
- templates/applications/list.html
|
||||||
|
- templates/profiles/list.html
|
||||||
|
- docs/design/STITCH_INTEGRATION_REPORT.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-25'
|
||||||
|
note: Premium calm-intelligence-laag voor dark/light, compacte mobiele KPI's, responsieve filtercommandobar,
|
||||||
|
intern scrollende pipeline en breed profiel. Bestaande login-CSP-fout opgelost met lokale externe JS. Browsermatrix
|
||||||
|
390/1280 zonder overflow en schone console; volledige gate groen met 264 tests, 2 optionele skips en 83,44%
|
||||||
|
dekking.
|
||||||
|
- id: VR-217
|
||||||
|
title: Borg Unraid-authenticatie en voeg interactieve radarillustraties toe
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-216
|
||||||
|
summary: Maak het bestaande SSH-deploymentprofiel expliciet en voeg een lokale, toegankelijke interactieve radarillustratie
|
||||||
|
toe op inhoudelijk passende cockpitstates zonder echte data of kernflows te vervangen.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Deploymentdocumentatie en helper valideren de geconfigureerde Unraid-hostalias en falen met een concrete herstelmelding
|
||||||
|
wanneer de verkeerde SSH-identiteit wordt gekozen.
|
||||||
|
- Een herbruikbare lokale radarillustratie versterkt profiel- en lege radarstates met pointer-/focusrespons en
|
||||||
|
bevat geen externe asset, fictieve metric of betekenisdragende informatie.
|
||||||
|
- De illustratie degradeert zonder JavaScript, respecteert prefers-reduced-motion en blijft verborgen voor hulptechnologie;
|
||||||
|
kernacties en tekst blijven zelfstandig bruikbaar.
|
||||||
|
- Browsercontrole op 390 en 1280 px in donker en licht thema toont geen overflow, consolefout of concurrerende
|
||||||
|
visuele hiërarchie; de live Unraid-container is na deployment healthy.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_deployment_configuration.py tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- Browsermatrix 390x844 en 1280x800 in donker en licht thema
|
||||||
|
- Live SSH-, containerhealth- en HTTP-smoke via de geconfigureerde hostalias
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- scripts/deploy_unraid.ps1
|
||||||
|
- docs/operations/UNRAID_DEPLOYMENT.md
|
||||||
|
- templates/components/radar_illustration.html
|
||||||
|
- templates/profiles/list.html
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/js/app.js
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-26'
|
||||||
|
note: Unraid-authenticatie hersteld via gevalideerde hostalias unraid-itworx met root en dedicated sleutel;
|
||||||
|
fail-fast deployhelper toegevoegd. Interactieve lokale SVG-radar met pointerparallax, dark/light en reduced-motionfallback
|
||||||
|
toegevoegd. Browsermatrix 390/1280 zonder overflow of consolefouten; 265 tests groen. Live image sha256:4d30e01e778777d5a2cde4339a9b2ad006edb25cae387293737420152d65069d
|
||||||
|
healthy en readiness/CSS/JS HTTP 200.
|
||||||
|
- id: VR-218
|
||||||
|
title: Personaliseer merkidentiteit en verfijn de globale UX
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-217
|
||||||
|
summary: Vervang het generieke favicon door een herkenbaar radarsymbool, voeg configureerbare eigenaarssignatuur
|
||||||
|
toe en verfijn shell, oriëntatie, navigatie en micro-interacties tot een coherente persoonlijke premiumervaring.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Favicon en hoofdlogo delen één scherp, lokaal en op 16 px herkenbaar radarsymbool met passende light/dark browsermetadata
|
||||||
|
en zonder externe assets.
|
||||||
|
- De shell en authenticatieschermen tonen VacatureRadar als product en een configureerbare eigenaarssignatuur
|
||||||
|
met veilige standaardwaarde Jens; deze branding wordt niet uit gebruikersdata of nepdata afgeleid.
|
||||||
|
- De topbar geeft compacte paginaoriëntatie, navigatie-/zoekinteracties hebben duidelijkere focus- en hoverstates
|
||||||
|
en mobiele bruikbaarheid blijft intact zonder extra bediening of JavaScriptafhankelijkheid.
|
||||||
|
- Browsercontrole op dashboard, verkenner, profiel en login bij 390 en 1280 px in donker en licht thema toont
|
||||||
|
geen overflow, consolefout of onduidelijke hiërarchie; live container en assets zijn healthy na deployment.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/e2e/test_vr114_browser_and_accessibility.py tests/integration/test_auth_login.py tests/integration/test_views.py
|
||||||
|
- Browsermatrix 390x844 en 1280x800 in donker en licht thema
|
||||||
|
- Live favicon-, logo-, readiness- en containerhealth-smoke
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- apps/core/context_processors.py
|
||||||
|
- static/favicon.svg
|
||||||
|
- static/img/vacatureradar-logo.svg
|
||||||
|
- templates/base.html
|
||||||
|
- templates/registration
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/css/auth.css
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-26'
|
||||||
|
note: Persoonlijke Jens-branding, vernieuwd radarbeeldmerk en favicon, contextuele bovenbalk en verfijnde navigatie/zoekfocus
|
||||||
|
zijn regressiegedekt, responsief in de browser gevalideerd en live uitgerold als healthy image sha256:5f25f00c9b7efb166587901ff39b02c56c98fb9946ddef0a4546a22f18dc0802.
|
||||||
|
- id: VR-219
|
||||||
|
title: Herstel ultrawide uitlijning en versterk persoonlijke merklock-up
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
depends_on:
|
||||||
|
- VR-218
|
||||||
|
summary: Breng de cockpit op brede schermen terug naar één voorspelbaar horizontaal grid en maak de persoonlijke
|
||||||
|
Jens-signatuur tot een herkenbaar onderdeel van de productbranding in plaats van losse metadata.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Header, filters, hoofdpanelen en onderliggende secties delen op desktop en ultrawide hetzelfde startpunt en
|
||||||
|
dezelfde contentbreedte; er ontstaat geen grote toevallige lege linkerkolom.
|
||||||
|
- Verkenner en automatisering benutten 1920- en 3840-pixelbreedtes evenwichtig zonder uitgerekte leesregels, losgekoppelde
|
||||||
|
panelen of documentoverflow.
|
||||||
|
- Sidebar en bovenbalk tonen één consistente VacatureRadar-by-Jens-merklock-up; authenticatie en mobiele shell
|
||||||
|
behouden dezelfde eigenaarssignatuur zonder gebruikersnaam als merk te presenteren.
|
||||||
|
- Browsercontrole op 390, 1280, 1920 en 3840 px bevestigt consistente ankers, bruikbare paneelverhoudingen, schone
|
||||||
|
console en intacte toetsenbord-/mobiele flows.
|
||||||
|
verification:
|
||||||
|
- Browsermatrix dashboard, verkenner, automatisering en login op 390/1280/1920/3840 px
|
||||||
|
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- templates/base.html
|
||||||
|
- static/css/layout.css
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/css/responsive.css
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-26'
|
||||||
|
note: Ultrawide hoofdsecties delen één links verankerd 2800px-grid; explorer- en automationverhoudingen zijn
|
||||||
|
begrensd en Personal intelligence by Jens is de consistente lock-up. Browsermatrix 390/1280/1920/3840 schoon;
|
||||||
|
267 tests groen; live image sha256:86836f82cc543a7adb414e47b9667e9123bb9ceba10d629a53086273aaaae03f healthy
|
||||||
|
met readiness en asset-smokes.
|
||||||
|
- id: VR-220
|
||||||
|
title: Integreer de officiële ITWorx-merkidentiteit
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-219
|
||||||
|
summary: Vervang de tijdelijke tekstuele Jens-branding door de door de eigenaar aangeleverde officiële ITWorx.tech-wordmark
|
||||||
|
en cloud-check-identiteit, met VacatureRadar herkenbaar als product binnen dat merk.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Sidebar, login en herstelpagina's gebruiken de transparante officiële ITWorx.tech-wordmark uit het aangeleverde
|
||||||
|
asset pack; VacatureRadar blijft duidelijk als productnaam aanwezig.
|
||||||
|
- Browserfavicon en app-/Dockericoon gebruiken de officiële cloud-check-symboliek in een compacte transparante,
|
||||||
|
lokaal gehoste en voor klein formaat geoptimaliseerde variant.
|
||||||
|
- Merkassets hebben betekenisvolle alt-/toegankelijkheidslabels, laden zonder externe runtimebron en blijven scherp
|
||||||
|
en leesbaar in donker/licht thema en op mobiel.
|
||||||
|
- Browsercontrole op login, dashboard en verkenner bij 390, 1280 en 1920 px toont correcte assets, geen overflow
|
||||||
|
of consolefouten; de live container levert brandingassets en readiness met HTTP 200.
|
||||||
|
verification:
|
||||||
|
- Controleer afmetingen, transparantie en bestandsgrootte van de afgeleide merkassets
|
||||||
|
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py tests/unit/test_deployment_configuration.py
|
||||||
|
- Browsermatrix 390/1280/1920 voor login, dashboard en verkenner
|
||||||
|
- Live asset-, favicon-, readiness- en containerhealth-smoke
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- static/img/itworx-wordmark.png
|
||||||
|
- static/img/itworx-app-icon.png
|
||||||
|
- static/img/vacatureradar-docker.png
|
||||||
|
- templates/base.html
|
||||||
|
- templates/registration
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/css/auth.css
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-26'
|
||||||
|
note: Officiële aangeleverde ITWorx.tech-wordmark geïntegreerd in sidebar, topbar, login en herstel; cloud-check
|
||||||
|
exact afgeleid voor 512px favicon/app-icon en 256px Dockermanicoon. Browsermatrix 390/1280/1920 schoon, 267
|
||||||
|
tests groen en live image sha256:daf34407813b37cdf2d0fc65a482ded65375f89eedd3f0144a8c0ce206d41565 healthy
|
||||||
|
met readiness/PNG-smokes.
|
||||||
|
- id: VR-221
|
||||||
|
title: Scheid appmerk van ontwikkelaarsbranding
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-220
|
||||||
|
summary: Herstel VacatureRadar als zelfstandig productmerk met een nieuw professioneel radarlogo en favicon, en
|
||||||
|
plaats de aangeleverde ITWorx.tech-identiteit uitsluitend als subtiele ontwikkelaarscredit.
|
||||||
|
acceptance_criteria:
|
||||||
|
- VacatureRadar gebruikt een zelfstandig, vectorvriendelijk beeldmerk dat radar, detectie en een gevonden kans
|
||||||
|
combineert, scherp blijft op 16 px en visueel niet met het ITWorx-cloud-check-logo concurreert.
|
||||||
|
- Sidebar, login, favicon en Dockerman tonen het VacatureRadar-productmerk; ITWorx.tech staat alleen op geschikte
|
||||||
|
secundaire plaatsen als `Ontwikkeld door ITWorx.tech`.
|
||||||
|
- De ontwikkelaarscredit gebruikt de officiële aangeleverde ITWorx-wordmark lokaal en blijft ondergeschikt, toegankelijk
|
||||||
|
en leesbaar in donker/licht thema en op mobiel.
|
||||||
|
- Browsercontrole op login, dashboard en verkenner bij 390, 1280 en 1920 px toont correcte merkhiërarchie, geladen
|
||||||
|
assets, geen overflow of consolefouten; live container en alle assets zijn healthy.
|
||||||
|
verification:
|
||||||
|
- Inspecteer VacatureRadar-SVG op viewBox, elementeenvoud en klein-formaat-signatuur
|
||||||
|
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py tests/unit/test_deployment_configuration.py
|
||||||
|
- Browsermatrix 390/1280/1920 voor login, dashboard en verkenner
|
||||||
|
- Live logo-, favicon-, ontwikkelaarswordmark-, readiness- en containerhealth-smoke
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- static/favicon.svg
|
||||||
|
- static/img/vacatureradar-logo.svg
|
||||||
|
- static/img/vacatureradar-docker.png
|
||||||
|
- static/img/itworx-wordmark.png
|
||||||
|
- templates/base.html
|
||||||
|
- templates/registration
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/css/auth.css
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-26'
|
||||||
|
note: VacatureRadar heeft een zelfstandig professioneel open-radar-logo, SVG-favicon en 256px Dockermanicoon;
|
||||||
|
officiële ITWorx.tech-wordmark staat alleen als Ontwikkeld door-credit. Twee beeldconcepten verkend, finale
|
||||||
|
geometrie handmatig als vijf-elementen-SVG gebouwd. Browsermatrix 390/1280/1920 schoon, 267 tests groen en
|
||||||
|
live image sha256:4184aeff33759b7a205637b0a0333b751b599141fe691a61965d2f1c56ca984a healthy met alle asset-smokes.
|
||||||
|
- id: VR-222
|
||||||
|
title: Voer een integrale premium visuele audit en herstelroadmap uit
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-221
|
||||||
|
summary: Audit de volledige cockpit als één designsysteem en herstel merkhiërarchie, uitlijning, informatiedichtheid,
|
||||||
|
interactiedoelen, responsief gedrag en motion tot een consistente premium productervaring.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Een gedateerd auditrapport bevat geprioriteerde bevindingen, concreet gevolg, herstelactie en volledige roadmap
|
||||||
|
voor alle hoofdschermen, login en herstel.
|
||||||
|
- Topbar en hoofdcontent delen voorspelbare horizontale ankers; dashboard en activiteit zijn aantoonbaar compacter
|
||||||
|
zonder echte informatie te verbergen of controles onbruikbaar te maken.
|
||||||
|
- VacatureRadar blijft het enige primaire appmerk; ITWorx.tech staat uitsluitend als subtiele ontwikkelaarscredit
|
||||||
|
en geen concept- of chroma-keybeeld wordt als productasset geleverd.
|
||||||
|
- Controls, focus, reduced motion, dark/light en 390/768/1280/1440/1920/3840 px zijn gecontroleerd zonder overflow,
|
||||||
|
consolefouten of regressie in de server-rendered kernflow.
|
||||||
|
- De live container is healthy en levert het finale logo, favicon en de relevante CSS met HTTP 200.
|
||||||
|
verification:
|
||||||
|
- Browsermatrix voor alle hoofdschermen plus login in dark/light en 390/768/1280/1440/1920/3840 px
|
||||||
|
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Live readiness-, containerhealth- en assetsmokes
|
||||||
|
primary_paths:
|
||||||
|
- docs/design/VISUAL_AUDIT_2026-07-26.md
|
||||||
|
- templates/base.html
|
||||||
|
- templates/dashboard/today.html
|
||||||
|
- templates/activity/list.html
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/css/pages.css
|
||||||
|
- static/css/responsive.css
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-26'
|
||||||
|
note: 'Integrale audit en roadmap uitgevoerd: gedeeld contentframe, vijf dashboardmatches, compacte activiteit,
|
||||||
|
subtiele ITWorx-credit, grotere klikdoelen, reduced-motion, CSS-cacheversies en herstelde mobiele login. Browsermatrix
|
||||||
|
50 checks plus light/login schoon; 268 tests groen, 2 skips, 83,45% dekking. Live image sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70
|
||||||
|
healthy; readiness en assets HTTP 200.'
|
||||||
|
- id: VR-223
|
||||||
|
title: Verfijn transparant productlogo en professionele widescreenshell
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-004
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-222
|
||||||
|
summary: Herstel de resterende merk- en shellfrictie uit de gebruikersscreenshot met een transparant cleaner productlogo,
|
||||||
|
één rustige sidebarfooter zonder dubbele accountidentiteit en beter benutte widescreenruimte.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Het VacatureRadar-logo heeft geen donkere tegelachtergrond en blijft als eenvoudige vector scherp en herkenbaar
|
||||||
|
op dark/light, login, sidebar en klein formaat; favicon en Dockerman blijven doelgeschikt.
|
||||||
|
- De sidebar toont onderaan één samenhangende status-/ontwikkelaarszone en herhaalt de demo- of accountidentiteit
|
||||||
|
niet; accountacties blijven bereikbaar via het bestaande topbarmenu.
|
||||||
|
- De cockpit benut 1920, 2560 en 3840 px evenwichtiger zonder uitgerekte leesregels, losstaande topbaracties of
|
||||||
|
horizontale documentoverflow; mobiel en laptop blijven intact.
|
||||||
|
- Een gerichte vervolgcheck identificeert en herstelt bijkomende zichtbare shellproblemen, inclusief light/dark,
|
||||||
|
korte viewport en toetsenbord-/interactiestates.
|
||||||
|
- De live container is healthy en levert logo, favicon, CSS en readiness met HTTP 200.
|
||||||
|
verification:
|
||||||
|
- Browsermatrix dashboard, verkenner, automatisering, login op 390/1280/1920/2560/3840 px en korte sidebarviewport
|
||||||
|
- uv run pytest tests/integration/test_auth_login.py tests/integration/test_views.py tests/unit/test_deployment_configuration.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Live readiness-, containerhealth- en assetsmokes
|
||||||
|
primary_paths:
|
||||||
|
- static/img/vacatureradar-logo.svg
|
||||||
|
- static/css/layout.css
|
||||||
|
- static/css/premium.css
|
||||||
|
- static/css/responsive.css
|
||||||
|
- templates/base.html
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-27'
|
||||||
|
note: Transparant vier-elementenlogo zonder donkere tegel; één compacte sidebarfooter zonder dubbele accountidentiteit;
|
||||||
|
monochrome dark-mode ITWorx-credit en widescreenframes van 2200/2480 px. Browsermatrix 18 shellchecks plus
|
||||||
|
dark/light/login schoon; volledige gate tweemaal groen met 268 tests, 2 skips en 83,45% dekking. Live image
|
||||||
|
sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921 healthy; readiness en assets HTTP
|
||||||
|
200.
|
||||||
- id: VR-301
|
- id: VR-301
|
||||||
title: Multi-tenant SaaS, teams en billing
|
title: Multi-tenant SaaS, teams en billing
|
||||||
status: deferred
|
status: deferred
|
||||||
@@ -1764,6 +2145,40 @@ tasks:
|
|||||||
- Niet uitvoeren binnen huidige scope.
|
- Niet uitvoeren binnen huidige scope.
|
||||||
primary_paths:
|
primary_paths:
|
||||||
- docs/product/SCOPE_AND_NON_GOALS.md
|
- docs/product/SCOPE_AND_NON_GOALS.md
|
||||||
|
- id: VR-224
|
||||||
|
title: Maak de repository-README functioneel en user-first met actuele screenshots
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-223
|
||||||
|
summary: Herbouw de repository-ingang rond het gebruikersprobleem, de dagelijkse workflow en een veilige quickstart,
|
||||||
|
ondersteund door compacte actuele productbeelden uit de echte server-rendered cockpit.
|
||||||
|
acceptance_criteria:
|
||||||
|
- De README begint bij doel, doelgroep en dagelijkse gebruikersflow; installatie, beheer, architectuur en agentoverdracht
|
||||||
|
volgen in een duidelijke progressieve volgorde zonder bestaande essentiële informatie te verliezen.
|
||||||
|
- Minstens vier actuele screenshots tonen verschillende functionele hoofdschermen, desktop en mobiel en dark/light;
|
||||||
|
ze bevatten geen secrets of persoonlijke gegevens en zijn lokaal, geoptimaliseerd en correct gelinkt.
|
||||||
|
- Quickstarts, poorten, lokale credentials, veiligheidsgrenzen en operationele documentatielinks zijn aantoonbaar
|
||||||
|
correct en maken duidelijk wat demo-, ontwikkel- en productiegebruik betekent.
|
||||||
|
- README-links en repositorydocumentatie valideren, de beelden zijn visueel gecontroleerd zonder overflow of consolefouten
|
||||||
|
en de volledige kwaliteitsgate slaagt.
|
||||||
|
verification:
|
||||||
|
- Browsercontrole van de vastgelegde routes en viewports
|
||||||
|
- python scripts/validate_repository.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- README.md
|
||||||
|
- docs/screenshots/readme/
|
||||||
|
- docs/quality/TRACEABILITY_MATRIX.md
|
||||||
|
- docs/ai/PROJECT_STATE.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-27'
|
||||||
|
note: User-first README met gecontroleerde quickstarts en vier actuele dark/light desktop-/mobielscreenshots;
|
||||||
|
browsermatrix zonder overflow of consolewaarschuwingen, repositoryvalidatie groen en volledige gate met 268
|
||||||
|
tests, 2 skips en 83,48% dekking.
|
||||||
- id: VR-302
|
- id: VR-302
|
||||||
title: Automatisch sollicitaties invullen of versturen
|
title: Automatisch sollicitaties invullen of versturen
|
||||||
status: deferred
|
status: deferred
|
||||||
@@ -1799,3 +2214,291 @@ tasks:
|
|||||||
primary_paths:
|
primary_paths:
|
||||||
- docs/api/openapi.yaml
|
- docs/api/openapi.yaml
|
||||||
- docs/architecture/adrs/0008-server-rendered-design.md
|
- docs/architecture/adrs/0008-server-rendered-design.md
|
||||||
|
- id: VR-225
|
||||||
|
title: Hard publieke reverse-proxydeployment en gasttoegang uit
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-001
|
||||||
|
- NFR-006
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-224
|
||||||
|
summary: Maak de bestaande persoonlijke installatie veilig en reproduceerbaar bereikbaar achter Nginx Proxy Manager,
|
||||||
|
herstel publieke host- en CSRF-configuratie en voorkom dat een gedeeld demoaccount persistente data wijzigt.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Eén publieke HTTPS-URL configureert hostvalidatie, CSRF, secure cookies, SSL-detectie en redirect zonder de
|
||||||
|
interne containerpoort in de externe origin op te nemen.
|
||||||
|
- Productie start fail-fast bij debugmodusgerelateerde onveiligheid, een zwakke secret, onveilige cookies, ontbrekende
|
||||||
|
HTTPS-origin of een schrijfbare publieke demo.
|
||||||
|
- Login- en importlimieten gebruiken in productie een gedeelde cache; forwarded client-IP’s worden alleen vanaf
|
||||||
|
expliciet vertrouwde proxy-CIDR’s geaccepteerd.
|
||||||
|
- Het gastaccount is server-side alleen-lezen, heeft een begrensde sessie, kan afmelden en toont de beperking
|
||||||
|
zichtbaar.
|
||||||
|
- Unraid-envpad, Nginx Proxy Manager, healthchecks, noindex, heruitrol en terugdraaien zijn exact gedocumenteerd.
|
||||||
|
verification:
|
||||||
|
- PYTHONPATH=/mnt/data/vr_windows_site/site-packages pytest -q
|
||||||
|
- python manage.py check
|
||||||
|
- python manage.py makemigrations --check --dry-run
|
||||||
|
- python scripts/validate_repository.py
|
||||||
|
- bash -n scripts/deploy_docker.sh scripts/entrypoint.sh scripts/unraid_aio_entrypoint.sh
|
||||||
|
- Live Nginx-, TLS-, CSRF-, Dockerhealth- en restore-smoke op de doelserver
|
||||||
|
primary_paths:
|
||||||
|
- config/settings.py
|
||||||
|
- apps/core/middleware.py
|
||||||
|
- apps/core/network.py
|
||||||
|
- apps/core/health.py
|
||||||
|
- scripts/configure_public_url.py
|
||||||
|
- docker-compose.unraid.yml
|
||||||
|
- docs/operations/NGINX_PROXY_MANAGER.md
|
||||||
|
- docs/quality/PRODUCTION_AUDIT_2026-07-27.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-27'
|
||||||
|
note: Publieke host/CSRF/HTTPS-configuratie, gedeelde Redis-rate-limiting, proxy-IPvertrouwen, cache-readiness,
|
||||||
|
noindex en read-only gastdemo geïmplementeerd. 295 tests groen met 2 correcte Playwrightskips; Django check,
|
||||||
|
migratiecheck, repositoryvalidatie, YAML-, syntax- en packagechecks groen. Live servervalidatie blijft extern.
|
||||||
|
- id: VR-226
|
||||||
|
title: Verdiep de user-first README met een volledige productgalerij
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-006
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-225
|
||||||
|
summary: Maak de productpresentatie overtuigender met een logisch opgebouwde, actuele galerij die de volledige
|
||||||
|
gebruikersreis toont zonder de README traag, repetitief of marketingmatig te maken.
|
||||||
|
acceptance_criteria:
|
||||||
|
- De README bevat minstens acht actuele beelden die dashboard, zoeken/analyse, skills, sollicitatieopvolging,
|
||||||
|
profiel, bronnen, automatisering en mobiel gebruik als één samenhangende productreis tonen.
|
||||||
|
- Desktopbeelden gebruiken een consistente professionele viewport en rustige uitsnede; dark/light en mobiel zijn
|
||||||
|
doelbewust verdeeld, zonder overgangsoverlays, overflow, consolefouten, secrets of persoonlijke gegevens.
|
||||||
|
- De galerij heeft korte taakgerichte captions, een scanbare compositie en compacte lokale assets; duplicatieve
|
||||||
|
beelden of losse decoratieve screenshots worden verwijderd of vervangen.
|
||||||
|
- De eerder aangetroffen VR-225-baselinefouten in lint en platformafhankelijke bestandsmoduscontrole zijn hersteld;
|
||||||
|
repositoryvalidatie en de volledige kwaliteitsgate slagen.
|
||||||
|
verification:
|
||||||
|
- Browsercontrole van alle vastgelegde routes op desktop en mobiel in dark/light
|
||||||
|
- uv run pytest tests/unit/test_public_configuration.py
|
||||||
|
- python scripts/validate_repository.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- README.md
|
||||||
|
- docs/screenshots/readme/
|
||||||
|
- scripts/configure_public_url.py
|
||||||
|
- tests/unit/test_public_configuration.py
|
||||||
|
- docs/quality/TRACEABILITY_MATRIX.md
|
||||||
|
- docs/ai/PROJECT_STATE.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-28'
|
||||||
|
note: README verdiept tot een klikbare user-first productreis met negen actuele beelden op 1920x1080 en 390x844
|
||||||
|
in dark/light; alle routes zonder overflow of consolewaarschuwingen. VR-225-lint en Windows-modusassertie
|
||||||
|
hersteld; 296 tests groen, 2 skips en 83,44% dekking.
|
||||||
|
- id: VR-227
|
||||||
|
title: Professionaliseer VacatureRadar integraal voor release 0.3.13
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-001
|
||||||
|
- NFR-006
|
||||||
|
- NFR-007
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
- NFR-010
|
||||||
|
depends_on:
|
||||||
|
- VR-226
|
||||||
|
summary: Voer de integrale responsive, UX-, accessibility-, performance-, observability-, security- en operationsroadmap
|
||||||
|
uit en vervang optionele browsercontrole door een verplichte releasegate.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Hoofdcontent en topbar delen een gecentreerd adaptief frame; gastbanner, sticky elementen en pipeline blijven
|
||||||
|
bruikbaar van 390 tot 3440 px zonder documentoverflow.
|
||||||
|
- De vacatureflow is begrensd gepagineerd, globale zoekcopy is eerlijk, mobiel pipelinegebruik is verticaal en
|
||||||
|
demomutaties veroorzaken geen controlmuur.
|
||||||
|
- Playwright Chromium draait verplicht in CI op zes viewports, faalt op console/page errors en controleert focus,
|
||||||
|
kernreis en documentoverflow zonder skips.
|
||||||
|
- Automatisering toont een echte worker/beat-heartbeat en markeert niet-meetbare queuediepte eerlijk; benchmarkparserdekking
|
||||||
|
is volledig en reproduceerbaar.
|
||||||
|
- Baseline, performance, architectuur, operations, testing, traceability, externe acties en finale acceptance
|
||||||
|
zijn actueel en de volledige repositorygate slaagt.
|
||||||
|
verification:
|
||||||
|
- python -m scripts.benchmark --quick --skip-performance --json
|
||||||
|
- python -m pytest tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- python -m pytest tests/unit/test_runtime_observability.py tests/unit/test_generic_html_adapter.py
|
||||||
|
- docker compose config
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- static/css/
|
||||||
|
- static/js/app.js
|
||||||
|
- templates/base.html
|
||||||
|
- apps/core/services/automation.py
|
||||||
|
- apps/core/tasks.py
|
||||||
|
- tests/e2e/test_vr114_browser_and_accessibility.py
|
||||||
|
- docs/audit/
|
||||||
|
- USER_INPUT_REQUIRED.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-29'
|
||||||
|
note: Release 0.3.13 professionaliseert responsive layout, mobiele gastflow, begrensde vacature-UX, verplichte
|
||||||
|
zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, parserbenchmark, authdekking, SBOM en integrale
|
||||||
|
documentatie; volledige gate 312 tests groen, 84,16% dekking.
|
||||||
|
- id: VR-228
|
||||||
|
title: Optimaliseer de importpipeline voor release 0.3.14
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-007
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-227
|
||||||
|
summary: Profileer de PostgreSQL-import reproduceerbaar, verwijder aantoonbare N+1- en overbodige writes en lever
|
||||||
|
de geverifieerde optimalisatie veilig op zonder wijziging aan deduplicatie, scoring of datakwaliteit.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Een fail-closed runner meet een disposable PostgreSQL-database met vaste fixture, warm-up en drie herhalingen.
|
||||||
|
- De geoptimaliseerde import reduceert queryvolume en doorlooptijd aantoonbaar tegenover 0.3.13 en rapporteert
|
||||||
|
p50, p95, throughput, geheugen en queryfamilies.
|
||||||
|
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische ScoreRuns
|
||||||
|
plus veldprovenance blijven intact.
|
||||||
|
- Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie
|
||||||
|
bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid.
|
||||||
|
verification:
|
||||||
|
- python -m scripts.benchmark_import --jobs 250 --iterations 3
|
||||||
|
- uv run pytest tests/integration/test_pipeline.py tests/unit/test_import_benchmark.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- apps/jobs/services/pipeline.py
|
||||||
|
- scripts/benchmark.py
|
||||||
|
- scripts/benchmark_import.py
|
||||||
|
- tests/integration/test_pipeline.py
|
||||||
|
- docs/audit/IMPORT_PERFORMANCE_FINAL.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-07-29'
|
||||||
|
note: 'Release 0.3.14: geïsoleerde PostgreSQL-benchmark reduceert importduur 44,74% en queries minstens 63,73%;
|
||||||
|
twee volledige gates met 318 tests groen; productie healthy op image sha256:1ee84241, transactionele importsmoke
|
||||||
|
teruggerold en rollbackimage 0.3.13 beschikbaar.'
|
||||||
|
- id: VR-229
|
||||||
|
title: Herstel de publieke demo na de visuele 0.3.15-audit
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-008
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-228
|
||||||
|
summary: Maak de publieke demo opnieuw representatief en tijdsbestendig, scheid mailboxingress van URL-fetching
|
||||||
|
en laat operationele statussen exact zeggen wat ze meten.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Iedere nieuwe demosessie herstelt zes fictieve actuele vacatures, scores en toekomstige opvolgdatums zonder
|
||||||
|
duplicaten of groeiende ScoreRun-historiek.
|
||||||
|
- De demo toont negen fictieve, actieve platformmailboxkoppelingen zonder secrets of echte persoonsgegevens.
|
||||||
|
- E-mailbronnen worden nooit door de generieke URL-fetchscheduler gepland en oude mailboxpolicyfouten vervuilen
|
||||||
|
dashboard en activiteit niet meer.
|
||||||
|
- Navigatie en dashboard noemen het zoekprofiel expliciet actief zonder brongezondheid te suggereren; het lege
|
||||||
|
loginwachtwoordveld lijkt niet vooraf ingevuld.
|
||||||
|
- De negen kernroutes blijven in dark/light en op mobiel, desktop en ultrawide zonder overflow, consolefouten
|
||||||
|
of headingregressies bruikbaar; de volledige kwaliteitsgate slaagt.
|
||||||
|
verification:
|
||||||
|
- python -m pytest tests/integration/test_auth_login.py tests/integration/test_tasks_and_more_views.py
|
||||||
|
- Browseraudit op 390x844, 1280x600, 1440x900 en 3440x1440
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- apps/jobs/services/demo_seed.py
|
||||||
|
- apps/jobs/services/activity.py
|
||||||
|
- apps/jobs/services/cockpit.py
|
||||||
|
- apps/sources/tasks.py
|
||||||
|
- templates/base.html
|
||||||
|
- templates/dashboard/today.html
|
||||||
|
- templates/registration/login.html
|
||||||
|
- tests/integration/test_auth_login.py
|
||||||
|
- tests/integration/test_tasks_and_more_views.py
|
||||||
|
result:
|
||||||
|
completed_at: '2026-08-12'
|
||||||
|
note: Publieke demo wordt bij iedere sessie idempotent hersteld met 6 actuele fictieve vacatures, tijdsbestendige
|
||||||
|
opvolging en 9 fictieve mailboxkoppelingen; EMAIL-bronnen en demo-mailboxen zijn uit de verkeerde schedulers
|
||||||
|
en activityfeeds uitgesloten, statuscopy en loginplaceholder zijn gecorrigeerd. Gerichte 51 tests en volledige
|
||||||
|
gate met 327 tests groen, 84,04% dekking.
|
||||||
|
- id: VR-230
|
||||||
|
title: Herstel de immutable Gitea-releasepublicatie
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-229
|
||||||
|
summary: Verwijder de externe GitHub-API-rate-limit uit de releasebootstrap en publiceer de actuele productiebuild
|
||||||
|
opnieuw als aantoonbaar immutable private-registryimage.
|
||||||
|
acceptance_criteria:
|
||||||
|
- De uv-bootstrap is op een immutable actioncommit en een exacte uv-versie gepind.
|
||||||
|
- De releaseworkflow gebruikt voor uv-downloads geen GitHub Releases-API die op een gedeeld runner-IP kan limiteren.
|
||||||
|
- Gitea Actions publiceert een private registryimage met digest, SBOM, checksums en release notes.
|
||||||
|
- Unraid is op de gepubliceerde digest gepind en live health/readiness slagen.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_deployment_configuration.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Gitea Actions releasebewijs en live Unraid health/readiness
|
||||||
|
primary_paths:
|
||||||
|
- .gitea/workflows/release.yml
|
||||||
|
- tests/unit/test_deployment_configuration.py
|
||||||
|
- docs/audit/FINAL_ACCEPTANCE.md
|
||||||
|
result:
|
||||||
|
completed_at: '2026-08-12'
|
||||||
|
note: Releasebootstrap gepind op immutable setup-uv v8.1.0-commit en uv 0.11.12 via Astral-mirror; Gitea Actions-run
|
||||||
|
3062 publiceerde digest sha256:98f4b33d met checksum-geldige SBOM/release-evidence; Unraid gepind op volledige
|
||||||
|
digest en live health/readiness groen.
|
||||||
|
- id: VR-232
|
||||||
|
title: Houd uitgaand werk vast tijdens gecontroleerde datamigratie
|
||||||
|
status: ready
|
||||||
|
priority: P0
|
||||||
|
requirement_ids: [NFR-009]
|
||||||
|
depends_on: [VR-231]
|
||||||
|
summary: Door de eigenaar gevraagde migratie met rollback; Celery start pas na vrijgave van de kandidaatkopie.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Een marker in de kandidaatkopie blokkeert worker en scheduler voor hun eerste externe actie.
|
||||||
|
- Zonder marker blijft normaal opstartgedrag ongewijzigd.
|
||||||
|
- Onbekende procesrollen en ongeldige markers falen gesloten.
|
||||||
|
- De migratie verwijdert uitsluitend haar eigen marker na geverifieerde commit.
|
||||||
|
- Gewone webverzoeken blijven geblokkeerd tot commit; alleen exacte GET/HEAD-healthroutes zijn beschikbaar.
|
||||||
|
- De migratie behoudt het bestaande Docker-netwerk via een verplichte gevalideerde variabele.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_migration_worker_gate.py
|
||||||
|
- uv run pytest tests/unit/test_migration_maintenance.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
primary_paths:
|
||||||
|
- scripts/migration_worker_gate.py
|
||||||
|
- deployment/unraid/supervisord.conf
|
||||||
|
- tests/unit/test_migration_worker_gate.py
|
||||||
|
- tests/unit/test_migration_maintenance.py
|
||||||
|
- apps/core/middleware.py
|
||||||
|
- config/settings.py
|
||||||
|
- docker-compose.migration.yml
|
||||||
|
- id: VR-231
|
||||||
|
title: Sluit de operationele 0.3.17-restpunten
|
||||||
|
status: done
|
||||||
|
priority: P0
|
||||||
|
requirement_ids:
|
||||||
|
- NFR-009
|
||||||
|
depends_on:
|
||||||
|
- VR-230
|
||||||
|
summary: Verwijder de achterhaalde push-to-deployroute, herstel voldoende Docker-netwerkcapaciteit, valideer de
|
||||||
|
resterende mailboxfout en maak actuele versus historische deploymentstatus ondubbelzinnig.
|
||||||
|
acceptance_criteria:
|
||||||
|
- Main-pushes maken geen onbeperkt wachtende deployruns meer; alleen de immutable tagrelease bouwt en publiceert.
|
||||||
|
- Unraid heeft een persistente, conflictvrije default address pool met minstens 256 kleine bridge-netwerken.
|
||||||
|
- De Jobat-productiemailbox slaagt opnieuw via de bestaande OAuth/IMAP-taak of levert een exact extern restpunt.
|
||||||
|
- Projectstatus en deploymentdocumentatie beschrijven alleen het actuele releasepad als operationele waarheid.
|
||||||
|
verification:
|
||||||
|
- uv run pytest tests/unit/test_deployment_configuration.py
|
||||||
|
- ./scripts/codex_verify.sh
|
||||||
|
- Live Docker-netwerk-, mailbox-, container- en readinesssmokes
|
||||||
|
primary_paths:
|
||||||
|
- .gitea/workflows/release.yml
|
||||||
|
- docs/operations/GITEA_DEPLOYMENT.md
|
||||||
|
- docs/operations/UNRAID_DEPLOYMENT.md
|
||||||
|
- docs/ai/PROJECT_STATE.md
|
||||||
|
- tests/unit/test_deployment_configuration.py
|
||||||
|
result:
|
||||||
|
completed_at: '2026-08-12'
|
||||||
|
note: Oude main-pushdeploy verwijderd en run 3065 begrensd geannuleerd; Jobat OAuth/IMAP-herpoll groen met 61
|
||||||
|
duplicates en 0 fouten; Unraid persistent op conflictvrije 10.200.0.0/16→/24-pool gezet en live proefnetwerk
|
||||||
|
bewezen; na Docker-herstart alle vooraf actieve containers hersteld en geen unhealthy containers, VacatureRadar
|
||||||
|
publiek/intern healthy.
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Projectstatus
|
# Projectstatus
|
||||||
|
|
||||||
- Laatst bijgewerkt: 2026-07-25
|
## Gecontroleerde migratie — 2026-09-09
|
||||||
- Repositoryversie: 0.3.0 volledige Stitch Intelligence Cockpit
|
|
||||||
|
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
|
- Uitvoeringsmodus: autonome backlog
|
||||||
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
||||||
|
|
||||||
@@ -19,6 +31,162 @@ De repository bevat een uitvoerbare Django-MVP met:
|
|||||||
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
||||||
- offline unit-, integratie- en securitytests.
|
- offline unit-, integratie- en securitytests.
|
||||||
|
|
||||||
|
## Laatste 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-IP’s gelden alleen vanaf expliciet vertrouwde proxy-CIDR’s;
|
||||||
|
- 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
|
## Laatste frontendvervanging
|
||||||
|
|
||||||
VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
|
VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
|
||||||
@@ -32,6 +200,127 @@ VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
|
|||||||
|
|
||||||
Het volledige scherm- en afwijkingsrapport staat in `docs/design/STITCH_INTEGRATION_REPORT.md`.
|
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-209–213-verificatie op 2026-07-22:
|
VR-209–213-verificatie op 2026-07-22:
|
||||||
|
|
||||||
- 248 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,40% branch-aware codedekking;
|
- 248 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,40% branch-aware codedekking;
|
||||||
@@ -184,11 +473,14 @@ VR-208-herverificatie op 2026-07-22:
|
|||||||
## Deploymentstatus
|
## Deploymentstatus
|
||||||
|
|
||||||
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
||||||
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, 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 Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
|
||||||
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
||||||
- De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie.
|
- Historisch bewijs (2026-07-22, inmiddels vervangen): de self-hosted runner was
|
||||||
- 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.
|
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
|
## Laatste uitgevoerde backlogtaak
|
||||||
|
|
||||||
@@ -381,21 +673,23 @@ Een toekomstige agent moet altijd de actuele waarheid opnieuw bepalen met:
|
|||||||
|
|
||||||
## Nog te bouwen
|
## 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
|
## Externe blokkades
|
||||||
|
|
||||||
Deze blokkeren onafhankelijke code niet:
|
Deze blokkeren onafhankelijke code niet:
|
||||||
|
|
||||||
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
|
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
|
||||||
- live IMAP/SMTP-account en app-password;
|
|
||||||
- Unraid-domein, DNS, TLS en reverse proxy;
|
|
||||||
- containerregistry en credentials;
|
|
||||||
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
|
|
||||||
|
|
||||||
## Bekende verificatiebeperking van de aangeleverde basis
|
## 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
|
## Hervatten
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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` |
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Performanceaudit 2026-07-29
|
||||||
|
|
||||||
|
## Importoptimalisatie 0.3.14
|
||||||
|
|
||||||
|
De eerder geregistreerde importhotspot is opgelost en reproduceerbaar gemeten in een disposable PostgreSQL-database. Tegenover de 0.3.13-baseline daalt de gemiddelde 250-itemimport van 16.358,158 naar 9.040,123 ms; p95 daalt van 79,871 naar 52,296 ms en queryvolume van meer dan 9.000 naar exact 3.264. Zie `IMPORT_PERFORMANCE_BASELINE.md`, `IMPORT_QUERY_ANALYSIS.md` en `IMPORT_PERFORMANCE_FINAL.md`.
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
- Lokale quickbenchmark: 250 invoeritems op Windows 11, Python 3.13, 16 CPU-threads.
|
||||||
|
- Import: p50 348,879 ms; p95 825,107 ms; totaal 125.982,74 ms.
|
||||||
|
- Dashboardlijst: p95 1.128,986 ms in de synthetische benchmark.
|
||||||
|
- Detail: p95 101,754 ms.
|
||||||
|
- Rescore: 53,284 ms voor de overgebleven unieke job in deze quickrun.
|
||||||
|
- Piekgeheugen: 105,588 MB.
|
||||||
|
- Frontend: lokale CSS en JavaScript, zonder framework of externe runtimeassets.
|
||||||
|
|
||||||
|
De eerste run ontdekte een echte benchmarkfout: de generieke HTML-fixture leverde slechts vier van zes verwachte velden. De adapter en fixture ondersteunen nu ook gelabelde publicatiedatum en dienstverband. De herhaalde kwaliteitsrun behaalt 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en nul rankingchurn.
|
||||||
|
|
||||||
|
## Wijzigingen
|
||||||
|
|
||||||
|
- Vacaturepaginering verlaagd van 30 naar 20 kaarten per pagina.
|
||||||
|
- Ultrawideframe gecentreerd zonder extra clientcode.
|
||||||
|
- Mobiele pipeline gebruikt een verticale grid en veroorzaakt geen horizontale documentoverflow.
|
||||||
|
- Runtimeheartbeat gebruikt de bestaande cache en voegt geen netwerkprobe aan paginarendering toe.
|
||||||
|
|
||||||
|
## Resterende risico's
|
||||||
|
|
||||||
|
- De synthetische quickimport overschrijdt de historische NFR-007-p95-doelwaarde; profiling van het volledige 25.000-itempad blijft zinvol vóór opschaling.
|
||||||
|
- De quickbenchmark meldt meer dan 7.900 databasequeries voor 250 importpogingen. Correctheid en idempotentie zijn goed, maar bulkimport is de eerstvolgende optimalisatiekandidaat.
|
||||||
|
- Queuediepte is met de generieke Django-cachebackend niet betrouwbaar meetbaar en wordt daarom eerlijk als onbekend getoond.
|
||||||
|
|
||||||
|
## Productiereleaseprofiling
|
||||||
|
|
||||||
|
De releaseherhaling overschreed opnieuw Django's queryloglimiet van 9.000 queries voordat de 250-itemrun afrondde. Een aansluitende herhaalrun produceerde door achtergebleven benchmarkidentiteiten geen nieuwe vacatures; database-isolatie via de lokale `.env` activeerde vervolgens terecht de productiedriver. Daardoor is geen betrouwbare nawaarde beschikbaar en is geen risicovolle pipelinebulkrefactor in de productieoplevering opgenomen. Vervolg: geef de benchmark een expliciete tijdelijke database en profileer `process_extraction_batch` per queryfamilie; behoud idempotentie, dedupeprecision/recall 1,0 en nul rankingchurn.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Professionaliseringstraceability
|
||||||
|
|
||||||
|
| Roadmaponderdeel | Implementatie | Bewijs | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Responsive frame | `premium.css`, `layout.css` | zes Playwrightviewports, overflowassertie | implemented and verified |
|
||||||
|
| Mobiele gastbanner | `base.html`, `components.css` | integratietest en mobiele E2E | implemented and verified |
|
||||||
|
| Sticky/mobiele pipeline | `responsive.css`, `premium.css` | mobiele kernreis | implemented and verified |
|
||||||
|
| Verplichte browsergate | CI, E2E-suite | Chromiuminstallatie, console/page-errorchecks | implemented and verified |
|
||||||
|
| Vacatureflow | `JobListView`, `jobs/list.html` | view- en E2E-tests | implemented and verified |
|
||||||
|
| Zoekscope en recente termen | `base.html`, `app.js` | lokale, begrensde browseropslag | implemented and verified |
|
||||||
|
| Democontrols | read-only middleware en responsive CSS | auth- en E2E-tests | implemented and verified |
|
||||||
|
| Runtimeobservability | `apps/core/tasks.py`, automationservice | runtime-observabilitytests | implemented and verified |
|
||||||
|
| Parser/performance | generic HTML-adapter en benchmarkfixture | benchmark 12/12, precision/recall 1,0 | implemented and verified |
|
||||||
|
| Container/security | bestaande Docker/CI-hardening | live imagebuild, migrations, restart en readiness | implemented and live verified |
|
||||||
|
| Live TLS/proxy | productieconfiguratie en OpenResty | HTTP→HTTPS, HSTS, securityheaders en browserflow | implemented and live verified |
|
||||||
|
| Back-up/restore | custom dump, media en runbook | geïsoleerde restore, exacte tellingen en aparte applicatiestart | implemented and live verified |
|
||||||
|
| Rollback | immutable vorige en huidige imagetags | oude image healthy, daarna 0.3.13 opnieuw healthy | implemented and live verified |
|
||||||
|
| Externe identity/mail | versleutelde configuratieboundary | `USER_INPUT_REQUIRED.md` | optional external action |
|
||||||
|
| Importperformance 0.3.14 | run-lokale caches, evidence bulk sync en scorecopy | geïsoleerde 3-run PostgreSQL-meting en regressietests | implemented and verified |
|
||||||
@@ -92,3 +92,39 @@ Er is geen oude shell, alternatieve feature flag of parallelle frontendroute. Ou
|
|||||||
De volledige `scripts/codex_verify.sh`-gate is zowel vóór als na ledgerafronding groen: Ruff, Django system check, `makemigrations --check --dry-run`, 248 geslaagde tests, 2 optioneel overgeslagen Playwrightvarianten, 84,40% branch-aware coverage, backlogvalidatie en repository-/documentvalidatie.
|
De volledige `scripts/codex_verify.sh`-gate is zowel vóór als na ledgerafronding groen: Ruff, Django system check, `makemigrations --check --dry-run`, 248 geslaagde tests, 2 optioneel overgeslagen Playwrightvarianten, 84,40% branch-aware coverage, backlogvalidatie en repository-/documentvalidatie.
|
||||||
|
|
||||||
Bekende externe beperkingen wijzigen de frontend niet: live mailbox-/SMTP-credentials en productie-infrastructuur blijven afzonderlijke `blocked-external` taken. Niet-bestaande backenddata wordt bewust als onbekend getoond.
|
Bekende externe beperkingen wijzigen de frontend niet: live mailbox-/SMTP-credentials en productie-infrastructuur blijven afzonderlijke `blocked-external` taken. Niet-bestaande backenddata wordt bewust als onbekend getoond.
|
||||||
|
|
||||||
|
## Premium verfijning (VR-216)
|
||||||
|
|
||||||
|
De high-fidelity cockpit is na een senior UX-audit verfijnd volgens het principe “calm intelligence”. De onderliggende Stitch-informatiearchitectuur en alle server-rendered flows blijven intact; de visuele laag vermindert gelijkwaardige kaders en reserveert cyan voor primaire acties, actieve navigatie en betekenisvolle signalen. Navigatie, labels en hulptekst gebruiken humane typografie, terwijl cijfers en technische statusinformatie hun compacte technische karakter behouden.
|
||||||
|
|
||||||
|
Dashboard-KPI's staan mobiel in een compact 2×2-raster zodat de eerste matches binnen de eerste viewport beginnen. De vacaturefilters gedragen zich als één responsieve commandobar, pipelinekolommen hebben een stabiele interne scroll en scroll-snap, en het zoekprofiel gebruikt een brede samengestelde kaart in plaats van een klein eiland in lege ruimte. Het lichte thema heeft meer tonale diepte en minder grijze vlakheid.
|
||||||
|
|
||||||
|
De geïntegreerde browsercontrole dekte dashboard, verkenner, pipeline en profiel op 1280×800, dashboard en verkenner op 390×844, en het dashboard in donker en licht thema. De gemeten documentbreedte bleef binnen de viewport; op 390 px gebruikte het dashboard twee KPI-kolommen en begon de matchsectie op 550 px. De loginconsole is schoon nadat de voorheen inline logininteractie naar `static/js/login.js` is verplaatst conform de bestaande CSP.
|
||||||
|
|
||||||
|
### Interactieve illustratielaag
|
||||||
|
|
||||||
|
Het zoekprofiel gebruikt een herbruikbare lokale SVG-radar als projectillustratie. Scan, signalen en orbitale details bewegen langzaam; pointerbeweging geeft maximaal tien pixels parallax. De visual is `aria-hidden`, bevat geen metric of statusclaim en laat alle profielinformatie en acties zelfstandig intact. Zonder JavaScript blijft de compositie bruikbaar en `prefers-reduced-motion: reduce` verwijdert animatie en parallax volledig. Op 390 px staat de illustratie onder de primaire profielactie, zodat zij de kernflow niet uit de eerste leesvolgorde verdringt.
|
||||||
|
|
||||||
|
## Persoonlijke merklaag (VR-218)
|
||||||
|
|
||||||
|
Favicon, login en shell gebruiken één compact radarbeeldmerk met een donker fundament, cyan scansignaal en emerald detectiepunt. De tekstsignatuur noemt standaard `Jens · private intelligence`; `VACATURERADAR_OWNER_NAME` maakt deze eigenaar expliciet configureerbaar zonder templatefork. De bovenbalk combineert eigenaar en routecontext met de bestaande zoekactie, zodat gebruikers sneller herkennen in welke werkruimte en flow zij zich bevinden.
|
||||||
|
|
||||||
|
De actieve navigatie, merklock-up en zoekfocus hebben preciezere gewicht-, contrast- en motionstates gekregen. Op smalle viewports verdwijnt de aanvullende werkruimtecontext zodat zoeken en primaire navigatie voorrang houden. De browsercontrole van dashboard, verkenner, profiel en login op 1280×850 en 390×844 bevestigt een schone console, geladen lokale assets en geen documentoverflow.
|
||||||
|
|
||||||
|
## Ultrawide gridcorrectie (VR-219)
|
||||||
|
|
||||||
|
De eerdere premiumlaag centreerde ieder direct hoofdonderdeel afzonderlijk op maximaal 1860 px. Op een 3840-pixelviewport ontstond daardoor links van de inhoud ruim 1100 px lege ruimte, terwijl de vaste bovenbalk een ander anker gebruikte. Alle directe hoofdsecties delen nu één links verankerde contentzone van maximaal 2800 px. Op 3840 px beginnen heading, filters, explorer, systeemstatus, pipeline, KPI's en automationdetail exact op x=296; op 1920 px vullen zij de beschikbare 1576 px en op 390 px de beschikbare 350 px.
|
||||||
|
|
||||||
|
De verkenner begrenst zijn resultatenkolom op ultrawide tot 880 px en geeft de resterende ruimte aan vacature-intelligence. De automationlograil blijft tussen 340 en 430 px, zodat tabellen en logs een voorspelbare verhouding houden. De merklock-up is tegelijk geharmoniseerd tot `VacatureRadar — Personal intelligence by Jens` en de desktopwerkruimte tot `Jens Intelligence`; login en herstelpagina's gebruiken dezelfde eigenaarssignatuur.
|
||||||
|
|
||||||
|
## Officiële ITWorx-merklaag (VR-220)
|
||||||
|
|
||||||
|
De projecteigenaar verduidelijkte dat “eigen branding” verwijst naar de officiële ITWorx.tech-identiteit. De tijdelijke tekstuele Jens-lock-up is daarom vervangen door de transparante, opgeschoonde wordmark uit het aangeleverde `ITWorx_Logo_Asset_Pack_2026-07-25.zip`. In de sidebar staat ITWorx.tech als hoofdmerk met `VacatureRadar · intelligence cockpit` als productdescriptor; de bovenbalk combineert ITWorx.tech met de bestaande routecontext. Login en wachtwoordherstel tonen eerst de officiële wordmark en vervolgens VacatureRadar als product.
|
||||||
|
|
||||||
|
Het favicon/app-icon en Dockermanicoon zijn deterministische crops van het cloud-check-symbool uit dezelfde goedgekeurde wordmark, op respectievelijk 512×512 en 256×256. Er is geen nieuw beeldmerk gegenereerd of nagetekend. Alle assets zijn lokaal, transparant en gedocumenteerd in `static/img/README.md`. De browsermatrix op 390, 1280 en 1920 px bevestigt correcte wordmarkverhoudingen, geladen PNG-assets, geen documentoverflow en een schone console.
|
||||||
|
|
||||||
|
## Gecorrigeerde merkarchitectuur (VR-221)
|
||||||
|
|
||||||
|
Een volgende verduidelijking maakte duidelijk dat ITWorx.tech de ontwikkelaar is en niet het productmerk. VacatureRadar gebruikt daarom opnieuw een zelfstandig beeldmerk, nu herontworpen als een open cyan radarboog, heldere scanstraal en één emerald kanssignaal op een compact navy appvlak. Twee beeldgeneratieconcepten verkenden de vormtaal; de geselecteerde eenvoudige richting is vervolgens bewust handmatig als vijf-elementen-SVG opgebouwd voor exacte geometrie, schaalbaarheid en herkenning op faviconformaat.
|
||||||
|
|
||||||
|
Sidebar, bovenbalk, login, favicon en Dockerman tonen VacatureRadar als primair merk. De officiële aangeleverde ITWorx.tech-wordmark staat alleen nog in een rustige `Ontwikkeld door`-credit tussen radarstatus en account, en onder authenticatiekaarten. De credit gebruikt een lokaal asset en concurreert niet met de producttitel. Browsercontrole op 390, 1280 en 1920 px bevestigt correcte hiërarchie, geladen logo's, geen overflow en een schone console.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Visuele audit en herstelroadmap
|
||||||
|
|
||||||
|
Datum: 26 juli 2026
|
||||||
|
Scope: alle tien primaire cockpitroutes, login en wachtwoordherstel; dark/light; 390, 768, 1280, 1440, 1920 en 3840 px.
|
||||||
|
|
||||||
|
## Ontwerpoordeel
|
||||||
|
|
||||||
|
VacatureRadar heeft een onderscheidende, geloofwaardige dark-intelligencebasis. De cyan/emerald-signalen, technische typografie en datagedreven panelen passen bij het product. De vorige iteraties hebben echter lokale oplossingen op elkaar gestapeld. Daardoor zijn merkhiërarchie, informatiedichtheid en horizontale ankers niet overal één systeem. De premiumkans zit vooral in rust, reductie en voorspelbaarheid, niet in meer decoratie.
|
||||||
|
|
||||||
|
## Bevindingen
|
||||||
|
|
||||||
|
| Prioriteit | Bevinding | Gevolg | Herstel |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| P0 | De roze conceptafbeelding was een beeldgeneratie-werkachtergrond en had nooit als logoresultaat gepresenteerd mogen worden. | Merkverwarring en vertrouwensverlies. | Alleen het lokaal gecontroleerde VacatureRadar-radarlogo wordt als productasset gebruikt; geen gegenereerde conceptplaat wordt geleverd of gedeployed. |
|
||||||
|
| P1 | De bovenbalk en hoofdcontent hadden verschillende maximale breedtes op ultrawide. | Acties leken los te staan van de pagina en oogbewegingen werden te lang. | Eén gedeeld contentframe voor topbar en hoofdsecties, met een begrensde ultrawidevariant. |
|
||||||
|
| P1 | Het dashboard toonde tot acht vrijwel gelijkwaardige vacaturekaarten en werd 2.399 px hoog op 1440×900. | Een dagoverzicht voelde als een tweede resultatenlijst. | Beperk het overzicht tot vijf besliskaarten; de verkenner blijft de volledige rangschikking. |
|
||||||
|
| P1 | De activiteitstijdlijn gebruikte 256 px per gebeurtenis en werd 5.901 px hoog. | Lage informatiedichtheid, veel scrollen en zwakke scanbaarheid. | Compactere marker, kaart, detailzone en verticale tussenruimte zonder informatieverlies. |
|
||||||
|
| P1 | De ITWorx-credit stond in een wit, omkaderd vlak met relatief groot woordmerk. | De ontwikkelaarscredit concurreerde visueel met het productmerk en oogde als advertentie. | Maak de credit randloos, kleiner, minder verzadigd en secundair; VacatureRadar blijft primair. |
|
||||||
|
| P1 | Meerdere tekstlinks hadden een klikhoogte rond 26 px. | Zwakkere muis- en touchbruikbaarheid, ondanks semantisch correcte links. | Secundaire actielinks krijgen minimaal 38 px interactiehoogte; primaire controls blijven 44 px. |
|
||||||
|
| P2 | Op 1440 px gebruikt de filterconsole twee functionele rijen, maar de visuele groepering is zwak. | Zoeken, verfijnen en toepassen lezen als losse velden. | Behoud responsieve wrapping, maar versterk consolekaart, vaste gaps en consistente controlhoogtes. |
|
||||||
|
| P2 | Dashboardkaarten hadden veel interne verticale ruimte en sterke schaduwen. | De feed werd zwaarder dan de intelligence-rail. | Compactere kaartpadding en gaps; schaduw uitsluitend als elevatiesignaal. |
|
||||||
|
| P2 | De profielpagina benut slechts circa 1.040 px van 1.121 px beschikbare content. | Een enkel profiel voelt onbedoeld klein. | Bewuste brede compositie behouden, met illustratie als ondersteunend element en duidelijke primaire actie. |
|
||||||
|
| P2 | Systeemstatus en acht pipelinefasen zijn op laptopbreedte informatief maar erg dicht. | Kleine labels vertragen scanbaarheid. | Pipeline behoudt interne horizontale overflow op klein scherm; statusmetadata groepeert in consistente kolommen. |
|
||||||
|
| P2 | De UI bevat veel uppercase labels van 8–10 px. | Technische sfeer, maar zwakkere leesbaarheid op compacte of geschaalde displays. | Uppercase alleen voor korte metadata; body- en actietekst blijven minimaal 12–15 px. |
|
||||||
|
| P2 | Bewegingsreductie dekte de radarillustratie, niet alle transities en entreeanimaties. | Onvolledige reduced-motionervaring. | Globale gereduceerde motion voor animatie, transitie en smooth scrolling. |
|
||||||
|
| P3 | De accountnaam verschijnt zowel in de sidebar als in het menu; de topbar herhaalt productnaam en paginacontext. | Lichte redundantie, vooral op smaller desktop. | Sidebar blijft identiteit en navigatie; topbar blijft context, zoeken en systeemacties met compactere lock-up. |
|
||||||
|
| P3 | Lange beheerpagina’s zijn inhoudelijk correct maar hebben weinig lokale navigatie. | Gebruikers verliezen sneller hun positie in bronnen en activiteit. | Compacte rijen, sticky context waar aanwezig en paginering blijven leidend; geen verborgen inhoud. |
|
||||||
|
|
||||||
|
## Uitgevoerde roadmap
|
||||||
|
|
||||||
|
1. **Merkdiscipline** — productlogo en favicon strikt scheiden van ITWorx-ontwikkelaarsbranding; credit visueel terugplaatsen.
|
||||||
|
2. **Layoutfundament** — één maximaal contentframe voor topbar en pagina, met voorspelbare ankers van laptop tot ultrawide.
|
||||||
|
3. **Informatiereductie** — dashboard terugbrengen tot vijf kernmatches en activiteit compacter maken.
|
||||||
|
4. **Interactie en toegankelijkheid** — secundaire klikdoelen vergroten en reduced-motion volledig maken.
|
||||||
|
5. **Responsieve verfijning** — alle kernroutes controleren op mobiel, laptop, desktop en ultrawide, in licht en donker.
|
||||||
|
6. **Regressie en release** — templatecontracten, volledige gate, live containerhealth en assetsmokes uitvoeren.
|
||||||
|
|
||||||
|
## Acceptatiebewijs
|
||||||
|
|
||||||
|
- Browsermatrix: 50 combinaties over tien ingelogde routes op 390, 768, 1280, 1920 en 3840 px; geen documentoverflow of consolewaarschuwingen.
|
||||||
|
- Licht thema: dashboard en mobiele verkenner gecontroleerd; correcte thematokens, geen overflow en intacte filters.
|
||||||
|
- Authenticatie: login op 390 en 1440 px; één herstel-link, gescheiden labels, zelfstandig gerenderde lijniconen en correcte merkhiërarchie.
|
||||||
|
- Meetbare reductie: dashboard 2.399 → 1.838 px op 1440 px; kernmatches 7/8 → 5; activiteitgebeurtenis 268 → 242 px na cacheverversing.
|
||||||
|
- Repositorygate: Ruff, Django check, migratiecheck, 268 tests geslaagd, 2 optionele browsertests overgeslagen en 83,45% branch-aware dekking.
|
||||||
|
- Live release: image `sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70` is healthy; readiness, favicon, productlogo, ITWorx-wordmark en CSS 0.3.9 antwoorden met HTTP 200.
|
||||||
|
|
||||||
|
## Vervolgcorrectie VR-223 — 27 juli 2026
|
||||||
|
|
||||||
|
De gebruikersscreenshot maakte zichtbaar dat de eerdere oplossing onderaan de sidebar nog te veel losse identiteitselementen stapelde en dat het productlogo door zijn donkere tegel zwaarder oogde dan de rest van de interface.
|
||||||
|
|
||||||
|
- het productlogo is teruggebracht tot vier transparante vectorvormen: open radarboog, scanwijzer, kern en kanssignaal;
|
||||||
|
- de sidebar bevat geen tweede accountkaart meer; profielstatus en ontwikkelaarscredit vormen één rustige footer en accountacties blijven in het topbarmenu;
|
||||||
|
- de ITWorx-wordmark heeft op dark mode geen witte stickerachtergrond meer maar een subtiele monochrome behandeling; light mode behoudt de officiële kleuren;
|
||||||
|
- het contentframe groeit gecontroleerd naar 2.200 px op widescreen en 2.480 px op 4K, zonder onbeperkte kaart- of leesregelbreedtes;
|
||||||
|
- 18 shellcombinaties over dashboard, verkenner en automatisering op 390, 1280, 1920, 2560 en 3840 px plus 1280×600 hadden geen overlap, overflow of consolemelding;
|
||||||
|
- login en sidebar zijn aanvullend in dark/light gecontroleerd: transparante logoachtergrond, één zichtbare accountlocatie en correcte themabehandeling.
|
||||||
|
- live release `sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921` is healthy; readiness, favicon, transparant productlogo en CSS 0.3.11 antwoorden met HTTP 200.
|
||||||
@@ -1,40 +1,39 @@
|
|||||||
# Gitea push-to-deploy (self-hosted, geen handmatige variabelenvulling)
|
# Gitea immutable releasepad
|
||||||
|
|
||||||
Push naar `main` of `master` triggert automatisch:
|
Een push naar `main` publiceert geen productiecode. Alleen een expliciete semantische
|
||||||
|
tag `v*.*.*` start `.gitea/workflows/release.yml`. De runner:
|
||||||
|
|
||||||
- checkout op de server runner
|
1. verifieert de volledige repository;
|
||||||
- `bash scripts/deploy_docker.sh`
|
2. bouwt de Unraid-image met commit- en versielabels;
|
||||||
- deploy met `docker compose up -d --build`
|
3. publiceert die image naar de private Gitea-containerregistry;
|
||||||
|
4. genereert een SPDX-SBOM, digestbestand, checksums en release notes.
|
||||||
|
|
||||||
Deze workflow gebruikt defaults en hoeft niet te wachten op repo-secrets voor host/ssh/credentials.
|
Productie wordt daarna expliciet op de volledige `versie@sha256:digest` gepind.
|
||||||
|
Hierdoor kan een gewone documentatie- of codepush nooit ongemerkt productie
|
||||||
|
overschrijven en bestaat geen parallel mutable push-to-deploypad meer.
|
||||||
|
|
||||||
## Vereiste eenmalige setup op de server
|
## Vereiste runnerconfiguratie
|
||||||
|
|
||||||
1. Start en registreer een self-hosted Gitea-runner voor deze repo.
|
- De Gitea-runner accepteert label `ubuntu-latest` en kan jobcontainers starten.
|
||||||
2. Zorg dat die runner Docker/Compose kan draaien in de projectmap waar de runner draait.
|
- `REGISTRY_USER` en `REGISTRY_TOKEN` zijn repositorysecrets met uitsluitend de
|
||||||
3. Laat éénmaal dit script draaien op de server:
|
noodzakelijke package-schrijfrechten.
|
||||||
|
- Unraid gebruikt een afzonderlijk `read:package`-token om gepubliceerde images te
|
||||||
|
pullen; dat token staat alleen in de root-Dockerconfig op de server.
|
||||||
|
- De runnerhost heeft voldoende vrije Docker-address-pools voor tijdelijke
|
||||||
|
jobnetwerken; volg daarvoor de netwerksectie in
|
||||||
|
[Unraid-deployment](UNRAID_DEPLOYMENT.md#docker-netwerkcapaciteit).
|
||||||
|
|
||||||
```bash
|
## Publiceren en uitrollen
|
||||||
bash scripts/deploy_docker.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Daarna is het gedrag volledig push-only:
|
1. Maak en verifieer een database- en configuratieback-up.
|
||||||
|
2. Push de geverifieerde releasecommit naar `main`.
|
||||||
|
3. Maak en push de semantische tag.
|
||||||
|
4. Wacht tot de Gitea-releasejob groen is en verifieer de artifactchecksums.
|
||||||
|
5. Pull op Unraid de gepubliceerde volledige digest.
|
||||||
|
6. Zet `VACATURERADAR_IMAGE` op `versie@sha256:digest` en herstart Compose met
|
||||||
|
`--no-build --force-recreate`.
|
||||||
|
7. Controleer intern en publiek `/health/live/` en `/health/ready/`, runtimeversie,
|
||||||
|
containerhealth en restartteller.
|
||||||
|
|
||||||
- `git push` naar `main` of `master` → automatische deploy
|
PostgreSQL, Redis en Ollama blijven niet publiek bereikbaar. Bewaar de vorige
|
||||||
|
digest totdat minstens één volledige schedulercyclus groen is.
|
||||||
## Defaults
|
|
||||||
|
|
||||||
- `APP_HOST`: hostname van de server (fallback: `hostname`)
|
|
||||||
- `APP_SCHEME`: `http`
|
|
||||||
- `DJANGO_DEBUG`: afgeleid uit `APP_SCHEME`
|
|
||||||
- `0` als `APP_SCHEME=https`
|
|
||||||
- `1` als `APP_SCHEME=http`
|
|
||||||
- `DEPLOY_COMPOSE_FILE`: `docker-compose.yml`
|
|
||||||
- `APP_ORIGINS`, `APP_HOSTS`, `APP_PORT`: optioneel en veilig overnemen als op runner gezet
|
|
||||||
|
|
||||||
## Endpoint na deploy
|
|
||||||
|
|
||||||
- `http://<runner-host>:1226/health/ready/`
|
|
||||||
- `http://<runner-host>:1226/health/live/`
|
|
||||||
|
|
||||||
Voor productie: zet later op de runner de gewenste runtime-omgeving (bijv. reverse proxy + TLS) en herstart push.
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Nginx Proxy Manager en publiek HTTPS-domein
|
||||||
|
|
||||||
|
Deze handleiding publiceert VacatureRadar achter Nginx Proxy Manager zonder de Django-container rechtstreeks op internet bloot te stellen.
|
||||||
|
|
||||||
|
## Waarom `DisallowedHost` verschijnt
|
||||||
|
|
||||||
|
Django weigert onbekende `Host`-headers bewust. Een publieke hostnaam moet daarom expliciet overeenkomen met:
|
||||||
|
|
||||||
|
- `PUBLIC_BASE_URL`;
|
||||||
|
- `DJANGO_ALLOWED_HOSTS`;
|
||||||
|
- `DJANGO_CSRF_TRUSTED_ORIGINS`.
|
||||||
|
|
||||||
|
De applicatie leidt de host en CSRF-origin automatisch af uit `PUBLIC_BASE_URL`, maar de expliciete lijsten blijven ondersteund voor LAN- en beheerhosts.
|
||||||
|
|
||||||
|
Een publieke Django-debugpagina is een afzonderlijk beveiligingsprobleem. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; debugpagina's tonen interne paden en implementatiedetails.
|
||||||
|
|
||||||
|
## VacatureRadar configureren
|
||||||
|
|
||||||
|
Voer dit uit in de huidige bronmap op Unraid:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/user/appdata/VacatureRadar
|
||||||
|
python scripts/configure_public_url.py \
|
||||||
|
https://vacatureradar.itworx.tech \
|
||||||
|
--env-file /mnt/user/appdata/VacatureRadar/.env \
|
||||||
|
--cache-url redis://127.0.0.1:6379/1
|
||||||
|
```
|
||||||
|
|
||||||
|
De helper:
|
||||||
|
|
||||||
|
- bewaart bestaande secrets en databasewachtwoorden;
|
||||||
|
- zet `DJANGO_DEBUG=0`;
|
||||||
|
- voegt de host en CSRF-origin toe;
|
||||||
|
- activeert secure cookies en HTTPS-redirect;
|
||||||
|
- zet een conservatieve HSTS-periode;
|
||||||
|
- activeert reverse-proxyherkenning;
|
||||||
|
- maakt de gastdemo alleen-lezen;
|
||||||
|
- gebruikt Redis voor gedeelde rate limiting en readiness;
|
||||||
|
- schrijft het bestand atomair met modus `0600`.
|
||||||
|
|
||||||
|
Hermaak daarna de container. Een gewone restart laadt gewijzigde environmentvariabelen niet altijd opnieuw:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
|
||||||
|
```
|
||||||
|
|
||||||
|
Voor een andere omgeving kan `deployment/production.env.example` als checklist worden gebruikt.
|
||||||
|
|
||||||
|
## Proxy Host in Nginx Proxy Manager
|
||||||
|
|
||||||
|
Gebruik voor de Proxy Host:
|
||||||
|
|
||||||
|
| Instelling | Waarde |
|
||||||
|
|---|---|
|
||||||
|
| Domain Names | `vacatureradar.itworx.tech` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | het LAN-IP of de Docker-hostnaam van VacatureRadar |
|
||||||
|
| Forward Port | `1226` |
|
||||||
|
| Cache Assets | uit, tenzij expliciet getest |
|
||||||
|
| Block Common Exploits | aan |
|
||||||
|
| Websockets Support | niet vereist |
|
||||||
|
|
||||||
|
Op het tabblad **SSL**:
|
||||||
|
|
||||||
|
- vraag een geldig certificaat aan;
|
||||||
|
- activeer Force SSL;
|
||||||
|
- activeer HTTP/2;
|
||||||
|
- activeer HSTS pas nadat HTTPS correct werkt;
|
||||||
|
- activeer HSTS subdomains alleen wanneer alle subdomeinen HTTPS ondersteunen.
|
||||||
|
|
||||||
|
Nginx Proxy Manager stuurt de noodzakelijke proxyheaders normaal al door. Gebruik bij een aangepaste configuratie minstens:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
```
|
||||||
|
|
||||||
|
Overschrijf deze headers aan de proxyzijde; laat een internetclient ze niet vrij bepalen.
|
||||||
|
|
||||||
|
## Correcte client-IP-rate-limiting
|
||||||
|
|
||||||
|
VacatureRadar vertrouwt `X-Forwarded-For` alleen wanneer de directe afzender in `TRUSTED_PROXY_CIDRS` staat. Zonder deze instelling blijft rate limiting veilig op het proxy-IP werken, maar worden bezoekers niet individueel onderscheiden.
|
||||||
|
|
||||||
|
Bepaal het proxy-IP of Docker-netwerk en voeg alleen dat bereik toe, bijvoorbeeld:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/configure_public_url.py \
|
||||||
|
https://vacatureradar.itworx.tech \
|
||||||
|
--env-file /mnt/user/appdata/VacatureRadar/.env \
|
||||||
|
--cache-url redis://127.0.0.1:6379/1 \
|
||||||
|
--trusted-proxy-cidr 172.18.0.0/16
|
||||||
|
```
|
||||||
|
|
||||||
|
Gebruik geen brede private range zoals `10.0.0.0/8` of `172.16.0.0/12` zonder aantoonbare noodzaak.
|
||||||
|
|
||||||
|
## Validatie
|
||||||
|
|
||||||
|
Controleer eerst intern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsS http://127.0.0.1:1226/health/live/
|
||||||
|
curl -fsS http://127.0.0.1:1226/health/ready/
|
||||||
|
```
|
||||||
|
|
||||||
|
Controleer daarna publiek:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://vacatureradar.itworx.tech/
|
||||||
|
curl -fsS https://vacatureradar.itworx.tech/robots.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Verwacht:
|
||||||
|
|
||||||
|
- geen `DisallowedHost`;
|
||||||
|
- geen Django-debugpagina;
|
||||||
|
- een redirect naar login of HTTP 200 na authenticatie;
|
||||||
|
- `Strict-Transport-Security` zodra HSTS actief is;
|
||||||
|
- `X-Content-Type-Options: nosniff`;
|
||||||
|
- `X-Frame-Options: DENY`;
|
||||||
|
- `Content-Security-Policy`;
|
||||||
|
- standaard `X-Robots-Tag: noindex, nofollow, noarchive` voor een persoonlijke installatie.
|
||||||
|
|
||||||
|
Controleer ook een echte login, logout en één CSRF-beschermde actie. Alleen een succesvolle GET bewijst niet dat proxy-HTTPS en CSRF correct zijn.
|
||||||
|
|
||||||
|
## Terugdraaien
|
||||||
|
|
||||||
|
Bij een probleem:
|
||||||
|
|
||||||
|
1. zet de Proxy Host tijdelijk op access-list/VPN-only of offline;
|
||||||
|
2. herstel de vorige `.env` uit de beheerde back-up;
|
||||||
|
3. hermaak de container;
|
||||||
|
4. controleer intern `/health/ready/`;
|
||||||
|
5. publiceer pas opnieuw nadat login en CSRF via HTTPS slagen.
|
||||||
@@ -77,6 +77,11 @@ celery -A config inspect reserved
|
|||||||
## Digest ontbreekt of dubbel
|
## Digest ontbreekt of dubbel
|
||||||
|
|
||||||
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
|
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
|
||||||
|
- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`,
|
||||||
|
`M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en
|
||||||
|
`EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`.
|
||||||
|
Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope;
|
||||||
|
Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund.
|
||||||
- Controleer `DigestOutbox` op datum en status.
|
- Controleer `DigestOutbox` op datum en status.
|
||||||
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
|
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
|
||||||
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
|
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
|
||||||
|
|||||||
@@ -4,28 +4,48 @@ Deze handleiding gebruikt `docker-compose.unraid.yml` als declaratieve bron van
|
|||||||
|
|
||||||
## Vooraf
|
## Vooraf
|
||||||
|
|
||||||
Maak deze directories:
|
Alle paden in `docker-compose.unraid.yml` zijn relatief aan de map waarin dat bestand staat. Je kopieert de projectmap dus naar een plek naar keuze; de aanbevolen locatie blijft:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/mnt/user/appdata/vacatureradar/config
|
/mnt/user/appdata/VacatureRadar
|
||||||
/mnt/user/appdata/vacatureradar/media
|
|
||||||
/mnt/user/appdata/vacatureradar/logs
|
|
||||||
/mnt/user/appdata/vacatureradar/source/local/postgres-aio17
|
|
||||||
/mnt/user/appdata/vacatureradar/source/local/redis-aio
|
|
||||||
/mnt/user/appdata/vacatureradar/ollama # alleen bij lokale AI
|
|
||||||
/mnt/user/backups/vacatureradar
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
De container maakt `local/`, `local/media`, `local/logs`, `local/postgres-aio17`, `local/redis-aio` en `local/backups` bij de eerste start zelf aan en zet de juiste eigenaar. Voor back-ups blijft `/mnt/user/backups/vacatureradar` de aanbevolen doelmap.
|
||||||
|
|
||||||
Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd.
|
Gebruik bij voorkeur een cache-backed `appdata`-share en neem die afzonderlijk in je back-upregime op. De PostgreSQL-datadir mag niet gelijktijdig door file-syncsoftware worden gemuteerd.
|
||||||
|
|
||||||
|
## Manueel kopiëren en uitrollen
|
||||||
|
|
||||||
|
Wanneer je niet via `scripts/deploy_unraid.sh` werkt maar de map met de hand kopieert (rsync, SMB-share, ZIP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/user/appdata/VacatureRadar
|
||||||
|
cp .env.unraid.example .env # alleen de eerste keer
|
||||||
|
nano .env # alle CHANGE_ME-waarden vervangen
|
||||||
|
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
|
||||||
|
docker compose -f docker-compose.unraid.yml logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopieer `local/` niet mee vanaf je werkstation: die map bevat de databasedirectory van de server. Kopieer `.env` evenmin over een bestaand serverbestand heen.
|
||||||
|
|
||||||
|
Een afwijkende hostpoort zet je in `.env` via `VACATURERADAR_HOST_PORT`; standaard is dat `1226`.
|
||||||
|
|
||||||
## Dockerman
|
## Dockerman
|
||||||
|
|
||||||
De container verschijnt als **VacatureRadar** en bevat metadata voor:
|
De container verschijnt als **VacatureRadar** en bevat metadata voor:
|
||||||
|
|
||||||
- WebUI: `http://[IP]:1226/`;
|
- WebUI: `http://[IP]:1226/`;
|
||||||
- icoon: het lokale VacatureRadar-favicon op `/static/favicon.svg`;
|
- icoon: het lokale 256×256 VacatureRadar-PNG op `/static/img/vacatureradar-docker.png`;
|
||||||
- één herstart- en healthcheckpunt voor alle interne processen.
|
- één herstart- en healthcheckpunt voor alle interne processen.
|
||||||
|
|
||||||
|
Dockerman haalt het tegelicoon via de gepubliceerde webpoort op en cachet het. De
|
||||||
|
PNG is daarom een afzonderlijke rasterasset met een stabiel pad; de SVG-favicon
|
||||||
|
blijft voor browsers behouden. Het icoonlabel gebruikt bewust de concrete
|
||||||
|
server-side URL `http://127.0.0.1:1226/...`: Dockerman vervangt `[IP]` en
|
||||||
|
`[PORT:...]` alleen voor de WebUI, niet voordat het een icoon downloadt. Na een
|
||||||
|
upgrade waarbij het oude icoon nog zichtbaar is, herlaad de Docker-pagina zodat
|
||||||
|
Dockerman de lokale cache opbouwt.
|
||||||
|
|
||||||
## Imagekeuze
|
## Imagekeuze
|
||||||
|
|
||||||
De Unraid-compose bouwt lokaal met `Dockerfile.unraid` en tagt de image als `vacatureradar:unraid`. Pin voor reproduceerbare releases aanvullend een commitgebonden tag.
|
De Unraid-compose bouwt lokaal met `Dockerfile.unraid` en tagt de image als `vacatureradar:unraid`. Pin voor reproduceerbare releases aanvullend een commitgebonden tag.
|
||||||
@@ -38,48 +58,68 @@ Gebruik in productie geen zwevende `latest` zonder gecontroleerd rollbackpad.
|
|||||||
|
|
||||||
## Configuratiebestand
|
## Configuratiebestand
|
||||||
|
|
||||||
Kopieer of genereer `.env` naar:
|
`docker-compose.unraid.yml` leest `.env` naast het composebestand zelf:
|
||||||
|
|
||||||
De deploy-helper `scripts/deploy_docker.sh` vult ontbrekende sleutels op basis van veilige defaults.
|
|
||||||
Zet voor publieke productie vooraf de gewenste variabelen:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/mnt/user/appdata/vacatureradar/config/.env
|
<projectmap>/.env
|
||||||
APP_HOST=jobs.example.be
|
|
||||||
APP_SCHEME=https
|
|
||||||
DJANGO_DEBUG=0
|
|
||||||
SESSION_COOKIE_SECURE=1
|
|
||||||
CSRF_COOKIE_SECURE=1
|
|
||||||
SECURE_SSL_REDIRECT=1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Gebruik daarna de helper (zie hieronder).
|
Een afwijkende locatie kan vóór Compose via `VACATURERADAR_ENV_FILE` worden ingesteld. Start van [`.env.unraid.example`](../../.env.unraid.example) voor een all-in-one container die rechtstreeks op poort 1226 draait, of van [`deployment/production.env.example`](../../deployment/production.env.example) zodra er een reverse proxy met TLS voor staat. Kopieer nooit ongewijzigde `CHANGE_ME`-waarden.
|
||||||
|
|
||||||
|
Configureer een bestaand bestand voor de publieke URL zonder secrets te overschrijven:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/user/appdata/VacatureRadar
|
||||||
|
python scripts/configure_public_url.py \
|
||||||
|
https://vacatureradar.example.be \
|
||||||
|
--env-file .env \
|
||||||
|
--cache-url redis://127.0.0.1:6379/1
|
||||||
|
```
|
||||||
|
|
||||||
Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs.
|
Beperk de bestandsrechten van `.env` tot de beheerder. Voeg geen secrets toe aan de ZIP, Git, screenshots of supportlogs.
|
||||||
|
|
||||||
## Eerste uitrol
|
## Eerste uitrol
|
||||||
|
|
||||||
1. Controleer alle `CHANGE_ME`-waarden.
|
1. Controleer alle `CHANGE_ME`-waarden en maak een back-up van een bestaande `.env`.
|
||||||
2. Gebruik de deploy-helper voor een snelle eerste start:
|
2. Start of hermaak de Unraid-container vanuit de bronmap:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /app/VacatureRadar
|
cd /mnt/user/appdata/VacatureRadar
|
||||||
APP_HOST=jobs.example.be APP_SCHEME=https DJANGO_DEBUG=0 \
|
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
|
||||||
SESSION_COOKIE_SECURE=1 CSRF_COOKIE_SECURE=1 SECURE_SSL_REDIRECT=1 \
|
|
||||||
bash scripts/deploy_docker.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Start de ene appcontainer; de entrypoint initialiseert PostgreSQL, voert migraties uit en start daarna alle processen onder Supervisor.
|
3. De entrypoint valideert de productieconfiguratie, initialiseert PostgreSQL, voert migraties uit en start alle processen onder Supervisor.
|
||||||
6. Controleer `GET /health/live/` en `GET /health/ready/`.
|
4. Controleer intern `GET /health/live/` en `GET /health/ready/`.
|
||||||
6. Voer eenmalig in de webcontainer uit:
|
5. Voer alleen wanneer bootstrap niet automatisch is ingeschakeld eenmalig uit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python manage.py bootstrap_instance
|
docker exec -it VacatureRadar python manage.py bootstrap_instance
|
||||||
python manage.py collectstatic --noinput
|
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Meld lokaal aan en wijzig het bootstrapwachtwoord.
|
6. Meld lokaal aan en wijzig het bootstrapwachtwoord.
|
||||||
8. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd.
|
7. Activeer nog geen live bron of mailbox voordat bronbeleid, retentie en back-up zijn gecontroleerd.
|
||||||
|
|
||||||
|
## Beheerverbinding en heruitrol
|
||||||
|
|
||||||
|
Gebruik vanaf een beheerwerkstation altijd een benoemde SSH-hostalias met een afzonderlijke deploysleutel. Een rechtstreekse verbinding naar het IP-adres kan de lokale standaardgebruiker en standaardsleutel kiezen en daardoor ten onrechte als een server-side authenticatiefout lijken.
|
||||||
|
|
||||||
|
```sshconfig
|
||||||
|
Host unraid-itworx
|
||||||
|
HostName server.example.test
|
||||||
|
Port 22
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/itworx_unraid_deploy
|
||||||
|
IdentitiesOnly yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Controleer de verbinding zonder wachtwoordprompt en voer daarna de beveiligde synchronisatie, imagebuild, containervervanging en health-smoke uit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -o BatchMode=yes unraid-itworx 'id -un && docker info >/dev/null'
|
||||||
|
bash scripts/deploy_unraid.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy_unraid.sh` weigert een IP-adres, niet-rootprofiel of generieke standaardsleutel. Alleen door Git gekende of niet-genegeerde projectbestanden worden verpakt; `.env`, `local/`, databasebestanden en andere genegeerde runtimegegevens worden niet verzonden. De helper wacht op Docker-health en controleert `/health/ready/` voordat hij succes meldt. Een afwijkende veilige alias kan expliciet via `UNRAID_SSH_HOST` worden ingesteld.
|
||||||
|
|
||||||
## Releaseverificatie en upgradepad
|
## Releaseverificatie en upgradepad
|
||||||
|
|
||||||
@@ -105,7 +145,7 @@ Expose de applicatie niet rechtstreeks op internet. Plaats Nginx Proxy Manager,
|
|||||||
- optioneel extra access control/VPN voor persoonlijke installatie;
|
- optioneel extra access control/VPN voor persoonlijke installatie;
|
||||||
- geen publieke toegang tot PostgreSQL, Redis of Ollama.
|
- geen publieke toegang tot PostgreSQL, Redis of Ollama.
|
||||||
|
|
||||||
Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk.
|
Wanneer een proxy op hetzelfde Docker-netwerk draait, publiceer poort 1226 alleen intern. Wanneer Unraid routing een hostpoort vereist, beperk die via firewall/VLAN tot de proxy of het beheernetwerk. Volg voor host-, CSRF-, proxyheader- en TLS-instellingen de [Nginx Proxy Manager-handleiding](NGINX_PROXY_MANAGER.md).
|
||||||
|
|
||||||
## Mailbox
|
## Mailbox
|
||||||
|
|
||||||
@@ -124,7 +164,26 @@ IMAP_MAX_MESSAGES_PER_POLL=200
|
|||||||
IMAP_MAX_MESSAGE_BYTES=1000000
|
IMAP_MAX_MESSAGE_BYTES=1000000
|
||||||
```
|
```
|
||||||
|
|
||||||
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
|
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
|
||||||
|
|
||||||
|
Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:<versie>@sha256:<digest>` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`.
|
||||||
|
|
||||||
|
## Docker-netwerkcapaciteit
|
||||||
|
|
||||||
|
Deze Unraid-host draait veel geïsoleerde Compose-netwerken. De ingebouwde
|
||||||
|
Dockerpools delen grote `/16`- en `/20`-netwerken uit en kunnen daardoor opraken
|
||||||
|
terwijl er nog ruim voldoende private adresruimte bestaat. De live host gebruikt
|
||||||
|
daarom persistent in `/boot/config/docker.cfg`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOCKER_OPTS="--default-address-pool base=10.200.0.0/16,size=24"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dat levert 256 nieuwe `/24`-netwerken en overlapt niet met LAN `192.168.10.0/24`,
|
||||||
|
WireGuard `10.253.0.0/16` of bestaande Docker-netwerken. De instelling geldt
|
||||||
|
alleen voor nieuw aangemaakte netwerken; bestaande subnets veranderen niet.
|
||||||
|
Maak vóór wijziging een kopie van `docker.cfg`, herstart Docker éénmaal en bewijs
|
||||||
|
de pool met een tijdelijk netwerk dat na inspectie meteen wordt verwijderd.
|
||||||
|
|
||||||
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
|
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Productie-audit — 27 juli 2026
|
||||||
|
|
||||||
|
## Samenvatting
|
||||||
|
|
||||||
|
VacatureRadar heeft al een volwassen functionele en visuele basis. De audit vond geen noodzaak voor een nieuwe frontendarchitectuur; de belangrijkste professionaliseringswinst zat in de grens tussen Django, Nginx Proxy Manager en de Unraid-runtime.
|
||||||
|
|
||||||
|
De gerapporteerde `DisallowedHost` was een symptoom van ontbrekende publieke hostconfiguratie. De publiek zichtbare Django-debugpagina maakte tegelijk duidelijk dat de runtime nog ontwikkelinstellingen gebruikte. Deze release behandelt daarom hostvalidatie, HTTPS-detectie, CSRF, gedeelde rate limiting, readiness en gasttoegang als één productiecontract.
|
||||||
|
|
||||||
|
## Bevindingen en herstel
|
||||||
|
|
||||||
|
| Prioriteit | Bevinding | Risico | Herstel |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Kritiek | Publieke host ontbrak in `ALLOWED_HOSTS` | onbeschikbare publieke route | `PUBLIC_BASE_URL` leidt host en CSRF-origin af; configuratiehelper toegevoegd |
|
||||||
|
| Kritiek | `DEBUG=True` was publiek zichtbaar | lek van interne paden en implementatiedetails | productiehelper zet debug uit; productiecheck stopt bij onveilige configuratie |
|
||||||
|
| Hoog | HTTPS-origin kon de interne poort `1226` bevatten | CSRF-fouten achter poort 443 | publieke en interne poort zijn gescheiden in deployscripts |
|
||||||
|
| Hoog | Secure redirect kon interne healthchecks omleiden | container blijft unhealthy na HTTPS-hardening | alleen `/health/live/` en `/health/ready/` zijn intern vrijgesteld |
|
||||||
|
| Hoog | Loginrate-limiting gebruikte proceslokale cache | limiet verschilde per Gunicornworker | Redis-cache als gedeelde productiestandaard en readinessdependency |
|
||||||
|
| Hoog | `X-Forwarded-For` werd zonder proxyvertrouwen gebruikt | IP-spoofing en omzeilbare rate limiting | forwarded chain alleen bij expliciet vertrouwde proxy-CIDR |
|
||||||
|
| Hoog | Gedeelde demo kon mutaties uitvoeren | bezoekers beïnvloeden elkaars demo en persistente data | server-side read-only middleware, begrensde sessie en zichtbare UI-status |
|
||||||
|
| Middel | Unraid-documentatie wees naar een andere `.env` dan Compose | wijzigingen werden niet geladen | pad gestandaardiseerd op `source/.env`; override blijft mogelijk |
|
||||||
|
| Middel | Deployhelper schreef een custom `.env`, maar Compose kon alsnog de standaardfile lezen | productie-instellingen werden stil genegeerd | geselecteerd env-bestand wordt nu voor Compose-interpolatie én containeromgeving gebruikt |
|
||||||
|
| Middel | AIO Compose interpoleerde databasewachtwoord buiten `env_file` | custom env-pad kon een kapotte URL leveren | database-URL wordt uitsluitend in de containerentrypoint opgebouwd |
|
||||||
|
| Middel | Persoonlijke installatie kon geïndexeerd worden | onbedoelde vindbaarheid van login en productmetadata | `robots.txt` en `X-Robots-Tag` standaard op noindex |
|
||||||
|
| Middel | Runtime startte zonder Django deployment check | foutieve securityconfiguratie werd laat ontdekt | `manage.py check --deploy` vóór migraties en processtart |
|
||||||
|
| Laag | Project- en assetversies liepen uiteen | cache- en release-identiteit onduidelijk | releaseversie gecentraliseerd op `0.3.12` |
|
||||||
|
| Laag | Overdrachtsarchief bevatte lokale Windows/runtimeartefacten | onnodige omvang en risico op secrets of stale state | bestaande schone packager en integriteitsmanifest verplicht gebruikt |
|
||||||
|
|
||||||
|
## Bewust niet gewijzigd
|
||||||
|
|
||||||
|
- De server-rendered Django-architectuur blijft behouden.
|
||||||
|
- De Stitch Intelligence Cockpit blijft de productiefrontend; er is geen SPA of extern runtime-CDN toegevoegd.
|
||||||
|
- De containerpoort blijft op Unraid bereikbaar voor Nginx Proxy Manager. Netwerksegmentatie of firewalling blijft een live infrastructuurtaak.
|
||||||
|
- HSTS start conservatief op 300 seconden. Verhoog pas na een geslaagde publieke login-, CSRF- en subdomeincontrole.
|
||||||
|
- Zoekmachine-indexering blijft standaard uit omdat dit een persoonlijke installatie is.
|
||||||
|
|
||||||
|
## Live controles na deployment
|
||||||
|
|
||||||
|
1. Hermaak de container met de bestaande `.env`.
|
||||||
|
2. Controleer intern beide health-endpoints.
|
||||||
|
3. Controleer publiek dat HTTP naar HTTPS gaat.
|
||||||
|
4. Controleer login en logout.
|
||||||
|
5. Voer één echte CSRF-beschermde actie uit met het beheerdersaccount.
|
||||||
|
6. Controleer dat het demoaccount geen mutatie kan uitvoeren.
|
||||||
|
7. Controleer securityheaders en afwezigheid van een debugpagina.
|
||||||
|
8. Controleer in Nginx Proxy Manager dat alleen de proxy de hostpoort kan bereiken.
|
||||||
|
9. Maak een back-up en voer een herstel-smoke uit vóór verdere bronautomatisering.
|
||||||
|
|
||||||
|
## Resterende externe punten
|
||||||
|
|
||||||
|
- De echte Nginx Proxy Manager-configuratie en het proxy-IP/CIDR zijn niet in de repository zichtbaar.
|
||||||
|
- De live Unraid-container, database en Redis zijn in deze auditomgeving niet bereikbaar.
|
||||||
|
- Een volledige Dockerbuild, publieke TLS-smoke en hersteltest moeten daarom op de doelserver worden uitgevoerd.
|
||||||
@@ -48,7 +48,7 @@ Django/Celery
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| T-01 | SSRF via bron- of vacature-URL | toegang tot LAN, metadata of beheerinterfaces | schema/host/IP/poortcontrole, DNS-resolutie, redirecthercontrole, denylist, geen credentials in URL | `tests/security/test_url_security.py`, `test_policy.py`, fetchertests |
|
| T-01 | SSRF via bron- of vacature-URL | toegang tot LAN, metadata of beheerinterfaces | schema/host/IP/poortcontrole, DNS-resolutie, redirecthercontrole, denylist, geen credentials in URL | `tests/security/test_url_security.py`, `test_policy.py`, fetchertests |
|
||||||
| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert DNS opnieuw; het werkelijk verbonden peer-IP wordt als publiek gecontroleerd wanneer de transportlaag dit exposeert, anders geldt conservatieve DNS-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests |
|
| T-02 | DNS rebinding/tijd-van-controle | request bereikt ander IP dan beoordeeld | iedere fetch en redirect valideert DNS opnieuw; het werkelijk verbonden peer-IP wordt als publiek gecontroleerd wanneer de transportlaag dit exposeert, anders geldt conservatieve DNS-set-overlap | `VR-115`, `tests/unit/test_fetcher.py`, aanvullende fetcher negative tests |
|
||||||
| T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders | `tests/security/test_sanitize.py`, viewtests |
|
| T-03 | XSS/HTML-injectie | sessiediefstal of ongewenste navigatie | Bleach allowlist, platte tekst, template autoescaping, CSP/securityheaders en uitsluitend lokale externe scripts zonder `unsafe-inline` | `tests/security/test_sanitize.py`, auth- en viewtests |
|
||||||
| T-04 | Prompt injection in vacature | AI verandert beleid of voert tools uit | vacaturetekst in data-envelope, schema-output, evidence-validatie, cachefouten met foutcategorie, geen toolrechten, deterministische harde regels, AI optioneel | AI-tests, ADR-0003, `VR-109` evaluaties |
|
| T-04 | Prompt injection in vacature | AI verandert beleid of voert tools uit | vacaturetekst in data-envelope, schema-output, evidence-validatie, cachefouten met foutcategorie, geen toolrechten, deterministische harde regels, AI optioneel | AI-tests, ADR-0003, `VR-109` evaluaties |
|
||||||
| T-05 | Malafide/oversized document | geheugen/CPU/diskuitputting | contenttype-, byte-, timeout- en redirectlimieten; korte retentie | fetchertests, source-runmetrics |
|
| T-05 | Malafide/oversized document | geheugen/CPU/diskuitputting | contenttype-, byte-, timeout- en redirectlimieten; korte retentie | fetchertests, source-runmetrics |
|
||||||
| T-06 | Crawlstorm of ban | externe overlast, IP-blokkade | expliciet allowbeleid, per-origin minimuminterval, concurrencylimiet, leasegebaseerde taakclaim met TTL + crash recovery, conditional requests, backoff, quarantaine | tasktests; `VR-103`, `VR-112` |
|
| T-06 | Crawlstorm of ban | externe overlast, IP-blokkade | expliciet allowbeleid, per-origin minimuminterval, concurrencylimiet, leasegebaseerde taakclaim met TTL + crash recovery, conditional requests, backoff, quarantaine | tasktests; `VR-103`, `VR-112` |
|
||||||
@@ -67,7 +67,7 @@ Django/Celery
|
|||||||
| T-19 | Automatisch solliciteren/massamail | reputatie/privacy/juridisch risico | geen submitendpoint/task, expliciete ADR en non-goal | codezoekreview, AC-043 |
|
| T-19 | Automatisch solliciteren/massamail | reputatie/privacy/juridisch risico | geen submitendpoint/task, expliciete ADR en non-goal | codezoekreview, AC-043 |
|
||||||
| T-20 | Kwaadaardig bestand in sollicitatiedossier | host/browsercompromis | uploads nog niet actief; vóór implementatie allowlist, sniffing, size limit, niet-uitvoerbare opslag vereist | `VR-113`, nieuwe threat review |
|
| T-20 | Kwaadaardig bestand in sollicitatiedossier | host/browsercompromis | uploads nog niet actief; vóór implementatie allowlist, sniffing, size limit, niet-uitvoerbare opslag vereist | `VR-113`, nieuwe threat review |
|
||||||
| T-21 | Back-up bevat secrets/PII | langdurig datalek | encryptie, gescheiden opslag, korte retentie, herstelprocedure | runbook en hersteltest |
|
| T-21 | Back-up bevat secrets/PII | langdurig datalek | encryptie, gescheiden opslag, korte retentie, herstelprocedure | runbook en hersteltest |
|
||||||
| T-22 | Supply-chainpackage/image | code execution | exacte versies/lockfile, beperkte dependencies, pinned baseimages/digests en scans gepland | CI, `VR-115` |
|
| T-22 | Supply-chainpackage/image | code execution | exacte versies/lockfile, beperkte dependencies, pinned baseimages/digests, taggestuurde immutable release als enig productiepad, gescheiden package read/write-tokens en SBOM/checksums | CI, `VR-115`, `VR-230`, `VR-231` |
|
||||||
| T-23 | Onbevoegde admin op thuisnetwerk | volledige datatoegang | uniek wachtwoord, TLS/VPN, geen defaultcredentials, sessiebeveiliging | Unraidchecklist |
|
| T-23 | Onbevoegde admin op thuisnetwerk | volledige datatoegang | uniek wachtwoord, TLS/VPN, geen defaultcredentials, sessiebeveiliging | Unraidchecklist |
|
||||||
| T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` |
|
| T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` |
|
||||||
| T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` |
|
| T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` |
|
||||||
@@ -81,6 +81,8 @@ Django/Celery
|
|||||||
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
|
| T-39 | Mol-/Kempenbron vermengt een andere werkgever, campus of vestiging | foutieve afstand, phishing of buitenregionale resultaten | exacte HTTPS-host/pad/GUID-routes; same-host details; expliciete plaatsfilters voor regionale en gedeelde lijsten; postcodecentra blijven als benadering herkenbaar; waaklijstbronnen worden niet gescand | `tests/unit/test_regional_adapters.py`, `tests/integration/test_tasks_and_more_views.py`, `VR-126/201` |
|
||||||
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
|
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
|
||||||
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
|
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
|
||||||
|
| T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` |
|
||||||
|
| T-43 | Gedeeld demoaccount wijzigt persistente data of activeert echte mailboxpolling | bezoekers beïnvloeden elkaars ervaring, echte data of externe accounts | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, uitsluitend fictieve `example.test`-mailboxen; de loginseed herstelt begrensd en idempotent en de scheduler sluit de demo-user expliciet uit | `tests/integration/test_auth_login.py`, `tests/integration/test_tasks_and_more_views.py`, `tests/unit/test_settings_security.py`, `VR-225/229` |
|
||||||
|
|
||||||
## Misbruikscenario's
|
## Misbruikscenario's
|
||||||
|
|
||||||
@@ -113,8 +115,11 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
|||||||
|
|
||||||
## Resterende risico's
|
## Resterende risico's
|
||||||
|
|
||||||
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau.
|
- De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden
|
||||||
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn beperkt door in-memory rate limiting en blokkades op login/manual-import.
|
peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet
|
||||||
|
geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens
|
||||||
|
mag daarom niet voor productie-fetches worden gebruikt.
|
||||||
|
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR.
|
||||||
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
|
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
|
||||||
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
|
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
|
||||||
- Een persoonlijke Unraid-host blijft afhankelijk van correcte netwerksegmentatie, patching en back-ups door de beheerder.
|
- Een persoonlijke Unraid-host blijft afhankelijk van correcte netwerksegmentatie, patching en back-ups door de beheerder.
|
||||||
@@ -148,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
|
|||||||
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
|
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
|
||||||
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
|
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
|
||||||
- Verificatie: `tests/integration/test_source_health.py`
|
- Verificatie: `tests/integration/test_source_health.py`
|
||||||
|
# Microsoft 365 OAuth
|
||||||
|
|
||||||
|
- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten.
|
||||||
|
- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret.
|
||||||
|
- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only.
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
|
| PR-044 | I | profiel/modelversievelden | scoring/profiletests | VR-109 promptversie |
|
||||||
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
|
| PR-045 | I | optionele Ollamaservice/fallback | `test_ai.py` | VR-109 |
|
||||||
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
|
| PR-046 | I | geen tools + guardrails | AI/securitytests | VR-109/115 evaluaties |
|
||||||
| PR-050 | I | dashboard/list/skillsradar + Stitch-dominante cockpitshell/feed/rail en ultrawide compositie | viewtests, skill-insighttests, VR-119/206/208 browsermatrix | VR-114/118/119/206/208 e2e/a11y |
|
| PR-050 | I | dashboard/list/skillsradar + Stitch-dominante cockpitshell/feed/rail, schaalbaar widescreenframe en auditgedreven informatiedichtheid | viewtests, skill-insighttests, VR-119/206/208/222/223 browsermatrix | VR-114/118/119/206/208/222/223 e2e/a11y |
|
||||||
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
|
| PR-051 | I | detailview/template/provenance + scorehero/intelligence-strip | viewtests, VR-119 screenshots | VR-114/118/119 |
|
||||||
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
|
| PR-052 | I | feedbackendpoint/UI | feedback/viewtests | — |
|
||||||
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
|
| PR-053 | I | applicationmodel/serviceflow | `tests/integration/test_dedupe_lifecycle_feedback.py`, `tests/integration/test_applications.py` | VR-113 uitgebreid dossier |
|
||||||
@@ -56,15 +56,15 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
|
|
||||||
| ID | Status | Bewijs | Vervolgtaak |
|
| ID | Status | Bewijs | Vervolgtaak |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| NFR-001 | I | policy-, URL- en fetchersecuritytests + settings/rate-limit tests | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/integration/test_views.py`, `tests/integration/test_sources_manual_import.py` |
|
| NFR-001 | I | policy-, URL- en fetchersecuritytests plus publieke host-, proxyvertrouwen-, demo- en gedeelde rate-limit-hardening | `tests/unit/test_fetcher.py`, `tests/unit/test_rate_limit.py`, `tests/unit/test_settings_security.py`, `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `tests/integration/test_auth_login.py`, VR-225 |
|
||||||
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
|
| NFR-002 | P | raw-retentie, minimale modellen, privacyrunbooks | VR-113/115 |
|
||||||
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
|
| NFR-003 | I/P | replay-, mail- en outboxtests; per-bronfouten | VR-103/112 |
|
||||||
| NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence |
|
| NFR-004 | I | ScoreRun, provenance, skillvraag met expliciet veld-/tekstonderscheid en UI-copy | VR-109/208 uitgebreid evidence |
|
||||||
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
|
| NFR-005 | I | domeinapps/services/adapters/ADRs | continue review |
|
||||||
| NFR-006 | I | offline pytest + coveragegate | VR-114 verbreedt e2e |
|
| NFR-006 | I | offline pytest + coveragegate, inclusief platformbewuste productieconfiguratietests | VR-114 e2e; `tests/unit/test_public_configuration.py`; VR-225/226 |
|
||||||
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
|
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
|
||||||
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer en elf actuele screenshots | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `artifacts/visual-validation/`, VR-209–213 |
|
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, negen beelden in `docs/screenshots/readme/`, VR-209–224/226 |
|
||||||
| NFR-009 | I | lokale SQLite en Docker/Unraidconfig, release-smoke en restore | VR-117 afgerond |
|
| NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, publieke URL-helper, Nginx Proxy Manager-runbook, gevalideerde SSH-deployalias, API-onafhankelijke immutable releasebootstrap zonder parallel push-to-deploypad, begrensde Docker-address pool, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, `tests/unit/test_public_configuration.py`, VR-117, VR-217/218/225/230/231 afgerond |
|
||||||
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
|
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
|
||||||
|
|
||||||
## Systeemacceptatie
|
## Systeemacceptatie
|
||||||
@@ -84,6 +84,11 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
|
|||||||
Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze matrix, relevante acceptatiecriteria en tests bij. Een status wordt alleen naar **I** gezet wanneer de code én het genoemde bewijs bestaan.
|
Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze matrix, relevante acceptatiecriteria en tests bij. Een status wordt alleen naar **I** gezet wanneer de code én het genoemde bewijs bestaan.
|
||||||
|
|
||||||
## Aanvullende traceability-opmerking
|
## Aanvullende traceability-opmerking
|
||||||
|
- VR-228 levert release 0.3.14 met een fail-closed disposable PostgreSQL-benchmark, run-lokale importcaches, gebundelde veldprovenance en ongewijzigde score-snapshotcopy. De officiële drie iteraties bewijzen 3.264 queries per 250 items, parser 12/12, dedupeprecision/recall 1,0 en nul rankingchurn; bewijs staat in `docs/audit/IMPORT_PERFORMANCE_*.md` en het machineleesbare artifact.
|
||||||
|
- VR-229 houdt de publieke demo representatief door fictieve vacatures, scores, mailboxkoppelingen en opvolgdatums bij iedere demosessie idempotent te herstellen. Mailboxingress is uitgesloten van URL-fetchplanning en operationele UI-copy onderscheidt een actief profiel van bron- en systeemgezondheid.
|
||||||
|
- VR-230 pint `setup-uv` en uv exact, vermijdt de gelimiteerde GitHub Releases-API via de Astral-mirror en bewijst publicatie plus live pinning van de immutable 0.3.17-registrydigest.
|
||||||
|
- VR-231 verwijdert de hangende mutable deployroute, vergroot de Unraid-netwerkcapaciteit conflictvrij naar 256 `/24`-netwerken en bewijst na een volledige Docker-herstart dat alle vooraf actieve containers en VacatureRadar gezond zijn; de resterende Jobat-poll is opnieuw foutvrij geslaagd.
|
||||||
|
- VR-227 professionaliseert release 0.3.13 integraal: gecentreerd ultrawideframe, mobiele gastbanner en verticale pipeline, begrensde vacaturepaginering, verplichte zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, volledige generieke parserbenchmark en SBOM-generatie. Bewijs staat in `docs/audit/`, de E2E-/auth-/runtime-/adaptertests en `USER_INPUT_REQUIRED.md`.
|
||||||
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
|
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
|
||||||
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
|
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
|
||||||
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
|
- VR-115 is afgerond met productie-hardening voor `config/settings.py`, DNS-rebindmitigatie in `apps/sources/services/fetcher.py`, rate limiting op login en handmatige import, CI-secret- en kwetsbaarheidsscans, non-root/read-only containerhardening en bijbehorende security/unit/integration-tests.
|
||||||
@@ -94,6 +99,12 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
|
|||||||
- VR-122 operationaliseert de veilige platformingress als dagelijkse, configureerbare en begrensde mailboxpoll. VDAB-alerts krijgen expliciete provenance, replay blijft idempotent en de bronpagina onderscheidt de werkende jobalertroute van de contractueel afgeschermde officiële Vacature API. Bewijs staat in de e-mailadapter-/importtests, `apps/sources/services/imap_import.py` en ADR-0005.
|
- VR-122 operationaliseert de veilige platformingress als dagelijkse, configureerbare en begrensde mailboxpoll. VDAB-alerts krijgen expliciete provenance, replay blijft idempotent en de bronpagina onderscheidt de werkende jobalertroute van de contractueel afgeschermde officiële Vacature API. Bewijs staat in de e-mailadapter-/importtests, `apps/sources/services/imap_import.py` en ADR-0005.
|
||||||
- VR-123 vervangt die globale poll door gebruikersgescopeerde VDAB- en Indeed-mailboxkoppelingen met versleutelde app-wachtwoorden, afzonderlijke intervallen, per-mailboxleases en UI-beheer. Bewijs staat in `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, de migratie en ADR-0009.
|
- VR-123 vervangt die globale poll door gebruikersgescopeerde VDAB- en Indeed-mailboxkoppelingen met versleutelde app-wachtwoorden, afzonderlijke intervallen, per-mailboxleases en UI-beheer. Bewijs staat in `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, de migratie en ADR-0009.
|
||||||
- VR-124 breidt dezelfde mailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs. De platformgebonden parser accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein; fixtures, adapter-/integratietests en de centrale denylist bewijzen de afscherming.
|
- VR-124 breidt dezelfde mailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs. De platformgebonden parser accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein; fixtures, adapter-/integratietests en de centrale denylist bewijzen de afscherming.
|
||||||
|
- VR-202 bewijst de productie-ingress met M365 app-only OAuth/XOAUTH2, een
|
||||||
|
dedicated alias en map, negen platformconnecties, een idempotente live
|
||||||
|
testmail, correcte schedulerstatus en een verzonden digest-outbox.
|
||||||
|
- VR-204 bewijst de immutable releaseketen via de private Gitea-registry,
|
||||||
|
digest-pin, commitlabel, SPDX-SBOM, checksums, release notes en een live
|
||||||
|
Unraid pull-/rollback-/herstelsmoke.
|
||||||
- VR-125 vervangt de twee Brusselse nicheboards door Careerjet, Randstad en Robert Half, retireert buitenregionale werkgevers zonder provenanceverlies en voegt host- en padgebonden adapters toe voor ACA Group, Xplore Group, UHasselt, PXL en ZOL in de kern Hasselt-Genk.
|
- VR-125 vervangt de twee Brusselse nicheboards door Careerjet, Randstad en Robert Half, retireert buitenregionale werkgevers zonder provenanceverlies en voegt host- en padgebonden adapters toe voor ACA Group, Xplore Group, UHasselt, PXL en ZOL in de kern Hasselt-Genk.
|
||||||
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
|
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
|
||||||
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
|
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
|
||||||
@@ -102,3 +113,15 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
|
|||||||
- VR-207 breidt het profiel uit met gegroepeerde field-/infrastructuurrollen, cv-skills en een ervaringsband. Skill-evidence is op vier treffers begrensd, afwezigheid wordt niet als tekort gepresenteerd en ambigue fieldtitels vereisen een onafhankelijk IT-contextsignaal. Persoonlijke cv-waarden staan uitsluitend in de lokale gebruikersdatabase; repositorytests en documentatie blijven gesaneerd.
|
- VR-207 breidt het profiel uit met gegroepeerde field-/infrastructuurrollen, cv-skills en een ervaringsband. Skill-evidence is op vier treffers begrensd, afwezigheid wordt niet als tekort gepresenteerd en ambigue fieldtitels vereisen een onafhankelijk IT-contextsignaal. Persoonlijke cv-waarden staan uitsluitend in de lokale gebruikersdatabase; repositorytests en documentatie blijven gesaneerd.
|
||||||
- VR-208 maakt die ervaringsband uitsluitend informatief en verwijdert ook senioriteitsinvloed uit de AI-component en feedbacklearning. De gebruikersgescopeerde Skillsradar analyseert actuele niet-uitgesloten IT-vacatures van actieve bronnen, onderscheidt skillveldbewijs van tekstinferentie en toont profieldekking en mogelijke leerkansen met categorie-/dekkingsfilters.
|
- VR-208 maakt die ervaringsband uitsluitend informatief en verwijdert ook senioriteitsinvloed uit de AI-component en feedbacklearning. De gebruikersgescopeerde Skillsradar analyseert actuele niet-uitgesloten IT-vacatures van actieve bronnen, onderscheidt skillveldbewijs van tekstinferentie en toont profieldekking en mogelijke leerkansen met categorie-/dekkingsfilters.
|
||||||
- VR-209–213 vervangen de volledige browserfrontend door de negen Stitch Intelligence Cockpit-composities. De nieuwe shell, dashboard/vacature-intelligence, pipeline/profiel/bronnen, automation/activiteit/werkgevers en responsive validatie zijn gekoppeld aan echte read models en serveracties. Bewijs staat in `docs/design/STITCH_INTEGRATION_REPORT.md`, route-/scope-/querytests en de elf beelden onder `artifacts/visual-validation/`.
|
- VR-209–213 vervangen de volledige browserfrontend door de negen Stitch Intelligence Cockpit-composities. De nieuwe shell, dashboard/vacature-intelligence, pipeline/profiel/bronnen, automation/activiteit/werkgevers en responsive validatie zijn gekoppeld aan echte read models en serveracties. Bewijs staat in `docs/design/STITCH_INTEGRATION_REPORT.md`, route-/scope-/querytests en de elf beelden onder `artifacts/visual-validation/`.
|
||||||
|
- VR-214 herstelt het ontbrekende Dockerman-tegelicoon met een lokale 256×256 PNG, een exact Unraid-poortlabel en een regressietest die URL, PNG-signatuur en afmetingen controleert.
|
||||||
|
- VR-215 corrigeert het Dockerman-downloadcontract: het icoonlabel gebruikt een concrete loopback-URL omdat Dockerman placeholders alleen voor WebUI resolveert; live bewijs omvat de aangemaakte `VacatureRadar-icon.png`-cache.
|
||||||
|
- VR-216 verfijnt de Stitch-cockpit naar “calm intelligence”: minder visuele ruis, selectief cyan, compactere mobiele KPI's, responsieve filters, intern scrollende pipeline en een CSP-veilige logininteractie.
|
||||||
|
- VR-217 borgt de werkende Unraid-hostalias en afzonderlijke deploysleutel in een fail-fast releasehelper en voegt een lokale decoratieve radarillustratie met pointerrespons en reduced-motionfallback toe.
|
||||||
|
- VR-218 verenigt favicon, login en shell onder één radarbeeldmerk, maakt de persoonlijke eigenaarssignatuur configureerbaar en voegt contextuele oriëntatie aan de bovenbalk toe zonder mobiele overflow.
|
||||||
|
- VR-219 verankert alle hoofdsecties op één ultrawide grid, begrenst paneelverhoudingen voor leesbaarheid en maakt `Personal intelligence by Jens` tot de consistente productlock-up op publieke en ingelogde schermen.
|
||||||
|
- VR-220 vervangt die tijdelijke tekstlock-up door de aangeleverde officiële ITWorx.tech-wordmark en een exact daaruit afgeleid cloud-check-appicoon voor browser, shell en Dockerman; VacatureRadar blijft de productnaam.
|
||||||
|
- VR-221 corrigeert de merkarchitectuur: VacatureRadar krijgt een zelfstandig radar-/kansbeeldmerk voor alle productcontactpunten en ITWorx.tech blijft uitsluitend zichtbaar als officiële secundaire ontwikkelaarscredit.
|
||||||
|
- VR-224 maakt de repository-ingang user-first: de README volgt de dagelijkse productflow, bevat gecontroleerde lokale quickstarts en gebruikt vier actuele dark/light- en desktop/mobielscreenshots zonder secrets of externe assets.
|
||||||
|
|
||||||
|
- VR-225 hardent de publieke installatie met `PUBLIC_BASE_URL`, exacte host/CSRF-afleiding, veilige proxy-HTTPS-detectie, vertrouwde client-IPchains, gedeelde Redis-rate-limiting, cache-readiness en een server-side alleen-lezen gastdemo. Bewijs staat in de productie-audit, Nginx-runbook en de settings-, network-, health-, auth- en deploymenttests.
|
||||||
|
- VR-226 verdiept de README tot een visuele productreis met negen actuele beelden op consistente desktop- en mobiele viewports. Dezelfde taak herstelt de VR-225-lintbaseline en maakt de POSIX-bestandsmodusassertie terecht platformafhankelijk.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# VacatureRadar 0.3.14
|
||||||
|
|
||||||
|
Deze release versnelt herhaalde vacature-imports zonder deduplicatie, scoring of gebruikersdata te veranderen. Een geïsoleerde PostgreSQL-benchmark bewijst 44,74% kortere importduur, minstens 63,73% minder databasequeries en 83,42% hogere throughput tegenover 0.3.13. De runner, regressiebudgetten en queryfamilierapportage maken deze winst reproduceerbaar.
|
||||||
|
|
||||||
|
Upgrade vereist geen schemawijziging. Voer zoals altijd `migrate --noinput` uit; rollback kan naar de bewaarde immutable 0.3.13-image zonder dataconversie.
|
||||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 121 KiB |
@@ -23,7 +23,7 @@
|
|||||||
"url": "https://careers.example.net/jobs/workplace-engineer",
|
"url": "https://careers.example.net/jobs/workplace-engineer",
|
||||||
"expected_job_count": 1,
|
"expected_job_count": 1,
|
||||||
"expected_parser_key": "generic-html",
|
"expected_parser_key": "generic-html",
|
||||||
"expected_parser_version": "1.0.0",
|
"expected_parser_version": "1.1.0",
|
||||||
"expected_fields": [
|
"expected_fields": [
|
||||||
"title",
|
"title",
|
||||||
"employer_name",
|
"employer_name",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<!doctype html><html lang="nl"><head><title>Workplace Engineer - Example Health</title><meta property="og:site_name" content="Example Health"><link rel="canonical" href="https://careers.example.net/jobs/workplace-engineer"></head><body><main><h1>Workplace Engineer</h1><dl><dt>Locatie</dt><dd>Genk, Limburg</dd></dl><section><h2>Jouw rol</h2><p>Je beheert endpoints, Microsoft Intune en Microsoft 365. De functie bevat occasionele tweedelijnssupport en hybride werk.</p></section></main></body></html>
|
<!doctype html><html lang="nl"><head><title>Workplace Engineer - Example Health</title><meta property="og:site_name" content="Example Health"><link rel="canonical" href="https://careers.example.net/jobs/workplace-engineer"></head><body><main><h1>Workplace Engineer</h1><dl><dt>Locatie</dt><dd>Genk, Limburg</dd><dt>Publicatiedatum</dt><dd>2026-07-21</dd><dt>Dienstverband</dt><dd>Voltijds</dd></dl><section><h2>Jouw rol</h2><p>Je beheert endpoints, Microsoft Intune en Microsoft 365. De functie bevat occasionele tweedelijnssupport en hybride werk.</p></section></main></body></html>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "vacatureradar"
|
name = "vacatureradar"
|
||||||
version = "0.1.0"
|
version = "0.3.14"
|
||||||
description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails."
|
description = "Persoonlijke autonome vacature-assistent voor toegestane publieke bronnen en vacaturemails."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12,<3.14"
|
requires-python = ">=3.12,<3.14"
|
||||||
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
"defusedxml==0.7.1",
|
"defusedxml==0.7.1",
|
||||||
"gunicorn==26.0.0",
|
"gunicorn==26.0.0",
|
||||||
"whitenoise==6.11.0",
|
"whitenoise==6.11.0",
|
||||||
"cryptography==49.0.0",
|
"cryptography==50.0.0",
|
||||||
"msal==1.37.0",
|
"msal==1.37.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import django
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.test.utils import CaptureQueriesContext
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
@@ -29,7 +28,7 @@ django.setup()
|
|||||||
from apps.jobs.models import Employer, JobPosting, JobSourceAlias, ScoreRun # noqa: E402
|
from apps.jobs.models import Employer, JobPosting, JobSourceAlias, ScoreRun # noqa: E402
|
||||||
from apps.jobs.services.dedupe import find_existing_job # noqa: E402
|
from apps.jobs.services.dedupe import find_existing_job # noqa: E402
|
||||||
from apps.jobs.services.normalization import CanonicalJobDraft, normalize_token # noqa: E402
|
from apps.jobs.services.normalization import CanonicalJobDraft, normalize_token # noqa: E402
|
||||||
from apps.jobs.services.pipeline import process_raw_document # noqa: E402
|
from apps.jobs.services.pipeline import PersistenceContext, process_raw_document # noqa: E402
|
||||||
from apps.jobs.services.scoring import calculate_score, rescore_jobs_with_profiles # noqa: E402
|
from apps.jobs.services.scoring import calculate_score, rescore_jobs_with_profiles # noqa: E402
|
||||||
from apps.profiles.models import SearchProfile # noqa: E402
|
from apps.profiles.models import SearchProfile # noqa: E402
|
||||||
from apps.sources.adapters.registry import registry # noqa: E402
|
from apps.sources.adapters.registry import registry # noqa: E402
|
||||||
@@ -575,15 +574,25 @@ def _clean_benchmark(artifacts: BenchmarkArtifact) -> None:
|
|||||||
get_user_model().objects.filter(pk=artifacts.user_id).delete()
|
get_user_model().objects.filter(pk=artifacts.user_id).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class QueryCounter:
|
||||||
|
"""Count database executions without Django's bounded debug-query log."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.count = 0
|
||||||
|
self.types: dict[str, int] = {}
|
||||||
|
|
||||||
|
def __call__(self, execute: Any, sql: str, params: Any, many: bool, context: Any) -> Any:
|
||||||
|
self.count += 1
|
||||||
|
query_type = sql.lstrip().split(None, 1)[0].upper() if sql.strip() else "UNKNOWN"
|
||||||
|
self.types[query_type] = self.types.get(query_type, 0) + 1
|
||||||
|
return execute(sql, params, many, context)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _query_capture() -> Any:
|
def _query_capture() -> Any:
|
||||||
original_force_debug_cursor = connection.force_debug_cursor
|
counter = QueryCounter()
|
||||||
connection.force_debug_cursor = True
|
with connection.execute_wrapper(counter):
|
||||||
try:
|
yield counter
|
||||||
with CaptureQueriesContext(connection) as captured:
|
|
||||||
yield captured
|
|
||||||
finally:
|
|
||||||
connection.force_debug_cursor = original_force_debug_cursor
|
|
||||||
|
|
||||||
|
|
||||||
def run_performance_benchmark(
|
def run_performance_benchmark(
|
||||||
@@ -619,6 +628,7 @@ def run_performance_benchmark(
|
|||||||
}
|
}
|
||||||
tracemalloc.start()
|
tracemalloc.start()
|
||||||
import_timings: list[float] = []
|
import_timings: list[float] = []
|
||||||
|
persistence_context = PersistenceContext()
|
||||||
rescore_timings: list[float] = []
|
rescore_timings: list[float] = []
|
||||||
list_timings: list[float] = []
|
list_timings: list[float] = []
|
||||||
detail_timings: list[float] = []
|
detail_timings: list[float] = []
|
||||||
@@ -646,9 +656,10 @@ def run_performance_benchmark(
|
|||||||
retain_until=timezone.now() + timezone.timedelta(days=7),
|
retain_until=timezone.now() + timezone.timedelta(days=7),
|
||||||
)
|
)
|
||||||
start = perf_counter()
|
start = perf_counter()
|
||||||
process_raw_document(document)
|
process_raw_document(document, context=persistence_context)
|
||||||
import_timings.append((perf_counter() - start) * 1000)
|
import_timings.append((perf_counter() - start) * 1000)
|
||||||
import_query_count = len(captured_import.captured_queries)
|
import_query_count = captured_import.count
|
||||||
|
import_query_types = captured_import.types
|
||||||
query_count_total += import_query_count
|
query_count_total += import_query_count
|
||||||
|
|
||||||
candidates = list(
|
candidates = list(
|
||||||
@@ -666,7 +677,8 @@ def run_performance_benchmark(
|
|||||||
batch_start = perf_counter()
|
batch_start = perf_counter()
|
||||||
rescore_jobs_with_profiles(batch, profile_id=profile.id)
|
rescore_jobs_with_profiles(batch, profile_id=profile.id)
|
||||||
rescore_timings.append((perf_counter() - batch_start) * 1000)
|
rescore_timings.append((perf_counter() - batch_start) * 1000)
|
||||||
rescore_query_count = len(captured_rescore.captured_queries)
|
rescore_query_count = captured_rescore.count
|
||||||
|
rescore_query_types = captured_rescore.types
|
||||||
query_count_total += rescore_query_count
|
query_count_total += rescore_query_count
|
||||||
|
|
||||||
with _query_capture() as captured_list:
|
with _query_capture() as captured_list:
|
||||||
@@ -695,8 +707,10 @@ def run_performance_benchmark(
|
|||||||
)
|
)
|
||||||
detail_timings.append((perf_counter() - start) * 1000)
|
detail_timings.append((perf_counter() - start) * 1000)
|
||||||
|
|
||||||
list_query_count = len(captured_list.captured_queries)
|
list_query_count = captured_list.count
|
||||||
detail_query_count = len(captured_detail.captured_queries)
|
detail_query_count = captured_detail.count
|
||||||
|
list_query_types = captured_list.types
|
||||||
|
detail_query_types = captured_detail.types
|
||||||
query_count_total += list_query_count + detail_query_count
|
query_count_total += list_query_count + detail_query_count
|
||||||
|
|
||||||
current_bytes, peak_bytes = tracemalloc.get_traced_memory()
|
current_bytes, peak_bytes = tracemalloc.get_traced_memory()
|
||||||
@@ -740,6 +754,7 @@ def run_performance_benchmark(
|
|||||||
"p50_ms": timing_stats["import_p50_ms"],
|
"p50_ms": timing_stats["import_p50_ms"],
|
||||||
"p95_ms": timing_stats["import_p95_ms"],
|
"p95_ms": timing_stats["import_p95_ms"],
|
||||||
"query_count": import_query_count,
|
"query_count": import_query_count,
|
||||||
|
"query_types": import_query_types,
|
||||||
"timing_count": len(import_timings),
|
"timing_count": len(import_timings),
|
||||||
},
|
},
|
||||||
"rescore": {
|
"rescore": {
|
||||||
@@ -747,11 +762,14 @@ def run_performance_benchmark(
|
|||||||
"p50_ms": timing_stats["rescore_p50_ms"],
|
"p50_ms": timing_stats["rescore_p50_ms"],
|
||||||
"p95_ms": timing_stats["rescore_p95_ms"],
|
"p95_ms": timing_stats["rescore_p95_ms"],
|
||||||
"query_count": rescore_query_count,
|
"query_count": rescore_query_count,
|
||||||
|
"query_types": rescore_query_types,
|
||||||
"timing_count": len(rescore_timings),
|
"timing_count": len(rescore_timings),
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"list_query_count": list_query_count,
|
"list_query_count": list_query_count,
|
||||||
"detail_query_count": detail_query_count,
|
"detail_query_count": detail_query_count,
|
||||||
|
"list_query_types": list_query_types,
|
||||||
|
"detail_query_types": detail_query_types,
|
||||||
"list_p95_ms": timing_stats["list_p95_ms"],
|
"list_p95_ms": timing_stats["list_p95_ms"],
|
||||||
"detail_p95_ms": timing_stats["detail_p95_ms"],
|
"detail_p95_ms": timing_stats["detail_p95_ms"],
|
||||||
"timing_count": len(list_timings) + len(detail_timings),
|
"timing_count": len(list_timings) + len(detail_timings),
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from statistics import mean
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||||
|
|
||||||
|
import django
|
||||||
|
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.conf import settings # noqa: E402
|
||||||
|
from django.core.management import call_command # noqa: E402
|
||||||
|
|
||||||
|
from scripts.benchmark import DEFAULT_DATASET, run_benchmark_report # noqa: E402
|
||||||
|
|
||||||
|
SAFE_DATABASE_PREFIX = "vacatureradar_bench_"
|
||||||
|
SAFE_PUBLIC_HOSTS = {"", "benchmark.invalid", "localhost", "127.0.0.1"}
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeBenchmarkEnvironment(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def validate_benchmark_identity(database: dict[str, Any], public_base_url: str) -> None:
|
||||||
|
engine = str(database.get("ENGINE", ""))
|
||||||
|
name = str(database.get("NAME", ""))
|
||||||
|
host = str(database.get("HOST", ""))
|
||||||
|
public_host = (urlparse(public_base_url).hostname or "").casefold()
|
||||||
|
if engine != "django.db.backends.postgresql":
|
||||||
|
raise UnsafeBenchmarkEnvironment("De importbenchmark vereist PostgreSQL.")
|
||||||
|
if not name.startswith(SAFE_DATABASE_PREFIX):
|
||||||
|
raise UnsafeBenchmarkEnvironment(
|
||||||
|
f"Onveilige databasenaam {name!r}; verwacht prefix {SAFE_DATABASE_PREFIX!r}."
|
||||||
|
)
|
||||||
|
if host not in {"127.0.0.1", "localhost", "postgres"}:
|
||||||
|
raise UnsafeBenchmarkEnvironment(f"Onveilige benchmarkdatabasehost {host!r}.")
|
||||||
|
if public_host not in SAFE_PUBLIC_HOSTS:
|
||||||
|
raise UnsafeBenchmarkEnvironment(
|
||||||
|
f"Publieke host {public_host!r} is niet toegestaan voor een benchmark."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary(reports: list[dict[str, Any]]) -> dict[str, float]:
|
||||||
|
performance = [report["performance"] for report in reports]
|
||||||
|
imports = [item["import"] for item in performance]
|
||||||
|
return {
|
||||||
|
"query_count_mean": round(mean(item["query_count"] for item in imports), 3),
|
||||||
|
"query_count_total_mean": round(mean(item["query_count"] for item in performance), 3),
|
||||||
|
"duration_ms_mean": round(mean(item["duration_ms_total"] for item in imports), 3),
|
||||||
|
"p50_ms_mean": round(mean(item["p50_ms"] for item in imports), 3),
|
||||||
|
"p95_ms_mean": round(mean(item["p95_ms"] for item in imports), 3),
|
||||||
|
"throughput_per_second": round(
|
||||||
|
mean(
|
||||||
|
report["jobs_processed"] / (item["duration_ms_total"] / 1000)
|
||||||
|
for report, item in zip(reports, imports, strict=True)
|
||||||
|
),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
"memory_peak_mb_mean": round(
|
||||||
|
mean(item["performance"]["memory_peak_mb"] for item in performance), 3
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_isolated_benchmark(
|
||||||
|
*, dataset: Path, jobs: int, iterations: int, output: Path
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
validate_benchmark_identity(settings.DATABASES["default"], settings.PUBLIC_BASE_URL)
|
||||||
|
if iterations < 1:
|
||||||
|
raise ValueError("iterations moet minstens 1 zijn.")
|
||||||
|
call_command("migrate", interactive=False, verbosity=0)
|
||||||
|
call_command("flush", interactive=False, verbosity=0)
|
||||||
|
warmup = run_benchmark_report(dataset, quick=True, jobs=jobs)
|
||||||
|
measured: list[dict[str, Any]] = []
|
||||||
|
for _ in range(iterations):
|
||||||
|
call_command("flush", interactive=False, verbosity=0)
|
||||||
|
measured.append(run_benchmark_report(dataset, quick=True, jobs=jobs))
|
||||||
|
report = {
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"database_identity": {
|
||||||
|
"engine": settings.DATABASES["default"]["ENGINE"],
|
||||||
|
"name_prefix": SAFE_DATABASE_PREFIX,
|
||||||
|
"host": settings.DATABASES["default"]["HOST"],
|
||||||
|
},
|
||||||
|
"configuration": {
|
||||||
|
"jobs": jobs,
|
||||||
|
"iterations": iterations,
|
||||||
|
"warmup_runs": 1,
|
||||||
|
"concurrency": 1,
|
||||||
|
"dataset": str(dataset),
|
||||||
|
},
|
||||||
|
"warmup_status": warmup["status"],
|
||||||
|
"summary": _summary(measured),
|
||||||
|
"iterations": measured,
|
||||||
|
"status": "passed" if all(item["status"] == "passed" for item in measured) else "failed",
|
||||||
|
}
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Fail-closed geïsoleerde importbenchmark")
|
||||||
|
parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
|
||||||
|
parser.add_argument("--jobs", type=int, default=250)
|
||||||
|
parser.add_argument("--iterations", type=int, default=3)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output", type=Path, default=Path("artifacts/import-performance-report.json")
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
report = run_isolated_benchmark(
|
||||||
|
dataset=args.dataset, jobs=args.jobs, iterations=args.iterations, output=args.output
|
||||||
|
)
|
||||||
|
print(json.dumps(report, indent=2, sort_keys=True))
|
||||||
|
return 0 if report["status"] == "passed" else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_public_url(value: str) -> tuple[str, str]:
|
||||||
|
raw = value.strip().rstrip("/")
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"Gebruik een volledige http(s)-URL, bijvoorbeeld https://jobs.example.be"
|
||||||
|
)
|
||||||
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"De publieke URL mag geen credentials, query of fragment bevatten."
|
||||||
|
)
|
||||||
|
if parsed.path not in {"", "/"}:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"De applicatie moet op de root van het domein gepubliceerd worden."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigurationError("De publieke URL bevat een ongeldige poort.") from exc
|
||||||
|
host = parsed.hostname.casefold()
|
||||||
|
display_host = f"[{host}]" if ":" in host else host
|
||||||
|
default_port = 443 if parsed.scheme == "https" else 80
|
||||||
|
port_suffix = f":{port}" if port and port != default_port else ""
|
||||||
|
return f"{parsed.scheme}://{display_host}{port_suffix}", host
|
||||||
|
|
||||||
|
|
||||||
|
def parse_env(text: str) -> tuple[list[str], dict[str, str]]:
|
||||||
|
lines = text.splitlines()
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||||
|
continue
|
||||||
|
key, value = stripped.split("=", 1)
|
||||||
|
values[key.strip()] = value.strip()
|
||||||
|
return lines, values
|
||||||
|
|
||||||
|
|
||||||
|
def merge_csv(existing: str, additions: list[str]) -> str:
|
||||||
|
result: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in [*existing.split(","), *additions]:
|
||||||
|
value = item.strip()
|
||||||
|
if value and value.casefold() not in seen:
|
||||||
|
seen.add(value.casefold())
|
||||||
|
result.append(value)
|
||||||
|
return ",".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def render_env(lines: list[str], updates: dict[str, str]) -> str:
|
||||||
|
rendered: list[str] = []
|
||||||
|
written: set[str] = set()
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.startswith("#") and "=" in stripped:
|
||||||
|
key = stripped.split("=", 1)[0].strip()
|
||||||
|
if key in updates:
|
||||||
|
rendered.append(f"{key}={updates[key]}")
|
||||||
|
written.add(key)
|
||||||
|
continue
|
||||||
|
rendered.append(line)
|
||||||
|
missing = [key for key in updates if key not in written]
|
||||||
|
if missing:
|
||||||
|
if rendered and rendered[-1].strip():
|
||||||
|
rendered.append("")
|
||||||
|
rendered.append("# Publieke URL en reverse-proxyhardening")
|
||||||
|
rendered.extend(f"{key}={updates[key]}" for key in missing)
|
||||||
|
return "\n".join(rendered).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_updates(
|
||||||
|
current: dict[str, str],
|
||||||
|
public_url: str,
|
||||||
|
*,
|
||||||
|
hsts_seconds: int,
|
||||||
|
cache_url: str | None,
|
||||||
|
trusted_proxy_cidrs: list[str],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
origin, host = normalize_public_url(public_url)
|
||||||
|
if not origin.startswith("https://"):
|
||||||
|
raise ConfigurationError("Een publieke productie-URL moet HTTPS gebruiken.")
|
||||||
|
updates = {
|
||||||
|
"PUBLIC_BASE_URL": origin,
|
||||||
|
"DJANGO_DEBUG": "0",
|
||||||
|
"DJANGO_ALLOWED_HOSTS": merge_csv(
|
||||||
|
current.get("DJANGO_ALLOWED_HOSTS", ""), [host, "127.0.0.1", "localhost"]
|
||||||
|
),
|
||||||
|
"DJANGO_CSRF_TRUSTED_ORIGINS": merge_csv(
|
||||||
|
current.get("DJANGO_CSRF_TRUSTED_ORIGINS", ""), [origin]
|
||||||
|
),
|
||||||
|
"SESSION_COOKIE_SECURE": "1",
|
||||||
|
"CSRF_COOKIE_SECURE": "1",
|
||||||
|
"SECURE_SSL_REDIRECT": "1",
|
||||||
|
"SECURE_HSTS_SECONDS": str(hsts_seconds),
|
||||||
|
"TRUST_PROXY_HEADERS": "1",
|
||||||
|
"DEMO_READ_ONLY": "1",
|
||||||
|
"SEARCH_ENGINE_INDEXING_ENABLED": "0",
|
||||||
|
"HEALTHCHECK_REQUIRE_CACHE": "1",
|
||||||
|
}
|
||||||
|
if cache_url:
|
||||||
|
updates["CACHE_URL"] = cache_url
|
||||||
|
if trusted_proxy_cidrs:
|
||||||
|
updates["TRUSTED_PROXY_CIDRS"] = merge_csv(
|
||||||
|
current.get("TRUSTED_PROXY_CIDRS", ""), trusted_proxy_cidrs
|
||||||
|
)
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
def configure_env(
|
||||||
|
env_file: Path,
|
||||||
|
public_url: str,
|
||||||
|
*,
|
||||||
|
hsts_seconds: int = 300,
|
||||||
|
cache_url: str | None = None,
|
||||||
|
trusted_proxy_cidrs: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
if hsts_seconds < 0:
|
||||||
|
raise ConfigurationError("HSTS-seconden mogen niet negatief zijn.")
|
||||||
|
original = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
|
||||||
|
lines, current = parse_env(original)
|
||||||
|
updates = build_updates(
|
||||||
|
current,
|
||||||
|
public_url,
|
||||||
|
hsts_seconds=hsts_seconds,
|
||||||
|
cache_url=cache_url,
|
||||||
|
trusted_proxy_cidrs=trusted_proxy_cidrs or [],
|
||||||
|
)
|
||||||
|
output = render_env(lines, updates)
|
||||||
|
env_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, temporary_name = tempfile.mkstemp(prefix=f".{env_file.name}.", dir=env_file.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
||||||
|
handle.write(output)
|
||||||
|
os.chmod(temporary_name, 0o600)
|
||||||
|
os.replace(temporary_name, env_file)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary_name):
|
||||||
|
os.unlink(temporary_name)
|
||||||
|
return list(updates)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Configureer VacatureRadar veilig voor een publieke reverse-proxy-URL."
|
||||||
|
)
|
||||||
|
parser.add_argument("public_url", help="Bijvoorbeeld https://vacatureradar.example.be")
|
||||||
|
parser.add_argument("--env-file", type=Path, default=Path(".env"))
|
||||||
|
parser.add_argument(
|
||||||
|
"--hsts-seconds",
|
||||||
|
type=int,
|
||||||
|
default=300,
|
||||||
|
help="Start conservatief; verhoog na succesvolle HTTPS-validatie.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--cache-url", help="Bij Unraid AIO: redis://127.0.0.1:6379/1")
|
||||||
|
parser.add_argument(
|
||||||
|
"--trusted-proxy-cidr",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="Optioneel vertrouwd proxy-IP/CIDR voor correcte client-IP-rate-limiting.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
changed = configure_env(
|
||||||
|
args.env_file,
|
||||||
|
args.public_url,
|
||||||
|
hsts_seconds=args.hsts_seconds,
|
||||||
|
cache_url=args.cache_url,
|
||||||
|
trusted_proxy_cidrs=args.trusted_proxy_cidr,
|
||||||
|
)
|
||||||
|
except (OSError, ConfigurationError) as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
print(f"Bijgewerkt: {args.env_file}")
|
||||||
|
print("Veilig ingestelde sleutels: " + ", ".join(changed))
|
||||||
|
print("Hermaak de container; een gewone restart laadt gewijzigde environment niet opnieuw.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -36,9 +36,10 @@ generate_secret() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_env() {
|
write_env() {
|
||||||
local key="$1"
|
local key="$1"
|
||||||
local value="$2"
|
local value="$2"
|
||||||
|
local force="${3:-0}"
|
||||||
local existing_line
|
local existing_line
|
||||||
local existing_value
|
local existing_value
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ ensure_env() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
existing_value="${existing_line#*=}"
|
existing_value="${existing_line#*=}"
|
||||||
if [[ -n "$existing_value" && \
|
if [[ "$force" != "1" && -n "$existing_value" && \
|
||||||
"$existing_value" != CHANGE_ME* && \
|
"$existing_value" != CHANGE_ME* && \
|
||||||
"$existing_value" != dev-only-change-me && \
|
"$existing_value" != dev-only-change-me && \
|
||||||
"$existing_value" != codex-local-only ]]; then
|
"$existing_value" != codex-local-only ]]; then
|
||||||
@@ -66,12 +67,17 @@ ensure_env() {
|
|||||||
fi
|
fi
|
||||||
done < "$env_file"
|
done < "$env_file"
|
||||||
mv "$temp_env" "$env_file"
|
mv "$temp_env" "$env_file"
|
||||||
|
chmod 600 "$env_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app_host_was_explicit=0
|
||||||
|
[[ -n "${APP_HOST:-}" ]] && app_host_was_explicit=1
|
||||||
app_host="${APP_HOST:-${HOSTNAME:-localhost}}"
|
app_host="${APP_HOST:-${HOSTNAME:-localhost}}"
|
||||||
app_scheme="${APP_SCHEME:-http}"
|
app_scheme="${APP_SCHEME:-http}"
|
||||||
|
app_public_port="${APP_PUBLIC_PORT:-}"
|
||||||
app_hostnames="${APP_HOSTS:-$app_host}"
|
app_hostnames="${APP_HOSTS:-$app_host}"
|
||||||
app_origin="${APP_ORIGINS:-}"
|
app_origin="${APP_ORIGINS:-}"
|
||||||
|
app_base_url=""
|
||||||
|
|
||||||
# De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd
|
# De lokale healthcheck gebruikt 127.0.0.1. Houd die host en localhost altijd
|
||||||
# toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld.
|
# toegestaan, ook wanneer APP_HOSTS expliciet door de runner is ingesteld.
|
||||||
@@ -81,16 +87,23 @@ for required_host in 127.0.0.1 localhost; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$app_origin" ]]; then
|
if [[ -z "$app_public_port" ]]; then
|
||||||
if [[ "$app_scheme" == "https" ]]; then
|
if [[ "$app_scheme" == "https" ]]; then
|
||||||
if [[ "$app_port" == "443" ]]; then
|
app_public_port="443"
|
||||||
app_origin="https://$app_host"
|
|
||||||
else
|
else
|
||||||
app_origin="https://$app_host:$app_port"
|
app_public_port="$app_port"
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ( "$app_scheme" == "https" && "$app_public_port" == "443" ) || \
|
||||||
|
( "$app_scheme" == "http" && "$app_public_port" == "80" ) ]]; then
|
||||||
|
app_base_url="${app_scheme}://${app_host}"
|
||||||
else
|
else
|
||||||
app_origin="http://$app_host:$app_port"
|
app_base_url="${app_scheme}://${app_host}:${app_public_port}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$app_origin" ]]; then
|
||||||
|
app_origin="$app_base_url"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
secret_key="$(generate_secret)"
|
secret_key="$(generate_secret)"
|
||||||
@@ -98,12 +111,19 @@ postgres_password="${POSTGRES_PASSWORD:-$(generate_secret)}"
|
|||||||
admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}"
|
admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}"
|
||||||
db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar"
|
db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar"
|
||||||
|
|
||||||
debug="${DJANGO_DEBUG:-1}"
|
debug="${DJANGO_DEBUG:-}"
|
||||||
|
if [[ -z "$debug" ]]; then
|
||||||
|
if [[ "$app_host_was_explicit" == "1" && "$app_scheme" == "https" ]]; then
|
||||||
|
debug="0"
|
||||||
|
else
|
||||||
|
debug="1"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
if [[ "$debug" == "0" ]]; then
|
if [[ "$debug" == "0" ]]; then
|
||||||
session_secure="${SESSION_COOKIE_SECURE:-1}"
|
session_secure="${SESSION_COOKIE_SECURE:-1}"
|
||||||
csrf_secure="${CSRF_COOKIE_SECURE:-1}"
|
csrf_secure="${CSRF_COOKIE_SECURE:-1}"
|
||||||
ssl_redirect="${SECURE_SSL_REDIRECT:-1}"
|
ssl_redirect="${SECURE_SSL_REDIRECT:-1}"
|
||||||
hsts_seconds="${SECURE_HSTS_SECONDS:-31536000}"
|
hsts_seconds="${SECURE_HSTS_SECONDS:-300}"
|
||||||
else
|
else
|
||||||
session_secure="${SESSION_COOKIE_SECURE:-0}"
|
session_secure="${SESSION_COOKIE_SECURE:-0}"
|
||||||
csrf_secure="${CSRF_COOKIE_SECURE:-0}"
|
csrf_secure="${CSRF_COOKIE_SECURE:-0}"
|
||||||
@@ -111,37 +131,51 @@ else
|
|||||||
hsts_seconds="${SECURE_HSTS_SECONDS:-0}"
|
hsts_seconds="${SECURE_HSTS_SECONDS:-0}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_env "DJANGO_SECRET_KEY" "$secret_key"
|
write_env "DJANGO_SECRET_KEY" "$secret_key"
|
||||||
ensure_env "DJANGO_DEBUG" "$debug"
|
write_env "DJANGO_DEBUG" "$debug" "$app_host_was_explicit"
|
||||||
ensure_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames"
|
write_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames" "$app_host_was_explicit"
|
||||||
ensure_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin"
|
write_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin" "$app_host_was_explicit"
|
||||||
ensure_env "DJANGO_TIME_ZONE" "Europe/Brussels"
|
if [[ "$app_host_was_explicit" == "1" ]]; then
|
||||||
ensure_env "POSTGRES_DB" "vacatureradar"
|
write_env "PUBLIC_BASE_URL" "$app_base_url" 1
|
||||||
ensure_env "POSTGRES_USER" "vacatureradar"
|
if [[ "$app_scheme" == "https" ]]; then
|
||||||
ensure_env "POSTGRES_PASSWORD" "$postgres_password"
|
write_env "TRUST_PROXY_HEADERS" "1" 1
|
||||||
ensure_env "POSTGRES_HOST" "postgres"
|
else
|
||||||
ensure_env "POSTGRES_PORT" "5432"
|
write_env "TRUST_PROXY_HEADERS" "0" 1
|
||||||
ensure_env "DATABASE_URL" "$db_url"
|
fi
|
||||||
ensure_env "REDIS_URL" "redis://redis:6379/0"
|
write_env "USE_X_FORWARDED_HOST" "0" 1
|
||||||
ensure_env "CELERY_TASK_ALWAYS_EAGER" "0"
|
fi
|
||||||
ensure_env "VACATURERADAR_ADMIN_USERNAME" "admin"
|
write_env "DJANGO_TIME_ZONE" "Europe/Brussels"
|
||||||
ensure_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
|
write_env "POSTGRES_DB" "vacatureradar"
|
||||||
ensure_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
|
write_env "POSTGRES_USER" "vacatureradar"
|
||||||
ensure_env "SESSION_COOKIE_SECURE" "$session_secure"
|
write_env "POSTGRES_PASSWORD" "$postgres_password"
|
||||||
ensure_env "CSRF_COOKIE_SECURE" "$csrf_secure"
|
write_env "POSTGRES_HOST" "postgres"
|
||||||
ensure_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
|
write_env "POSTGRES_PORT" "5432"
|
||||||
ensure_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
|
write_env "DATABASE_URL" "$db_url"
|
||||||
ensure_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
|
write_env "REDIS_URL" "redis://redis:6379/0"
|
||||||
ensure_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
|
write_env "CACHE_URL" "redis://redis:6379/1"
|
||||||
ensure_env "SOURCE_POLICY_MODE" "strict"
|
write_env "HEALTHCHECK_REQUIRE_CACHE" "1"
|
||||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
|
write_env "CELERY_TASK_ALWAYS_EAGER" "0"
|
||||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
|
write_env "VACATURERADAR_ADMIN_USERNAME" "admin"
|
||||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
|
write_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
|
||||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
|
write_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
|
||||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
|
write_env "SESSION_COOKIE_SECURE" "$session_secure"
|
||||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
|
write_env "CSRF_COOKIE_SECURE" "$csrf_secure"
|
||||||
|
write_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
|
||||||
|
write_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
|
||||||
|
write_env "DEMO_READ_ONLY" "1"
|
||||||
|
write_env "SEARCH_ENGINE_INDEXING_ENABLED" "0"
|
||||||
|
write_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
|
||||||
|
write_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
|
||||||
|
write_env "SOURCE_POLICY_MODE" "strict"
|
||||||
|
write_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
|
||||||
|
write_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
|
||||||
|
write_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||||
|
write_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
|
||||||
|
write_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
|
||||||
|
write_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||||
|
|
||||||
docker compose -f "$compose_file" up -d --build
|
export VACATURERADAR_ENV_FILE="$env_file"
|
||||||
|
docker compose --env-file "$env_file" -f "$compose_file" up -d --build
|
||||||
|
|
||||||
if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
|
if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
|
||||||
for i in {1..30}; do
|
for i in {1..30}; do
|
||||||
@@ -157,14 +191,16 @@ if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat <<"EOF"
|
cat <<EOF
|
||||||
Deploy klaargezet.
|
Deploy klaargezet.
|
||||||
|
|
||||||
Applicatie:
|
Applicatie:
|
||||||
- open http://127.0.0.1:1226/
|
- lokaal: http://127.0.0.1:${app_port}/
|
||||||
- admin: admin / waarde uit $env_file
|
- publiek: ${app_base_url}
|
||||||
|
- admin: admin / waarde uit ${env_file}
|
||||||
|
|
||||||
Volgende stappen:
|
Volgende stappen:
|
||||||
- Zet APP_HOST en APP_SCHEME=HTTPS voor publieke domeinstandaard.
|
- Gebruik APP_HOST=<domein> APP_SCHEME=https DJANGO_DEBUG=0 voor publieke productie.
|
||||||
- Zet APP_ORIGINS en DJANGO_DEBUG=0 voor echte productie.
|
- APP_PORT is de interne hostpoort; APP_PUBLIC_PORT is optioneel en standaard 443 bij HTTPS.
|
||||||
|
- Hermaak de container na iedere environmentwijziging.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
unraid_host="${UNRAID_SSH_HOST:-unraid-itworx}"
|
||||||
|
remote_root="${UNRAID_APP_ROOT:-/mnt/user/appdata/VacatureRadar}"
|
||||||
|
archive_name="vacatureradar-deploy-$$.tar.gz"
|
||||||
|
local_archive="$(mktemp -t "${archive_name}.XXXXXX")"
|
||||||
|
trap 'rm -f "$local_archive"' EXIT
|
||||||
|
|
||||||
|
ssh_config="$(ssh -G "$unraid_host" 2>/dev/null)"
|
||||||
|
ssh_user="$(printf '%s\n' "$ssh_config" | awk '$1 == "user" { print $2; exit }')"
|
||||||
|
identity_file="$(printf '%s\n' "$ssh_config" | awk '$1 == "identityfile" { print $2; exit }')"
|
||||||
|
|
||||||
|
if [[ "$unraid_host" == *.* ]] || [[ "$ssh_user" != "root" ]] || [[ "$identity_file" == *"id_ed25519" ]]; then
|
||||||
|
echo "Onveilig of onvolledig Unraid-SSH-profiel: '$unraid_host'." >&2
|
||||||
|
echo "Gebruik een Host-alias met User root, IdentitiesOnly yes en een afzonderlijke deploysleutel." >&2
|
||||||
|
echo "Verwacht lokaal standaard: Host unraid-itworx / IdentityFile ~/.ssh/itworx_unraid_deploy" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=8 "$unraid_host" "test \"\$(id -un)\" = root && docker info >/dev/null"
|
||||||
|
|
||||||
|
git ls-files --cached --others --exclude-standard -z \
|
||||||
|
| while IFS= read -r -d '' tracked_path; do
|
||||||
|
[[ -e "$tracked_path" ]] && printf '%s\0' "$tracked_path"
|
||||||
|
done \
|
||||||
|
| tar --null --files-from=- --create --gzip --file="$local_archive"
|
||||||
|
|
||||||
|
scp -q "$local_archive" "$unraid_host:/tmp/$archive_name"
|
||||||
|
ssh "$unraid_host" "set -eu
|
||||||
|
mkdir -p '$remote_root'
|
||||||
|
tar -xzf '/tmp/$archive_name' -C '$remote_root'
|
||||||
|
rm -f '/tmp/$archive_name'
|
||||||
|
cd '$remote_root'
|
||||||
|
docker compose -f docker-compose.unraid.yml up -d --build
|
||||||
|
timeout 120 sh -c 'until docker inspect VacatureRadar --format=\"{{.State.Health.Status}}\" 2>/dev/null | grep -qx healthy; do sleep 3; done'
|
||||||
|
curl --fail --silent --show-error http://127.0.0.1:1226/health/ready/ >/dev/null
|
||||||
|
docker ps --filter name=VacatureRadar --format '{{.Names}} {{.Status}} {{.Image}}'"
|
||||||
@@ -4,6 +4,8 @@ set -eu
|
|||||||
mkdir -p /app/media /app/logs /app/local
|
mkdir -p /app/media /app/logs /app/local
|
||||||
mkdir -p /tmp/celery
|
mkdir -p /tmp/celery
|
||||||
|
|
||||||
|
python manage.py check --deploy
|
||||||
|
|
||||||
if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then
|
if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then
|
||||||
python manage.py migrate --noinput
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Keep outbound Celery work paused during a verified cold-data migration.
|
||||||
|
|
||||||
|
The operator creates the hold file in the candidate data clone before startup
|
||||||
|
and removes it only after committing the verified deployment. Normal startups
|
||||||
|
without a hold file behave unchanged. This does not pause an existing worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HOLD_FILE = Path("/app/local/.migration-worker-hold")
|
||||||
|
COMMANDS = {
|
||||||
|
"worker": [
|
||||||
|
"/app/.venv/bin/celery",
|
||||||
|
"-A",
|
||||||
|
"config",
|
||||||
|
"worker",
|
||||||
|
"-l",
|
||||||
|
"INFO",
|
||||||
|
"-Q",
|
||||||
|
"high,default,low",
|
||||||
|
"--concurrency=2",
|
||||||
|
],
|
||||||
|
"scheduler": [
|
||||||
|
"/app/.venv/bin/celery",
|
||||||
|
"-A",
|
||||||
|
"config",
|
||||||
|
"beat",
|
||||||
|
"-l",
|
||||||
|
"INFO",
|
||||||
|
"--schedule",
|
||||||
|
"/tmp/celerybeat-schedule", # noqa: S108 - existing supervised container-local schedule path
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def hold_active(path=HOLD_FILE):
|
||||||
|
try:
|
||||||
|
mode = path.lstat().st_mode
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
if not stat.S_ISREG(mode):
|
||||||
|
raise RuntimeError("Migration hold must be a regular file")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def start(role, *, check=hold_active, sleep=time.sleep, execute=os.execv):
|
||||||
|
if role not in COMMANDS:
|
||||||
|
raise ValueError("Unsupported supervised process")
|
||||||
|
while check():
|
||||||
|
sleep(1)
|
||||||
|
command = COMMANDS[role]
|
||||||
|
execute(command[0], command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
start(sys.argv[1] if len(sys.argv) == 2 else "")
|
||||||
@@ -53,4 +53,8 @@ if [[ -n "${APP_PORT:-}" ]]; then
|
|||||||
export APP_PORT
|
export APP_PORT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${APP_PUBLIC_PORT:-}" ]]; then
|
||||||
|
export APP_PUBLIC_PORT
|
||||||
|
fi
|
||||||
|
|
||||||
bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE"
|
bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_FILE"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
: "${DATABASE_URL:?Stel DATABASE_URL in op een afzonderlijke vacatureradar_bench_* PostgreSQL-database.}"
|
||||||
|
export PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-https://benchmark.invalid}"
|
||||||
|
export DJANGO_DEBUG=0
|
||||||
|
export DJANGO_SECRET_KEY="${DJANGO_SECRET_KEY:-benchmark-only-secret-key-not-for-production-000000000000000000}"
|
||||||
|
export CELERY_TASK_ALWAYS_EAGER=1
|
||||||
|
export OLLAMA_ENABLED=0
|
||||||
|
export EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
|
||||||
|
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
exec uv run python -m scripts.benchmark_import "$@"
|
||||||
|
fi
|
||||||
|
exec .venv/bin/python -m scripts.benchmark_import "$@"
|
||||||
@@ -3,13 +3,14 @@ set -euo pipefail
|
|||||||
|
|
||||||
pg_data=/app/local/postgres-aio17
|
pg_data=/app/local/postgres-aio17
|
||||||
redis_data=/app/local/redis-aio
|
redis_data=/app/local/redis-aio
|
||||||
pg_bin="$(dirname "$(readlink -f "$(command -v pg_config)")")"
|
pg_bin="$(pg_config --bindir)"
|
||||||
fresh_database=0
|
fresh_database=0
|
||||||
|
|
||||||
mkdir -p "$pg_data" "$redis_data" /app/media /app/logs /tmp/celery
|
mkdir -p "$pg_data" "$redis_data" /app/media /app/logs /app/local/backups /tmp/celery
|
||||||
chown -R postgres:postgres "$pg_data"
|
chown -R postgres:postgres "$pg_data"
|
||||||
chown -R redis:redis "$redis_data"
|
chown -R redis:redis "$redis_data"
|
||||||
chown -R app:app /app/media /app/logs /tmp/celery
|
chown -R app:app /app/media /app/logs /app/local/backups /tmp/celery
|
||||||
|
chmod 0700 "$pg_data"
|
||||||
|
|
||||||
if [[ ! -s "$pg_data/PG_VERSION" ]]; then
|
if [[ ! -s "$pg_data/PG_VERSION" ]]; then
|
||||||
runuser -u postgres -- "$pg_bin/initdb" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256
|
runuser -u postgres -- "$pg_bin/initdb" -D "$pg_data" --auth-local=trust --auth-host=scram-sha-256
|
||||||
@@ -37,6 +38,7 @@ fi
|
|||||||
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
|
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -m fast -w stop
|
||||||
|
|
||||||
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
|
runuser -u postgres -- "$pg_bin/pg_ctl" -D "$pg_data" -o "-c listen_addresses=127.0.0.1" -w start
|
||||||
|
runuser -u app -- python manage.py check --deploy
|
||||||
runuser -u app -- python manage.py migrate --noinput
|
runuser -u app -- python manage.py migrate --noinput
|
||||||
if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then
|
if [[ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" == "1" ]]; then
|
||||||
runuser -u app -- python manage.py bootstrap_instance || true
|
runuser -u app -- python manage.py bootstrap_instance || true
|
||||||
|
|||||||
@@ -13,16 +13,21 @@ MARKDOWN_LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
|
|||||||
REQUIRED_ENV_KEYS = {
|
REQUIRED_ENV_KEYS = {
|
||||||
"DJANGO_SECRET_KEY",
|
"DJANGO_SECRET_KEY",
|
||||||
"DJANGO_DEBUG",
|
"DJANGO_DEBUG",
|
||||||
|
"PUBLIC_BASE_URL",
|
||||||
"DJANGO_ALLOWED_HOSTS",
|
"DJANGO_ALLOWED_HOSTS",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"POSTGRES_PASSWORD",
|
"POSTGRES_PASSWORD",
|
||||||
"REDIS_URL",
|
"REDIS_URL",
|
||||||
|
"CACHE_URL",
|
||||||
|
"HEALTHCHECK_REQUIRE_CACHE",
|
||||||
"SOURCE_POLICY_MODE",
|
"SOURCE_POLICY_MODE",
|
||||||
"MAILBOX_CREDENTIAL_KEYS",
|
"MAILBOX_CREDENTIAL_KEYS",
|
||||||
"IMAP_CONNECT_TIMEOUT_SECONDS",
|
"IMAP_CONNECT_TIMEOUT_SECONDS",
|
||||||
"IMAP_MAX_MESSAGES_PER_POLL",
|
"IMAP_MAX_MESSAGES_PER_POLL",
|
||||||
"IMAP_MAX_MESSAGE_BYTES",
|
"IMAP_MAX_MESSAGE_BYTES",
|
||||||
"OLLAMA_ENABLED",
|
"OLLAMA_ENABLED",
|
||||||
|
"DEMO_READ_ONLY",
|
||||||
|
"TRUST_PROXY_HEADERS",
|
||||||
}
|
}
|
||||||
REQUIRED_DOCS = {
|
REQUIRED_DOCS = {
|
||||||
"README.md",
|
"README.md",
|
||||||
@@ -34,6 +39,8 @@ REQUIRED_DOCS = {
|
|||||||
"docs/quality/THREAT_MODEL.md",
|
"docs/quality/THREAT_MODEL.md",
|
||||||
"docs/quality/TRACEABILITY_MATRIX.md",
|
"docs/quality/TRACEABILITY_MATRIX.md",
|
||||||
"docs/operations/UNRAID_DEPLOYMENT.md",
|
"docs/operations/UNRAID_DEPLOYMENT.md",
|
||||||
|
"docs/operations/NGINX_PROXY_MANAGER.md",
|
||||||
|
"deployment/production.env.example",
|
||||||
"docs/api/openapi.yaml",
|
"docs/api/openapi.yaml",
|
||||||
".agents/skills/vacatureradar-maintainer/SKILL.md",
|
".agents/skills/vacatureradar-maintainer/SKILL.md",
|
||||||
}
|
}
|
||||||
|
|||||||