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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 14:13:22 +02:00
Jens 61502548d4 fix(security): bump cryptography 49.0.0 -> 50.0.0 (CVE-2026-69247)
Verified: 332 tests pass (325 unit/integration + 7 E2E/accessibility
with Playwright chromium), ruff clean. Identified via ITWorx
security-hardening programme Trivy scan.
2026-08-15 20:28:44 +02:00
Jens 7b6f6d43fc Close 0.3.17 operational follow-ups 2026-08-12 15:12:51 +02:00
Jens 4822ee6880 Record verified 0.3.17 production release
deploy / deploy (push) Waiting to run
2026-08-12 13:50:01 +02:00
Jens 3cfb2260cc Fix immutable release bootstrap for 0.3.17
release / verify-publish (push) Successful in 6m13s
deploy / deploy (push) Canceled after 0s
2026-08-12 13:35:02 +02:00
Jens b5045cf487 Document 0.3.16 deployment evidence
deploy / deploy (push) Canceled after 0s
2026-08-12 12:04:50 +02:00
Jens a6c8a79e31 Harden public demo for 0.3.16
release / verify-publish (push) Failing after 6s
deploy / deploy (push) Canceled after 0s
2026-08-12 11:42:00 +02:00
Jens 2419e6bf01 Update README for 0.3.15 release
deploy / deploy (push) Canceled after 0s
2026-08-12 11:00:50 +02:00
Jens 3ab9127548 Record live mail and registry acceptance
deploy / deploy (push) Canceled after 0s
2026-07-30 02:53:38 +02:00
Jens db9f340130 Use Gitea-compatible artifact upload
release / verify-publish (push) Successful in 5m34s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:31:30 +02:00
Jens aa1e6e50c1 Generate release SBOM with Syft
release / verify-publish (push) Failing after 5m33s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:25:07 +02:00
Jens 7991b04b35 Install browser dependencies for release gate
release / verify-publish (push) Failing after 5m11s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:18:35 +02:00
Jens 8073533c16 Provision uv in release workflow
release / verify-publish (push) Failing after 1m48s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:14:37 +02:00
Jens db7d96a546 Fix release verification invocation
release / verify-publish (push) Failing after 4s
deploy / deploy (push) Canceled after 0s
2026-07-30 02:13:26 +02:00
Jens 6da5d75acd Remove personal address from environment example
deploy / deploy (push) Canceled after 0s
release / verify-publish (push) Failing after 11m34s
2026-07-30 02:00:23 +02:00
Jens 67be350283 Harden live mail OAuth and immutable releases
deploy / deploy (push) Canceled after 0s
2026-07-30 02:00:09 +02:00
Jens 1397f085bd docs: record 0.3.14 production acceptance
deploy / deploy (push) Canceled after 0s
2026-07-29 19:07:05 +02:00
59 changed files with 1609 additions and 210 deletions
+4
View File
@@ -36,6 +36,10 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_USE_TLS=1 SMTP_USE_TLS=1
M365_TENANT_ID=
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_MAILBOX_USER=
# Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst. # Roteerbare Fernet-sleutels voor app-wachtwoorden; actieve sleutel eerst.
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
+8 -4
View File
@@ -16,8 +16,8 @@ DJANGO_SECRET_KEY=CHANGE_ME_generate_at_least_50_random_characters
DJANGO_DEBUG=0 DJANGO_DEBUG=0
# Leeg laten tot er een publieke HTTPS-URL is. # Leeg laten tot er een publieke HTTPS-URL is.
PUBLIC_BASE_URL= PUBLIC_BASE_URL=
DJANGO_ALLOWED_HOSTS=192.168.10.150,127.0.0.1,localhost DJANGO_ALLOWED_HOSTS=vacatureradar.local,127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226,http://127.0.0.1:1226 DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226,http://127.0.0.1:1226
DJANGO_TIME_ZONE=Europe/Brussels DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.12 VACATURERADAR_VERSION=0.3.12
@@ -36,8 +36,8 @@ CELERY_TASK_ALWAYS_EAGER=0
# Maakt bij een lege database eenmalig het beheerdersaccount aan. # Maakt bij een lege database eenmalig het beheerdersaccount aan.
# Inloggen kan met de gebruikersnaam of het e-mailadres. # Inloggen kan met de gebruikersnaam of het e-mailadres.
VACATURERADAR_ADMIN_USERNAME=jens@itworx.tech VACATURERADAR_ADMIN_USERNAME=admin@example.invalid
VACATURERADAR_ADMIN_EMAIL=jens@itworx.tech VACATURERADAR_ADMIN_EMAIL=admin@example.invalid
VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password VACATURERADAR_ADMIN_PASSWORD=CHANGE_ME_unique_admin_password
VACATURERADAR_AUTO_BOOTSTRAP=1 VACATURERADAR_AUTO_BOOTSTRAP=1
@@ -74,6 +74,10 @@ SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_USE_TLS=1 SMTP_USE_TLS=1
M365_TENANT_ID=
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_MAILBOX_USER=
# Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Genereer met: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MAILBOX_CREDENTIAL_KEYS= MAILBOX_CREDENTIAL_KEYS=
-62
View File
@@ -1,62 +0,0 @@
name: deploy
on:
push:
branches:
- main
- master
permissions:
contents: read
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Push-to-deploy op server
run: |
set -euo pipefail
if [ -z "${DEPLOY_COMPOSE_FILE:-}" ]; then
DEPLOY_COMPOSE_FILE="docker-compose.yml"
fi
if [ -z "${APP_HOST:-}" ]; then
APP_HOST="$(hostname -f 2>/dev/null || hostname)"
fi
if [ -z "${APP_SCHEME:-}" ]; then
APP_SCHEME="http"
fi
if [ -z "${DJANGO_DEBUG:-}" ]; then
if [ "${APP_SCHEME}" = "https" ]; then
DJANGO_DEBUG="0"
else
DJANGO_DEBUG="1"
fi
fi
export APP_HOST
export APP_SCHEME
export DJANGO_DEBUG
export DEPLOY_COMPOSE_FILE
if [ -n "${APP_ORIGINS:-}" ]; then
export APP_ORIGINS
fi
if [ -n "${APP_HOSTS:-}" ]; then
export APP_HOSTS
fi
if [ -n "${APP_PORT:-}" ]; then
export APP_PORT
fi
bash scripts/deploy_docker.sh "${DEPLOY_COMPOSE_FILE}" .env
+135
View File
@@ -0,0 +1,135 @@
name: Managed validation
on:
pull_request:
workflow_dispatch:
inputs:
profile:
description: Allowlisted validation profile
required: true
default: full
type: choice
options: [test, lint, typecheck, build, security, full]
permissions:
contents: read
concurrency:
group: managed-validation-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
full:
name: full
# Public fork code must never execute automatically on the private runner.
if: ${{ gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository }}
runs-on: ubuntu-latest
timeout-minutes: 30
container:
# Gitea's JavaScript actions execute inside the job container, so use
# the version-matched Playwright image that includes Node as well as the
# pinned Chromium browser and native runtime libraries.
image: mcr.microsoft.com/playwright:v1.54.0-noble
options: --ipc=host
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Provision the supported Python runtime
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13.2'
- name: Validate repository with a bounded profile
shell: bash
env:
REQUESTED_PROFILE: ${{ inputs.profile }}
run: |
set -euo pipefail
profile="${REQUESTED_PROFILE:-full}"
case "${profile}" in
test|lint|typecheck|build|security|full) ;;
*) echo "Profile is not allowlisted" >&2; exit 2 ;;
esac
git diff --check
if git grep -nE '^(<<<<<<< |=======$|>>>>>>> )' -- . ':!*.lock' ':!*.patch'; then
echo "Unresolved merge markers detected" >&2
exit 1
fi
if [[ -f pyproject.toml || -f requirements.txt ]]; then
# Compile only tracked Python sources. Running compileall after a
# Node install would otherwise traverse node_modules and turn a
# lightweight baseline into a large runner workload.
git ls-files -z '*.py' | xargs -0 -r python -m py_compile
if [[ -f uv.lock ]]; then
python -m venv "${RUNNER_TEMP}/managed-uv"
uv_python="${RUNNER_TEMP}/managed-uv/bin/python"
"${uv_python}" -m pip install --disable-pip-version-check uv==0.10.0
managed_uv="${RUNNER_TEMP}/managed-uv/bin/uv"
export UV_PROJECT_ENVIRONMENT="${RUNNER_TEMP}/managed-project-venv"
"${managed_uv}" sync --locked --extra browser
export PATH="${UV_PROJECT_ENVIRONMENT}/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import pytest' 2>/dev/null; then
# The image supplies the native browser libraries. Install
# only the lockfile-matched browser payload into an explicit
# job-local path; never invoke Playwright's apt workflow.
export PLAYWRIGHT_BROWSERS_PATH="${RUNNER_TEMP}/managed-playwright"
timeout --foreground 5m "${managed_uv}" run playwright install chromium
timeout --foreground 20m "${managed_uv}" run python -m pytest
fi
fi
if [[ "${profile}" == lint || "${profile}" == full ]]; then
if "${managed_uv}" run python -c 'import ruff' 2>/dev/null; then
"${managed_uv}" run python -m ruff check .
fi
fi
elif [[ -f requirements.txt ]]; then
python -m venv "${RUNNER_TEMP}/managed-python"
managed_python="${RUNNER_TEMP}/managed-python/bin/python"
"${managed_python}" -m pip install --disable-pip-version-check -r requirements.txt
export PATH="${RUNNER_TEMP}/managed-python/bin:${PATH}"
if [[ "${profile}" == test || "${profile}" == full ]]; then
if "${managed_python}" -c 'import pytest' 2>/dev/null; then
"${managed_python}" -m pytest
fi
fi
fi
fi
# Prepare Python before invoking Node scripts. Polyglot repositories
# commonly delegate their test script to Python and need the managed
# virtual environment to be active first.
if [[ -f package.json ]]; then
corepack enable
if [[ -f pnpm-lock.yaml ]]; then
pnpm install --frozen-lockfile
[[ "${profile}" == test || "${profile}" == full ]] && pnpm --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && pnpm --if-present lint
[[ "${profile}" == typecheck || "${profile}" == full ]] && pnpm --if-present typecheck
[[ "${profile}" == build || "${profile}" == full ]] && pnpm --if-present build
elif [[ -f package-lock.json ]]; then
npm ci
[[ "${profile}" == test || "${profile}" == full ]] && npm run --if-present test
[[ "${profile}" == lint || "${profile}" == full ]] && npm run --if-present lint
if [[ "${profile}" == typecheck || "${profile}" == full ]]; then
npm run --if-present typecheck
fi
[[ "${profile}" == build || "${profile}" == full ]] && npm run --if-present build
fi
fi
if [[ -f go.mod ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
go test ./...
fi
fi
if [[ -f Cargo.toml ]]; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
cargo test --locked
fi
fi
if compgen -G '*.sln' >/dev/null; then
if [[ "${profile}" == test || "${profile}" == build || "${profile}" == full ]]; then
dotnet test --configuration Release
fi
fi
+59
View File
@@ -0,0 +1,59 @@
name: release
on:
push:
tags:
- "v*.*.*"
jobs:
verify-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pinned uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: "0.11.12"
python-version: "3.13"
- name: Install verification browser
run: |
uv sync --extra browser
uv run playwright install --with-deps chromium
- name: Verify release
run: bash ./scripts/codex_verify.sh
- name: Build immutable image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
registry="gitea.itworx.tech"
image="${registry}/jens/vacatureradar:${version}"
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
docker build --file Dockerfile.unraid \
--build-arg "VCS_REF=${GITHUB_SHA}" \
--build-arg "APP_VERSION=${version}" \
--tag "$image" .
docker push "$image"
docker image inspect "$image" --format '{{index .RepoDigests 0}}' > image-digest.txt
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
anchore/syft:v1.33.0 \
"docker:$image" -o spdx-json > sbom.spdx.json
cat > RELEASE_NOTES.md <<EOF
# VacatureRadar ${version}
Immutable release image built from commit ${GITHUB_SHA}.
The accompanying digest, SPDX SBOM and checksums are release evidence.
EOF
sha256sum image-digest.txt sbom.spdx.json RELEASE_NOTES.md > SHA256SUMS
- name: Publish release evidence
uses: actions/upload-artifact@v3
with:
name: release-evidence-${{ github.ref_name }}
path: |
image-digest.txt
sbom.spdx.json
SHA256SUMS
RELEASE_NOTES.md
+31
View File
@@ -0,0 +1,31 @@
name: Unraid autoredeploy
on:
push:
branches: [main]
paths-ignore:
- ".gitea/**"
- "docs/**"
- "**/*.md"
workflow_dispatch:
concurrency:
group: unraid-production-vacatureradar
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
name: Deploy vacatureradar
runs-on: unraid-deploy
timeout-minutes: 180
steps:
- name: Deploy exact Gitea revision
run: |
set -euo pipefail
docker exec gitea-deploy-control \
/opt/gitea-deploy/deploy.py deploy \
"$GITHUB_REPOSITORY" "$GITHUB_SHA"
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
path: ./ path: ./
extra_args: --only-verified extra_args: --only-verified
- name: Scan project dependencies and lockfiles (high/critical) - name: Scan project dependencies and lockfiles (high/critical)
uses: aquasecurity/trivy-action@0.30.0 uses: aquasecurity/trivy-action@v0.30.0
with: with:
scan-type: fs scan-type: fs
scan-ref: . scan-ref: .
@@ -66,7 +66,7 @@ jobs:
- name: Build production image - name: Build production image
run: docker build --pull --tag vacatureradar:ci . run: docker build --pull --tag vacatureradar:ci .
- name: Scan container image for high/critical vulnerabilities - name: Scan container image for high/critical vulnerabilities
uses: aquasecurity/trivy-action@0.30.0 uses: aquasecurity/trivy-action@v0.30.0
with: with:
image-ref: vacatureradar:ci image-ref: vacatureradar:ci
format: table format: table
@@ -74,7 +74,7 @@ jobs:
exit-code: "1" exit-code: "1"
ignore-unfixed: true ignore-unfixed: true
- name: Generate CycloneDX software bill of materials - name: Generate CycloneDX software bill of materials
uses: aquasecurity/trivy-action@0.30.0 uses: aquasecurity/trivy-action@v0.30.0
with: with:
image-ref: vacatureradar:ci image-ref: vacatureradar:ci
format: cyclonedx format: cyclonedx
+21
View File
@@ -2,6 +2,27 @@
Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak. Alle betekenisvolle wijzigingen worden hier bijgehouden. Het project volgt voorlopig een pre-1.0 semantische versieaanpak.
## 0.3.17 - 2026-08-12
### Hersteld
- De Gitea-releasebootstrap gebruikt een immutable `setup-uv`-commit en een exacte
uv-versie; downloads lopen via de Astral-mirror en zijn niet langer afhankelijk
van de gelimiteerde GitHub Releases-API op het gedeelde runner-IP.
- De achterhaalde `main`-push-to-deployworkflow is verwijderd; alleen een
semantische tag kan nog een immutable releaseimage publiceren.
- De Unraid Docker-host gebruikt een conflictvrije `/24`-address pool met ruimte
voor 256 tijdelijke en toekomstige netwerken.
## 0.3.16 - 2026-08-12
### Hersteld
- De publieke demo herstelt bij iedere nieuwe demosessie zes actuele fictieve vacatures, hun laatste scores en twee toekomstige opvolgmomenten zonder duplicaten of onbeperkte scorehistoriek.
- Negen fictieve `example.test`-platformmailboxen maken de mailboxflow zichtbaar zonder echte accounts, credentials of externe polls.
- Mailboxingress wordt uitsluitend door de mailboxscheduler verwerkt; e-mailbronnen komen niet meer in URL-fetchplanning, dashboardscanstatus of de algemene bronrunactiviteit terecht.
- Statuscopy onderscheidt een actief zoekprofiel van operationele brongezondheid en het lege loginwachtwoordveld lijkt niet langer vooraf ingevuld.
## 0.3.14 - 2026-07-29 ## 0.3.14 - 2026-07-29
### Gewijzigd ### Gewijzigd
+1 -1
View File
@@ -2,4 +2,4 @@
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). VacatureRadar 0.3.14 is een single-user-first, server-rendered vacature-intelligencecockpit. De actuele uitvoeringsstatus, laatste gate en externe restpunten staan in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md). Productgebruik en installatie beginnen in [README.md](README.md).
Release 0.3.14 verlaagt voor de vaste 250-itemimport de gemiddelde duur met 44,74% en het queryvolume met minstens 63,73%, met behoud van parser-, dedupe-, ranking-, provenance- en scorehistoriekcontracten. Het actuele productie-image en livebewijs worden na deployment in [docs/ai/PROJECT_STATE.md](docs/ai/PROJECT_STATE.md) geregistreerd. Alleen optionele externe identity- en mailboxcredentials staan nog in [USER_INPUT_REQUIRED.md](USER_INPUT_REQUIRED.md). 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).
+7
View File
@@ -9,6 +9,13 @@ De standaard productieopstelling gebruikt afzonderlijke web-, worker-, scheduler
Gebruik nooit wildcardhosts of secrets uit voorbeeldbestanden. `PUBLIC_BASE_URL` is de publieke bron van waarheid voor host-, origin-, HTTPS- en cookieconfiguratie. 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 ## Productiebewijs 0.3.13
- Doel: `/mnt/user/appdata/VacatureRadar`, composeproject `vacatureradar`, hostpoort 1226 naar containerpoort 8080. - Doel: `/mnt/user/appdata/VacatureRadar`, composeproject `vacatureradar`, hostpoort 1226 naar containerpoort 8080.
+6
View File
@@ -1,6 +1,12 @@
# syntax=docker/dockerfile:1.7 # syntax=docker/dockerfile:1.7
FROM python:3.13-slim-trixie FROM python:3.13-slim-trixie
ARG VCS_REF=unknown
ARG APP_VERSION=dev
LABEL org.opencontainers.image.source="https://gitea.itworx.tech/Jens/VacatureRadar" \
org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.version=$APP_VERSION
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \ UV_COMPILE_BYTECODE=1 \
+2
View File
@@ -9,4 +9,6 @@ Dagelijks beheer omvat readiness, worker/beat-heartbeat, bronruns, notificatie-o
## Live observability 2026-07-29 ## 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. 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.
+25 -4
View File
@@ -8,6 +8,13 @@
VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-paginas, feeds en vacaturemails, verwijdert dubbels en irrelevante resultaten en rangschikt de overblijvende kansen tegen jouw zoekprofiel. Jij behoudt altijd de controle: VacatureRadar helpt zoeken en opvolgen, maar solliciteert nooit automatisch. VacatureRadar is een persoonlijke vacature-assistent voor België. De applicatie volgt toegestane werkgeversbronnen, ATS-paginas, feeds en vacaturemails, verwijdert dubbels en irrelevante resultaten en rangschikt de overblijvende kansen tegen jouw zoekprofiel. Jij behoudt altijd de controle: VacatureRadar helpt zoeken en opvolgen, maar solliciteert nooit automatisch.
**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).
![Dagoverzicht van VacatureRadar in licht thema](docs/screenshots/readme/dashboard-light.png) ![Dagoverzicht van VacatureRadar in licht thema](docs/screenshots/readme/dashboard-light.png)
## Voor wie is dit? ## Voor wie is dit?
@@ -37,12 +44,15 @@ VacatureRadar is bedoeld voor één persoon of een eigen, afgeschermde installat
| Vacatureverkenner | Zoeken en filteren met een uitlegbare detailanalyse naast de resultaten | | Vacatureverkenner | Zoeken en filteren met een uitlegbare detailanalyse naast de resultaten |
| Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel | | Skillsradar | Actuele marktvraag naast de skills uit je eigen profiel |
| Sollicitatiepipeline | Dossiers, status, notities, deadlines en een auditbare tijdlijn | | Sollicitatiepipeline | Dossiers, status, notities, deadlines en een auditbare tijdlijn |
| Bronnenbeheer | Veilige bronconfiguratie, gezondheid, handmatige import en mailboxkoppelingen | | Bronnenbeheer | Veilige bronconfiguratie, gezondheid, handmatige import en afzonderlijke mailboxkoppelingen voor negen jobplatformen |
| Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend | | Automatisering | Eerlijke 24-uursmetingen; niet-gemeten informatie blijft expliciet onbekend |
## Bekijk de volledige productflow ## Bekijk de volledige productflow
De beelden komen uit de lokale demo-omgeving. De getoonde cijfers zijn omgevingsdata, geen marketingclaims. Klik op een beeld voor de volledige resolutie. 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.
### 1. Vind de relevante kansen ### 1. Vind de relevante kansen
@@ -78,7 +88,7 @@ Een analyse maakt score, datakwaliteit, beslissignalen, aandachtspunten en broni
<table> <table>
<tr> <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>Sollicitatiepipeline</strong><br><sub>Bewaar status, deadlines, notities en tijdlijn per dossier; de uiteindelijke sollicitatie blijft altijd handmatig.</sub></td>
<td width="50%"><strong>Bronnenbeheer</strong><br><sub>Zie policyreview, laatste scans, volgende runs en brongezondheid zonder technische logs te moeten doorzoeken.</sub></td> <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>
<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/applications-dark.png"><img src="docs/screenshots/readme/applications-dark.png" alt="Sollicitatiepipeline met persoonlijke dossiers"></a></td>
@@ -141,7 +151,7 @@ Na de eerste login:
1. Open **Zoekprofiel** en stel regio, radius, rollen, skills en werkvoorkeuren in. 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. 2. Open **Bronnen** en activeer alleen bronnen waarvan beleid en voorwaarden beoordeeld zijn.
3. Configureer optioneel een eigen vacaturemailbox; credentials worden niet in de repository opgeslagen. 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. 4. Controleer **Automatisering** voordat je op onbemande verwerking vertrouwt.
5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks. 5. Verifieer in productie altijd HTTPS, back-ups, herstel en healthchecks.
@@ -158,6 +168,12 @@ De publieke URL wordt vervolgens gebruikt voor `ALLOWED_HOSTS`, CSRF, secure coo
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). 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).
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).
## Eerlijke grenzen ## Eerlijke grenzen
VacatureRadar: VacatureRadar:
@@ -193,6 +209,11 @@ Die controleert formatting, lint, Django-configuratie, migraties, de volledige t
## Beheer en deployment ## 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) - [Lokaal ontwikkelen](docs/operations/LOCAL_DEVELOPMENT.md)
- [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md) - [Docker en Unraid uitrollen](docs/operations/UNRAID_DEPLOYMENT.md)
- [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md) - [Nginx Proxy Manager configureren](docs/operations/NGINX_PROXY_MANAGER.md)
+2 -1
View File
@@ -38,7 +38,8 @@ Deze data mag nooit shell-, netwerk-, e-mail-, bestandssysteem- of sollicitatiea
## Kwetsbaarheid melden ## Kwetsbaarheid melden
Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Documenteer intern: Maak geen publieke issue met secrets of persoonlijke vacaturegegevens. Meld een
kwetsbaarheid privé via `security@itworx.tech` en vermeld:
1. component en versie/commit; 1. component en versie/commit;
2. reproduceerbare stappen met synthetische data; 2. reproduceerbare stappen met synthetische data;
+24
View File
@@ -9,6 +9,30 @@ from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme from django.utils.http import url_has_allowed_host_and_scheme
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: class DemoReadOnlyMiddleware:
"""Prevent a shared public demo account from mutating application data.""" """Prevent a shared public demo account from mutating application data."""
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import base64
from django.conf import settings
class Microsoft365OAuthError(RuntimeError):
"""Safe OAuth boundary error that never includes tokens or client secrets."""
def acquire_exchange_access_token(*, client_factory=None) -> str:
tenant_id = settings.M365_TENANT_ID.strip()
client_id = settings.M365_CLIENT_ID.strip()
client_secret = settings.M365_CLIENT_SECRET.strip()
if not all((tenant_id, client_id, client_secret)):
raise Microsoft365OAuthError("Microsoft 365 OAuth is niet volledig geconfigureerd.")
if client_factory is None:
import msal
client_factory = msal.ConfidentialClientApplication
factory = client_factory
client = factory(
client_id,
authority=f"https://login.microsoftonline.com/{tenant_id}",
client_credential=client_secret,
)
result = client.acquire_token_for_client(scopes=[settings.M365_EXCHANGE_SCOPE])
token = result.get("access_token") if isinstance(result, dict) else None
if not isinstance(token, str) or not token:
category = "unknown"
if isinstance(result, dict):
category = str(result.get("error") or "unknown")[:80]
raise Microsoft365OAuthError(
f"Microsoft 365 OAuth-token kon niet worden verkregen ({category})."
)
return token
def xoauth2_bytes(username: str, access_token: str) -> bytes:
if not username or "\x00" in username or "\r" in username or "\n" in username:
raise Microsoft365OAuthError("Ongeldig Microsoft 365-mailboxadres.")
return f"user={username}\x01auth=Bearer {access_token}\x01\x01".encode()
def xoauth2_b64(username: str, access_token: str) -> str:
return base64.b64encode(xoauth2_bytes(username, access_token)).decode("ascii")
+3 -2
View File
@@ -8,7 +8,7 @@ from django.urls import reverse
from apps.notifications.models import DigestOutbox, ReminderOutbox from apps.notifications.models import DigestOutbox, ReminderOutbox
from apps.profiles.models import ProfileRevision from apps.profiles.models import ProfileRevision
from apps.sources.models import SourceRun from apps.sources.models import Source, SourceRun
from ..models import ApplicationTimelineEvent, Feedback, JobVersion from ..models import ApplicationTimelineEvent, Feedback, JobVersion
@@ -37,7 +37,8 @@ ACTIVITY_FILTERS = (
def _source_events() -> list[ActivityEvent]: def _source_events() -> list[ActivityEvent]:
events = [] events = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:100]: runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
for run in runs.select_related("source").order_by("-started_at")[:100]:
detail = ( detail = (
f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · " f"{run.extracted_count} geëxtraheerd · {run.created_count} nieuw · "
f"{run.duplicate_count} duplicaat" f"{run.duplicate_count} duplicaat"
+5 -2
View File
@@ -57,7 +57,8 @@ def annotate_latest_profile_score(
def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]: def _recent_dashboard_activity(user: User) -> list[dict[str, Any]]:
activity: list[dict[str, Any]] = [] activity: list[dict[str, Any]] = []
for run in SourceRun.objects.select_related("source").order_by("-started_at")[:4]: source_runs = SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
for run in source_runs.select_related("source").order_by("-started_at")[:4]:
activity.append( activity.append(
{ {
"occurred_at": run.started_at, "occurred_at": run.started_at,
@@ -154,7 +155,9 @@ def build_dashboard_cockpit(user: User, profile: SearchProfile | None) -> dict[s
"source_counts": source_counts, "source_counts": source_counts,
"recent_activity": _recent_dashboard_activity(user), "recent_activity": _recent_dashboard_activity(user),
"day_conclusion": day_conclusion, "day_conclusion": day_conclusion,
"latest_scan": SourceRun.objects.order_by("-started_at").first(), "latest_scan": SourceRun.objects.exclude(source__source_type=Source.Type.EMAIL)
.order_by("-started_at")
.first(),
} }
+53 -9
View File
@@ -122,11 +122,10 @@ def demo_environment_seeded(user) -> bool:
def seed_demo_environment(user) -> None: def seed_demo_environment(user) -> None:
"""Maak (idempotent) een fictieve demo-omgeving voor ``user``.""" """Maak of herstel een fictieve, tijdsbestendige demo-omgeving voor ``user``."""
if demo_environment_seeded(user):
return
from apps.jobs.models import Application, Employer, JobPosting, ScoreRun from apps.jobs.models import Application, Employer, JobPosting, ScoreRun
from apps.sources.models import MailboxConnection, Source, SourceRun
from apps.sources.platforms import PLATFORM_ALERTS
profile, _ = SearchProfile.objects.get_or_create( profile, _ = SearchProfile.objects.get_or_create(
user=user, user=user,
@@ -153,6 +152,7 @@ def seed_demo_environment(user) -> None:
profile.save(update_fields=["is_active"]) profile.save(update_fields=["is_active"])
now = timezone.now() now = timezone.now()
today = timezone.localdate(now)
workplace_map = { workplace_map = {
"hybrid": JobPosting.Workplace.HYBRID, "hybrid": JobPosting.Workplace.HYBRID,
"remote": JobPosting.Workplace.REMOTE, "remote": JobPosting.Workplace.REMOTE,
@@ -185,7 +185,7 @@ def seed_demo_environment(user) -> None:
}, },
) )
slug = _digest("demo-job", str(index), title, employer_name) slug = _digest("demo-job", str(index), title, employer_name)
job, job_created = JobPosting.objects.get_or_create( job, _ = JobPosting.objects.update_or_create(
canonical_key=slug, canonical_key=slug,
defaults={ defaults={
"employer": employer, "employer": employer,
@@ -221,6 +221,11 @@ def seed_demo_environment(user) -> None:
) )
created_jobs.append(job) created_jobs.append(job)
ScoreRun.objects.filter(
job=job,
profile=profile,
model_version="demo-seed",
).delete()
ScoreRun.objects.create( ScoreRun.objects.create(
job=job, job=job,
profile=profile, profile=profile,
@@ -244,26 +249,65 @@ def seed_demo_environment(user) -> None:
# Een kleine sollicitatiepijplijn voor de demo. # Een kleine sollicitatiepijplijn voor de demo.
if created_jobs: if created_jobs:
Application.objects.get_or_create( Application.objects.update_or_create(
user=user, user=user,
job=created_jobs[0], job=created_jobs[0],
defaults={ defaults={
"status": Application.Status.INTERVIEW, "status": Application.Status.INTERVIEW,
"applied_at": now - timedelta(days=6), "applied_at": now - timedelta(days=6),
"follow_up_date": (now + timedelta(days=2)).date(), "follow_up_date": today + timedelta(days=2),
"contact_name": "Sofie Vandael", "contact_name": "Sofie Vandael",
"contact_email": "sofie.vandael@cloudnexus.example", "contact_email": "sofie.vandael@cloudnexus.example",
"notes": "Eerste gesprek gepland. Demo-data.", "notes": "Eerste gesprek gepland. Demo-data.",
}, },
) )
if len(created_jobs) > 3: if len(created_jobs) > 3:
Application.objects.get_or_create( Application.objects.update_or_create(
user=user, user=user,
job=created_jobs[3], job=created_jobs[3],
defaults={ defaults={
"status": Application.Status.APPLIED, "status": Application.Status.APPLIED,
"applied_at": now - timedelta(days=2), "applied_at": now - timedelta(days=2),
"follow_up_date": (now + timedelta(days=5)).date(), "follow_up_date": today + timedelta(days=5),
"notes": "Sollicitatie verstuurd. Demo-data.", "notes": "Sollicitatie verstuurd. Demo-data.",
}, },
) )
# Platformmailboxen zijn zichtbare ingressconfiguratie, geen crawlbare URL-bronnen.
# De demo gebruikt uitsluitend fictieve adressen en wordt door de scheduler overgeslagen.
for platform in PLATFORM_ALERTS:
MailboxConnection.objects.update_or_create(
user=user,
platform=platform,
defaults={
"provider": MailboxConnection.Provider.OUTLOOK,
"username": "alerts@example.test",
"mailbox": f"VacatureRadar/{platform}",
"enabled": True,
"poll_interval_minutes": MailboxConnection.PollInterval.THIRTY_MINUTES,
"next_poll_at": now + timedelta(minutes=30),
"last_polled_at": now - timedelta(minutes=5),
"last_success_at": now - timedelta(minutes=5),
"last_error_category": "",
"last_error_message": "",
"lease_token": "",
"lease_expires_at": None,
},
)
source, _ = Source.objects.update_or_create(
source_type=Source.Type.EMAIL,
domain=f"{platform}.mailbox.local",
defaults={
"name": f"{PLATFORM_ALERTS[platform].label} vacaturemailbox",
"base_url": "",
"status": Source.Status.ACTIVE,
"policy": Source.Policy.ALLOW,
"policy_reason": "Mailboxingress; niet via URL-fetch gepland.",
"parser_key": "email-alert",
"failure_count": 0,
"last_failure_at": None,
"next_run_at": None,
"metadata": {"demo": True, "mailbox_ingress": True},
},
)
SourceRun.objects.filter(source=source).delete()
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import smtplib
from email.utils import parseaddr
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.sources.services.tls import trusted_tls_context
class Microsoft365OAuthEmailBackend(BaseEmailBackend):
"""Send mail through Exchange Online with app-only SASL XOAUTH2."""
def send_messages(self, email_messages) -> int:
if not email_messages:
return 0
sent = 0
try:
token = acquire_exchange_access_token()
username = settings.M365_MAILBOX_USER.strip()
if not username:
raise Microsoft365OAuthError(
"M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer."
)
with smtplib.SMTP(
settings.EMAIL_HOST or "smtp.office365.com",
settings.EMAIL_PORT,
timeout=settings.M365_SMTP_TIMEOUT_SECONDS,
) as client:
client.ehlo()
client.starttls(context=trusted_tls_context())
client.ehlo()
code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}")
if code != 235:
raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.")
for message in email_messages:
recipients = message.recipients()
if not recipients:
continue
sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1]
client.sendmail(
sender or username,
recipients,
message.message().as_bytes(linesep="\r\n"),
)
sent += 1
except Exception:
if self.fail_silently:
return sent
raise
return sent
+6 -2
View File
@@ -9,7 +9,10 @@ class MailboxConnectionForm(forms.ModelForm):
password = forms.CharField( password = forms.CharField(
required=False, required=False,
label="App-wachtwoord", label="App-wachtwoord",
help_text="Laat leeg bij wijzigen om het bestaande app-wachtwoord te behouden.", help_text=(
"Alleen voor Gmail of een aangepaste provider. Microsoft 365 gebruikt "
"de centrale OAuth-configuratie."
),
widget=forms.PasswordInput( widget=forms.PasswordInput(
attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"}, attrs={"autocomplete": "new-password", "placeholder": "App-wachtwoord"},
render_value=False, render_value=False,
@@ -50,7 +53,8 @@ class MailboxConnectionForm(forms.ModelForm):
data = super().clean() data = super().clean()
if data.get("provider") != MailboxConnection.Provider.CUSTOM: if data.get("provider") != MailboxConnection.Provider.CUSTOM:
data["custom_host"] = "" data["custom_host"] = ""
if not self.instance.pk and not data.get("password"): uses_oauth = data.get("provider") == MailboxConnection.Provider.OUTLOOK
if not self.instance.pk and not uses_oauth and not data.get("password"):
self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.") self.add_error("password", "Een app-wachtwoord is verplicht voor een nieuwe koppeling.")
return data return data
@@ -0,0 +1,13 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sources", "0007_alter_mailboxconnection_platform")]
operations = [
migrations.AlterField(
model_name="mailboxconnection",
name="encrypted_password",
field=models.TextField(blank=True),
)
]
+1 -1
View File
@@ -357,7 +357,7 @@ class MailboxConnection(TimeStampedModel):
custom_host = models.CharField(max_length=255, blank=True) custom_host = models.CharField(max_length=255, blank=True)
port = models.PositiveIntegerField(default=993) port = models.PositiveIntegerField(default=993)
username = models.CharField(max_length=320) username = models.CharField(max_length=320)
encrypted_password = models.TextField() encrypted_password = models.TextField(blank=True)
mailbox = models.CharField(max_length=255, default="INBOX") mailbox = models.CharField(max_length=255, default="INBOX")
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
poll_interval_minutes = models.PositiveIntegerField( poll_interval_minutes = models.PositiveIntegerField(
+7 -1
View File
@@ -74,14 +74,19 @@ def _validate_connected_peer(
*, *,
before: ValidatedUrl, before: ValidatedUrl,
after: ValidatedUrl, after: ValidatedUrl,
require_peer: bool,
) -> None: ) -> None:
peer_ip = _connected_peer_ip(response) peer_ip = _connected_peer_ip(response)
if peer_ip is not None: if peer_ip is not None:
if not peer_ip.is_global: if not peer_ip.is_global:
raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}") raise FetchError(f"Niet-publiek verbonden IP-adres geblokkeerd: {peer_ip}")
return return
if require_peer:
raise FetchError(
"Verbonden IP-adres kon niet veilig worden vastgesteld; aanvraag geblokkeerd."
)
# Mock/custom transports do not always expose the peer socket. Keep the conservative # Mock/custom transports do not always expose the peer socket. Keep the conservative
# DNS-overlap fallback for those transports. # DNS-overlap fallback only for explicitly injected test/custom clients.
if not set(before.addresses).intersection(after.addresses): if not set(before.addresses).intersection(after.addresses):
raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.") raise FetchError("DNS-rebindcontrole faalde bij het benaderen van bron.")
@@ -175,6 +180,7 @@ def fetch_url(
response, response,
before=validation, before=validation,
after=post_validation, after=post_validation,
require_peer=own_client,
) )
if response.status_code in {301, 302, 303, 307, 308}: if response.status_code in {301, 302, 303, 307, 308}:
location = response.headers.get("location") location = response.headers.get("location")
+13 -2
View File
@@ -7,6 +7,7 @@ from typing import Any
from django.conf import settings from django.conf import settings
from apps.core.services.microsoft365 import acquire_exchange_access_token, xoauth2_bytes
from apps.sources.models import EmailMessageRecord, MailboxConnection from apps.sources.models import EmailMessageRecord, MailboxConnection
from apps.sources.services.email_import import ingest_email, message_identity from apps.sources.services.email_import import ingest_email, message_identity
from apps.sources.services.mailbox_connections import decrypt_mailbox_password from apps.sources.services.mailbox_connections import decrypt_mailbox_password
@@ -30,7 +31,11 @@ def poll_imap_mailbox(
if client_factory is None: if client_factory is None:
client_factory = imaplib.IMAP4_SSL client_factory = imaplib.IMAP4_SSL
host_validator(f"https://{connection.imap_host}") host_validator(f"https://{connection.imap_host}")
password = decrypt_mailbox_password(connection) password = (
""
if connection.provider == MailboxConnection.Provider.OUTLOOK
else decrypt_mailbox_password(connection)
)
client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS} client_options = {"timeout": settings.IMAP_CONNECT_TIMEOUT_SECONDS}
if client_factory is imaplib.IMAP4_SSL: if client_factory is imaplib.IMAP4_SSL:
client_options["ssl_context"] = trusted_tls_context() client_options["ssl_context"] = trusted_tls_context()
@@ -42,7 +47,13 @@ def poll_imap_mailbox(
"failed": 0, "failed": 0,
} }
try: try:
client.login(connection.username, password) if connection.provider == MailboxConnection.Provider.OUTLOOK:
access_token = acquire_exchange_access_token()
client.authenticate(
"XOAUTH2", lambda _challenge: xoauth2_bytes(connection.username, access_token)
)
else:
client.login(connection.username, password)
status, _ = client.select(connection.mailbox, readonly=True) status, _ = client.select(connection.mailbox, readonly=True)
if status != "OK": if status != "OK":
raise RuntimeError("IMAP-mailbox kon niet worden geopend") raise RuntimeError("IMAP-mailbox kon niet worden geopend")
+7 -2
View File
@@ -48,7 +48,9 @@ def decrypt_mailbox_password(connection: MailboxConnection) -> str:
def rotate_mailbox_credentials() -> int: def rotate_mailbox_credentials() -> int:
"""Re-encrypt every mailbox password with the first configured key.""" """Re-encrypt every mailbox password with the first configured key."""
rotated = 0 rotated = 0
for connection in MailboxConnection.objects.select_for_update().all(): for connection in MailboxConnection.objects.select_for_update().exclude(
provider=MailboxConnection.Provider.OUTLOOK
):
password = decrypt_mailbox_password(connection) password = decrypt_mailbox_password(connection)
connection.encrypted_password = encrypt_mailbox_password(password) connection.encrypted_password = encrypt_mailbox_password(password)
connection.save(update_fields=["encrypted_password", "updated_at"]) connection.save(update_fields=["encrypted_password", "updated_at"])
@@ -79,7 +81,10 @@ def save_mailbox_connection(
"poll_interval_minutes", "poll_interval_minutes",
): ):
setattr(connection, field, cleaned_data[field]) setattr(connection, field, cleaned_data[field])
if password: uses_oauth = connection.provider == MailboxConnection.Provider.OUTLOOK
if uses_oauth:
connection.encrypted_password = ""
elif password:
connection.encrypted_password = encrypt_mailbox_password(password) connection.encrypted_password = encrypt_mailbox_password(password)
elif not connection.encrypted_password: elif not connection.encrypted_password:
raise MailboxCredentialError("Een app-wachtwoord is verplicht.") raise MailboxCredentialError("Een app-wachtwoord is verplicht.")
+3 -1
View File
@@ -188,7 +188,8 @@ def schedule_due_sources() -> dict[str, int]:
now = timezone.now() now = timezone.now()
due = Source.objects.filter( due = Source.objects.filter(
status__in=[Source.Status.ACTIVE, Source.Status.TRIAL], status__in=[Source.Status.ACTIVE, Source.Status.TRIAL],
).filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now)) ).exclude(source_type=Source.Type.EMAIL)
due = due.filter(Q(next_run_at__isnull=True) | Q(next_run_at__lte=now))
ids = list(due.values_list("id", flat=True).distinct()[:200]) ids = list(due.values_list("id", flat=True).distinct()[:200])
for source_id in ids: for source_id in ids:
fetch_source.delay(source_id) fetch_source.delay(source_id)
@@ -200,6 +201,7 @@ def schedule_mailbox_polls() -> dict[str, int]:
now = timezone.now() now = timezone.now()
due_ids = list( due_ids = list(
MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now) MailboxConnection.objects.filter(enabled=True, next_poll_at__lte=now)
.exclude(user__username=settings.DEMO_USERNAME)
.filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now)) .filter(Q(lease_expires_at__isnull=True) | Q(lease_expires_at__lte=now))
.values_list("id", flat=True)[:100] .values_list("id", flat=True)[:100]
) )
+8 -1
View File
@@ -139,7 +139,7 @@ CSRF_TRUSTED_ORIGINS = merge_unique(
) )
TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels") TIME_ZONE = os.getenv("DJANGO_TIME_ZONE", "Europe/Brussels")
VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens" VACATURERADAR_OWNER_NAME = os.getenv("VACATURERADAR_OWNER_NAME", "Jens").strip()[:40] or "Jens"
VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.14").strip() or "0.3.14" VACATURERADAR_VERSION = os.getenv("VACATURERADAR_VERSION", "0.3.17").strip() or "0.3.17"
LANGUAGE_CODE = "nl-be" LANGUAGE_CODE = "nl-be"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
@@ -159,6 +159,7 @@ INSTALLED_APPS = [
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"apps.core.middleware.MigrationMaintenanceMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
@@ -298,6 +299,12 @@ EMAIL_PORT = int(os.getenv("SMTP_PORT", "587"))
EMAIL_HOST_USER = os.getenv("SMTP_USER", "") EMAIL_HOST_USER = os.getenv("SMTP_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "") EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD", "")
EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True) EMAIL_USE_TLS = env_bool("SMTP_USE_TLS", True)
M365_TENANT_ID = os.getenv("M365_TENANT_ID", "")
M365_CLIENT_ID = os.getenv("M365_CLIENT_ID", "")
M365_CLIENT_SECRET = os.getenv("M365_CLIENT_SECRET", "")
M365_MAILBOX_USER = os.getenv("M365_MAILBOX_USER", EMAIL_HOST_USER)
M365_EXCHANGE_SCOPE = "https://outlook.office365.com/.default"
M365_SMTP_TIMEOUT_SECONDS = env_int_range("M365_SMTP_TIMEOUT_SECONDS", 20, minimum=3, maximum=120)
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_SAMESITE = "Lax"
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -9,7 +9,7 @@ DJANGO_ALLOWED_HOSTS=vacatureradar.example.be,127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be DJANGO_CSRF_TRUSTED_ORIGINS=https://vacatureradar.example.be
DJANGO_TIME_ZONE=Europe/Brussels DJANGO_TIME_ZONE=Europe/Brussels
VACATURERADAR_OWNER_NAME=Jens VACATURERADAR_OWNER_NAME=Jens
VACATURERADAR_VERSION=0.3.14 VACATURERADAR_VERSION=0.3.17
POSTGRES_DB=vacatureradar POSTGRES_DB=vacatureradar
POSTGRES_USER=vacatureradar POSTGRES_USER=vacatureradar
+2 -2
View File
@@ -36,7 +36,7 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
[program:worker] [program:worker]
command=/app/.venv/bin/celery -A config worker -l INFO -Q high,default,low --concurrency=2 command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py worker
directory=/app directory=/app
user=app user=app
priority=50 priority=50
@@ -47,7 +47,7 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
[program:scheduler] [program:scheduler]
command=/app/.venv/bin/celery -A config beat -l INFO --schedule /tmp/celerybeat-schedule command=/app/.venv/bin/python /app/scripts/migration_worker_gate.py scheduler
directory=/app directory=/app
user=app user=app
priority=60 priority=60
+35
View File
@@ -0,0 +1,35 @@
# Append after docker-compose.unraid.yml. Operator-only migration prerequisite;
# every path must be a verified cold clone, never the original live data.
# Also pass MIGRATION_ENV_FILE as Compose --env-file for interpolation parity.
name: vacatureradar-managed
services:
app:
network_mode: ${MIGRATION_NETWORK:?exact original network required}
labels:
io.itworx.migration-attempt: ${MIGRATION_ATTEMPT:?journaled migration attempt required}
image: ${MIGRATION_IMAGE:?set an attempt-specific candidate image}
env_file:
- ${VACATURERADAR_ENV_FILE:?set the protected external runtime env file}
build:
labels:
org.opencontainers.image.source: jens/vacatureradar
org.opencontainers.image.revision: ${MIGRATION_SOURCE_REVISION:?full verified Git commit required}
org.opencontainers.image.source-tree: ${MIGRATION_SOURCE_TREE:?verified Git tree required}
org.opencontainers.image.version: ${MIGRATION_BUILD_ID:?broker build ID required}
org.opencontainers.image.created: ${MIGRATION_BUILD_DATE:?broker UTC build date required}
volumes:
- type: bind
source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}/media
target: /app/media
bind:
create_host_path: false
- type: bind
source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}/logs
target: /app/logs
bind:
create_host_path: false
- type: bind
source: ${MIGRATION_LOCAL_ROOT:?verified cold clone of the complete local tree required}
target: /app/local
bind:
create_host_path: false
+1 -1
View File
@@ -12,7 +12,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.unraid dockerfile: Dockerfile.unraid
image: vacatureradar:unraid image: ${VACATURERADAR_IMAGE:-vacatureradar:unraid}
container_name: VacatureRadar container_name: VacatureRadar
env_file: env_file:
- ${VACATURERADAR_ENV_FILE:-.env} - ${VACATURERADAR_ENV_FILE:-.env}
+40
View File
@@ -0,0 +1,40 @@
# Gecontroleerde migratie van legacy deployments
`docker-compose.migration.yml` is een optionele operatoroverride, geen wijziging
aan de standaarddeployment. Gebruik deze alleen na de expliciete migratieprocedure
in ProjectBrain `scripts/legacy-deployment-migration/`.
De operator valideert de exacte Git-revisie en imageprovenance, stopt de oude
container en maakt een gecontroleerde koude kopie van de volledige `local`-map.
`MIGRATION_LOCAL_ROOT` verwijst uitsluitend naar die kopie. Media, logs en de
PostgreSQL-data houden hun geneste mountrelatie. De originele data, image en
container blijven bewaard voor rollback; startupmigraties mogen ze niet wijzigen.
Alle migratievariabelen zijn verplicht, paden worden niet automatisch aangemaakt,
en een attemptlabel bindt de nieuwe container aan precies één hersteltransactie.
De operator controleert gemergde Compose-mounts, gezondheid, HTTP en de echte
brokerreceipt voordat de configuratie definitief wordt overgezet.
De override is offline getest met echte Compose-rendering en ontbrekende
invoervariabelen. Toevoeging van dit bestand bewijst geen uitgevoerde productiemigratie.
# Outbound worker hold
Before starting a migration candidate, create `.migration-worker-hold` in its
cloned `/app/local` directory. The supervised Celery worker and scheduler wait
without consuming tasks until the operator removes that exact attempt-owned
file after the deployment commit. Normal startup is unchanged when it is absent.
Do not put the marker into the original data. This is a startup gate, not a
control for pausing an already-running worker. Recovery before commit retains
the hold and candidate data; recovery after commit resumes activation and must
never revert to stale original data after outbound work has been released.
The first Django middleware also returns 503 (no-store) for every ordinary
request while held, including GET requests. Only exact GET/HEAD requests to
`/health/ready/` and `/health/live/` pass. Marker inspection errors fail closed.
The migration helper creates a nonce-bound marker only in the cold clone and
removes it with directory fsync after a durable `committed` journal. Recovery
after that boundary may resume activation but can never restore old data.
`MIGRATION_NETWORK` is required and must equal the inspected existing
`vacatureradar_default` network. The managed Compose project does not move the
application to a newly-created network.
+9
View File
@@ -0,0 +1,9 @@
# Repository sanitation status
The accidental deployment log containing live root-SSH/container output was removed from the current tree. Active Unraid examples now use a neutral local hostname and generic appdata/backup paths rather than the operator's private host and storage layout.
## HISTORY_REWRITE_REQUIRED
Earlier commits contain the removed `deploy-log.txt` object, including private deployment commands, host information and runtime/database output. Earlier `.env.unraid.example`, `BACKUP_RESTORE.md` and `DEPLOYMENT.md` revisions also contain the original private host and appdata/backup paths.
Before public review, scan all refs for credentials, SSH targets, personal applicant/job data, production databases, media, backups, logs, archives and large objects. Review fixtures and screenshots for names, contact details, application history and other personal data. No history was rewritten during this campaign.
+158 -15
View File
@@ -1,6 +1,6 @@
schema_version: 1 schema_version: 1
project: VacatureRadar project: VacatureRadar
updated_at: '2026-07-29' updated_at: '2026-08-12'
execution_policy: execution_policy:
order: file-order, then first ready task whose dependencies are done order: file-order, then first ready task whose dependencies are done
no_product_questions: true no_product_questions: true
@@ -1341,7 +1341,7 @@ tasks:
gebruikt strikt de gepinde certifi-bundel zonder bypass. gebruikt strikt de gepinde certifi-bundel zonder bypass.
- id: VR-202 - id: VR-202
title: Live IMAP- en SMTP-credentials configureren title: Live IMAP- en SMTP-credentials configureren
status: blocked-external status: done
priority: P0 priority: P0
requirement_ids: requirement_ids:
- PR-016 - PR-016
@@ -1356,22 +1356,27 @@ tasks:
summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en summary: Configureer de gewenste afzonderlijke platformmailboxen via de beveiligde UI, de Fernet-secretkey en
digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs. digestrecipient; test read-only import en mailverzending zonder credentials in Git/logs.
acceptance_criteria: acceptance_criteria:
- Iedere platformmailbox gebruikt TLS, een dedicated account/app-wachtwoord en read-only mailboxselectie. - Iedere platformmailbox gebruikt TLS, een dedicated alias en read-only mailboxselectie; Microsoft 365 gebruikt
app-only OAuth/XOAUTH2 en geen Basic Auth of app-wachtwoord.
- Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de - Dezelfde live testmail importeert per mailbox idempotent en alle geconfigureerde intervallen worden door de
scheduler gerespecteerd. scheduler gerespecteerd.
- SMTP verstuurt één testdigest en outboxstatus klopt. - SMTP verstuurt één testdigest en outboxstatus klopt.
- De encryptiesleutel staat alleen in de productie-secretstore/.env; app-wachtwoorden zijn als ciphertext in de - OAuth-clientcredentials en de encryptiesleutel staan alleen in de productie-secretstore/.env; eventuele Gmail/custom-app-wachtwoorden
database aanwezig en sleutelrotatie is live getest. zijn als ciphertext opgeslagen en sleutelrotatie is live getest.
verification: verification:
- Volg Unraid mailboxchecklist - Volg Unraid mailboxchecklist
- Inspecteer logs op afwezigheid van secret/payload - Inspecteer logs op afwezigheid van secret/payload
primary_paths: primary_paths:
- /mnt/user/appdata/vacatureradar/config/.env - /mnt/user/appdata/VacatureRadar/.env
- Bronnen -> Platformmailboxen - Bronnen -> Platformmailboxen
external_blocker: Vereist mailbox-/SMTP-account en app-password van de gebruiker. result:
completed_at: '2026-07-30'
note: M365 app-only OAuth/XOAUTH2 live ingericht met dedicated alias/map en Exchange-regel; negen platformmailboxen
importeerden dezelfde live testmail idempotent, schedulerstatus was correct, testdigest stond sent en logs
bleven payload-/secretvrij.
- id: VR-203 - id: VR-203
title: Productiedomein, TLS en reverse proxy activeren title: Productiedomein, TLS en reverse proxy activeren
status: blocked-external status: done
priority: P0 priority: P0
requirement_ids: requirement_ids:
- NFR-001 - NFR-001
@@ -1393,10 +1398,14 @@ tasks:
primary_paths: primary_paths:
- Unraid reverse proxy - Unraid reverse proxy
- /mnt/user/appdata/vacatureradar/config/.env - /mnt/user/appdata/vacatureradar/config/.env
external_blocker: Vereist gekozen domein/DNS en toegang tot de Unraid reverse proxy. result:
completed_at: '2026-07-29'
note: Publiek domein, HTTP-naar-HTTPS, TLS/securityheaders, DEBUG=False, secure cookies, CSRF-gastlogin, kernroutes
en readiness live bewezen in docs/audit/FINAL_ACCEPTANCE.md op 2026-07-29; interne diensten zijn uitsluitend
containerintern bereikbaar.
- id: VR-204 - id: VR-204
title: Immutable containerimage publiceren title: Immutable containerimage publiceren
status: blocked-external status: done
priority: P1 priority: P1
requirement_ids: requirement_ids:
- NFR-009 - NFR-009
@@ -1414,9 +1423,12 @@ tasks:
- Pull image op schone host - Pull image op schone host
- Start smoke stack en rollback naar vorige digest - Start smoke stack en rollback naar vorige digest
primary_paths: primary_paths:
- .github/workflows/release.yml - .gitea/workflows/release.yml
- docker-compose.unraid.yml - docker-compose.unraid.yml
external_blocker: Vereist registrykeuze, account en credentials. result:
completed_at: '2026-07-30'
note: Gitea private registry publiceerde v0.3.15 als immutable digest met commitlabel, SPDX-SBOM, checksums
en release notes; Unraid pull, digest-pin, healthy smoke en rollback naar 0.3.14 plus herstel zijn live geslaagd.
- id: VR-205 - id: VR-205
title: Gelicenseerde Belgische geodatabron laden title: Gelicenseerde Belgische geodatabron laden
status: done status: done
@@ -2331,7 +2343,7 @@ tasks:
documentatie; volledige gate 312 tests groen, 84,16% dekking. documentatie; volledige gate 312 tests groen, 84,16% dekking.
- id: VR-228 - id: VR-228
title: Optimaliseer de importpipeline voor release 0.3.14 title: Optimaliseer de importpipeline voor release 0.3.14
status: ready status: done
priority: P0 priority: P0
requirement_ids: requirement_ids:
- NFR-007 - NFR-007
@@ -2345,8 +2357,8 @@ tasks:
- Een fail-closed runner meet een disposable PostgreSQL-database met vaste fixture, warm-up en drie herhalingen. - Een fail-closed runner meet een disposable PostgreSQL-database met vaste fixture, warm-up en drie herhalingen.
- De geoptimaliseerde import reduceert queryvolume en doorlooptijd aantoonbaar tegenover 0.3.13 en rapporteert - De geoptimaliseerde import reduceert queryvolume en doorlooptijd aantoonbaar tegenover 0.3.13 en rapporteert
p50, p95, throughput, geheugen en queryfamilies. p50, p95, throughput, geheugen en queryfamilies.
- Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische - Parserdekking blijft 12/12, dedupeprecision en -recall blijven 1,0, rankingchurn blijft nul en historische ScoreRuns
ScoreRuns plus veldprovenance blijven intact. plus veldprovenance blijven intact.
- Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie - Regressietests begrenzen het queryvolume; de volledige kwaliteitsgate slaagt tweemaal en productieacceptatie
bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid. bewijst health, beperkte importsmoke, publieke kernflows en rollbackgereedheid.
verification: verification:
@@ -2359,3 +2371,134 @@ tasks:
- scripts/benchmark_import.py - scripts/benchmark_import.py
- tests/integration/test_pipeline.py - tests/integration/test_pipeline.py
- docs/audit/IMPORT_PERFORMANCE_FINAL.md - docs/audit/IMPORT_PERFORMANCE_FINAL.md
result:
completed_at: '2026-07-29'
note: 'Release 0.3.14: geïsoleerde PostgreSQL-benchmark reduceert importduur 44,74% en queries minstens 63,73%;
twee volledige gates met 318 tests groen; productie healthy op image sha256:1ee84241, transactionele importsmoke
teruggerold en rollbackimage 0.3.13 beschikbaar.'
- id: VR-229
title: Herstel de publieke demo na de visuele 0.3.15-audit
status: done
priority: P1
requirement_ids:
- NFR-008
- NFR-009
depends_on:
- VR-228
summary: Maak de publieke demo opnieuw representatief en tijdsbestendig, scheid mailboxingress van URL-fetching
en laat operationele statussen exact zeggen wat ze meten.
acceptance_criteria:
- Iedere nieuwe demosessie herstelt zes fictieve actuele vacatures, scores en toekomstige opvolgdatums zonder
duplicaten of groeiende ScoreRun-historiek.
- De demo toont negen fictieve, actieve platformmailboxkoppelingen zonder secrets of echte persoonsgegevens.
- E-mailbronnen worden nooit door de generieke URL-fetchscheduler gepland en oude mailboxpolicyfouten vervuilen
dashboard en activiteit niet meer.
- Navigatie en dashboard noemen het zoekprofiel expliciet actief zonder brongezondheid te suggereren; het lege
loginwachtwoordveld lijkt niet vooraf ingevuld.
- De negen kernroutes blijven in dark/light en op mobiel, desktop en ultrawide zonder overflow, consolefouten
of headingregressies bruikbaar; de volledige kwaliteitsgate slaagt.
verification:
- python -m pytest tests/integration/test_auth_login.py tests/integration/test_tasks_and_more_views.py
- Browseraudit op 390x844, 1280x600, 1440x900 en 3440x1440
- ./scripts/codex_verify.sh
primary_paths:
- apps/jobs/services/demo_seed.py
- apps/jobs/services/activity.py
- apps/jobs/services/cockpit.py
- apps/sources/tasks.py
- templates/base.html
- templates/dashboard/today.html
- templates/registration/login.html
- tests/integration/test_auth_login.py
- tests/integration/test_tasks_and_more_views.py
result:
completed_at: '2026-08-12'
note: Publieke demo wordt bij iedere sessie idempotent hersteld met 6 actuele fictieve vacatures, tijdsbestendige
opvolging en 9 fictieve mailboxkoppelingen; EMAIL-bronnen en demo-mailboxen zijn uit de verkeerde schedulers
en activityfeeds uitgesloten, statuscopy en loginplaceholder zijn gecorrigeerd. Gerichte 51 tests en volledige
gate met 327 tests groen, 84,04% dekking.
- id: VR-230
title: Herstel de immutable Gitea-releasepublicatie
status: done
priority: P0
requirement_ids:
- NFR-009
depends_on:
- VR-229
summary: Verwijder de externe GitHub-API-rate-limit uit de releasebootstrap en publiceer de actuele productiebuild
opnieuw als aantoonbaar immutable private-registryimage.
acceptance_criteria:
- De uv-bootstrap is op een immutable actioncommit en een exacte uv-versie gepind.
- De releaseworkflow gebruikt voor uv-downloads geen GitHub Releases-API die op een gedeeld runner-IP kan limiteren.
- Gitea Actions publiceert een private registryimage met digest, SBOM, checksums en release notes.
- Unraid is op de gepubliceerde digest gepind en live health/readiness slagen.
verification:
- uv run pytest tests/unit/test_deployment_configuration.py
- ./scripts/codex_verify.sh
- Gitea Actions releasebewijs en live Unraid health/readiness
primary_paths:
- .gitea/workflows/release.yml
- tests/unit/test_deployment_configuration.py
- docs/audit/FINAL_ACCEPTANCE.md
result:
completed_at: '2026-08-12'
note: Releasebootstrap gepind op immutable setup-uv v8.1.0-commit en uv 0.11.12 via Astral-mirror; Gitea Actions-run
3062 publiceerde digest sha256:98f4b33d met checksum-geldige SBOM/release-evidence; Unraid gepind op volledige
digest en live health/readiness groen.
- id: VR-232
title: Houd uitgaand werk vast tijdens gecontroleerde datamigratie
status: ready
priority: P0
requirement_ids: [NFR-009]
depends_on: [VR-231]
summary: Door de eigenaar gevraagde migratie met rollback; Celery start pas na vrijgave van de kandidaatkopie.
acceptance_criteria:
- Een marker in de kandidaatkopie blokkeert worker en scheduler voor hun eerste externe actie.
- Zonder marker blijft normaal opstartgedrag ongewijzigd.
- Onbekende procesrollen en ongeldige markers falen gesloten.
- De migratie verwijdert uitsluitend haar eigen marker na geverifieerde commit.
- Gewone webverzoeken blijven geblokkeerd tot commit; alleen exacte GET/HEAD-healthroutes zijn beschikbaar.
- De migratie behoudt het bestaande Docker-netwerk via een verplichte gevalideerde variabele.
verification:
- uv run pytest tests/unit/test_migration_worker_gate.py
- uv run pytest tests/unit/test_migration_maintenance.py
- ./scripts/codex_verify.sh
primary_paths:
- scripts/migration_worker_gate.py
- deployment/unraid/supervisord.conf
- tests/unit/test_migration_worker_gate.py
- tests/unit/test_migration_maintenance.py
- apps/core/middleware.py
- config/settings.py
- docker-compose.migration.yml
- id: VR-231
title: Sluit de operationele 0.3.17-restpunten
status: done
priority: P0
requirement_ids:
- NFR-009
depends_on:
- VR-230
summary: Verwijder de achterhaalde push-to-deployroute, herstel voldoende Docker-netwerkcapaciteit, valideer de
resterende mailboxfout en maak actuele versus historische deploymentstatus ondubbelzinnig.
acceptance_criteria:
- Main-pushes maken geen onbeperkt wachtende deployruns meer; alleen de immutable tagrelease bouwt en publiceert.
- Unraid heeft een persistente, conflictvrije default address pool met minstens 256 kleine bridge-netwerken.
- De Jobat-productiemailbox slaagt opnieuw via de bestaande OAuth/IMAP-taak of levert een exact extern restpunt.
- Projectstatus en deploymentdocumentatie beschrijven alleen het actuele releasepad als operationele waarheid.
verification:
- uv run pytest tests/unit/test_deployment_configuration.py
- ./scripts/codex_verify.sh
- Live Docker-netwerk-, mailbox-, container- en readinesssmokes
primary_paths:
- .gitea/workflows/release.yml
- docs/operations/GITEA_DEPLOYMENT.md
- docs/operations/UNRAID_DEPLOYMENT.md
- docs/ai/PROJECT_STATE.md
- tests/unit/test_deployment_configuration.py
result:
completed_at: '2026-08-12'
note: Oude main-pushdeploy verwijderd en run 3065 begrensd geannuleerd; Jobat OAuth/IMAP-herpoll groen met 61
duplicates en 0 fouten; Unraid persistent op conflictvrije 10.200.0.0/16→/24-pool gezet en live proefnetwerk
bewezen; na Docker-herstart alle vooraf actieve containers hersteld en geen unhealthy containers, VacatureRadar
publiek/intern healthy.
+150 -10
View File
@@ -1,7 +1,19 @@
# Projectstatus # Projectstatus
- Laatst bijgewerkt: 2026-07-29 ## Gecontroleerde migratie — 2026-09-09
- Repositoryversie: 0.3.13 integrale professionalisering
VR-232 voegt een startup-hold voor Celery worker/beat toe tijdens de expliciet
goedgekeurde legacy-migratie. Alleen de kandidaatdatakopie krijgt een marker;
uitgaand werk wordt pas na een geverifieerde deploymentcommit vrijgegeven.
De eerste middleware blokkeert ook gewone webverzoeken tot commit; alleen exacte
GET/HEAD-healthroutes blijven beschikbaar. De netwerkoverride behoudt het bestaande
netwerk. Lokaal slagen 344 tests inclusief browsercontroles (84,07% coverage).
De volledige `scripts/codex_verify.sh`-gate is groen: Ruff, Django-checks,
migratiecontrole, tests, taakledger en repositoryvalidatie zijn geslaagd.
Live migratie is niet uitgevoerd. Bestaande productietaken zijn niet gewijzigd.
- Laatst bijgewerkt: 2026-08-12
- Repositoryversie: 0.3.17 immutable releaseherstel
- Uitvoeringsmodus: autonome backlog - Uitvoeringsmodus: autonome backlog
- Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next` - Eerste uitvoerbare taak: controleer met `python scripts/backlog.py next`
@@ -19,8 +31,131 @@ De repository bevat een uitvoerbare Django-MVP met:
- Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts; - Celeryworker/scheduler, healthchecks, Docker/Unraidconfiguratie en back-up-/herstelscripts;
- offline unit-, integratie- en securitytests. - offline unit-, integratie- en securitytests.
## Laatste documentatie-update
Visuele demo-hardening `VR-229` op 2026-08-12:
- iedere publieke demosessie herstelt idempotent zes actuele fictieve vacatures,
de laatste scores, twee toekomstige opvolgmomenten en negen geïsoleerde
`example.test`-platformmailboxkoppelingen;
- mailboxbronnen worden niet meer door de URL-fetchscheduler gepland, de
publieke demo wordt uitgesloten van echte mailboxpolling en historische
mailboxpolicyfouten vervuilen dashboard en activiteit niet langer;
- navigatie en dashboard onderscheiden nu expliciet een actief zoekprofiel van
bron- of systeemgezondheid; het lege loginwachtwoordveld heeft geen misleidende
mask-placeholder meer;
- visuele audit: negen kernroutes groen in dark/light op 390×844, 1280×600,
1440×900 en 3440×1440 zonder overflow, heading- of consolefouten;
- gerichte regressies: 51 tests groen; volledige gate: 327 tests, 0 failures,
0 skips en 84,04% branch-aware dekking;
- commit `a6c8a79` en tag `v0.3.16` zijn naar Gitea gepusht; vóór uitrol is onder
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.16-20260812T094608Z`
een checksum-geldige database- en configuratieback-up gemaakt;
- Unraid draait healthy zonder restarts op lokaal gebouwde image
`sha256:c98001c063e8358dc59dfc175c574187b36021dd5a81f44e3ba982a28c9f9341`;
een live sessiesmoke bevestigde dashboard 200, zes demomatches, negen
platformmailboxen, actieve ingress, nul oude policyfouten en geen
wachtwoord-maskplaceholder;
- `VR-230` herstelde de Gitea-releaseworkflow door `setup-uv` op immutable commit
`0880764` en uv op 0.11.12 te pinnen; de Astral-mirror verwijdert de eerdere
GitHub Releases-API-rate-limit;
- vier lege, verweesde Docker-netwerken zijn gecontroleerd verwijderd nadat de
runner geen subnet meer kon reserveren; actieve productie- en compose-netwerken
bleven onaangeroerd;
- Gitea Actions-run 3062 publiceerde 0.3.17 met digest
`sha256:98f4b33db353c192967013818f7c82c4f171921f3d300645908d52680fde209f`
en artifact `release-evidence-v0.3.17` met SPDX-SBOM, checksums en release notes;
- een dedicated `read:package`-token geeft Unraid alleen pulltoegang. De
pre-deployback-up staat onder
`/mnt/user/appdata/VacatureRadar/local/backups/release-0.3.17-20260812T114510Z`;
- Unraid is live op de immutable digest gepind: container healthy, nul restarts,
interne liveness/readiness groen, publieke readiness 200 en runtimeversie 0.3.17.
- `VR-231` verwijderde de oude `main`-push-to-deployworkflow en annuleerde de
laatste onbeperkt wachtende run 3065; alleen semantische tags publiceren nog;
- Jobat is via de bestaande OAuth/IMAP-taak geforceerd herhaald: 61 duplicaten
correct herkend, nul fouten en de foutstatus volledig gewist;
- Unraid gebruikt persistent `10.200.0.0/16,size=24`; een live proefnetwerk kreeg
`10.200.1.0/24` en werd daarna verwijderd. Na de gecontroleerde Docker-herstart
zijn alle 127 vooraf actieve containers hersteld; VacatureRadar, Grimmory en
MeTube zijn healthy en er zijn geen unhealthy of herstartende containers;
- serverback-ups bewaren de eerdere `docker.cfg`, de running-containersnapshot,
de Grimmory-template en de Gitea-database vóór de begrensde runannulering.
Volgende uitvoerbare backlogtaak: geen.
README-update op 2026-07-30:
- de repository-ingang benoemt release 0.3.15, de groene 324-testgate, de live
M365 app-only OAuth/XOAUTH2-flow en de immutable private Gitea-registryrelease;
- eerste configuratie noemt alle negen ondersteunde jobplatformen en verwijst
expliciet naar het M365 least-privilege- en rotatiepad in het runbook;
- de bestaande negen screenshots zijn visueel herbeoordeeld. De layout is nog
actueel; de bronafbeelding toont bewust een gesaneerde demo zonder actieve
ingress en is daarom als demo-momentopname verduidelijkt, niet vervangen door
een productiebeeld met accountgegevens;
- eindverificatie: volledige gate groen met 324 tests, 0 failures, 0 skips en
84,07% branch-aware dekking.
## Laatste productiehardening ## 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: 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`; - basiscommit `5e2453c` en hardeningcommit `a4a71eb` naar `origin/main` gepusht en op Unraid werkelijk gebouwd als finale image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`;
@@ -338,10 +473,13 @@ VR-208-herverificatie op 2026-07-22:
## Deploymentstatus ## Deploymentstatus
- `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht. - `main` is via de bestaande Gitea-SSH-sleutel naar `NuklearRabbit/VacatureRadar` gepusht.
- De applicatie draait op de Unraid-server via `http://192.168.10.150:1226/` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler. - De applicatie draait op de Unraid-server via de geconfigureerde private host en poort `1226` als één Dockerman-container; Supervisor bewaakt intern PostgreSQL 17, Redis, web, worker en scheduler.
- De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226. - De Dockerman-tegel gebruikt de lokale VacatureRadar-favicon en opent rechtstreeks de WebUI op poort 1226.
- De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd. - De server-side `.env` heeft rechten `0600`; gegenereerde secrets zijn niet naar Git of logs gekopieerd.
- De self-hosted Gitea Actions-runner is niet actief. Deze sessie gebruikte daarom een directe, commitgebonden SSH-deploy; push-to-deploy vereist nog runnerherstel/registratie. - Historisch bewijs (2026-07-22, inmiddels vervangen): de self-hosted runner was
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. - Deze historische LAN-beperking is op 2026-07-29 opgeheven: het publieke domein, reverse proxy/TLS, `DJANGO_DEBUG=0` en secure cookies zijn live gevalideerd.
## Laatste uitgevoerde backlogtaak ## Laatste uitgevoerde backlogtaak
@@ -535,21 +673,23 @@ Een toekomstige agent moet altijd de actuele waarheid opnieuw bepalen met:
## Nog te bouwen ## Nog te bouwen
Momenteel staan er geen `ready` taken meer over; het volgende te behandelen item is `VR-202` als `blocked-external` voor live mailbox-/SMTP-credentials en echte jobalertmails van de gekozen platformen. Momenteel staan er geen `ready` taken of P0/P1-releaseblokkers meer over.
## Externe blokkades ## Externe blokkades
Deze blokkeren onafhankelijke code niet: Deze blokkeren onafhankelijke code niet:
- verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus; - verdere persoonlijke werkgevers-/bronnenkeuze en actuele menselijke review buiten de actieve VITO-/Deliverect-startercatalogus;
- live IMAP/SMTP-account en app-password;
- Unraid-domein, DNS, TLS en reverse proxy;
- containerregistry en credentials;
- actieve/voor deze repository geregistreerde Gitea Actions-runner;
## Bekende verificatiebeperking van de aangeleverde basis ## Bekende verificatiebeperking van de aangeleverde basis
De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn geverifieerd. De optionele pytest-Playwrightvarianten vallen lokaal terug op de HTML/a11y-probe; VR-119 is aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op het begeleide profiel en de live vacaturefeed en VR-124 op de acht platformmailboxen en alertkaarten. Een publieke HTTPS-smoke blijft afhankelijk van domein-, DNS- en reverse-proxyconfiguratie. De Pythonapplicatie, Dockerimage en PostgreSQL/Redis/Unraid-stack zijn
geverifieerd. De verplichte Playwright-gate draait lokaal met Chromium; VR-119 is
aanvullend met de geïntegreerde browser op alle kernroutes uitgevoerd, VR-120 op
het begeleide profiel en de live vacaturefeed en VR-124 op de acht
platformmailboxen en alertkaarten. Publieke HTTPS, proxyheaders en de gastflow
zijn op 2026-07-29 live geaccepteerd en vastgelegd in
`docs/audit/FINAL_ACCEPTANCE.md`.
## Hervatten ## Hervatten
+28 -7
View File
@@ -1,18 +1,39 @@
# Finale acceptance 0.3.13 # Finale acceptance 0.3.17
Status: productie geaccepteerd op 2026-07-29. Status: productie geaccepteerd op 2026-08-12.
## Bewijs ## Bewijs
- `./scripts/codex_verify.sh`: 312 geslaagd, 0 mislukt, 0 overgeslagen, 84,16% branch-aware dekking. - `./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. - Benchmarkkwaliteit: 12/12 parservelden, dedupeprecision 1,0, recall 1,0 en rankingchurn 0,0.
- Unraid Docker 27.5.1 en Compose 2.40.3 bouwden image `sha256:34aef8853f77a3f311b119bd67d9f1160a97fd4a21e62c9776222be289bf3b88`; migrations, 148 static files, manifestgeneratie, healthcheck en restart slaagden. - 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. - HTTP redirect naar HTTPS, OpenResty/TLS, HSTS/securityheaders, `DEBUG=False`, secure session-/CSRF-cookies en geen `DisallowedHost` zijn publiek bewezen.
- Login en gastknop, alleen-lezen gasttoegang, dashboard, zoek/filterlijst, detail, pipeline, bronnen en automatisering werkten live. - 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. - 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. - PostgreSQL custom dump plus media/configbewijs is naar een geïsoleerde database hersteld; migrations, exacte recordtellingen en een aparte applicatiestart slaagden.
- Vorige image `sha256:1851863be947b1e8b504e61e73b89d09c1dff704257b48856c775bb55f741459` is live healthy gestart en daarna opnieuw vervangen door 0.3.13, eveneens healthy. - 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 ## Uitkomst
Er bestaan geen bekende P0- of P1-releaseblokkers. Alleen optionele Entra- en mailboxcredentials blijven extern. De importqueryoptimalisatie blijft een meetbaar performancevervolg en geen correctness- of releaseblokker. Er bestaan geen bekende P0- of P1-releaseblokkers.
+31 -32
View File
@@ -1,40 +1,39 @@
# Gitea push-to-deploy (self-hosted, geen handmatige variabelenvulling) # Gitea immutable releasepad
Push naar `main` of `master` triggert automatisch: Een push naar `main` publiceert geen productiecode. Alleen een expliciete semantische
tag `v*.*.*` start `.gitea/workflows/release.yml`. De runner:
- checkout op de server runner 1. verifieert de volledige repository;
- `bash scripts/deploy_docker.sh` 2. bouwt de Unraid-image met commit- en versielabels;
- deploy met `docker compose up -d --build` 3. publiceert die image naar de private Gitea-containerregistry;
4. genereert een SPDX-SBOM, digestbestand, checksums en release notes.
Deze workflow gebruikt defaults en hoeft niet te wachten op repo-secrets voor host/ssh/credentials. Productie wordt daarna expliciet op de volledige `versie@sha256:digest` gepind.
Hierdoor kan een gewone documentatie- of codepush nooit ongemerkt productie
overschrijven en bestaat geen parallel mutable push-to-deploypad meer.
## Vereiste eenmalige setup op de server ## Vereiste runnerconfiguratie
1. Start en registreer een self-hosted Gitea-runner voor deze repo. - De Gitea-runner accepteert label `ubuntu-latest` en kan jobcontainers starten.
2. Zorg dat die runner Docker/Compose kan draaien in de projectmap waar de runner draait. - `REGISTRY_USER` en `REGISTRY_TOKEN` zijn repositorysecrets met uitsluitend de
3. Laat éénmaal dit script draaien op de server: noodzakelijke package-schrijfrechten.
- Unraid gebruikt een afzonderlijk `read:package`-token om gepubliceerde images te
pullen; dat token staat alleen in de root-Dockerconfig op de server.
- De runnerhost heeft voldoende vrije Docker-address-pools voor tijdelijke
jobnetwerken; volg daarvoor de netwerksectie in
[Unraid-deployment](UNRAID_DEPLOYMENT.md#docker-netwerkcapaciteit).
```bash ## Publiceren en uitrollen
bash scripts/deploy_docker.sh
```
Daarna is het gedrag volledig push-only: 1. Maak en verifieer een database- en configuratieback-up.
2. Push de geverifieerde releasecommit naar `main`.
3. Maak en push de semantische tag.
4. Wacht tot de Gitea-releasejob groen is en verifieer de artifactchecksums.
5. Pull op Unraid de gepubliceerde volledige digest.
6. Zet `VACATURERADAR_IMAGE` op `versie@sha256:digest` en herstart Compose met
`--no-build --force-recreate`.
7. Controleer intern en publiek `/health/live/` en `/health/ready/`, runtimeversie,
containerhealth en restartteller.
- `git push` naar `main` of `master` → automatische deploy PostgreSQL, Redis en Ollama blijven niet publiek bereikbaar. Bewaar de vorige
digest totdat minstens één volledige schedulercyclus groen is.
## Defaults
- `APP_HOST`: hostname van de server (fallback: `hostname`)
- `APP_SCHEME`: `http`
- `DJANGO_DEBUG`: afgeleid uit `APP_SCHEME`
- `0` als `APP_SCHEME=https`
- `1` als `APP_SCHEME=http`
- `DEPLOY_COMPOSE_FILE`: `docker-compose.yml`
- `APP_ORIGINS`, `APP_HOSTS`, `APP_PORT`: optioneel en veilig overnemen als op runner gezet
## Endpoint na deploy
- `http://<runner-host>:1226/health/ready/`
- `http://<runner-host>:1226/health/live/`
Voor productie: zet later op de runner de gewenste runtime-omgeving (bijv. reverse proxy + TLS) en herstart push.
+5
View File
@@ -77,6 +77,11 @@ celery -A config inspect reserved
## Digest ontbreekt of dubbel ## Digest ontbreekt of dubbel
- Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`. - Controleer actief profiel, digesttijd/zone en `DIGEST_RECIPIENT`.
- Microsoft 365 gebruikt uitsluitend app-only OAuth. Configureer `M365_TENANT_ID`,
`M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_MAILBOX_USER` en
`EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`.
Verleen alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp`, admin consent en mailboxscope;
Basic Auth en Microsoft 365-app-wachtwoorden worden niet ondersteund.
- Controleer `DigestOutbox` op datum en status. - Controleer `DigestOutbox` op datum en status.
- Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd. - Bij SMTP-fout: corrigeer configuratie en retry dezelfde outbox gecontroleerd.
- Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière. - Maak geen tweede outboxrecord handmatig; uniciteit per profiel/datum is de duplicaatbarrière.
+21 -2
View File
@@ -105,7 +105,7 @@ Gebruik vanaf een beheerwerkstation altijd een benoemde SSH-hostalias met een af
```sshconfig ```sshconfig
Host unraid-itworx Host unraid-itworx
HostName 192.168.10.150 HostName server.example.test
Port 22 Port 22
User root User root
IdentityFile ~/.ssh/itworx_unraid_deploy IdentityFile ~/.ssh/itworx_unraid_deploy
@@ -164,7 +164,26 @@ IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000 IMAP_MAX_MESSAGE_BYTES=1000000
``` ```
Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Kies Gmail, Outlook / Microsoft 365 of een aangepaste publieke IMAP-host, gebruik een app-wachtwoord en laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is. Herstart web, worker en scheduler. Open daarna **Bronnen → Platformmailboxen** en koppel ieder gewenst platform afzonderlijk: VDAB, Indeed, LinkedIn, ictjob.be, Jobat, StepStone, Careerjet, Randstad of Robert Half. Gmail en aangepaste publieke IMAP-hosts gebruiken een app-wachtwoord. Outlook / Microsoft 365 gebruikt centraal app-only OAuth via de vier `M365_*`-waarden en `EMAIL_BACKEND=apps.notifications.backends.Microsoft365OAuthEmailBackend`; geef de Entra-app alleen `IMAP.AccessAsApp` en `SMTP.SendAsApp` en beperk de Exchange-serviceprincipal tot de bedoelde mailbox. Laat `INBOX` geselecteerd tenzij de alert naar een aparte map wordt gefilterd. De scheduler controleert iedere vijf minuten welke mailbox volgens haar eigen interval verschuldigd is.
Voor een immutable registryrelease zet je `VACATURERADAR_IMAGE=gitea.itworx.tech/jens/vacatureradar:<versie>@sha256:<digest>` en start je met `docker compose -f docker-compose.unraid.yml up -d --no-build`.
## Docker-netwerkcapaciteit
Deze Unraid-host draait veel geïsoleerde Compose-netwerken. De ingebouwde
Dockerpools delen grote `/16`- en `/20`-netwerken uit en kunnen daardoor opraken
terwijl er nog ruim voldoende private adresruimte bestaat. De live host gebruikt
daarom persistent in `/boot/config/docker.cfg`:
```text
DOCKER_OPTS="--default-address-pool base=10.200.0.0/16,size=24"
```
Dat levert 256 nieuwe `/24`-netwerken en overlapt niet met LAN `192.168.10.0/24`,
WireGuard `10.253.0.0/16` of bestaande Docker-netwerken. De instelling geldt
alleen voor nieuw aangemaakte netwerken; bestaande subnets veranderen niet.
Maak vóór wijziging een kopie van `docker.cfg`, herstart Docker éénmaal en bewijs
de pool met een tijdelijk netwerk dat na inspectie meteen wordt verwijderd.
Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je: Voor sleutelrotatie zet je `nieuwe-sleutel,oude-sleutel` in `MAILBOX_CREDENTIAL_KEYS`, herstart je de processen en draai je:
+11 -3
View File
@@ -67,7 +67,7 @@ Django/Celery
| T-19 | Automatisch solliciteren/massamail | reputatie/privacy/juridisch risico | geen submitendpoint/task, expliciete ADR en non-goal | codezoekreview, AC-043 | | T-19 | Automatisch solliciteren/massamail | reputatie/privacy/juridisch risico | geen submitendpoint/task, expliciete ADR en non-goal | codezoekreview, AC-043 |
| T-20 | Kwaadaardig bestand in sollicitatiedossier | host/browsercompromis | uploads nog niet actief; vóór implementatie allowlist, sniffing, size limit, niet-uitvoerbare opslag vereist | `VR-113`, nieuwe threat review | | T-20 | Kwaadaardig bestand in sollicitatiedossier | host/browsercompromis | uploads nog niet actief; vóór implementatie allowlist, sniffing, size limit, niet-uitvoerbare opslag vereist | `VR-113`, nieuwe threat review |
| T-21 | Back-up bevat secrets/PII | langdurig datalek | encryptie, gescheiden opslag, korte retentie, herstelprocedure | runbook en hersteltest | | T-21 | Back-up bevat secrets/PII | langdurig datalek | encryptie, gescheiden opslag, korte retentie, herstelprocedure | runbook en hersteltest |
| T-22 | Supply-chainpackage/image | code execution | exacte versies/lockfile, beperkte dependencies, pinned baseimages/digests en scans gepland | CI, `VR-115` | | T-22 | Supply-chainpackage/image | code execution | exacte versies/lockfile, beperkte dependencies, pinned baseimages/digests, taggestuurde immutable release als enig productiepad, gescheiden package read/write-tokens en SBOM/checksums | CI, `VR-115`, `VR-230`, `VR-231` |
| T-23 | Onbevoegde admin op thuisnetwerk | volledige datatoegang | uniek wachtwoord, TLS/VPN, geen defaultcredentials, sessiebeveiliging | Unraidchecklist | | T-23 | Onbevoegde admin op thuisnetwerk | volledige datatoegang | uniek wachtwoord, TLS/VPN, geen defaultcredentials, sessiebeveiliging | Unraidchecklist |
| T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` | | T-24 | Source terms wijzigen | ongewenste voortgezette crawling | reviewdatum, source health en policy expiry gepland | `VR-102`, `VR-112` |
| T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` | | T-31 | Sollicitatiedossier-export bevat niet-gewenste payload | data-lek of onbedoeld dossieroverdragen | export bevat alleen het gekozen dossier, ZIP-inhoud is beperkt tot snapshot/tijdlijn/print-HTML, bestandsnaam is geslugified zonder padseparators | `tests/integration/test_applications.py` |
@@ -82,7 +82,7 @@ Django/Celery
| T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` | | T-40 | Verouderde lokale CA-store of ongeldige serverketen lokt een TLS-bypass uit | onderschepte vacature- of mailboxdata en credentialdiefstal | expliciet gepinde `certifi`-CA-bundel voor HTTP, robots en IMAP; hostnaamcontrole en `CERT_REQUIRED`; nooit `verify=False`; dependency- en negatieve TLS-tests bij update | `tests/unit/test_tls.py`, `tests/security/test_robots.py`, `apps/sources/services/tls.py`, `VR-201` |
| T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` | | T-41 | Gemodificeerde `next`-parameter na vacaturefeedback | open redirect naar phishingdomein na een geldige gebruikersactie | terugkeer-URL moet dezelfde host en het actuele HTTPS-beleid respecteren; anders veilige detailfallback | `tests/integration/test_tasks_and_more_views.py`, `VR-206` |
| T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` | | T-42 | Gespoofte proxyheaders of ontbrekende publieke hostconfiguratie | host-headerfouten, onjuiste HTTPS-detectie of omzeilde IP-rate-limiting | `PUBLIC_BASE_URL`, exacte host/CSRF-validatie, proxy moet headers overschrijven, forwarded client-IP alleen vanaf `TRUSTED_PROXY_CIDRS` | `tests/unit/test_public_configuration.py`, `tests/unit/test_network.py`, `VR-225` |
| T-43 | Gedeeld demoaccount wijzigt persistente data | bezoekers beïnvloeden elkaars ervaring of echte data | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, zichtbare UI-status en productiecheck | `tests/integration/test_auth_login.py`, `tests/unit/test_settings_security.py`, `VR-225` | | T-43 | Gedeeld demoaccount wijzigt persistente data of activeert echte mailboxpolling | bezoekers beïnvloeden elkaars ervaring, echte data of externe accounts | server-side read-only middleware, korte sessie, geen bruikbaar wachtwoord, uitsluitend fictieve `example.test`-mailboxen; de loginseed herstelt begrensd en idempotent en de scheduler sluit de demo-user expliciet uit | `tests/integration/test_auth_login.py`, `tests/integration/test_tasks_and_more_views.py`, `tests/unit/test_settings_security.py`, `VR-225/229` |
## Misbruikscenario's ## Misbruikscenario's
@@ -115,7 +115,10 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
## Resterende risico's ## Resterende risico's
- De fetcher controleert het werkelijk verbonden peer-IP wanneer httpcore dat exposeert. Een custom transport dat geen peerinformatie aanbiedt valt terug op DNS-set-overlap; volledige IP-pinning blijft een aanvullend egress-hardeningniveau. - De productiefetcher blokkeert fail-closed wanneer httpcore het werkelijk verbonden
peer-IP niet exposeert en weigert ieder niet-publiek peer-IP. Alleen een expliciet
geïnjecteerde test/custom client mag terugvallen op DNS-set-overlap; die clientgrens
mag daarom niet voor productie-fetches worden gebruikt.
- Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR. - Brute-force of herhaalde foutieve mutatiepogingen blijven mogelijk, maar zijn in productie begrensd via gedeelde Redis-rate-limiting en blokkades op login/manual-import. Correcte individuele client-IPherkenning vereist het exacte proxy-CIDR.
- Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen. - Bronvoorwaarden vereisen menselijke/externe review per domein; automatisering kan dat niet juridisch beslissen.
- Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet. - Fuzzy dedupe en featureheuristieken kunnen inhoudelijk verkeerd zijn; provenance, feedback en benchmarkevaluaties beperken maar elimineren dit niet.
@@ -150,3 +153,8 @@ Een wijziging aan netwerk, mail, AI, rendering, auth, exports of uploads vereist
- Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting - Impact: herhaalde herstarts van dezelfde bron veroorzaken extra belasting
- Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger - Mitigatie: cooldown plus metadataflag voor eenmalige canary, en trialstatus met beperkte runtrigger
- Verificatie: `tests/integration/test_source_health.py` - Verificatie: `tests/integration/test_source_health.py`
# Microsoft 365 OAuth
- Exchange Online gebruikt app-only XOAUTH2; Basic Auth en app-wachtwoorden zijn uitgesloten.
- Client secrets leven alleen in de productie-secretstore en tokenfouten bevatten geen token of secret.
- De serviceprincipal krijgt uitsluitend IMAP/SMTP-toegang tot de bedoelde mailbox; IMAP-selectie blijft read-only.
+10 -1
View File
@@ -64,7 +64,7 @@ Legenda: **I** = geïmplementeerd en getest in de huidige MVP; **P** = gedeeltel
| NFR-006 | I | offline pytest + coveragegate, inclusief platformbewuste productieconfiguratietests | VR-114 e2e; `tests/unit/test_public_configuration.py`; VR-225/226 | | NFR-006 | I | offline pytest + coveragegate, inclusief platformbewuste productieconfiguratietests | VR-114 e2e; `tests/unit/test_public_configuration.py`; VR-225/226 |
| NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 | | NFR-007 | P | `scripts/benchmark.py`, `tests/benchmark/test_benchmark.py` | VR-116 |
| NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, negen beelden in `docs/screenshots/readme/`, VR-209224/226 | | NFR-008 | I | volledige negen-schermen-Stitch-cockpit, acht viewports, dark/light, toetsenborddrawer, premium visuele hiërarchie, zelfstandig VacatureRadar-productmerk, lokale ITWorx-ontwikkelaarscredit, toegankelijke interactieve radarillustratie en actuele user-first productdocumentatie | `tests/e2e/test_vr114_browser_and_accessibility.py`, `tests/integration/test_intelligence_views.py`, `tests/integration/test_views.py`, `artifacts/visual-validation/`, negen beelden in `docs/screenshots/readme/`, VR-209224/226 |
| NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, publieke URL-helper, Nginx Proxy Manager-runbook, gevalideerde SSH-deployalias, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, `tests/unit/test_public_configuration.py`, VR-117, VR-217/218/225 afgerond | | NFR-009 | I | lokale SQLite, configureerbare eigenaarbranding, Docker/Unraidconfig, publieke URL-helper, Nginx Proxy Manager-runbook, gevalideerde SSH-deployalias, API-onafhankelijke immutable releasebootstrap zonder parallel push-to-deploypad, begrensde Docker-address pool, release-smoke en restore | `tests/unit/test_deployment_configuration.py`, `tests/unit/test_public_configuration.py`, VR-117, VR-217/218/225/230/231 afgerond |
| NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability | | NFR-010 | I | `apps/sources/services/health.py`, `apps/sources/tasks.py`, `apps/core/views.py`, `config/settings.py` | `tests/integration/test_source_health.py` | VR-112 observability |
## Systeemacceptatie ## Systeemacceptatie
@@ -85,6 +85,9 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
## Aanvullende traceability-opmerking ## Aanvullende traceability-opmerking
- VR-228 levert release 0.3.14 met een fail-closed disposable PostgreSQL-benchmark, run-lokale importcaches, gebundelde veldprovenance en ongewijzigde score-snapshotcopy. De officiële drie iteraties bewijzen 3.264 queries per 250 items, parser 12/12, dedupeprecision/recall 1,0 en nul rankingchurn; bewijs staat in `docs/audit/IMPORT_PERFORMANCE_*.md` en het machineleesbare artifact. - VR-228 levert release 0.3.14 met een fail-closed disposable PostgreSQL-benchmark, run-lokale importcaches, gebundelde veldprovenance en ongewijzigde score-snapshotcopy. De officiële drie iteraties bewijzen 3.264 queries per 250 items, parser 12/12, dedupeprecision/recall 1,0 en nul rankingchurn; bewijs staat in `docs/audit/IMPORT_PERFORMANCE_*.md` en het machineleesbare artifact.
- VR-229 houdt de publieke demo representatief door fictieve vacatures, scores, mailboxkoppelingen en opvolgdatums bij iedere demosessie idempotent te herstellen. Mailboxingress is uitgesloten van URL-fetchplanning en operationele UI-copy onderscheidt een actief profiel van bron- en systeemgezondheid.
- VR-230 pint `setup-uv` en uv exact, vermijdt de gelimiteerde GitHub Releases-API via de Astral-mirror en bewijst publicatie plus live pinning van de immutable 0.3.17-registrydigest.
- VR-231 verwijdert de hangende mutable deployroute, vergroot de Unraid-netwerkcapaciteit conflictvrij naar 256 `/24`-netwerken en bewijst na een volledige Docker-herstart dat alle vooraf actieve containers en VacatureRadar gezond zijn; de resterende Jobat-poll is opnieuw foutvrij geslaagd.
- VR-227 professionaliseert release 0.3.13 integraal: gecentreerd ultrawideframe, mobiele gastbanner en verticale pipeline, begrensde vacaturepaginering, verplichte zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, volledige generieke parserbenchmark en SBOM-generatie. Bewijs staat in `docs/audit/`, de E2E-/auth-/runtime-/adaptertests en `USER_INPUT_REQUIRED.md`. - VR-227 professionaliseert release 0.3.13 integraal: gecentreerd ultrawideframe, mobiele gastbanner en verticale pipeline, begrensde vacaturepaginering, verplichte zes-viewport-Playwrightgate zonder skips, echte runtimeheartbeat, volledige generieke parserbenchmark en SBOM-generatie. Bewijs staat in `docs/audit/`, de E2E-/auth-/runtime-/adaptertests en `USER_INPUT_REQUIRED.md`.
- VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews. - VR-102 is gerealiseerd met auditbare policyreviews (`SourcePolicyReview`), robotscache met TTL/size/SSRF-controles en fail-closed policy-gating op verlopen of conflicterende reviews.
- VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests. - VR-107 is gerealiseerd met `apps/sources/views.py`, `apps/sources/services/manual_import.py`, `templates/sources/list.html`, `static/js/manual_import.js` en bijhorende unit/integration-tests.
@@ -96,6 +99,12 @@ Iedere backlogtaak die een requirement wijzigt, werkt in dezelfde commit deze ma
- VR-122 operationaliseert de veilige platformingress als dagelijkse, configureerbare en begrensde mailboxpoll. VDAB-alerts krijgen expliciete provenance, replay blijft idempotent en de bronpagina onderscheidt de werkende jobalertroute van de contractueel afgeschermde officiële Vacature API. Bewijs staat in de e-mailadapter-/importtests, `apps/sources/services/imap_import.py` en ADR-0005. - VR-122 operationaliseert de veilige platformingress als dagelijkse, configureerbare en begrensde mailboxpoll. VDAB-alerts krijgen expliciete provenance, replay blijft idempotent en de bronpagina onderscheidt de werkende jobalertroute van de contractueel afgeschermde officiële Vacature API. Bewijs staat in de e-mailadapter-/importtests, `apps/sources/services/imap_import.py` en ADR-0005.
- VR-123 vervangt die globale poll door gebruikersgescopeerde VDAB- en Indeed-mailboxkoppelingen met versleutelde app-wachtwoorden, afzonderlijke intervallen, per-mailboxleases en UI-beheer. Bewijs staat in `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, de migratie en ADR-0009. - VR-123 vervangt die globale poll door gebruikersgescopeerde VDAB- en Indeed-mailboxkoppelingen met versleutelde app-wachtwoorden, afzonderlijke intervallen, per-mailboxleases en UI-beheer. Bewijs staat in `tests/unit/test_mailbox_credentials.py`, `tests/integration/test_mailbox_connections.py`, de migratie en ADR-0009.
- VR-124 breidt dezelfde mailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs. De platformgebonden parser accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein; fixtures, adapter-/integratietests en de centrale denylist bewijzen de afscherming. - VR-124 breidt dezelfde mailboxingang uit met LinkedIn, ictjob.be, Jobat, StepStone, EuroBrussels en BrusselsJobs. De platformgebonden parser accepteert uitsluitend gelabelde HTTPS-links op het gekozen jobboarddomein; fixtures, adapter-/integratietests en de centrale denylist bewijzen de afscherming.
- VR-202 bewijst de productie-ingress met M365 app-only OAuth/XOAUTH2, een
dedicated alias en map, negen platformconnecties, een idempotente live
testmail, correcte schedulerstatus en een verzonden digest-outbox.
- VR-204 bewijst de immutable releaseketen via de private Gitea-registry,
digest-pin, commitlabel, SPDX-SBOM, checksums, release notes en een live
Unraid pull-/rollback-/herstelsmoke.
- VR-125 vervangt de twee Brusselse nicheboards door Careerjet, Randstad en Robert Half, retireert buitenregionale werkgevers zonder provenanceverlies en voegt host- en padgebonden adapters toe voor ACA Group, Xplore Group, UHasselt, PXL en ZOL in de kern Hasselt-Genk. - VR-125 vervangt de twee Brusselse nicheboards door Careerjet, Randstad en Robert Half, retireert buitenregionale werkgevers zonder provenanceverlies en voegt host- en padgebonden adapters toe voor ACA Group, Xplore Group, UHasselt, PXL en ZOL in de kern Hasselt-Genk.
- VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39. - VR-126 herankert profiel en broncatalogus op postcode 2400 Mol en 40 km. De host-, pad- en locatiefilters voor SCK CEN, Cipal Schaubroeck, VanRoey, NTX en Thomas More worden gedekt door regionale adaptertests, source-viewtests, live bronruns en de bijgewerkte threat T-39.
- VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests. - VR-201 activeert Gemeente Mol en zeven bijkomende Kempen-werkgevers na menselijke voorwaardenreview, fixtures, fail-closed locatie-/routefilters en twee livecycli. Twaalf interessante werkgevers zonder stabiele actieve ingang staan afzonderlijk en niet-scanbaar op de UI-waaklijst. Bewijs staat in de livecatalogus, seeddata, `KempenEmployerAdapter`, bronruns en adapter-/seed-/viewtests.
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
"defusedxml==0.7.1", "defusedxml==0.7.1",
"gunicorn==26.0.0", "gunicorn==26.0.0",
"whitenoise==6.11.0", "whitenoise==6.11.0",
"cryptography==49.0.0", "cryptography==50.0.0",
"msal==1.37.0", "msal==1.37.0",
] ]
+60
View File
@@ -0,0 +1,60 @@
"""Keep outbound Celery work paused during a verified cold-data migration.
The operator creates the hold file in the candidate data clone before startup
and removes it only after committing the verified deployment. Normal startups
without a hold file behave unchanged. This does not pause an existing worker.
"""
import os
import stat
import sys
import time
from pathlib import Path
HOLD_FILE = Path("/app/local/.migration-worker-hold")
COMMANDS = {
"worker": [
"/app/.venv/bin/celery",
"-A",
"config",
"worker",
"-l",
"INFO",
"-Q",
"high,default,low",
"--concurrency=2",
],
"scheduler": [
"/app/.venv/bin/celery",
"-A",
"config",
"beat",
"-l",
"INFO",
"--schedule",
"/tmp/celerybeat-schedule", # noqa: S108 - existing supervised container-local schedule path
],
}
def hold_active(path=HOLD_FILE):
try:
mode = path.lstat().st_mode
except FileNotFoundError:
return False
if not stat.S_ISREG(mode):
raise RuntimeError("Migration hold must be a regular file")
return True
def start(role, *, check=hold_active, sleep=time.sleep, execute=os.execv):
if role not in COMMANDS:
raise ValueError("Unsupported supervised process")
while check():
sleep(1)
command = COMMANDS[role]
execute(command[0], command)
if __name__ == "__main__":
start(sys.argv[1] if len(sys.argv) == 2 else "")
+1 -1
View File
@@ -48,7 +48,7 @@
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="sidebar-radar-card"> <div class="sidebar-radar-card">
<span class="status-beacon {% if navigation_profile %}is-live{% endif %}" aria-hidden="true"></span> <span class="status-beacon {% if navigation_profile %}is-live{% endif %}" aria-hidden="true"></span>
<span><strong>{% if navigation_profile %}Radar actief{% else %}Profiel nodig{% endif %}</strong><small>{% if navigation_profile %}{{ navigation_profile.name }}{% else %}Configureer je zoekprofiel{% endif %}</small></span> <span><strong>{% if navigation_profile %}Profiel actief{% else %}Profiel nodig{% endif %}</strong><small>{% if navigation_profile %}{{ navigation_profile.name }}{% else %}Configureer je zoekprofiel{% endif %}</small></span>
</div> </div>
{% include "components/developer_credit.html" %} {% include "components/developer_credit.html" %}
</div> </div>
+1 -1
View File
@@ -3,7 +3,7 @@
{% block content %} {% block content %}
<header class="cockpit-heading dashboard-heading"> <header class="cockpit-heading dashboard-heading">
<div><p class="eyebrow">Intelligence cockpit · vandaag</p><h1>Goedemorgen, {{ user.first_name|default:user.username }}.</h1><p>{{ day_conclusion }}</p></div> <div><p class="eyebrow">Intelligence cockpit · vandaag</p><h1>Goedemorgen, {{ user.first_name|default:user.username }}.</h1><p>{{ day_conclusion }}</p></div>
<div class="radar-status" aria-label="Radarstatus"><span class="status-beacon {% if active_profile %}is-live{% endif %}" aria-hidden="true"></span><span>{% if active_profile %}{{ active_profile.name }} actief{% else %}Geen actief profiel{% endif %}</span>{% if latest_scan %}<span aria-hidden="true">·</span><span>scan {{ latest_scan.started_at|timesince }} geleden</span>{% endif %}</div> <div class="radar-status" aria-label="Profiel- en scanstatus"><span class="status-beacon {% if active_profile %}is-live{% endif %}" aria-hidden="true"></span><span>{% if active_profile %}Profiel actief · {{ active_profile.name }}{% else %}Geen actief profiel{% endif %}</span>{% if latest_scan %}<span aria-hidden="true">·</span><span>laatste URL-scan {{ latest_scan.started_at|timesince }} geleden</span>{% endif %}</div>
</header> </header>
<section class="stats-grid" aria-label="Radar kerncijfers"> <section class="stats-grid" aria-label="Radar kerncijfers">
+1 -1
View File
@@ -56,7 +56,7 @@
</div> </div>
<div class="input-wrap"> <div class="input-wrap">
<input class="auth-input" id="id_password" name="password" type="password" <input class="auth-input" id="id_password" name="password" type="password"
autocomplete="current-password" placeholder="••••••••" required autocomplete="current-password" required
{% if form.errors %}aria-invalid="true"{% endif %}> {% if form.errors %}aria-invalid="true"{% endif %}>
<button class="pw-toggle" type="button" data-pw-toggle aria-label="Wachtwoord tonen"> <button class="pw-toggle" type="button" data-pw-toggle aria-label="Wachtwoord tonen">
<svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-eye><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="2.7" stroke="currentColor" stroke-width="1.8"/></svg> <svg class="icon" viewBox="0 0 24 24" fill="none" aria-hidden="true" data-eye><path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="2.7" stroke="currentColor" stroke-width="1.8"/></svg>
+48 -2
View File
@@ -135,15 +135,61 @@ def test_demo_login_logs_in_and_seeds_environment(client):
# Het gevulde dashboard rendert zonder fouten. # Het gevulde dashboard rendert zonder fouten.
dashboard = client.get(reverse("dashboard:today")) dashboard = client.get(reverse("dashboard:today"))
assert dashboard.status_code == 200 assert dashboard.status_code == 200
dashboard_html = dashboard.content.decode("utf-8")
assert "Profiel actief" in dashboard_html
assert "Radar actief" not in dashboard_html
assert "Nog geen persoonlijke matches" not in dashboard_html
sources = client.get(reverse("sources:list"))
assert sources.status_code == 200
assert "minstens één import actief" in sources.content.decode("utf-8")
def test_demo_login_is_idempotent(client): def test_demo_login_is_idempotent(client, monkeypatch):
from apps.jobs.models import JobPosting from datetime import UTC, datetime, timedelta
from django.utils import timezone
from apps.jobs.models import Application, JobPosting
from apps.jobs.services import demo_seed
from apps.profiles.models import SearchProfile
from apps.sources.models import MailboxConnection, Source
# 22:30 UTC is already the following calendar day in Europe/Brussels.
# Freezing this boundary prevents UTC `.date()` calls from creeping back in.
near_local_midnight = datetime(2026, 9, 2, 22, 30, tzinfo=UTC)
monkeypatch.setattr(demo_seed.timezone, "now", lambda: near_local_midnight)
expected_local_day = timezone.localdate(near_local_midnight)
client.post(reverse("demo-login")) client.post(reverse("demo-login"))
first = JobPosting.objects.count() first = JobPosting.objects.count()
demo_user = get_user_model().objects.get(username=settings.DEMO_USERNAME)
profile = SearchProfile.objects.get(user=demo_user, is_active=True)
job = profile.scores.order_by("-score").first().job
job.status = JobPosting.Status.EXPIRED
job.last_seen = timezone.now() - timedelta(days=30)
job.save(update_fields=["status", "last_seen"])
application = Application.objects.filter(user=demo_user).first()
application.follow_up_date = timezone.localdate() - timedelta(days=14)
application.save(update_fields=["follow_up_date"])
client.logout()
client.post(reverse("demo-login")) client.post(reverse("demo-login"))
assert JobPosting.objects.count() == first assert JobPosting.objects.count() == first
job.refresh_from_db()
application.refresh_from_db()
assert job.status == JobPosting.Status.ACTIVE
assert timezone.localdate(job.last_seen) == expected_local_day
assert application.follow_up_date > expected_local_day
assert profile.scores.count() == 6
assert MailboxConnection.objects.filter(user=demo_user, enabled=True).count() == 9
assert Source.objects.filter(source_type=Source.Type.EMAIL, failure_count=0).count() >= 9
def test_login_password_field_has_no_mask_placeholder(client):
html = client.get(reverse("login")).content.decode("utf-8")
assert 'id="id_password"' in html
assert 'placeholder="••••••••"' not in html
def test_demo_account_is_read_only_and_session_is_bounded(client): def test_demo_account_is_read_only_and_session_is_bounded(client):
@@ -61,6 +61,7 @@ def test_user_can_link_edit_pause_and_sync_separate_platform_mailboxes(client, u
) )
assert response.status_code == 302 assert response.status_code == 302
indeed = MailboxConnection.objects.get(user=user, platform="indeed") indeed = MailboxConnection.objects.get(user=user, platform="indeed")
assert indeed.encrypted_password == ""
edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk])) edit_response = client.get(reverse("sources:mailbox_edit", args=[vdab.pk]))
assert edit_response.status_code == 200 assert edit_response.status_code == 200
@@ -104,6 +104,55 @@ def test_fetch_source_resets_fail_state_on_304(db, source, monkeypatch):
assert source.failure_count == 0 assert source.failure_count == 0
def test_due_source_scheduler_never_schedules_mailbox_sources(db, monkeypatch):
from apps.sources.tasks import schedule_due_sources
email_source = Source.objects.create(
name="Mailboxbron",
source_type=Source.Type.EMAIL,
base_url="",
domain="example.mailbox.local",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
)
scheduled = []
monkeypatch.setattr(
"apps.sources.tasks.fetch_source.delay", lambda source_id: scheduled.append(source_id)
)
result = schedule_due_sources()
assert email_source.pk not in scheduled
assert result["scheduled"] == len(scheduled)
def test_mailbox_scheduler_excludes_public_demo_user(db, monkeypatch, settings):
from django.contrib.auth import get_user_model
from apps.sources.models import MailboxConnection
from apps.sources.tasks import schedule_mailbox_polls
demo = get_user_model().objects.create_user(username=settings.DEMO_USERNAME)
connection = MailboxConnection.objects.create(
user=demo,
platform="vdab",
provider="outlook",
username="demo@example.test",
mailbox="Demo/VDAB",
enabled=True,
next_poll_at=timezone.now() - timedelta(minutes=1),
)
scheduled = []
monkeypatch.setattr(
"apps.sources.tasks.poll_mailbox.delay",
lambda connection_id: scheduled.append(connection_id),
)
schedule_mailbox_polls()
assert connection.pk not in scheduled
def test_fetch_source_is_blocked_by_conflicting_review(db): def test_fetch_source_is_blocked_by_conflicting_review(db):
source = Source.objects.create( source = Source.objects.create(
name="Blocked source", name="Blocked source",
@@ -128,3 +128,19 @@ def test_deploy_uses_selected_environment_file_for_compose() -> None:
assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script assert 'export VACATURERADAR_ENV_FILE="$env_file"' in deploy_script
assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script assert 'docker compose --env-file "$env_file" -f "$compose_file" up -d --build' in deploy_script
assert "waarde uit ${env_file}" in deploy_script assert "waarde uit ${env_file}" in deploy_script
def test_release_uv_install_is_immutable_and_does_not_require_github_api() -> None:
workflow = (ROOT / ".gitea" / "workflows" / "release.yml").read_text(encoding="utf-8")
assert "astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0" in workflow
assert 'version: "0.11.12"' in workflow
assert "astral-sh/setup-uv@v6" not in workflow
def test_release_is_the_only_gitea_deployment_trigger() -> None:
workflows = ROOT / ".gitea" / "workflows"
assert not (workflows / "deploy.yml").exists()
release = yaml.safe_load((workflows / "release.yml").read_text(encoding="utf-8"))
assert release[True] == {"push": {"tags": ["v*.*.*"]}}
+19
View File
@@ -8,6 +8,7 @@ from apps.sources.services.fetcher import (
FetchTimeoutError, FetchTimeoutError,
PolicyBlockedError, PolicyBlockedError,
RateLimitedError, RateLimitedError,
_validate_connected_peer,
fetch_url, fetch_url,
) )
from apps.sources.services.url_security import ValidatedUrl from apps.sources.services.url_security import ValidatedUrl
@@ -100,6 +101,24 @@ def test_fetcher_rejects_dns_rebinding(monkeypatch):
client.close() client.close()
def test_production_transport_fails_closed_without_connected_peer():
validated = ValidatedUrl(
url="https://example.org/jobs",
hostname="example.org",
port=443,
addresses=("93.184.216.34",),
)
response = httpx.Response(200)
with pytest.raises(FetchError, match="Verbonden IP-adres"):
_validate_connected_peer(
response,
before=validated,
after=validated,
require_peer=True,
)
class _PeerStream: class _PeerStream:
def __init__(self, address: str) -> None: def __init__(self, address: str) -> None:
self.address = address self.address = address
+13
View File
@@ -89,6 +89,19 @@ def test_rotation_reencrypts_existing_credentials_with_primary_key(user):
assert decrypt_mailbox_password(connection) == "rotate-me" assert decrypt_mailbox_password(connection) == "rotate-me"
@pytest.mark.django_db
def test_rotation_skips_outlook_oauth_connections(user):
MailboxConnection.objects.create(
user=user,
platform=MailboxConnection.Platform.INDEED,
provider=MailboxConnection.Provider.OUTLOOK,
username="vacatureradar@example.invalid",
encrypted_password="",
)
assert rotate_mailbox_credentials() == 0
@pytest.mark.django_db @pytest.mark.django_db
def test_private_imap_host_is_blocked_before_connection(user): def test_private_imap_host_is_blocked_before_connection(user):
key = Fernet.generate_key().decode("ascii") key = Fernet.generate_key().decode("ascii")
+108
View File
@@ -0,0 +1,108 @@
import base64
import pytest
from django.test import override_settings
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.notifications.backends import Microsoft365OAuthEmailBackend
@override_settings(
M365_TENANT_ID="tenant-id",
M365_CLIENT_ID="client-id",
M365_CLIENT_SECRET="client-secret",
M365_EXCHANGE_SCOPE="https://outlook.office365.com/.default",
)
def test_app_only_token_uses_tenant_authority_and_exchange_scope():
calls = {}
class Client:
def acquire_token_for_client(self, *, scopes):
calls["scopes"] = scopes
return {"access_token": "access-token"}
def factory(client_id, *, authority, client_credential):
calls.update(
client_id=client_id,
authority=authority,
client_credential=client_credential,
)
return Client()
assert acquire_exchange_access_token(client_factory=factory) == "access-token"
assert calls == {
"client_id": "client-id",
"authority": "https://login.microsoftonline.com/tenant-id",
"client_credential": "client-secret",
"scopes": ["https://outlook.office365.com/.default"],
}
@override_settings(M365_TENANT_ID="", M365_CLIENT_ID="", M365_CLIENT_SECRET="")
def test_missing_oauth_configuration_fails_without_secret_material():
with pytest.raises(Microsoft365OAuthError, match="niet volledig") as error:
acquire_exchange_access_token()
assert "secret" not in str(error.value).lower()
def test_xoauth2_payload_is_encoded_exactly():
encoded = xoauth2_b64("mailbox@example.invalid", "token-value")
assert base64.b64decode(encoded) == (
b"user=mailbox@example.invalid\x01auth=Bearer token-value\x01\x01"
)
@override_settings(
M365_MAILBOX_USER="vacatureradar@example.invalid",
EMAIL_HOST="smtp.office365.com",
EMAIL_PORT=587,
DEFAULT_FROM_EMAIL="VacatureRadar <vacatureradar@example.invalid>",
M365_SMTP_TIMEOUT_SECONDS=10,
)
def test_oauth_smtp_backend_authenticates_and_sends(monkeypatch):
calls = []
class SMTP:
def __init__(self, host, port, *, timeout):
calls.append(("connect", host, port, timeout))
def __enter__(self):
return self
def __exit__(self, *args):
return None
def ehlo(self):
calls.append(("ehlo",))
def starttls(self, *, context):
calls.append(("starttls", bool(context)))
def docmd(self, command, argument):
calls.append((command, argument))
return 235, b"ok"
def sendmail(self, sender, recipients, message):
calls.append(("sendmail", sender, recipients, message))
monkeypatch.setattr("apps.notifications.backends.smtplib.SMTP", SMTP)
monkeypatch.setattr(
"apps.notifications.backends.acquire_exchange_access_token",
lambda: "token-value",
)
from django.core.mail import EmailMessage as DjangoEmailMessage
django_message = DjangoEmailMessage(
subject="Test",
body="test",
from_email="vacatureradar@example.invalid",
to=["recipient@example.invalid"],
)
assert Microsoft365OAuthEmailBackend().send_messages([django_message]) == 1
assert calls[0] == ("connect", "smtp.office365.com", 587, 10)
assert any(call[0] == "AUTH" and call[1].startswith("XOAUTH2 ") for call in calls)
assert any(call[0] == "sendmail" for call in calls)
+45
View File
@@ -0,0 +1,45 @@
from unittest.mock import Mock, patch
import pytest
from django.http import HttpResponse
from django.test import RequestFactory
from apps.core.middleware import MigrationMaintenanceMiddleware
@pytest.mark.parametrize("method,path,allowed", [
("GET", "/health/ready/", True),
("HEAD", "/health/live/", True),
("POST", "/health/ready/", False),
("GET", "/health/ready", False),
("GET", "/", False),
("POST", "/accounts/login/", False),
("OPTIONS", "/health/live/", False),
])
def test_held_candidate_only_allows_exact_safe_health(method, path, allowed):
downstream = Mock(return_value=HttpResponse("ok"))
with patch("apps.core.middleware.hold_active", return_value=True):
response = MigrationMaintenanceMiddleware(downstream)(
RequestFactory().generic(method, path)
)
assert response.status_code == (200 if allowed else 503)
assert downstream.called is allowed
if not allowed:
assert response["Cache-Control"] == "no-store"
@pytest.mark.parametrize("failure", [PermissionError(), RuntimeError()])
def test_marker_errors_block_ordinary_traffic(failure):
downstream = Mock()
with patch("apps.core.middleware.hold_active", side_effect=failure):
response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().get("/"))
assert response.status_code == 503
downstream.assert_not_called()
def test_normal_runtime_unchanged_and_middleware_is_first(settings):
assert settings.MIDDLEWARE[0] == "apps.core.middleware.MigrationMaintenanceMiddleware"
downstream = Mock(return_value=HttpResponse("ok"))
with patch("apps.core.middleware.hold_active", return_value=False):
response = MigrationMaintenanceMiddleware(downstream)(RequestFactory().post("/"))
assert response.status_code == 200
+44
View File
@@ -0,0 +1,44 @@
import importlib.util
from pathlib import Path
import pytest
spec = importlib.util.spec_from_file_location(
"migration_worker_gate",
Path(__file__).resolve().parents[2] / "scripts/migration_worker_gate.py",
)
gate = importlib.util.module_from_spec(spec)
spec.loader.exec_module(gate)
@pytest.mark.parametrize("role", ["worker", "scheduler"])
def test_hold_prevents_outbound_process_until_release(role):
states = iter([True, True, False])
events = []
gate.start(
role,
check=lambda: next(states),
sleep=lambda seconds: events.append("wait"),
execute=lambda executable, args: events.append(args),
)
assert events == ["wait", "wait", gate.COMMANDS[role]]
def test_normal_startup_and_missing_hold(tmp_path):
assert not gate.hold_active(tmp_path / "absent")
events = []
gate.start("worker", check=lambda: False, execute=lambda executable, args: events.append(args))
assert events == [gate.COMMANDS["worker"]]
def test_hold_exists_and_unsupported_marker_fails_closed(tmp_path):
marker = tmp_path / "hold"
marker.write_text("fixture-attempt")
assert gate.hold_active(marker)
with pytest.raises(RuntimeError):
gate.hold_active(tmp_path)
def test_unknown_role_never_executes():
with pytest.raises(ValueError):
gate.start("shell", execute=lambda *args: pytest.fail("unreviewed command"))
+3 -3
View File
@@ -49,8 +49,8 @@ def test_configure_public_url_updates_only_public_security_settings(tmp_path: Pa
env_file.write_text( env_file.write_text(
"DJANGO_SECRET_KEY=keep-this-secret\n" "DJANGO_SECRET_KEY=keep-this-secret\n"
"POSTGRES_PASSWORD=keep-this-password\n" "POSTGRES_PASSWORD=keep-this-password\n"
"DJANGO_ALLOWED_HOSTS=192.168.10.150,localhost\n" "DJANGO_ALLOWED_HOSTS=vacatureradar.local,localhost\n"
"DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226\n", "DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226\n",
encoding="utf-8", encoding="utf-8",
) )
@@ -67,7 +67,7 @@ def test_configure_public_url_updates_only_public_security_settings(tmp_path: Pa
assert "DJANGO_DEBUG=0" in content assert "DJANGO_DEBUG=0" in content
assert "vacatureradar.itworx.tech" in content assert "vacatureradar.itworx.tech" in content
assert ( assert (
"DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.10.150:1226,https://vacatureradar.itworx.tech" "DJANGO_CSRF_TRUSTED_ORIGINS=http://vacatureradar.local:1226,https://vacatureradar.itworx.tech"
) in content ) in content
assert "CACHE_URL=redis://127.0.0.1:6379/1" in content assert "CACHE_URL=redis://127.0.0.1:6379/1" in content
assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content assert "TRUSTED_PROXY_CIDRS=172.18.0.0/16" in content
Generated
+145 -29
View File
@@ -141,6 +141,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" },
] ]
[[package]]
name = "charset-normalizer"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
{ url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
{ url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
{ url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
{ url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
{ url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
{ url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
{ url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
{ url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
{ url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" },
{ url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" },
{ url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" },
{ url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" },
{ url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" },
{ url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" },
{ url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" },
{ url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" },
{ url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" },
{ url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" },
{ url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" },
{ url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" },
{ url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" },
{ url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" },
{ url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
{ url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
{ url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
{ url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
{ url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
{ url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
{ url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
{ url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
{ url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
{ url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
{ url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.4.2" version = "8.4.2"
@@ -240,39 +302,39 @@ wheels = [
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "49.0.0" version = "50.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
] ]
[[package]] [[package]]
@@ -469,6 +531,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" },
] ]
[[package]]
name = "msal"
version = "1.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.2" version = "26.2"
@@ -595,6 +671,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pyjwt"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
@@ -686,6 +776,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" },
] ]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.12.7" version = "0.12.7"
@@ -774,6 +879,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" }, { url = "https://files.pythonhosted.org/packages/9e/a4/017a7a6cbe387d961a688ec31364ae60a5c4e22c96ae9921b79a947c855d/tzlocal-5.4.4-py3-none-any.whl", hash = "sha256:aae09f0126a8a86fa736be266eb4a471380d26a0de3bc14844e7821fee3e2a15", size = 18115, upload-time = "2026-06-29T08:03:38.666Z" },
] ]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]] [[package]]
name = "vacatureradar" name = "vacatureradar"
version = "0.3.14" version = "0.3.14"
@@ -790,6 +904,7 @@ dependencies = [
{ name = "gunicorn" }, { name = "gunicorn" },
{ name = "httpx" }, { name = "httpx" },
{ name = "lxml" }, { name = "lxml" },
{ name = "msal" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary"] },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "pyyaml" }, { name = "pyyaml" },
@@ -821,13 +936,14 @@ requires-dist = [
{ name = "bleach", specifier = "==6.4.0" }, { name = "bleach", specifier = "==6.4.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" }, { name = "celery", extras = ["redis"], specifier = "==5.6.3" },
{ name = "certifi", specifier = "==2026.6.17" }, { name = "certifi", specifier = "==2026.6.17" },
{ name = "cryptography", specifier = "==49.0.0" }, { name = "cryptography", specifier = "==50.0.0" },
{ name = "defusedxml", specifier = "==0.7.1" }, { name = "defusedxml", specifier = "==0.7.1" },
{ name = "django", specifier = "==5.2.16" }, { name = "django", specifier = "==5.2.16" },
{ name = "feedparser", specifier = "==6.0.12" }, { name = "feedparser", specifier = "==6.0.12" },
{ name = "gunicorn", specifier = "==26.0.0" }, { name = "gunicorn", specifier = "==26.0.0" },
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
{ name = "lxml", specifier = "==6.1.1" }, { name = "lxml", specifier = "==6.1.1" },
{ name = "msal", specifier = "==1.37.0" },
{ name = "playwright", marker = "extra == 'browser'", specifier = "==1.54.0" }, { name = "playwright", marker = "extra == 'browser'", specifier = "==1.54.0" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" }, { name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },