Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
8648b4f4d1 | ||
|
|
bdf44eec04 | ||
|
|
0101660bd7 | ||
|
|
5f435d030e | ||
|
|
ee915742bf | ||
|
|
4acd81afb5 | ||
|
|
afe18e5b96 | ||
|
|
4fa2a34447 | ||
|
|
3371455255 | ||
|
|
09af98193f | ||
|
|
a30ecbc553 | ||
|
|
029df89265 | ||
|
|
7d3d026d1d | ||
|
|
e234641c36 |
@@ -12,3 +12,4 @@ docs/reference/*.docx
|
||||
docs/reference/*.pdf
|
||||
docs/design/stitch-reference
|
||||
docs/design/validation
|
||||
artifacts
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
# Kopieer naar .env en wijzig ALLE waarden met CHANGE_ME.
|
||||
DJANGO_SECRET_KEY=CHANGE_ME_generate_a_long_random_value
|
||||
DJANGO_DEBUG=1
|
||||
# Leeg voor lokaal gebruik. In productie: https://vacatureradar.example.be
|
||||
PUBLIC_BASE_URL=
|
||||
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:1226
|
||||
DJANGO_TIME_ZONE=Europe/Brussels
|
||||
VACATURERADAR_OWNER_NAME=Jens
|
||||
VACATURERADAR_VERSION=0.3.14
|
||||
|
||||
# Gebruik DATABASE_URL niet voor SQLite. Laat leeg voor lokale sqlite-ontwikkeling.
|
||||
POSTGRES_DB=vacatureradar
|
||||
@@ -13,6 +17,9 @@ POSTGRES_HOST=postgres
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
# Gedeelde cache voor rate limiting en readiness; gebruik een afzonderlijke Redis-database.
|
||||
CACHE_URL=redis://redis:6379/1
|
||||
HEALTHCHECK_REQUIRE_CACHE=1
|
||||
CELERY_TASK_ALWAYS_EAGER=0
|
||||
|
||||
# Bootstrap-account; alleen gebruikt door `bootstrap_instance` wanneer nog geen gebruiker bestaat.
|
||||
@@ -29,6 +36,10 @@ SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_USE_TLS=1
|
||||
M365_TENANT_ID=
|
||||
M365_CLIENT_ID=
|
||||
M365_CLIENT_SECRET=
|
||||
M365_MAILBOX_USER=
|
||||
|
||||
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
|
||||
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
@@ -51,11 +62,37 @@ OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=
|
||||
OLLAMA_TIMEOUT_SECONDS=60
|
||||
|
||||
# Microsoft Entra ID (Azure AD) SSO. Laat ENTRA_ID_ENABLED=0 om SSO uit te zetten.
|
||||
# Registreer een app in Entra ID en zet de redirect-URI op <host>/auth/entra/callback/.
|
||||
ENTRA_ID_ENABLED=0
|
||||
ENTRA_CLIENT_ID=
|
||||
ENTRA_CLIENT_SECRET=CHANGE_ME_entra_client_secret
|
||||
ENTRA_TENANT_ID=
|
||||
# Optioneel: overschrijf de redirect-URI (anders automatisch afgeleid van de host).
|
||||
ENTRA_REDIRECT_URI=
|
||||
# Optioneel: beperk tot bepaalde e-maildomeinen, komma-gescheiden (bv. organisatie.nl).
|
||||
ENTRA_ALLOWED_DOMAINS=
|
||||
ENTRA_SCOPES=User.Read
|
||||
|
||||
# Demo-toegang: knop "Bekijk demo" logt in als afgeschermd gastaccount met een
|
||||
# fictieve, gevulde demo-omgeving. Zet op 0 om de knop te verbergen.
|
||||
DEMO_MODE_ENABLED=1
|
||||
DEMO_READ_ONLY=1
|
||||
DEMO_SESSION_SECONDS=3600
|
||||
DEMO_USERNAME=demo
|
||||
DEMO_EMAIL=demo@vacatureradar.local
|
||||
|
||||
# Beveiliging
|
||||
# Zet TRUST_PROXY_HEADERS alleen aan achter een beheerde proxy die Host en X-Forwarded-Proto overschrijft.
|
||||
TRUST_PROXY_HEADERS=0
|
||||
USE_X_FORWARDED_HOST=0
|
||||
# Alleen nodig wanneer client-IPheaders voor rate limiting worden vertrouwd.
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
SESSION_COOKIE_SECURE=0
|
||||
CSRF_COOKIE_SECURE=0
|
||||
SECURE_SSL_REDIRECT=0
|
||||
SECURE_HSTS_SECONDS=0
|
||||
SEARCH_ENGINE_INDEXING_ENABLED=0
|
||||
AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS=8
|
||||
AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
|
||||
AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS=300
|
||||
|
||||
@@ -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,135 @@
|
||||
name: Managed validation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: Allowlisted validation profile
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options: [test, lint, typecheck, build, security, full]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
full:
|
||||
name: full
|
||||
# Public fork code must never execute automatically on the private runner.
|
||||
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
# Gitea's JavaScript actions execute inside the job container, so use
|
||||
# the version-matched Playwright image that includes Node as well as the
|
||||
# pinned Chromium browser and native runtime libraries.
|
||||
image: mcr.microsoft.com/playwright:v1.54.0-noble
|
||||
options: --ipc=host
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Provision the supported Python runtime
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: '3.13.2'
|
||||
- name: Validate repository with a bounded profile
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_PROFILE: ${{ inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
profile="${REQUESTED_PROFILE:-full}"
|
||||
case "${profile}" in
|
||||
test|lint|typecheck|build|security|full) ;;
|
||||
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
git diff --check
|
||||
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
|
||||
echo "Unresolved merge markers detected" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f pyproject.toml || -f requirements.txt ]]; then
|
||||
# Compile only tracked Python sources. Running compileall after a
|
||||
# Node install would otherwise traverse node_modules and turn a
|
||||
# lightweight baseline into a large runner workload.
|
||||
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
|
||||
if [[ -f uv.lock ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-uv"
|
||||
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
|
||||
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
|
||||
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
|
||||
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
|
||||
"${managed_uv}" sync --locked --extra browser
|
||||
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
|
||||
# The image supplies the native browser libraries. Install
|
||||
# only the lockfile-matched browser payload into an explicit
|
||||
# job-local path; never invoke Playwright's apt workflow.
|
||||
export PLAYWRIGHT_BROWSERS_PATH="${RUNNER_TEMP}/managed-playwright"
|
||||
timeout --foreground 5m "${managed_uv}" run playwright install chromium
|
||||
timeout --foreground 20m "${managed_uv}" run python -m pytest
|
||||
fi
|
||||
fi
|
||||
if [[ "${profile}" == lint || "${profile}" == full ]]; then
|
||||
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
|
||||
"${managed_uv}" run python -m ruff check .
|
||||
fi
|
||||
fi
|
||||
elif [[ -f requirements.txt ]]; then
|
||||
python -m venv "${RUNNER_TEMP}/managed-python"
|
||||
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
|
||||
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
|
||||
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
|
||||
if [[ "${profile}" == test || "${profile}" == full ]]; then
|
||||
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
|
||||
"${managed_python}" -m pytest
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prepare Python before invoking Node scripts. Polyglot repositories
|
||||
# commonly delegate their test script to Python and need the managed
|
||||
# virtual environment to be active first.
|
||||
if [[ -f package.json ]]; then
|
||||
corepack enable
|
||||
if [[ -f pnpm-lock.yaml ]]; then
|
||||
pnpm install --frozen-lockfile
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
|
||||
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
|
||||
elif [[ -f package-lock.json ]]; then
|
||||
npm ci
|
||||
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
|
||||
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
|
||||
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
|
||||
npm run --if-present typecheck
|
||||
fi
|
||||
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f go.mod ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
go test ./...
|
||||
fi
|
||||
fi
|
||||
if [[ -f Cargo.toml ]]; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
cargo test --locked
|
||||
fi
|
||||
fi
|
||||
if compgen -G '*.sln' >/dev/null; then
|
||||
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
|
||||
dotnet test --configuration Release
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,59 @@
|
||||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
verify-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pinned uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
version: "0.11.12"
|
||||
python-version: "3.13"
|
||||
- name: Install verification browser
|
||||
run: |
|
||||
uv sync --extra browser
|
||||
uv run playwright install --with-deps chromium
|
||||
- name: Verify release
|
||||
run: bash ./scripts/codex_verify.sh
|
||||
- name: Build immutable image
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
registry="gitea.itworx.tech"
|
||||
image="${registry}/jens/vacatureradar:${version}"
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
|
||||
docker build --file Dockerfile.unraid \
|
||||
--build-arg "VCS_REF=${GITHUB_SHA}" \
|
||||
--build-arg "APP_VERSION=${version}" \
|
||||
--tag "$image" .
|
||||
docker push "$image"
|
||||
docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
anchore/syft:v1.33.0 \
|
||||
"docker:$image" -o spdx-json > sbom.spdx.json
|
||||
cat > RELEASE_NOTES.md <<EOF
|
||||
# VacatureRadar ${version}
|
||||
|
||||
Immutable release image built from commit ${GITHUB_SHA}.
|
||||
The accompanying digest, SPDX SBOM and checksums are release evidence.
|
||||
EOF
|
||||
sha256sum image-digest.txt sbom.spdx.json RELEASE_NOTES.md > SHA256SUMS
|
||||
- name: Publish release evidence
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: release-evidence-${{ github.ref_name }}
|
||||
path: |
|
||||
image-digest.txt
|
||||
sbom.spdx.json
|
||||
SHA256SUMS
|
||||
RELEASE_NOTES.md
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Unraid autoredeploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- ".gitea/**"
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: unraid-production-vacatureradar
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy vacatureradar
|
||||
runs-on: unraid-deploy
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Deploy exact Gitea revision
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker exec gitea-deploy-control \
|
||||
/opt/gitea-deploy/deploy.py deploy \
|
||||
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
|
||||
|
||||
@@ -24,13 +24,15 @@ jobs:
|
||||
run: python -m pip install --disable-pip-version-check uv==0.10.0
|
||||
- name: Sync locked dependencies
|
||||
run: uv sync --frozen --all-groups
|
||||
- name: Install Chromium for mandatory browser tests
|
||||
run: uv run playwright install --with-deps chromium
|
||||
- name: Secret scan
|
||||
uses: trufflesecurity/trufflehog@v3.79.0
|
||||
with:
|
||||
path: ./
|
||||
extra_args: --only-verified
|
||||
- name: Scan project dependencies and lockfiles (high/critical)
|
||||
uses: aquasecurity/trivy-action@0.30.0
|
||||
uses: aquasecurity/trivy-action@v0.30.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scan-ref: .
|
||||
@@ -47,6 +49,13 @@ jobs:
|
||||
IMAP_ENABLED: "0"
|
||||
OLLAMA_ENABLED: "0"
|
||||
run: ./scripts/codex_verify.sh
|
||||
- name: Upload browser failure artifacts
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: browser-failure-artifacts
|
||||
path: artifacts/browser-tests/
|
||||
if-no-files-found: ignore
|
||||
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,12 +66,24 @@ jobs:
|
||||
- name: Build production image
|
||||
run: docker build --pull --tag vacatureradar:ci .
|
||||
- name: Scan container image for high/critical vulnerabilities
|
||||
uses: aquasecurity/trivy-action@0.30.0
|
||||
uses: aquasecurity/trivy-action@v0.30.0
|
||||
with:
|
||||
image-ref: vacatureradar:ci
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
- name: Generate CycloneDX software bill of materials
|
||||
uses: aquasecurity/trivy-action@v0.30.0
|
||||
with:
|
||||
image-ref: vacatureradar:ci
|
||||
format: cyclonedx
|
||||
output: vacatureradar-sbom.cdx.json
|
||||
scan-type: image
|
||||
- name: Upload software bill of materials
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vacatureradar-sbom
|
||||
path: vacatureradar-sbom.cdx.json
|
||||
- name: Inspect image configuration
|
||||
run: docker image inspect vacatureradar:ci > /tmp/image-inspect.json
|
||||
|
||||
@@ -17,6 +17,7 @@ local/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.unraid.example
|
||||
secrets/
|
||||
|
||||
# IDE / OS
|
||||
|
||||
@@ -28,6 +28,13 @@ Lees niet blind alle referentiedocumenten. Gebruik `docs/quality/TRACEABILITY_MA
|
||||
- Geen productieafhankelijkheid toevoegen zonder duidelijke noodzaak, lockfile-update, tests en documentatie.
|
||||
- Geen secrets, tokens, echte e-mailadressen of persoonlijke gegevens committen.
|
||||
|
||||
## Frontendcontract
|
||||
|
||||
- De productiefrontend volgt `docs/design/STITCH_FRONTEND_CONTRACT.md`; Stitch is de visuele bron van waarheid, de Django-backend blijft leidend voor werking en data.
|
||||
- Gebruik uitsluitend echte of eerlijk als onbekend gemarkeerde data. Geen demo-KPI's, nepstatussen, dode controls, auto-apply of externe runtimeassets.
|
||||
- Kernflows blijven server-rendered en bruikbaar zonder JavaScript; afwijkingen voor security, toegankelijkheid en ontbrekende data worden gedocumenteerd.
|
||||
- Na een frontendmigratie blijft geen parallelle legacy-shell, ongebruikte componentstijl of verborgen oude UI bestaan.
|
||||
|
||||
## Codeconventies
|
||||
|
||||
- Python 3.12/3.13; type hints op publieke services.
|
||||
|
||||
@@ -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,149 @@
|
||||
|
||||
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
|
||||
|
||||
## 0.3.17 - 2026-08-12
|
||||
|
||||
### Hersteld
|
||||
|
||||
- De Gitea-releasebootstrap gebruikt een immutable `setup-uv`-commit en een exacte
|
||||
uv-versie; downloads lopen via de Astral-mirror en zijn niet langer afhankelijk
|
||||
van de gelimiteerde GitHub Releases-API op het gedeelde runner-IP.
|
||||
- De achterhaalde `main`-push-to-deployworkflow is verwijderd; alleen een
|
||||
semantische tag kan nog een immutable releaseimage publiceren.
|
||||
- De Unraid Docker-host gebruikt een conflictvrije `/24`-address pool met ruimte
|
||||
voor 256 tijdelijke en toekomstige netwerken.
|
||||
|
||||
## 0.3.16 - 2026-08-12
|
||||
|
||||
### Hersteld
|
||||
|
||||
- De publieke demo herstelt bij iedere nieuwe demosessie zes actuele fictieve vacatures, hun laatste scores en twee toekomstige opvolgmomenten zonder duplicaten of onbeperkte scorehistoriek.
|
||||
- Negen fictieve `example.test`-platformmailboxen maken de mailboxflow zichtbaar zonder echte accounts, credentials of externe polls.
|
||||
- Mailboxingress wordt uitsluitend door de mailboxscheduler verwerkt; e-mailbronnen komen niet meer in URL-fetchplanning, dashboardscanstatus of de algemene bronrunactiviteit terecht.
|
||||
- Statuscopy onderscheidt een actief zoekprofiel van operationele brongezondheid en het lege loginwachtwoordveld lijkt niet langer vooraf ingevuld.
|
||||
|
||||
## 0.3.14 - 2026-07-29
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- De importpipeline hergebruikt werkgevers en actieve profielen binnen één run, synchroniseert provenance gebundeld en vermijdt volledige rescoring van inhoudelijk ongewijzigde vacatures terwijl ScoreRun-historiek behouden blijft.
|
||||
- `last_changed` wijzigt alleen nog bij een inhoudelijke vacaturewijziging; een nieuwe waarneming actualiseert uitsluitend `last_seen`.
|
||||
- Een fail-closed PostgreSQL-runner levert één warm-up, drie metingen, p50/p95, throughput, geheugen en queryfamilies als machineleesbaar rapport.
|
||||
|
||||
### Performance
|
||||
|
||||
- De officiële 250-itemmeting is gemiddeld 44,74% sneller, gebruikt minstens 63,73% minder queries en verwerkt 83,42% meer documenten per seconde dan 0.3.13.
|
||||
|
||||
## Niet uitgebracht
|
||||
|
||||
### Toegevoegd
|
||||
|
||||
- `PUBLIC_BASE_URL` als één productiebron voor hostvalidatie, CSRF-origin, secure cookies en HTTPS-redirect.
|
||||
- Een idempotente `configure_public_url.py`-helper voor bestaande `.env`-bestanden achter Nginx Proxy Manager.
|
||||
- Een concrete Nginx Proxy Manager-runbook, productie-envvoorbeeld, publieke `robots.txt` en cache-readinesscheck.
|
||||
- Server-side alleen-lezenbescherming en begrensde sessies voor de gedeelde gastdemo.
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- De repository-README volgt nu de gebruikersreis van doel en dagelijkse workflow naar quickstart, configuratie, veiligheidsgrenzen, beheer en pas daarna architectuur en agentoverdracht.
|
||||
- Negen actuele, compacte productbeelden vertellen nu de volledige gebruikersreis: dagoverzicht, vacatureverkenner, beslisanalyse, Skillsradar, werkgevers, sollicitaties, bronnen, automatisering en mobiel zoekprofiel.
|
||||
- De README-galerij gebruikt taakgerichte captions, klikbare beelden, een consistente 1920×1080-desktopviewport en een gebalanceerde dark/light-compositie.
|
||||
- Het VacatureRadar-productlogo is hertekend als transparante vier-elementenradar zonder donkere tegel, rand of schaduw; login en sidebar gebruiken hetzelfde cleaner beeldmerk.
|
||||
- De sidebarfooter combineert radarstatus en een transparante, themabewuste ITWorx-credit; de dubbele demo-/accountidentiteit is verwijderd en blijft uitsluitend via het topbarmenu bereikbaar.
|
||||
- Widescreencontent schaalt nu gecontroleerd naar 2.200 px en op 4K naar 2.480 px, terwijl dashboardverhoudingen, korte viewports en mobiele layouts begrensd blijven.
|
||||
- De volledige visuele audit is vertaald naar één gedeeld contentframe voor topbar en pagina, een dashboard met maximaal vijf kernmatches en een compactere activiteitstijdlijn.
|
||||
- Secundaire actielinks hebben grotere klikdoelen, reduced motion dekt alle animaties en geïmporteerde stylesheets delen voortaan expliciet de releasecacheversie.
|
||||
- De ultrawide cockpit gebruikt één begrensd, links verankerd grid; headers, filters, pipeline, KPI's en detailpanelen delen daardoor op brede schermen hetzelfde startpunt zonder uitgerekte leesregels.
|
||||
- VacatureRadar heeft een zelfstandig open-radar-beeldmerk met scanstraal en kanssignaal voor shell, login, favicon en Dockerman; de officiële ITWorx.tech-wordmark staat uitsluitend nog als subtiele `Ontwikkeld door`-credit.
|
||||
- VacatureRadar gebruikt nu één scherper radarbeeldmerk voor favicon, login en applicatieshell; de persoonlijke eigenaar is via `VACATURERADAR_OWNER_NAME` configureerbaar.
|
||||
- De bovenbalk geeft naast zoeken ook eigenaar en actuele werkcontext aan; navigatie-, focus- en hoverstates zijn verfijnd zonder de compacte mobiele cockpit te verzwaren.
|
||||
- Het zoekprofiel bevat een lokale interactieve radarillustratie met pointerparallax, een rustige scansweep, light/dark-kleuren en een statische reduced-motionvariant; de illustratie is decoratief en introduceert geen fictieve data.
|
||||
- De Unraid-heruitrol gebruikt een gevalideerde SSH-hostalias met afzonderlijke deploysleutel en voert synchronisatie, build, healthwait en readiness-smoke als één reproduceerbaar commando uit.
|
||||
- De cockpit heeft een premium “calm intelligence”-laag met zachtere oppervlaktescheidingen, selectief cyan, humane navigatietypografie en een duidelijkere componenthiërarchie in donker en licht thema.
|
||||
- Mobiele kerncijfers staan compact in een 2×2-raster; vacaturefilters vormen een responsieve commandobar en de kanbanpipeline scrolt per kolom zonder documentoverflow.
|
||||
- Dashboardkaarten, vacature-intelligence en het zoekprofiel gebruiken rustigere composities met minder decoratieve randen en beter gedoseerde elevatie.
|
||||
|
||||
### Hersteld
|
||||
|
||||
- De productieconfiguratiehelper voldoet opnieuw aan Ruff en de `.env`-bestandmodustest houdt rekening met Windows, waar POSIX-modusbits niet afdwingbaar zijn.
|
||||
- Publieke Nginx-hostnamen veroorzaken niet langer `DisallowedHost` wanneer `PUBLIC_BASE_URL` correct staat.
|
||||
- HTTPS-deployment gebruikt de externe poort in plaats van de interne applicatiepoort voor CSRF en publieke 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
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- De voormalige MVP-frontend is volledig vervangen door één high-fidelity, server-rendered Stitch Intelligence Cockpit met nieuwe lokale tokens, shell, SVG-iconen, responsive lagen en progressive enhancement.
|
||||
- Alle negen Stitch-schermen zijn functioneel gekoppeld: dashboard, vacatureverkenner/detail, sollicitatiepipeline, zoekprofiel, brongezondheid, automatisering, activiteit en werkgeversintelligence.
|
||||
- Login, skillsradar, dossieredit, foutpagina's, empty states en messages gebruiken dezelfde componenttaal; de normale productiefrontend heeft geen legacyflag of parallelle shell.
|
||||
- Nieuwe read services leveren echte dashboard-, vacature-, activiteit-, werkgever- en automationdata; vacaturefilters tonen standaard alleen aantoonbaar IT-relevante kansen.
|
||||
- Sollicitatiestatus is een gebruikersgescopeerde CSRF-POST met audittrail. Niet-bestaande runtime- of marktdata blijft expliciet onbekend in plaats van gesimuleerd.
|
||||
- Bronhealth gebruikt één geslicete prefetch voor alle bronnen; het automatiseringsscherm daalde in de representatieve dataset van 63 naar 23 queries.
|
||||
|
||||
### Verificatie
|
||||
|
||||
- De geïntegreerde Codex-browser controleerde alle negen schermen op desktop en 390 px, plus kernflows op 360, 768, 1024, 1280, 1440, 1920 en 2560 px zonder documentoverflow, kapotte assets of consolewaarschuwingen.
|
||||
- Elf screenshots staan onder `artifacts/visual-validation/`; dark/light, mobiele drawer en toegankelijke actielabels zijn functioneel getest.
|
||||
- Gerichte regressies, Ruff, Django system check, migratiecheck en de queryregressietest zijn groen; de volledige gate-uitkomst staat in `docs/ai/PROJECT_STATE.md`.
|
||||
|
||||
## 0.2.13 — 2026-07-22
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- Ervaringsjaren en senioriteitslabels zijn volledig uit de deterministische én AI-score gehaald; de profielwaarde blijft uitsluitend informatieve context en bestaande senioriteitsgewichten worden via migratie verwijderd.
|
||||
- De nieuwe persoonlijke Skillsradar analyseert alleen actieve, IT-relevante, niet-uitgesloten vacatures van de huidige profielversie en negeert vacatures waarvan alle bronaliassen uitgeschakeld zijn.
|
||||
- Per technologie of capability toont de radar vacaturevolume, profieldekking, skillveldbewijs versus tekstinferentie, onderliggende vacatures en een niet-blokkerende mogelijke leer-/bijscholingsstap.
|
||||
- Categorie- en dekkingsfilters maken de marktvraag doorzoekbaar; de profielcheckboxes zijn uitgebreid met actuele Microsoft-, netwerk/security-, observability-, platform-, ITSM- en adoptieskills.
|
||||
- De Stitch-cockpit gebruikt een aparte vraag-/leerkansencompositie met adaptieve kaarten, context-rail en tweekoloms skillfeed op ultrawide.
|
||||
|
||||
### Verificatie
|
||||
|
||||
- 240 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,73% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||
- De geïntegreerde browsercontrole bevestigde filtering en bronselectie plus layouts op 2560×1440, 1440×900 en 390×844 zonder horizontale paginaoverflow of consolewaarschuwingen.
|
||||
|
||||
## 0.2.12 — 2026-07-22
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- De zoekprofieleditor biedt gegroepeerde selecties voor field service, infrastructuur, Modern Workplace, netwerk/security, back-up/monitoring, virtualisatie, VoIP en scripting.
|
||||
- Relevante IT-ervaring is configureerbaar als ervaringsband en wordt alleen met expliciet gevraagde vacaturejaren vergeleken.
|
||||
- Een breed cv-profiel verwatert de skillscore niet onbeperkt: vier aangetroffen skills volstaan voor de volledige skillcomponent en niet-vermelde voorkeurskills zijn geen bewezen tekort.
|
||||
- Ambigue field-/implementatietitels vereisen naast de titel een sterk IT-contextsignaal in de vacaturetekst; korte en meervoudige uitsluittermen gebruiken woordgrenzen.
|
||||
- De mobiele categoriekaarten stapelen op één kolom en behouden de Sticky-opslagactie zonder horizontale paginaoverflow.
|
||||
|
||||
### Verificatie
|
||||
|
||||
- 232 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,59% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||
- De geïntegreerde browsercontrole bevestigde de gegroepeerde editor op ultrawide en 390 px, de ervaringsband, lokale profielselecties en afwezigheid van horizontale paginaoverflow.
|
||||
- Persoonlijke cv-inhoud is alleen gebruikt om de lokale gebruikersprofielen af te stellen en is niet in code, fixtures, tests of documentatie opgenomen.
|
||||
|
||||
## 0.2.11 — 2026-07-22
|
||||
|
||||
### Gewijzigd
|
||||
|
||||
- De standaardradar toont alleen titelmatig aantoonbare IT-vacatures en laat verborgen/hard uitgesloten matches weg; het volledige bron- en scorearchief blijft expliciet bereikbaar.
|
||||
- Vacatures worden standaard op de laatste persoonlijke matchscore gerangschikt en tonen scorebewijs, datakwaliteit, aandachtspunt en directe acties in de lijst.
|
||||
- Vandaag gebruikt uitsluitend de laatste gebruikersgescopeerde score en telt alleen persoonlijke open dossiers.
|
||||
- Dashboard, vacaturelijst en detail volgen de Stitch-panelhiërarchie met context-rails, rijkere evidencekaarten en een adaptieve contentbreedte tot 2240 px.
|
||||
- Op 1920 en 2560 px krijgen resultaatfeeds twee kolommen; op 320 px stapelen filters, sectiekoppen en acties zonder horizontale overflow.
|
||||
- Feedbackterugkeer is tegen externe redirects afgeschermd en `/favicon.ico` gebruikt het lokale SVG-favicon.
|
||||
|
||||
### Verificatie
|
||||
|
||||
- 225 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,53% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||
- De geïntegreerde browsermatrix controleerde zes kernroutes en zes breedtes van 320 tot 2560 px, dark/light, mobiele navigatie en de radar-/archiefflow zonder horizontale paginaoverflow.
|
||||
|
||||
## 0.2.10 — 2026-07-22
|
||||
|
||||
### Gewijzigd
|
||||
@@ -11,12 +154,14 @@ Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorl
|
||||
- Twaalf interessante regionale werkgevers zijn als afzonderlijke, niet-scanbare waaklijst toegevoegd, ook wanneer zij momenteel geen lokale vacature publiceren.
|
||||
- De bronnenpagina toont 21 actieve bronnen, 12 waaklijstwerkgevers en de actieve GeoNames-afstandsstatus; waaklijstbronnen vallen buiten bulkacties en geplande scans.
|
||||
- HTTP-, robots- en IMAP-verbindingen gebruiken expliciet de gepinde `certifi`-CA-bundel met verplichte hostnaam- en certificaatcontrole. Gemeente Mol en Ziekenhuis Geel werken daarmee zonder TLS-bypass.
|
||||
- Celery gebruikt expliciet queue `default`, zodat bronruns en andere standaardtaken daadwerkelijk door de productie-worker worden geconsumeerd.
|
||||
- De fetcher controleert het werkelijk verbonden peer-IP en accepteert daardoor veilige publieke CDN-rotatie zonder de SSRF-blokkade voor private adressen te versoepelen.
|
||||
|
||||
### Verificatie
|
||||
|
||||
- De 8 nieuwe actieve bronnen leverden samen 53 kaarten per cyclus en voltooiden elk twee livecycli zonder parserwarnings; de tweede cycli maakten geen nieuwe dubbelen aan.
|
||||
- GeoNames valideerde 2.781 unieke Belgische postcode-/gemeenterijen; bron- en afgeleide SHA-256-checksums zijn vastgelegd en het actieve profiel is opnieuw gescoord.
|
||||
- 215 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,42% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||
- 218 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,46% branch-aware codedekking; Ruff, Django, migraties, taakledger en repositoryvalidatie zijn groen.
|
||||
- De Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, 8 nieuwe actieve werkgevers, één `h1`, een lege console en geen horizontale overflow op desktop of 390 px.
|
||||
|
||||
## 0.2.9 — 2026-07-22
|
||||
|
||||
@@ -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 . .
|
||||
RUN uv sync --frozen --no-dev \
|
||||
&& chmod +x scripts/*.sh \
|
||||
&& python manage.py collectstatic --noinput \
|
||||
&& DJANGO_DEBUG=0 \
|
||||
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
|
||||
PUBLIC_BASE_URL=https://build.invalid \
|
||||
python manage.py collectstatic --noinput \
|
||||
&& mkdir -p /app/media /app/logs /app/local \
|
||||
&& chown -R app:app /app \
|
||||
&& chmod -R 0775 /app/media /app/logs /app/local /app/staticfiles /app/.venv
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM python:3.13-slim-trixie
|
||||
|
||||
ARG VCS_REF=unknown
|
||||
ARG APP_VERSION=dev
|
||||
LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \
|
||||
org.opencontainers.image.revision=$VCS_REF \
|
||||
org.opencontainers.image.version=$APP_VERSION
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_DEFAULT_INDEX=https://pypi.org/simple \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates libpq5 postgresql redis-server supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0 /uv /uvx /bin/
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system app \
|
||||
&& adduser --system --ingroup app --home /app --no-create-home app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
COPY . .
|
||||
RUN uv sync --frozen --no-dev \
|
||||
&& chmod +x scripts/*.sh \
|
||||
&& DJANGO_DEBUG=0 \
|
||||
DJANGO_SECRET_KEY=build-only-static-manifest-key-not-used-at-runtime-000000000000 \
|
||||
PUBLIC_BASE_URL=https://build.invalid \
|
||||
python manage.py collectstatic --noinput \
|
||||
&& mkdir -p /app/media /app/logs /app/local /var/log/supervisor \
|
||||
&& chown -R app:app /app
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/scripts/unraid_aio_entrypoint.sh"]
|
||||
@@ -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,195 +1,260 @@
|
||||
# VacatureRadar
|
||||
<p align="center">
|
||||
<img src="static/img/vacatureradar-logo.svg" width="88" alt="VacatureRadar-logo">
|
||||
</p>
|
||||
|
||||
VacatureRadar is een persoonlijke, vrijwel autonome vacature-assistent voor toegestane publieke werkgeversbronnen, publieke ATS-pagina's, RSS/Atom en vacaturemails. De applicatie verzamelt vacatures, normaliseert en dedupliceert ze, past harde filters toe, berekent een uitlegbare matchscore en bundelt de beste resultaten in een dagelijkse digest.
|
||||
<h1 align="center">VacatureRadar</h1>
|
||||
|
||||
De repository bevat zowel een **werkende MVP-kern** als de volledige product-, architectuur-, UX-, beveiligings-, operationele en agentische overdrachtsdocumentatie om het project verder autonoom af te bouwen.
|
||||
<p align="center"><strong>Van vacature-overload naar een kleine, uitlegbare selectie die bij jou past.</strong></p>
|
||||
|
||||
## Huidige status
|
||||
VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-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 Nederlandstalige responsive dark/light Intelligence Cockpit;
|
||||
- PostgreSQL in productie en SQLite voor lokale ontwikkeling;
|
||||
- Celery-worker en scheduler via Redis;
|
||||
- veilige HTTP-fetcher met domeinbeleid, redirecthercontrole, contentlimieten en SSRF-blokkering;
|
||||
- JSON-LD `JobPosting`, generieke HTML-, RSS/Atom- en vacaturemailparsers;
|
||||
- normalisatie, herkomstbewijs, vacatureversies en idempotente opslag;
|
||||
- exacte en fuzzy deduplicatie;
|
||||
- deterministische kenmerken, harde uitsluitregels en uitlegbare scoring;
|
||||
- zoekprofielen met revisiehistoriek en begrensde feedbackaanpassing;
|
||||
- dagelijkse digests, vacaturelevenscyclus en sollicitatiedossiers;
|
||||
- optionele lokale Ollama-analyse met prompt-injectionguardrails;
|
||||
- Docker Compose voor lokaal gebruik en een Unraid-variant;
|
||||
- tests, fixtures, CI, back-up-/herstelscripts en healthchecks.
|
||||

|
||||
|
||||
Nog niet als productievoltooid beschouwd:
|
||||
## Voor wie is dit?
|
||||
|
||||
- live broninventaris en bronvoorwaarden per concrete werkgever;
|
||||
- een gekozen actuele geodataset met licentie;
|
||||
- productiespecifieke secrets, mailboxcredentials, domein/TLS en containerregistry.
|
||||
VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installatie die:
|
||||
|
||||
Deze punten staan als uitvoerbare taken in [`docs/ai/BACKLOG.yaml`](docs/ai/BACKLOG.yaml). Externe credentials blokkeren nooit de rest van het werk: taken worden met mocks en interfaces afgewerkt en daarna als `blocked-external` gedocumenteerd waar echte configuratie nodig is.
|
||||
- meerdere vacaturebronnen wil volgen zonder iedere site dagelijks te openen;
|
||||
- alleen aantoonbaar relevante vacatures wil beoordelen;
|
||||
- wil begrijpen waarom een vacature wel of niet past;
|
||||
- sollicitaties, deadlines en opvolging op één plek wil bewaren;
|
||||
- lokale controle over profiel-, vacature- en brondata belangrijk vindt.
|
||||
|
||||
Release-productiepad:
|
||||
## Zo gebruik je VacatureRadar
|
||||
|
||||
- `./scripts/release_verify.sh` bouwt de smoke-, backup/restore- en artifactcontrole;
|
||||
- [`docs/operations/UNRAID_DEPLOYMENT.md`](docs/operations/UNRAID_DEPLOYMENT.md) bevat een expliciet upgrade/rollbackpad met releaseartefacten.
|
||||
1. **Stel je radar in.** Kies rollen, skills, regio, afstand, werkmodel en harde grenzen.
|
||||
2. **Laat toegestane bronnen verwerken.** De radar ontdekt, haalt op, normaliseert en dedupliceert vacatures volgens bronbeleid en SSRF-beveiliging.
|
||||
3. **Beoordeel de beste signalen.** Iedere match toont score, datakwaliteit, positieve signalen en aandachtspunten.
|
||||
4. **Leer van de markt.** De Skillsradar laat zien welke technologieën terugkomen en waar je profiel dekking heeft.
|
||||
5. **Volg acties op.** Bewaar interessante vacatures, beheer sollicitatiedossiers en houd gesprekken en deadlines bij.
|
||||
6. **Controleer de werking.** Het Automation center maakt bronruns, pipelinegezondheid en onbekende runtime-informatie zichtbaar.
|
||||
|
||||
## Snelste start voor Codex
|
||||
## Wat zit erin?
|
||||
|
||||
Open de uitgepakte repository als project en geef Codex deze opdracht:
|
||||
| Onderdeel | Wat je eraan hebt |
|
||||
|---|---|
|
||||
| Dagoverzicht | Prioriteiten, beste matches, open trajecten en radarstatus in één beeld |
|
||||
| Vacatureverkenner | Zoeken en filteren met een uitlegbare detailanalyse naast de resultaten |
|
||||
| Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel |
|
||||
| Sollicitatiepipeline | Dossiers, status, notities, deadlines en een auditbare tijdlijn |
|
||||
| Bronnenbeheer | Veilige bronconfiguratie, gezondheid, handmatige import en afzonderlijke mailboxkoppelingen voor negen jobplatformen |
|
||||
| Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend |
|
||||
|
||||
```text
|
||||
Lees CODEX_START_HERE.md en AGENTS.md volledig. Voer daarna het autonome uitvoeringsprotocol uit. Werk alle taken met status ready in docs/ai/BACKLOG.yaml op volgorde af. Vraag geen productbeslissingen; gebruik de vastgelegde defaults. Wanneer een extern secret, account of live bronreview ontbreekt, implementeer en test de interface met fixtures, markeer uitsluitend dat externe deel als blocked-external en ga meteen door met de volgende taak. Stop pas nadat alle ready taken done zijn en scripts/codex_verify.sh volledig slaagt.
|
||||
```
|
||||
## Bekijk de volledige productflow
|
||||
|
||||
De repository-instructies staan in [`AGENTS.md`](AGENTS.md). De handmatige startpagina staat in [`CODEX_START_HERE.md`](CODEX_START_HERE.md).
|
||||
De beelden komen uit een gesaneerde lokale demo-omgeving. De getoonde aantallen
|
||||
zijn een momentopname van die demo en bewijzen geen productie-status; daarvoor
|
||||
geldt uitsluitend de gedateerde [finale acceptance](docs/audit/FINAL_ACCEPTANCE.md).
|
||||
Klik op een beeld voor de volledige resolutie.
|
||||
|
||||
## Lokale quickstart
|
||||
### 1. Vind de relevante kansen
|
||||
|
||||
Vereisten: Python 3.12 of 3.13 en `uv`.
|
||||
De verkenner brengt zoeken, filters, gerangschikte resultaten en de belangrijkste matchcontext samen. Zo hoef je niet tussen lijst- en detailpagina’s te blijven wisselen.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/screenshots/readme/matches-dark.png"><img src="docs/screenshots/readme/matches-dark.png" alt="Vacatureverkenner met gerangschikte matches en directe intelligence"></a>
|
||||
</p>
|
||||
|
||||
### 2. Begrijp waarom een vacature past
|
||||
|
||||
Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broninformatie afzonderlijk zichtbaar. Ervaring en senioriteitslabels worden als context getoond, niet als verborgen scorefactor.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/screenshots/readme/job-analysis-dark.png"><img src="docs/screenshots/readme/job-analysis-dark.png" alt="Uitlegbare vacatureanalyse met scorecomponenten en besliscontext"></a>
|
||||
</p>
|
||||
|
||||
### 3. Bouw markt- en werkgeversinzicht op
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><strong>Skillsradar</strong><br><sub>Vergelijk aantoonbare marktvraag met je profiel, zonder ontbrekende skills als afwijzingsregel te behandelen.</sub></td>
|
||||
<td width="50%"><strong>Werkgeversradar</strong><br><sub>Bekijk werkgevers, actieve IT-vacatures, bronnen en eerdere dossiers in één genormaliseerd overzicht.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/readme/skills-light.png"><img src="docs/screenshots/readme/skills-light.png" alt="Skillsradar met profieldekking en actuele marktvraag"></a></td>
|
||||
<td><a href="docs/screenshots/readme/employers-light.png"><img src="docs/screenshots/readme/employers-light.png" alt="Werkgeversradar met vacature- en broninformatie"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 4. Volg trajecten op en houd bronnen gezond
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><strong>Sollicitatiepipeline</strong><br><sub>Bewaar status, deadlines, notities en tijdlijn per dossier; de uiteindelijke sollicitatie blijft altijd handmatig.</sub></td>
|
||||
<td width="50%"><strong>Bronnenbeheer</strong><br><sub>Zie policyreview, laatste scans, volgende runs, brongezondheid en de status van iedere platformmailbox zonder technische logs te moeten doorzoeken.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/readme/applications-dark.png"><img src="docs/screenshots/readme/applications-dark.png" alt="Sollicitatiepipeline met persoonlijke dossiers"></a></td>
|
||||
<td><a href="docs/screenshots/readme/sources-light.png"><img src="docs/screenshots/readme/sources-light.png" alt="Bronnenbeheer met policy- en gezondheidsstatus"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 5. Controleer de radar — ook mobiel
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="72%"><strong>Automation center</strong><br><sub>Volg de echte verwerkingspipeline, bronruns en systeemstatus. Niet-gemeten runtime-informatie blijft expliciet onbekend.</sub></td>
|
||||
<td width="28%"><strong>Mobiel zoekprofiel</strong><br><sub>Controleer je persoonlijke radar en navigeer door de kernflow vanaf een smal scherm.</sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="docs/screenshots/readme/automation-dark.png"><img src="docs/screenshots/readme/automation-dark.png" alt="Automation center met pipeline en bronruns"></a></td>
|
||||
<td align="center"><a href="docs/screenshots/readme/profile-mobile-light.png"><img src="docs/screenshots/readme/profile-mobile-light.png" width="240" alt="Mobiel zoekprofiel van VacatureRadar"></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Snel starten
|
||||
|
||||
### Optie 1 — lokale demo
|
||||
|
||||
Vereisten: Python 3.12 of 3.13, [`uv`](https://docs.astral.sh/uv/) en Git Bash op Windows.
|
||||
|
||||
```bash
|
||||
./scripts/codex_bootstrap.sh
|
||||
uv run python manage.py runserver 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Het bootstrap-script:
|
||||
|
||||
1. maakt alleen wanneer nodig een lokale `.env` aan;
|
||||
2. installeert exact de gelockte dependencies;
|
||||
3. voert migraties uit;
|
||||
4. maakt demo-inhoud aan;
|
||||
5. draait lint, systeemchecks, migratiecheck, tests en taakledgervalidatie.
|
||||
|
||||
Bij een door het script aangemaakte ontwikkelomgeving is de tijdelijke login:
|
||||
Open daarna [http://127.0.0.1:8080](http://127.0.0.1:8080). Wanneer het bootstrap-script de ontwikkelomgeving heeft aangemaakt, kun je lokaal aanmelden met:
|
||||
|
||||
```text
|
||||
admin / codex-local-only
|
||||
```
|
||||
|
||||
Gebruik die nooit buiten lokale ontwikkeling.
|
||||
Het bootstrap-script maakt zo nodig `.env`, installeert de gelockte dependencies, voert migraties uit, laadt veilige demodata en draait de kwaliteitsgates. Deze tijdelijke login is uitsluitend voor lokale ontwikkeling; wijzig of verwijder hem voordat de installatie vanaf een ander toestel bereikbaar is.
|
||||
|
||||
## Docker Compose
|
||||
### Optie 2 — Docker Compose
|
||||
|
||||
Vereisten: Docker met Compose v2 en Bash.
|
||||
|
||||
```bash
|
||||
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
|
||||
```
|
||||
|
||||
De webinterface luistert op `0.0.0.0:1226` (containerpoort `8080`). Voor een one-shot deploy kun je nu draaien:
|
||||
Open daarna [http://127.0.0.1:1226](http://127.0.0.1:1226). Het deployscript vult ontbrekende lokale `.env`-waarden met sterke gegenereerde defaults, start PostgreSQL, Redis, Django, de worker en scheduler en controleert de health-endpoints.
|
||||
|
||||
```bash
|
||||
APP_HOST=localhost APP_SCHEME=http bash scripts/deploy_docker.sh
|
||||
```text
|
||||
http://127.0.0.1:1226/health/ready/
|
||||
http://127.0.0.1:1226/health/live/
|
||||
```
|
||||
|
||||
De script zet ontbrekende `.env`-waarden automatisch met sterke defaults (secrets, database-wachtwoord, admin-credentials, ports) en start direct de volledige Docker-stack. Daarna:
|
||||
Gebruik voor een bestaande installatie geen nieuwe gegenereerde `.env`: bewaar de huidige secrets en databasegegevens. Zie [lokale ontwikkeling](docs/operations/LOCAL_DEVELOPMENT.md) of [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) voor het volledige pad.
|
||||
|
||||
- `http://127.0.0.1:1226/health/ready/`
|
||||
- `http://127.0.0.1:1226/health/live/`
|
||||
## Eerste configuratie
|
||||
|
||||
Zet voor publieke productie:
|
||||
- `APP_HOST=<jouw-domein>`
|
||||
- `APP_SCHEME=https`
|
||||
- `DJANGO_DEBUG=0`
|
||||
- `SESSION_COOKIE_SECURE=1`
|
||||
- `CSRF_COOKIE_SECURE=1`
|
||||
- `SECURE_SSL_REDIRECT=1`
|
||||
Na de eerste login:
|
||||
|
||||
De script blijft geen handmatige invulstappen voor compose vereisen; pas deze omgevingsvariabelen aan vóór eerste deploy.
|
||||
1. Open **Zoekprofiel** en stel regio, radius, rollen, skills en werkvoorkeuren in.
|
||||
2. Open **Bronnen** en activeer alleen bronnen waarvan beleid en voorwaarden beoordeeld zijn.
|
||||
3. Koppel optioneel afzonderlijke vacaturemailboxen voor VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad en Robert Half. Credentials worden niet in de repository opgeslagen.
|
||||
4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt.
|
||||
5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks.
|
||||
|
||||
## Push-to-deploy via Gitea
|
||||
Voor een publieke HTTPS-host configureer je de bestaande `.env` veilig met:
|
||||
|
||||
Er is een `.gitea/workflows/deploy.yml` toegevoegd voor automatisch deployen op elke push naar `main` en `master`.
|
||||
```bash
|
||||
python scripts/configure_public_url.py \
|
||||
https://vacatureradar.example.be \
|
||||
--env-file .env \
|
||||
--cache-url redis://127.0.0.1:6379/1
|
||||
```
|
||||
|
||||
Wat je nodig hebt:
|
||||
De publieke URL wordt vervolgens gebruikt voor `ALLOWED_HOSTS`, CSRF, secure cookies en HTTPS-detectie. Gebruik achter Nginx altijd `DJANGO_DEBUG=0`; een debugpagina mag nooit publiek zichtbaar zijn. Hermaak de container na een environmentwijziging.
|
||||
|
||||
- éénmalige setup van een **self-hosted Gitea-runner** op je server met Docker-toegang
|
||||
Publiceer poort `1226` niet rechtstreeks op internet. Gebruik een beheerde reverse proxy met TLS en volg [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md) en de concrete [Nginx Proxy Manager-handleiding](docs/operations/NGINX_PROXY_MANAGER.md). Een volledig productievoorbeeld staat in [`deployment/production.env.example`](deployment/production.env.example).
|
||||
|
||||
Daarna doet elke push automatisch:
|
||||
Voor Microsoft 365 gebruikt VacatureRadar uitsluitend app-only OAuth/XOAUTH2
|
||||
voor IMAP en SMTP; Basic Auth en Microsoft 365-app-wachtwoorden worden niet
|
||||
ondersteund. Beperk de Entra-serviceprincipal tot de bedoelde mailbox en geef
|
||||
alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`. De exacte configuratie- en
|
||||
rotatiestappen staan in het [operationele runbook](docs/operations/RUNBOOK.md).
|
||||
|
||||
- checkout van de repo op de runner
|
||||
- `bash scripts/deploy_docker.sh` op de server
|
||||
- automatische healthcheck + `collectstatic`
|
||||
## Eerlijke grenzen
|
||||
|
||||
De workflow verwacht geen host/SSH-repo secrets uit het Actions-configuratiescherm.
|
||||
VacatureRadar:
|
||||
|
||||
Zie [Gitea push-to-deploy](docs/operations/GITEA_DEPLOYMENT.md) voor de exacte serverconfiguratie.
|
||||
- logt niet automatisch in op vacaturesites en omzeilt geen CAPTCHA of rate limit;
|
||||
- crawlt denylistplatformen niet rechtstreeks;
|
||||
- behandelt HTML, e-mail en modeloutput altijd als onbetrouwbare data;
|
||||
- laat iedere URL en redirect door bronbeleid en SSRF-validatie gaan;
|
||||
- past deterministische harde regels toe vóór optionele AI-analyse;
|
||||
- verstuurt nooit automatisch een sollicitatie;
|
||||
- toont ontbrekende of niet-gemeten informatie als onbekend in plaats van die te simuleren.
|
||||
|
||||
## Zelf een schone overdrachts-ZIP maken
|
||||
Lees vóór productiegebruik [SECURITY.md](SECURITY.md) en het [threat model](docs/quality/THREAT_MODEL.md).
|
||||
|
||||
## Ontwikkelen en verifiëren
|
||||
|
||||
De kern is een server-rendered Django 5.2-modulaire monoliet met PostgreSQL, Redis/Celery en een lokale dark/light-interface zonder runtime-CDN of SPA-afhankelijkheid. SQLite wordt gebruikt voor lokale tests.
|
||||
|
||||
```bash
|
||||
uv sync --frozen --all-groups
|
||||
uv run playwright install chromium
|
||||
uv run python manage.py migrate
|
||||
uv run python manage.py runserver 0.0.0.0:8080
|
||||
```
|
||||
|
||||
Voer vóór iedere oplevering de volledige gate uit:
|
||||
|
||||
```bash
|
||||
./scripts/codex_verify.sh
|
||||
```
|
||||
|
||||
Die controleert formatting, lint, Django-configuratie, migraties, de volledige testset, verplichte Chromium-E2E, minimaal 70% branch-aware dekking, documentatie en de taakledger. De browsergate controleert de kernreis, focus, consolefouten en documentoverflow op mobiel, tablet, laptop, desktop, widescreen en ultrawide.
|
||||
|
||||
## Beheer en deployment
|
||||
|
||||
Productiereleases worden door Gitea Actions gebouwd, gescand en als private
|
||||
containerimage gepubliceerd. Pin Unraid op `versie@sha256:digest`, bewaar de
|
||||
vorige digest voor rollback en gebruik nooit alleen een veranderlijke tag als
|
||||
productiereferentie.
|
||||
|
||||
- [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md)
|
||||
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
|
||||
- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md)
|
||||
- [Push-to-deploy met Gitea](docs/operations/GITEA_DEPLOYMENT.md)
|
||||
- [Operationeel runbook](docs/operations/RUNBOOK.md)
|
||||
- [Back-up en herstel](docs/operations/BACKUP_RESTORE.md)
|
||||
- [Professionalisering baseline](docs/audit/BASELINE.md)
|
||||
- [Performanceaudit](docs/audit/PERFORMANCE.md)
|
||||
- [Finale acceptance](docs/audit/FINAL_ACCEPTANCE.md)
|
||||
|
||||
Een overdrachtsarchief zonder secrets, lokale databases of caches maak en controleer je met:
|
||||
|
||||
```bash
|
||||
uv run python scripts/package_project.py ../VacatureRadar_Project.zip
|
||||
uv run python scripts/verify_package.py ../VacatureRadar_Project.zip
|
||||
```
|
||||
|
||||
Het packaging-script sluit `.env`, virtuele omgevingen, caches, lokale databases, logs, media en back-ups uit. In de ZIP staat `PROJECT_MANIFEST.json` met grootte en SHA-256 per bestand.
|
||||
|
||||
## Kwaliteitsgates
|
||||
|
||||
```bash
|
||||
./scripts/codex_verify.sh
|
||||
```
|
||||
|
||||
De gate vereist:
|
||||
|
||||
- Ruff zonder bevindingen;
|
||||
- een geldige Django-configuratie;
|
||||
- geen ontbrekende migraties;
|
||||
- alle tests geslaagd;
|
||||
- minimaal 70% branch-aware codedekking;
|
||||
- een geldige machineleesbare backlog.
|
||||
|
||||
## Repositorykaart
|
||||
|
||||
```text
|
||||
apps/ Django-domeinmodules
|
||||
core/ dashboard, health en beveiligingsheaders
|
||||
profiles/ zoekprofielen en revisies
|
||||
sources/ bronbeleid, fetcher, adapters en IMAP
|
||||
jobs/ normalisatie, dedupe, scoring en sollicitaties
|
||||
notifications/ digest-outbox en verzending
|
||||
config/ Django/Celery-configuratie
|
||||
config-data/ voorbeeldprofielen en bronbeleid
|
||||
fixtures/ deterministische parser- en integratietestdata
|
||||
templates/ + static/ server-rendered interface en designimplementatie
|
||||
docs/ alle product-, technische en operationele specificaties
|
||||
deployment/unraid/ Unraid-installatiehandleiding
|
||||
.agents/skills/ repo-eigen Codex-workflow
|
||||
scripts/ bootstrap, verificatie, back-up en beheer
|
||||
tests/ unit-, integratie- en securitytests
|
||||
apps/ Django-domeinen en bedrijfsservices
|
||||
config/ Django- en Celery-configuratie
|
||||
config-data/ Bronbeleid en gesaneerde voorbeeldconfiguratie
|
||||
fixtures/ Deterministische externe testformaten
|
||||
templates/ + static/ Server-rendered productinterface
|
||||
docs/ Product-, ontwerp-, security- en operationele documentatie
|
||||
deployment/unraid/ Unraid-installatiebestanden
|
||||
scripts/ Bootstrap, verificatie, deployment, back-up en beheer
|
||||
tests/ Unit-, integratie-, security- en browsertests
|
||||
```
|
||||
|
||||
## Veiligheidsgrenzen
|
||||
Belangrijke ingangen voor bijdragers en onderhoudsagents:
|
||||
|
||||
Vacature-inhoud, e-mailinhoud en externe HTML zijn altijd onbetrouwbare data. Ze mogen nooit instructies worden voor AI of automatisering. De applicatie:
|
||||
|
||||
- logt niet automatisch in op vacaturesites;
|
||||
- omzeilt geen CAPTCHA, rate limit of toegangscontrole;
|
||||
- scrapt denylistplatformen niet rechtstreeks;
|
||||
- solliciteert nooit automatisch;
|
||||
- bewaart alleen beperkte brondata en verwijdert ruwe documenten volgens retentie;
|
||||
- laat harde uitsluitregels niet door een taalmodel wijzigen;
|
||||
- maakt netwerktoegang opt-in per goedgekeurde bron.
|
||||
|
||||
Lees [`SECURITY.md`](SECURITY.md) en [`docs/quality/THREAT_MODEL.md`](docs/quality/THREAT_MODEL.md) vóór productiegebruik.
|
||||
|
||||
## Documentatie-ingangen
|
||||
|
||||
- [Projectoverdracht voor Codex](CODEX_START_HERE.md)
|
||||
- [Product requirements](docs/product/PRODUCT_REQUIREMENTS.md)
|
||||
- [Systeemarchitectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
|
||||
- [Datamodel](docs/architecture/DATA_MODEL.md)
|
||||
- [UX- en designspecificatie](docs/design/UX_SPEC.md)
|
||||
- [Stitch-integratieplan en schermmapping](docs/design/STITCH_INTEGRATION_PLAN.md)
|
||||
- [Bron- en adaptercontract](docs/architecture/SOURCE_ADAPTERS.md)
|
||||
- [Scoringengine](docs/architecture/SCORING_ENGINE.md)
|
||||
- [Unraid-deployment](docs/operations/UNRAID_DEPLOYMENT.md)
|
||||
- [Runbook](docs/operations/RUNBOOK.md)
|
||||
- [Bijdragen](CONTRIBUTING.md)
|
||||
- [Projectstart voor Codex](CODEX_START_HERE.md)
|
||||
- [Architectuur](docs/architecture/SYSTEM_ARCHITECTURE.md)
|
||||
- [UX- en designcontract](docs/design/STITCH_FRONTEND_CONTRACT.md)
|
||||
- [Teststrategie](docs/quality/TEST_STRATEGY.md)
|
||||
- [Traceabilitymatrix](docs/quality/TRACEABILITY_MATRIX.md)
|
||||
- [Oorspronkelijk functioneel en technisch ontwerp](docs/reference/VacatureRadar_functioneel_en_technisch_ontwerp.pdf)
|
||||
- [Verificatierapport van de aangeleverde basis](VERIFICATION_REPORT.md)
|
||||
- [Actuele projectstatus](docs/ai/PROJECT_STATE.md)
|
||||
- [Machineleesbare backlog](docs/ai/BACKLOG.yaml)
|
||||
|
||||
## Licentie
|
||||
|
||||
MIT voor de broncode. Controleer voor iedere externe vacaturebron afzonderlijk de voorwaarden, robotsregels, databankrechten en toepasselijke wetgeving. Dit project levert technisch bronbeleid, maar geen juridisch advies.
|
||||
De broncode valt onder de [MIT-licentie](LICENSE). Controleer voor iedere externe vacaturebron afzonderlijk de voorwaarden, robotsregels, databankrechten en toepasselijke wetgeving. Het project levert technisch bronbeleid, geen juridisch advies.
|
||||
|
||||
@@ -30,11 +30,16 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea
|
||||
- minimale ruwe-dataretentie;
|
||||
- geen secrets in logs of databasefixtures;
|
||||
- AI uitgeschakeld als veilige standaard;
|
||||
- menselijke bevestiging voor iedere sollicitatie.
|
||||
- menselijke bevestiging voor iedere sollicitatie;
|
||||
- een publieke URL die exact in host- en CSRF-validatie zit;
|
||||
- `DEBUG=False`, secure cookies en correcte proxy-HTTPS-detectie;
|
||||
- forwarded client-IPheaders alleen vanaf expliciet vertrouwde proxy-CIDR's;
|
||||
- een gedeeld gastaccount uitsluitend in server-side read-onlymodus.
|
||||
|
||||
## Kwetsbaarheid melden
|
||||
|
||||
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documenteer intern:
|
||||
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Meld een
|
||||
kwetsbaarheid privé via `security@itworx.tech` en vermeld:
|
||||
|
||||
1. component en versie/commit;
|
||||
2. reproduceerbare stappen met synthetische data;
|
||||
@@ -42,7 +47,7 @@ Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documente
|
||||
4. voorgestelde mitigatie;
|
||||
5. eventuele rotatie- of verwijderacties.
|
||||
|
||||
Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen.
|
||||
Voor een persoonlijke installatie moet de eigenaar vervolgens de betrokken credentials roteren, logs controleren en de reparatie uitrollen. Publieke reverse-proxyproblemen worden behandeld volgens `docs/operations/NGINX_PROXY_MANAGER.md`.
|
||||
|
||||
## Geen securityclaims
|
||||
|
||||
|
||||
@@ -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
|
||||
- Omgeving: Linux, Python 3.13.5, uv 0.10.0
|
||||
- Projectversie: 0.1.0
|
||||
- Doel: aantonen dat de ZIP een reproduceerbare, werkende MVP-basis en autonome overdracht bevat
|
||||
- Datum: 27 juli 2026
|
||||
- Onderwerp: productiehardening, Nginx Proxy Manager, gastmodus en overdrachts-ZIP
|
||||
- Branch: `fix/public-production-hardening`
|
||||
|
||||
## Uitgevoerde volledige gate
|
||||
## Gecontroleerd
|
||||
|
||||
Commando:
|
||||
### Applicatie en regressies
|
||||
|
||||
```bash
|
||||
./scripts/codex_verify.sh
|
||||
```text
|
||||
296 tests geslaagd
|
||||
2 optionele Playwrighttests overgeslagen omdat de browserbinary niet lokaal aanwezig was
|
||||
83,43% branch-aware codedekking; vereiste drempel 70%
|
||||
```
|
||||
|
||||
Resultaat:
|
||||
De afzonderlijke HTML- en accessibilityprobe bleef actief. De Playwrightfallback is hersteld zodat een geïnstalleerde Pythonmodule zonder browserbinary geen synchrone databasecall meer binnen de Playwright-eventloop uitvoert.
|
||||
|
||||
- Ruff: geslaagd;
|
||||
- Django system check: geslaagd, 0 issues;
|
||||
- migratiecontrole: geslaagd, geen ontbrekende migraties;
|
||||
- pytest: 43/43 tests geslaagd;
|
||||
- branch-aware codedekking: 80,42% (`apps` en `config`), drempel 70%;
|
||||
- machineleesbare backlog: geldig, 35 taken en acyclische dependencies;
|
||||
- lokale Markdownlinks, YAML/Compose/OpenAPI, `.env.example` en Codex-skillfrontmatter: geldig.
|
||||
### Django en data
|
||||
|
||||
De tests omvatten unit-, integratie- en securitypaden voor parsers, URL-security, fetchbeleid, sanitization, normalisatie, dedupe, scoring, profiles, e-mailimport, notificaties, lifecycle, tasks en webviews.
|
||||
- `python manage.py check`: 0 issues;
|
||||
- `python manage.py makemigrations --check --dry-run`: geen wijzigingen;
|
||||
- productie-settings met `PUBLIC_BASE_URL=https://vacatureradar.itworx.tech`: laden correct;
|
||||
- `manage.py check --deploy`: geen errors; HSTS-preload blijft bewust uit en geeft daarom alleen de verwachte Django-waarschuwing;
|
||||
- publieke host en HTTPS-origin worden aantoonbaar aan `ALLOWED_HOSTS` en `CSRF_TRUSTED_ORIGINS` toegevoegd.
|
||||
|
||||
## Clean-room ZIP-proef
|
||||
### Repository en deployment
|
||||
|
||||
De gegenereerde ZIP is in een lege directory uitgepakt. Voor de start is gecontroleerd dat `.env`, `.venv` en `local/db.sqlite3` niet aanwezig waren. Daarna is uitgevoerd:
|
||||
- repositoryvalidator: geslaagd;
|
||||
- taakledger: geldig, 64 taken, 0 uitvoerbare ready-taken;
|
||||
- Docker Compose, Unraid Compose en backlog-YAML: parseerbaar;
|
||||
- shellsyntax van deploy- en entrypoints: geldig;
|
||||
- Pythoncompilecheck: geslaagd;
|
||||
- `git diff --check`: geslaagd;
|
||||
- packagevalidatie en manifestcontrole: geslaagd na definitieve packaging.
|
||||
|
||||
```bash
|
||||
./scripts/codex_bootstrap.sh
|
||||
```
|
||||
## Belangrijkste herstelde productieproblemen
|
||||
|
||||
Resultaat:
|
||||
- `DisallowedHost` voor de publieke Nginx-host;
|
||||
- publieke Django-debugmodus;
|
||||
- verkeerde externe CSRF-origin met interne poort `1226`;
|
||||
- healthchecks die door HTTPS-redirect konden falen;
|
||||
- proceslokale rate limiting bij meerdere Gunicornworkers;
|
||||
- onvoorwaardelijk vertrouwen in `X-Forwarded-For`;
|
||||
- schrijfbare gedeelde demo;
|
||||
- afwijkende Unraid `.env`-paden;
|
||||
- database-URL-interpolatie buiten het Unraid-`env_file`;
|
||||
- URL-import die zonder staticfilesmanifest kon falen tijdens deployment checks;
|
||||
- stale project-/assetversie en verouderd verificatierapport.
|
||||
|
||||
- nieuwe virtuele omgeving uit `uv.lock` opgebouwd;
|
||||
- alle migraties op een lege SQLite-database toegepast;
|
||||
- lokaal beheeraccount, standaardprofiel en één demovacature aangemaakt;
|
||||
- volledige kwaliteitsgate opnieuw geslaagd met 43 tests en 80,42% dekking;
|
||||
- ontwikkelserver gestart;
|
||||
- `/health/live/` gaf `200` met `ok: true`;
|
||||
- `/health/ready/` gaf `200` met databasecheck `ok`;
|
||||
- `/login/` gaf `200` met de Nederlandstalige titel `Aanmelden · VacatureRadar`.
|
||||
## Omgevingsbeperking
|
||||
|
||||
## Offline/reproduceerbare eigenschappen
|
||||
De normale `uv sync --frozen` kon in deze uitvoeringsomgeving geen packages downloaden door DNS-uitval. Voor de regressies zijn de pure Python-packages uit de door de gebruiker aangeleverde gelockte Windows-omgeving gecombineerd met reeds aanwezige Linux-wheels. Daardoor konden Django, pytest en de volledige offline suite betrouwbaar draaien.
|
||||
|
||||
- De standaardtests doen geen live internetrequests.
|
||||
- IMAP en Ollama staan standaard uit.
|
||||
- Celery draait in tests eager en vereist geen Redis.
|
||||
- SQLite wordt voor lokale bootstrap/tests gebruikt; productieconfiguratie ondersteunt PostgreSQL.
|
||||
- Externe bronformaten worden via gesaneerde fixtures getest.
|
||||
- `.venv`, caches, `.env`, lokale database, logs, media en back-ups worden door het packaging-script uitgesloten.
|
||||
De Ruff-Linuxbinary en Playwright-browser konden zonder netwerk niet worden geïnstalleerd. Pythoncompile, alle tests, Djangochecks, lijnlengtecontrole op gewijzigde Pythonbestanden en `git diff --check` zijn wel uitgevoerd. Een Dockerdaemon en de live Unraid/Nginx-omgeving waren niet beschikbaar.
|
||||
|
||||
## Autonome overdracht
|
||||
## Nog op de doelserver uitvoeren
|
||||
|
||||
Gevalideerd:
|
||||
1. bestaande `.env` back-uppen;
|
||||
2. `scripts/configure_public_url.py` uitvoeren;
|
||||
3. container met `--force-recreate` herbouwen;
|
||||
4. interne liveness/readiness controleren;
|
||||
5. publieke TLS-, login-, logout- en CSRF-smoke uitvoeren;
|
||||
6. demo-read-only controleren;
|
||||
7. exacte Nginx Proxy Manager-CIDR instellen wanneer individuele client-IP-rate-limiting gewenst is;
|
||||
8. databaseback-up en herstel-smoke uitvoeren.
|
||||
|
||||
```bash
|
||||
python scripts/backlog.py validate
|
||||
python scripts/backlog.py next
|
||||
```
|
||||
|
||||
Ledgerstatus bij oplevering:
|
||||
|
||||
- 10 taken `done`: de werkende MVP- en overdrachtsbasis;
|
||||
- 17 taken `ready`: uitvoerbare product-, hardening- en releasestappen;
|
||||
- 5 taken `blocked-external`: uitsluitend live bronreview, accounts, domein/registry of bulkdataset;
|
||||
- 3 taken `deferred`: multi-tenant SaaS, automatisch solliciteren en algemene publieke API.
|
||||
|
||||
Codex krijgt vaste productbeslissingen via `AGENTS.md`, `CODEX_START_HERE.md`, accepted ADRs, een repo-eigen skill en een startprompt. Een externe blokkade mag alleen het live restpunt blokkeren; interface, tests, mocks en documentatie moeten eerst worden voltooid.
|
||||
|
||||
## Niet in deze omgeving uitgevoerd
|
||||
|
||||
Docker was in de assemblageomgeving niet beschikbaar. Daarom zijn de Dockerfile en Composebestanden statisch gevalideerd, maar een echte imagebuild, PostgreSQL/Redis-container-smoke, Unraid-installatie en restore-drill worden niet als uitgevoerd geclaimd. `VR-115` en `VR-117` bevatten de exacte acceptatiecriteria en commando's voor een Docker-capabele omgeving.
|
||||
|
||||
Live bronvoorwaarden, mailboxcredentials, SMTP, domein/TLS, registry en een gelicenseerde Belgische geodatabron vereisen persoonlijke/externe configuratie en staan apart als `blocked-external`.
|
||||
|
||||
## VR-118 — Stitch Intelligence Cockpit (2026-07-21)
|
||||
|
||||
Aanvullend op de bestaande releasebasis is de volledige interface opnieuw geïntegreerd en gecontroleerd.
|
||||
|
||||
Uitgevoerde browsermatrix:
|
||||
|
||||
- 1440×900, 1280×800, 1024×768, 768×1024, 390×844 en 360×800;
|
||||
- exact één `h1` en geen documentbrede horizontale overflow op ieder formaat;
|
||||
- dashboard, vacaturelijst/detail, sollicitatiepipeline, profiel/editor, bronnen en systeem;
|
||||
- dark en light, mobiele open/sluitnavigatie, bron-disclosure en vacature → gesolliciteerd → pipeline;
|
||||
- geen externe productie-scripts of stylesheets en geen nieuwe consolefouten na de storagefallbackfix.
|
||||
|
||||
Screenshots: `docs/design/validation/`. Deze map en de originele Stitch-referentie zijn via `.dockerignore` uitgesloten van de productiecontainer.
|
||||
|
||||
Volledige afrondingsgate:
|
||||
|
||||
- Ruff: geslaagd;
|
||||
- Django system check: geslaagd;
|
||||
- migratiecontrole: geen wijzigingen;
|
||||
- pytest: 146 geslaagd, 2 optionele Playwrightvarianten overgeslagen met geslaagde HTML/a11y-fallback;
|
||||
- branch-aware codedekking: 82,00%, boven de vereiste 70%;
|
||||
- taakledger: 36 taken, waarvan 28 done, 5 blocked-external, 3 deferred en 0 ready;
|
||||
- repositorydocumentatie en configuratie: geldig.
|
||||
Zie `docs/operations/NGINX_PROXY_MANAGER.md` en `docs/quality/PRODUCTION_AUDIT_2026-07-27.md`.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Authenticatie-backends voor VacatureRadar.
|
||||
|
||||
Sta inloggen toe met e-mailadres *of* gebruikersnaam, zodat het Stitch-loginscherm
|
||||
(dat om een e-mailadres vraagt) blijft werken zonder het bestaande admin-login op
|
||||
gebruikersnaam te breken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.backends import ModelBackend
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class EmailOrUsernameModelBackend(ModelBackend):
|
||||
"""Zoekt de gebruiker op via gebruikersnaam of (case-insensitief) e-mailadres."""
|
||||
|
||||
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||
user_model = get_user_model()
|
||||
if username is None:
|
||||
username = kwargs.get(user_model.USERNAME_FIELD)
|
||||
if username is None or password is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = user_model.objects.get(
|
||||
Q(**{f"{user_model.USERNAME_FIELD}__iexact": username}) | Q(email__iexact=username)
|
||||
)
|
||||
except user_model.DoesNotExist:
|
||||
# Draai toch een hash om timing-aanvallen (user-enumeratie) te beperken.
|
||||
user_model().set_password(password)
|
||||
return None
|
||||
except user_model.MultipleObjectsReturned:
|
||||
# Meerdere accounts met dezelfde e-mail: val terug op exacte username-match.
|
||||
user = (
|
||||
user_model.objects.filter(**{f"{user_model.USERNAME_FIELD}__iexact": username})
|
||||
.order_by("id")
|
||||
.first()
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if user.check_password(password) and self.user_can_authenticate(user):
|
||||
return user
|
||||
return None
|
||||
@@ -2,15 +2,35 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
|
||||
def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
||||
match = request.resolver_match
|
||||
route_key = (match.namespace, match.url_name) if match else (None, None)
|
||||
page_contexts = {
|
||||
("dashboard", "today"): "Dagoverzicht",
|
||||
("dashboard", "system"): "Automatisering",
|
||||
("jobs", "list"): "Vacatureverkenner",
|
||||
("jobs", "detail"): "Vacatureanalyse",
|
||||
("jobs", "applications"): "Sollicitatiepipeline",
|
||||
("jobs", "application-edit"): "Sollicitatiedossier",
|
||||
("jobs", "skill-insights"): "Skillsradar",
|
||||
("jobs", "activity"): "Activiteit",
|
||||
("jobs", "employers"): "Werkgevers",
|
||||
("jobs", "employer-detail"): "Werkgeveranalyse",
|
||||
("profiles", "list"): "Zoekprofiel",
|
||||
("profiles", "edit"): "Radarconfiguratie",
|
||||
("sources", "list"): "Bronbeheer",
|
||||
}
|
||||
context = {
|
||||
"app_name": "VacatureRadar",
|
||||
"app_version": "0.2.2",
|
||||
"app_version": settings.VACATURERADAR_VERSION,
|
||||
"app_owner_name": settings.VACATURERADAR_OWNER_NAME,
|
||||
"page_context_label": page_contexts.get(route_key, "Persoonlijke cockpit"),
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
context["navigation_profile"] = (
|
||||
@@ -18,4 +38,8 @@ def navigation_context(request: HttpRequest) -> dict[str, Any]:
|
||||
.only("id", "name")
|
||||
.first()
|
||||
)
|
||||
context["demo_read_only"] = bool(
|
||||
getattr(settings, "DEMO_READ_ONLY", True)
|
||||
and request.user.username == settings.DEMO_USERNAME
|
||||
)
|
||||
return context
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Microsoft Entra ID (Azure AD) SSO via de OAuth2 authorization-code flow.
|
||||
|
||||
De ``msal``-bibliotheek wordt bewust *lazy* geïmporteerd: zolang SSO uit staat
|
||||
(``ENTRA_ID_ENABLED=0``) hoeft het pakket niet geïnstalleerd te zijn en blijven de
|
||||
bestaande tests en de kernapplicatie volledig werken.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class EntraConfigError(RuntimeError):
|
||||
"""Entra ID is ingeschakeld maar niet (volledig) geconfigureerd."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntraConfig:
|
||||
client_id: str
|
||||
client_secret: str
|
||||
tenant_id: str
|
||||
scopes: list[str]
|
||||
allowed_domains: tuple[str, ...]
|
||||
redirect_uri: str | None
|
||||
|
||||
@property
|
||||
def authority(self) -> str:
|
||||
return f"https://login.microsoftonline.com/{self.tenant_id}"
|
||||
|
||||
|
||||
def entra_enabled() -> bool:
|
||||
return bool(getattr(settings, "ENTRA_ID_ENABLED", False))
|
||||
|
||||
|
||||
def load_config() -> EntraConfig:
|
||||
client_id = getattr(settings, "ENTRA_CLIENT_ID", "") or ""
|
||||
client_secret = getattr(settings, "ENTRA_CLIENT_SECRET", "") or ""
|
||||
tenant_id = getattr(settings, "ENTRA_TENANT_ID", "") or ""
|
||||
if not (client_id and client_secret and tenant_id):
|
||||
raise EntraConfigError(
|
||||
"Entra ID staat aan maar ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET en "
|
||||
"ENTRA_TENANT_ID zijn niet allemaal ingesteld."
|
||||
)
|
||||
scopes = list(getattr(settings, "ENTRA_SCOPES", ["User.Read"]))
|
||||
allowed = tuple(
|
||||
d.lower().lstrip("@") for d in getattr(settings, "ENTRA_ALLOWED_DOMAINS", []) if d
|
||||
)
|
||||
return EntraConfig(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_id=tenant_id,
|
||||
scopes=scopes,
|
||||
allowed_domains=allowed,
|
||||
redirect_uri=getattr(settings, "ENTRA_REDIRECT_URI", "") or None,
|
||||
)
|
||||
|
||||
|
||||
def _build_app(config: EntraConfig):
|
||||
try:
|
||||
import msal # lazy import: alleen nodig als SSO aan staat
|
||||
except ModuleNotFoundError as exc: # pragma: no cover - hangt van omgeving af
|
||||
raise EntraConfigError(
|
||||
"Het pakket 'msal' is niet geïnstalleerd. Voeg het toe (zie pyproject.toml) "
|
||||
"om Entra ID SSO te gebruiken."
|
||||
) from exc
|
||||
|
||||
return msal.ConfidentialClientApplication(
|
||||
client_id=config.client_id,
|
||||
client_credential=config.client_secret,
|
||||
authority=config.authority,
|
||||
)
|
||||
|
||||
|
||||
def build_auth_flow(config: EntraConfig, redirect_uri: str) -> dict:
|
||||
"""Start de authorization-code flow en geef het flow-dict terug (bewaar in sessie)."""
|
||||
app = _build_app(config)
|
||||
return app.initiate_auth_code_flow(config.scopes, redirect_uri=redirect_uri)
|
||||
|
||||
|
||||
def redeem_auth_code(config: EntraConfig, flow: dict, auth_response: dict) -> dict:
|
||||
"""Wissel de teruggekeerde code in voor tokens en claims."""
|
||||
app = _build_app(config)
|
||||
return app.acquire_token_by_auth_code_flow(flow, auth_response)
|
||||
|
||||
|
||||
def extract_identity(token_result: dict) -> tuple[str, str]:
|
||||
"""Haal (e-mailadres, weergavenaam) uit de id-token-claims."""
|
||||
claims = token_result.get("id_token_claims") or {}
|
||||
email = (
|
||||
claims.get("preferred_username") or claims.get("email") or claims.get("upn") or ""
|
||||
).strip()
|
||||
name = (claims.get("name") or "").strip()
|
||||
return email, name
|
||||
|
||||
|
||||
def domain_allowed(config: EntraConfig, email: str) -> bool:
|
||||
if not config.allowed_domains:
|
||||
return True
|
||||
domain = email.rsplit("@", 1)[-1].lower()
|
||||
return domain in config.allowed_domains
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Formulieren voor authenticatie."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class EmailAuthenticationForm(AuthenticationForm):
|
||||
"""Loginformulier dat het ``username``-veld presenteert als e-mailadres.
|
||||
|
||||
Het veld blijft ``username`` heten zodat ``LoginView`` en de authenticatie-backend
|
||||
onveranderd werken; alleen label, invoertype en foutmelding zijn aangepast.
|
||||
"""
|
||||
|
||||
error_messages = {
|
||||
**AuthenticationForm.error_messages,
|
||||
"invalid_login": _("E-mailadres of wachtwoord klopt niet."),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
field = self.fields["username"]
|
||||
field.label = _("E-mailadres")
|
||||
field.widget.attrs.update(
|
||||
{
|
||||
"type": "email",
|
||||
"autocomplete": "username",
|
||||
"autocapitalize": "none",
|
||||
"spellcheck": "false",
|
||||
"placeholder": "naam@organisatie.nl",
|
||||
}
|
||||
)
|
||||
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import connections
|
||||
from django.db.utils import OperationalError
|
||||
from django.db.utils import DatabaseError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -23,7 +26,20 @@ def readiness() -> HealthResult:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
checks["database"] = "ok"
|
||||
except OperationalError as exc:
|
||||
except DatabaseError as exc:
|
||||
ok = False
|
||||
checks["database"] = f"error:{exc.__class__.__name__}"
|
||||
|
||||
if getattr(settings, "HEALTHCHECK_REQUIRE_CACHE", False):
|
||||
key = f"health:{secrets.token_hex(8)}"
|
||||
try:
|
||||
cache.set(key, "ok", timeout=10)
|
||||
if cache.get(key) != "ok":
|
||||
raise RuntimeError("cache roundtrip failed")
|
||||
cache.delete(key)
|
||||
checks["cache"] = "ok"
|
||||
except Exception as exc: # Health boundary: report category, never payload/details.
|
||||
ok = False
|
||||
checks["cache"] = f"error:{exc.__class__.__name__}"
|
||||
|
||||
return HealthResult(ok=ok, checks=checks)
|
||||
|
||||
@@ -2,11 +2,86 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
from scripts.migration_worker_gate import hold_active
|
||||
|
||||
|
||||
class MigrationMaintenanceMiddleware:
|
||||
"""Block candidate traffic before durable migration activation, including GET writes."""
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
try:
|
||||
held = hold_active()
|
||||
except (OSError, RuntimeError):
|
||||
held = True
|
||||
health = request.method in {"GET", "HEAD"} and request.path_info in {
|
||||
"/health/ready/", "/health/live/",
|
||||
}
|
||||
if held and not health:
|
||||
response = JsonResponse({"error": "migration_maintenance"}, status=503)
|
||||
response["Retry-After"] = "30"
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class DemoReadOnlyMiddleware:
|
||||
"""Prevent a shared public demo account from mutating application data."""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
user = getattr(request, "user", None)
|
||||
is_demo = bool(
|
||||
getattr(settings, "DEMO_READ_ONLY", True)
|
||||
and user
|
||||
and getattr(user, "is_authenticated", False)
|
||||
and getattr(user, "username", "") == settings.DEMO_USERNAME
|
||||
)
|
||||
if not is_demo or request.method in self.SAFE_METHODS:
|
||||
return self.get_response(request)
|
||||
|
||||
if request.path_info == reverse("logout"):
|
||||
return self.get_response(request)
|
||||
|
||||
if "application/json" in request.headers.get("Accept", ""):
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "demo_read_only",
|
||||
"message": "De publieke demo is alleen-lezen.",
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
|
||||
messages.warning(
|
||||
request,
|
||||
"De publieke demo is alleen-lezen; wijzigingen zijn uitgeschakeld.",
|
||||
)
|
||||
referer = request.META.get("HTTP_REFERER", "")
|
||||
if referer and url_has_allowed_host_and_scheme(
|
||||
referer,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return redirect(referer)
|
||||
return redirect("dashboard:today")
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Kleine CSP zonder externe assets; vacature-HTML wordt bovendien gesanitized."""
|
||||
"""Small CSP without external assets; vacancy HTML is sanitized separately."""
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
|
||||
self.get_response = get_response
|
||||
@@ -33,4 +108,6 @@ class SecurityHeadersMiddleware:
|
||||
"Permissions-Policy", "camera=(), microphone=(), geolocation=()"
|
||||
)
|
||||
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if not getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
|
||||
response.headers.setdefault("X-Robots-Tag", "noindex, nofollow, noarchive")
|
||||
return response
|
||||
|
||||
@@ -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.utils import timezone
|
||||
|
||||
|
||||
def _client_ip(request: HttpRequest) -> str:
|
||||
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR", "").strip()
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
return request.META.get("REMOTE_ADDR", "unknown").strip() or "unknown"
|
||||
from apps.core.network import client_ip
|
||||
|
||||
|
||||
def _identity_for_user(request: HttpRequest) -> str:
|
||||
@@ -21,7 +16,7 @@ def _identity_for_user(request: HttpRequest) -> str:
|
||||
username = (
|
||||
(request.POST.get("username", "") if request.method == "POST" else "").strip().lower()
|
||||
)
|
||||
return f"anon:{username or _client_ip(request)}"
|
||||
return f"anon:{username or client_ip(request)}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Read services for the application cockpit."""
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
from apps.jobs.services.relevance import it_relevance_query
|
||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source, SourceRun
|
||||
|
||||
from ..health import readiness
|
||||
from ..tasks import RUNTIME_HEARTBEAT_KEY
|
||||
|
||||
|
||||
def _runtime_heartbeat_state() -> dict[str, Any]:
|
||||
if settings.CELERY_TASK_ALWAYS_EAGER:
|
||||
return {"label": "Eager/lokaal", "healthy": True, "observed_at": None}
|
||||
|
||||
raw_timestamp = cache.get(RUNTIME_HEARTBEAT_KEY)
|
||||
if not raw_timestamp:
|
||||
return {"label": "Geen recente heartbeat", "healthy": False, "observed_at": None}
|
||||
|
||||
try:
|
||||
observed_at = timezone.datetime.fromisoformat(str(raw_timestamp))
|
||||
if timezone.is_naive(observed_at):
|
||||
observed_at = timezone.make_aware(observed_at)
|
||||
except (TypeError, ValueError):
|
||||
return {"label": "Ongeldige heartbeat", "healthy": False, "observed_at": None}
|
||||
|
||||
age_seconds = max(0, int((timezone.now() - observed_at).total_seconds()))
|
||||
return {
|
||||
"label": f"Actief · {age_seconds}s geleden"
|
||||
if age_seconds <= 150
|
||||
else "Heartbeat verouderd",
|
||||
"healthy": age_seconds <= 150,
|
||||
"observed_at": observed_at,
|
||||
}
|
||||
|
||||
|
||||
def build_automation_cockpit() -> dict[str, Any]:
|
||||
"""Build an honest operational read model without probing unsafe hidden endpoints."""
|
||||
|
||||
since = timezone.now() - timedelta(hours=24)
|
||||
recent_runs = SourceRun.objects.filter(started_at__gte=since)
|
||||
run_metrics = recent_runs.aggregate(
|
||||
discovered=Sum("discovered_count"),
|
||||
extracted=Sum("extracted_count"),
|
||||
created=Sum("created_count"),
|
||||
updated=Sum("updated_count"),
|
||||
duplicates=Sum("duplicate_count"),
|
||||
failures=Count("id", filter=Q(status=SourceRun.Status.FAILED)),
|
||||
)
|
||||
notification_counts = {
|
||||
"pending": DigestOutbox.objects.filter(status=DigestOutbox.Status.PENDING).count()
|
||||
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.PENDING).count(),
|
||||
"failed": DigestOutbox.objects.filter(status=DigestOutbox.Status.FAILED).count()
|
||||
+ ReminderOutbox.objects.filter(status=ReminderOutbox.Status.FAILED).count(),
|
||||
"sent_24h": DigestOutbox.objects.filter(
|
||||
status=DigestOutbox.Status.SENT, sent_at__gte=since
|
||||
).count()
|
||||
+ ReminderOutbox.objects.filter(
|
||||
status=ReminderOutbox.Status.SENT, sent_at__gte=since
|
||||
).count(),
|
||||
}
|
||||
source_total = Source.objects.count()
|
||||
source_active = Source.objects.filter(status=Source.Status.ACTIVE).count()
|
||||
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||
phase_rows = [
|
||||
{"key": "discover", "label": "Ontdekken", "value": source_total, "unit": "bronnen"},
|
||||
{"key": "fetch", "label": "Ophalen", "value": recent_runs.count(), "unit": "runs / 24u"},
|
||||
{
|
||||
"key": "extract",
|
||||
"label": "Extraheren",
|
||||
"value": run_metrics["extracted"] or 0,
|
||||
"unit": "items",
|
||||
},
|
||||
{
|
||||
"key": "normalize",
|
||||
"label": "Normaliseren",
|
||||
"value": (run_metrics["created"] or 0) + (run_metrics["updated"] or 0),
|
||||
"unit": "records",
|
||||
},
|
||||
{
|
||||
"key": "dedupe",
|
||||
"label": "Dedupliceren",
|
||||
"value": run_metrics["duplicates"] or 0,
|
||||
"unit": "herkend",
|
||||
},
|
||||
{
|
||||
"key": "filter",
|
||||
"label": "IT-filter",
|
||||
"value": active_jobs.exclude(it_relevance_query()).count(),
|
||||
"unit": "uit beeld",
|
||||
},
|
||||
{
|
||||
"key": "analyze",
|
||||
"label": "Analyseren",
|
||||
"value": ScoreRun.objects.filter(created_at__gte=since).count(),
|
||||
"unit": "scores / 24u",
|
||||
},
|
||||
{
|
||||
"key": "notify",
|
||||
"label": "Notificeren",
|
||||
"value": notification_counts["sent_24h"],
|
||||
"unit": "verzonden / 24u",
|
||||
},
|
||||
]
|
||||
runtime_heartbeat = _runtime_heartbeat_state()
|
||||
return {
|
||||
"health": readiness(),
|
||||
"source_total": source_total,
|
||||
"source_active": source_active,
|
||||
"run_metrics": run_metrics,
|
||||
"run_count": recent_runs.count(),
|
||||
"active_jobs": active_jobs.filter(it_relevance_query()).count(),
|
||||
"review_jobs": ScoreRun.objects.filter(
|
||||
recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE],
|
||||
job__status=JobPosting.Status.ACTIVE,
|
||||
).count(),
|
||||
"notification_counts": notification_counts,
|
||||
"phase_rows": phase_rows,
|
||||
"recent_runs": list(
|
||||
SourceRun.objects.select_related("source").order_by("-started_at")[:10]
|
||||
),
|
||||
"source_rows": Source.objects.annotate(run_count=Count("runs")).order_by("name")[:12],
|
||||
"queue_state": "Eager/lokaal"
|
||||
if settings.CELERY_TASK_ALWAYS_EAGER
|
||||
else "Diepte niet beschikbaar",
|
||||
"runtime_heartbeat": runtime_heartbeat,
|
||||
"ai_state": "Geconfigureerd"
|
||||
if settings.OLLAMA_ENABLED and settings.OLLAMA_MODEL
|
||||
else "Niet geconfigureerd",
|
||||
"ai_profiles": SearchProfile.objects.filter(ai_scoring_enabled=True).count(),
|
||||
"browser_state": "Geen aparte browserextractor geconfigureerd",
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,24 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model, login
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.db.models import Count, Q
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from apps.core import entra as entra_sso
|
||||
from apps.core.forms import EmailAuthenticationForm
|
||||
from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure
|
||||
from apps.jobs.models import Application, JobPosting, ScoreRun
|
||||
from apps.sources.models import Source
|
||||
from apps.jobs.services.cockpit import build_dashboard_cockpit
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.services.health import collect_source_health
|
||||
|
||||
from .health import readiness
|
||||
from .services.automation import build_automation_cockpit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSO_BACKEND = "apps.core.auth_backends.EmailOrUsernameModelBackend"
|
||||
SESSION_ENTRA_FLOW = "entra_auth_flow"
|
||||
SESSION_ENTRA_NEXT = "entra_next"
|
||||
|
||||
|
||||
def _safe_next(request, candidate: str | None) -> str | None:
|
||||
if candidate and url_has_allowed_host_and_scheme(
|
||||
candidate,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
class SecurityAwareLoginView(LoginView):
|
||||
template_name = "registration/login.html"
|
||||
authentication_form = EmailAuthenticationForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["entra_enabled"] = entra_sso.entra_enabled()
|
||||
context["demo_enabled"] = bool(getattr(settings, "DEMO_MODE_ENABLED", False))
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _remaining_minutes(seconds: int) -> str:
|
||||
@@ -69,6 +100,147 @@ class SecurityAwareLoginView(LoginView):
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
def _split_name(full_name: str) -> tuple[str, str]:
|
||||
parts = full_name.split()
|
||||
if not parts:
|
||||
return "", ""
|
||||
if len(parts) == 1:
|
||||
return parts[0][:150], ""
|
||||
return parts[0][:150], " ".join(parts[1:])[:150]
|
||||
|
||||
|
||||
def _unique_username(base: str) -> str:
|
||||
user_model = get_user_model()
|
||||
base = (base or "gebruiker")[:140].strip() or "gebruiker"
|
||||
candidate = base
|
||||
suffix = 1
|
||||
while user_model.objects.filter(username__iexact=candidate).exists():
|
||||
suffix += 1
|
||||
candidate = f"{base}-{suffix}"
|
||||
return candidate
|
||||
|
||||
|
||||
def _get_or_create_sso_user(email: str, full_name: str):
|
||||
user_model = get_user_model()
|
||||
user = user_model.objects.filter(email__iexact=email).order_by("id").first()
|
||||
first, last = _split_name(full_name)
|
||||
if user is None:
|
||||
user = user_model(
|
||||
username=_unique_username(email.split("@", 1)[0]),
|
||||
email=email,
|
||||
first_name=first,
|
||||
last_name=last,
|
||||
)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
elif full_name and not (user.first_name or user.last_name):
|
||||
user.first_name, user.last_name = first, last
|
||||
user.save(update_fields=["first_name", "last_name"])
|
||||
return user
|
||||
|
||||
|
||||
@require_POST
|
||||
def entra_login(request):
|
||||
"""Start de Entra ID authorization-code flow."""
|
||||
if not entra_sso.entra_enabled():
|
||||
messages.error(request, "Inloggen via Entra ID is niet ingeschakeld.")
|
||||
return redirect("login")
|
||||
try:
|
||||
config = entra_sso.load_config()
|
||||
redirect_uri = config.redirect_uri or request.build_absolute_uri(reverse("entra-callback"))
|
||||
flow = entra_sso.build_auth_flow(config, redirect_uri)
|
||||
except entra_sso.EntraConfigError as exc:
|
||||
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
|
||||
return redirect("login")
|
||||
|
||||
request.session[SESSION_ENTRA_FLOW] = flow
|
||||
next_url = _safe_next(request, request.POST.get("next"))
|
||||
if next_url:
|
||||
request.session[SESSION_ENTRA_NEXT] = next_url
|
||||
return redirect(flow["auth_uri"])
|
||||
|
||||
|
||||
def entra_callback(request):
|
||||
"""Verwerk de terugkeer van Microsoft na de SSO-flow."""
|
||||
if not entra_sso.entra_enabled():
|
||||
return redirect("login")
|
||||
|
||||
flow = request.session.pop(SESSION_ENTRA_FLOW, None)
|
||||
next_url = request.session.pop(SESSION_ENTRA_NEXT, None)
|
||||
if not flow:
|
||||
messages.error(request, "De Entra ID-sessie is verlopen. Probeer opnieuw.")
|
||||
return redirect("login")
|
||||
|
||||
try:
|
||||
config = entra_sso.load_config()
|
||||
result = entra_sso.redeem_auth_code(config, flow, request.GET.dict())
|
||||
except entra_sso.EntraConfigError as exc:
|
||||
logger.warning("Entra ID niet beschikbaar: %s", exc)
|
||||
messages.error(request, "Inloggen via Entra ID is nu niet beschikbaar.")
|
||||
return redirect("login")
|
||||
|
||||
if "error" in result:
|
||||
logger.warning("Entra ID-fout: %s", result.get("error_description") or result.get("error"))
|
||||
messages.error(request, "Inloggen via Entra ID is mislukt.")
|
||||
return redirect("login")
|
||||
|
||||
email, name = entra_sso.extract_identity(result)
|
||||
if not email:
|
||||
messages.error(request, "Entra ID gaf geen e-mailadres terug.")
|
||||
return redirect("login")
|
||||
if not entra_sso.domain_allowed(config, email):
|
||||
messages.error(request, "Dit Entra ID-account mag hier niet inloggen.")
|
||||
return redirect("login")
|
||||
|
||||
user = _get_or_create_sso_user(email, name)
|
||||
login(request, user, backend=SSO_BACKEND)
|
||||
clear_rate_limit(request, namespace="login")
|
||||
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
|
||||
@require_POST
|
||||
def demo_login(request):
|
||||
"""Log in als afgeschermd demo-account."""
|
||||
if not getattr(settings, "DEMO_MODE_ENABLED", False):
|
||||
messages.error(request, "De demomodus is niet ingeschakeld.")
|
||||
return redirect("login")
|
||||
|
||||
user_model = get_user_model()
|
||||
user, created = user_model.objects.get_or_create(
|
||||
username=settings.DEMO_USERNAME,
|
||||
defaults={
|
||||
"email": settings.DEMO_EMAIL,
|
||||
"first_name": "Demo",
|
||||
"last_name": "Gebruiker",
|
||||
},
|
||||
)
|
||||
if created:
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
|
||||
try:
|
||||
from apps.jobs.services.demo_seed import seed_demo_environment
|
||||
|
||||
seed_demo_environment(user)
|
||||
except Exception:
|
||||
# De demo mag nooit de login blokkeren.
|
||||
logger.exception("Kon de demo-omgeving niet seeden")
|
||||
|
||||
login(request, user, backend=SSO_BACKEND)
|
||||
request.session.set_expiry(settings.DEMO_SESSION_SECONDS)
|
||||
next_url = _safe_next(request, request.POST.get("next"))
|
||||
return redirect(next_url or settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
|
||||
def robots_txt(request):
|
||||
if getattr(settings, "SEARCH_ENGINE_INDEXING_ENABLED", False):
|
||||
content = "User-agent: *\nAllow: /\n"
|
||||
else:
|
||||
content = "User-agent: *\nDisallow: /\n"
|
||||
return HttpResponse(content, content_type="text/plain; charset=utf-8")
|
||||
|
||||
|
||||
def health_live(request):
|
||||
return JsonResponse({"ok": True, "service": "vacatureradar"})
|
||||
|
||||
@@ -83,44 +255,9 @@ class TodayView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
latest_scores = (
|
||||
ScoreRun.objects.select_related("job", "job__employer", "profile")
|
||||
.filter(job__status=JobPosting.Status.ACTIVE, hard_exclusions=[])
|
||||
.order_by("-score", "-created_at")
|
||||
)
|
||||
# Eén score per vacature, zonder PostgreSQL-specifieke DISTINCT ON.
|
||||
seen: set[str] = set()
|
||||
cards = []
|
||||
for score in latest_scores[:250]:
|
||||
key = str(score.job_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
cards.append(score)
|
||||
if len(cards) >= 20:
|
||||
break
|
||||
context.update(
|
||||
{
|
||||
"score_cards": cards,
|
||||
"priority_applications": Application.objects.filter(user=self.request.user)
|
||||
.exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN])
|
||||
.select_related("job", "job__employer")
|
||||
.order_by("follow_up_date", "-updated_at")[:3],
|
||||
"active_jobs": JobPosting.objects.filter(status=JobPosting.Status.ACTIVE).count(),
|
||||
"new_today": JobPosting.objects.filter(
|
||||
first_seen__date=timezone.localdate()
|
||||
).count(),
|
||||
"applications_open": Application.objects.exclude(
|
||||
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
|
||||
).count(),
|
||||
"source_counts": Source.objects.exclude(
|
||||
domain="jobs.example.org", status=Source.Status.DISABLED
|
||||
).aggregate(
|
||||
total=Count("id"),
|
||||
unhealthy=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
|
||||
),
|
||||
}
|
||||
)
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
context.update(build_dashboard_cockpit(self.request.user, profile))
|
||||
context["active_profile"] = profile
|
||||
return context
|
||||
|
||||
|
||||
@@ -129,8 +266,6 @@ class SystemStatusView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["health"] = readiness()
|
||||
context["source_summary"] = Source.objects.values("status").annotate(total=Count("id"))
|
||||
context["job_summary"] = JobPosting.objects.values("status").annotate(total=Count("id"))
|
||||
context.update(build_automation_cockpit())
|
||||
context["source_health"] = collect_source_health()
|
||||
return context
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.notifications.models import DigestOutbox, ReminderOutbox
|
||||
from apps.profiles.models import ProfileRevision
|
||||
from apps.sources.models import Source, SourceRun
|
||||
|
||||
from ..models import ApplicationTimelineEvent, Feedback, JobVersion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActivityEvent:
|
||||
occurred_at: datetime
|
||||
event_type: str
|
||||
title: str
|
||||
detail: str
|
||||
tone: str
|
||||
url: str
|
||||
object_label: str
|
||||
|
||||
|
||||
ACTIVITY_FILTERS = (
|
||||
("all", "Alle activiteit"),
|
||||
("source", "Bronruns"),
|
||||
("job", "Vacatures"),
|
||||
("application", "Sollicitaties"),
|
||||
("feedback", "Feedback"),
|
||||
("notification", "Notificaties"),
|
||||
("profile", "Profiel"),
|
||||
)
|
||||
|
||||
|
||||
def _source_events() -> list[ActivityEvent]:
|
||||
events = []
|
||||
runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||
for run in runs.select_related("source").order_by("-started_at")[:100]:
|
||||
detail = (
|
||||
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
|
||||
f"{run.duplicate_count} duplicaat"
|
||||
)
|
||||
if run.error_category:
|
||||
detail = f"{run.error_category}: {run.error_message or 'geen foutdetail'}"
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
run.started_at,
|
||||
"source",
|
||||
f"Bronrun {run.get_status_display().lower()}",
|
||||
detail,
|
||||
"positive" if run.status == SourceRun.Status.SUCCESS else "attention",
|
||||
reverse("sources:list") + f"#source-details-{run.source_id}",
|
||||
run.source.name,
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _job_events() -> list[ActivityEvent]:
|
||||
return [
|
||||
ActivityEvent(
|
||||
version.created_at,
|
||||
"job",
|
||||
"Vacature-inhoud gewijzigd" if version.changed_fields else "Vacatureversie opgeslagen",
|
||||
", ".join(version.changed_fields)
|
||||
if version.changed_fields
|
||||
else "Bronmoment vastgelegd",
|
||||
"neutral",
|
||||
reverse("jobs:detail", kwargs={"pk": version.job_id}),
|
||||
version.job.original_title,
|
||||
)
|
||||
for version in JobVersion.objects.select_related("job").order_by("-created_at")[:100]
|
||||
]
|
||||
|
||||
|
||||
def build_activity_page(
|
||||
*, user, event_type: str = "all", query: str = "", page: int | str = 1, per_page: int = 20
|
||||
) -> Page:
|
||||
"""Combine bounded trustworthy events into a user-safe read-only timeline."""
|
||||
|
||||
events: list[ActivityEvent] = []
|
||||
if event_type in {"all", "source"}:
|
||||
events.extend(_source_events())
|
||||
if event_type in {"all", "job"}:
|
||||
events.extend(_job_events())
|
||||
if event_type in {"all", "feedback"}:
|
||||
for item in (
|
||||
Feedback.objects.filter(user=user).select_related("job").order_by("-created_at")[:100]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"feedback",
|
||||
item.get_action_display(),
|
||||
item.reason or "Gebruikersactie opgeslagen",
|
||||
"neutral",
|
||||
reverse("jobs:detail", kwargs={"pk": item.job_id}),
|
||||
item.job.original_title,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "application"}:
|
||||
for item in (
|
||||
ApplicationTimelineEvent.objects.filter(user=user)
|
||||
.select_related("application__job")
|
||||
.order_by("-created_at")[:100]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"application",
|
||||
item.get_event_type_display(),
|
||||
"Sollicitatiedossier bijgewerkt",
|
||||
"positive" if item.event_type == item.EventType.STATUS_CHANGED else "neutral",
|
||||
reverse("jobs:application-edit", kwargs={"pk": item.application_id}),
|
||||
item.application.job.original_title,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "notification"}:
|
||||
for model, label in ((DigestOutbox, "Samenvatting"), (ReminderOutbox, "Herinnering")):
|
||||
for item in model.objects.filter(profile__user=user).order_by("-created_at")[:60]:
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"notification",
|
||||
f"{label} {item.get_status_display().lower()}",
|
||||
item.subject,
|
||||
"positive" if item.status == item.Status.SENT else "attention",
|
||||
reverse("dashboard:system"),
|
||||
label,
|
||||
)
|
||||
)
|
||||
if event_type in {"all", "profile"}:
|
||||
for item in (
|
||||
ProfileRevision.objects.filter(profile__user=user)
|
||||
.select_related("profile")
|
||||
.order_by("-created_at")[:60]
|
||||
):
|
||||
events.append(
|
||||
ActivityEvent(
|
||||
item.created_at,
|
||||
"profile",
|
||||
f"Zoekprofiel versie {item.version}",
|
||||
item.reason or "Profielconfiguratie opgeslagen",
|
||||
"neutral",
|
||||
reverse("profiles:edit", kwargs={"pk": item.profile_id}),
|
||||
item.profile.name,
|
||||
)
|
||||
)
|
||||
normalized_query = query.strip().casefold()
|
||||
if normalized_query:
|
||||
events = [
|
||||
item
|
||||
for item in events
|
||||
if normalized_query in f"{item.title} {item.detail} {item.object_label}".casefold()
|
||||
]
|
||||
events.sort(key=lambda item: item.occurred_at, reverse=True)
|
||||
return Paginator(events, per_page).get_page(page)
|
||||
@@ -274,6 +274,31 @@ def track_application_changes(
|
||||
return events
|
||||
|
||||
|
||||
def change_application_status(*, application: Application, user, status: str) -> Application:
|
||||
"""Persist an accessible pipeline status change and its audit event."""
|
||||
|
||||
valid_statuses = {value for value, _label in Application.Status.choices}
|
||||
if status not in valid_statuses:
|
||||
raise ValueError("Ongeldige sollicitatiestatus.")
|
||||
previous = application.status
|
||||
if previous == status:
|
||||
return application
|
||||
with transaction.atomic():
|
||||
application.status = status
|
||||
update_fields = ["status", "updated_at"]
|
||||
if status == Application.Status.APPLIED and application.applied_at is None:
|
||||
application.applied_at = timezone.now()
|
||||
update_fields.append("applied_at")
|
||||
application.save(update_fields=update_fields)
|
||||
_record_timeline_event(
|
||||
application=application,
|
||||
user=user,
|
||||
event_type=ApplicationTimelineEvent.EventType.STATUS_CHANGED,
|
||||
metadata={"from": previous, "to": status},
|
||||
)
|
||||
return application
|
||||
|
||||
|
||||
def build_print_html(application: Application) -> str:
|
||||
snapshot = application.snapshot if isinstance(application.snapshot, dict) else {}
|
||||
job = application.job
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import (
|
||||
CharField,
|
||||
Count,
|
||||
DateTimeField,
|
||||
DecimalField,
|
||||
F,
|
||||
JSONField,
|
||||
OuterRef,
|
||||
Q,
|
||||
QuerySet,
|
||||
Subquery,
|
||||
)
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source, SourceRun
|
||||
|
||||
from ..models import Application, ApplicationTimelineEvent, JobPosting, ScoreRun
|
||||
from .relevance import it_relevance_query
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def annotate_latest_profile_score(
|
||||
queryset: QuerySet[JobPosting], profile: SearchProfile | None
|
||||
) -> QuerySet[JobPosting]:
|
||||
"""Annotate jobs with one deterministic latest score for the active profile."""
|
||||
|
||||
if profile is None:
|
||||
return queryset
|
||||
latest = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by("-created_at")
|
||||
return queryset.annotate(
|
||||
match_score=Subquery(
|
||||
latest.values("score")[:1],
|
||||
output_field=DecimalField(max_digits=5, decimal_places=2),
|
||||
),
|
||||
match_confidence=Subquery(
|
||||
latest.values("confidence")[:1],
|
||||
output_field=DecimalField(max_digits=4, decimal_places=3),
|
||||
),
|
||||
match_recommendation=Subquery(
|
||||
latest.values("recommendation")[:1], output_field=CharField()
|
||||
),
|
||||
match_positives=Subquery(latest.values("positives")[:1], output_field=JSONField()),
|
||||
match_concerns=Subquery(latest.values("concerns")[:1], output_field=JSONField()),
|
||||
match_hard_exclusions=Subquery(
|
||||
latest.values("hard_exclusions")[:1], output_field=JSONField()
|
||||
),
|
||||
match_scored_at=Subquery(latest.values("created_at")[:1], output_field=DateTimeField()),
|
||||
)
|
||||
|
||||
|
||||
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
|
||||
activity: list[dict[str, Any]] = []
|
||||
source_runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||
for run in source_runs.select_related("source").order_by("-started_at")[:4]:
|
||||
activity.append(
|
||||
{
|
||||
"occurred_at": run.started_at,
|
||||
"kind": "source",
|
||||
"title": f"Bronrun {run.get_status_display().lower()}",
|
||||
"detail": run.source.name,
|
||||
"tone": "positive" if run.status == SourceRun.Status.SUCCESS else "attention",
|
||||
}
|
||||
)
|
||||
for event in (
|
||||
ApplicationTimelineEvent.objects.filter(user=user)
|
||||
.select_related("application__job")
|
||||
.order_by("-created_at")[:4]
|
||||
):
|
||||
activity.append(
|
||||
{
|
||||
"occurred_at": event.created_at,
|
||||
"kind": "application",
|
||||
"title": event.get_event_type_display(),
|
||||
"detail": event.application.job.original_title,
|
||||
"tone": "neutral",
|
||||
}
|
||||
)
|
||||
return sorted(activity, key=lambda item: item["occurred_at"], reverse=True)[:5]
|
||||
|
||||
|
||||
def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[str, Any]:
|
||||
"""Build truthful dashboard metrics and bounded read lists."""
|
||||
|
||||
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||
active_it_jobs = active_jobs.filter(it_relevance_query())
|
||||
today = timezone.localdate()
|
||||
latest_scores = ScoreRun.objects.none()
|
||||
if profile is not None:
|
||||
latest_score_id = (
|
||||
ScoreRun.objects.filter(job_id=OuterRef("job_id"), profile=profile)
|
||||
.order_by("-created_at")
|
||||
.values("pk")[:1]
|
||||
)
|
||||
latest_scores = (
|
||||
ScoreRun.objects.select_related("job", "job__employer")
|
||||
.filter(
|
||||
profile=profile,
|
||||
pk=Subquery(latest_score_id),
|
||||
job__status=JobPosting.Status.ACTIVE,
|
||||
recommendation__in=[
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
],
|
||||
hard_exclusions=[],
|
||||
)
|
||||
.filter(it_relevance_query("job__"))
|
||||
)
|
||||
|
||||
sources = Source.objects.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
|
||||
source_counts = sources.aggregate(
|
||||
total=Count("id"),
|
||||
active=Count("id", filter=Q(status=Source.Status.ACTIVE)),
|
||||
attention=Count("id", filter=~Q(status=Source.Status.ACTIVE)),
|
||||
)
|
||||
open_applications = Application.objects.filter(user=user).exclude(
|
||||
status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]
|
||||
)
|
||||
new_today = active_it_jobs.filter(first_seen__date=today).count()
|
||||
strong_today = latest_scores.filter(
|
||||
recommendation=ScoreRun.Recommendation.STRONG,
|
||||
job__first_seen__date=today,
|
||||
).count()
|
||||
filtered_count = active_jobs.exclude(it_relevance_query()).count()
|
||||
duplicate_count = JobPosting.objects.filter(status=JobPosting.Status.DUPLICATE).count()
|
||||
|
||||
if strong_today:
|
||||
day_conclusion = f"{strong_today} sterke match(es) verdienen vandaag eerst je aandacht."
|
||||
elif new_today:
|
||||
day_conclusion = f"De radar vond vandaag {new_today} nieuwe relevante vacature(s)."
|
||||
else:
|
||||
day_conclusion = "Geen nieuw sterk signaal; je bestaande opvolging blijft prioritair."
|
||||
|
||||
return {
|
||||
# Het dagoverzicht blijft een beslissingsscherm; de volledige rangschikking
|
||||
# hoort in de verkenner. Vijf kaarten houden de primaire taak scanbaar.
|
||||
"score_cards": list(latest_scores.order_by("-score", "-created_at")[:5]),
|
||||
"priority_applications": list(
|
||||
open_applications.select_related("job", "job__employer").order_by(
|
||||
F("follow_up_date").asc(nulls_last=True), "-updated_at"
|
||||
)[:4]
|
||||
),
|
||||
"active_jobs": active_it_jobs.count(),
|
||||
"filtered_non_it_jobs": filtered_count,
|
||||
"duplicate_jobs": duplicate_count,
|
||||
"new_today": new_today,
|
||||
"strong_today": strong_today,
|
||||
"applications_open": open_applications.count(),
|
||||
"source_counts": source_counts,
|
||||
"recent_activity": _recent_dashboard_activity(user),
|
||||
"day_conclusion": day_conclusion,
|
||||
"latest_scan": SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
|
||||
.order_by("-started_at")
|
||||
.first(),
|
||||
}
|
||||
|
||||
|
||||
def build_job_intelligence(
|
||||
*, job: JobPosting, profile: SearchProfile | None, user: User
|
||||
) -> dict[str, Any]:
|
||||
"""Return the bounded, user-scoped read model for explorer and detail."""
|
||||
|
||||
score = (
|
||||
ScoreRun.objects.filter(job=job, profile=profile).order_by("-created_at").first()
|
||||
if profile
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"score": score,
|
||||
"application": Application.objects.filter(user=user, job=job).first(),
|
||||
"duplicate_count": job.duplicates.count(),
|
||||
"source_count": job.source_aliases.count(),
|
||||
"version_count": job.versions.count(),
|
||||
"latest_versions": list(job.versions.all()[:5]),
|
||||
"provenance_rows": list(job.provenance.select_related("source_alias__source")[:12]),
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Vult een fictieve demo-omgeving voor het demo-account.
|
||||
|
||||
Wordt aangeroepen bij 'Bekijk demo' zodat de gebruiker meteen een geloofwaardige,
|
||||
gevulde cockpit ziet (profiel, vacatures, matchscores en een sollicitatiepijplijn)
|
||||
in plaats van een leeg dashboard. Volledig idempotent: eenmaal geseed, wordt er
|
||||
niet opnieuw geseed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
_DEMO_PROFILE_NAME = "Demo · IT-infrastructuur Limburg"
|
||||
|
||||
# (titel, werkgever, domein, gemeente, regio, werkplek, skills_required,
|
||||
# skills_preferred, score, recommendation, positives, concerns)
|
||||
_DEMO_JOBS = [
|
||||
(
|
||||
"Infrastructure Engineer",
|
||||
"CloudNexus",
|
||||
"cloudnexus.example",
|
||||
"Hasselt",
|
||||
"Limburg",
|
||||
"hybrid",
|
||||
["Microsoft 365", "VMware", "Azure"],
|
||||
["Terraform"],
|
||||
Decimal("92.4"),
|
||||
"strong",
|
||||
["Dichtbij Hasselt", "Sterke match op Microsoft 365 en VMware", "Hybride werk"],
|
||||
[],
|
||||
),
|
||||
(
|
||||
"Cloud Architect",
|
||||
"Skylink Solutions",
|
||||
"skylink.example",
|
||||
"Remote",
|
||||
"Vlaanderen",
|
||||
"remote",
|
||||
["Azure", "Kubernetes"],
|
||||
["Bicep", "Landing Zones"],
|
||||
Decimal("88.1"),
|
||||
"strong",
|
||||
["Volledig remote", "Azure-zwaartepunt sluit aan"],
|
||||
["Iets seniorder dan gevraagd"],
|
||||
),
|
||||
(
|
||||
"Systeembeheerder Microsoft 365",
|
||||
"Gemeente Genk",
|
||||
"genk.example",
|
||||
"Genk",
|
||||
"Limburg",
|
||||
"on_site",
|
||||
["Microsoft 365", "Active Directory"],
|
||||
["Intune"],
|
||||
Decimal("83.6"),
|
||||
"strong",
|
||||
["Publieke sector met stabiliteit", "M365-focus"],
|
||||
["Volledig op locatie"],
|
||||
),
|
||||
(
|
||||
"DevOps Engineer",
|
||||
"Datastroom BV",
|
||||
"datastroom.example",
|
||||
"Leuven",
|
||||
"Vlaams-Brabant",
|
||||
"hybrid",
|
||||
["CI/CD", "Docker", "Azure"],
|
||||
["GitLab", "Observability"],
|
||||
Decimal("77.9"),
|
||||
"possible",
|
||||
["Moderne toolchain", "Hybride"],
|
||||
["Reistijd naar Leuven"],
|
||||
),
|
||||
(
|
||||
"Network Engineer",
|
||||
"Noorder Telecom",
|
||||
"noordertelecom.example",
|
||||
"Antwerpen",
|
||||
"Antwerpen",
|
||||
"on_site",
|
||||
["Cisco", "Netwerkbeheer"],
|
||||
["Fortinet"],
|
||||
Decimal("70.4"),
|
||||
"possible",
|
||||
["Stevige netwerkrol"],
|
||||
["Weinig cloudcomponent", "Op locatie in Antwerpen"],
|
||||
),
|
||||
(
|
||||
"IT Support Specialist",
|
||||
"MediCare Groep",
|
||||
"medicare.example",
|
||||
"Diepenbeek",
|
||||
"Limburg",
|
||||
"on_site",
|
||||
["Servicedesk", "Windows"],
|
||||
["ITIL"],
|
||||
Decimal("62.3"),
|
||||
"weak",
|
||||
["Dichtbij"],
|
||||
["Sterk supportgericht", "Weinig infrastructuurverdieping"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _digest(*parts: str) -> str:
|
||||
return hashlib.sha256("::".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def demo_environment_seeded(user) -> bool:
|
||||
profile = SearchProfile.objects.filter(user=user, name=_DEMO_PROFILE_NAME).first()
|
||||
if profile is None:
|
||||
return False
|
||||
from apps.jobs.models import ScoreRun
|
||||
|
||||
return ScoreRun.objects.filter(profile=profile).exists()
|
||||
|
||||
|
||||
def seed_demo_environment(user) -> None:
|
||||
"""Maak of herstel een fictieve, tijdsbestendige demo-omgeving voor ``user``."""
|
||||
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
|
||||
from apps.sources.models import MailboxConnection, Source, SourceRun
|
||||
from apps.sources.platforms import PLATFORM_ALERTS
|
||||
|
||||
profile, _ = SearchProfile.objects.get_or_create(
|
||||
user=user,
|
||||
name=_DEMO_PROFILE_NAME,
|
||||
defaults={
|
||||
"is_active": True,
|
||||
"home_municipality": "Hasselt",
|
||||
"home_latitude": Decimal("50.930700"),
|
||||
"home_longitude": Decimal("5.332500"),
|
||||
"max_distance_km": 60,
|
||||
"experience_years": 5,
|
||||
"learning_enabled": True,
|
||||
"desired_titles": ["infrastructure engineer", "systeembeheerder", "cloud engineer"],
|
||||
"excluded_titles": ["sales", "recruiter"],
|
||||
"desired_skills": ["Microsoft 365", "VMware", "Azure"],
|
||||
"allowed_employment_types": ["full_time", "permanent"],
|
||||
"preferred_workplace": ["hybrid", "remote", "on_site"],
|
||||
"preferred_regions": ["Limburg", "Vlaams-Brabant"],
|
||||
},
|
||||
)
|
||||
SearchProfile.objects.filter(user=user).exclude(pk=profile.pk).update(is_active=False)
|
||||
if not profile.is_active:
|
||||
profile.is_active = True
|
||||
profile.save(update_fields=["is_active"])
|
||||
|
||||
now = timezone.now()
|
||||
today = timezone.localdate(now)
|
||||
workplace_map = {
|
||||
"hybrid": JobPosting.Workplace.HYBRID,
|
||||
"remote": JobPosting.Workplace.REMOTE,
|
||||
"on_site": JobPosting.Workplace.ON_SITE,
|
||||
}
|
||||
created_jobs: list = []
|
||||
|
||||
for index, (
|
||||
title,
|
||||
employer_name,
|
||||
domain,
|
||||
municipality,
|
||||
region,
|
||||
workplace,
|
||||
req_skills,
|
||||
pref_skills,
|
||||
score,
|
||||
recommendation,
|
||||
positives,
|
||||
concerns,
|
||||
) in enumerate(_DEMO_JOBS):
|
||||
employer, _ = Employer.objects.get_or_create(
|
||||
normalized_name=employer_name.lower(),
|
||||
defaults={
|
||||
"name": employer_name,
|
||||
"domain": domain,
|
||||
"is_direct_employer": True,
|
||||
"is_recruiter": False,
|
||||
"confidence": 0.92,
|
||||
},
|
||||
)
|
||||
slug = _digest("demo-job", str(index), title, employer_name)
|
||||
job, _ = JobPosting.objects.update_or_create(
|
||||
canonical_key=slug,
|
||||
defaults={
|
||||
"employer": employer,
|
||||
"original_title": title,
|
||||
"normalized_title": title.lower(),
|
||||
"job_family": "infrastructure",
|
||||
"canonical_url": f"https://{domain}/vacatures/{slug[:12]}",
|
||||
"content_hash": _digest("demo-content", slug),
|
||||
"description_text": (
|
||||
f"{title} bij {employer_name} in {municipality}. "
|
||||
"Fictieve demovacature voor de VacatureRadar-demo-omgeving."
|
||||
),
|
||||
"raw_location": f"{municipality}, {region}",
|
||||
"region": region,
|
||||
"municipality": municipality,
|
||||
"workplace_type": workplace_map[workplace],
|
||||
"employment_types": ["full_time", "permanent"],
|
||||
"skills_required": req_skills,
|
||||
"skills_preferred": pref_skills,
|
||||
"direct_employer": True,
|
||||
"recruiter": False,
|
||||
"extraction_confidence": 0.9,
|
||||
"analysis_features": {
|
||||
"support_ratio": 0.1,
|
||||
"public_sector_signal": 1.0 if "Gemeente" in employer_name else 0.0,
|
||||
"experience_years_max": 6,
|
||||
},
|
||||
"status": JobPosting.Status.ACTIVE,
|
||||
"first_seen": now - timedelta(days=index + 1),
|
||||
"last_seen": now,
|
||||
"date_posted": now - timedelta(days=index + 1),
|
||||
},
|
||||
)
|
||||
created_jobs.append(job)
|
||||
|
||||
ScoreRun.objects.filter(
|
||||
job=job,
|
||||
profile=profile,
|
||||
model_version="demo-seed",
|
||||
).delete()
|
||||
ScoreRun.objects.create(
|
||||
job=job,
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
score=score,
|
||||
confidence=Decimal("0.78"),
|
||||
recommendation=recommendation,
|
||||
components={
|
||||
"content": float(score) * 0.45,
|
||||
"skills": float(score) * 0.30,
|
||||
"location": float(score) * 0.25,
|
||||
},
|
||||
positives=positives,
|
||||
concerns=concerns,
|
||||
hard_exclusions=[],
|
||||
evidence={"demo": True},
|
||||
model_version="demo-seed",
|
||||
prompt_version="1.0.0",
|
||||
created_at=now - timedelta(hours=index),
|
||||
)
|
||||
|
||||
# Een kleine sollicitatiepijplijn voor de demo.
|
||||
if created_jobs:
|
||||
Application.objects.update_or_create(
|
||||
user=user,
|
||||
job=created_jobs[0],
|
||||
defaults={
|
||||
"status": Application.Status.INTERVIEW,
|
||||
"applied_at": now - timedelta(days=6),
|
||||
"follow_up_date": today + timedelta(days=2),
|
||||
"contact_name": "Sofie Vandael",
|
||||
"contact_email": "sofie.vandael@cloudnexus.example",
|
||||
"notes": "Eerste gesprek gepland. Demo-data.",
|
||||
},
|
||||
)
|
||||
if len(created_jobs) > 3:
|
||||
Application.objects.update_or_create(
|
||||
user=user,
|
||||
job=created_jobs[3],
|
||||
defaults={
|
||||
"status": Application.Status.APPLIED,
|
||||
"applied_at": now - timedelta(days=2),
|
||||
"follow_up_date": today + timedelta(days=5),
|
||||
"notes": "Sollicitatie verstuurd. Demo-data.",
|
||||
},
|
||||
)
|
||||
|
||||
# Platformmailboxen zijn zichtbare ingressconfiguratie, geen crawlbare URL-bronnen.
|
||||
# De demo gebruikt uitsluitend fictieve adressen en wordt door de scheduler overgeslagen.
|
||||
for platform in PLATFORM_ALERTS:
|
||||
MailboxConnection.objects.update_or_create(
|
||||
user=user,
|
||||
platform=platform,
|
||||
defaults={
|
||||
"provider": MailboxConnection.Provider.OUTLOOK,
|
||||
"username": "alerts@example.test",
|
||||
"mailbox": f"VacatureRadar/{platform}",
|
||||
"enabled": True,
|
||||
"poll_interval_minutes": MailboxConnection.PollInterval.THIRTY_MINUTES,
|
||||
"next_poll_at": now + timedelta(minutes=30),
|
||||
"last_polled_at": now - timedelta(minutes=5),
|
||||
"last_success_at": now - timedelta(minutes=5),
|
||||
"last_error_category": "",
|
||||
"last_error_message": "",
|
||||
"lease_token": "",
|
||||
"lease_expires_at": None,
|
||||
},
|
||||
)
|
||||
source, _ = Source.objects.update_or_create(
|
||||
source_type=Source.Type.EMAIL,
|
||||
domain=f"{platform}.mailbox.local",
|
||||
defaults={
|
||||
"name": f"{PLATFORM_ALERTS[platform].label} vacaturemailbox",
|
||||
"base_url": "",
|
||||
"status": Source.Status.ACTIVE,
|
||||
"policy": Source.Policy.ALLOW,
|
||||
"policy_reason": "Mailboxingress; niet via URL-fetch gepland.",
|
||||
"parser_key": "email-alert",
|
||||
"failure_count": 0,
|
||||
"last_failure_at": None,
|
||||
"next_run_at": None,
|
||||
"metadata": {"demo": True, "mailbox_ingress": True},
|
||||
},
|
||||
)
|
||||
SourceRun.objects.filter(source=source).delete()
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Avg, Count, Max, Q, QuerySet
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.models import Source
|
||||
|
||||
from ..models import Application, Employer, JobPosting, ScoreRun
|
||||
from .cockpit import annotate_latest_profile_score
|
||||
from .relevance import it_relevance_query
|
||||
|
||||
|
||||
def employer_index_queryset(*, user, profile: SearchProfile | None) -> QuerySet[Employer]:
|
||||
score_filter = Q(jobs__scores__profile=profile) if profile else Q(pk__isnull=True)
|
||||
return Employer.objects.annotate(
|
||||
active_vacancies=Count(
|
||||
"jobs", filter=Q(jobs__status=JobPosting.Status.ACTIVE), distinct=True
|
||||
),
|
||||
relevant_vacancies=Count(
|
||||
"jobs",
|
||||
filter=Q(jobs__status=JobPosting.Status.ACTIVE) & it_relevance_query("jobs__"),
|
||||
distinct=True,
|
||||
),
|
||||
previous_applications=Count(
|
||||
"jobs__applications",
|
||||
filter=Q(jobs__applications__user=user),
|
||||
distinct=True,
|
||||
),
|
||||
source_count=Count("jobs__source_aliases__source", distinct=True),
|
||||
latest_job_at=Max("jobs__first_seen"),
|
||||
average_match=Avg("jobs__scores__score", filter=score_filter),
|
||||
)
|
||||
|
||||
|
||||
def filter_employers(
|
||||
queryset: QuerySet[Employer], *, query: str = "", channel: str = "", active_only: bool = False
|
||||
) -> QuerySet[Employer]:
|
||||
if query.strip():
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=query.strip())
|
||||
| Q(domain__icontains=query.strip())
|
||||
| Q(jobs__raw_location__icontains=query.strip())
|
||||
)
|
||||
if channel == "direct":
|
||||
queryset = queryset.filter(is_direct_employer=True, is_recruiter=False)
|
||||
elif channel == "recruiter":
|
||||
queryset = queryset.filter(is_recruiter=True)
|
||||
if active_only:
|
||||
queryset = queryset.filter(active_vacancies__gt=0)
|
||||
return queryset.order_by("-relevant_vacancies", "-active_vacancies", "name").distinct()
|
||||
|
||||
|
||||
def build_employer_detail(
|
||||
*, employer: Employer, user, profile: SearchProfile | None
|
||||
) -> dict[str, Any]:
|
||||
jobs = annotate_latest_profile_score(
|
||||
employer.jobs.select_related("employer").all(), profile
|
||||
).order_by("-first_seen")
|
||||
sources = Source.objects.filter(job_aliases__job__employer=employer).distinct().order_by("name")
|
||||
locations = list(
|
||||
employer.jobs.exclude(raw_location="")
|
||||
.order_by("raw_location")
|
||||
.values_list("raw_location", flat=True)
|
||||
.distinct()[:8]
|
||||
)
|
||||
return {
|
||||
"employer": employer,
|
||||
"recent_jobs": list(jobs[:12]),
|
||||
"active_jobs": list(jobs.filter(status=JobPosting.Status.ACTIVE)[:8]),
|
||||
"applications": list(
|
||||
Application.objects.filter(user=user, job__employer=employer)
|
||||
.select_related("job")
|
||||
.order_by("-updated_at")[:8]
|
||||
),
|
||||
"sources": list(sources[:10]),
|
||||
"locations": locations,
|
||||
"match_history": list(
|
||||
ScoreRun.objects.filter(profile=profile, job__employer=employer)
|
||||
.select_related("job")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
if profile
|
||||
else [],
|
||||
}
|
||||
@@ -22,7 +22,6 @@ LEARNING_FEATURES = {
|
||||
"location",
|
||||
"conditions",
|
||||
"employer",
|
||||
"seniority",
|
||||
"preferences",
|
||||
}
|
||||
|
||||
|
||||
@@ -49,13 +49,21 @@ EMPLOYMENT_MAP = {
|
||||
"vast": "permanent",
|
||||
}
|
||||
TITLE_FAMILIES = {
|
||||
"it field engineer": "field-support",
|
||||
"field service engineer": "field-support",
|
||||
"it engineer": "infrastructure",
|
||||
"system engineer": "infrastructure",
|
||||
"system network engineer": "infrastructure",
|
||||
"system administrator": "infrastructure",
|
||||
"systeembeheerder": "infrastructure",
|
||||
"infrastructure engineer": "infrastructure",
|
||||
"network engineer": "network",
|
||||
"network administrator": "network",
|
||||
"netwerkbeheerder": "network",
|
||||
"workplace engineer": "workplace",
|
||||
"support engineer": "support",
|
||||
"it technician": "support",
|
||||
"pc technician": "support",
|
||||
"helpdesk": "support",
|
||||
"developer": "software-development",
|
||||
"data engineer": "data",
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field as dataclass_field
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.jobs.models import Employer, FieldProvenance, JobPosting, JobSourceAlias, JobVersion
|
||||
from apps.jobs.models import (
|
||||
Employer,
|
||||
FieldProvenance,
|
||||
JobPosting,
|
||||
JobSourceAlias,
|
||||
JobVersion,
|
||||
ScoreRun,
|
||||
)
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.sources.adapters.base import FieldEvidence
|
||||
from apps.sources.adapters.registry import registry
|
||||
@@ -29,17 +38,31 @@ RECRUITER_TERMS = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistenceContext:
|
||||
"""Run-scoped reference cache; never shared between workers or imports."""
|
||||
|
||||
employers: dict[tuple[str, str], Employer | None] = dataclass_field(default_factory=dict)
|
||||
active_profiles: list[SearchProfile] | None = None
|
||||
latest_scores: dict[tuple[object, int], ScoreRun] = dataclass_field(default_factory=dict)
|
||||
|
||||
|
||||
def _confidence(value: float) -> Decimal:
|
||||
return Decimal(str(max(0.0, min(1.0, value))))
|
||||
|
||||
|
||||
def resolve_employer(draft: CanonicalJobDraft) -> Employer | None:
|
||||
def resolve_employer(
|
||||
draft: CanonicalJobDraft, *, context: PersistenceContext | None = None
|
||||
) -> Employer | None:
|
||||
name = draft.employer_name.strip()
|
||||
domain = draft.employer_domain.strip()
|
||||
if not name and not domain:
|
||||
return None
|
||||
display_name = name or domain
|
||||
normalized = normalize_token(display_name)
|
||||
cache_key = (normalized, domain)
|
||||
if context is not None and cache_key in context.employers:
|
||||
return context.employers[cache_key]
|
||||
recruiter = any(term in normalized for term in RECRUITER_TERMS)
|
||||
employer, _ = Employer.objects.get_or_create(
|
||||
normalized_name=normalized,
|
||||
@@ -61,6 +84,8 @@ def resolve_employer(draft: CanonicalJobDraft) -> Employer | None:
|
||||
changed.extend(["is_recruiter", "is_direct_employer"])
|
||||
if changed:
|
||||
employer.save(update_fields=[*changed, "updated_at"])
|
||||
if context is not None:
|
||||
context.employers[cache_key] = employer
|
||||
return employer
|
||||
|
||||
|
||||
@@ -162,12 +187,81 @@ def _apply_draft(
|
||||
if direct and canonical_url and job.canonical_url != canonical_url:
|
||||
job.canonical_url = canonical_url
|
||||
changed.append("canonical_url")
|
||||
if changed and "last_changed" not in changed:
|
||||
substantive_changes = [field for field in changed if field != "last_seen"]
|
||||
if substantive_changes and "last_changed" not in changed:
|
||||
job.last_changed = timezone.now()
|
||||
changed.append("last_changed")
|
||||
return changed
|
||||
|
||||
|
||||
def _sync_evidence(
|
||||
*,
|
||||
job: JobPosting,
|
||||
alias: JobSourceAlias,
|
||||
evidence_items: list[FieldEvidence],
|
||||
parser_version: str,
|
||||
) -> None:
|
||||
existing = {
|
||||
(item.field_name, item.extraction_method): item
|
||||
for item in FieldProvenance.objects.filter(job=job, source_alias=alias)
|
||||
}
|
||||
creates: list[FieldProvenance] = []
|
||||
updates: list[FieldProvenance] = []
|
||||
for evidence in evidence_items:
|
||||
key = (evidence.field_name, evidence.method)
|
||||
values = {
|
||||
"confidence": _confidence(evidence.confidence),
|
||||
"evidence_excerpt": evidence.evidence[:1000],
|
||||
"parser_version": parser_version,
|
||||
}
|
||||
current = existing.get(key)
|
||||
if current is None:
|
||||
creates.append(
|
||||
FieldProvenance(
|
||||
job=job,
|
||||
source_alias=alias,
|
||||
field_name=evidence.field_name,
|
||||
extraction_method=evidence.method,
|
||||
**values,
|
||||
)
|
||||
)
|
||||
continue
|
||||
changed = False
|
||||
for field_name, value in values.items():
|
||||
if getattr(current, field_name) != value:
|
||||
setattr(current, field_name, value)
|
||||
changed = True
|
||||
if changed:
|
||||
current.updated_at = timezone.now()
|
||||
updates.append(current)
|
||||
if creates:
|
||||
FieldProvenance.objects.bulk_create(creates, batch_size=250)
|
||||
if updates:
|
||||
FieldProvenance.objects.bulk_update(
|
||||
updates,
|
||||
["confidence", "evidence_excerpt", "parser_version", "updated_at"],
|
||||
batch_size=250,
|
||||
)
|
||||
|
||||
|
||||
def _copy_score(score: ScoreRun) -> ScoreRun:
|
||||
return ScoreRun.objects.create(
|
||||
job=score.job,
|
||||
profile=score.profile,
|
||||
profile_version=score.profile_version,
|
||||
score=score.score,
|
||||
confidence=score.confidence,
|
||||
recommendation=score.recommendation,
|
||||
components=score.components,
|
||||
positives=score.positives,
|
||||
concerns=score.concerns,
|
||||
hard_exclusions=score.hard_exclusions,
|
||||
evidence=score.evidence,
|
||||
model_version=score.model_version,
|
||||
prompt_version=score.prompt_version,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def persist_draft(
|
||||
draft: CanonicalJobDraft,
|
||||
@@ -176,14 +270,16 @@ def persist_draft(
|
||||
parser_key: str,
|
||||
parser_version: str,
|
||||
extraction_confidence: float,
|
||||
context: PersistenceContext | None = None,
|
||||
) -> tuple[JobPosting, DedupeDecision, bool]:
|
||||
source = document.source
|
||||
employer = resolve_employer(draft)
|
||||
employer = resolve_employer(draft, context=context)
|
||||
decision = find_existing_job(draft, source=source)
|
||||
direct = _source_is_direct(source, draft)
|
||||
if decision.resolved_direct:
|
||||
direct = True
|
||||
created = False
|
||||
substantive_change = False
|
||||
|
||||
if decision.job is None:
|
||||
try:
|
||||
@@ -240,6 +336,7 @@ def persist_draft(
|
||||
if extraction_confidence > float(job.extraction_confidence):
|
||||
job.extraction_confidence = _confidence(extraction_confidence)
|
||||
changed.append("extraction_confidence")
|
||||
substantive_change = any(field not in {"last_seen", "last_changed"} for field in changed)
|
||||
if changed:
|
||||
job.save(update_fields=list(dict.fromkeys([*changed, "updated_at"])))
|
||||
|
||||
@@ -284,31 +381,58 @@ def persist_draft(
|
||||
update_fields=["last_seen", "raw_document", "payload", "is_canonical", "updated_at"]
|
||||
)
|
||||
|
||||
for evidence in draft.evidence:
|
||||
FieldProvenance.objects.update_or_create(
|
||||
_sync_evidence(
|
||||
job=job,
|
||||
source_alias=alias,
|
||||
field_name=evidence.field_name,
|
||||
extraction_method=evidence.method,
|
||||
defaults={
|
||||
"confidence": _confidence(evidence.confidence),
|
||||
"evidence_excerpt": evidence.evidence[:1000],
|
||||
"parser_version": parser_version,
|
||||
},
|
||||
alias=alias,
|
||||
evidence_items=draft.evidence,
|
||||
parser_version=parser_version,
|
||||
)
|
||||
|
||||
if created or substantive_change:
|
||||
JobVersion.objects.get_or_create(
|
||||
job=job,
|
||||
content_hash=job.content_hash,
|
||||
defaults={"snapshot": job_snapshot(job), "changed_fields": []},
|
||||
)
|
||||
for profile in SearchProfile.objects.filter(is_active=True):
|
||||
score_and_save(job, profile)
|
||||
if context is not None:
|
||||
if context.active_profiles is None:
|
||||
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
|
||||
profiles = context.active_profiles
|
||||
else:
|
||||
profiles = SearchProfile.objects.filter(is_active=True)
|
||||
for profile in profiles:
|
||||
score = score_and_save(job, profile)
|
||||
if context is not None:
|
||||
context.latest_scores[(job.pk, profile.pk)] = score
|
||||
else:
|
||||
if context is not None:
|
||||
if context.active_profiles is None:
|
||||
context.active_profiles = list(SearchProfile.objects.filter(is_active=True))
|
||||
profiles = context.active_profiles
|
||||
else:
|
||||
profiles = SearchProfile.objects.filter(is_active=True)
|
||||
for profile in profiles:
|
||||
cache_key = (job.pk, profile.pk)
|
||||
previous = context.latest_scores.get(cache_key) if context is not None else None
|
||||
if previous is None:
|
||||
previous = (
|
||||
ScoreRun.objects.filter(job=job, profile=profile)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if previous is None or previous.profile_version != profile.version:
|
||||
score = score_and_save(job, profile)
|
||||
else:
|
||||
score = _copy_score(previous)
|
||||
if context is not None:
|
||||
context.latest_scores[cache_key] = score
|
||||
return job, decision, created
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def process_raw_document(document: RawDocument) -> dict[str, int | str | list[str]]:
|
||||
def process_raw_document(
|
||||
document: RawDocument, *, context: PersistenceContext | None = None
|
||||
) -> dict[str, int | str | list[str]]:
|
||||
result = registry.extract(document)
|
||||
document.parser_key = result.parser_key
|
||||
document.parser_version = result.parser_version
|
||||
@@ -341,6 +465,7 @@ def process_raw_document(document: RawDocument) -> dict[str, int | str | list[st
|
||||
parser_key=result.parser_key,
|
||||
parser_version=result.parser_version,
|
||||
extraction_confidence=result.confidence,
|
||||
context=context,
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .normalization import normalize_token
|
||||
|
||||
# Deliberately title-led: employer pages regularly mention digital systems in otherwise
|
||||
# non-IT vacancies. A description hit alone must therefore never make a vacancy relevant.
|
||||
IT_TITLE_TERMS = (
|
||||
"it",
|
||||
"ict",
|
||||
"informatica",
|
||||
"software",
|
||||
"development engineer",
|
||||
"devops",
|
||||
"secops",
|
||||
"cloud",
|
||||
"data engineer",
|
||||
"data scientist",
|
||||
"data steward",
|
||||
"database",
|
||||
"dba",
|
||||
"business intelligence",
|
||||
"bi analyst",
|
||||
"system engineer",
|
||||
"systems engineer",
|
||||
"system network engineer",
|
||||
"system administrator",
|
||||
"systeembeheer",
|
||||
"infrastructure engineer",
|
||||
"infrastructuur engineer",
|
||||
"network engineer",
|
||||
"network administrator",
|
||||
"network architect",
|
||||
"netwerkbeheer",
|
||||
"netwerk engineer",
|
||||
"cybersecurity",
|
||||
"cyber security",
|
||||
"security engineer",
|
||||
"security architect",
|
||||
"security analyst",
|
||||
"information security",
|
||||
"informatiebeveiliging",
|
||||
"workplace engineer",
|
||||
"endpoint engineer",
|
||||
"microsoft 365 engineer",
|
||||
"m365 engineer",
|
||||
"intune engineer",
|
||||
"modern workplace",
|
||||
"digital workplace",
|
||||
"service desk",
|
||||
"servicedesk",
|
||||
"helpdesk",
|
||||
"support engineer",
|
||||
"support specialist",
|
||||
"it technician",
|
||||
"pc technician",
|
||||
"application lead",
|
||||
"application engineer",
|
||||
"application manager",
|
||||
"application specialist",
|
||||
"applicatiebeheer",
|
||||
"platform engineer",
|
||||
"platform expert",
|
||||
"solution architect",
|
||||
"solutions architect",
|
||||
"technical architect",
|
||||
"technisch architect",
|
||||
"functional analyst",
|
||||
"functioneel analist",
|
||||
"business analyst",
|
||||
"product owner",
|
||||
"web developer",
|
||||
"mobile developer",
|
||||
"low-code developer",
|
||||
"low code developer",
|
||||
"api developer",
|
||||
"idm developer",
|
||||
"power platform",
|
||||
"forgerock",
|
||||
"embedded",
|
||||
"c#",
|
||||
"front end",
|
||||
"frontend",
|
||||
"back end",
|
||||
"backend",
|
||||
"full stack",
|
||||
"fullstack",
|
||||
"php",
|
||||
".net",
|
||||
"java",
|
||||
"python",
|
||||
"powershell",
|
||||
"azure",
|
||||
"aws",
|
||||
"microsoft 365",
|
||||
"linux",
|
||||
"windows server",
|
||||
"kubernetes",
|
||||
"docker",
|
||||
"terraform",
|
||||
"erp",
|
||||
"sap",
|
||||
"machine learning",
|
||||
"ai engineer",
|
||||
"artificial intelligence",
|
||||
"image processing",
|
||||
"computer vision",
|
||||
"qa engineer",
|
||||
"test automation",
|
||||
)
|
||||
|
||||
# These engineering titles occur both in IT and in unrelated technical sectors. They are
|
||||
# accepted only when the title supplies the role context and the description independently
|
||||
# supplies a strong IT signal. A description signal by itself remains insufficient.
|
||||
CONTEXTUAL_IT_TITLE_TERMS = (
|
||||
"field engineer",
|
||||
"field service engineer",
|
||||
"implementation engineer",
|
||||
"implementation consultant",
|
||||
"service engineer",
|
||||
)
|
||||
|
||||
IT_DESCRIPTION_TERMS = (
|
||||
"microsoft 365",
|
||||
"m365",
|
||||
"windows server",
|
||||
"active directory",
|
||||
"entra id",
|
||||
"intune",
|
||||
"autopilot",
|
||||
"exchange online",
|
||||
"sharepoint",
|
||||
"vmware",
|
||||
"proxmox",
|
||||
"hyper-v",
|
||||
"networking",
|
||||
"netwerkbeheer",
|
||||
"tcp/ip",
|
||||
"vlan",
|
||||
"vpn",
|
||||
"dhcp",
|
||||
"dns",
|
||||
"firewall",
|
||||
"switches",
|
||||
"routers",
|
||||
"workstations",
|
||||
"desktops",
|
||||
"laptops",
|
||||
"voip",
|
||||
"3cx",
|
||||
"powershell",
|
||||
)
|
||||
|
||||
# These phrases can contain IT vocabulary while describing commercial, recruitment,
|
||||
# or educational-design work. They therefore override positive title signals.
|
||||
NON_IT_TITLE_TERMS = (
|
||||
"business developer",
|
||||
"business development",
|
||||
"it recruitment",
|
||||
"it recruiter",
|
||||
"learning designer",
|
||||
)
|
||||
|
||||
IT_PROFILE_TERMS = (*IT_TITLE_TERMS, "networking", "security", "active directory", "intune")
|
||||
|
||||
|
||||
def _term_pattern(term: str) -> str:
|
||||
escaped = re.escape(term).replace(r"\ ", r"[\s/_-]+")
|
||||
return rf"(?<!\w){escaped}(?!\w)"
|
||||
|
||||
|
||||
IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in IT_TITLE_TERMS) + ")"
|
||||
NON_IT_TITLE_PATTERN = "(?:" + "|".join(_term_pattern(term) for term in NON_IT_TITLE_TERMS) + ")"
|
||||
CONTEXTUAL_IT_TITLE_PATTERN = (
|
||||
"(?:" + "|".join(_term_pattern(term) for term in CONTEXTUAL_IT_TITLE_TERMS) + ")"
|
||||
)
|
||||
IT_DESCRIPTION_PATTERN = (
|
||||
"(?:" + "|".join(_term_pattern(term) for term in IT_DESCRIPTION_TERMS) + ")"
|
||||
)
|
||||
_IT_TITLE_RE = re.compile(IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_NON_IT_TITLE_RE = re.compile(NON_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_CONTEXTUAL_IT_TITLE_RE = re.compile(CONTEXTUAL_IT_TITLE_PATTERN, re.IGNORECASE)
|
||||
_IT_DESCRIPTION_RE = re.compile(IT_DESCRIPTION_PATTERN, re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ItRelevanceAssessment:
|
||||
relevant: bool
|
||||
signals: tuple[str, ...]
|
||||
reason: str
|
||||
|
||||
|
||||
def assess_it_relevance(title: str, description: str = "") -> ItRelevanceAssessment:
|
||||
normalized_title = normalize_token(title)
|
||||
blocked_signal = _NON_IT_TITLE_RE.search(normalized_title)
|
||||
if blocked_signal:
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
reason=f"Niet-technische titelcontext: {blocked_signal.group(0)}.",
|
||||
)
|
||||
signals = tuple(
|
||||
term
|
||||
for term in IT_TITLE_TERMS
|
||||
if re.search(_term_pattern(normalize_token(term)), normalized_title, re.IGNORECASE)
|
||||
)
|
||||
if signals:
|
||||
return ItRelevanceAssessment(
|
||||
relevant=True,
|
||||
signals=signals[:4],
|
||||
reason="IT-signaal in functietitel: " + ", ".join(signals[:4]),
|
||||
)
|
||||
contextual_title = _CONTEXTUAL_IT_TITLE_RE.search(normalized_title)
|
||||
if contextual_title:
|
||||
description_signal = _IT_DESCRIPTION_RE.search(normalize_token(description))
|
||||
if description_signal:
|
||||
return ItRelevanceAssessment(
|
||||
relevant=True,
|
||||
signals=(contextual_title.group(0), description_signal.group(0)),
|
||||
reason=(
|
||||
"IT-context bevestigd via ambigue functietitel en vacaturetekst: "
|
||||
f"{contextual_title.group(0)}, {description_signal.group(0)}."
|
||||
),
|
||||
)
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
reason="Ambigue technische functietitel zonder aantoonbare IT-context.",
|
||||
)
|
||||
return ItRelevanceAssessment(
|
||||
relevant=False,
|
||||
signals=(),
|
||||
reason="Geen aantoonbaar IT-signaal in de functietitel.",
|
||||
)
|
||||
|
||||
|
||||
def profile_requires_it_focus(profile: SearchProfile) -> bool:
|
||||
configured = " ".join([profile.name, *profile.desired_titles, *profile.desired_skills])
|
||||
normalized = normalize_token(configured)
|
||||
return any(
|
||||
re.search(_term_pattern(normalize_token(term)), normalized, re.IGNORECASE)
|
||||
for term in IT_PROFILE_TERMS
|
||||
)
|
||||
|
||||
|
||||
def it_relevance_query(prefix: str = "") -> Q:
|
||||
"""Return the database equivalent of the conservative title-led classifier."""
|
||||
clear_title = Q(**{f"{prefix}original_title__iregex": IT_TITLE_PATTERN})
|
||||
contextual_title = Q(**{f"{prefix}original_title__iregex": CONTEXTUAL_IT_TITLE_PATTERN}) & Q(
|
||||
**{f"{prefix}description_text__iregex": IT_DESCRIPTION_PATTERN}
|
||||
)
|
||||
return (clear_title | contextual_title) & ~Q(
|
||||
**{f"{prefix}original_title__iregex": NON_IT_TITLE_PATTERN}
|
||||
)
|
||||
@@ -14,6 +14,8 @@ from apps.jobs.services.geocoding import resolve_cached_location
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .normalization import normalize_token
|
||||
from .relevance import assess_it_relevance, profile_requires_it_focus
|
||||
from .skill_terms import contains_term, skill_is_present
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -51,6 +53,7 @@ class DistanceAssessment:
|
||||
|
||||
EXACT_DISTANCE_CONF_THRESHOLD = 0.80
|
||||
AI_MAX_WEIGHT = 20.0
|
||||
SKILL_MATCH_TARGET = 4
|
||||
|
||||
|
||||
def _similarity(left: str, right: str) -> float:
|
||||
@@ -112,6 +115,14 @@ def _title_fit(job: JobPosting, profile: SearchProfile) -> float:
|
||||
)
|
||||
|
||||
|
||||
def _contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
|
||||
return contains_term(text, term, allow_plural=allow_plural)
|
||||
|
||||
|
||||
def _skill_is_present(skill: str, text: str) -> bool:
|
||||
return skill_is_present(skill, text)
|
||||
|
||||
|
||||
def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str], list[str]]:
|
||||
desired = {normalize_token(skill) for skill in profile.desired_skills if skill}
|
||||
if not desired:
|
||||
@@ -119,9 +130,10 @@ def _skill_fit(job: JobPosting, profile: SearchProfile) -> tuple[float, list[str
|
||||
text = normalize_token(
|
||||
" ".join(job.skills_required + job.skills_preferred) + " " + job.description_text
|
||||
)
|
||||
present = sorted(skill for skill in desired if skill and skill in text)
|
||||
present = sorted(skill for skill in desired if _skill_is_present(skill, text))
|
||||
missing = sorted(desired - set(present))
|
||||
return len(present) / len(desired), present, missing
|
||||
evidence_target = min(SKILL_MATCH_TARGET, len(desired))
|
||||
return min(1.0, len(present) / evidence_target), present, missing
|
||||
|
||||
|
||||
def _distance(job: JobPosting, profile: SearchProfile) -> DistanceAssessment:
|
||||
@@ -172,7 +184,7 @@ def _hard_exclusions(
|
||||
configured_terms = list(profile.excluded_titles)
|
||||
configured_terms += list(profile.hard_rules.get("excluded_title_terms", []))
|
||||
for term in configured_terms:
|
||||
if normalize_token(term) and normalize_token(term) in title:
|
||||
if _contains_term(title, normalize_token(term), allow_plural=True):
|
||||
reasons.append(f"Uitgesloten titelterm: {term}")
|
||||
|
||||
excluded_types = set(profile.hard_rules.get("excluded_employment_types", []))
|
||||
@@ -227,6 +239,9 @@ def _hard_exclusions(
|
||||
conflicts = sorted(excluded_skills.intersection(explicit_job_skills))
|
||||
if conflicts:
|
||||
reasons.append("Uitgesloten verplichte skill: " + ", ".join(conflicts))
|
||||
it_relevance = assess_it_relevance(job.original_title, job.description_text)
|
||||
if profile_requires_it_focus(profile) and not it_relevance.relevant:
|
||||
reasons.append(it_relevance.reason)
|
||||
return reasons, distance.distance_confidence
|
||||
|
||||
|
||||
@@ -244,20 +259,10 @@ def _ai_feature_score(features: dict[str, Any]) -> float:
|
||||
support_ratio = float(features.get("support_ratio") or 0.0)
|
||||
consultancy_ratio = float(features.get("consultancy_ratio") or 0.0)
|
||||
travel_ratio = float(features.get("travel_ratio") or 0.0)
|
||||
seniority = str(features.get("seniority") or "").strip().lower()
|
||||
seniority_boost = {
|
||||
"junior": 0.0,
|
||||
"medior": 0.08,
|
||||
"senior": 0.12,
|
||||
"lead": 0.14,
|
||||
"expert": 0.16,
|
||||
"unknown": 0.03,
|
||||
"": 0.03,
|
||||
}.get(seniority, 0.05)
|
||||
score = (
|
||||
0.5 * (1.0 - support_ratio) + 0.25 * (1.0 - consultancy_ratio) + 0.15 * (1.0 - travel_ratio)
|
||||
)
|
||||
return max(0.0, min(1.0, score + seniority_boost))
|
||||
return max(0.0, min(1.0, score))
|
||||
|
||||
|
||||
def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysis, float, float]:
|
||||
@@ -292,6 +297,7 @@ def _analyze_with_ai(job: JobPosting, profile: SearchProfile) -> tuple[AiAnalysi
|
||||
def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
distance = _distance(job, profile)
|
||||
exclusions, distance_confidence = _hard_exclusions(job, profile, distance)
|
||||
it_relevance = assess_it_relevance(job.original_title, job.description_text)
|
||||
title_fit = _title_fit(job, profile)
|
||||
skill_fit, present_skills, missing_skills = _skill_fit(job, profile)
|
||||
features = job.analysis_features or {}
|
||||
@@ -321,12 +327,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
else:
|
||||
conditions_fit = 0.65
|
||||
employer_fit = 1.0 if job.direct_employer and not job.recruiter else 0.45
|
||||
experience_years = features.get("experience_years_max")
|
||||
seniority_fit = (
|
||||
0.75
|
||||
if experience_years is None
|
||||
else max(0.25, 1.0 - max(0, int(experience_years) - 5) * 0.1)
|
||||
)
|
||||
if profile.preferred_workplace:
|
||||
preference_fit = 1.0 if job.workplace_type in profile.preferred_workplace else 0.45
|
||||
else:
|
||||
@@ -342,7 +342,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
"location": location_fit,
|
||||
"conditions": conditions_fit,
|
||||
"employer": employer_fit,
|
||||
"seniority": seniority_fit,
|
||||
"preferences": preference_fit,
|
||||
}
|
||||
ai_analysis, ai_component, ai_weight = _analyze_with_ai(job, profile)
|
||||
@@ -381,6 +380,8 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
concerns: list[str] = []
|
||||
if title_fit >= 0.75:
|
||||
positives.append("Functietitel sluit goed aan op het zoekprofiel.")
|
||||
if it_relevance.relevant:
|
||||
positives.append("IT-focus bevestigd via de functietitel.")
|
||||
if present_skills:
|
||||
positives.append("Herkenbare skills: " + ", ".join(present_skills[:6]))
|
||||
if job.direct_employer and not job.recruiter:
|
||||
@@ -402,8 +403,6 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
)
|
||||
if support_ratio >= 0.5:
|
||||
concerns.append("Vacature bevat sterke first-line/helpdesksignalen.")
|
||||
if missing_skills:
|
||||
concerns.append("Niet duidelijk teruggevonden: " + ", ".join(missing_skills[:6]))
|
||||
if (
|
||||
distance.exact_distance_km is None
|
||||
and distance.has_distance_data
|
||||
@@ -435,6 +434,20 @@ def calculate_score(job: JobPosting, profile: SearchProfile) -> ScoreResult:
|
||||
concerns=concerns,
|
||||
hard_exclusions=exclusions,
|
||||
evidence={
|
||||
"it_relevance": {
|
||||
"relevant": it_relevance.relevant,
|
||||
"signals": list(it_relevance.signals),
|
||||
"reason": it_relevance.reason,
|
||||
},
|
||||
"skills": {
|
||||
"matched": present_skills,
|
||||
"configured_count": len(present_skills) + len(missing_skills),
|
||||
"match_target": min(
|
||||
SKILL_MATCH_TARGET,
|
||||
len(present_skills) + len(missing_skills),
|
||||
),
|
||||
"not_observed": missing_skills,
|
||||
},
|
||||
"distance_km": distance.exact_distance_km,
|
||||
"distance_has_data": distance.has_distance_data,
|
||||
"distance_exact": distance.exact_distance_km is not None,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import CharField, Exists, OuterRef, Q, QuerySet, Subquery
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.jobs.models import JobPosting, JobSourceAlias, ScoreRun
|
||||
from apps.profiles.models import SearchProfile
|
||||
from apps.profiles.taxonomy import SKILL_CHOICES
|
||||
from apps.sources.models import Source
|
||||
|
||||
from .normalization import normalize_token
|
||||
from .relevance import it_relevance_query
|
||||
from .skill_terms import canonical_skill_key, skill_is_present
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDefinition:
|
||||
key: str
|
||||
label: str
|
||||
category: str
|
||||
category_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillJobExample:
|
||||
id: str
|
||||
title: str
|
||||
employer: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDemandItem:
|
||||
key: str
|
||||
label: str
|
||||
category: str
|
||||
category_key: str
|
||||
vacancy_count: int
|
||||
share_percent: int
|
||||
explicit_count: int
|
||||
inferred_count: int
|
||||
covered: bool
|
||||
learning_focus: str
|
||||
search_term: str
|
||||
examples: tuple[SkillJobExample, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillDemandReport:
|
||||
total_jobs: int
|
||||
jobs_with_signals: int
|
||||
signal_count: int
|
||||
covered_count: int
|
||||
gap_count: int
|
||||
coverage_percent: int
|
||||
items: tuple[SkillDemandItem, ...]
|
||||
categories: tuple[tuple[str, str], ...]
|
||||
selected_category: str
|
||||
selected_coverage: str
|
||||
|
||||
|
||||
def skill_catalog() -> tuple[SkillDefinition, ...]:
|
||||
return tuple(
|
||||
SkillDefinition(
|
||||
key=normalize_token(value),
|
||||
label=label,
|
||||
category=category,
|
||||
category_key=slugify(category),
|
||||
)
|
||||
for category, choices in SKILL_CHOICES
|
||||
for value, label in choices
|
||||
)
|
||||
|
||||
|
||||
def relevant_jobs_for_profile(profile: SearchProfile) -> QuerySet[JobPosting]:
|
||||
latest_score = ScoreRun.objects.filter(
|
||||
job=OuterRef("pk"),
|
||||
profile=profile,
|
||||
profile_version=profile.version,
|
||||
).order_by("-created_at")
|
||||
source_aliases = JobSourceAlias.objects.filter(job=OuterRef("pk"))
|
||||
active_source_aliases = source_aliases.filter(source__status=Source.Status.ACTIVE)
|
||||
return (
|
||||
JobPosting.objects.select_related("employer")
|
||||
.filter(status=JobPosting.Status.ACTIVE)
|
||||
.filter(it_relevance_query())
|
||||
.annotate(
|
||||
profile_recommendation=Subquery(
|
||||
latest_score.values("recommendation")[:1], output_field=CharField()
|
||||
),
|
||||
has_source_alias=Exists(source_aliases),
|
||||
has_active_source_alias=Exists(active_source_aliases),
|
||||
)
|
||||
.filter(
|
||||
profile_recommendation__in=(
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
ScoreRun.Recommendation.WEAK,
|
||||
)
|
||||
)
|
||||
.filter(Q(has_source_alias=False) | Q(has_active_source_alias=True))
|
||||
.order_by("-first_seen")
|
||||
)
|
||||
|
||||
|
||||
def _structured_skill_text(job: JobPosting) -> str:
|
||||
return normalize_token(
|
||||
" ".join(str(value) for value in [*job.skills_required, *job.skills_preferred])
|
||||
)
|
||||
|
||||
|
||||
def _inferred_skill_text(job: JobPosting) -> str:
|
||||
requirements = " ".join(str(value) for value in job.requirements)
|
||||
return normalize_token(f"{job.original_title} {requirements} {job.description_text}")
|
||||
|
||||
|
||||
def _learning_focus(definition: SkillDefinition) -> str:
|
||||
focuses = {
|
||||
"Microsoft & endpoint": (
|
||||
"Oefen een praktische beheer- of migratiecase en leg de relevante "
|
||||
"Microsoft Learn-modules vast."
|
||||
),
|
||||
"Netwerk & security": (
|
||||
"Bouw een kleine labcase rond configuratie, troubleshooting en beveiligde toegang."
|
||||
),
|
||||
"Back-up & monitoring": (
|
||||
"Oefen detectie, herstel en rapportering met een reproduceerbare homelabcase."
|
||||
),
|
||||
"Virtualisatie & platform": (
|
||||
"Maak een deployment- of platformlab en documenteer beschikbaarheid en herstel."
|
||||
),
|
||||
"IT-servicemanagement": (
|
||||
"Koppel de methodiek aan een concrete incident-, problem- of changecase."
|
||||
),
|
||||
"Digitale werkplek & adoptie": (
|
||||
"Werk een kleine governance- of adoptiecase uit met meetbare gebruikersimpact."
|
||||
),
|
||||
"Automation & scripting": (
|
||||
"Automatiseer één herkenbare beheertaak en publiceer een veilig voorbeeldscript."
|
||||
),
|
||||
"VoIP & telefonie": "Simuleer configuratie en troubleshooting in een kleine telefoniecase.",
|
||||
"Field service & uitvoering": (
|
||||
"Documenteer een end-to-end interventie: diagnose, oplossing en overdracht."
|
||||
),
|
||||
}
|
||||
return focuses.get(
|
||||
definition.category,
|
||||
"Maak een kleine praktijkcase en leg vast welke vacature-eis je ermee kunt aantonen.",
|
||||
)
|
||||
|
||||
|
||||
def _profile_skill_keys(profile: SearchProfile, catalog: tuple[SkillDefinition, ...]) -> set[str]:
|
||||
catalog_keys = {definition.key for definition in catalog}
|
||||
return {
|
||||
canonical
|
||||
for value in profile.desired_skills
|
||||
if (canonical := canonical_skill_key(str(value), catalog_keys)) is not None
|
||||
}
|
||||
|
||||
|
||||
def build_skill_demand_report(
|
||||
profile: SearchProfile,
|
||||
*,
|
||||
category: str = "all",
|
||||
coverage: str = "all",
|
||||
jobs: QuerySet[JobPosting] | list[JobPosting] | None = None,
|
||||
) -> SkillDemandReport:
|
||||
catalog = skill_catalog()
|
||||
valid_categories = {definition.category_key for definition in catalog}
|
||||
selected_category = category if category in valid_categories else "all"
|
||||
selected_coverage = coverage if coverage in {"all", "gap", "covered"} else "all"
|
||||
job_list = list(relevant_jobs_for_profile(profile) if jobs is None else jobs)
|
||||
profile_skills = _profile_skill_keys(profile, catalog)
|
||||
items: list[SkillDemandItem] = []
|
||||
jobs_with_signals: set[str] = set()
|
||||
|
||||
for definition in catalog:
|
||||
examples: list[SkillJobExample] = []
|
||||
explicit_count = 0
|
||||
inferred_count = 0
|
||||
for job in job_list:
|
||||
explicit = skill_is_present(definition.key, _structured_skill_text(job))
|
||||
inferred = skill_is_present(definition.key, _inferred_skill_text(job))
|
||||
if not explicit and not inferred:
|
||||
continue
|
||||
if explicit:
|
||||
explicit_count += 1
|
||||
else:
|
||||
inferred_count += 1
|
||||
jobs_with_signals.add(str(job.pk))
|
||||
if len(examples) < 3:
|
||||
examples.append(
|
||||
SkillJobExample(
|
||||
id=str(job.pk),
|
||||
title=job.original_title,
|
||||
employer=job.employer_name,
|
||||
)
|
||||
)
|
||||
vacancy_count = explicit_count + inferred_count
|
||||
if vacancy_count == 0:
|
||||
continue
|
||||
items.append(
|
||||
SkillDemandItem(
|
||||
key=definition.key,
|
||||
label=definition.label,
|
||||
category=definition.category,
|
||||
category_key=definition.category_key,
|
||||
vacancy_count=vacancy_count,
|
||||
share_percent=round(vacancy_count / len(job_list) * 100) if job_list else 0,
|
||||
explicit_count=explicit_count,
|
||||
inferred_count=inferred_count,
|
||||
covered=definition.key in profile_skills,
|
||||
learning_focus=_learning_focus(definition),
|
||||
search_term=definition.key,
|
||||
examples=tuple(examples),
|
||||
)
|
||||
)
|
||||
|
||||
ranked = sorted(
|
||||
items,
|
||||
key=lambda item: (-item.vacancy_count, -item.explicit_count, item.label.casefold()),
|
||||
)
|
||||
covered_count = sum(item.covered for item in ranked)
|
||||
gap_count = len(ranked) - covered_count
|
||||
filtered = tuple(
|
||||
item
|
||||
for item in ranked
|
||||
if (selected_category == "all" or item.category_key == selected_category)
|
||||
and (
|
||||
selected_coverage == "all"
|
||||
or (selected_coverage == "covered" and item.covered)
|
||||
or (selected_coverage == "gap" and not item.covered)
|
||||
)
|
||||
)
|
||||
categories = tuple(
|
||||
(slugify(category_label), category_label)
|
||||
for category_label, _choices in SKILL_CHOICES
|
||||
if any(item.category == category_label for item in ranked)
|
||||
)
|
||||
return SkillDemandReport(
|
||||
total_jobs=len(job_list),
|
||||
jobs_with_signals=len(jobs_with_signals),
|
||||
signal_count=len(ranked),
|
||||
covered_count=covered_count,
|
||||
gap_count=gap_count,
|
||||
coverage_percent=round(covered_count / len(ranked) * 100) if ranked else 0,
|
||||
items=filtered,
|
||||
categories=categories,
|
||||
selected_category=selected_category,
|
||||
selected_coverage=selected_coverage,
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from apps.jobs.services.normalization import normalize_token
|
||||
|
||||
SKILL_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
"microsoft 365": ("m365", "office 365", "o365"),
|
||||
"microsoft teams": ("ms teams",),
|
||||
"entra id": ("azure active directory", "azure ad"),
|
||||
"intune": ("microsoft intune", "endpoint manager"),
|
||||
"autopilot": ("windows autopilot",),
|
||||
"group policy": ("group policy object", "gpo"),
|
||||
"dynamics 365": ("microsoft dynamics 365", "d365"),
|
||||
"microsoft copilot": ("copilot for microsoft 365", "m365 copilot"),
|
||||
"microsoft defender": ("defender for endpoint", "microsoft defender for endpoint"),
|
||||
"microsoft sentinel": ("azure sentinel", "sentinel siem"),
|
||||
"conditional access": ("voorwaardelijke toegang",),
|
||||
"networking": ("network", "netwerk", "lan", "wan"),
|
||||
"routing": ("routering",),
|
||||
"switching": ("network switches", "switches"),
|
||||
"sd-wan": ("sd wan",),
|
||||
"firewalls": ("firewall",),
|
||||
"fortinet": ("fortigate",),
|
||||
"palo alto": ("palo alto networks",),
|
||||
"wi-fi": ("wifi", "wireless"),
|
||||
"backup and restore": ("backup", "back-up", "restore"),
|
||||
"disaster recovery": ("business continuity", "bcp", "dr plan"),
|
||||
"monitoring": ("infrastructure monitoring", "system monitoring"),
|
||||
"observability": ("telemetry", "tracing"),
|
||||
"high availability": ("hoogbeschikbaarheid", "ha architecture"),
|
||||
"vmware": ("vsphere", "esxi"),
|
||||
"ci/cd": ("continuous integration", "continuous delivery", "ci cd"),
|
||||
"deployment": ("deployments", "software deployment", "uitrol"),
|
||||
"reliability": ("platform reliability", "site reliability", "sre"),
|
||||
"scalability": ("scalable", "schaalbaarheid"),
|
||||
"voip": ("voice over ip", "telefonie", "telephony", "3cx", "innovaphone"),
|
||||
"teams telephony": ("teams phone", "teams telefonie", "teams voice"),
|
||||
"onsite support": ("on-site support", "support op locatie", "field support"),
|
||||
"second line support": ("second-line support", "2nd line", "tweedelijnssupport"),
|
||||
"installations": ("installatie", "installaties", "roll-out", "rollout"),
|
||||
"migrations": ("migratie", "migraties"),
|
||||
"technical documentation": ("technische documentatie",),
|
||||
"customer support": ("klantondersteuning", "user support"),
|
||||
"incident management": ("incidentbeheer", "incident response", "escalations"),
|
||||
"problem management": ("probleembeheer", "root cause analysis"),
|
||||
"change management": ("wijzigingsbeheer", "organizational change"),
|
||||
"itil": ("itil 4", "it service management"),
|
||||
"servicenow": ("service now",),
|
||||
"jira service management": ("jira service desk", "jsm"),
|
||||
"digital workplace": ("digitale werkplek",),
|
||||
"m365 governance": ("microsoft 365 governance", "office 365 governance"),
|
||||
"document management": ("documentbeheer", "document management system", "dms"),
|
||||
"data governance": ("data governance", "datagovernance"),
|
||||
"user adoption": ("gebruikersadoptie", "technology adoption"),
|
||||
"training and workshops": ("user training", "workshops", "training geven"),
|
||||
"customer experience": ("customer satisfaction", "csat", "nps"),
|
||||
}
|
||||
|
||||
|
||||
def contains_term(text: str, term: str, *, allow_plural: bool = False) -> bool:
|
||||
normalized_term = normalize_token(term)
|
||||
if not normalized_term:
|
||||
return False
|
||||
pattern = re.escape(normalized_term).replace(r"\ ", r"[\s/_-]+")
|
||||
if allow_plural and normalized_term[-1].isalpha() and not normalized_term.endswith("s"):
|
||||
pattern += "s?"
|
||||
return re.search(rf"(?<!\w){pattern}(?!\w)", text, re.IGNORECASE) is not None
|
||||
|
||||
|
||||
def skill_terms(skill: str) -> tuple[str, ...]:
|
||||
normalized = normalize_token(skill)
|
||||
return (normalized, *SKILL_ALIASES.get(normalized, ()))
|
||||
|
||||
|
||||
def skill_is_present(skill: str, text: str) -> bool:
|
||||
return any(contains_term(text, candidate) for candidate in skill_terms(skill))
|
||||
|
||||
|
||||
def canonical_skill_key(value: str, catalog_keys: set[str]) -> str | None:
|
||||
normalized = normalize_token(value)
|
||||
if normalized in catalog_keys:
|
||||
return normalized
|
||||
for key in catalog_keys:
|
||||
if normalized in {normalize_token(term) for term in SKILL_ALIASES.get(key, ())}:
|
||||
return key
|
||||
return None
|
||||
@@ -1,20 +1,30 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
ActivityListView,
|
||||
ApplicationListView,
|
||||
ApplicationUpdateView,
|
||||
EmployerDetailView,
|
||||
EmployerListView,
|
||||
JobDetailView,
|
||||
JobListView,
|
||||
SkillInsightsView,
|
||||
application_delete,
|
||||
application_export,
|
||||
application_print,
|
||||
application_status,
|
||||
job_feedback,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", JobListView.as_view(), name="list"),
|
||||
path("skills/", SkillInsightsView.as_view(), name="skill-insights"),
|
||||
path("activity/", ActivityListView.as_view(), name="activity"),
|
||||
path("employers/", EmployerListView.as_view(), name="employers"),
|
||||
path("employers/<int:pk>/", EmployerDetailView.as_view(), name="employer-detail"),
|
||||
path("applications/", ApplicationListView.as_view(), name="applications"),
|
||||
path("applications/<int:pk>/", ApplicationUpdateView.as_view(), name="application-edit"),
|
||||
path("applications/<int:pk>/status/", application_status, name="application-status"),
|
||||
path("applications/<int:pk>/export/", application_export, name="application-export"),
|
||||
path("applications/<int:pk>/print/", application_print, name="application-print"),
|
||||
path("applications/<int:pk>/delete/", application_delete, name="application-delete"),
|
||||
|
||||
@@ -3,55 +3,173 @@ from __future__ import annotations
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import CharField, DecimalField, OuterRef, Q, Subquery
|
||||
from django.db.models import Count, F, Q
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
from django.views.generic import DetailView, ListView, TemplateView, UpdateView
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .forms import ApplicationForm
|
||||
from .models import Application, Feedback, JobPosting, ScoreRun
|
||||
from .models import Application, Employer, Feedback, JobPosting, ScoreRun
|
||||
from .services.activity import ACTIVITY_FILTERS, build_activity_page
|
||||
from .services.applications import (
|
||||
build_application_export,
|
||||
build_print_html,
|
||||
change_application_status,
|
||||
delete_application_dossier,
|
||||
track_application_changes,
|
||||
)
|
||||
from .services.cockpit import annotate_latest_profile_score, build_job_intelligence
|
||||
from .services.employer_intelligence import (
|
||||
build_employer_detail,
|
||||
employer_index_queryset,
|
||||
filter_employers,
|
||||
)
|
||||
from .services.feedback import record_feedback
|
||||
from .services.relevance import it_relevance_query
|
||||
from .services.skill_demand import build_skill_demand_report
|
||||
|
||||
|
||||
class SkillInsightsView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "jobs/skill_insights.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
context["active_profile"] = profile
|
||||
context["report"] = (
|
||||
build_skill_demand_report(
|
||||
profile,
|
||||
category=self.request.GET.get("category", "all"),
|
||||
coverage=self.request.GET.get("coverage", "all"),
|
||||
)
|
||||
if profile
|
||||
else None
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class ActivityListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "activity/list.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
event_type = self.request.GET.get("type", "all")
|
||||
valid_types = {value for value, _label in ACTIVITY_FILTERS}
|
||||
if event_type not in valid_types:
|
||||
event_type = "all"
|
||||
context.update(
|
||||
{
|
||||
"event_page": build_activity_page(
|
||||
user=self.request.user,
|
||||
event_type=event_type,
|
||||
query=self.request.GET.get("q", ""),
|
||||
page=self.request.GET.get("page", 1),
|
||||
),
|
||||
"activity_filters": ACTIVITY_FILTERS,
|
||||
"current_type": event_type,
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class EmployerListView(LoginRequiredMixin, ListView):
|
||||
model = Employer
|
||||
template_name = "employers/list.html"
|
||||
context_object_name = "employers"
|
||||
paginate_by = 40
|
||||
|
||||
def get_queryset(self):
|
||||
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
return filter_employers(
|
||||
employer_index_queryset(user=self.request.user, profile=self.profile),
|
||||
query=self.request.GET.get("q", ""),
|
||||
channel=self.request.GET.get("channel", ""),
|
||||
active_only=self.request.GET.get("active") == "1",
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
employers = list(context["employers"])
|
||||
selected_id = self.request.GET.get("selected", "")
|
||||
selected = next((item for item in employers if str(item.pk) == selected_id), None)
|
||||
if selected is None and employers:
|
||||
selected = employers[0]
|
||||
context.update(
|
||||
{
|
||||
"selected_employer": selected,
|
||||
"selected_detail": build_employer_detail(
|
||||
employer=selected, user=self.request.user, profile=self.profile
|
||||
)
|
||||
if selected
|
||||
else None,
|
||||
"active_profile": self.profile,
|
||||
"employer_total": Employer.objects.count(),
|
||||
"employer_active_total": Employer.objects.filter(
|
||||
jobs__status=JobPosting.Status.ACTIVE
|
||||
)
|
||||
.distinct()
|
||||
.count(),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class EmployerDetailView(LoginRequiredMixin, DetailView):
|
||||
model = Employer
|
||||
template_name = "employers/detail.html"
|
||||
context_object_name = "employer"
|
||||
|
||||
def get_queryset(self):
|
||||
self.profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
return employer_index_queryset(user=self.request.user, profile=self.profile)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
build_employer_detail(
|
||||
employer=self.object, user=self.request.user, profile=self.profile
|
||||
)
|
||||
)
|
||||
context["active_profile"] = self.profile
|
||||
return context
|
||||
|
||||
|
||||
class JobListView(LoginRequiredMixin, ListView):
|
||||
model = JobPosting
|
||||
template_name = "jobs/list.html"
|
||||
context_object_name = "jobs"
|
||||
paginate_by = 30
|
||||
paginate_by = 20
|
||||
|
||||
VALID_RECOMMENDATIONS = {
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
ScoreRun.Recommendation.WEAK,
|
||||
ScoreRun.Recommendation.HIDDEN,
|
||||
}
|
||||
VALID_MATCH_FILTERS = {*VALID_RECOMMENDATIONS, "radar", "all"}
|
||||
VALID_SORTS = {"match", "newest", "closing"}
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = JobPosting.objects.select_related("employer").all()
|
||||
queryset = JobPosting.objects.select_related("employer").annotate(
|
||||
duplicate_count=Count("duplicates", distinct=True)
|
||||
)
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
if profile:
|
||||
latest_score = ScoreRun.objects.filter(job=OuterRef("pk"), profile=profile).order_by(
|
||||
"-created_at"
|
||||
)
|
||||
queryset = queryset.annotate(
|
||||
match_score=Subquery(
|
||||
latest_score.values("score")[:1],
|
||||
output_field=DecimalField(max_digits=5, decimal_places=2),
|
||||
),
|
||||
match_confidence=Subquery(
|
||||
latest_score.values("confidence")[:1],
|
||||
output_field=DecimalField(max_digits=4, decimal_places=3),
|
||||
),
|
||||
match_recommendation=Subquery(
|
||||
latest_score.values("recommendation")[:1], output_field=CharField()
|
||||
),
|
||||
)
|
||||
queryset = annotate_latest_profile_score(queryset, profile)
|
||||
query = self.request.GET.get("q", "").strip()
|
||||
status = self.request.GET.get("status", "active").strip()
|
||||
workplace = self.request.GET.get("workplace", "").strip()
|
||||
channel = self.request.GET.get("channel", "").strip()
|
||||
focus = self.request.GET.get("focus", "it").strip()
|
||||
recommendation = self.request.GET.get("match", "radar").strip()
|
||||
sort = self.request.GET.get("sort", "match").strip()
|
||||
if recommendation not in self.VALID_MATCH_FILTERS:
|
||||
recommendation = "radar"
|
||||
if query:
|
||||
queryset = queryset.filter(
|
||||
Q(original_title__icontains=query)
|
||||
@@ -63,7 +181,68 @@ class JobListView(LoginRequiredMixin, ListView):
|
||||
queryset = queryset.filter(status=status)
|
||||
if workplace:
|
||||
queryset = queryset.filter(workplace_type=workplace)
|
||||
return queryset.order_by("-first_seen")
|
||||
if channel == "direct":
|
||||
queryset = queryset.filter(direct_employer=True, recruiter=False)
|
||||
elif channel == "recruiter":
|
||||
queryset = queryset.filter(recruiter=True)
|
||||
if focus != "all":
|
||||
queryset = queryset.filter(it_relevance_query())
|
||||
if profile and recommendation == "radar":
|
||||
queryset = queryset.filter(
|
||||
Q(
|
||||
match_recommendation__in=[
|
||||
ScoreRun.Recommendation.STRONG,
|
||||
ScoreRun.Recommendation.POSSIBLE,
|
||||
ScoreRun.Recommendation.WEAK,
|
||||
]
|
||||
)
|
||||
| Q(match_recommendation__isnull=True)
|
||||
)
|
||||
elif profile and recommendation in self.VALID_RECOMMENDATIONS:
|
||||
queryset = queryset.filter(match_recommendation=recommendation)
|
||||
if sort not in self.VALID_SORTS:
|
||||
sort = "match"
|
||||
if sort == "newest":
|
||||
return queryset.order_by("-first_seen", F("match_score").desc(nulls_last=True))
|
||||
if sort == "closing":
|
||||
return queryset.order_by(F("valid_through").asc(nulls_last=True), "-first_seen")
|
||||
return queryset.order_by(F("match_score").desc(nulls_last=True), "-first_seen")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
active_jobs = JobPosting.objects.filter(status=JobPosting.Status.ACTIVE)
|
||||
it_jobs = active_jobs.filter(it_relevance_query())
|
||||
context.update(
|
||||
{
|
||||
"active_profile": SearchProfile.objects.filter(
|
||||
user=self.request.user, is_active=True
|
||||
).first(),
|
||||
"active_job_count": active_jobs.count(),
|
||||
"it_job_count": it_jobs.count(),
|
||||
"filtered_non_it_count": active_jobs.exclude(it_relevance_query()).count(),
|
||||
"current_focus": self.request.GET.get("focus", "it"),
|
||||
"current_match": (
|
||||
self.request.GET.get("match", "radar")
|
||||
if self.request.GET.get("match", "radar") in self.VALID_MATCH_FILTERS
|
||||
else "radar"
|
||||
),
|
||||
"current_sort": self.request.GET.get("sort", "match"),
|
||||
"current_channel": self.request.GET.get("channel", ""),
|
||||
}
|
||||
)
|
||||
jobs = list(context["jobs"])
|
||||
selected_id = self.request.GET.get("selected", "")
|
||||
selected_job = next((job for job in jobs if str(job.pk) == selected_id), None)
|
||||
if selected_job is None and jobs:
|
||||
selected_job = jobs[0]
|
||||
context["selected_job"] = selected_job
|
||||
if selected_job is not None:
|
||||
context["selected_intelligence"] = build_job_intelligence(
|
||||
job=selected_job,
|
||||
profile=context["active_profile"],
|
||||
user=self.request.user,
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class JobDetailView(LoginRequiredMixin, DetailView):
|
||||
@@ -79,16 +258,9 @@ class JobDetailView(LoginRequiredMixin, DetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
profile = SearchProfile.objects.filter(user=self.request.user, is_active=True).first()
|
||||
context["score"] = (
|
||||
ScoreRun.objects.filter(job=self.object, profile=profile)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
if profile
|
||||
else None
|
||||
context.update(
|
||||
build_job_intelligence(job=self.object, profile=profile, user=self.request.user)
|
||||
)
|
||||
context["application"] = Application.objects.filter(
|
||||
user=self.request.user, job=self.object
|
||||
).first()
|
||||
context["latest_feedback"] = Feedback.objects.filter(
|
||||
user=self.request.user, job=self.object
|
||||
).first()
|
||||
@@ -113,7 +285,13 @@ def job_feedback(request, pk):
|
||||
)
|
||||
messages.success(request, "Actie opgeslagen.")
|
||||
next_url = request.POST.get("next")
|
||||
return redirect(next_url or reverse("jobs:detail", kwargs={"pk": pk}))
|
||||
if next_url and url_has_allowed_host_and_scheme(
|
||||
next_url,
|
||||
allowed_hosts={request.get_host()},
|
||||
require_https=request.is_secure(),
|
||||
):
|
||||
return redirect(next_url)
|
||||
return redirect(reverse("jobs:detail", kwargs={"pk": pk}))
|
||||
|
||||
|
||||
class ApplicationListView(LoginRequiredMixin, ListView):
|
||||
@@ -122,8 +300,10 @@ class ApplicationListView(LoginRequiredMixin, ListView):
|
||||
context_object_name = "applications"
|
||||
|
||||
def get_queryset(self):
|
||||
return Application.objects.filter(user=self.request.user).select_related(
|
||||
"job", "job__employer"
|
||||
return (
|
||||
Application.objects.filter(user=self.request.user)
|
||||
.select_related("job", "job__employer")
|
||||
.prefetch_related("timeline_events")
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -138,6 +318,16 @@ class ApplicationListView(LoginRequiredMixin, ListView):
|
||||
}
|
||||
for status, label in Application.Status.choices
|
||||
]
|
||||
context["status_choices"] = Application.Status.choices
|
||||
context["open_count"] = sum(
|
||||
item.status not in {Application.Status.REJECTED, Application.Status.WITHDRAWN}
|
||||
for item in applications
|
||||
)
|
||||
context["follow_up_count"] = sum(
|
||||
bool(item.follow_up_date and item.follow_up_date <= timezone.localdate())
|
||||
for item in applications
|
||||
)
|
||||
context["today"] = timezone.localdate()
|
||||
return context
|
||||
|
||||
|
||||
@@ -147,7 +337,11 @@ class ApplicationUpdateView(LoginRequiredMixin, UpdateView):
|
||||
template_name = "applications/edit.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return Application.objects.filter(user=self.request.user).select_related("job")
|
||||
return (
|
||||
Application.objects.filter(user=self.request.user)
|
||||
.select_related("job", "job__employer")
|
||||
.prefetch_related("timeline_events")
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
previous = (
|
||||
@@ -185,6 +379,23 @@ def application_export(request, pk: int):
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def application_status(request, pk: int):
|
||||
application = get_object_or_404(Application, pk=pk, user=request.user)
|
||||
try:
|
||||
change_application_status(
|
||||
application=application,
|
||||
user=request.user,
|
||||
status=request.POST.get("status", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
messages.error(request, str(exc))
|
||||
else:
|
||||
messages.success(request, "Pipelinestatus bijgewerkt.")
|
||||
return redirect("jobs:applications")
|
||||
|
||||
|
||||
@login_required
|
||||
def application_print(request, pk: int):
|
||||
application = get_object_or_404(
|
||||
|
||||
@@ -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
|
||||
@@ -3,13 +3,12 @@ from __future__ import annotations
|
||||
|
||||
def default_weights() -> dict[str, float]:
|
||||
return {
|
||||
"content": 25.0,
|
||||
"skills": 20.0,
|
||||
"location": 15.0,
|
||||
"content": 30.0,
|
||||
"skills": 25.0,
|
||||
"location": 20.0,
|
||||
"conditions": 10.0,
|
||||
"employer": 10.0,
|
||||
"seniority": 10.0,
|
||||
"preferences": 10.0,
|
||||
"preferences": 5.0,
|
||||
"ai": 0.0,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,55 +5,12 @@ import re
|
||||
from django import forms
|
||||
|
||||
from .models import SearchProfile
|
||||
|
||||
TITLE_CHOICES = (
|
||||
("system engineer", "System engineer"),
|
||||
("infrastructure engineer", "Infrastructure engineer"),
|
||||
("cloud engineer", "Cloud engineer"),
|
||||
("devops engineer", "DevOps engineer"),
|
||||
("network engineer", "Network engineer"),
|
||||
("security engineer", "Security engineer"),
|
||||
("workplace engineer", "Workplace engineer"),
|
||||
("support engineer", "Support engineer"),
|
||||
("software engineer", "Software engineer"),
|
||||
("data engineer", "Data engineer"),
|
||||
("solution architect", "Solution architect"),
|
||||
("project manager", "Projectmanager"),
|
||||
)
|
||||
|
||||
EXCLUDED_TITLE_CHOICES = (
|
||||
("sales", "Sales"),
|
||||
("recruiter", "Recruiter"),
|
||||
("account manager", "Accountmanager"),
|
||||
("callcenter", "Callcenter"),
|
||||
("stage", "Stage"),
|
||||
("student", "Studentenjob"),
|
||||
)
|
||||
|
||||
SKILL_CHOICES = (
|
||||
("azure", "Microsoft Azure"),
|
||||
("aws", "AWS"),
|
||||
("microsoft 365", "Microsoft 365"),
|
||||
("active directory", "Active Directory"),
|
||||
("entra id", "Entra ID"),
|
||||
("intune", "Microsoft Intune"),
|
||||
("linux", "Linux"),
|
||||
("windows server", "Windows Server"),
|
||||
("networking", "Netwerken"),
|
||||
("security", "Security"),
|
||||
("python", "Python"),
|
||||
("powershell", "PowerShell"),
|
||||
("terraform", "Terraform"),
|
||||
("docker", "Docker"),
|
||||
("kubernetes", "Kubernetes"),
|
||||
("ci/cd", "CI/CD"),
|
||||
)
|
||||
|
||||
EXCLUDED_SKILL_CHOICES = (
|
||||
("cold calling", "Cold calling"),
|
||||
("door to door", "Deur-aan-deurverkoop"),
|
||||
("commission only", "Alleen commissieloon"),
|
||||
("night shift", "Nachtwerk"),
|
||||
from .taxonomy import (
|
||||
EXCLUDED_SKILL_CHOICES,
|
||||
EXCLUDED_TITLE_CHOICES,
|
||||
SKILL_CHOICES,
|
||||
TITLE_CHOICES,
|
||||
iter_choices,
|
||||
)
|
||||
|
||||
EMPLOYMENT_CHOICES = (
|
||||
@@ -91,27 +48,31 @@ class SearchProfileForm(forms.ModelForm):
|
||||
label="Gewenste functietitels",
|
||||
required=False,
|
||||
choices=TITLE_CHOICES,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
help_text="Selecteer alle rollen die bij je zoekrichting passen.",
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||
help_text="Selecteer alle rollen die bij je zoekrichting passen; de beste titelmatch telt.",
|
||||
)
|
||||
excluded_titles = forms.MultipleChoiceField(
|
||||
label="Uitgesloten functietitels",
|
||||
required=False,
|
||||
choices=EXCLUDED_TITLE_CHOICES,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||
help_text="Vacatures met deze titelwoorden worden hard uitgesloten.",
|
||||
)
|
||||
desired_skills = forms.MultipleChoiceField(
|
||||
label="Gewenste skills",
|
||||
required=False,
|
||||
choices=SKILL_CHOICES,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||
help_text=(
|
||||
"Kies je herkenbare cv-skills ruim. Maximaal vier aangetroffen skills volstaan voor "
|
||||
"de volledige skillcomponent; extra keuzes verwateren je score niet."
|
||||
),
|
||||
)
|
||||
excluded_skills = forms.MultipleChoiceField(
|
||||
label="Uitgesloten kenmerken",
|
||||
required=False,
|
||||
choices=EXCLUDED_SKILL_CHOICES,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "grouped-choices"}),
|
||||
)
|
||||
allowed_employment_types = forms.MultipleChoiceField(
|
||||
label="Toegestane contractvormen",
|
||||
@@ -145,6 +106,7 @@ class SearchProfileForm(forms.ModelForm):
|
||||
"is_active",
|
||||
"home_postal_code",
|
||||
"max_distance_km",
|
||||
"experience_years",
|
||||
"desired_titles",
|
||||
"excluded_titles",
|
||||
"desired_skills",
|
||||
@@ -163,6 +125,7 @@ class SearchProfileForm(forms.ModelForm):
|
||||
"is_active": "Actief profiel",
|
||||
"home_postal_code": "Thuispostcode",
|
||||
"max_distance_km": "Maximale afstand (km)",
|
||||
"experience_years": "Relevante IT-ervaring",
|
||||
"recommendation_threshold": "Aanbevelingsdrempel",
|
||||
"top_match_threshold": "Topmatchdrempel",
|
||||
"digest_time": "Tijdstip dagelijkse samenvatting",
|
||||
@@ -173,6 +136,10 @@ class SearchProfileForm(forms.ModelForm):
|
||||
"Vier cijfers volstaan. Gemeente en coördinaten worden veilig afgeleid wanneer "
|
||||
"lokale geodata beschikbaar is."
|
||||
),
|
||||
"experience_years": (
|
||||
"Optionele profielcontext. Deze waarde telt nooit mee voor je matchscore en "
|
||||
"kan een vacature niet uitsluiten."
|
||||
),
|
||||
"learning_enabled": "Past alleen zachte voorkeuren aan; harde regels blijven vast.",
|
||||
}
|
||||
widgets = {
|
||||
@@ -203,14 +170,15 @@ class SearchProfileForm(forms.ModelForm):
|
||||
field = self.fields[field_name]
|
||||
stored = getattr(self.instance, field_name, [])
|
||||
existing_by_casefold = {
|
||||
str(value).casefold(): str(value) for value, _label in field.choices
|
||||
str(value).casefold(): str(value) for value, _label in iter_choices(field.choices)
|
||||
}
|
||||
extra = [
|
||||
(str(value), str(value))
|
||||
for value in stored
|
||||
if str(value).casefold() not in existing_by_casefold
|
||||
]
|
||||
field.choices = [*field.choices, *extra]
|
||||
if extra:
|
||||
field.choices = [*field.choices, ("Eerder opgeslagen", tuple(extra))]
|
||||
field.initial = [
|
||||
existing_by_casefold.get(str(value).casefold(), str(value)) for value in stored
|
||||
]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-07-22 13:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0004_reminder_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='searchprofile',
|
||||
name='experience_years',
|
||||
field=models.PositiveSmallIntegerField(choices=[(0, 'Niet ingesteld'), (1, 'Minder dan 2 jaar'), (3, '2 tot 4 jaar'), (5, '5 tot 7 jaar'), (8, '8 tot 9 jaar'), (10, '10 tot 14 jaar'), (15, '15 jaar of meer')], default=0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_seniority_weight(apps, schema_editor):
|
||||
search_profile = apps.get_model("profiles", "SearchProfile")
|
||||
for profile in search_profile.objects.all().iterator():
|
||||
weights = dict(profile.weights or {})
|
||||
if "seniority" not in weights:
|
||||
continue
|
||||
weights.pop("seniority", None)
|
||||
profile.weights = weights
|
||||
profile.save(update_fields=["weights"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("profiles", "0005_searchprofile_experience_years"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_seniority_weight, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -16,6 +16,16 @@ from .defaults import (
|
||||
default_weights,
|
||||
)
|
||||
|
||||
EXPERIENCE_YEARS_CHOICES = (
|
||||
(0, "Niet ingesteld"),
|
||||
(1, "Minder dan 2 jaar"),
|
||||
(3, "2 tot 4 jaar"),
|
||||
(5, "5 tot 7 jaar"),
|
||||
(8, "8 tot 9 jaar"),
|
||||
(10, "10 tot 14 jaar"),
|
||||
(15, "15 jaar of meer"),
|
||||
)
|
||||
|
||||
|
||||
class SearchProfile(TimeStampedModel):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
@@ -29,6 +39,10 @@ class SearchProfile(TimeStampedModel):
|
||||
home_latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
home_longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
max_distance_km = models.PositiveIntegerField(default=45)
|
||||
experience_years = models.PositiveSmallIntegerField(
|
||||
choices=EXPERIENCE_YEARS_CHOICES,
|
||||
default=0,
|
||||
)
|
||||
|
||||
desired_titles = models.JSONField(default=list, blank=True)
|
||||
excluded_titles = models.JSONField(default=list, blank=True)
|
||||
@@ -105,6 +119,7 @@ class SearchProfile(TimeStampedModel):
|
||||
if self.home_longitude is not None
|
||||
else None,
|
||||
"max_distance_km": self.max_distance_km,
|
||||
"experience_years": self.experience_years,
|
||||
"desired_titles": self.desired_titles,
|
||||
"excluded_titles": self.excluded_titles,
|
||||
"desired_skills": self.desired_skills,
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
|
||||
Choice = tuple[str, str]
|
||||
ChoiceGroup = tuple[str, tuple[Choice, ...]]
|
||||
|
||||
|
||||
TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||
(
|
||||
"Field service & support",
|
||||
(
|
||||
("it field engineer", "IT Field Engineer"),
|
||||
("field service engineer", "Field Service Engineer"),
|
||||
("it engineer", "IT Engineer"),
|
||||
("it support engineer", "IT Support Engineer"),
|
||||
("support engineer", "Support Engineer"),
|
||||
("onsite support engineer", "On-site Support Engineer"),
|
||||
("service desk engineer", "Service Desk Engineer"),
|
||||
("technical support engineer", "Technical Support Engineer"),
|
||||
("it technician", "IT Technician"),
|
||||
("pc technician", "PC Technician"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Systemen & infrastructuur",
|
||||
(
|
||||
("system engineer", "System Engineer"),
|
||||
("system network engineer", "System & Network Engineer"),
|
||||
("infrastructure engineer", "Infrastructure Engineer"),
|
||||
("system administrator", "System Administrator"),
|
||||
("systeembeheerder", "Systeembeheerder"),
|
||||
("network engineer", "Network Engineer"),
|
||||
("network administrator", "Network Administrator"),
|
||||
("netwerkbeheerder", "Netwerkbeheerder"),
|
||||
("infrastructure consultant", "Infrastructure Consultant"),
|
||||
("implementation engineer", "Implementation Engineer"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Modern Workplace & endpoint",
|
||||
(
|
||||
("workplace engineer", "Workplace Engineer"),
|
||||
("modern workplace engineer", "Modern Workplace Engineer"),
|
||||
("microsoft 365 engineer", "Microsoft 365 Engineer"),
|
||||
("microsoft 365 consultant", "Microsoft 365 Consultant"),
|
||||
("modern workplace consultant", "Modern Workplace Consultant"),
|
||||
("endpoint engineer", "Endpoint Engineer"),
|
||||
("intune engineer", "Intune Engineer"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Andere IT-richtingen",
|
||||
(
|
||||
("cloud engineer", "Cloud Engineer"),
|
||||
("devops engineer", "DevOps Engineer"),
|
||||
("security engineer", "Security Engineer"),
|
||||
("platform engineer", "Platform Engineer"),
|
||||
("software engineer", "Software Engineer"),
|
||||
("data engineer", "Data Engineer"),
|
||||
("solution architect", "Solution Architect"),
|
||||
("project manager", "IT-projectmanager"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
EXCLUDED_TITLE_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||
(
|
||||
"Commercieel & werving",
|
||||
(
|
||||
("sales", "Sales"),
|
||||
("recruiter", "Recruiter"),
|
||||
("account manager", "Accountmanager"),
|
||||
("business developer", "Business Developer"),
|
||||
("callcenter", "Callcenter"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Software, data & analyse",
|
||||
(
|
||||
("software developer", "Software Developer"),
|
||||
("software engineer", "Software Engineer"),
|
||||
("web developer", "Web Developer"),
|
||||
("developer", "Developmentfuncties (breed)"),
|
||||
("software", "Softwarefuncties (breed)"),
|
||||
("backend", "Backendfuncties"),
|
||||
("frontend", "Frontendfuncties"),
|
||||
("php", "PHP-ontwikkeling"),
|
||||
("data engineer", "Data Engineer"),
|
||||
("data scientist", "Data Scientist"),
|
||||
("data", "Datafuncties (breed)"),
|
||||
("ai engineer", "AI Engineer"),
|
||||
("ai", "AI-functies (breed)"),
|
||||
("machine learning", "Machine Learning"),
|
||||
("functional analyst", "Functional Analyst"),
|
||||
("functioneel analist", "Functioneel Analist"),
|
||||
("business analyst", "Business Analyst"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Management & starters",
|
||||
(
|
||||
("product owner", "Product Owner"),
|
||||
("project manager", "Projectmanager"),
|
||||
("architect", "Architectrollen"),
|
||||
("service delivery manager", "Service Delivery Manager"),
|
||||
("team lead", "Teamlead"),
|
||||
("head of", "Head-of-rollen"),
|
||||
("procurement", "Procurement"),
|
||||
("stage", "Stage"),
|
||||
("internship", "Internship"),
|
||||
("student", "Studentenjob"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Niet-IT-techniek",
|
||||
(
|
||||
("electromechanical", "Elektromechanica"),
|
||||
("maintenance technician", "Onderhoudstechnieker"),
|
||||
("process engineer", "Process Engineer"),
|
||||
("automation engineer", "Automation Engineer"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Onderzoek & beeldverwerking",
|
||||
(
|
||||
("research", "Onderzoeksfuncties"),
|
||||
("scientist", "Scientist"),
|
||||
("image processing", "Beeldverwerking"),
|
||||
("remote sensing", "Remote sensing"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Cloud & businessplatformen",
|
||||
(
|
||||
("cloud", "Cloudfuncties (breed)"),
|
||||
("devops", "DevOps"),
|
||||
("azure consultant", "Azure Consultant"),
|
||||
("sap", "SAP"),
|
||||
("power platform", "Power Platform"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Onderwijs",
|
||||
(
|
||||
("lecturer", "Lecturer"),
|
||||
("lector", "Lector"),
|
||||
("docent", "Docent"),
|
||||
("gastdocent", "Gastdocent"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||
(
|
||||
"Field service & uitvoering",
|
||||
(
|
||||
("troubleshooting", "Troubleshooting"),
|
||||
("onsite support", "On-site support"),
|
||||
("hardware", "Hardware & randapparatuur"),
|
||||
("installations", "Installaties & roll-outs"),
|
||||
("migrations", "Migraties"),
|
||||
("technical documentation", "Technische documentatie"),
|
||||
("customer support", "Klantondersteuning"),
|
||||
("second line support", "Tweede- en derdelijnssupport"),
|
||||
("customer experience", "Klanttevredenheid / CSAT"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Microsoft & endpoint",
|
||||
(
|
||||
("microsoft 365", "Microsoft 365"),
|
||||
("exchange online", "Exchange Online"),
|
||||
("sharepoint", "SharePoint"),
|
||||
("microsoft teams", "Microsoft Teams"),
|
||||
("entra id", "Entra ID"),
|
||||
("intune", "Microsoft Intune"),
|
||||
("autopilot", "Windows Autopilot"),
|
||||
("windows server", "Windows Server"),
|
||||
("active directory", "Active Directory"),
|
||||
("group policy", "Group Policy / GPO"),
|
||||
("dynamics 365", "Microsoft Dynamics 365"),
|
||||
("microsoft copilot", "Microsoft Copilot"),
|
||||
("microsoft defender", "Microsoft Defender"),
|
||||
("microsoft sentinel", "Microsoft Sentinel"),
|
||||
("conditional access", "Conditional Access"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Netwerk & security",
|
||||
(
|
||||
("networking", "Netwerken"),
|
||||
("tcp/ip", "TCP/IP"),
|
||||
("vlan", "VLAN"),
|
||||
("vpn", "VPN"),
|
||||
("dhcp", "DHCP"),
|
||||
("dns", "DNS"),
|
||||
("firewalls", "Firewalls"),
|
||||
("watchguard", "WatchGuard"),
|
||||
("cisco", "Cisco"),
|
||||
("ubiquiti", "Ubiquiti"),
|
||||
("wi-fi", "Wi-Fi"),
|
||||
("routing", "Routing"),
|
||||
("switching", "Switching"),
|
||||
("sd-wan", "SD-WAN"),
|
||||
("fortinet", "Fortinet / FortiGate"),
|
||||
("palo alto", "Palo Alto Networks"),
|
||||
("aruba", "HPE Aruba"),
|
||||
("meraki", "Cisco Meraki"),
|
||||
("sophos", "Sophos"),
|
||||
("zero trust", "Zero Trust"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Back-up & monitoring",
|
||||
(
|
||||
("backup and restore", "Back-up & restore"),
|
||||
("veeam", "Veeam"),
|
||||
("windows server backup", "Windows Server Backup"),
|
||||
("synology", "Synology"),
|
||||
("zabbix", "Zabbix"),
|
||||
("monitoring", "Infrastructuurmonitoring"),
|
||||
("observability", "Observability"),
|
||||
("high availability", "High availability"),
|
||||
("disaster recovery", "Disaster recovery"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Virtualisatie & platform",
|
||||
(
|
||||
("vmware", "VMware"),
|
||||
("proxmox", "Proxmox"),
|
||||
("hyper-v", "Hyper-V"),
|
||||
("docker", "Docker"),
|
||||
("azure", "Microsoft Azure"),
|
||||
("aws", "AWS"),
|
||||
("linux", "Linux"),
|
||||
("kubernetes", "Kubernetes"),
|
||||
("terraform", "Terraform"),
|
||||
("ci/cd", "CI/CD"),
|
||||
("deployment", "Deployment & uitrol"),
|
||||
("reliability", "Platform reliability"),
|
||||
("scalability", "Schaalbaarheid"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"VoIP & telefonie",
|
||||
(
|
||||
("voip", "VoIP"),
|
||||
("3cx", "3CX"),
|
||||
("innovaphone", "Innovaphone"),
|
||||
("teams telephony", "Teams-telefonie"),
|
||||
("yealink", "Yealink"),
|
||||
("snom", "Snom"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Automation & scripting",
|
||||
(
|
||||
("powershell", "PowerShell"),
|
||||
("bash", "Bash"),
|
||||
("python", "Python"),
|
||||
("c#", "C#"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"IT-servicemanagement",
|
||||
(
|
||||
("itil", "ITIL / IT-servicemanagement"),
|
||||
("incident management", "Incidentmanagement"),
|
||||
("problem management", "Problemmanagement"),
|
||||
("change management", "Changemanagement"),
|
||||
("servicenow", "ServiceNow"),
|
||||
("jira service management", "Jira Service Management"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Digitale werkplek & adoptie",
|
||||
(
|
||||
("digital workplace", "Digital Workplace"),
|
||||
("m365 governance", "Microsoft 365-governance"),
|
||||
("document management", "Documentmanagement"),
|
||||
("data governance", "Datagovernance"),
|
||||
("user adoption", "Gebruikersadoptie"),
|
||||
("training and workshops", "Training & workshops"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
EXCLUDED_SKILL_CHOICES: tuple[ChoiceGroup, ...] = (
|
||||
(
|
||||
"Commerciële voorwaarden",
|
||||
(
|
||||
("cold calling", "Cold calling"),
|
||||
("door to door", "Deur-aan-deurverkoop"),
|
||||
("commission only", "Alleen commissieloon"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"Werkregeling",
|
||||
(
|
||||
("night shift", "Nachtwerk"),
|
||||
("rotating shifts", "Ploegendienst"),
|
||||
("on-call duty", "Structurele wachtdienst"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def iter_choices(choices: Iterable[Choice | tuple[str, Sequence[Choice]]]) -> Iterator[Choice]:
|
||||
"""Flatten Django optgroups while retaining compatibility with legacy flat choices."""
|
||||
for value, label_or_choices in choices:
|
||||
if isinstance(label_or_choices, tuple | list):
|
||||
yield from iter_choices(label_or_choices)
|
||||
else:
|
||||
yield str(value), str(label_or_choices)
|
||||
@@ -6,6 +6,8 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import ListView, UpdateView
|
||||
|
||||
from apps.jobs.models import JobPosting, ScoreRun
|
||||
|
||||
from .forms import SearchProfileForm
|
||||
from .models import SearchProfile
|
||||
from .services import resolve_profile_home_location, save_profile_revision
|
||||
@@ -29,6 +31,21 @@ class ProfileUpdateView(LoginRequiredMixin, UpdateView):
|
||||
def get_queryset(self):
|
||||
return SearchProfile.objects.filter(user=self.request.user)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["preview_score"] = (
|
||||
ScoreRun.objects.filter(
|
||||
profile=self.object,
|
||||
job__status=JobPosting.Status.ACTIVE,
|
||||
hard_exclusions=[],
|
||||
)
|
||||
.select_related("job", "job__employer")
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
context["recent_revisions"] = list(self.object.revisions.all()[:4])
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
location_result = resolve_profile_home_location(self.object)
|
||||
|
||||
@@ -9,13 +9,15 @@ from .base import ExtractedJob, ExtractionResult, FieldEvidence
|
||||
|
||||
LABEL_PATTERNS = {
|
||||
"location": re.compile(r"^(locatie|location|lieu|plaats|standplaats)\s*:?$", re.I),
|
||||
"date_posted": re.compile(r"^(publicatiedatum|geplaatst|date posted|published)\s*:?$", re.I),
|
||||
"employment_type": re.compile(r"^(dienstverband|contract|employment type)\s*:?$", re.I),
|
||||
"employer": re.compile(r"^(werkgever|employer|company|organisatie|société)\s*:?$", re.I),
|
||||
}
|
||||
|
||||
|
||||
class GenericHtmlAdapter:
|
||||
parser_key = "generic-html"
|
||||
parser_version = "1.0.0"
|
||||
parser_version = "1.1.0"
|
||||
|
||||
@staticmethod
|
||||
def _meta(soup: BeautifulSoup, *names: str) -> str:
|
||||
@@ -59,6 +61,8 @@ class GenericHtmlAdapter:
|
||||
soup, LABEL_PATTERNS["employer"]
|
||||
)
|
||||
location = self._label_value(soup, LABEL_PATTERNS["location"])
|
||||
date_posted = self._label_value(soup, LABEL_PATTERNS["date_posted"])
|
||||
employment_type = self._label_value(soup, LABEL_PATTERNS["employment_type"])
|
||||
main = soup.find("main") or soup.find("article") or soup.body
|
||||
description_html = str(main) if main else ""
|
||||
description_text = (
|
||||
@@ -74,6 +78,12 @@ class GenericHtmlAdapter:
|
||||
FieldEvidence("location_text", "html-label", 0.62, location[:240]),
|
||||
FieldEvidence("description", "html-main", 0.70, description_text[:300]),
|
||||
]
|
||||
if date_posted:
|
||||
evidence.append(FieldEvidence("date_posted", "html-label", 0.62, date_posted[:40]))
|
||||
if employment_type:
|
||||
evidence.append(
|
||||
FieldEvidence("employment_types", "html-label", 0.62, employment_type[:120])
|
||||
)
|
||||
job = ExtractedJob(
|
||||
url=job_url,
|
||||
title=title,
|
||||
@@ -81,6 +91,8 @@ class GenericHtmlAdapter:
|
||||
location_text=location,
|
||||
description_html=description_html,
|
||||
description_text=description_text,
|
||||
date_posted=date_posted,
|
||||
employment_types=[employment_type] if employment_type else [],
|
||||
raw={"generic_html": True},
|
||||
evidence=evidence,
|
||||
)
|
||||
|
||||
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
|
||||
password = forms.CharField(
|
||||
required=False,
|
||||
label="App-wachtwoord",
|
||||
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.",
|
||||
help_text=(
|
||||
"Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt "
|
||||
"de centrale OAuth-configuratie."
|
||||
),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
|
||||
render_value=False,
|
||||
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
|
||||
data = super().clean()
|
||||
if data.get("provider") != MailboxConnection.Provider.CUSTOM:
|
||||
data["custom_host"] = ""
|
||||
if not self.instance.pk and not data.get("password"):
|
||||
uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK
|
||||
if not self.instance.pk and not uses_oauth and not data.get("password"):
|
||||
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
|
||||
return data
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("sources", "0007_alter_mailboxconnection_platform")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="mailboxconnection",
|
||||
name="encrypted_password",
|
||||
field=models.TextField(blank=True),
|
||||
)
|
||||
]
|
||||
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
|
||||
custom_host = models.CharField(max_length=255, blank=True)
|
||||
port = models.PositiveIntegerField(default=993)
|
||||
username = models.CharField(max_length=320)
|
||||
encrypted_password = models.TextField()
|
||||
encrypted_password = models.TextField(blank=True)
|
||||
mailbox = models.CharField(max_length=255, default="INBOX")
|
||||
enabled = models.BooleanField(default=True)
|
||||
poll_interval_minutes = models.PositiveIntegerField(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
@@ -53,6 +54,43 @@ class FetchTimeoutError(FetchError):
|
||||
pass
|
||||
|
||||
|
||||
def _connected_peer_ip(
|
||||
response: httpx.Response,
|
||||
) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||
"""Return the actual remote peer when httpcore exposes its network stream."""
|
||||
stream = response.extensions.get("network_stream")
|
||||
if stream is None or not hasattr(stream, "get_extra_info"):
|
||||
return None
|
||||
try:
|
||||
server_address = stream.get_extra_info("server_addr")
|
||||
raw_address = server_address[0] if server_address else None
|
||||
return ipaddress.ip_address(raw_address) if raw_address else None
|
||||
except (IndexError, TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _validate_connected_peer(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
before: ValidatedUrl,
|
||||
after: ValidatedUrl,
|
||||
require_peer: bool,
|
||||
) -> None:
|
||||
peer_ip = _connected_peer_ip(response)
|
||||
if peer_ip is not None:
|
||||
if not peer_ip.is_global:
|
||||
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
|
||||
return
|
||||
if require_peer:
|
||||
raise FetchError(
|
||||
"Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd."
|
||||
)
|
||||
# Mock/custom transports do not always expose the peer socket. Keep the conservative
|
||||
# DNS-overlap fallback only for explicitly injected test/custom clients.
|
||||
if not set(before.addresses).intersection(after.addresses):
|
||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||
|
||||
|
||||
def parse_retry_after(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
@@ -127,26 +165,23 @@ def fetch_url(
|
||||
verify=trusted_tls_context(),
|
||||
)
|
||||
current_url = url
|
||||
previous_validation: ValidatedUrl | None = None
|
||||
try:
|
||||
for _ in range(settings.FETCHER_MAX_REDIRECTS + 1):
|
||||
validation = validate_public_url(
|
||||
current_url,
|
||||
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
|
||||
)
|
||||
if (
|
||||
previous_validation is not None
|
||||
and validation.hostname == previous_validation.hostname
|
||||
and not set(validation.addresses).intersection(previous_validation.addresses)
|
||||
):
|
||||
raise FetchError("DNS-rebindcontrole faalde bij redirect naar dezelfde host.")
|
||||
response = http_client.get(current_url, headers=headers)
|
||||
post_validation = validate_public_url(
|
||||
str(response.url) if response.url else current_url,
|
||||
allow_nonstandard_ports=settings.FETCHER_ALLOW_NONSTANDARD_PORTS,
|
||||
)
|
||||
if not set(validation.addresses).intersection(set(post_validation.addresses)):
|
||||
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
|
||||
_validate_connected_peer(
|
||||
response,
|
||||
before=validation,
|
||||
after=post_validation,
|
||||
require_peer=own_client,
|
||||
)
|
||||
if response.status_code in {301, 302, 303, 307, 308}:
|
||||
location = response.headers.get("location")
|
||||
if not location:
|
||||
@@ -155,7 +190,6 @@ def fetch_url(
|
||||
next_decision = assess_url(current_url, source=source)
|
||||
if not next_decision.allowed:
|
||||
raise PolicyBlockedError(next_decision.reason)
|
||||
previous_validation = validation
|
||||
continue
|
||||
if response.status_code == 304:
|
||||
return FetchedDocument(url, current_url, 304, dict(response.headers), b"")
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db.models import Prefetch
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.sources.models import Source, SourcePolicyReview, SourceRun
|
||||
@@ -214,12 +215,17 @@ def _determine_health_action(runs: list[SourceRun]) -> tuple[str | None, str | N
|
||||
def collect_source_health(
|
||||
*, runs_to_consider: int = SOURCE_HEALTH_RUN_WINDOW
|
||||
) -> list[SourceHealth]:
|
||||
sources = Source.objects.order_by("name").all()
|
||||
sources = Source.objects.prefetch_related(
|
||||
Prefetch(
|
||||
"runs",
|
||||
queryset=SourceRun.objects.order_by("-started_at")[:runs_to_consider],
|
||||
to_attr="health_runs",
|
||||
)
|
||||
).order_by("name")
|
||||
rows: list[SourceHealth] = []
|
||||
|
||||
for source in sources:
|
||||
run_queryset = SourceRun.objects.filter(source=source).order_by("-started_at")
|
||||
runs = list(run_queryset[:runs_to_consider])
|
||||
runs = source.health_runs
|
||||
monitored_runs = len(runs)
|
||||
success_runs = [run for run in runs if run.status == SourceRun.Status.SUCCESS]
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes
|
||||
from apps.sources.models import EmailMessageRecord, MailboxConnection
|
||||
from apps.sources.services.email_import import ingest_email, message_identity
|
||||
from apps.sources.services.mailbox_connections import decrypt_mailbox_password
|
||||
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
|
||||
if client_factory is None:
|
||||
client_factory = imaplib.IMAP4_SSL
|
||||
host_validator(f"https://{connection.imap_host}")
|
||||
password = decrypt_mailbox_password(connection)
|
||||
password = (
|
||||
""
|
||||
if connection.provider == MailboxConnection.Provider.OUTLOOK
|
||||
else decrypt_mailbox_password(connection)
|
||||
)
|
||||
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
|
||||
if client_factory is imaplib.IMAP4_SSL:
|
||||
client_options["ssl_context"] = trusted_tls_context()
|
||||
@@ -42,6 +47,12 @@ def poll_imap_mailbox(
|
||||
"failed": 0,
|
||||
}
|
||||
try:
|
||||
if connection.provider == MailboxConnection.Provider.OUTLOOK:
|
||||
access_token = acquire_exchange_access_token()
|
||||
client.authenticate(
|
||||
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
|
||||
)
|
||||
else:
|
||||
client.login(connection.username, password)
|
||||
status, _ = client.select(connection.mailbox, readonly=True)
|
||||
if status != "OK":
|
||||
|
||||
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
|
||||
def rotate_mailbox_credentials() -> int:
|
||||
"""Re-encrypt every mailbox password with the first configured key."""
|
||||
rotated = 0
|
||||
for connection in MailboxConnection.objects.select_for_update().all():
|
||||
for connection in MailboxConnection.objects.select_for_update().exclude(
|
||||
provider=MailboxConnection.Provider.OUTLOOK
|
||||
):
|
||||
password = decrypt_mailbox_password(connection)
|
||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||
connection.save(update_fields=["encrypted_password", "updated_at"])
|
||||
@@ -79,7 +81,10 @@ def save_mailbox_connection(
|
||||
"poll_interval_minutes",
|
||||
):
|
||||
setattr(connection, field, cleaned_data[field])
|
||||
if password:
|
||||
uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK
|
||||
if uses_oauth:
|
||||
connection.encrypted_password = ""
|
||||
elif password:
|
||||
connection.encrypted_password = encrypt_mailbox_password(password)
|
||||
elif not connection.encrypted_password:
|
||||
raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
|
||||
|
||||
@@ -188,7 +188,8 @@ def schedule_due_sources() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
due = Source.objects.filter(
|
||||
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
|
||||
).filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
|
||||
).exclude(source_type=Source.Type.EMAIL)
|
||||
due = due.filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
|
||||
ids = list(due.values_list("id", flat=True).distinct()[:200])
|
||||
for source_id in ids:
|
||||
fetch_source.delay(source_id)
|
||||
@@ -200,6 +201,7 @@ def schedule_mailbox_polls() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
due_ids = list(
|
||||
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
|
||||
.exclude(user__username=settings.DEMO_USERNAME)
|
||||
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
|
||||
.values_list("id", flat=True)[:100]
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
@@ -17,7 +17,7 @@ from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rat
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .forms import MailboxConnectionForm, ManualImportForm
|
||||
from .models import MailboxConnection, Source, SourcePolicyReview
|
||||
from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun
|
||||
from .platforms import PLATFORM_ALERTS
|
||||
from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection
|
||||
from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source
|
||||
@@ -27,7 +27,14 @@ from .tasks import fetch_source, poll_mailbox
|
||||
|
||||
def _source_list_queryset():
|
||||
return (
|
||||
Source.objects.prefetch_related("policy_reviews")
|
||||
Source.objects.prefetch_related(
|
||||
"policy_reviews",
|
||||
Prefetch(
|
||||
"runs",
|
||||
queryset=SourceRun.objects.order_by("-started_at")[:1],
|
||||
to_attr="latest_runs",
|
||||
),
|
||||
)
|
||||
.exclude(domain="jobs.example.org", status=Source.Status.DISABLED)
|
||||
.filter(
|
||||
Q(metadata__hidden_from_source_list__isnull=True)
|
||||
@@ -69,6 +76,9 @@ def _manual_import_context(
|
||||
"active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(),
|
||||
"source_total_count": base_queryset.count(),
|
||||
"review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(),
|
||||
"failed_source_count": Source.objects.filter(
|
||||
Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0)
|
||||
).count(),
|
||||
"watchlist_sources": watchlist_queryset,
|
||||
"watchlist_count": watchlist_queryset.count(),
|
||||
"manual_import_form": form,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
@@ -29,6 +30,63 @@ def env_int_range(name: str, default: int, *, minimum: int, maximum: int) -> int
|
||||
return value
|
||||
|
||||
|
||||
def env_cidrs(name: str, default: str = "") -> list[str]:
|
||||
networks: list[str] = []
|
||||
for value in env_list(name, default):
|
||||
try:
|
||||
networks.append(str(ipaddress.ip_network(value, strict=False)))
|
||||
except ValueError as exc:
|
||||
raise ImproperlyConfigured(f"{name} bevat een ongeldig IP-netwerk: {value}") from exc
|
||||
return networks
|
||||
|
||||
|
||||
def merge_unique(*groups: list[str]) -> list[str]:
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for value in group:
|
||||
key = value.casefold()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
def normalize_public_base_url(value: str) -> tuple[str, str]:
|
||||
raw = value.strip().rstrip("/")
|
||||
if not raw:
|
||||
return "", ""
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL moet een geldige http(s)-URL zijn.")
|
||||
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ImproperlyConfigured(
|
||||
"PUBLIC_BASE_URL mag geen credentials, query of fragment bevatten."
|
||||
)
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL mag geen applicatiepad bevatten.")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL bevat een ongeldige poort.") from exc
|
||||
host = parsed.hostname.casefold()
|
||||
display_host = f"[{host}]" if ":" in host else host
|
||||
default_port = 443 if parsed.scheme == "https" else 80
|
||||
port_suffix = f":{port}" if port and port != default_port else ""
|
||||
return f"{parsed.scheme}://{display_host}{port_suffix}", host
|
||||
|
||||
|
||||
def configured_public_base_url() -> tuple[str, str]:
|
||||
raw = os.getenv("PUBLIC_BASE_URL", "").strip()
|
||||
if not raw:
|
||||
app_host = os.getenv("APP_HOST", "").strip()
|
||||
if app_host:
|
||||
scheme = os.getenv("APP_SCHEME", "https").strip().lower() or "https"
|
||||
public_port = os.getenv("APP_PUBLIC_PORT", "").strip()
|
||||
raw = f"{scheme}://{app_host}{f':{public_port}' if public_port else ''}"
|
||||
return normalize_public_base_url(raw)
|
||||
|
||||
|
||||
def _validate_production_security(
|
||||
*,
|
||||
debug: bool,
|
||||
@@ -38,6 +96,9 @@ def _validate_production_security(
|
||||
session_cookie_secure: bool,
|
||||
csrf_cookie_secure: bool,
|
||||
secure_ssl_redirect: bool,
|
||||
public_base_url: str = "",
|
||||
demo_mode_enabled: bool = False,
|
||||
demo_read_only: bool = True,
|
||||
) -> None:
|
||||
if debug:
|
||||
return
|
||||
@@ -49,6 +110,8 @@ def _validate_production_security(
|
||||
raise ImproperlyConfigured("DJANGO_SECRET_KEY is te kort voor een productieomgeving.")
|
||||
if not allowed_hosts:
|
||||
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie niet leeg zijn.")
|
||||
if any(host.strip() == "*" for host in allowed_hosts):
|
||||
raise ImproperlyConfigured("ALLOWED_HOSTS mag in productie geen wildcard bevatten.")
|
||||
if not csrf_trusted_origins:
|
||||
raise ImproperlyConfigured("CSRF_TRUSTED_ORIGINS is verplicht bij DEBUG=False.")
|
||||
if not any(origin.lower().startswith("https://") for origin in csrf_trusted_origins):
|
||||
@@ -57,13 +120,26 @@ def _validate_production_security(
|
||||
raise ImproperlyConfigured("Session- en CSRF-cookies moeten Secure=True zijn in productie.")
|
||||
if not secure_ssl_redirect:
|
||||
raise ImproperlyConfigured("SECURE_SSL_REDIRECT moet True zijn in productie.")
|
||||
if public_base_url and not public_base_url.startswith("https://"):
|
||||
raise ImproperlyConfigured("PUBLIC_BASE_URL moet HTTPS gebruiken in productie.")
|
||||
if demo_mode_enabled and not demo_read_only:
|
||||
raise ImproperlyConfigured("Een publieke productiedemo moet alleen-lezen zijn.")
|
||||
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||
DEBUG = env_bool("DJANGO_DEBUG", True)
|
||||
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver")
|
||||
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
|
||||
PUBLIC_BASE_URL, PUBLIC_HOST = configured_public_base_url()
|
||||
ALLOWED_HOSTS = merge_unique(
|
||||
env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,testserver"),
|
||||
[PUBLIC_HOST] if PUBLIC_HOST else [],
|
||||
)
|
||||
CSRF_TRUSTED_ORIGINS = merge_unique(
|
||||
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
|
||||
[PUBLIC_BASE_URL] if PUBLIC_BASE_URL else [],
|
||||
)
|
||||
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
|
||||
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
|
||||
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.17").strip() or "0.3.17"
|
||||
LANGUAGE_CODE = "nl-be"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
@@ -83,6 +159,7 @@ INSTALLED_APPS = [
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"apps.core.middleware.MigrationMaintenanceMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
@@ -91,6 +168,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"apps.core.middleware.DemoReadOnlyMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"apps.core.middleware.SecurityHeadersMiddleware",
|
||||
]
|
||||
@@ -190,6 +268,30 @@ LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "dashboard:today"
|
||||
LOGOUT_REDIRECT_URL = "login"
|
||||
|
||||
# Inloggen kan met gebruikersnaam of e-mailadres. ModelBackend blijft als fallback.
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"apps.core.auth_backends.EmailOrUsernameModelBackend",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
]
|
||||
PASSWORD_RESET_TIMEOUT = int(os.getenv("PASSWORD_RESET_TIMEOUT", str(3 * 24 * 3600)))
|
||||
|
||||
# Microsoft Entra ID (Azure AD) SSO. Uit tenzij expliciet geconfigureerd.
|
||||
ENTRA_ID_ENABLED = env_bool("ENTRA_ID_ENABLED", False)
|
||||
ENTRA_CLIENT_ID = os.getenv("ENTRA_CLIENT_ID", "")
|
||||
ENTRA_CLIENT_SECRET = os.getenv("ENTRA_CLIENT_SECRET", "")
|
||||
ENTRA_TENANT_ID = os.getenv("ENTRA_TENANT_ID", "")
|
||||
ENTRA_REDIRECT_URI = os.getenv("ENTRA_REDIRECT_URI", "")
|
||||
ENTRA_SCOPES = env_list("ENTRA_SCOPES", "User.Read")
|
||||
ENTRA_ALLOWED_DOMAINS = env_list("ENTRA_ALLOWED_DOMAINS")
|
||||
|
||||
# Demo-toegang: in ontwikkeling standaard beschikbaar; in productie alleen expliciet.
|
||||
# Een publiek gastaccount blijft standaard read-only en krijgt een begrensde sessie.
|
||||
DEMO_MODE_ENABLED = env_bool("DEMO_MODE_ENABLED", DEBUG)
|
||||
DEMO_READ_ONLY = env_bool("DEMO_READ_ONLY", True)
|
||||
DEMO_SESSION_SECONDS = env_int_range("DEMO_SESSION_SECONDS", 3600, minimum=300, maximum=86400)
|
||||
DEMO_USERNAME = os.getenv("DEMO_USERNAME", "demo")
|
||||
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@vacatureradar.local")
|
||||
|
||||
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "VacatureRadar <vacatureradar@localhost>")
|
||||
EMAIL_HOST = os.getenv("SMTP_HOST", "")
|
||||
@@ -197,36 +299,61 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
|
||||
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
|
||||
M365_TENANT_ID = os.getenv("M365_TENANT_ID", "")
|
||||
M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "")
|
||||
M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "")
|
||||
M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER)
|
||||
M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default"
|
||||
M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120)
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
|
||||
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", False)
|
||||
_PUBLIC_HTTPS = PUBLIC_BASE_URL.startswith("https://")
|
||||
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", _PUBLIC_HTTPS)
|
||||
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", _PUBLIC_HTTPS)
|
||||
CSRF_COOKIE_SAMESITE = "Lax"
|
||||
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", False)
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", _PUBLIC_HTTPS)
|
||||
TRUST_PROXY_HEADERS = env_bool("TRUST_PROXY_HEADERS", bool(PUBLIC_BASE_URL))
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") if TRUST_PROXY_HEADERS else None
|
||||
USE_X_FORWARDED_HOST = env_bool("USE_X_FORWARDED_HOST", False)
|
||||
TRUSTED_PROXY_CIDRS = env_cidrs("TRUSTED_PROXY_CIDRS")
|
||||
# Interne containerprobes blijven HTTP; publieke applicatieroutes worden wel omgeleid.
|
||||
SECURE_REDIRECT_EXEMPT = [r"^health/(?:live|ready)/$"]
|
||||
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True)
|
||||
SECURE_HSTS_PRELOAD = env_bool("SECURE_HSTS_PRELOAD", False)
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
|
||||
X_FRAME_OPTIONS = "DENY"
|
||||
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "vacatureradar-rate-limit",
|
||||
}
|
||||
}
|
||||
SEARCH_ENGINE_INDEXING_ENABLED = env_bool("SEARCH_ENGINE_INDEXING_ENABLED", False)
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
CACHE_URL = os.getenv("CACHE_URL", "" if DEBUG else REDIS_URL).strip()
|
||||
_IS_TESTING = bool(os.getenv("PYTEST_CURRENT_TEST") or "pytest" in os.sys.modules)
|
||||
if CACHE_URL and not _IS_TESTING:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": CACHE_URL,
|
||||
"KEY_PREFIX": "vacatureradar",
|
||||
"TIMEOUT": 300,
|
||||
}
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||
"LOCATION": "vacatureradar-local",
|
||||
}
|
||||
}
|
||||
HEALTHCHECK_REQUIRE_CACHE = env_bool("HEALTHCHECK_REQUIRE_CACHE", not DEBUG)
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
CELERY_TASK_ALWAYS_EAGER = env_bool("CELERY_TASK_ALWAYS_EAGER", False)
|
||||
CELERY_TASK_EAGER_PROPAGATES = True
|
||||
CELERY_TASK_ACKS_LATE = True
|
||||
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||
CELERY_TASK_DEFAULT_QUEUE = "default"
|
||||
CELERY_TASK_TIME_LIMIT = 300
|
||||
CELERY_TASK_SOFT_TIME_LIMIT = 270
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
@@ -249,6 +376,10 @@ if DEBUG and not MAILBOX_CREDENTIAL_KEYS:
|
||||
]
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"runtime-heartbeat": {
|
||||
"task": "apps.core.tasks.record_runtime_heartbeat",
|
||||
"schedule": 60.0,
|
||||
},
|
||||
"schedule-mailbox-polls": {
|
||||
"task": "apps.sources.tasks.schedule_mailbox_polls",
|
||||
"schedule": 300.0,
|
||||
@@ -334,6 +465,9 @@ _validate_production_security(
|
||||
session_cookie_secure=SESSION_COOKIE_SECURE,
|
||||
csrf_cookie_secure=CSRF_COOKIE_SECURE,
|
||||
secure_ssl_redirect=SECURE_SSL_REDIRECT,
|
||||
public_base_url=PUBLIC_BASE_URL,
|
||||
demo_mode_enabled=DEMO_MODE_ENABLED,
|
||||
demo_read_only=DEMO_READ_ONLY,
|
||||
)
|
||||
|
||||
LOGGING = {
|
||||
|
||||
@@ -5,10 +5,23 @@ from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from apps.core.views import SecurityAwareLoginView
|
||||
from apps.core.views import (
|
||||
SecurityAwareLoginView,
|
||||
demo_login,
|
||||
entra_callback,
|
||||
entra_login,
|
||||
robots_txt,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"favicon.ico",
|
||||
RedirectView.as_view(url=f"{settings.STATIC_URL}favicon.svg", permanent=True),
|
||||
name="favicon",
|
||||
),
|
||||
path("robots.txt", robots_txt, name="robots-txt"),
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"login/",
|
||||
@@ -16,6 +29,31 @@ urlpatterns = [
|
||||
name="login",
|
||||
),
|
||||
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
|
||||
# Wachtwoordherstel (Django's ingebouwde flow, eigen templates).
|
||||
path(
|
||||
"wachtwoord/herstellen/",
|
||||
auth_views.PasswordResetView.as_view(),
|
||||
name="password_reset",
|
||||
),
|
||||
path(
|
||||
"wachtwoord/herstellen/verzonden/",
|
||||
auth_views.PasswordResetDoneView.as_view(),
|
||||
name="password_reset_done",
|
||||
),
|
||||
path(
|
||||
"wachtwoord/herstellen/<uidb64>/<token>/",
|
||||
auth_views.PasswordResetConfirmView.as_view(),
|
||||
name="password_reset_confirm",
|
||||
),
|
||||
path(
|
||||
"wachtwoord/herstellen/klaar/",
|
||||
auth_views.PasswordResetCompleteView.as_view(),
|
||||
name="password_reset_complete",
|
||||
),
|
||||
# Entra ID SSO en demo-toegang.
|
||||
path("auth/entra/login/", entra_login, name="entra-login"),
|
||||
path("auth/entra/callback/", entra_callback, name="entra-callback"),
|
||||
path("auth/demo/", demo_login, name="demo-login"),
|
||||
path("", include(("apps.core.urls", "dashboard"), namespace="dashboard")),
|
||||
path("jobs/", include(("apps.jobs.urls", "jobs"), namespace="jobs")),
|
||||
path("profiles/", include(("apps.profiles.urls", "profiles"), namespace="profiles")),
|
||||
|
||||
@@ -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
|
||||
|
||||
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Volg eerst [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
|
||||
De composebron staat in de repositoryroot als `docker-compose.unraid.yml`. Deze bouwt één Dockerman-container via `Dockerfile.unraid`; interne processen worden door Supervisor bewaakt. Alle paden zijn relatief aan de map met het composebestand, dus je kopieert de projectmap naar de server en start daar:
|
||||
|
||||
```bash
|
||||
cp .env.unraid.example .env # eenmalig, daarna alle CHANGE_ME-waarden invullen
|
||||
docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
|
||||
```
|
||||
|
||||
Volg voor de volledige procedure [`docs/operations/UNRAID_DEPLOYMENT.md`](../../docs/operations/UNRAID_DEPLOYMENT.md).
|
||||
|
||||
Deze directory is gereserveerd voor toekomstige Unraid-template-XML en release-specifieke voorbeelden. Commit hier geen ingevulde `.env`, app-password, registrypulltoken of databaseback-up.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/tmp/supervisord.pid
|
||||
|
||||
[program:postgres]
|
||||
command=/usr/lib/postgresql/17/bin/postgres -D /app/local/postgres-aio17
|
||||
user=postgres
|
||||
priority=10
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:redis]
|
||||
command=/usr/bin/redis-server --dir /app/local/redis-aio --save 60 1 --loglevel warning --protected-mode yes --bind 127.0.0.1
|
||||
user=redis
|
||||
priority=20
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:web]
|
||||
command=/app/.venv/bin/gunicorn config.wsgi:application --bind 0.0.0.0:8080 --workers 2 --threads 4 --timeout 90 --no-control-socket
|
||||
directory=/app
|
||||
user=app
|
||||
priority=40
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:worker]
|
||||
command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py worker
|
||||
directory=/app
|
||||
user=app
|
||||
priority=50
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:scheduler]
|
||||
command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py scheduler
|
||||
directory=/app
|
||||
user=app
|
||||
priority=60
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
@@ -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,76 +1,49 @@
|
||||
# Compose-bron van waarheid met paden voor een Unraid-installatie.
|
||||
# Lees deployment/unraid/README.md vóór gebruik en wijzig secrets.
|
||||
# Eén Dockerman-container met intern bewaakte PostgreSQL-, Redis-, web- en Celery-processen.
|
||||
#
|
||||
# Kopieer de projectmap naar de server en start vanuit die map:
|
||||
# cp .env.unraid.example .env # eenmalig, daarna alle CHANGE_ME-waarden invullen
|
||||
# docker compose -f docker-compose.unraid.yml up -d --build --force-recreate
|
||||
#
|
||||
# Alle paden zijn relatief aan deze map, zodat de map overal kan staan.
|
||||
name: vacatureradar
|
||||
|
||||
x-app: &app
|
||||
image: ghcr.io/CHANGE_ME/vacatureradar:latest
|
||||
user: "10001:10001"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=128m
|
||||
- /var/tmp:rw,noexec,nosuid,size=128m
|
||||
- /app/.cache:rw,noexec,nosuid,size=128m
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.unraid
|
||||
image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
|
||||
container_name: VacatureRadar
|
||||
env_file:
|
||||
- /mnt/user/appdata/vacatureradar/config/.env
|
||||
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||
environment:
|
||||
POSTGRES_HOST: 127.0.0.1
|
||||
POSTGRES_PORT: "5432"
|
||||
REDIS_URL: redis://127.0.0.1:6379/0
|
||||
CACHE_URL: redis://127.0.0.1:6379/1
|
||||
HEALTHCHECK_REQUIRE_CACHE: "1"
|
||||
# Alleen gebruikt bij een lege datadir; laat leeg wanneer je niet migreert.
|
||||
VACATURERADAR_RESTORE_DUMP: ${VACATURERADAR_RESTORE_DUMP:-}
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
stop_grace_period: 30s
|
||||
ports:
|
||||
- "${VACATURERADAR_HOST_PORT:-1226}:8080"
|
||||
volumes:
|
||||
- /mnt/user/appdata/vacatureradar/media:/app/media
|
||||
- /mnt/user/appdata/vacatureradar/logs:/app/logs
|
||||
- /mnt/user/appdata/vacatureradar/local:/app/local
|
||||
networks: [internal]
|
||||
depends_on:
|
||||
postgres: {condition: service_healthy}
|
||||
redis: {condition: service_healthy}
|
||||
|
||||
services:
|
||||
web:
|
||||
<<: *app
|
||||
ports: ["1226:8080"]
|
||||
worker:
|
||||
<<: *app
|
||||
environment:
|
||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
||||
cap_drop:
|
||||
- ALL
|
||||
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
|
||||
scheduler:
|
||||
<<: *app
|
||||
environment:
|
||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
||||
cap_drop:
|
||||
- ALL
|
||||
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
|
||||
postgres:
|
||||
image: postgres:17.5-bookworm
|
||||
restart: unless-stopped
|
||||
env_file: [/mnt/user/appdata/vacatureradar/config/.env]
|
||||
volumes: [/mnt/user/appdata/vacatureradar/postgres:/var/lib/postgresql/data]
|
||||
networks: [internal]
|
||||
- ./local/media:/app/media
|
||||
- ./local/logs:/app/logs
|
||||
- ./local:/app/local
|
||||
labels:
|
||||
net.unraid.docker.managed: dockerman
|
||||
net.unraid.docker.webui: http://[IP]:[PORT:8080]/
|
||||
# Dockerman downloadt iconen server-side zonder [IP]/[PORT]-substitutie.
|
||||
net.unraid.docker.icon: http://127.0.0.1:1226/static/img/vacatureradar-docker.png
|
||||
org.opencontainers.image.title: VacatureRadar
|
||||
org.opencontainers.image.description: Persoonlijke vacature-intelligence voor de Belgische arbeidsmarkt
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U vacatureradar -d vacatureradar"]
|
||||
interval: 10s
|
||||
test: ["CMD", "python", "scripts/healthcheck.py", "http://127.0.0.1:8080/health/ready/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
redis:
|
||||
image: redis:7.4.2-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
|
||||
volumes: [/mnt/user/appdata/vacatureradar/redis:/data]
|
||||
networks: [internal]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
ollama:
|
||||
image: ollama/ollama:0.9.6
|
||||
profiles: ["ai"]
|
||||
restart: unless-stopped
|
||||
volumes: [/mnt/user/appdata/vacatureradar/ollama:/root/.ollama]
|
||||
networks: [internal]
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
@@ -11,8 +11,11 @@ x-app: &app
|
||||
- /var/tmp:rw,noexec,nosuid,size=128m
|
||||
- /app/.cache:rw,noexec,nosuid,size=128m
|
||||
env_file:
|
||||
- .env
|
||||
- ${VACATURERADAR_ENV_FILE:-.env}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
|
||||
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
@@ -43,6 +46,8 @@ services:
|
||||
<<: *app
|
||||
environment:
|
||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
||||
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
|
||||
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
|
||||
cap_drop:
|
||||
- ALL
|
||||
command: ["celery", "-A", "config", "worker", "-l", "INFO", "-Q", "high,default,low", "--concurrency=2"]
|
||||
@@ -57,6 +62,8 @@ services:
|
||||
<<: *app
|
||||
environment:
|
||||
VACATURERADAR_RUN_MIGRATIONS: "0"
|
||||
CACHE_URL: ${CACHE_URL:-redis://redis:6379/1}
|
||||
HEALTHCHECK_REQUIRE_CACHE: ${HEALTHCHECK_REQUIRE_CACHE:-1}
|
||||
cap_drop:
|
||||
- ALL
|
||||
command: ["celery", "-A", "config", "beat", "-l", "INFO", "--schedule", "/tmp/celerybeat-schedule"]
|
||||
|
||||
@@ -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,7 +1,19 @@
|
||||
# Projectstatus
|
||||
|
||||
- Laatst bijgewerkt: 2026-07-22
|
||||
- Repositoryversie: 0.2.10 Kempen-werkgeversradar en GeoNames
|
||||
## Gecontroleerde migratie — 2026-09-09
|
||||
|
||||
VR-232 voegt een startup-hold voor Celery worker/beat toe tijdens de expliciet
|
||||
goedgekeurde legacy-migratie. Alleen de kandidaatdatakopie krijgt een marker;
|
||||
uitgaand werk wordt pas na een geverifieerde deploymentcommit vrijgegeven.
|
||||
De eerste middleware blokkeert ook gewone webverzoeken tot commit; alleen exacte
|
||||
GET/HEAD-healthroutes blijven beschikbaar. De netwerkoverride behoudt het bestaande
|
||||
netwerk. Lokaal slagen 344 tests inclusief browsercontroles (84,07% coverage).
|
||||
De volledige `scripts/codex_verify.sh`-gate is groen: Ruff, Django-checks,
|
||||
migratiecontrole, tests, taakledger en repositoryvalidatie zijn geslaagd.
|
||||
Live migratie is niet uitgevoerd. Bestaande productietaken zijn niet gewijzigd.
|
||||
|
||||
- Laatst bijgewerkt: 2026-08-12
|
||||
- Repositoryversie: 0.3.17 immutable releaseherstel
|
||||
- Uitvoeringsmodus: autonome backlog
|
||||
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
|
||||
|
||||
@@ -14,11 +26,310 @@ De repository bevat een uitvoerbare Django-MVP met:
|
||||
- JSON-LD-, HTML-, RSS/Atom- en e-mailadapters;
|
||||
- normalisatie, sanitization, werkgeverresolutie, dedupe, aliassen, provenance en versies;
|
||||
- deterministische features, harde exclusions, scores en optionele uitgeschakelde Ollama-boundary;
|
||||
- responsive dark/light cockpitdashboard, vacaturelijst/detail, sollicitatiepipeline, profiel-, bron- en systeemschermen;
|
||||
- volledige responsive dark/light Stitch-cockpit met negen functionele hoofdschermen, skillsradar, login en foutstates;
|
||||
- feedbackacties, applicationdossiers, lifecycle en dagelijkse digest-outbox;
|
||||
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
|
||||
- offline unit-, integratie- en securitytests.
|
||||
|
||||
## Laatste documentatie-update
|
||||
|
||||
Visuele demo-hardening `VR-229` op 2026-08-12:
|
||||
|
||||
- iedere publieke demosessie herstelt idempotent zes actuele fictieve vacatures,
|
||||
de laatste scores, twee toekomstige opvolgmomenten en negen geïsoleerde
|
||||
`example.test`-platformmailboxkoppelingen;
|
||||
- mailboxbronnen worden niet meer door de URL-fetchscheduler gepland, de
|
||||
publieke demo wordt uitgesloten van echte mailboxpolling en historische
|
||||
mailboxpolicyfouten vervuilen dashboard en activiteit niet langer;
|
||||
- navigatie en dashboard onderscheiden nu expliciet een actief zoekprofiel van
|
||||
bron- of systeemgezondheid; het lege loginwachtwoordveld heeft geen misleidende
|
||||
mask-placeholder meer;
|
||||
- visuele audit: negen kernroutes groen in dark/light op 390×844, 1280×600,
|
||||
1440×900 en 3440×1440 zonder overflow, heading- of consolefouten;
|
||||
- gerichte regressies: 51 tests groen; volledige gate: 327 tests, 0 failures,
|
||||
0 skips en 84,04% branch-aware dekking;
|
||||
- commit `a6c8a79` en tag `v0.3.16` zijn naar Gitea gepusht; vóór uitrol is onder
|
||||
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.16-20260812T094608Z`
|
||||
een checksum-geldige database- en configuratieback-up gemaakt;
|
||||
- Unraid draait healthy zonder restarts op lokaal gebouwde image
|
||||
`sha256:c98001c063e8358dc59dfc175c574187b36021dd5a81f44e3ba982a28c9f9341`;
|
||||
een live sessiesmoke bevestigde dashboard 200, zes demomatches, negen
|
||||
platformmailboxen, actieve ingress, nul oude policyfouten en geen
|
||||
wachtwoord-maskplaceholder;
|
||||
- `VR-230` herstelde de Gitea-releaseworkflow door `setup-uv` op immutable commit
|
||||
`0880764` en uv op 0.11.12 te pinnen; de Astral-mirror verwijdert de eerdere
|
||||
GitHub Releases-API-rate-limit;
|
||||
- vier lege, verweesde Docker-netwerken zijn gecontroleerd verwijderd nadat de
|
||||
runner geen subnet meer kon reserveren; actieve productie- en compose-netwerken
|
||||
bleven onaangeroerd;
|
||||
- Gitea Actions-run 3062 publiceerde 0.3.17 met digest
|
||||
`sha256:98f4b33db353c192967013818f7c82c4f171921f3d300645908d52680fde209f`
|
||||
en artifact `release-evidence-v0.3.17` met SPDX-SBOM, checksums en release notes;
|
||||
- een dedicated `read:package`-token geeft Unraid alleen pulltoegang. De
|
||||
pre-deployback-up staat onder
|
||||
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.17-20260812T114510Z`;
|
||||
- Unraid is live op de immutable digest gepind: container healthy, nul restarts,
|
||||
interne liveness/readiness groen, publieke readiness 200 en runtimeversie 0.3.17.
|
||||
- `VR-231` verwijderde de oude `main`-push-to-deployworkflow en annuleerde de
|
||||
laatste onbeperkt wachtende run 3065; alleen semantische tags publiceren nog;
|
||||
- Jobat is via de bestaande OAuth/IMAP-taak geforceerd herhaald: 61 duplicaten
|
||||
correct herkend, nul fouten en de foutstatus volledig gewist;
|
||||
- Unraid gebruikt persistent `10.200.0.0/16,size=24`; een live proefnetwerk kreeg
|
||||
`10.200.1.0/24` en werd daarna verwijderd. Na de gecontroleerde Docker-herstart
|
||||
zijn alle 127 vooraf actieve containers hersteld; VacatureRadar, Grimmory en
|
||||
MeTube zijn healthy en er zijn geen unhealthy of herstartende containers;
|
||||
- serverback-ups bewaren de eerdere `docker.cfg`, de running-containersnapshot,
|
||||
de Grimmory-template en de Gitea-database vóór de begrensde runannulering.
|
||||
Volgende uitvoerbare backlogtaak: geen.
|
||||
|
||||
README-update op 2026-07-30:
|
||||
|
||||
- de repository-ingang benoemt release 0.3.15, de groene 324-testgate, de live
|
||||
M365 app-only OAuth/XOAUTH2-flow en de immutable private Gitea-registryrelease;
|
||||
- eerste configuratie noemt alle negen ondersteunde jobplatformen en verwijst
|
||||
expliciet naar het M365 least-privilege- en rotatiepad in het runbook;
|
||||
- de bestaande negen screenshots zijn visueel herbeoordeeld. De layout is nog
|
||||
actueel; de bronafbeelding toont bewust een gesaneerde demo zonder actieve
|
||||
ingress en is daarom als demo-momentopname verduidelijkt, niet vervangen door
|
||||
een productiebeeld met accountgegevens;
|
||||
- eindverificatie: volledige gate groen met 324 tests, 0 failures, 0 skips en
|
||||
84,07% branch-aware dekking.
|
||||
|
||||
## Laatste productiehardening
|
||||
|
||||
Productieoplevering 0.3.14 op 2026-07-29:
|
||||
|
||||
- VR-228 en commit `cbd7220` zijn via `origin/main` uitgerold als image `sha256:1ee84241c2f4ecef751f45df760054309843e1e98192035bc27476ae5a9477df`;
|
||||
- de geïsoleerde PostgreSQL-runner mat na één warm-up drie identieke queryprofielen: 3.264 importqueries, gemiddeld 9.040,123 ms, p50 32,900 ms, p95 52,296 ms, 28,031 documenten/s en 18,799 MB piekgeheugen;
|
||||
- tegenover 0.3.13 is de import 44,74% sneller, gebruikt hij minstens 63,73% minder queries en stijgt throughput 83,42%; parser 12/12, precision/recall 1,0 en rankingchurn 0 bleven behouden;
|
||||
- beide volledige gates waren groen met elk 318 tests, 0 failures, 0 skips en 84,20% dekking;
|
||||
- de pre-releasecustomdump, media en composeconfig staan checksum-geldig onder `/mnt/user/appdata/VacatureRadar/backups/release-0.3.14-20260729T164500Z`; rollbackimage `vacatureradar:rollback-0.3.13` wijst naar `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
|
||||
- productie is healthy zonder restarts, versie 0.3.14 is publiek zichtbaar, alle gastkernroutes antwoorden 200 en een echte importsmoke creëerde één job binnen een transactie waarna rollback expliciet bevestigd werd.
|
||||
|
||||
Productieoplevering 0.3.15 op 2026-07-30:
|
||||
|
||||
- `VR-202` is live afgerond met een dedicated alias, geïsoleerde Exchange-map en
|
||||
inboxregel, app-only OAuth/XOAUTH2 voor IMAP en SMTP en negen platformconnecties;
|
||||
- dezelfde echte testmail werd per connectie eenmaal geïmporteerd en bij herhaling
|
||||
als duplicate herkend; alle ingestelde intervallen zetten `next_poll_at` correct
|
||||
en de scheduler plande geen connectie te vroeg;
|
||||
- de productie-Fernet-sleutel werd live naar een nieuwe primaire MultiFernet-sleutel
|
||||
geroteerd; een tijdelijke versleutelde custom credential bleef ontsleutelbaar en
|
||||
is na de proef verwijderd;
|
||||
- één echte digest werd verzonden en de outbox eindigde op `sent`; productie-logs
|
||||
bevatten geen OAuth-secret, bearer-token of mailpayload;
|
||||
- `VR-204` publiceerde `v0.3.15` naar de private Gitea-registry en Unraid is gepind
|
||||
op `sha256:5a6dc253db7a60c42c0c806ae084ddc6ee7c98db5db71a857423b894a3662ba5`;
|
||||
- commitlabel, digest, SPDX-SBOM, checksums en release notes zijn checksum-geldig
|
||||
opgeslagen als Gitea Actions-artifact `release-evidence-v0.3.15`;
|
||||
- pull, interne en publieke readiness, rollback naar `0.3.14` en herstel naar de
|
||||
immutable `0.3.15`-digest zijn live geslaagd;
|
||||
- lokale en Gitea-releasegate zijn groen met 324 tests, 0 failures, 0 skips en
|
||||
84,05% branch-aware dekking.
|
||||
|
||||
Volgende uitvoerbare taak: geen; alle P0- en P1-releasepunten zijn afgerond.
|
||||
|
||||
## Laatste risicohardening
|
||||
|
||||
Live mailboxvoorbereiding op 2026-07-30:
|
||||
|
||||
- Exchange Online bevestigde dat de dedicated VacatureRadar-alias aan de productie-mailbox is toegevoegd;
|
||||
- de alias is niet primair gemaakt en bestaande adressen zijn ongewijzigd gebleven;
|
||||
- de Entra-appregistratie, admin consent en Exchange-mailboxscope zijn live ingericht
|
||||
met app-only OAuth; tijdelijke bootstraprechten en de bootstrap-app zijn na afloop verwijderd;
|
||||
- `VR-202` is volledig afgerond; Basic Auth is niet geactiveerd;
|
||||
- de volledige lokale gate is groen met 324 tests, 0 failures, 0 skips en 84,05%
|
||||
branch-aware dekking.
|
||||
|
||||
Risicohardening op 2026-07-29:
|
||||
|
||||
- de productiefetcher blokkeert nu fail-closed wanneer de HTTP-transportlaag het
|
||||
werkelijk verbonden peer-IP niet beschikbaar stelt; ieder niet-publiek peer-IP
|
||||
bleef al geblokkeerd;
|
||||
- alleen expliciet geïnjecteerde test/custom clients mogen nog de begrensde
|
||||
DNS-overlapfallback gebruiken; een negatieve regressietest dekt de
|
||||
productiegrens;
|
||||
- `VR-203` is administratief afgerond op basis van het bestaande live bewijs in
|
||||
`docs/audit/FINAL_ACCEPTANCE.md`: publieke HTTP→HTTPS, TLS/securityheaders,
|
||||
veilige cookies, CSRF-gastlogin, kernroutes en readiness zijn aantoonbaar
|
||||
geslaagd;
|
||||
- verificatie: Ruff groen, 8 gerichte fetchertests geslaagd en de volledige gate
|
||||
groen met 319 tests, 0 failures, 0 skips en 84,21% branch-aware dekking.
|
||||
|
||||
Productieoplevering 0.3.13 op 2026-07-29:
|
||||
|
||||
- basiscommit `5e2453c` en hardeningcommit `a4a71eb` naar `origin/main` gepusht en op Unraid werkelijk gebouwd als finale image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
|
||||
- migrations, 148 static files, poort 1226, mounts, restartpolicy, PostgreSQL, Redis, Gunicorn, Celery worker/beat en readiness live gevalideerd, inclusief containerrestart;
|
||||
- publieke HTTP→HTTPS, proxy/securityheaders, login-/gastflow, kernroutes, dark/light, 390/1280/3440 px en browserconsole live groen;
|
||||
- productiecustomdump en media gehasht, naar een geïsoleerde database hersteld en via migrations, recordtellingen en afzonderlijke Gunicorn-readiness bewezen;
|
||||
- vorige image `sha256:1851863be947b1e8b504e61e73b89d09c1dff704257b48856c775bb55f741459` live healthy teruggezet, waarna 0.3.13 opnieuw healthy is uitgerold;
|
||||
- importprofiling overschreed opnieuw 9.000 queries; veilige optimalisatie vereist eerst een geïsoleerde benchmarkdatabase en blijft als performancevervolg gedocumenteerd.
|
||||
|
||||
VR-227 op 2026-07-29:
|
||||
|
||||
- globaal contentframe en topbar zijn gecentreerd tot ultrawide; de gastbanner staat in de normale hoofdflow en de mobiele pipeline is verticaal;
|
||||
- vacaturepaginering is begrensd tot twintig resultaten, zoekscope is eerlijk en recente zoektermen blijven lokaal tot vijf items beperkt;
|
||||
- Playwright Chromium is een verplichte CI-gate op zes viewports en faalt op focusverlies, overflow, console- en page-errors; de volledige gebruikersreis draait zonder skips;
|
||||
- cachegebaseerde worker/beat-heartbeat vervangt de vroegere ongemeten runtimeclaim; onbekende queuediepte blijft expliciet onbekend;
|
||||
- generieke HTML-extractie ondersteunt publicatiedatum en dienstverband; benchmarkkwaliteitsrun behaalt 12/12 velden, precision/recall 1,0 en nul rankingchurn;
|
||||
- authbackenddekking bedraagt 97%, Entra 86% en totale branch-aware dekking 84,16%; volledige gate groen met 312 tests, 0 failures en 0 skips;
|
||||
- CI genereert een CycloneDX-SBOM. Docker kon lokaal niet worden uitgevoerd omdat de binary ontbreekt; live en externe restpunten staan uitsluitend in `USER_INPUT_REQUIRED.md`.
|
||||
|
||||
Volgende uitvoerbare taak: geen na afronding van VR-227.
|
||||
|
||||
VR-225 op 2026-07-27:
|
||||
|
||||
- `PUBLIC_BASE_URL` stuurt publieke host-, origin- en HTTPS-instellingen aan en voorkomt de gemelde `DisallowedHost` achter Nginx Proxy Manager;
|
||||
- publieke debugmodus, zwakke secrets, onveilige cookies, ontbrekende HTTPS-origin en een schrijfbare productiedemo worden fail-fast geweigerd;
|
||||
- login- en importrate-limiting delen Redis over alle workers; forwarded client-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
|
||||
|
||||
VR-209 tot en met VR-213 vervangen de oude browserfrontend integraal:
|
||||
|
||||
- één nieuwe lokale Stitch-shell en design system zonder CDN of clientframework;
|
||||
- echte dashboard-/vacature-intelligence, sollicitatiepipeline, profielconfigurator en source-healthinterface;
|
||||
- nieuwe activiteit- en werkgeversroutes/read models en een eerlijke automationcockpit;
|
||||
- standaard IT-relevantie, persoonlijke scorecontext en user-scoped mutaties blijven intact;
|
||||
- browsermatrix van 360 tot 2560 px, elf actuele screenshots, schone console en geen documentoverflow;
|
||||
- bronhealth-N+1 verwijderd: twee queries voor alle bron/runmetingen, 23 totale queries voor het representatieve automationscherm.
|
||||
|
||||
Het volledige scherm- en afwijkingsrapport staat in `docs/design/STITCH_INTEGRATION_REPORT.md`.
|
||||
|
||||
## Laatste onderhoudsaudit
|
||||
|
||||
VR-226 op 2026-07-28:
|
||||
|
||||
- README-presentatie verdiept van vier losse beelden naar een klikbare productreis met negen actuele screenshots: overzicht, zoeken, analyse, skills, werkgevers, sollicitaties, bronnen, automatisering en mobiel profiel;
|
||||
- alle desktopbeelden opnieuw vastgelegd op 1920×1080, doelbewust verdeeld over dark/light en gecombineerd met 390×844 mobiel; geen overgangsoverlays, horizontale overflow, secrets of consolewaarschuwingen;
|
||||
- de onrustige mobiele dashboardcapture is afgekeurd en verwijderd; de volledige galerij blijft lokaal en samen circa 1,1 MB;
|
||||
- vier aangetroffen VR-225-lintproblemen en de ongeldige POSIX-modusassertie op Windows zijn hersteld; volledige gate groen met 296 geslaagde tests, 2 optionele skips en 83,44% branch-aware dekking. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-224 op 2026-07-27:
|
||||
|
||||
- README volledig herordend rond doelgroep, dagelijkse workflow, concrete productwaarde, veilige quickstart en eerste configuratie; architectuur, beheer en Codex-overdracht volgen pas daarna;
|
||||
- vier actuele lokale screenshots toegevoegd voor dagoverzicht, vacatureverkenner, automatisering en mobiel zoekprofiel, samen minder dan 330 kB en zonder secrets of persoonlijke gegevens;
|
||||
- browsercontrole op 1600×1000 en 390×844 in dark/light toont geen horizontale overflow of consolewaarschuwingen; repositorylinks en configuratiedocumentatie valideren;
|
||||
- volledige gate groen met 268 geslaagde tests, 2 optionele skips en 83,48% branch-aware dekking. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-223 op 2026-07-27:
|
||||
|
||||
- VacatureRadar-logo verfijnd tot een transparante vier-elementenradar zonder donkere tegel, rand of schaduw; login en sidebar delen dezelfde schaalbare SVG;
|
||||
- dubbele demo-/accountidentiteit uit de sidebar verwijderd; radarstatus en een themabewuste transparante ITWorx-credit vormen één compacte footer, terwijl accountmenu en afmelden in de topbar intact blijven;
|
||||
- widescreenframe groeit naar 2.200 px en op 4K naar 2.480 px; browsermatrix van 18 shellcombinaties op 390/1280/1920/2560/3840 px en 1280×600 toont geen overlap, overflow of consolewaarschuwing;
|
||||
- dark/light en mobiele login bevestigen een transparante logoachtergrond, correcte wordmarkbehandeling en intacte kerninteracties.
|
||||
- gerichte regressies: 30 tests groen; volledige gate tweemaal groen met 268 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
|
||||
- live image `sha256:ea9e61a1febca59eb906bfdebcd9fd2b2c2b000189a8772f4abf09012722e921` healthy uitgerold; readiness, favicon, transparant productlogo en CSS 0.3.11 leveren HTTP 200. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-222 op 2026-07-26:
|
||||
|
||||
- integrale visuele audit van tien hoofdroutes plus login/herstel vastgelegd in `docs/design/VISUAL_AUDIT_2026-07-26.md`, met geprioriteerde bevindingen, gevolgen en uitgevoerde roadmap;
|
||||
- topbar en hoofdcontent delen één begrensd contentframe; het dagoverzicht toont maximaal vijf kernmatches en daalde op 1440 px van 2.399 naar 1.838 px;
|
||||
- activiteitstijdlijn, ontwikkelaarscredit, secundaire klikdoelen en reduced-motion zijn verfijnd; CSS-imports gebruiken een gedeelde cacheversie zodat een release werkelijk alle stijllagen ververst;
|
||||
- mobiele loginbotsing, foutieve oog-/demo-iconen en dubbele herstel-link zijn opgelost; VacatureRadar blijft productmerk en ITWorx.tech uitsluitend ontwikkelaarscredit;
|
||||
- browsercontrole: 50 combinaties over tien routes en 390/768/1280/1920/3840 px, plus light-theme en login, zonder overflow of consolewaarschuwingen;
|
||||
- volledige gate tweemaal groen met 268 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking; live image `sha256:7e1688db80241813a04e27038ef5e4afb64748868b6a97e353346f077be50b70` healthy uitgerold en readiness, favicon, productlogo, ontwikkelaarswordmark plus CSS 0.3.9 leveren HTTP 200. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-221 op 2026-07-26:
|
||||
|
||||
- merkarchitectuur gecorrigeerd: ITWorx.tech is de ontwikkelaar, VacatureRadar het zelfstandige productmerk;
|
||||
- twee logo-concepten zijn met de ingebouwde beeldgeneratieflow verkend; de eenvoudigste radar-/kansrichting is handmatig vertaald naar een exact vijf-elementen-SVG met open cyan radarboog, scanstraal en emerald kanssignaal;
|
||||
- sidebar, bovenbalk, login, favicon en Dockerman gebruiken het VacatureRadar-logo; de officiële ITWorx.tech-wordmark staat alleen als `Ontwikkeld door`-credit in secundaire zones;
|
||||
- appmetadata heet opnieuw uitsluitend `VacatureRadar`; het ongebruikte ITWorx-appicoon is verwijderd en bron-/assetdocumentatie is bijgewerkt;
|
||||
- browsermatrix op 390, 1280 en 1920 px toont correcte hiërarchie, geen overflow en een schone console; 29 gerichte regressies en de volledige gate zijn groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
|
||||
- live image `sha256:4184aeff33759b7a205637b0a0333b751b599141fe691a61965d2f1c56ca984a` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en favicon, productlogo, Dockermanicoon plus ITWorx-ontwikkelaarscredit live gevalideerd. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-220 op 2026-07-26:
|
||||
|
||||
- “eigen branding” is gecorrigeerd naar de door de eigenaar aangeleverde officiële ITWorx.tech-identiteit in plaats van een tijdelijke tekstuele Jens-signatuur;
|
||||
- sidebar, bovenbalk, login en wachtwoordherstel gebruiken de transparante opgeschoonde ITWorx.tech-wordmark; VacatureRadar blijft herkenbaar als product en intelligence cockpit;
|
||||
- favicon/app-icon (512×512) en Dockermanicoon (256×256) zijn exact uit het officiële cloud-check-symbool gecropt en Lanczos geschaald, zonder generatieve wijziging;
|
||||
- merkassets zijn lokaal, transparant, geoptimaliseerd en met herkomst vastgelegd in `static/img/README.md`;
|
||||
- browsercontrole op 390, 1280 en 1920 px toont correcte assets, geen overflow en een schone console; 29 gerichte regressies en de volledige gate zijn groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
|
||||
- de deployhelper negeert nu veilig verwijderde indexpaden; de twee vervangen SVG-assets zijn expliciet uit de gevalideerde remote sourceboom verwijderd en de uiteindelijke image bevat 147 in plaats van 149 static files;
|
||||
- live image `sha256:daf34407813b37cdf2d0fc65a482ded65375f89eedd3f0144a8c0ce206d41565` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en wordmark-, app-icon- en Dockermanassets leveren `image/png` met HTTP 200. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-219 op 2026-07-26:
|
||||
|
||||
- de door gebruikersscreenshots aangetoonde ultrawide-uitlijningsfout is hersteld: directe hoofdsecties zijn niet langer afzonderlijk gecentreerd op 1860 px, maar delen één links verankerd grid tot 2800 px;
|
||||
- op 3840 px beginnen heading, filters, explorer, systeemstatus, pipeline, KPI's en automationdetail op x=296; dezelfde gridbreedte schaalt zonder overflow naar 1920, 1280 en 390 px;
|
||||
- de resultatenkolom en automationlograil hebben begrensde ultrawide-verhoudingen zodat primaire inhoud niet onnodig uitrekt;
|
||||
- sidebar, bovenbalk, login en herstelpagina's gebruiken consistent `Personal intelligence by Jens` en `Jens Intelligence`, los van de accountgebruikersnaam;
|
||||
- browsermatrix schoon zonder consolewaarschuwingen; gerichte regressies 23 tests groen en volledige gate groen met 267 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking;
|
||||
- live image `sha256:86836f82cc543a7adb414e47b9667e9123bb9ceba10d629a53086273aaaae03f` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en de nieuwe branding plus 2800px-grid-CSS live gevalideerd. Volgende uitvoerbare taak: geen.
|
||||
|
||||
VR-218 op 2026-07-26:
|
||||
|
||||
- favicon, login en applicatieshell delen één verfijnd lokaal radarbeeldmerk met een sterker silhouet op klein formaat;
|
||||
- de persoonlijke signatuur `Jens · private intelligence` is zichtbaar op publieke en ingelogde schermen en via `VACATURERADAR_OWNER_NAME` configureerbaar;
|
||||
- de desktopbovenbalk toont eigenaar plus actuele routecontext; navigatie- en zoekfocus zijn verfijnd en de aanvullende context verdwijnt doelbewust op mobiel;
|
||||
- gerichte regressies: Ruff groen, 29 tests geslaagd en 2 optionele skips; browsercontrole van dashboard, verkenner, profiel en login op 1280×850 en 390×844 zonder documentoverflow of consolewaarschuwingen;
|
||||
- live image `sha256:5f25f00c9b7efb166587901ff39b02c56c98fb9946ddef0a4546a22f18dc0802` via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en persoonlijke loginbranding plus SVG-favicon live gevalideerd;
|
||||
- volledige gate groen met 266 geslaagde tests, 2 optionele skips en 83,45% branch-aware dekking; volgende uitvoerbare taak: geen, alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
|
||||
|
||||
VR-217 op 2026-07-26:
|
||||
|
||||
- de vermeende Unraid-authenticatiefout bleek een clientprofielkeuze: rechtstreeks IP gebruikte gebruiker `jens`, terwijl `unraid-itworx` correct `root`, `IdentitiesOnly` en de afzonderlijke deploysleutel selecteert;
|
||||
- `scripts/deploy_unraid.sh` valideert de hostalias, synchroniseert uitsluitend niet-genegeerde projectbestanden en combineert build, containerhealth en readiness-smoke;
|
||||
- het zoekprofiel heeft een lokale interactieve SVG-radar met scansweep, signalen, pointerparallax en statische reduced-motionvariant zonder fictieve data;
|
||||
- browsercontrole op 1280×800 en 390×844 in licht en donker toont geen documentoverflow of consolefouten; de illustratie blijft decoratief en staat mobiel na de primaire actie;
|
||||
- volledige gate groen met 265 tests, 2 optionele skips en 83,44% branch-aware dekking;
|
||||
- live image `sha256:4d30e01e778777d5a2cde4339a9b2ad006edb25cae387293737420152d65069d` gebouwd en via `unraid-itworx` uitgerold; container healthy, readiness HTTP 200 en de nieuwe CSS-/JS-assets via LAN HTTP 200;
|
||||
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
|
||||
|
||||
VR-216 op 2026-07-26:
|
||||
|
||||
- premium “calm intelligence”-laag toegevoegd met zachtere scheidingen, spaarzamer cyan, humane navigatietypografie en duidelijkere elevatie in donker en licht thema;
|
||||
- dashboard-KPI's staan mobiel compact 2×2, filters vormen een responsieve commandobar, pipelinekolommen scrollen intern en het profiel gebruikt een brede compositie;
|
||||
- de browsermatrix controleerde 1280×800 en 390×844 zonder documentoverflow; dashboard, verkenner, pipeline, profiel en beide thema's zijn visueel beoordeeld;
|
||||
- een bestaande CSP-consolefout op login is opgelost door de interactie naar een lokale externe JavaScriptasset te verplaatsen en regressiegedekt;
|
||||
- gerichte verificatie: Ruff groen, 22 tests geslaagd en 2 optionele Playwright-skips; volledige gate groen met 264 tests, 2 optionele skips en 83,44% branch-aware dekking;
|
||||
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en uitgestelde productkeuzes resteren.
|
||||
|
||||
VR-215 op 2026-07-26:
|
||||
|
||||
- Dockermans eigen implementatie bleek `[IP]`/`[PORT]` uitsluitend voor WebUI-links
|
||||
te vervangen en niet vóór een icoondownload; het eerdere label kon daardoor de
|
||||
cache niet vullen;
|
||||
- het live icoonlabel gebruikt nu de server-side loopback-URL op hostpoort 1226;
|
||||
- de Unraid-host bevat zowel de persistente als actuele RAM-cache
|
||||
`VacatureRadar-icon.png`, en Dockermans gegenereerde `docker.json` verwijst naar
|
||||
`/state/plugins/dynamix.docker.manager/images/VacatureRadar-icon.png`;
|
||||
- de gerecreate container is healthy en de PNG-/readiness-smokes slagen;
|
||||
- volgende uitvoerbare taak: geen; alleen bestaande externe blokkades en
|
||||
uitgestelde productkeuzes resteren.
|
||||
|
||||
VR-214 op 2026-07-25:
|
||||
|
||||
- het ontbrekende Unraid/Dockerman-tegelicoon is hersteld met een lokale 256×256 PNG op
|
||||
`/static/img/vacatureradar-docker.png`; de browserfavicon blijft de bestaande SVG;
|
||||
- de volledige WebUI- en icoonlabels, PNG-signatuur en afmetingen zijn regressiegedekt;
|
||||
- `collectstatic` kopieert de asset naar de productie-staticroot;
|
||||
- de volledige `scripts/codex_verify.sh`-gate slaagde via Git Bash met 264 tests,
|
||||
2 optionele Playwright-skips en 83,47% branch-aware codedekking;
|
||||
- de Unraid-image is daarna live gebouwd als
|
||||
`sha256:799825df1a343b6bee43695850454a5fdc977b5af3366077e6ac6aaeeb94f6f9`;
|
||||
de gerecreate container was healthy, liveness/readiness slaagden en het
|
||||
Dockerman-label wees naar de lokale PNG, die via LAN HTTP 200 met
|
||||
`Content-Type: image/png` en 7.111 bytes leverde;
|
||||
- er zijn tijdens de volledige lint-, systeem-, migratie-, test-, ledger- en
|
||||
repositorycontrole geen andere releaseblokkerende regressies gevonden;
|
||||
- volgende uitvoerbare taak: geen; alleen de bestaande externe blokkades en
|
||||
uitgestelde productkeuzes resteren.
|
||||
|
||||
VR-209–213-verificatie op 2026-07-22:
|
||||
|
||||
- 248 tests geslaagd, 2 optionele Playwrightvarianten overgeslagen en 84,40% branch-aware codedekking;
|
||||
- Ruff, Django system check, migratiecheck, backlogvalidatie en repository-/documentvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||
- alle negen routes in de geïntegreerde browser gecontroleerd zonder consolewaarschuwingen, kapotte assets of documentoverflow;
|
||||
- browsermatrix: 360×800, 390×844, 768×1024, 1024×768, 1280×800, 1440×900, 1920×1080 en 2560×1440;
|
||||
- elf actuele beelden onder `artifacts/visual-validation/`; artifacts en Stitch-referenties blijven buiten de productieimage;
|
||||
- volgende uitvoerbare taak: geen; alleen de bestaande externe credentials-/infrastructuurblokkades en uitgestelde productkeuzes resteren.
|
||||
|
||||
## Laatste geverifieerde baseline
|
||||
|
||||
Release-audit op 2026-07-21:
|
||||
@@ -30,6 +341,7 @@ Release-audit op 2026-07-21:
|
||||
- backlog- en repositoryvalidatie geslaagd;
|
||||
- de lockfile gebruikt publieke PyPI-bronnen in plaats van een niet-overdraagbare interne registry;
|
||||
- releaseblokkers hersteld in template-rendering, JSON/BOM-verwerking, leases, feedback, dossierautorisatie, ATS-provenance, employer-resolutie en DNS-rebindcontrole;
|
||||
- de productie-fetcher valideert het verbonden peer-IP, zodat legitieme CDN-DNS-rotatie bij DAF en Xplore Group niet als rebind wordt afgewezen terwijl private peers geblokkeerd blijven;
|
||||
- een echte Dockerimagebuild op de Unraid-server, migratiecontrole en live LAN-smoke voor liveness, readiness, login en static assets zijn geslaagd;
|
||||
- deploymenthardening voorkomt gelijktijdige migraties door worker/scheduler, schakelt de Gunicorn-controlsocket uit voor de read-only container en houdt de lokale healthcheckhost toegestaan.
|
||||
|
||||
@@ -119,25 +431,78 @@ VR-126-herverificatie op 2026-07-22:
|
||||
|
||||
VR-201/VR-205-herverificatie op 2026-07-22:
|
||||
|
||||
- 215 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||
- 84,42% branch-aware codedekking;
|
||||
- 218 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||
- 84,46% branch-aware codedekking;
|
||||
- Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||
- GeoNames België 2026-04-03 is onder CC BY 4.0 met checksums gedocumenteerd, 2.781 unieke invoerrijen zijn gevalideerd en 2400 Mol is opgelost naar het benaderende postcodecentrum 51.1919, 5.1166;
|
||||
- Gemeente Mol en zeven bijkomende Kempen-werkgevers passeerden elk twee livecycli zonder parserwarnings; samen leverden zij 53 kaarten per cyclus;
|
||||
- twaalf regionale werkgevers staan afzonderlijk als niet-scanbare waaklijst in de UI, ook wanneer zij momenteel geen vacature hebben;
|
||||
- Codex-browsercontrole bevestigde 21 actieve bronnen, 12 waaklijstkaarten, de GeoNames-status, één `h1`, geen horizontale overflow op desktop of 390 px en geen consolefouten;
|
||||
- de Unraid-deploy gebruikt expliciet Celery-queue `default`, overeenkomstig de queues die de productie-worker consumeert;
|
||||
- de fetcher valideert het werkelijk verbonden publieke peer-IP, zodat legitieme CDN-rotatie van DAF en Xplore Group veilig wordt ondersteund;
|
||||
- taakledger bevat 44 taken: 38 `done`, 3 `blocked-external`, 3 `deferred` en 0 `ready`.
|
||||
|
||||
VR-206-herverificatie op 2026-07-22:
|
||||
|
||||
- 225 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||
- 84,53% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||
- de standaardradar gebruikt een deterministische titelgestuurde IT-grens en laat verborgen/hard uitgesloten matches weg, met expliciete archiefopties voor volledige inspectie;
|
||||
- de lokale auditinventaris telt 336 actieve records: 108 aantoonbare IT-titels, 228 gefilterde niet-IT-records en 102 kaarten in de persoonlijke radarselectie;
|
||||
- de Stitch-cockpit benut maximaal 2240 px met tweekoloms matchfeeds op ultrawide; de zes kernroutes zijn handmatig gecontroleerd op 320, 390, 1024, 1440, 1920 en 2560 px zonder horizontale paginaoverflow;
|
||||
- dark/light, gelabelde velden, mobiele drawer, score-/confidencecopy, archieffilters en inline bewaren zijn in de geïntegreerde browser gecontroleerd.
|
||||
|
||||
VR-207-herverificatie op 2026-07-22:
|
||||
|
||||
- 232 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||
- 84,59% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||
- het profiel bevat gegroepeerde field-/infrastructuur-, Microsoft/endpoint-, netwerk/security-, back-up/monitoring-, virtualisatie-, VoIP- en scriptingkeuzes plus een ervaringsband;
|
||||
- de skillcomponent bereikt zijn maximum bij vier aangetroffen cv-skills en presenteert niet-vermelde voorkeurskills niet meer als tekort;
|
||||
- ambigue field-/implementatietitels vereisen een onafhankelijk IT-contextsignaal en titeluitsluitingen respecteren woordgrenzen en eenvoudige Engelse meervouden;
|
||||
- de lokale actieve auditprofielen zijn zonder persoonsgegevens in Git afgestemd op 10–14 jaar ervaring, 27 relevante roltitels en 46 cv-skills; na herscoring blijven 14 plausibele field-/infrastructuurkansen zichtbaar;
|
||||
- geïntegreerde browsercontrole bevestigde de categorie-editor op ultrawide en 390 px, één mobiele categoriekolom, geldige profielselecties en geen horizontale paginaoverflow.
|
||||
|
||||
VR-208-herverificatie op 2026-07-22:
|
||||
|
||||
- 240 geslaagde tests en 2 optioneel overgeslagen pytest-Playwrightvarianten; HTML/a11y-fallback geslaagd;
|
||||
- 84,73% branch-aware codedekking; Ruff, Django system check, migratiecontrole, taakledger en repositoryvalidatie geslaagd via `scripts/codex_verify.sh`;
|
||||
- ervaringsduur en senioriteitslabels hebben geen invloed meer op deterministische score, AI-score, aanbeveling, uitsluiting of feedbacklearning; migratie 0006 ruimt het oude gewicht op;
|
||||
- de persoonlijke Skillsradar analyseert alleen actieve IT-vacatures met een niet-verborgen score voor de actuele profielversie en laat records van uitsluitend uitgeschakelde bronnen buiten beschouwing;
|
||||
- de lokale radar toont 9 onderzochte vacatures, 5 herkenbare vraagsignalen, 2 gedekte skills en 3 mogelijke leerkansen zonder een tekstsignaal als harde eis te presenteren;
|
||||
- geïntegreerde browsercontrole bevestigde filters, 88 profielskillcheckboxes, één `h1`, lege console en geen horizontale paginaoverflow op 2560×1440, 1440×900 en 390×844.
|
||||
|
||||
## Deploymentstatus
|
||||
|
||||
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
|
||||
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` met PostgreSQL, Redis, web, worker en scheduler.
|
||||
- De applicatie draait op de Unraid-server via de geconfigureerde private host en poort `1226` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
|
||||
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
|
||||
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
|
||||
- De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie.
|
||||
- De huidige uitrol is alleen geschikt voor het beheerde LAN. Publieke productie vereist eerst een gekozen domein, reverse proxy/TLS en `DJANGO_DEBUG=0` met secure cookies.
|
||||
- Historisch bewijs (2026-07-22, inmiddels vervangen): de self-hosted runner was
|
||||
toen niet actief en die sessie gebruikte een directe SSH-deploy. Sinds 0.3.17
|
||||
is uitsluitend de taggestuurde immutable registryrelease operationeel; een
|
||||
gewone push naar `main` deployt bewust niet.
|
||||
- Deze historische LAN-beperking is op 2026-07-29 opgeheven: het publieke domein, reverse proxy/TLS, `DJANGO_DEBUG=0` en secure cookies zijn live gevalideerd.
|
||||
|
||||
## Laatste uitgevoerde backlogtaak
|
||||
|
||||
- 2026-07-22: `VR-208` voltooid.
|
||||
- Ervaringsjaren zijn alleen nog profielcontext; alle score-, recommendation-, concern-, evidence- en learninginvloed is verwijderd.
|
||||
- Een gebruikersgescopeerde vraaganalyse brengt actuele skillfrequentie, bewijskwaliteit, profieldekking en niet-blokkerende leer-/bijscholingskansen in kaart.
|
||||
- De profielcatalogus en nieuwe responsieve Skillsradar ondersteunen Microsoft, netwerk/security, monitoring/platform, ITSM en digitale adoptie met begeleide filters.
|
||||
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||
|
||||
- 2026-07-22: `VR-207` voltooid.
|
||||
- De twee aangeleverde cv's zijn visueel en tekstueel lokaal beoordeeld; alleen afgeleide technische profielkeuzes zijn in de lokale gebruikersdatabase opgeslagen.
|
||||
- Relevante ervaring, rollen, skills en doelgerichte uitsluitingen zijn toegepast en alle 336 actieve vacatures zijn opnieuw gescoord voor beide lokale auditprofielen.
|
||||
- De profiel- en scorelogica is regressiegedekt; afwezige cv-skills verlagen een breed profiel niet onbeperkt en worden niet als bewezen tekort getoond.
|
||||
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||
|
||||
- 2026-07-22: `VR-206` voltooid.
|
||||
- Niet-IT-vacatures en commercieel/recruitmentmisleidende IT-titels zijn standaard uit Vandaag en de vacaturefeed verwijderd zonder bronhistoriek te wissen.
|
||||
- Matchsortering, radar-/archieffilters, score-evidence en gebruikersgescopeerde dashboardscores maken de dagelijkse selectie beslisbaar.
|
||||
- De Stitch-dominante shell, feeds, details en context-rails schalen van 320 tot 2560 px en gebruiken op ultrawide twee resultaatkolommen.
|
||||
- Een open redirect in feedbackterugkeer, 320 px bronoverflow en de ontbrekende faviconfallback zijn als auditbevindingen mee opgelost.
|
||||
- Volgende taak: geen uitvoerbare `ready` taak; `VR-202`, `VR-203` en `VR-204` blijven `blocked-external`.
|
||||
|
||||
- 2026-07-22: `VR-201` en `VR-205` voltooid.
|
||||
- De officiële GeoNames-postcodedataset lost postcode 2400 op naar Mol en levert indicatieve postcodecentrumafstanden; licentie, versie, reviewdatum en twee SHA-256-checksums zijn vastgelegd.
|
||||
- Gemeente Mol, Ziekenhuis Geel, lokaal bestuur Geel, Stad Turnhout, Group Renotec, Ravago, Sanofi Geel en DAF Trucks Westerlo zijn na fixtures, negatieve routechecks en twee livecycli actief.
|
||||
@@ -308,22 +673,23 @@ Een toekomstige agent moet altijd de actuele waarheid opnieuw bepalen met:
|
||||
|
||||
## Nog te bouwen
|
||||
|
||||
Momenteel staan er geen `ready` taken meer over; het volgende te behandelen item is `VR-202` als `blocked-external` voor live mailbox-/SMTP-credentials en echte jobalertmails van de gekozen platformen.
|
||||
Momenteel staan er geen `ready` taken of P0/P1-releaseblokkers meer over.
|
||||
|
||||
## Externe blokkades
|
||||
|
||||
Deze blokkeren onafhankelijke code niet:
|
||||
|
||||
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
|
||||
- live IMAP/SMTP-account en app-password;
|
||||
- Unraid-domein, DNS, TLS en reverse proxy;
|
||||
- containerregistry en credentials;
|
||||
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
|
||||
- keuze/verkrijging van een gelicenseerde actuele Belgische postcode-/gemeentedataset.
|
||||
|
||||
## Bekende verificatiebeperking van de aangeleverde basis
|
||||
|
||||
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie.
|
||||
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn
|
||||
geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is
|
||||
aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op
|
||||
het begeleide profiel en de live vacaturefeed en VR-124 op de acht
|
||||
platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow
|
||||
zijn op 2026-07-29 live geaccepteerd en vastgelegd in
|
||||
`docs/audit/FINAL_ACCEPTANCE.md`.
|
||||
|
||||
## Hervatten
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Ondersteund of gepland:
|
||||
- uitgesloten regio/gemeente;
|
||||
- afstand boven maximum, behalve remote;
|
||||
- uitgesloten verplichte skill;
|
||||
- een titelgestuurde IT-grens; ambigue field-/implementatietitels vereisen daarnaast een sterk IT-contextsignaal in de vacaturetekst;
|
||||
- expliciete rijbewijs-/reis-/taalvereisten wanneer betrouwbaar geëxtraheerd;
|
||||
- verlopen of niet-actieve vacature buiten de ranking.
|
||||
|
||||
@@ -35,15 +36,16 @@ Standaardgewichten, totaal 100:
|
||||
|
||||
| Component | Gewicht | Kernsignalen |
|
||||
|---|---:|---|
|
||||
| Content | 25 | titelovereenkomst, taken, weinig ongewenste support |
|
||||
| Skills | 20 | gewenste skills in expliciete velden/tekst |
|
||||
| Locatie | 15 | afstand, remote/hybrid, voorkeursregio |
|
||||
| Content | 30 | titelovereenkomst, taken, weinig ongewenste support |
|
||||
| Skills | 25 | gewenste skills in expliciete velden/tekst |
|
||||
| Locatie | 20 | afstand, remote/hybrid, voorkeursregio |
|
||||
| Voorwaarden | 10 | toegestane contract-/werkvorm |
|
||||
| Werkgever | 10 | directe bron versus recruiter |
|
||||
| Senioriteit | 10 | gevraagde ervaring versus profiel |
|
||||
| Voorkeuren | 10 | publieke sector en andere soft signals |
|
||||
| Voorkeuren | 5 | publieke sector en andere soft signals |
|
||||
|
||||
Bij aangepaste gewichten worden componentbijdragen genormaliseerd op de totale som.
|
||||
- De profielervaring is uitsluitend informatieve context. Gevraagde ervaringsjaren en senioriteitslabels veranderen de score, aanbeveling en uitsluitingen nooit.
|
||||
- Een breed cv-profiel wordt niet bestraft omdat veel relevante skills zijn geselecteerd: maximaal vier aangetroffen profielskills volstaan voor de volledige skillcomponent. Niet-aangetroffen voorkeurskills blijven evidence, maar worden niet als bewezen tekort of concern gepresenteerd.
|
||||
- AI-boost is opt-in via profielinstelling en telt alleen mee als profielgewicht > 0.
|
||||
- De AI-component is softwarematig begrensd (maximale bijdrage via `ai`-gewicht) om dominantie te vermijden.
|
||||
|
||||
@@ -91,6 +93,7 @@ Een score bewaart:
|
||||
Copyregels:
|
||||
|
||||
- zeg "niet teruggevonden" in plaats van "ontbreekt" wanneer brondata onvolledig is;
|
||||
- presenteer niet-vermelde cv-skills niet als tekort; alleen expliciet aangetroffen skills zijn positieve evidence;
|
||||
- label inference als inference;
|
||||
- toon maximaal enkele kernredenen bovenaan en volledige details uitklapbaar;
|
||||
- geef geen kanspercentage op aanwerving zonder gevalideerd model.
|
||||
@@ -119,7 +122,7 @@ Offline evaluatie is beschikbaar via `scripts/feedback_learning_report.py` met f
|
||||
Toegestaan:
|
||||
|
||||
- support-/consultancy-/travelratio schatten;
|
||||
- senioritylabel;
|
||||
- senioritylabel als informatieve extractie, zonder score-invloed;
|
||||
- korte Nederlandse samenvatting;
|
||||
- evidencefragmenten selecteren;
|
||||
- ambiguïteitswarnings.
|
||||
|
||||
@@ -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.
|
||||