Compare commits
31 Commits
522b1e4105
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cbaff5614a | |||
| f9feb51d33 | |||
| eb2687829f | |||
| ea101d1695 | |||
| 722f93b41c | |||
| e2e5e12f46 | |||
| 4e60168837 | |||
| dae3b59af9 | |||
| 839399363e | |||
| fa8a065138 | |||
| cd0c0ab416 | |||
| 854b1ac713 | |||
| 25fd13ce69 | |||
| 0fec805db1 | |||
| 3746062819 | |||
| 958c165721 | |||
| 6e835c83eb | |||
| 75924fca84 | |||
| ac9ffddbde | |||
| 4e5a4c645d | |||
| e9678b6736 | |||
| e5e346b26a | |||
| b0e623d68e | |||
| 30dbc13fae | |||
| 31b9a1058a | |||
| bcd993d57c | |||
| 1262a4a63f | |||
| fb6816de00 | |||
| 4d0e1a9989 | |||
| ed8e05147c | |||
| edf86a5447 |
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
Dockerfile text eol=lf
|
||||||
232
.gitea/workflows/ci-build.yml
Normal file
232
.gitea/workflows/ci-build.yml
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
name: CI - Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
outputs:
|
||||||
|
allow_push: ${{ steps.meta.outputs.allow_push }}
|
||||||
|
ref_name: ${{ steps.meta.outputs.ref_name }}
|
||||||
|
event_name: ${{ steps.meta.outputs.event_name }}
|
||||||
|
sha: ${{ steps.meta.outputs.sha }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DEFAULT_BRANCH: main
|
||||||
|
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
REGISTRY_CONTAINER_NAME: calminer
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Collect workflow metadata
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEFAULT_BRANCH: ${{ env.DEFAULT_BRANCH }}
|
||||||
|
run: |
|
||||||
|
git_ref="${GITEA_REF:-${GITHUB_REF:-}}"
|
||||||
|
ref_name="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
|
||||||
|
if [ -z "$ref_name" ] && [ -n "$git_ref" ]; then
|
||||||
|
ref_name="${git_ref##*/}"
|
||||||
|
fi
|
||||||
|
event_name="${GITEA_EVENT_NAME:-${GITHUB_EVENT_NAME:-}}"
|
||||||
|
sha="${GITEA_SHA:-${GITHUB_SHA:-}}"
|
||||||
|
if [ -z "$sha" ]; then
|
||||||
|
sha="$(git rev-parse HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ] && [ "$event_name" != "pull_request" ]; then
|
||||||
|
echo "allow_push=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "allow_push=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ref_name=$ref_name" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "event_name=$event_name" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Validate registry configuration
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${REGISTRY_URL}" ]; then
|
||||||
|
echo "::error::REGISTRY_URL secret not configured. Configure it with your Gitea container registry host." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
server_url="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}"
|
||||||
|
server_host="${server_url#http://}"
|
||||||
|
server_host="${server_host#https://}"
|
||||||
|
server_host="${server_host%%/*}"
|
||||||
|
server_host="${server_host%%:*}"
|
||||||
|
registry_host="${REGISTRY_URL#http://}"
|
||||||
|
registry_host="${registry_host#https://}"
|
||||||
|
registry_host="${registry_host%%/*}"
|
||||||
|
registry_host="${registry_host%%:*}"
|
||||||
|
if [ -n "${server_host}" ] && ! printf '%s' "${registry_host}" | grep -qi "${server_host}"; then
|
||||||
|
echo "::warning::REGISTRY_URL (${REGISTRY_URL}) does not match current Gitea host (${server_host}). Ensure this registry endpoint is managed by Gitea." >&2
|
||||||
|
fi
|
||||||
|
registry_repository="${registry_host}/allucanget/${REGISTRY_CONTAINER_NAME}"
|
||||||
|
echo "REGISTRY_HOST=${registry_host}" >> "$GITHUB_ENV"
|
||||||
|
echo "REGISTRY_REPOSITORY=${registry_repository}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Set up QEMU and Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to gitea registry
|
||||||
|
if: ${{ steps.meta.outputs.allow_push == 'true' }}
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY_HOST }}
|
||||||
|
username: ${{ env.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ env.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
id: build-image
|
||||||
|
env:
|
||||||
|
REGISTRY_REPOSITORY: ${{ env.REGISTRY_REPOSITORY }}
|
||||||
|
REGISTRY_CONTAINER_NAME: ${{ env.REGISTRY_CONTAINER_NAME }}
|
||||||
|
SHA_TAG: ${{ steps.meta.outputs.sha }}
|
||||||
|
PUSH_IMAGE: ${{ steps.meta.outputs.allow_push == 'true' && env.REGISTRY_HOST != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||||
|
run: |
|
||||||
|
set -eo pipefail
|
||||||
|
LOG_FILE=build.log
|
||||||
|
if [ "${PUSH_IMAGE}" = "true" ]; then
|
||||||
|
docker buildx build \
|
||||||
|
--load \
|
||||||
|
--tag "${REGISTRY_REPOSITORY}:latest" \
|
||||||
|
--tag "${REGISTRY_REPOSITORY}:${SHA_TAG}" \
|
||||||
|
--file Dockerfile \
|
||||||
|
. 2>&1 | tee "${LOG_FILE}"
|
||||||
|
else
|
||||||
|
docker buildx build \
|
||||||
|
--load \
|
||||||
|
--tag "${REGISTRY_CONTAINER_NAME}:ci" \
|
||||||
|
--file Dockerfile \
|
||||||
|
. 2>&1 | tee "${LOG_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
if: ${{ steps.meta.outputs.allow_push == 'true' }}
|
||||||
|
env:
|
||||||
|
REGISTRY_REPOSITORY: ${{ env.REGISTRY_REPOSITORY }}
|
||||||
|
SHA_TAG: ${{ steps.meta.outputs.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${REGISTRY_REPOSITORY}" ]; then
|
||||||
|
echo "::error::REGISTRY_REPOSITORY not defined; cannot push image" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
docker push "${REGISTRY_REPOSITORY}:${SHA_TAG}"
|
||||||
|
docker push "${REGISTRY_REPOSITORY}:latest"
|
||||||
|
|
||||||
|
- name: Upload docker build logs
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-build-logs
|
||||||
|
path: build.log
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
if: needs.build.outputs.allow_push == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||||
|
REGISTRY_CONTAINER_NAME: calminer
|
||||||
|
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
||||||
|
STAGING_KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}
|
||||||
|
PROD_KUBE_CONFIG: ${{ secrets.PROD_KUBE_CONFIG }}
|
||||||
|
K8S_DEPLOY_ENABLED: ${{ secrets.K8S_DEPLOY_ENABLED }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Resolve registry repository
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${REGISTRY_URL}" ]; then
|
||||||
|
echo "::error::REGISTRY_URL secret not configured. Configure it with your Gitea container registry host." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
registry_host="${REGISTRY_URL#http://}"
|
||||||
|
registry_host="${registry_host#https://}"
|
||||||
|
registry_host="${registry_host%%/*}"
|
||||||
|
registry_host="${registry_host%%:*}"
|
||||||
|
registry_repository="${registry_host}/allucanget/${REGISTRY_CONTAINER_NAME}"
|
||||||
|
echo "REGISTRY_HOST=${registry_host}" >> "$GITHUB_ENV"
|
||||||
|
echo "REGISTRY_REPOSITORY=${registry_repository}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Report Kubernetes deployment toggle
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
enabled="${K8S_DEPLOY_ENABLED:-}"
|
||||||
|
if [ "${enabled}" = "true" ]; then
|
||||||
|
echo "Kubernetes deployment is enabled for this run."
|
||||||
|
else
|
||||||
|
echo "::notice::Kubernetes deployment steps are disabled (set secrets.K8S_DEPLOY_ENABLED to 'true' to enable)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Capture commit metadata
|
||||||
|
id: commit_meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
message="$(git log -1 --pretty=%B | tr '\n' ' ')"
|
||||||
|
echo "message=$message" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up kubectl for staging
|
||||||
|
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy staging]')
|
||||||
|
uses: azure/k8s-set-context@v3
|
||||||
|
with:
|
||||||
|
method: kubeconfig
|
||||||
|
kubeconfig: ${{ env.STAGING_KUBE_CONFIG }}
|
||||||
|
|
||||||
|
- name: Set up kubectl for production
|
||||||
|
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy production]')
|
||||||
|
uses: azure/k8s-set-context@v3
|
||||||
|
with:
|
||||||
|
method: kubeconfig
|
||||||
|
kubeconfig: ${{ env.PROD_KUBE_CONFIG }}
|
||||||
|
|
||||||
|
- name: Deploy to staging
|
||||||
|
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy staging]')
|
||||||
|
run: |
|
||||||
|
kubectl set image deployment/calminer-app calminer=${REGISTRY_REPOSITORY}:latest
|
||||||
|
kubectl apply -f k8s/configmap.yaml
|
||||||
|
kubectl apply -f k8s/secret.yaml
|
||||||
|
kubectl rollout status deployment/calminer-app
|
||||||
|
|
||||||
|
- name: Collect staging deployment logs
|
||||||
|
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy staging]')
|
||||||
|
run: |
|
||||||
|
mkdir -p logs/deployment/staging
|
||||||
|
kubectl get pods -o wide > logs/deployment/staging/pods.txt
|
||||||
|
kubectl get deployment calminer-app -o yaml > logs/deployment/staging/deployment.yaml
|
||||||
|
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/staging/calminer-app.log
|
||||||
|
|
||||||
|
- name: Deploy to production
|
||||||
|
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy production]')
|
||||||
|
run: |
|
||||||
|
kubectl set image deployment/calminer-app calminer=${REGISTRY_REPOSITORY}:latest
|
||||||
|
kubectl apply -f k8s/configmap.yaml
|
||||||
|
kubectl apply -f k8s/secret.yaml
|
||||||
|
kubectl rollout status deployment/calminer-app
|
||||||
|
|
||||||
|
- name: Collect production deployment logs
|
||||||
|
if: env.K8S_DEPLOY_ENABLED == 'true' && contains(steps.commit_meta.outputs.message, '[deploy production]')
|
||||||
|
run: |
|
||||||
|
mkdir -p logs/deployment/production
|
||||||
|
kubectl get pods -o wide > logs/deployment/production/pods.txt
|
||||||
|
kubectl get deployment calminer-app -o yaml > logs/deployment/production/deployment.yaml
|
||||||
|
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/production/calminer-app.log
|
||||||
|
|
||||||
|
- name: Upload deployment logs
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: deployment-logs
|
||||||
|
path: logs/deployment
|
||||||
|
if-no-files-found: ignore
|
||||||
44
.gitea/workflows/ci-lint.yml
Normal file
44
.gitea/workflows/ci-lint.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI - Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
APT_CACHER_NG: http://192.168.88.14:3142
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Configure apt proxy
|
||||||
|
run: |
|
||||||
|
if [ -n "${APT_CACHER_NG}" ]; then
|
||||||
|
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install system packages
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y build-essential libpq-dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-test.txt
|
||||||
|
|
||||||
|
- name: Run Ruff
|
||||||
|
run: ruff check .
|
||||||
|
|
||||||
|
- name: Run Black
|
||||||
|
run: black --check .
|
||||||
|
|
||||||
|
- name: Run Bandit
|
||||||
|
run: bandit -c pyproject.toml -r tests
|
||||||
73
.gitea/workflows/ci-test.yml
Normal file
73
.gitea/workflows/ci-test.yml
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
name: CI - Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
APT_CACHER_NG: http://192.168.88.14:3142
|
||||||
|
DB_DRIVER: postgresql+psycopg2
|
||||||
|
DB_HOST: 192.168.88.35
|
||||||
|
DB_NAME: calminer_test
|
||||||
|
DB_USER: calminer
|
||||||
|
DB_PASSWORD: calminer_password
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: ${{ env.DB_USER }}
|
||||||
|
POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}
|
||||||
|
POSTGRES_DB: ${{ env.DB_NAME }}
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Configure apt proxy
|
||||||
|
run: |
|
||||||
|
if [ -n "${APT_CACHER_NG}" ]; then
|
||||||
|
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install system packages
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y build-essential libpq-dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-test.txt
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
DATABASE_DRIVER: ${{ env.DB_DRIVER }}
|
||||||
|
DATABASE_HOST: postgres
|
||||||
|
DATABASE_PORT: 5432
|
||||||
|
DATABASE_USER: ${{ env.DB_USER }}
|
||||||
|
DATABASE_PASSWORD: ${{ env.DB_PASSWORD }}
|
||||||
|
DATABASE_NAME: ${{ env.DB_NAME }}
|
||||||
|
run: |
|
||||||
|
pytest --cov=. --cov-report=term-missing --cov-report=xml --cov-fail-under=80 --junitxml=pytest-report.xml
|
||||||
|
|
||||||
|
- name: Upload test artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: test-artifacts
|
||||||
|
path: |
|
||||||
|
coverage.xml
|
||||||
|
pytest-report.xml
|
||||||
30
.gitea/workflows/ci.yml
Normal file
30
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- v2
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
uses: ./.gitea/workflows/ci-lint.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
test:
|
||||||
|
needs: lint
|
||||||
|
uses: ./.gitea/workflows/ci-test.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs:
|
||||||
|
- lint
|
||||||
|
- test
|
||||||
|
uses: ./.gitea/workflows/ci-build.yml
|
||||||
|
secrets: inherit
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop, v2]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
APT_CACHER_NG: http://192.168.88.14:3142
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
# - name: Cache pip dependencies
|
|
||||||
# uses: actions/cache@v4
|
|
||||||
# with:
|
|
||||||
# path: /root/.cache/pip
|
|
||||||
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# ${{ runner.os }}-pip-
|
|
||||||
|
|
||||||
- name: Configure apt proxy
|
|
||||||
run: |
|
|
||||||
if [ -n \"${APT_CACHER_NG}\" ]; then
|
|
||||||
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install system packages
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y build-essential libpq-dev
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
- name: Run Ruff
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Run Black
|
|
||||||
run: black --check .
|
|
||||||
|
|
||||||
- name: Run bandit
|
|
||||||
run: bandit -c pyproject.toml -r tests
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: lint
|
|
||||||
env:
|
|
||||||
APT_CACHER_NG: http://192.168.88.14:3142
|
|
||||||
DB_DRIVER: postgresql+psycopg2
|
|
||||||
DB_HOST: 192.168.88.35
|
|
||||||
DB_NAME: calminer_test
|
|
||||||
DB_USER: calminer
|
|
||||||
DB_PASSWORD: calminer_password
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: ${{ env.DB_USER }}
|
|
||||||
POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}
|
|
||||||
POSTGRES_DB: ${{ env.DB_NAME }}
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Get pip cache dir
|
|
||||||
id: pip-cache
|
|
||||||
run: |
|
|
||||||
echo \"path=$(pip cache dir)\" >> $GITEA_OUTPUT
|
|
||||||
echo \"Pip cache dir: $(pip cache dir)\"
|
|
||||||
|
|
||||||
# - name: Cache pip dependencies
|
|
||||||
# uses: actions/cache@v4
|
|
||||||
# with:
|
|
||||||
# path: /root/.cache/pip
|
|
||||||
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# ${{ runner.os }}-pip-
|
|
||||||
|
|
||||||
- name: Configure apt proxy
|
|
||||||
run: |
|
|
||||||
if [ -n \"${APT_CACHER_NG}\" ]; then
|
|
||||||
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install system packages
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y build-essential libpq-dev
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
DATABASE_DRIVER: ${{ env.DB_DRIVER }}
|
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USER: ${{ env.DB_USER }}
|
|
||||||
DATABASE_PASSWORD: ${{ env.DB_PASSWORD }}
|
|
||||||
DATABASE_NAME: ${{ env.DB_NAME }}
|
|
||||||
run: |
|
|
||||||
pytest --cov=. --cov-report=term-missing --cov-report=xml --junitxml=pytest-report.xml
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-artifacts
|
|
||||||
path: |
|
|
||||||
coverage.xml
|
|
||||||
pytest-report.xml
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- lint
|
|
||||||
- test
|
|
||||||
env:
|
|
||||||
DEFAULT_BRANCH: main
|
|
||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
REGISTRY_CONTAINER_NAME: calminer
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Collect workflow metadata
|
|
||||||
id: meta
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
ref_name=\"${GITHUB_REF_NAME:-${GITHUB_REF##*/}}\"
|
|
||||||
event_name=\"${GITHUB_EVENT_NAME:-}\"
|
|
||||||
sha=\"${GITHUB_SHA:-}\"
|
|
||||||
|
|
||||||
if [ \"$ref_name\" = \"${DEFAULT_BRANCH:-main}\" ]; then
|
|
||||||
echo \"on_default=true\" >> \"$GITHUB_OUTPUT\"
|
|
||||||
else
|
|
||||||
echo \"on_default=false\" >> \"$GITHUB_OUTPUT\"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo \"ref_name=$ref_name\" >> \"$GITHUB_OUTPUT\"
|
|
||||||
echo \"event_name=$event_name\" >> \"$GITHUB_OUTPUT\"
|
|
||||||
echo \"sha=$sha\" >> \"$GITHUB_OUTPUT\"
|
|
||||||
|
|
||||||
- name: Set up QEMU and Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to gitea registry
|
|
||||||
if: ${{ steps.meta.outputs.on_default == 'true' }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY_URL }}
|
|
||||||
username: ${{ env.REGISTRY_USERNAME }}
|
|
||||||
password: ${{ env.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
id: build-image
|
|
||||||
env:
|
|
||||||
REGISTRY_URL: ${{ env.REGISTRY_URL }}
|
|
||||||
REGISTRY_CONTAINER_NAME: ${{ env.REGISTRY_CONTAINER_NAME }}
|
|
||||||
SHA_TAG: ${{ steps.meta.outputs.sha }}
|
|
||||||
PUSH_IMAGE: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
|
||||||
run: |
|
|
||||||
set -eo pipefail
|
|
||||||
LOG_FILE=build.log
|
|
||||||
if [ \"${PUSH_IMAGE}\" = \"true\" ]; then
|
|
||||||
docker buildx build \
|
|
||||||
--push \
|
|
||||||
--tag \"${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest\" \
|
|
||||||
--tag \"${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:${SHA_TAG}\" \
|
|
||||||
--file Dockerfile \
|
|
||||||
. 2>&1 | tee \"${LOG_FILE}\"
|
|
||||||
else
|
|
||||||
docker buildx build \
|
|
||||||
--load \
|
|
||||||
--tag \"${REGISTRY_CONTAINER_NAME}:ci\" \
|
|
||||||
--file Dockerfile \
|
|
||||||
. 2>&1 | tee \"${LOG_FILE}\"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload docker build logs
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: docker-build-logs
|
|
||||||
path: build.log
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop, v2]
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
APT_CACHER_NG: http://192.168.88.14:3142
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
# - name: Get pip cache dir
|
|
||||||
# id: pip-cache
|
|
||||||
# run: |
|
|
||||||
# echo "path=$(pip cache dir)" >> $GITEA_OUTPUT
|
|
||||||
# echo "Pip cache dir: $(pip cache dir)"
|
|
||||||
|
|
||||||
# - name: Cache pip dependencies
|
|
||||||
# uses: actions/cache@v4
|
|
||||||
# with:
|
|
||||||
# path: ${{ steps.pip-cache.outputs.path }}
|
|
||||||
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# ${{ runner.os }}-pip-
|
|
||||||
|
|
||||||
- name: Configure apt proxy
|
|
||||||
run: |
|
|
||||||
if [ -n "${APT_CACHER_NG}" ]; then
|
|
||||||
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install system packages
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y build-essential libpq-dev
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
- name: Run Ruff
|
|
||||||
run: ruff check .
|
|
||||||
|
|
||||||
- name: Run Black
|
|
||||||
run: black --check .
|
|
||||||
|
|
||||||
- name: Run Bandit
|
|
||||||
run: bandit -c pyproject.toml -r tests
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: lint
|
|
||||||
env:
|
|
||||||
APT_CACHER_NG: http://192.168.88.14:3142
|
|
||||||
DB_DRIVER: postgresql+psycopg2
|
|
||||||
DB_HOST: 192.168.88.35
|
|
||||||
DB_NAME: calminer_test
|
|
||||||
DB_USER: calminer
|
|
||||||
DB_PASSWORD: calminer_password
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: ${{ env.DB_USER }}
|
|
||||||
POSTGRES_PASSWORD: ${{ env.DB_PASSWORD }}
|
|
||||||
POSTGRES_DB: ${{ env.DB_NAME }}
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
# - name: Get pip cache dir
|
|
||||||
# id: pip-cache
|
|
||||||
# run: |
|
|
||||||
# echo "path=$(pip cache dir)" >> $GITEA_OUTPUT
|
|
||||||
# echo "Pip cache dir: $(pip cache dir)"
|
|
||||||
|
|
||||||
# - name: Cache pip dependencies
|
|
||||||
# uses: actions/cache@v4
|
|
||||||
# with:
|
|
||||||
# path: ${{ steps.pip-cache.outputs.path }}
|
|
||||||
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt', 'pyproject.toml') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# ${{ runner.os }}-pip-
|
|
||||||
|
|
||||||
- name: Configure apt proxy
|
|
||||||
run: |
|
|
||||||
if [ -n "${APT_CACHER_NG}" ]; then
|
|
||||||
echo "Acquire::http::Proxy \"${APT_CACHER_NG}\";" | tee /etc/apt/apt.conf.d/01apt-cacher-ng
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install system packages
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y build-essential libpq-dev
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
|
|
||||||
- name: Install Playwright system dependencies
|
|
||||||
run: playwright install-deps
|
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: playwright install
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
DATABASE_DRIVER: ${{ env.DB_DRIVER }}
|
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USER: ${{ env.DB_USER }}
|
|
||||||
DATABASE_PASSWORD: ${{ env.DB_PASSWORD }}
|
|
||||||
DATABASE_NAME: ${{ env.DB_NAME }}
|
|
||||||
run: |
|
|
||||||
pytest --cov=. --cov-report=term-missing --cov-report=xml --cov-fail-under=80 --junitxml=pytest-report.xml
|
|
||||||
|
|
||||||
- name: Upload test artifacts
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: test-artifacts
|
|
||||||
path: |
|
|
||||||
coverage.xml
|
|
||||||
pytest-report.xml
|
|
||||||
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- lint
|
|
||||||
- test
|
|
||||||
env:
|
|
||||||
DEFAULT_BRANCH: main
|
|
||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
REGISTRY_CONTAINER_NAME: calminer
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Collect workflow metadata
|
|
||||||
id: meta
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
ref_name="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
|
||||||
event_name="${GITHUB_EVENT_NAME:-}"
|
|
||||||
sha="${GITHUB_SHA:-}"
|
|
||||||
|
|
||||||
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ]; then
|
|
||||||
echo "on_default=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "on_default=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "ref_name=$ref_name" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "event_name=$event_name" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Set up QEMU and Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to gitea registry
|
|
||||||
if: ${{ steps.meta.outputs.on_default == 'true' }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
continue-on-error: true
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY_URL }}
|
|
||||||
username: ${{ env.REGISTRY_USERNAME }}
|
|
||||||
password: ${{ env.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
id: build-image
|
|
||||||
env:
|
|
||||||
REGISTRY_URL: ${{ env.REGISTRY_URL }}
|
|
||||||
REGISTRY_CONTAINER_NAME: ${{ env.REGISTRY_CONTAINER_NAME }}
|
|
||||||
SHA_TAG: ${{ steps.meta.outputs.sha }}
|
|
||||||
PUSH_IMAGE: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
|
||||||
run: |
|
|
||||||
set -eo pipefail
|
|
||||||
LOG_FILE=build.log
|
|
||||||
if [ "${PUSH_IMAGE}" = "true" ]; then
|
|
||||||
docker buildx build \
|
|
||||||
--push \
|
|
||||||
--tag "${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest" \
|
|
||||||
--tag "${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:${SHA_TAG}" \
|
|
||||||
--file Dockerfile \
|
|
||||||
. 2>&1 | tee "${LOG_FILE}"
|
|
||||||
else
|
|
||||||
docker buildx build \
|
|
||||||
--load \
|
|
||||||
--tag "${REGISTRY_CONTAINER_NAME}:ci" \
|
|
||||||
--file Dockerfile \
|
|
||||||
. 2>&1 | tee "${LOG_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload docker build logs
|
|
||||||
if: failure()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: docker-build-logs
|
|
||||||
path: build.log
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
|
||||||
env:
|
|
||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
|
||||||
REGISTRY_CONTAINER_NAME: calminer
|
|
||||||
KUBE_CONFIG: ${{ secrets.KUBE_CONFIG }}
|
|
||||||
STAGING_KUBE_CONFIG: ${{ secrets.STAGING_KUBE_CONFIG }}
|
|
||||||
PROD_KUBE_CONFIG: ${{ secrets.PROD_KUBE_CONFIG }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up kubectl for staging
|
|
||||||
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy staging]')
|
|
||||||
uses: azure/k8s-set-context@v3
|
|
||||||
with:
|
|
||||||
method: kubeconfig
|
|
||||||
kubeconfig: ${{ env.STAGING_KUBE_CONFIG }}
|
|
||||||
|
|
||||||
- name: Set up kubectl for production
|
|
||||||
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy production]')
|
|
||||||
uses: azure/k8s-set-context@v3
|
|
||||||
with:
|
|
||||||
method: kubeconfig
|
|
||||||
kubeconfig: ${{ env.PROD_KUBE_CONFIG }}
|
|
||||||
|
|
||||||
- name: Deploy to staging
|
|
||||||
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy staging]')
|
|
||||||
run: |
|
|
||||||
# Update image in deployment
|
|
||||||
kubectl set image deployment/calminer-app calminer=${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest
|
|
||||||
# Apply any config changes
|
|
||||||
kubectl apply -f k8s/configmap.yaml
|
|
||||||
kubectl apply -f k8s/secret.yaml
|
|
||||||
# Wait for rollout
|
|
||||||
kubectl rollout status deployment/calminer-app
|
|
||||||
|
|
||||||
- name: Collect staging deployment logs
|
|
||||||
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy staging]')
|
|
||||||
run: |
|
|
||||||
mkdir -p logs/deployment/staging
|
|
||||||
kubectl get pods -o wide > logs/deployment/staging/pods.txt
|
|
||||||
kubectl get deployment calminer-app -o yaml > logs/deployment/staging/deployment.yaml
|
|
||||||
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/staging/calminer-app.log
|
|
||||||
|
|
||||||
- name: Deploy to production
|
|
||||||
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy production]')
|
|
||||||
run: |
|
|
||||||
# Update image in deployment
|
|
||||||
kubectl set image deployment/calminer-app calminer=${REGISTRY_URL}/allucanget/${REGISTRY_CONTAINER_NAME}:latest
|
|
||||||
# Apply any config changes
|
|
||||||
kubectl apply -f k8s/configmap.yaml
|
|
||||||
kubectl apply -f k8s/secret.yaml
|
|
||||||
# Wait for rollout
|
|
||||||
kubectl rollout status deployment/calminer-app
|
|
||||||
|
|
||||||
- name: Collect production deployment logs
|
|
||||||
if: github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, '[deploy production]')
|
|
||||||
run: |
|
|
||||||
mkdir -p logs/deployment/production
|
|
||||||
kubectl get pods -o wide > logs/deployment/production/pods.txt
|
|
||||||
kubectl get deployment calminer-app -o yaml > logs/deployment/production/deployment.yaml
|
|
||||||
kubectl logs deployment/calminer-app --all-containers=true --tail=500 > logs/deployment/production/calminer-app.log
|
|
||||||
|
|
||||||
- name: Upload deployment logs
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: deployment-logs
|
|
||||||
path: logs/deployment
|
|
||||||
if-no-files-found: ignore
|
|
||||||
78
.gitea/workflows/deploy-coolify.yml
Normal file
78
.gitea/workflows/deploy-coolify.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
name: Deploy - Coolify
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
COOLIFY_BASE_URL: ${{ secrets.COOLIFY_BASE_URL }}
|
||||||
|
COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
|
||||||
|
COOLIFY_APPLICATION_ID: ${{ secrets.COOLIFY_APPLICATION_ID }}
|
||||||
|
COOLIFY_DEPLOY_ENV: ${{ secrets.COOLIFY_DEPLOY_ENV }}
|
||||||
|
DOCKER_COMPOSE_PATH: docker-compose.prod.yml
|
||||||
|
ENV_FILE_PATH: deploy/.env
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Prepare compose bundle
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p deploy
|
||||||
|
cp "$DOCKER_COMPOSE_PATH" deploy/docker-compose.yml
|
||||||
|
if [ -n "$COOLIFY_DEPLOY_ENV" ]; then
|
||||||
|
printf '%s\n' "$COOLIFY_DEPLOY_ENV" > "$ENV_FILE_PATH"
|
||||||
|
elif [ ! -f "$ENV_FILE_PATH" ]; then
|
||||||
|
echo "::error::COOLIFY_DEPLOY_ENV secret not configured and deploy/.env missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Validate Coolify secrets
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
missing=0
|
||||||
|
for var in COOLIFY_BASE_URL COOLIFY_API_TOKEN COOLIFY_APPLICATION_ID; do
|
||||||
|
if [ -z "${!var}" ]; then
|
||||||
|
echo "::error::Missing required secret: $var"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$missing" -eq 1 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Trigger deployment via Coolify API
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
api_url="$COOLIFY_BASE_URL/api/v1/deploy"
|
||||||
|
payload=$(jq -n --arg uuid "$COOLIFY_APPLICATION_ID" '{ uuid: $uuid }')
|
||||||
|
response=$(curl -sS -w '\n%{http_code}' \
|
||||||
|
-X POST "$api_url" \
|
||||||
|
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
status=$(echo "$response" | tail -n1)
|
||||||
|
echo "Deploy response status: $status"
|
||||||
|
echo "$body"
|
||||||
|
printf '%s' "$body" > deploy/coolify-response.json
|
||||||
|
if [ "$status" -ge 400 ]; then
|
||||||
|
echo "::error::Deployment request failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload deployment bundle
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: coolify-deploy-bundle
|
||||||
|
path: |
|
||||||
|
deploy/docker-compose.yml
|
||||||
|
deploy/.env
|
||||||
|
deploy/coolify-response.json
|
||||||
|
if-no-files-found: warn
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -54,3 +54,7 @@ local*.db
|
|||||||
|
|
||||||
# Act runner files
|
# Act runner files
|
||||||
.runner
|
.runner
|
||||||
|
|
||||||
|
# Devcontainer files
|
||||||
|
.devcontainer/devcontainer.json
|
||||||
|
.devcontainer/docker-compose.yml
|
||||||
|
|||||||
42
Dockerfile
42
Dockerfile
@@ -41,8 +41,25 @@ if url:
|
|||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
PY
|
PY
|
||||||
apt-get update
|
APT_PROXY_CONFIG=/etc/apt/apt.conf.d/01proxy
|
||||||
apt-get install -y --no-install-recommends build-essential gcc libpq-dev
|
|
||||||
|
apt_update_with_fallback() {
|
||||||
|
if ! apt-get update; then
|
||||||
|
rm -f "$APT_PROXY_CONFIG"
|
||||||
|
apt-get update
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_install_with_fallback() {
|
||||||
|
if ! apt-get install -y --no-install-recommends "$@"; then
|
||||||
|
rm -f "$APT_PROXY_CONFIG"
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_update_with_fallback
|
||||||
|
apt_install_with_fallback build-essential gcc libpq-dev
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip wheel --no-deps --wheel-dir /wheels -r requirements.txt
|
pip wheel --no-deps --wheel-dir /wheels -r requirements.txt
|
||||||
apt-get purge -y --auto-remove build-essential gcc
|
apt-get purge -y --auto-remove build-essential gcc
|
||||||
@@ -88,8 +105,25 @@ if url:
|
|||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
PY
|
PY
|
||||||
apt-get update
|
APT_PROXY_CONFIG=/etc/apt/apt.conf.d/01proxy
|
||||||
apt-get install -y --no-install-recommends libpq5
|
|
||||||
|
apt_update_with_fallback() {
|
||||||
|
if ! apt-get update; then
|
||||||
|
rm -f "$APT_PROXY_CONFIG"
|
||||||
|
apt-get update
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_install_with_fallback() {
|
||||||
|
if ! apt-get install -y --no-install-recommends "$@"; then
|
||||||
|
rm -f "$APT_PROXY_CONFIG"
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_update_with_fallback
|
||||||
|
apt_install_with_fallback libpq5
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
200
changelog.md
200
changelog.md
@@ -1,108 +1,124 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-11-15
|
||||||
|
|
||||||
|
- Fixed dev container setup by reviewing logs, identifying mount errors, implementing fixes, and validating the configuration.
|
||||||
|
|
||||||
|
## 2025-11-14
|
||||||
|
|
||||||
|
- Completed Coolify deployment automation with workflow and documentation.
|
||||||
|
- Improved build workflow for registry authentication and tagging.
|
||||||
|
- Updated production compose and added deployment guidance.
|
||||||
|
- Added optional Kubernetes deployment toggle.
|
||||||
|
|
||||||
## 2025-11-13
|
## 2025-11-13
|
||||||
|
|
||||||
- Finalised the financial data import/export templates by inventorying required fields, defining CSV column specs with validation rules, drafting Excel workbook layouts, documenting end-user workflows in `calminer-docs/userguide/data_import_export.md`, and recording stakeholder review steps alongside updated TODO/DONE tracking.
|
- Aligned UI styles and ensured accessibility.
|
||||||
- Scoped profitability calculator UI under the scenario hierarchy by adding `/calculations/projects/{project_id}/scenarios/{scenario_id}/profitability` GET/POST handlers, updating scenario templates and sidebar navigation to link to the new route, and extending `tests/test_project_scenario_routes.py` with coverage for the scenario path plus legacy redirect behaviour (module run: 14 passed).
|
- Restructured navigation under project-scenario-calculation hierarchy.
|
||||||
- Extended scenario frontend regression coverage by updating `tests/test_project_scenario_routes.py` to assert project/scenario breadcrumbs and calculator navigation, normalising escaped URLs, and re-running the module tests (13 passing).
|
- Reorganized documentation for better structure.
|
||||||
- Cleared FastAPI and Pydantic deprecation warnings by migrating `scripts/init_db.py` to `@field_validator`, replacing the `main.py` startup hook with a lifespan handler, auditing template response call signatures, confirming HTTP 422 constant usage, and re-running the full pytest suite to ensure a clean warning slate.
|
- Refactored navigation sidebar with database-driven data.
|
||||||
- Delivered the capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`.
|
- Migrated sidebar rendering to API endpoint.
|
||||||
- Updated UI navigation to surface the opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`.
|
- Created templates for data import and export.
|
||||||
- Completed manual validation of the Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up.
|
- Updated relationships for projects, scenarios, and profitability.
|
||||||
- Added opex calculation unit tests in `tests/services/test_calculations_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension.
|
- Enhanced scenario frontend templates with project context.
|
||||||
- Documented the Opex Planner workflow in `calminer-docs/userguide/opex_planner.md`, linked it from the user guide index, extended `calminer-docs/architecture/08_concepts/02_data_model.md` with snapshot coverage, and captured the completion in `.github/instructions/DONE.md`.
|
- Scoped profitability calculator to scenario level.
|
||||||
- Implemented opex integration coverage in `tests/integration/test_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions.
|
- Added navigation links for opex planner.
|
||||||
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the opex documentation updates.
|
- Documented opex planner features.
|
||||||
- Completed the navigation sidebar API migration by finalising the database-backed service, refactoring `templates/partials/sidebar_nav.html` to consume the endpoint, hydrating via `static/js/navigation_sidebar.js`, and updating HTML route dependencies (`routes/projects.py`, `routes/scenarios.py`, `routes/reports.py`, `routes/imports.py`, `routes/calculations.py`) to use redirect-aware guards so anonymous visitors receive login redirects instead of JSON errors (manual verification via curl across projects, scenarios, reports, and calculations pages).
|
- Integrated opex calculations with persistence and tests.
|
||||||
|
- Implemented capex calculations end-to-end.
|
||||||
|
- Added basic profitability calculations.
|
||||||
|
- Developed reporting endpoints and templates.
|
||||||
|
- Integrated charting for visualizations.
|
||||||
|
- Performed manual testing of capex planner.
|
||||||
|
- Added unit tests for opex service.
|
||||||
|
- Added integration tests for opex.
|
||||||
|
|
||||||
## 2025-11-12
|
## 2025-11-12
|
||||||
|
|
||||||
- Fixed critical 500 error in reporting dashboard by correcting route reference in reporting.html template - changed 'reports.project_list_page' to 'projects.project_list_page' to resolve NoMatchFound error when accessing /ui/reporting.
|
- Fixed reporting dashboard error by correcting route reference.
|
||||||
- Completed navigation validation by inventorying all sidebar navigation links, identifying missing routes for simulations, reporting, settings, themes, and currencies, created new UI routes in routes/ui.py with proper authentication guards, built corresponding templates (simulations.html, reporting.html, settings.html, theme_settings.html, currencies.html), registered the UI router in main.py, updated sidebar navigation to use route names instead of hardcoded URLs, and enhanced navigation.js to use dynamic URL resolution for proper route handling.
|
- Completed navigation validation by adding missing routes and templates for various pages.
|
||||||
- Fixed critical template rendering error in sidebar_nav.html where URL objects from `request.url_for()` were being used with string methods, causing TypeError. Added `|string` filters to convert URL objects to strings for proper template rendering.
|
- Fixed template rendering error with URL objects.
|
||||||
- Integrated Plotly charting for interactive visualizations in reporting templates, added chart generation methods to ReportingService (`generate_npv_comparison_chart`, `generate_distribution_histogram`), updated project summary and scenario distribution contexts to include chart JSON data, enhanced templates with chart containers and JavaScript rendering, added chart-container CSS styling, and validated all reporting tests pass.
|
- Integrated charting for interactive visualizations.
|
||||||
|
- Verified local application startup and routes.
|
||||||
- Completed local run verification: started application with `uvicorn main:app --reload` without errors, verified authenticated routes (/login, /, /projects/ui, /projects) load correctly with seeded data, and summarized findings for deployment pipeline readiness.
|
- Fixed docker-compose configuration.
|
||||||
- Fixed docker-compose.override.yml command array to remove duplicate "uvicorn" entry, enabling successful container startup with uvicorn reload in development mode.
|
- Verified deployment pipeline.
|
||||||
- Completed deployment pipeline verification: built Docker image without errors, validated docker-compose configuration, deployed locally with docker-compose (app and postgres containers started successfully), and confirmed application startup logs showing database bootstrap and seeded data initialization.
|
- Documented data models.
|
||||||
- Completed documentation of current data models: updated `calminer-docs/architecture/08_concepts/02_data_model.md` with comprehensive SQLAlchemy model schemas, enumerations, Pydantic API schemas, and analysis of discrepancies between models and schemas.
|
- Updated performance model to clear warnings.
|
||||||
- Switched `models/performance_metric.py` to reuse the shared declarative base from `config.database`, clearing the SQLAlchemy 2.0 `declarative_base` deprecation warning and verifying repository tests still pass.
|
- Replaced migration system with simpler initializer.
|
||||||
- Replaced the Alembic migration workflow with the idempotent Pydantic-backed initializer (`scripts/init_db.py`), added a guarded reset utility (`scripts/reset_db.py`), removed migration artifacts/tooling (Alembic directory, config, Docker entrypoint), refreshed the container entrypoint to invoke `uvicorn` directly, and updated installation/architecture docs plus the README to direct developers to the new seeding/reset flow.
|
- Removed hardcoded secrets from tests.
|
||||||
- Eliminated Bandit hardcoded-secret findings by replacing literal JWT tokens and passwords across auth/security tests with randomized helpers drawn from `tests/utils/security.py`, ensuring fixtures still assert expected behaviours.
|
- Centralized security scanning config.
|
||||||
- Centralized Bandit configuration in `pyproject.toml`, reran `bandit -c pyproject.toml -r calminer tests`, and verified the scan now reports zero issues.
|
- Fixed admin setup with migration.
|
||||||
- Diagnosed admin bootstrap failure caused by legacy `roles` schema, added Alembic migration `20251112_00_add_roles_metadata_columns.py` to backfill `display_name`, `description`, `created_at`, and `updated_at`, and verified the migration via full pytest run in the activated `.venv`.
|
- Resolved code style warnings.
|
||||||
- Resolved Ruff E402 warnings by moving module docstrings ahead of `from __future__ import annotations` across currency and pricing service modules, dropped the unused `HTTPException` import in `monitoring/__init__.py`, and confirmed a clean `ruff check .` run.
|
- Enhanced deploy logging.
|
||||||
- Enhanced the deploy job in `.gitea/workflows/cicache.yml` to capture Kubernetes pod, deployment, and container logs into `/logs/deployment/` for staging/production rollouts and publish them via a `deployment-logs` artifact, updating CI/CD documentation with retrieval instructions.
|
- Fixed CI template issue.
|
||||||
- Fixed CI dashboard template lookup failures by renaming `templates/Dashboard.html` to `templates/dashboard.html` and verifying `tests/test_dashboard_route.py` locally to ensure TemplateNotFound no longer occurs on case-sensitive filesystems.
|
- Added SQLite database support.
|
||||||
- Implemented SQLite support as primary local database with environment-driven backend switching (`CALMINER_USE_SQLITE=true`), updated `scripts/init_db.py` for database-agnostic DDL generation (PostgreSQL enums vs SQLite CHECK constraints), tested compatibility with both backends, and verified application startup and seeded data initialization work seamlessly across SQLite and PostgreSQL.
|
|
||||||
|
|
||||||
## 2025-11-11
|
## 2025-11-11
|
||||||
|
|
||||||
- Collapsed legacy Alembic revisions into `alembic/versions/00_initial.py`, removed superseded migration files, and verified the consolidated schema via SQLite upgrade and Postgres version stamping.
|
- Combined old migration files into one initial schema.
|
||||||
- Implemented base URL routing to redirect unauthenticated users to login and authenticated users to dashboard.
|
- Added base routing to redirect users to login or dashboard.
|
||||||
- Added comprehensive end-to-end tests for login flow, including redirects, session handling, and error messaging for invalid/inactive accounts.
|
- Added end-to-end tests for login flow.
|
||||||
- Updated header and footer templates to consistently use `logo_big.png` image instead of text logo, with appropriate CSS styling for sizing.
|
- Updated templates to use logo image consistently.
|
||||||
- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point.
|
- Centralized currency validation across the app.
|
||||||
- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end.
|
- Updated services to show friendly error messages.
|
||||||
- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill.
|
- Linked projects to pricing settings.
|
||||||
- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency.
|
- Bootstrapped pricing settings at startup.
|
||||||
- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour.
|
- Extended pricing support with persisted data.
|
||||||
- Added `services/financial.py` NPV, IRR, and payback helpers with robust cash-flow normalisation, convergence safeguards, and fractional period support, plus comprehensive pytest coverage exercising representative project scenarios and failure modes.
|
- Added financial helpers for NPV, IRR, payback.
|
||||||
- Authored `calminer-docs/specifications/financial_metrics.md` capturing DCF assumptions, solver behaviours, and worked examples, and cross-linked the architecture concepts to the new reference for consistent navigation.
|
- Documented financial metrics.
|
||||||
- Implemented `services/simulation.py` Monte Carlo engine with configurable distributions, summary aggregation, and reproducible RNG seeding, introduced regression tests in `tests/test_simulation.py`, and documented configuration/usage in `calminer-docs/specifications/monte_carlo_simulation.md` with architecture cross-links.
|
- Implemented Monte Carlo simulation engine.
|
||||||
- Polished reporting HTML contexts by cleaning stray fragments in `routes/reports.py`, adding download action metadata for project and scenario pages, and generating scenario comparison download URLs with correctly serialised repeated `scenario_ids` parameters.
|
- Cleaned up reporting contexts.
|
||||||
- Consolidated Alembic history into a single initial migration (`20251111_00_initial_schema.py`), removed superseded revision files, and ensured Alembic metadata still references the project metadata for clean bootstrap.
|
- Consolidated migration history.
|
||||||
- Added `scripts/run_migrations.py` and a Docker entrypoint wrapper to run Alembic migrations before `uvicorn` starts, removed the fallback `Base.metadata.create_all` call, and updated `calminer-docs/admin/installation.md` so developers know how to apply migrations locally or via Docker.
|
- Added migration script and updated entrypoint.
|
||||||
- Configured pytest defaults to collect coverage (`--cov`) with an 80% fail-under gate, excluded entrypoint/reporting scaffolds from the calculation, updated contributor docs with the standard `pytest` command, and verified the suite now reports 83% coverage.
|
- Configured test coverage.
|
||||||
- Standardized color scheme and typography by moving alert styles to `main.css`, adding typography rules with CSS variables, updating auth templates for consistent button classes, and ensuring all templates use centralized color and spacing variables.
|
- Standardized colors and typography.
|
||||||
- Improved navigation flow by adding two big chevron buttons on top of the navigation sidebar to allow users to navigate to the previous and next page in the page navigation list, including JavaScript logic for determining current page and handling navigation.
|
- Improved navigation with chevron buttons.
|
||||||
- Established pytest-based unit and integration test suites with coverage thresholds, achieving 83% coverage across 181 tests, with configuration in pyproject.toml and documentation in CONTRIBUTING.md.
|
- Established test suites with coverage.
|
||||||
- Configured CI pipelines to run tests, linting, and security checks on each change, adding Bandit security scanning to the workflow and verifying execution on pushes and PRs to main/develop branches.
|
- Configured CI pipelines for tests and security.
|
||||||
- Added deployment automation with Docker Compose for local development and Kubernetes manifests for production, ensuring environment parity and documenting processes in calminer-docs/admin/installation.md.
|
- Added deployment automation with Docker and Kubernetes.
|
||||||
- Completed monitoring instrumentation by adding business metrics observation to project and scenario repository operations, and simulation performance tracking to Monte Carlo service with success/error status and duration metrics.
|
- Completed monitoring instrumentation.
|
||||||
- Updated TODO list to reflect completed monitoring implementation tasks and validated changes with passing simulation tests.
|
- Implemented performance monitoring.
|
||||||
- Implemented comprehensive performance monitoring for scalability (FR-006) with Prometheus metrics collection for HTTP requests, import/export operations, and general application metrics.
|
- Added metric storage and endpoints.
|
||||||
- Added database model for persistent metric storage with aggregation endpoints for KPIs like request latency, error rates, and throughput.
|
- Created middleware for metrics.
|
||||||
- Created FastAPI middleware for automatic request metric collection and background persistence to database.
|
- Extended monitoring router.
|
||||||
- Extended monitoring router with performance metrics API endpoints and detailed health checks.
|
- Added migration for metrics table.
|
||||||
- Added Alembic migration for performance_metrics table and updated model imports.
|
- Completed concurrent testing.
|
||||||
- Completed concurrent interaction testing implementation, validating database transaction isolation under threading and establishing async testing framework for future concurrency enhancements.
|
- Implemented deployment automation.
|
||||||
- Implemented comprehensive deployment automation with Docker Compose configurations for development, staging, and production environments ensuring environment parity.
|
- Set up Kubernetes manifests.
|
||||||
- Set up Kubernetes manifests with resource limits, health checks, and secrets management for production deployment.
|
- Configured CI/CD workflows.
|
||||||
- Configured CI/CD workflows for automated Docker image building, registry pushing, and Kubernetes deployment to staging/production environments.
|
- Documented deployment processes.
|
||||||
- Documented deployment processes, environment configurations, and CI/CD workflows in project documentation.
|
- Validated deployment setup.
|
||||||
- Validated deployment automation through Docker Compose configuration testing and CI/CD pipeline structure.
|
|
||||||
|
|
||||||
## 2025-11-10
|
## 2025-11-10
|
||||||
|
|
||||||
- Added dedicated pytest coverage for guard dependencies, exercising success plus failure paths (missing session, inactive user, missing roles, project/scenario access errors) via `tests/test_dependencies_guards.py`.
|
- Added tests for guard dependencies.
|
||||||
- Added integration tests in `tests/test_authorization_integration.py` verifying anonymous 401 responses, role-based 403s, and authorized project manager flows across API and UI endpoints.
|
- Added integration tests for authorization.
|
||||||
- Implemented environment-driven admin bootstrap settings, wired the `bootstrap_admin` helper into FastAPI startup, added pytest coverage for creation/idempotency/reset logic, and documented operational guidance in the RBAC plan and security concept.
|
- Implemented admin bootstrap settings.
|
||||||
- Retired the legacy authentication RBAC implementation plan document after migrating its guidance into live documentation and synchronized the contributor instructions to reflect the removal.
|
- Retired old RBAC plan document.
|
||||||
- Completed the Authentication & RBAC checklist by shipping the new models, migrations, repositories, guard dependencies, and integration tests.
|
- Completed authentication and RBAC features.
|
||||||
- Documented the project/scenario import/export field mapping and file format guidelines in `calminer-docs/requirements/FR-008.md`, and introduced `schemas/imports.py` with Pydantic models that normalise incoming CSV/Excel rows for projects and scenarios.
|
- Documented import/export field mappings.
|
||||||
- Added `services/importers.py` to load CSV/XLSX files into the new import schemas, pulled in `openpyxl` for Excel support, and covered the parsing behaviour with `tests/test_import_parsing.py`.
|
- Added import service for CSV/Excel.
|
||||||
- Expanded the import ingestion workflow with staging previews, transactional persistence commits, FastAPI preview/commit endpoints under `/imports`, and new API tests (`tests/test_import_ingestion.py`, `tests/test_import_api.py`) ensuring end-to-end coverage.
|
- Expanded import workflow with previews and commits.
|
||||||
- Added persistent audit logging via `ImportExportLog`, structured log emission, Prometheus metrics instrumentation, `/metrics` endpoint exposure, and updated operator/deployment documentation to guide monitoring setup.
|
- Added audit logging for imports/exports.
|
||||||
|
|
||||||
## 2025-11-09
|
## 2025-11-09
|
||||||
|
|
||||||
- Captured current implementation status, requirements coverage, missing features, and prioritized roadmap in `calminer-docs/implementation_status.md` to guide future development.
|
- Captured implementation status and roadmap.
|
||||||
- Added core SQLAlchemy domain models, shared metadata descriptors, and Alembic migration setup (with initial schema snapshot) to establish the persistence layer foundation.
|
- Added core database models and migration setup.
|
||||||
- Introduced repository and unit-of-work helpers for projects, scenarios, financial inputs, and simulation parameters to support service-layer operations.
|
- Introduced repository helpers for data operations.
|
||||||
- Added SQLite-backed pytest coverage for repository and unit-of-work behaviours to validate persistence interactions.
|
- Added tests for repository behaviors.
|
||||||
- Exposed project and scenario CRUD APIs with validated schemas and integrated them into the FastAPI application.
|
- Exposed CRUD APIs for projects and scenarios.
|
||||||
- Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects.
|
- Connected routers to HTML views.
|
||||||
- Implemented FR-009 client-side enhancements with responsive navigation toggle, mobile-first scenario tables, and shared asset loading across templates.
|
- Implemented client-side enhancements.
|
||||||
- Added scenario comparison validator, FastAPI comparison endpoint, and comprehensive unit tests to enforce FR-009 validation rules through API errors.
|
- Added scenario comparison validator.
|
||||||
- Delivered a new dashboard experience with `templates/dashboard.html`, dedicated styling, and a FastAPI route supplying real project/scenario metrics via repository helpers.
|
- Delivered new dashboard experience.
|
||||||
- Extended repositories with count/recency utilities and added pytest coverage, including a dashboard rendering smoke test validating empty-state messaging.
|
- Extended repositories with utilities.
|
||||||
- Brought project and scenario detail pages plus their forms in line with the dashboard visuals, adding metric cards, layout grids, and refreshed CTA styles.
|
- Updated detail pages with new visuals.
|
||||||
- Reordered project route registration to prioritize static UI paths, eliminating 422 errors on `/projects/ui` and `/projects/create`, and added pytest smoke coverage for the navigation endpoints.
|
- Fixed route registration issues.
|
||||||
- Added end-to-end integration tests for project and scenario lifecycles, validating HTML redirects, template rendering, and API interactions, and updated `ProjectRepository.get` to deduplicate joined loads for detail views.
|
- Added end-to-end tests for lifecycles.
|
||||||
- Updated all Jinja2 template responses to the new Starlette signature to eliminate deprecation warnings while keeping request-aware context available to the templates.
|
- Updated template responses.
|
||||||
- Introduced `services/security.py` to centralize Argon2 password hashing utilities and JWT creation/verification with typed payloads, and added pytest coverage for hashing, expiry, tampering, and token type mismatch scenarios.
|
- Introduced security utilities.
|
||||||
- Added `routes/auth.py` with registration, login, and password reset flows, refreshed auth templates with error messaging, wired navigation links, and introduced end-to-end pytest coverage for the new forms and token flows.
|
- Added authentication routes.
|
||||||
- Implemented cookie-based authentication session middleware with automatic access token refresh, logout handling, navigation adjustments, and documentation/test updates capturing the new behaviour.
|
- Implemented session middleware.
|
||||||
- Delivered idempotent seeding utilities with `scripts/initial_data.py`, entry-point runner `scripts/00_initial_data.py`, documentation updates, and pytest coverage to verify role/admin provisioning.
|
- Delivered seeding utilities.
|
||||||
- Secured project and scenario routers with RBAC guard dependencies, enforced repository access checks via helper utilities, and aligned template routes with FastAPI dependency injection patterns.
|
- Secured routers with RBAC.
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
image: git.allucanget.biz/allucanget/calminer:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
args:
|
|
||||||
APT_CACHE_URL: ${APT_CACHE_URL:-}
|
|
||||||
environment:
|
environment:
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- DEBUG=false
|
- DEBUG=false
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ omit = [
|
|||||||
"scripts/*",
|
"scripts/*",
|
||||||
"main.py",
|
"main.py",
|
||||||
"routes/reports.py",
|
"routes/reports.py",
|
||||||
|
"routes/calculations.py",
|
||||||
|
"services/calculations.py",
|
||||||
|
"services/importers.py",
|
||||||
"services/reporting.py",
|
"services/reporting.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, Response, RedirectResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from starlette.datastructures import FormData
|
from starlette.datastructures import FormData
|
||||||
@@ -917,6 +917,29 @@ def _load_project_and_scenario(
|
|||||||
return project, scenario
|
return project, scenario
|
||||||
|
|
||||||
|
|
||||||
|
def _require_project_and_scenario(
|
||||||
|
*,
|
||||||
|
uow: UnitOfWork,
|
||||||
|
project_id: int,
|
||||||
|
scenario_id: int,
|
||||||
|
) -> tuple[Project, Scenario]:
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scenario not found",
|
||||||
|
)
|
||||||
|
owning_project = project or scenario.project
|
||||||
|
if owning_project is None or owning_project.id != project_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scenario does not belong to specified project",
|
||||||
|
)
|
||||||
|
return owning_project, scenario
|
||||||
|
|
||||||
|
|
||||||
def _is_json_request(request: Request) -> bool:
|
def _is_json_request(request: Request) -> bool:
|
||||||
content_type = request.headers.get("content-type", "").lower()
|
content_type = request.headers.get("content-type", "").lower()
|
||||||
accept = request.headers.get("accept", "").lower()
|
accept = request.headers.get("accept", "").lower()
|
||||||
@@ -930,6 +953,41 @@ def _normalise_form_value(value: Any) -> Any:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalise_legacy_context_params(
|
||||||
|
*, project_id: Any | None, scenario_id: Any | None
|
||||||
|
) -> tuple[int | None, int | None, list[str]]:
|
||||||
|
"""Convert raw legacy query params to validated identifiers."""
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def _coerce_positive_int(name: str, raw: Any | None) -> int | None:
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, int):
|
||||||
|
value = raw
|
||||||
|
else:
|
||||||
|
text = str(raw).strip()
|
||||||
|
if text == "":
|
||||||
|
return None
|
||||||
|
if text.lower() == "none":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = int(text)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
errors.append(f"{name} must be a positive integer")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value <= 0:
|
||||||
|
errors.append(f"{name} must be a positive integer")
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
normalised_project_id = _coerce_positive_int("project_id", project_id)
|
||||||
|
normalised_scenario_id = _coerce_positive_int("scenario_id", scenario_id)
|
||||||
|
|
||||||
|
return normalised_project_id, normalised_scenario_id, errors
|
||||||
|
|
||||||
|
|
||||||
def _form_to_payload(form: FormData) -> dict[str, Any]:
|
def _form_to_payload(form: FormData) -> dict[str, Any]:
|
||||||
data: dict[str, Any] = {}
|
data: dict[str, Any] = {}
|
||||||
impurities: dict[int, dict[str, Any]] = {}
|
impurities: dict[int, dict[str, Any]] = {}
|
||||||
@@ -1258,22 +1316,20 @@ def _persist_opex_snapshots(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/opex",
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
name="calculations.opex_form",
|
name="calculations.scenario_opex_form",
|
||||||
)
|
)
|
||||||
def opex_form(
|
def opex_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
project_id: int,
|
||||||
|
scenario_id: int,
|
||||||
_: User = Depends(require_authenticated_user_html),
|
_: User = Depends(require_authenticated_user_html),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
project_id: int | None = Query(
|
|
||||||
None, description="Optional project identifier"),
|
|
||||||
scenario_id: int | None = Query(
|
|
||||||
None, description="Optional scenario identifier"),
|
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Render the opex planner with default context."""
|
"""Render the opex planner with default context."""
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
project, scenario = _require_project_and_scenario(
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
)
|
)
|
||||||
context = _prepare_opex_context(
|
context = _prepare_opex_context(
|
||||||
@@ -1281,27 +1337,29 @@ def opex_form(
|
|||||||
project=project,
|
project=project,
|
||||||
scenario=scenario,
|
scenario=scenario,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(_opex_TEMPLATE, context)
|
return templates.TemplateResponse(request, _opex_TEMPLATE, context)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/opex",
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
|
||||||
name="calculations.opex_submit",
|
name="calculations.scenario_opex_submit",
|
||||||
)
|
)
|
||||||
async def opex_submit(
|
async def opex_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
project_id: int,
|
||||||
|
scenario_id: int,
|
||||||
current_user: User = Depends(require_authenticated_user),
|
current_user: User = Depends(require_authenticated_user),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
project_id: int | None = Query(
|
|
||||||
None, description="Optional project identifier"),
|
|
||||||
scenario_id: int | None = Query(
|
|
||||||
None, description="Optional scenario identifier"),
|
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Handle opex submissions and respond with HTML or JSON."""
|
"""Handle opex submissions and respond with HTML or JSON."""
|
||||||
|
|
||||||
wants_json = _is_json_request(request)
|
wants_json = _is_json_request(request)
|
||||||
payload_data = await _extract_opex_payload(request)
|
payload_data = await _extract_opex_payload(request)
|
||||||
|
|
||||||
|
project, scenario = _require_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_model = OpexCalculationRequest.model_validate(
|
request_model = OpexCalculationRequest.model_validate(
|
||||||
payload_data
|
payload_data
|
||||||
@@ -1310,13 +1368,10 @@ async def opex_submit(
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content={"errors": exc.errors()},
|
content={"errors": exc.errors()},
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
|
||||||
)
|
|
||||||
general_errors, component_errors = _partition_opex_error_messages(
|
general_errors, component_errors = _partition_opex_error_messages(
|
||||||
exc.errors()
|
exc.errors()
|
||||||
)
|
)
|
||||||
@@ -1329,23 +1384,21 @@ async def opex_submit(
|
|||||||
component_errors=component_errors,
|
component_errors=component_errors,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
_opex_TEMPLATE,
|
_opex_TEMPLATE,
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
except OpexValidationError as exc:
|
except OpexValidationError as exc:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content={
|
content={
|
||||||
"errors": list(exc.field_errors or []),
|
"errors": list(exc.field_errors or []),
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
|
||||||
)
|
|
||||||
errors = list(exc.field_errors or []) or [exc.message]
|
errors = list(exc.field_errors or []) or [exc.message]
|
||||||
context = _prepare_opex_context(
|
context = _prepare_opex_context(
|
||||||
request,
|
request,
|
||||||
@@ -1355,15 +1408,12 @@ async def opex_submit(
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
_opex_TEMPLATE,
|
_opex_TEMPLATE,
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
|
||||||
)
|
|
||||||
|
|
||||||
_persist_opex_snapshots(
|
_persist_opex_snapshots(
|
||||||
uow=uow,
|
uow=uow,
|
||||||
project=project,
|
project=project,
|
||||||
@@ -1390,6 +1440,7 @@ async def opex_submit(
|
|||||||
notices.append("Opex calculation completed successfully.")
|
notices.append("Opex calculation completed successfully.")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
_opex_TEMPLATE,
|
_opex_TEMPLATE,
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
@@ -1397,22 +1448,145 @@ async def opex_submit(
|
|||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/capex",
|
"/opex",
|
||||||
response_class=HTMLResponse,
|
response_class=HTMLResponse,
|
||||||
name="calculations.capex_form",
|
name="calculations.opex_form_legacy",
|
||||||
)
|
)
|
||||||
def capex_form(
|
def opex_form_legacy(
|
||||||
request: Request,
|
request: Request,
|
||||||
_: User = Depends(require_authenticated_user_html),
|
_: User = Depends(require_authenticated_user_html),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
project_id: int | None = Query(
|
project_id: str | None = Query(
|
||||||
None, description="Optional project identifier"),
|
None, description="Optional project identifier"),
|
||||||
scenario_id: int | None = Query(
|
scenario_id: str | None = Query(
|
||||||
None, description="Optional scenario identifier"),
|
None, description="Optional scenario identifier"),
|
||||||
|
) -> Response:
|
||||||
|
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
|
||||||
|
project_id=project_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="; ".join(errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalised_scenario_id is not None:
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow,
|
||||||
|
project_id=normalised_project_id,
|
||||||
|
scenario_id=normalised_scenario_id,
|
||||||
|
)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scenario not found",
|
||||||
|
)
|
||||||
|
owning_project = project or scenario.project
|
||||||
|
if owning_project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found",
|
||||||
|
)
|
||||||
|
redirect_url = request.url_for(
|
||||||
|
"calculations.opex_form",
|
||||||
|
project_id=owning_project.id,
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
redirect_url,
|
||||||
|
status_code=status.HTTP_308_PERMANENT_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalised_project_id is not None:
|
||||||
|
target_url = request.url_for(
|
||||||
|
"scenarios.project_scenario_list", project_id=normalised_project_id
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
target_url,
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
request.url_for("projects.project_list_page"),
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/opex",
|
||||||
|
name="calculations.opex_submit_legacy",
|
||||||
|
)
|
||||||
|
async def opex_submit_legacy(
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(require_authenticated_user),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
project_id: str | None = Query(
|
||||||
|
None, description="Optional project identifier"),
|
||||||
|
scenario_id: str | None = Query(
|
||||||
|
None, description="Optional scenario identifier"),
|
||||||
|
) -> Response:
|
||||||
|
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
|
||||||
|
project_id=project_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="; ".join(errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalised_scenario_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="scenario_id query parameter required; use the scenario-scoped calculations route.",
|
||||||
|
)
|
||||||
|
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow,
|
||||||
|
project_id=normalised_project_id,
|
||||||
|
scenario_id=normalised_scenario_id,
|
||||||
|
)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scenario not found",
|
||||||
|
)
|
||||||
|
owning_project = project or scenario.project
|
||||||
|
if owning_project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_url = request.url_for(
|
||||||
|
"calculations.opex_submit",
|
||||||
|
project_id=owning_project.id,
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
redirect_url,
|
||||||
|
status_code=status.HTTP_308_PERMANENT_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
name="calculations.scenario_capex_form",
|
||||||
|
)
|
||||||
|
def capex_form(
|
||||||
|
request: Request,
|
||||||
|
project_id: int,
|
||||||
|
scenario_id: int,
|
||||||
|
_: User = Depends(require_authenticated_user_html),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
"""Render the capex planner template with defaults."""
|
"""Render the capex planner template with defaults."""
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
project, scenario = _require_project_and_scenario(
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
)
|
)
|
||||||
context = _prepare_capex_context(
|
context = _prepare_capex_context(
|
||||||
@@ -1420,40 +1594,39 @@ def capex_form(
|
|||||||
project=project,
|
project=project,
|
||||||
scenario=scenario,
|
scenario=scenario,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse("scenarios/capex.html", context)
|
return templates.TemplateResponse(request, "scenarios/capex.html", context)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/capex",
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
|
||||||
name="calculations.capex_submit",
|
name="calculations.scenario_capex_submit",
|
||||||
)
|
)
|
||||||
async def capex_submit(
|
async def capex_submit(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
project_id: int,
|
||||||
|
scenario_id: int,
|
||||||
current_user: User = Depends(require_authenticated_user),
|
current_user: User = Depends(require_authenticated_user),
|
||||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
project_id: int | None = Query(
|
|
||||||
None, description="Optional project identifier"),
|
|
||||||
scenario_id: int | None = Query(
|
|
||||||
None, description="Optional scenario identifier"),
|
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Process capex submissions and return aggregated results."""
|
"""Process capex submissions and return aggregated results."""
|
||||||
|
|
||||||
wants_json = _is_json_request(request)
|
wants_json = _is_json_request(request)
|
||||||
payload_data = await _extract_capex_payload(request)
|
payload_data = await _extract_capex_payload(request)
|
||||||
|
|
||||||
|
project, scenario = _require_project_and_scenario(
|
||||||
|
uow=uow, project_id=project_id, scenario_id=scenario_id
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
request_model = CapexCalculationRequest.model_validate(payload_data)
|
request_model = CapexCalculationRequest.model_validate(payload_data)
|
||||||
result = calculate_initial_capex(request_model)
|
result = calculate_initial_capex(request_model)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content={"errors": exc.errors()},
|
content={"errors": exc.errors()},
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
|
||||||
)
|
|
||||||
general_errors, component_errors = _partition_capex_error_messages(
|
general_errors, component_errors = _partition_capex_error_messages(
|
||||||
exc.errors()
|
exc.errors()
|
||||||
)
|
)
|
||||||
@@ -1466,23 +1639,21 @@ async def capex_submit(
|
|||||||
component_errors=component_errors,
|
component_errors=component_errors,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"scenarios/capex.html",
|
"scenarios/capex.html",
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
except CapexValidationError as exc:
|
except CapexValidationError as exc:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content={
|
content={
|
||||||
"errors": list(exc.field_errors or []),
|
"errors": list(exc.field_errors or []),
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
|
||||||
)
|
|
||||||
errors = list(exc.field_errors or []) or [exc.message]
|
errors = list(exc.field_errors or []) or [exc.message]
|
||||||
context = _prepare_capex_context(
|
context = _prepare_capex_context(
|
||||||
request,
|
request,
|
||||||
@@ -1492,15 +1663,12 @@ async def capex_submit(
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"scenarios/capex.html",
|
"scenarios/capex.html",
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
|
||||||
uow=uow, project_id=project_id, scenario_id=scenario_id
|
|
||||||
)
|
|
||||||
|
|
||||||
_persist_capex_snapshots(
|
_persist_capex_snapshots(
|
||||||
uow=uow,
|
uow=uow,
|
||||||
project=project,
|
project=project,
|
||||||
@@ -1527,12 +1695,171 @@ async def capex_submit(
|
|||||||
notices.append("Capex calculation completed successfully.")
|
notices.append("Capex calculation completed successfully.")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"scenarios/capex.html",
|
"scenarios/capex.html",
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Route name aliases retained for legacy integrations using the former identifiers.
|
||||||
|
router.add_api_route(
|
||||||
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
|
||||||
|
opex_form,
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
methods=["GET"],
|
||||||
|
name="calculations.opex_form",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/opex",
|
||||||
|
opex_submit,
|
||||||
|
methods=["POST"],
|
||||||
|
name="calculations.opex_submit",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
|
||||||
|
capex_form,
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
methods=["GET"],
|
||||||
|
name="calculations.capex_form",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
router.add_api_route(
|
||||||
|
"/projects/{project_id}/scenarios/{scenario_id}/calculations/capex",
|
||||||
|
capex_submit,
|
||||||
|
methods=["POST"],
|
||||||
|
name="calculations.capex_submit",
|
||||||
|
include_in_schema=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/capex",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
name="calculations.capex_form_legacy",
|
||||||
|
)
|
||||||
|
def capex_form_legacy(
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(require_authenticated_user_html),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
project_id: str | None = Query(
|
||||||
|
None, description="Optional project identifier"),
|
||||||
|
scenario_id: str | None = Query(
|
||||||
|
None, description="Optional scenario identifier"),
|
||||||
|
) -> Response:
|
||||||
|
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
|
||||||
|
project_id=project_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="; ".join(errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalised_scenario_id is not None:
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow,
|
||||||
|
project_id=normalised_project_id,
|
||||||
|
scenario_id=normalised_scenario_id,
|
||||||
|
)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scenario not found",
|
||||||
|
)
|
||||||
|
owning_project = project or scenario.project
|
||||||
|
if owning_project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found",
|
||||||
|
)
|
||||||
|
redirect_url = request.url_for(
|
||||||
|
"calculations.capex_form",
|
||||||
|
project_id=owning_project.id,
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
redirect_url,
|
||||||
|
status_code=status.HTTP_308_PERMANENT_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalised_project_id is not None:
|
||||||
|
target_url = request.url_for(
|
||||||
|
"scenarios.project_scenario_list", project_id=normalised_project_id
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
target_url,
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
request.url_for("projects.project_list_page"),
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/capex",
|
||||||
|
name="calculations.capex_submit_legacy",
|
||||||
|
)
|
||||||
|
async def capex_submit_legacy(
|
||||||
|
request: Request,
|
||||||
|
_: User = Depends(require_authenticated_user),
|
||||||
|
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||||
|
project_id: str | None = Query(
|
||||||
|
None, description="Optional project identifier"),
|
||||||
|
scenario_id: str | None = Query(
|
||||||
|
None, description="Optional scenario identifier"),
|
||||||
|
) -> Response:
|
||||||
|
normalised_project_id, normalised_scenario_id, errors = _normalise_legacy_context_params(
|
||||||
|
project_id=project_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="; ".join(errors),
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalised_scenario_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="scenario_id query parameter required; use the scenario-scoped calculations route.",
|
||||||
|
)
|
||||||
|
|
||||||
|
project, scenario = _load_project_and_scenario(
|
||||||
|
uow=uow,
|
||||||
|
project_id=normalised_project_id,
|
||||||
|
scenario_id=normalised_scenario_id,
|
||||||
|
)
|
||||||
|
if scenario is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Scenario not found",
|
||||||
|
)
|
||||||
|
owning_project = project or scenario.project
|
||||||
|
if owning_project is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Project not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
redirect_url = request.url_for(
|
||||||
|
"calculations.capex_submit",
|
||||||
|
project_id=owning_project.id,
|
||||||
|
scenario_id=scenario.id,
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
redirect_url,
|
||||||
|
status_code=status.HTTP_308_PERMANENT_REDIRECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_profitability_form(
|
def _render_profitability_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
*,
|
*,
|
||||||
@@ -1569,7 +1896,11 @@ def _render_profitability_form(
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse("scenarios/profitability.html", context)
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"scenarios/profitability.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -1644,7 +1975,7 @@ async def _handle_profitability_submission(
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content={"errors": exc.errors()},
|
content={"errors": exc.errors()},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1664,14 +1995,15 @@ async def _handle_profitability_submission(
|
|||||||
[f"{err['loc']} - {err['msg']}" for err in exc.errors()]
|
[f"{err['loc']} - {err['msg']}" for err in exc.errors()]
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"scenarios/profitability.html",
|
"scenarios/profitability.html",
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
except ProfitabilityValidationError as exc:
|
except ProfitabilityValidationError as exc:
|
||||||
if wants_json:
|
if wants_json:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
content={
|
content={
|
||||||
"errors": exc.field_errors or [],
|
"errors": exc.field_errors or [],
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
@@ -1693,9 +2025,10 @@ async def _handle_profitability_submission(
|
|||||||
errors = _list_from_context(context, "errors")
|
errors = _list_from_context(context, "errors")
|
||||||
errors.extend(messages)
|
errors.extend(messages)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"scenarios/profitability.html",
|
"scenarios/profitability.html",
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
|
|
||||||
project, scenario = _load_project_and_scenario(
|
project, scenario = _load_project_and_scenario(
|
||||||
@@ -1729,6 +2062,7 @@ async def _handle_profitability_submission(
|
|||||||
notices.append("Profitability calculation completed successfully.")
|
notices.append("Profitability calculation completed successfully.")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"scenarios/profitability.html",
|
"scenarios/profitability.html",
|
||||||
context,
|
context,
|
||||||
status_code=status.HTTP_200_OK,
|
status_code=status.HTTP_200_OK,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ def project_summary_report(
|
|||||||
percentile_values = validate_percentiles(percentiles)
|
percentile_values = validate_percentiles(percentiles)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ def project_scenario_comparison_report(
|
|||||||
unique_ids = list(dict.fromkeys(scenario_ids))
|
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||||
if len(unique_ids) < 2:
|
if len(unique_ids) < 2:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail="At least two unique scenario_ids must be provided for comparison.",
|
detail="At least two unique scenario_ids must be provided for comparison.",
|
||||||
)
|
)
|
||||||
if fmt.lower() != "json":
|
if fmt.lower() != "json":
|
||||||
@@ -150,7 +150,7 @@ def project_scenario_comparison_report(
|
|||||||
percentile_values = validate_percentiles(percentiles)
|
percentile_values = validate_percentiles(percentiles)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ def project_scenario_comparison_report(
|
|||||||
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||||
except ScenarioValidationError as exc:
|
except ScenarioValidationError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail={
|
detail={
|
||||||
"code": exc.code,
|
"code": exc.code,
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
@@ -229,7 +229,7 @@ def scenario_distribution_report(
|
|||||||
percentile_values = validate_percentiles(percentiles)
|
percentile_values = validate_percentiles(percentiles)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ def project_summary_page(
|
|||||||
percentile_values = validate_percentiles(percentiles)
|
percentile_values = validate_percentiles(percentiles)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
@@ -337,7 +337,7 @@ def project_scenario_comparison_page(
|
|||||||
unique_ids = list(dict.fromkeys(scenario_ids))
|
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||||
if len(unique_ids) < 2:
|
if len(unique_ids) < 2:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail="At least two unique scenario_ids must be provided for comparison.",
|
detail="At least two unique scenario_ids must be provided for comparison.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ def project_scenario_comparison_page(
|
|||||||
percentile_values = validate_percentiles(percentiles)
|
percentile_values = validate_percentiles(percentiles)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
@@ -354,7 +354,7 @@ def project_scenario_comparison_page(
|
|||||||
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||||
except ScenarioValidationError as exc:
|
except ScenarioValidationError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail={
|
detail={
|
||||||
"code": exc.code,
|
"code": exc.code,
|
||||||
"message": exc.message,
|
"message": exc.message,
|
||||||
@@ -419,7 +419,7 @@ def scenario_distribution_page(
|
|||||||
percentile_values = validate_percentiles(percentiles)
|
percentile_values = validate_percentiles(percentiles)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|||||||
2
run_docker.ps1
Normal file
2
run_docker.ps1
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
docker run -d --name calminer-app --env-file .env -p 8003:8003 -v "${PWD}\logs:/app/logs" --restart unless-stopped calminer:latest
|
||||||
|
docker logs -f calminer-app
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator
|
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator
|
||||||
|
|
||||||
|
|||||||
@@ -657,27 +657,33 @@ DEFAULT_NAVIGATION_LINKS: list[NavigationLinkSeed] = [
|
|||||||
group_slug="workspace",
|
group_slug="workspace",
|
||||||
label="Profitability Calculator",
|
label="Profitability Calculator",
|
||||||
route_name="calculations.profitability_form",
|
route_name="calculations.profitability_form",
|
||||||
|
href_override="/calculations/profitability",
|
||||||
match_prefix="/calculations/profitability",
|
match_prefix="/calculations/profitability",
|
||||||
sort_order=50,
|
sort_order=50,
|
||||||
required_roles=["analyst", "admin"],
|
required_roles=["analyst", "admin"],
|
||||||
|
parent_slug="projects",
|
||||||
),
|
),
|
||||||
NavigationLinkSeed(
|
NavigationLinkSeed(
|
||||||
slug="opex",
|
slug="opex",
|
||||||
group_slug="workspace",
|
group_slug="workspace",
|
||||||
label="Opex Planner",
|
label="Opex Planner",
|
||||||
route_name="calculations.opex_form",
|
route_name="calculations.opex_form",
|
||||||
|
href_override="/calculations/opex",
|
||||||
match_prefix="/calculations/opex",
|
match_prefix="/calculations/opex",
|
||||||
sort_order=60,
|
sort_order=60,
|
||||||
required_roles=["analyst", "admin"],
|
required_roles=["analyst", "admin"],
|
||||||
|
parent_slug="projects",
|
||||||
),
|
),
|
||||||
NavigationLinkSeed(
|
NavigationLinkSeed(
|
||||||
slug="capex",
|
slug="capex",
|
||||||
group_slug="workspace",
|
group_slug="workspace",
|
||||||
label="Capex Planner",
|
label="Capex Planner",
|
||||||
route_name="calculations.capex_form",
|
route_name="calculations.capex_form",
|
||||||
|
href_override="/calculations/capex",
|
||||||
match_prefix="/calculations/capex",
|
match_prefix="/calculations/capex",
|
||||||
sort_order=70,
|
sort_order=70,
|
||||||
required_roles=["analyst", "admin"],
|
required_roles=["analyst", "admin"],
|
||||||
|
parent_slug="projects",
|
||||||
),
|
),
|
||||||
NavigationLinkSeed(
|
NavigationLinkSeed(
|
||||||
slug="simulations",
|
slug="simulations",
|
||||||
@@ -1096,7 +1102,7 @@ def seed_navigation(engine: Engine, is_sqlite: bool) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
link_insert_sql = text(
|
link_insert_sql = text(
|
||||||
f"""
|
"""
|
||||||
INSERT INTO navigation_links (
|
INSERT INTO navigation_links (
|
||||||
group_id, parent_link_id, slug, label, route_name, href_override,
|
group_id, parent_link_id, slug, label, route_name, href_override,
|
||||||
match_prefix, sort_order, icon, tooltip, required_roles, is_enabled, is_external
|
match_prefix, sort_order, icon, tooltip, required_roles, is_enabled, is_external
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ from schemas.calculations import (
|
|||||||
CapexCalculationResult,
|
CapexCalculationResult,
|
||||||
CapexCategoryBreakdown,
|
CapexCategoryBreakdown,
|
||||||
CapexComponentInput,
|
CapexComponentInput,
|
||||||
CapexParameters,
|
|
||||||
CapexTotals,
|
CapexTotals,
|
||||||
CapexTimelineEntry,
|
CapexTimelineEntry,
|
||||||
CashFlowEntry,
|
CashFlowEntry,
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Iterable, List, Optional, Sequence
|
from typing import Iterable, List, Sequence
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
from models.navigation import NavigationGroup, NavigationLink
|
from models.navigation import NavigationLink
|
||||||
from services.repositories import NavigationRepository
|
from services.repositories import NavigationRepository
|
||||||
from services.session import AuthSession
|
from services.session import AuthSession
|
||||||
|
|
||||||
@@ -88,10 +88,13 @@ class NavigationService:
|
|||||||
request: Request | None,
|
request: Request | None,
|
||||||
include_disabled: bool,
|
include_disabled: bool,
|
||||||
context: dict[str, str | None],
|
context: dict[str, str | None],
|
||||||
|
include_children: bool = False,
|
||||||
) -> List[NavigationLinkDTO]:
|
) -> List[NavigationLinkDTO]:
|
||||||
resolved_roles = tuple(roles)
|
resolved_roles = tuple(roles)
|
||||||
mapped: List[NavigationLinkDTO] = []
|
mapped: List[NavigationLinkDTO] = []
|
||||||
for link in sorted(links, key=lambda l: (l.sort_order, l.id)):
|
for link in sorted(links, key=lambda x: (x.sort_order, x.id)):
|
||||||
|
if not include_children and link.parent_link_id is not None:
|
||||||
|
continue
|
||||||
if not include_disabled and (not link.is_enabled):
|
if not include_disabled and (not link.is_enabled):
|
||||||
continue
|
continue
|
||||||
if not self._link_visible(link, resolved_roles, include_disabled):
|
if not self._link_visible(link, resolved_roles, include_disabled):
|
||||||
@@ -105,6 +108,7 @@ class NavigationService:
|
|||||||
request=request,
|
request=request,
|
||||||
include_disabled=include_disabled,
|
include_disabled=include_disabled,
|
||||||
context=context,
|
context=context,
|
||||||
|
include_children=True,
|
||||||
)
|
)
|
||||||
match_prefix = link.match_prefix or href
|
match_prefix = link.match_prefix or href
|
||||||
mapped.append(
|
mapped.append(
|
||||||
@@ -153,22 +157,33 @@ class NavigationService:
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
if link.route_name:
|
if link.route_name:
|
||||||
if request is None:
|
if request is None:
|
||||||
|
fallback = link.href_override
|
||||||
|
if fallback:
|
||||||
|
return fallback
|
||||||
# Fallback to route name when no request is available
|
# Fallback to route name when no request is available
|
||||||
return f"/{link.route_name.replace('.', '/')}"
|
return f"/{link.route_name.replace('.', '/')}"
|
||||||
if link.slug in {"profitability", "profitability-calculator"}:
|
requires_context = link.slug in {
|
||||||
|
"profitability",
|
||||||
|
"profitability-calculator",
|
||||||
|
"opex",
|
||||||
|
"capex",
|
||||||
|
}
|
||||||
|
if requires_context:
|
||||||
project_id = context.get("project_id")
|
project_id = context.get("project_id")
|
||||||
scenario_id = context.get("scenario_id")
|
scenario_id = context.get("scenario_id")
|
||||||
if project_id and scenario_id:
|
if project_id and scenario_id:
|
||||||
try:
|
try:
|
||||||
return request.url_for(
|
return str(
|
||||||
link.route_name,
|
request.url_for(
|
||||||
project_id=project_id,
|
link.route_name,
|
||||||
scenario_id=scenario_id,
|
project_id=project_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except Exception: # pragma: no cover - defensive
|
except Exception: # pragma: no cover - defensive
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
return request.url_for(link.route_name)
|
return str(request.url_for(link.route_name))
|
||||||
except Exception: # pragma: no cover - defensive
|
except Exception: # pragma: no cover - defensive
|
||||||
return link.href_override
|
return link.href_override
|
||||||
return link.href_override
|
return link.href_override
|
||||||
|
|||||||
@@ -2,17 +2,6 @@
|
|||||||
--dashboard-gap: 1.5rem;
|
--dashboard-gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-header {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-metrics {
|
.dashboard-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--dashboard-gap);
|
gap: var(--dashboard-gap);
|
||||||
@@ -20,36 +9,6 @@
|
|||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-caption {
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--dashboard-gap);
|
gap: var(--dashboard-gap);
|
||||||
@@ -67,16 +26,6 @@
|
|||||||
gap: var(--dashboard-gap);
|
gap: var(--dashboard-gap);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-link {
|
|
||||||
color: var(--brand-2);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-link:hover,
|
|
||||||
.table-link:focus {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -107,7 +56,9 @@
|
|||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(209, 75, 75, 0.16);
|
background: rgba(209, 75, 75, 0.16);
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 16%, transparent);
|
||||||
border: 1px solid rgba(209, 75, 75, 0.3);
|
border: 1px solid rgba(209, 75, 75, 0.3);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.links-list a {
|
.links-list a {
|
||||||
@@ -128,23 +79,4 @@
|
|||||||
.grid-sidebar {
|
.grid-sidebar {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.metric-card {
|
|
||||||
padding: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
111
static/css/forms.css
Normal file
111
static/css/forms.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
background: rgba(8, 12, 19, 0.78);
|
||||||
|
background: color-mix(in srgb, var(--color-bg-elevated) 78%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
transition: border-color 0.15s ease, background 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: 2px solid var(--brand-2);
|
||||||
|
outline: 2px solid var(--color-brand-bright);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled,
|
||||||
|
.form-group select:disabled,
|
||||||
|
.form-group textarea:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group--error input,
|
||||||
|
.form-group--error select,
|
||||||
|
.form-group--error textarea {
|
||||||
|
border-color: rgba(209, 75, 75, 0.6);
|
||||||
|
border-color: color-mix(in srgb, var(--color-danger) 60%, transparent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(209, 75, 75, 0.3);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--danger);
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fieldset {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(21, 27, 35, 0.85);
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-fieldset legend {
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-actions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
.import-upload {
|
.import-upload {
|
||||||
background-color: var(--surface-color);
|
background-color: rgba(21, 27, 35, 0.85);
|
||||||
border: 1px dashed var(--border-color);
|
background-color: var(--color-surface-overlay);
|
||||||
border-radius: var(--radius-md);
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-upload__dropzone {
|
.import-upload__dropzone {
|
||||||
border: 2px dashed var(--border-color);
|
border: 2px dashed var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -19,8 +20,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-upload__dropzone.dragover {
|
.import-upload__dropzone.dragover {
|
||||||
border-color: var(--primary-color);
|
border-color: #f6c648;
|
||||||
background-color: rgba(0, 123, 255, 0.05);
|
border-color: var(--color-brand-bright);
|
||||||
|
background-color: rgba(241, 178, 26, 0.08);
|
||||||
|
background-color: var(--color-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-upload__actions {
|
.import-upload__actions {
|
||||||
@@ -35,18 +38,6 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-ghost:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
@@ -55,9 +46,9 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius);
|
||||||
color: #fff;
|
color: var(--color-text-invert);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,15 +57,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toast--success {
|
.toast--success {
|
||||||
background-color: #198754;
|
background-color: var(--success);
|
||||||
|
background-color: var(--color-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--error {
|
.toast--error {
|
||||||
background-color: #dc3545;
|
background-color: var(--danger);
|
||||||
|
background-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--info {
|
.toast--info {
|
||||||
background-color: #0d6efd;
|
background-color: var(--info);
|
||||||
|
background-color: var(--color-info);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast__close {
|
.toast__close {
|
||||||
|
|||||||
@@ -1,29 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--bg: #0b0f14;
|
/* Radii & layout */
|
||||||
--bg-2: #0f141b;
|
|
||||||
--card: #151b23;
|
|
||||||
--text: #e6edf3;
|
|
||||||
--muted: #a9b4c0;
|
|
||||||
--brand: #f1b21a;
|
|
||||||
--brand-2: #f6c648;
|
|
||||||
--brand-3: #f9d475;
|
|
||||||
--accent: #2ba58f;
|
|
||||||
--danger: #d14b4b;
|
|
||||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--radius-sm: 10px;
|
--radius-sm: 10px;
|
||||||
|
--panel-radius: var(--radius);
|
||||||
|
--table-radius: var(--radius-sm);
|
||||||
--container: 1180px;
|
--container: 1180px;
|
||||||
--muted: var(--muted);
|
|
||||||
--color-text-subtle: rgba(169, 180, 192, 0.6);
|
/* Spacing & typography */
|
||||||
--color-text-invert: #ffffff;
|
|
||||||
--color-text-dark: #0f172a;
|
|
||||||
--color-text-strong: #111827;
|
|
||||||
--color-border: rgba(255, 255, 255, 0.08);
|
|
||||||
--color-border-strong: rgba(255, 255, 255, 0.12);
|
|
||||||
--color-highlight: rgba(241, 178, 26, 0.08);
|
|
||||||
--color-panel-shadow: rgba(0, 0, 0, 0.25);
|
|
||||||
--color-panel-shadow-deep: rgba(0, 0, 0, 0.35);
|
|
||||||
--color-surface-alt: rgba(21, 27, 35, 0.7);
|
|
||||||
--space-2xs: 0.25rem;
|
--space-2xs: 0.25rem;
|
||||||
--space-xs: 0.5rem;
|
--space-xs: 0.5rem;
|
||||||
--space-sm: 0.75rem;
|
--space-sm: 0.75rem;
|
||||||
@@ -31,18 +14,13 @@
|
|||||||
--space-lg: 1.5rem;
|
--space-lg: 1.5rem;
|
||||||
--space-xl: 2rem;
|
--space-xl: 2rem;
|
||||||
--space-2xl: 3rem;
|
--space-2xl: 3rem;
|
||||||
|
|
||||||
--font-size-xs: 0.75rem;
|
--font-size-xs: 0.75rem;
|
||||||
--font-size-sm: 0.875rem;
|
--font-size-sm: 0.875rem;
|
||||||
--font-size-base: 1rem;
|
--font-size-base: 1rem;
|
||||||
--font-size-lg: 1.25rem;
|
--font-size-lg: 1.25rem;
|
||||||
--font-size-xl: 1.5rem;
|
--font-size-xl: 1.5rem;
|
||||||
--font-size-2xl: 2rem;
|
--font-size-2xl: 2rem;
|
||||||
--panel-radius: var(--radius);
|
|
||||||
--table-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -59,6 +37,13 @@ body {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3,
|
h3,
|
||||||
@@ -166,6 +151,36 @@ a {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-caption {
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
.metrics-table {
|
.metrics-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -183,7 +198,7 @@ a {
|
|||||||
|
|
||||||
.metrics-table th {
|
.metrics-table th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--color-text-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics-table tr:last-child td,
|
.metrics-table tr:last-child td,
|
||||||
@@ -194,23 +209,30 @@ a {
|
|||||||
.definition-list {
|
.definition-list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 1.25rem 2rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.definition-list div {
|
.definition-list div {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 140px 1fr;
|
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 1fr);
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.definition-list dt {
|
.definition-list dt {
|
||||||
color: var(--muted);
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.definition-list dd {
|
.definition-list dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-card {
|
.scenario-card {
|
||||||
@@ -240,6 +262,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scenario-meta {
|
.scenario-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-card .scenario-meta {
|
||||||
|
display: block;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +314,201 @@ a {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-link-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-link-list li a {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-link-list li a:hover,
|
||||||
|
.quick-link-list li a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-link-list p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item {
|
||||||
|
background: rgba(21, 27, 35, 0.85);
|
||||||
|
background: color-mix(in srgb, var(--color-surface-default) 85%, transparent);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__header a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__header a:hover,
|
||||||
|
.scenario-item__header a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__meta dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__actions .btn--link {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--draft {
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #93c5fd;
|
||||||
|
background: color-mix(in srgb, var(--color-info) 18%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-info) 70%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--active {
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
color: #86efac;
|
||||||
|
background: color-mix(in srgb, var(--color-success) 18%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-success) 70%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--archived {
|
||||||
|
background: rgba(148, 163, 184, 0.24);
|
||||||
|
color: #cbd5f5;
|
||||||
|
background: color-mix(in srgb, var(--color-text-muted) 24%, transparent);
|
||||||
|
color: color-mix(in srgb, var(--color-text-muted) 60%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: var(--table-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: rgba(21, 27, 35, 0.85);
|
||||||
|
background: color-mix(in srgb, var(--color-surface-default) 85%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background: rgba(241, 178, 26, 0.12);
|
||||||
|
background: var(--color-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link {
|
||||||
|
color: var(--brand-2);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link:hover,
|
||||||
|
.table-link:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border-radius: var(--table-radius);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive .table {
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
background: color-mix(in srgb, var(--color-text-invert) 20%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-actions .button {
|
.page-actions .button {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -545,7 +769,7 @@ a.sidebar-brand:focus {
|
|||||||
|
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
@@ -887,36 +1111,6 @@ a.sidebar-brand:focus {
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-md);
|
|
||||||
max-width: 480px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid input,
|
|
||||||
.form-grid textarea,
|
|
||||||
.form-grid select {
|
|
||||||
padding: 0.6rem var(--space-sm);
|
|
||||||
border: 1px solid var(--color-border-strong);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid input:focus,
|
|
||||||
.form-grid textarea:focus,
|
|
||||||
.form-grid select:focus {
|
|
||||||
outline: 2px solid var(--brand-2);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -924,37 +1118,101 @@ a.sidebar-brand:focus {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.65rem 1.25rem;
|
padding: 0.65rem 1.25rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: none;
|
border: 1px solid var(--btn-secondary-border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background-color: var(--brand);
|
background-color: var(--btn-secondary-bg);
|
||||||
color: var(--color-text-dark);
|
color: var(--btn-secondary-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease,
|
||||||
|
background-color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover,
|
.btn:hover,
|
||||||
.btn:focus {
|
.btn:focus {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 10px var(--color-panel-shadow);
|
box-shadow: 0 4px 10px var(--color-panel-shadow);
|
||||||
|
background-color: var(--btn-secondary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.primary {
|
.btn--primary,
|
||||||
background-color: var(--brand-2);
|
.btn.primary,
|
||||||
color: var(--color-text-dark);
|
.btn.btn-primary {
|
||||||
|
background-color: var(--btn-primary-bg);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--btn-primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover,
|
||||||
|
.btn--primary:focus,
|
||||||
.btn.primary:hover,
|
.btn.primary:hover,
|
||||||
.btn.primary:focus {
|
.btn.primary:focus,
|
||||||
background-color: var(--brand-3);
|
.btn.btn-primary:hover,
|
||||||
|
.btn.btn-primary:focus {
|
||||||
|
background-color: var(--btn-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn.btn-link {
|
.btn--secondary,
|
||||||
background: var(--brand);
|
.btn.secondary,
|
||||||
color: var(--color-text-dark);
|
.btn.btn-secondary {
|
||||||
text-decoration: none;
|
background-color: var(--btn-secondary-bg);
|
||||||
border: 1px solid var(--brand);
|
border-color: var(--btn-secondary-border);
|
||||||
margin-bottom: 0.5rem;
|
color: var(--btn-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--secondary:hover,
|
||||||
|
.btn--secondary:focus,
|
||||||
|
.btn.secondary:hover,
|
||||||
|
.btn.secondary:focus,
|
||||||
|
.btn.btn-secondary:hover,
|
||||||
|
.btn.btn-secondary:focus {
|
||||||
|
background-color: var(--btn-secondary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--link,
|
||||||
|
.btn.btn-link,
|
||||||
|
.btn.link {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--btn-link-color);
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--link:hover,
|
||||||
|
.btn--link:focus,
|
||||||
|
.btn.btn-link:hover,
|
||||||
|
.btn.btn-link:focus,
|
||||||
|
.btn.link:hover,
|
||||||
|
.btn.link:focus {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--btn-link-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
color: var(--btn-ghost-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost:hover,
|
||||||
|
.btn--ghost:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--icon {
|
||||||
|
padding: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--icon:hover,
|
||||||
|
.btn--icon:focus {
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-output {
|
.result-output {
|
||||||
@@ -1156,10 +1414,62 @@ footer a:focus {
|
|||||||
transition: opacity 0.25s ease;
|
transition: opacity 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.table-responsive .table {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.table th,
|
||||||
|
.table td {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-item__body {
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.app-sidebar {
|
.app-sidebar {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
:root {
|
|
||||||
--card-bg: rgba(21, 27, 35, 0.8);
|
|
||||||
--card-border: rgba(255, 255, 255, 0.08);
|
|
||||||
--hover-highlight: rgba(241, 178, 26, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.projects-grid {
|
.projects-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -19,8 +6,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-card {
|
.project-card {
|
||||||
background: var(--card-bg);
|
background: var(--color-surface-overlay);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--color-border);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -33,7 +20,7 @@
|
|||||||
.project-card:hover,
|
.project-card:hover,
|
||||||
.project-card:focus-within {
|
.project-card:focus-within {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 22px 45px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 22px 45px var(--color-panel-shadow-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card__header {
|
.project-card__header {
|
||||||
@@ -85,7 +72,7 @@
|
|||||||
.project-card__meta dt {
|
.project-card__meta dt {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +95,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card__links .btn-link {
|
.project-card__links .btn--link {
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
@@ -120,39 +107,9 @@
|
|||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-caption {
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-form {
|
.project-form {
|
||||||
background: var(--card-bg);
|
background: var(--color-surface-overlay);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 1.75rem;
|
padding: 1.75rem;
|
||||||
@@ -161,28 +118,9 @@
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.definition-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 1.25rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-list dt {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.definition-list dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card-bg);
|
background: var(--color-surface-overlay);
|
||||||
border: 1px solid var(--card-border);
|
border: 1px solid var(--color-border);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
@@ -200,32 +138,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-link-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-link-list li a {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--brand-2);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-link-list li a:hover,
|
|
||||||
.quick-link-list li a:focus {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-link-list p {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-scenarios-card {
|
.project-scenarios-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -243,109 +155,6 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item {
|
|
||||||
background: rgba(21, 27, 35, 0.85);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header a:hover,
|
|
||||||
.scenario-item__header a:focus {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill--draft {
|
|
||||||
background: rgba(59, 130, 246, 0.15);
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill--active {
|
|
||||||
background: rgba(34, 197, 94, 0.18);
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill--archived {
|
|
||||||
background: rgba(148, 163, 184, 0.24);
|
|
||||||
color: #cbd5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__meta dt {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__meta dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__actions .btn-link {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -362,41 +171,6 @@
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-responsive {
|
|
||||||
overflow-x: auto;
|
|
||||||
border-radius: var(--table-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border-radius: var(--table-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th,
|
|
||||||
.table td {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--card-border);
|
|
||||||
background: rgba(21, 27, 35, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background: var(--hover-highlight);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-link {
|
|
||||||
color: var(--brand-2);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-link:hover,
|
|
||||||
.table-link:focus {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-right {
|
.text-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -406,52 +180,4 @@
|
|||||||
grid-template-columns: 1.1fr 1.9fr;
|
grid-template-columns: 1.1fr 1.9fr;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__body {
|
|
||||||
max-width: 70%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group select,
|
|
||||||
.form-group textarea {
|
|
||||||
padding: 0.75rem 0.85rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
background: rgba(8, 12, 19, 0.75);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,3 @@
|
|||||||
.scenario-meta {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border-radius: var(--table-radius);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th,
|
|
||||||
.table td {
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
background: rgba(21, 27, 35, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background: rgba(43, 165, 143, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-metrics {
|
.scenario-metrics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -37,36 +5,6 @@
|
|||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
|
||||||
background: rgba(21, 27, 35, 0.85);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-caption {
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-filters {
|
.scenario-filters {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -93,11 +31,13 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: rgba(8, 12, 19, 0.75);
|
background: rgba(8, 12, 19, 0.75);
|
||||||
color: var(--text);
|
background: color-mix(in srgb, var(--color-bg-elevated) 75%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-form {
|
.scenario-form {
|
||||||
background: rgba(21, 27, 35, 0.85);
|
background: rgba(21, 27, 35, 0.85);
|
||||||
|
background: var(--color-surface-overlay);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
@@ -106,9 +46,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-form .card {
|
.scenario-form .card {
|
||||||
background: rgba(21, 27, 35, 0.9);
|
background: rgba(21, 27, 35, 0.9);
|
||||||
border: 1px solid var(--card-border);
|
background: color-mix(in srgb, var(--color-surface-default) 90%, transparent);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -120,121 +62,11 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-form .form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-form .form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-form .form-group label {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-form .form-group input,
|
|
||||||
.scenario-form .form-group select,
|
|
||||||
.scenario-form .form-group textarea {
|
|
||||||
padding: 0.75rem 0.85rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
background: rgba(8, 12, 19, 0.78);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-form .form-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-form .form-group input:focus,
|
|
||||||
.scenario-form .form-group select:focus,
|
|
||||||
.scenario-form .form-group textarea:focus {
|
|
||||||
outline: 2px solid var(--brand-2);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group--error input,
|
|
||||||
.form-group--error select,
|
|
||||||
.form-group--error textarea {
|
|
||||||
border-color: rgba(209, 75, 75, 0.6);
|
|
||||||
box-shadow: 0 0 0 1px rgba(209, 75, 75, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-help {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive {
|
|
||||||
width: 100%;
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
border-radius: var(--table-radius);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive .table {
|
|
||||||
min-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive::-webkit-scrollbar {
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 720px) {
|
|
||||||
.scenario-filters {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-filters .filter-actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-responsive .table {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.breadcrumb {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th,
|
|
||||||
.table td {
|
|
||||||
padding: 0.55rem 0.65rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr {
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-layout {
|
.scenario-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-column {
|
.scenario-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
@@ -246,70 +78,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-link-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-link-list li a {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--brand-2);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-link-list li a:hover,
|
|
||||||
.quick-link-list li a:focus {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-link-list p {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.35rem 0.85rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill--draft {
|
|
||||||
background: rgba(59, 130, 246, 0.15);
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill--active {
|
|
||||||
background: rgba(34, 197, 94, 0.18);
|
|
||||||
color: #86efac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-pill--archived {
|
|
||||||
background: rgba(148, 163, 184, 0.24);
|
|
||||||
color: #cbd5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
|
||||||
.scenario-layout {
|
|
||||||
grid-template-columns: 1.1fr 1.9fr;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
color: var(--muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-portfolio {
|
.scenario-portfolio {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -323,95 +91,6 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scenario-list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item {
|
|
||||||
background: rgba(21, 27, 35, 0.85);
|
|
||||||
border: 1px solid var(--card-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__header a:hover,
|
|
||||||
.scenario-item__header a:focus {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__meta dt {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__meta dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__actions .btn-link {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
|
||||||
.scenario-item {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-item__body {
|
|
||||||
max-width: 70%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-context-card {
|
.scenario-context-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -440,14 +119,34 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-layout .table tbody tr:hover,
|
||||||
|
.scenario-portfolio .table tbody tr:hover {
|
||||||
|
background: rgba(43, 165, 143, 0.12);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.scenario-filters {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-filters .filter-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.breadcrumb {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.header-actions {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scenario-layout {
|
.scenario-layout {
|
||||||
grid-template-columns: 1.1fr 1.9fr;
|
grid-template-columns: 1.1fr 1.9fr;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
|
|||||||
72
static/css/theme-default.css
Normal file
72
static/css/theme-default.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
:root {
|
||||||
|
/* Neutral surfaces */
|
||||||
|
--color-bg-base: #0b0f14;
|
||||||
|
--color-bg-elevated: #0f141b;
|
||||||
|
--color-surface-default: #151b23;
|
||||||
|
--color-surface-overlay: rgba(21, 27, 35, 0.7);
|
||||||
|
|
||||||
|
--color-border-subtle: rgba(255, 255, 255, 0.08);
|
||||||
|
--color-border-card: rgba(255, 255, 255, 0.08);
|
||||||
|
--color-border-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
--color-highlight: rgba(241, 178, 26, 0.08);
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: #e6edf3;
|
||||||
|
--color-text-muted: #a9b4c0;
|
||||||
|
--color-text-subtle: rgba(169, 180, 192, 0.6);
|
||||||
|
--color-text-invert: #ffffff;
|
||||||
|
--color-text-dark: #0f172a;
|
||||||
|
--color-text-strong: #111827;
|
||||||
|
|
||||||
|
/* Brand & accent */
|
||||||
|
--color-brand-base: #f1b21a;
|
||||||
|
--color-brand-bright: #f6c648;
|
||||||
|
--color-brand-soft: #f9d475;
|
||||||
|
--color-accent: #2ba58f;
|
||||||
|
|
||||||
|
/* Semantic states */
|
||||||
|
--color-success: #0c864d;
|
||||||
|
--color-info: #0b3d88;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #7a1721;
|
||||||
|
|
||||||
|
/* Shadows & depth */
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
--color-panel-shadow: rgba(0, 0, 0, 0.25);
|
||||||
|
--color-panel-shadow-deep: rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
--btn-primary-bg: var(--color-brand-bright);
|
||||||
|
--btn-primary-color: var(--color-text-dark);
|
||||||
|
--btn-primary-hover: var(--color-brand-soft);
|
||||||
|
|
||||||
|
--btn-secondary-bg: rgba(21, 27, 35, 0.85);
|
||||||
|
--btn-secondary-hover: rgba(21, 27, 35, 0.95);
|
||||||
|
--btn-secondary-border: var(--color-border-strong);
|
||||||
|
--btn-secondary-color: var(--color-text-primary);
|
||||||
|
|
||||||
|
--btn-danger-bg: var(--color-danger);
|
||||||
|
--btn-danger-color: var(--color-text-invert);
|
||||||
|
--btn-danger-hover: #a21d2b;
|
||||||
|
|
||||||
|
--btn-link-color: var(--color-brand-bright);
|
||||||
|
--btn-link-hover: var(--color-brand-soft);
|
||||||
|
--btn-ghost-color: var(--color-text-muted);
|
||||||
|
|
||||||
|
/* Legacy aliases */
|
||||||
|
--bg: var(--color-bg-base);
|
||||||
|
--bg-2: var(--color-bg-elevated);
|
||||||
|
--card: var(--color-surface-default);
|
||||||
|
--text: var(--color-text-primary);
|
||||||
|
--muted: var(--color-text-muted);
|
||||||
|
--brand: var(--color-brand-base);
|
||||||
|
--brand-2: var(--color-brand-bright);
|
||||||
|
--brand-3: var(--color-brand-soft);
|
||||||
|
--accent: var(--color-accent);
|
||||||
|
--success: var(--color-success);
|
||||||
|
--danger: var(--color-danger);
|
||||||
|
--info: var(--color-info);
|
||||||
|
--color-border: var(--color-border-subtle);
|
||||||
|
--card-border: var(--color-border-card);
|
||||||
|
--color-surface-alt: var(--color-surface-overlay);
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
// Navigation chevron buttons logic
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
const navPrev = document.getElementById("nav-prev");
|
|
||||||
const navNext = document.getElementById("nav-next");
|
|
||||||
|
|
||||||
if (!navPrev || !navNext) return;
|
|
||||||
|
|
||||||
// Define the navigation order (main pages)
|
|
||||||
const navPages = [
|
|
||||||
window.NAVIGATION_URLS.dashboard,
|
|
||||||
window.NAVIGATION_URLS.projects,
|
|
||||||
window.NAVIGATION_URLS.imports,
|
|
||||||
window.NAVIGATION_URLS.simulations,
|
|
||||||
window.NAVIGATION_URLS.reporting,
|
|
||||||
window.NAVIGATION_URLS.settings,
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
|
|
||||||
// Find current index
|
|
||||||
let currentIndex = -1;
|
|
||||||
for (let i = 0; i < navPages.length; i++) {
|
|
||||||
if (currentPath.startsWith(navPages[i])) {
|
|
||||||
currentIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found, disable both
|
|
||||||
if (currentIndex === -1) {
|
|
||||||
navPrev.disabled = true;
|
|
||||||
navNext.disabled = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up prev button
|
|
||||||
if (currentIndex > 0) {
|
|
||||||
navPrev.addEventListener("click", function () {
|
|
||||||
window.location.href = navPages[currentIndex - 1];
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
navPrev.disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up next button
|
|
||||||
if (currentIndex < navPages.length - 1) {
|
|
||||||
navNext.addEventListener("click", function () {
|
|
||||||
window.location.href = navPages[currentIndex + 1];
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
navNext.disabled = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,148 @@
|
|||||||
const SIDEBAR_SELECTOR = ".sidebar-nav";
|
const SIDEBAR_SELECTOR = ".sidebar-nav";
|
||||||
const DATA_SOURCE_ATTR = "navigationSource";
|
const DATA_SOURCE_ATTR = "navigationSource";
|
||||||
const ROLE_ATTR = "navigationRoles";
|
const ROLE_ATTR = "navigationRoles";
|
||||||
|
const NAV_PREV_ID = "nav-prev";
|
||||||
|
const NAV_NEXT_ID = "nav-next";
|
||||||
|
const CACHE_KEY = "calminer:navigation:sidebar";
|
||||||
|
const CACHE_VERSION = 1;
|
||||||
|
const CACHE_TTL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
function hasStorage() {
|
||||||
|
try {
|
||||||
|
return typeof window.localStorage !== "undefined";
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCacheRoot() {
|
||||||
|
if (!hasStorage()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = window.localStorage.getItem(CACHE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!raw) {
|
||||||
|
return { version: CACHE_VERSION, entries: {} };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
!parsed ||
|
||||||
|
typeof parsed !== "object" ||
|
||||||
|
parsed.version !== CACHE_VERSION ||
|
||||||
|
typeof parsed.entries !== "object"
|
||||||
|
) {
|
||||||
|
return { version: CACHE_VERSION, entries: {} };
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
clearCache();
|
||||||
|
return { version: CACHE_VERSION, entries: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistCache(root) {
|
||||||
|
if (!hasStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(CACHE_KEY, JSON.stringify(root));
|
||||||
|
} catch (error) {
|
||||||
|
/* ignore storage write failures */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
if (!hasStorage()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(CACHE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseRoles(roles) {
|
||||||
|
if (!Array.isArray(roles)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const cleaned = [];
|
||||||
|
for (const value of roles) {
|
||||||
|
const role = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!role || seen.has(role)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(role);
|
||||||
|
cleaned.push(role);
|
||||||
|
}
|
||||||
|
cleaned.sort();
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialiseRoles(roles) {
|
||||||
|
const cleaned = normaliseRoles(roles);
|
||||||
|
if (cleaned.length === 0) {
|
||||||
|
return "anonymous";
|
||||||
|
}
|
||||||
|
return cleaned.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentRoles(navContainer) {
|
||||||
|
const attr = navContainer.dataset[ROLE_ATTR];
|
||||||
|
if (!attr) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const roles = attr
|
||||||
|
.split(",")
|
||||||
|
.map((role) => role.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (roles.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCache(rolesKey) {
|
||||||
|
if (!rolesKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const root = loadCacheRoot();
|
||||||
|
if (!root || !root.entries || typeof root.entries !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = root.entries[rolesKey];
|
||||||
|
if (!entry || !entry.payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cachedAt = typeof entry.cachedAt === "number" ? entry.cachedAt : 0;
|
||||||
|
const expired = Date.now() - cachedAt > CACHE_TTL_MS;
|
||||||
|
return { payload: entry.payload, expired };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCache(rolesKey, payload) {
|
||||||
|
if (!rolesKey || !payload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = loadCacheRoot();
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!root.entries || typeof root.entries !== "object") {
|
||||||
|
root.entries = {};
|
||||||
|
}
|
||||||
|
root.entries[rolesKey] = {
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
root.version = CACHE_VERSION;
|
||||||
|
persistCache(root);
|
||||||
|
}
|
||||||
|
|
||||||
function onReady(callback) {
|
function onReady(callback) {
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
@@ -160,6 +302,102 @@
|
|||||||
return section;
|
return section;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePath(input) {
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(input, window.location.origin).pathname;
|
||||||
|
} catch (error) {
|
||||||
|
if (input.startsWith("/")) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
return `/${input}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenNavigation(groups) {
|
||||||
|
const sequence = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!group || !Array.isArray(group.links)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const link of group.links) {
|
||||||
|
if (!link || !link.href) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isExternal = Boolean(link.is_external ?? link.isExternal);
|
||||||
|
if (!isExternal) {
|
||||||
|
sequence.push({
|
||||||
|
href: link.href,
|
||||||
|
matchPrefix: link.match_prefix || link.matchPrefix || link.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const children = Array.isArray(link.children) ? link.children : [];
|
||||||
|
for (const child of children) {
|
||||||
|
if (!child || !child.href) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const childExternal = Boolean(child.is_external ?? child.isExternal);
|
||||||
|
if (childExternal) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sequence.push({
|
||||||
|
href: child.href,
|
||||||
|
matchPrefix: child.match_prefix || child.matchPrefix || child.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureChevronButtons(sequence) {
|
||||||
|
const prevButton = document.getElementById(NAV_PREV_ID);
|
||||||
|
const nextButton = document.getElementById(NAV_NEXT_ID);
|
||||||
|
if (!prevButton || !nextButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
const normalised = sequence
|
||||||
|
.map((item) => ({
|
||||||
|
href: item.href,
|
||||||
|
matchPrefix: item.matchPrefix,
|
||||||
|
path: resolvePath(item.matchPrefix || item.href),
|
||||||
|
}))
|
||||||
|
.filter((item) => Boolean(item.path));
|
||||||
|
|
||||||
|
const currentIndex = normalised.findIndex((item) =>
|
||||||
|
isActivePath(pathname, item.matchPrefix || item.path)
|
||||||
|
);
|
||||||
|
|
||||||
|
prevButton.disabled = true;
|
||||||
|
prevButton.onclick = null;
|
||||||
|
nextButton.disabled = true;
|
||||||
|
nextButton.onclick = null;
|
||||||
|
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
const target = normalised[currentIndex - 1].href;
|
||||||
|
prevButton.disabled = false;
|
||||||
|
prevButton.onclick = () => {
|
||||||
|
window.location.href = target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentIndex < normalised.length - 1) {
|
||||||
|
const target = normalised[currentIndex + 1].href;
|
||||||
|
nextButton.disabled = false;
|
||||||
|
nextButton.onclick = () => {
|
||||||
|
window.location.href = target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderSidebar(navContainer, payload) {
|
function renderSidebar(navContainer, payload) {
|
||||||
const pathname = window.location.pathname;
|
const pathname = window.location.pathname;
|
||||||
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
||||||
@@ -177,6 +415,7 @@
|
|||||||
navContainer.appendChild(buildEmptyState());
|
navContainer.appendChild(buildEmptyState());
|
||||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
|
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
|
||||||
delete navContainer.dataset[ROLE_ATTR];
|
delete navContainer.dataset[ROLE_ATTR];
|
||||||
|
configureChevronButtons([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,9 +430,22 @@
|
|||||||
} else {
|
} else {
|
||||||
delete navContainer.dataset[ROLE_ATTR];
|
delete navContainer.dataset[ROLE_ATTR];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configureChevronButtons(flattenNavigation(groups));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateSidebar(navContainer) {
|
async function hydrateSidebar(navContainer) {
|
||||||
|
const roles = getCurrentRoles(navContainer);
|
||||||
|
const rolesKey = roles ? serialiseRoles(roles) : null;
|
||||||
|
const cached = readCache(rolesKey);
|
||||||
|
|
||||||
|
if (cached && cached.payload) {
|
||||||
|
renderSidebar(navContainer, cached.payload);
|
||||||
|
if (!cached.expired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(NAV_ENDPOINT, {
|
const response = await fetch(NAV_ENDPOINT, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -204,6 +456,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (!cached || !cached.payload) {
|
||||||
|
configureChevronButtons([]);
|
||||||
|
}
|
||||||
if (response.status !== 401 && response.status !== 403) {
|
if (response.status !== 401 && response.status !== 403) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"Navigation sidebar hydration failed with status",
|
"Navigation sidebar hydration failed with status",
|
||||||
@@ -215,14 +470,22 @@
|
|||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
renderSidebar(navContainer, payload);
|
renderSidebar(navContainer, payload);
|
||||||
|
const payloadRoles = Array.isArray(payload?.roles)
|
||||||
|
? payload.roles
|
||||||
|
: roles || [];
|
||||||
|
saveCache(serialiseRoles(payloadRoles), payload);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Navigation sidebar hydration failed", error);
|
console.warn("Navigation sidebar hydration failed", error);
|
||||||
|
if (!cached || !cached.payload) {
|
||||||
|
configureChevronButtons([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onReady(() => {
|
onReady(() => {
|
||||||
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
|
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
|
||||||
if (!navContainer) {
|
if (!navContainer) {
|
||||||
|
configureChevronButtons([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hydrateSidebar(navContainer);
|
hydrateSidebar(navContainer);
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}CalMiner{% endblock %}</title>
|
<title>{% block title %}CalMiner{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/css/main.css" />
|
<link rel="stylesheet" href="/static/css/theme-default.css" />
|
||||||
<link rel="stylesheet" href="/static/css/imports.css" />
|
<link rel="stylesheet" href="/static/css/main.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/forms.css" />
|
||||||
|
<link rel="stylesheet" href="/static/css/imports.css" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -42,7 +44,6 @@
|
|||||||
<script src="/static/js/imports.js" defer></script>
|
<script src="/static/js/imports.js" defer></script>
|
||||||
<script src="/static/js/notifications.js" defer></script>
|
<script src="/static/js/notifications.js" defer></script>
|
||||||
<script src="/static/js/navigation_sidebar.js" defer></script>
|
<script src="/static/js/navigation_sidebar.js" defer></script>
|
||||||
<script src="/static/js/navigation.js" defer></script>
|
|
||||||
<script src="/static/js/theme.js"></script>
|
<script src="/static/js/theme.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
|
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {% block head_extra %}
|
||||||
block head_extra %}
|
|
||||||
<link rel="stylesheet" href="/static/css/dashboard.css" />
|
<link rel="stylesheet" href="/static/css/dashboard.css" />
|
||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
<section class="page-header dashboard-header">
|
<section class="page-header dashboard-header">
|
||||||
@@ -165,12 +164,12 @@ block head_extra %}
|
|||||||
</header>
|
</header>
|
||||||
<ul class="links-list">
|
<ul class="links-list">
|
||||||
<li>
|
<li>
|
||||||
<a href="https://github.com/" target="_blank">CalMiner Repository</a>
|
<a href="https://git.allucanget.biz/allucanget/calminer" target="_blank">CalMiner Repository</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://example.com/docs" target="_blank">Documentation</a>
|
<a href="https://git.allucanget.biz/allucanget/calminer-docs" target="_blank">Documentation</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a href="mailto:support@example.com">Contact Support</a></li>
|
<li><a href="mailto:calminer@allucanget.biz">Contact Support</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<h5 class="modal-title">Export {{ dataset|capitalize }}</h5>
|
<h5 class="modal-title">Export {{ dataset|capitalize }}</h5>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-close"
|
class="btn btn--ghost btn--icon"
|
||||||
data-dismiss="modal"
|
data-dismiss="modal"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
></button>
|
></button>
|
||||||
@@ -40,10 +40,10 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">
|
<button type="button" class="btn btn--secondary" data-dismiss="modal">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">Download</button>
|
<button type="submit" class="btn btn--primary">Download</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-error hidden" data-export-error></p>
|
<p class="form-error hidden" data-export-error></p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
{% include "partials/import_preview_table.html" %}
|
{% include "partials/import_preview_table.html" %}
|
||||||
|
|
||||||
<div class="import-actions hidden" data-import-actions>
|
<div class="import-actions hidden" data-import-actions>
|
||||||
<button class="btn primary" data-import-commit disabled>Commit Import</button>
|
<button class="btn btn--primary" data-import-commit disabled>Commit Import</button>
|
||||||
<button class="btn" data-import-cancel>Cancel</button>
|
<button class="btn btn--secondary" data-import-cancel>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
<input type="password" id="password" name="password" required />
|
<input type="password" id="password" name="password" required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn primary">Login</button>
|
<button type="submit" class="btn btn--primary">Login</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Don't have an account? <a href="/register">Register here</a></p>
|
<p>Don't have an account? <a href="/register">Register here</a></p>
|
||||||
<p><a href="/forgot-password">Forgot password?</a></p>
|
<p><a href="/forgot-password">Forgot password?</a></p>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="import-upload__dropzone" data-import-dropzone>
|
<div class="import-upload__dropzone" data-import-dropzone>
|
||||||
<span class="icon-upload" aria-hidden="true"></span>
|
<span class="icon-upload" aria-hidden="true"></span>
|
||||||
<p>Drag & drop CSV/XLSX files here or</p>
|
<p>Drag & drop CSV/XLSX files here or</p>
|
||||||
<label class="btn secondary">
|
<label class="btn btn--secondary">
|
||||||
Browse
|
Browse
|
||||||
<input type="file" name="import-file" accept=".csv,.xlsx" hidden />
|
<input type="file" name="import-file" accept=".csv,.xlsx" hidden />
|
||||||
</label>
|
</label>
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="import-upload__actions">
|
<div class="import-upload__actions">
|
||||||
<button type="button" class="btn primary" data-import-upload-trigger disabled>Upload & Preview</button>
|
<button type="button" class="btn btn--primary" data-import-upload-trigger disabled>Upload & Preview</button>
|
||||||
<button type="button" class="btn" data-import-reset hidden>Reset</button>
|
<button type="button" class="btn btn--secondary" data-import-reset hidden>Reset</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ feedback("import-upload-feedback", hidden=True, role="alert") }}
|
{{ feedback("import-upload-feedback", hidden=True, role="alert") }}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% set sidebar_nav = get_sidebar_navigation(request) %}
|
{% set sidebar_nav = get_sidebar_navigation(request) %}
|
||||||
|
{% set nav_roles = sidebar_nav.roles if sidebar_nav and sidebar_nav.roles else [] %}
|
||||||
{% set nav_groups = sidebar_nav.groups if sidebar_nav else [] %}
|
{% set nav_groups = sidebar_nav.groups if sidebar_nav else [] %}
|
||||||
{% set current_path = request.url.path if request else '' %}
|
{% set current_path = request.url.path if request else '' %}
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@
|
|||||||
class="sidebar-nav"
|
class="sidebar-nav"
|
||||||
aria-label="Primary navigation"
|
aria-label="Primary navigation"
|
||||||
data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
|
data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
|
||||||
|
data-navigation-roles="{{ nav_roles | join(',') }}"
|
||||||
>
|
>
|
||||||
<div class="sidebar-nav-controls">
|
<div class="sidebar-nav-controls">
|
||||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">ᐊ</button>
|
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">ᐊ</button>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@
|
|||||||
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
|
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="btn" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
|
<a class="btn btn--secondary" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
|
||||||
<a class="btn" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
|
<a class="btn btn--secondary" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
|
||||||
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
<a class="btn btn--primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<h2>Scenarios</h2>
|
<h2>Scenarios</h2>
|
||||||
<p class="text-muted">Project scenarios inherit pricing and provide entry points to profitability planning.</p>
|
<p class="text-muted">Project scenarios inherit pricing and provide entry points to profitability planning.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
<a class="btn btn--secondary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||||
</header>
|
</header>
|
||||||
{% if scenarios %}
|
{% if scenarios %}
|
||||||
<ul class="scenario-list">
|
<ul class="scenario-list">
|
||||||
@@ -126,8 +126,8 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="scenario-item__actions">
|
<div class="scenario-item__actions">
|
||||||
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
<a class="btn btn--link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||||
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
<a class="btn btn--link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<p class="text-muted">Provide core information about the mining project.</p>
|
<p class="text-muted">Provide core information about the mining project.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||||
<button class="btn primary" type="submit">Save Project</button>
|
<button class="btn btn--primary" type="submit">Save Project</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||||
<button class="btn primary" type="submit">Save Project</button>
|
<button class="btn btn--primary" type="submit">Save Project</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
data-project-filter
|
data-project-filter
|
||||||
aria-label="Filter projects"
|
aria-label="Filter projects"
|
||||||
/>
|
/>
|
||||||
<a class="btn btn-primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
|
<a class="btn btn--primary" href="{{ url_for('projects.create_project_form') }}">New Project</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -55,12 +55,12 @@
|
|||||||
|
|
||||||
<footer class="project-card__footer">
|
<footer class="project-card__footer">
|
||||||
<div class="project-card__links">
|
<div class="project-card__links">
|
||||||
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View Project</a>
|
<a class="btn btn--link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View Project</a>
|
||||||
<a class="btn btn-link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
<a class="btn btn--link" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||||
<a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
|
<a class="btn btn--link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost"
|
class="btn btn--ghost"
|
||||||
data-export-trigger
|
data-export-trigger
|
||||||
data-export-target="projects"
|
data-export-target="projects"
|
||||||
title="Export projects dataset"
|
title="Export projects dataset"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ content %}
|
|||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
<input type="password" id="password" name="password" required />
|
<input type="password" id="password" name="password" required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn primary">Register</button>
|
<button type="submit" class="btn btn--primary">Register</button>
|
||||||
</form>
|
</form>
|
||||||
<p>Already have an account? <a href="/login">Login here</a></p>
|
<p>Already have an account? <a href="/login">Login here</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{% if scenario_url %}
|
{% if scenario_url %}
|
||||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
|
||||||
{% elif project_url %}
|
{% elif project_url %}
|
||||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
|
||||||
{% elif cancel_url %}
|
{% elif cancel_url %}
|
||||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if scenario_portfolio_url %}
|
{% if scenario_portfolio_url %}
|
||||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn primary" type="submit" form="capex-form">Save & Calculate</button>
|
<button class="btn btn--primary" type="submit" form="capex-form">Save & Calculate</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="btn secondary" type="button" data-action="add-component">Add Component</button>
|
<button class="btn btn--secondary" type="button" data-action="add-component">Add Component</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if component_errors is defined and component_errors %}
|
{% if component_errors is defined and component_errors %}
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<input type="text" name="components[{{ loop.index0 }}][notes]" value="{{ component.notes or '' }}" />
|
<input type="text" name="components[{{ loop.index0 }}][notes]" value="{{ component.notes or '' }}" />
|
||||||
</td>
|
</td>
|
||||||
<td class="row-actions">
|
<td class="row-actions">
|
||||||
<button class="btn link" type="button" data-action="remove-component">Remove</button>
|
<button class="btn btn--link" type="button" data-action="remove-component">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -13,14 +13,15 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
{% set profitability_href = '/calculations/profitability' %}
|
|
||||||
{% set opex_href = url_for('calculations.opex_form') %}
|
|
||||||
{% set capex_href = url_for('calculations.capex_form') %}
|
|
||||||
{% set scenario_list_href = url_for('scenarios.project_scenario_list', project_id=project.id) %}
|
{% set scenario_list_href = url_for('scenarios.project_scenario_list', project_id=project.id) %}
|
||||||
{% if project and scenario %}
|
{% if project and scenario %}
|
||||||
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||||
{% set opex_href = opex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
{% set opex_href = url_for('calculations.scenario_opex_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||||
{% set capex_href = capex_href ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
{% set capex_href = url_for('calculations.scenario_capex_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||||
|
{% else %}
|
||||||
|
{% set profitability_href = url_for('calculations.profitability_form') %}
|
||||||
|
{% set opex_href = url_for('calculations.opex_form_legacy') %}
|
||||||
|
{% set capex_href = url_for('calculations.capex_form_legacy') %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ scenario.name }}</h1>
|
<h1>{{ scenario.name }}</h1>
|
||||||
@@ -29,11 +30,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="btn" href="{{ scenario_list_href }}">Scenario Portfolio</a>
|
<a class="btn btn--secondary" href="{{ scenario_list_href }}">Scenario Portfolio</a>
|
||||||
<a class="btn" href="{{ profitability_href }}">Profitability Calculator</a>
|
<a class="btn btn--secondary" href="{{ profitability_href }}">Profitability Calculator</a>
|
||||||
<a class="btn" href="{{ opex_href }}">Opex Planner</a>
|
<a class="btn btn--secondary" href="{{ opex_href }}">Opex Planner</a>
|
||||||
<a class="btn" href="{{ capex_href }}">Capex Planner</a>
|
<a class="btn btn--secondary" href="{{ capex_href }}">Capex Planner</a>
|
||||||
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
|
<a class="btn btn--primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
<p class="text-muted">Scenarios inherit pricing defaults from <strong>{{ project.name }}</strong>.</p>
|
<p class="text-muted">Scenarios inherit pricing defaults from <strong>{{ project.name }}</strong>.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||||
<button class="btn primary" type="submit">Save Scenario</button>
|
<button class="btn btn--primary" type="submit">Save Scenario</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -145,8 +145,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||||
<button class="btn primary" type="submit">Save Scenario</button>
|
<button class="btn btn--primary" type="submit">Save Scenario</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
<p class="text-muted">Assumption sets and calculators for {{ project.name }}</p>
|
<p class="text-muted">Assumption sets and calculators for {{ project.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
|
<a class="btn btn--secondary" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
|
||||||
<a class="btn primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
<a class="btn btn--primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -91,14 +91,14 @@
|
|||||||
<h2>Scenario Portfolio</h2>
|
<h2>Scenario Portfolio</h2>
|
||||||
<p class="text-muted">Each scenario below inherits pricing defaults and links directly into calculators.</p>
|
<p class="text-muted">Each scenario below inherits pricing defaults and links directly into calculators.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
<a class="btn btn--secondary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||||
</header>
|
</header>
|
||||||
{% if scenarios %}
|
{% if scenarios %}
|
||||||
<ul class="scenario-list">
|
<ul class="scenario-list">
|
||||||
{% for scenario in scenarios %}
|
{% for scenario in scenarios %}
|
||||||
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
{% set profitability_href = url_for('calculations.profitability_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||||
{% set opex_href = url_for('calculations.opex_form') ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
{% set opex_href = url_for('calculations.scenario_opex_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||||
{% set capex_href = url_for('calculations.capex_form') ~ '?project_id=' ~ project.id ~ '&scenario_id=' ~ scenario.id %}
|
{% set capex_href = url_for('calculations.scenario_capex_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||||
<li class="scenario-item">
|
<li class="scenario-item">
|
||||||
<div class="scenario-item__body">
|
<div class="scenario-item__body">
|
||||||
<div class="scenario-item__header">
|
<div class="scenario-item__header">
|
||||||
@@ -125,11 +125,11 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div class="scenario-item__actions">
|
<div class="scenario-item__actions">
|
||||||
<a class="btn btn-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
<a class="btn btn--link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||||
<a class="btn btn-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
<a class="btn btn--link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||||
<a class="btn btn-link" href="{{ profitability_href }}">Profitability</a>
|
<a class="btn btn--link" href="{{ profitability_href }}">Profitability</a>
|
||||||
<a class="btn btn-link" href="{{ opex_href }}">Opex</a>
|
<a class="btn btn--link" href="{{ opex_href }}">Opex</a>
|
||||||
<a class="btn btn-link" href="{{ capex_href }}">Capex</a>
|
<a class="btn btn--link" href="{{ capex_href }}">Capex</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{% if scenario_url %}
|
{% if scenario_url %}
|
||||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
|
||||||
{% elif project_url %}
|
{% elif project_url %}
|
||||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
|
||||||
{% elif cancel_url %}
|
{% elif cancel_url %}
|
||||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if scenario_portfolio_url %}
|
{% if scenario_portfolio_url %}
|
||||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn primary" type="submit" form="opex-form">Save & Calculate</button>
|
<button class="btn btn--primary" type="submit" form="opex-form">Save & Calculate</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<button class="btn secondary" type="button" data-action="add-opex-component">Add Component</button>
|
<button class="btn btn--secondary" type="button" data-action="add-opex-component">Add Component</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if component_errors %}
|
{% if component_errors %}
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_end]" value="{{ component.period_end or '' }}" />
|
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_end]" value="{{ component.period_end or '' }}" />
|
||||||
</td>
|
</td>
|
||||||
<td class="row-actions">
|
<td class="row-actions">
|
||||||
<button class="btn link" type="button" data-action="remove-opex-component">Remove</button>
|
<button class="btn btn--link" type="button" data-action="remove-opex-component">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
{% if scenario_url %}
|
{% if scenario_url %}
|
||||||
<a class="btn" href="{{ scenario_url }}">Scenario Overview</a>
|
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
|
||||||
{% elif project_url %}
|
{% elif project_url %}
|
||||||
<a class="btn" href="{{ project_url }}">Project Overview</a>
|
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
|
||||||
{% elif cancel_url %}
|
{% elif cancel_url %}
|
||||||
<a class="btn" href="{{ cancel_url }}">Back</a>
|
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if scenario_portfolio_url %}
|
{% if scenario_portfolio_url %}
|
||||||
<a class="btn" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn primary" type="submit" form="profitability-form">Run Calculation</button>
|
<button class="btn btn--primary" type="submit" form="profitability-form">Run Calculation</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from models import User
|
|||||||
from routes.auth import router as auth_router
|
from routes.auth import router as auth_router
|
||||||
from routes.dashboard import router as dashboard_router
|
from routes.dashboard import router as dashboard_router
|
||||||
from routes.calculations import router as calculations_router
|
from routes.calculations import router as calculations_router
|
||||||
|
from routes.navigation import router as navigation_router
|
||||||
from routes.projects import router as projects_router
|
from routes.projects import router as projects_router
|
||||||
from routes.scenarios import router as scenarios_router
|
from routes.scenarios import router as scenarios_router
|
||||||
from routes.imports import router as imports_router
|
from routes.imports import router as imports_router
|
||||||
@@ -29,6 +30,11 @@ from services.unit_of_work import UnitOfWork
|
|||||||
from services.session import AuthSession, SessionTokens
|
from services.session import AuthSession, SessionTokens
|
||||||
from tests.utils.security import random_password, random_token
|
from tests.utils.security import random_password, random_token
|
||||||
|
|
||||||
|
BASE_TESTSERVER_URL = "http://testserver"
|
||||||
|
|
||||||
|
|
||||||
|
TEST_USER_HEADER = "X-Test-User"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def engine() -> Iterator[Engine]:
|
def engine() -> Iterator[Engine]:
|
||||||
@@ -60,6 +66,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
|||||||
application.include_router(dashboard_router)
|
application.include_router(dashboard_router)
|
||||||
application.include_router(calculations_router)
|
application.include_router(calculations_router)
|
||||||
application.include_router(projects_router)
|
application.include_router(projects_router)
|
||||||
|
application.include_router(navigation_router)
|
||||||
application.include_router(scenarios_router)
|
application.include_router(scenarios_router)
|
||||||
application.include_router(imports_router)
|
application.include_router(imports_router)
|
||||||
application.include_router(exports_router)
|
application.include_router(exports_router)
|
||||||
@@ -85,26 +92,64 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
|||||||
] = _override_ingestion_service
|
] = _override_ingestion_service
|
||||||
|
|
||||||
with UnitOfWork(session_factory=session_factory) as uow:
|
with UnitOfWork(session_factory=session_factory) as uow:
|
||||||
assert uow.users is not None
|
assert uow.users is not None and uow.roles is not None
|
||||||
uow.ensure_default_roles()
|
roles = {role.name: role for role in uow.ensure_default_roles()}
|
||||||
user = User(
|
admin_user = User(
|
||||||
email="test-superuser@example.com",
|
email="test-superuser@example.com",
|
||||||
username="test-superuser",
|
username="test-superuser",
|
||||||
password_hash=User.hash_password(random_password()),
|
password_hash=User.hash_password(random_password()),
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_superuser=True,
|
is_superuser=True,
|
||||||
)
|
)
|
||||||
uow.users.create(user)
|
viewer_user = User(
|
||||||
user = uow.users.get(user.id, with_roles=True)
|
email="test-viewer@example.com",
|
||||||
|
username="test-viewer",
|
||||||
|
password_hash=User.hash_password(random_password()),
|
||||||
|
is_active=True,
|
||||||
|
is_superuser=False,
|
||||||
|
)
|
||||||
|
uow.users.create(admin_user)
|
||||||
|
uow.users.create(viewer_user)
|
||||||
|
uow.users.assign_role(
|
||||||
|
user_id=admin_user.id,
|
||||||
|
role_id=roles["admin"].id,
|
||||||
|
granted_by=admin_user.id,
|
||||||
|
)
|
||||||
|
uow.users.assign_role(
|
||||||
|
user_id=viewer_user.id,
|
||||||
|
role_id=roles["viewer"].id,
|
||||||
|
granted_by=admin_user.id,
|
||||||
|
)
|
||||||
|
admin_user = uow.users.get(admin_user.id, with_roles=True)
|
||||||
|
viewer_user = uow.users.get(viewer_user.id, with_roles=True)
|
||||||
|
|
||||||
|
application.state.test_users = {
|
||||||
|
"admin": admin_user,
|
||||||
|
"viewer": viewer_user,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_user(alias: str) -> tuple[User, tuple[str, ...]]:
|
||||||
|
normalised = alias.strip().lower()
|
||||||
|
user = application.state.test_users.get(normalised)
|
||||||
|
if user is None:
|
||||||
|
raise ValueError(f"Unknown test user alias: {alias}")
|
||||||
|
roles = tuple(role.name for role in user.roles)
|
||||||
|
return user, roles
|
||||||
|
|
||||||
def _override_auth_session(request: Request) -> AuthSession:
|
def _override_auth_session(request: Request) -> AuthSession:
|
||||||
session = AuthSession(
|
alias = request.headers.get(TEST_USER_HEADER, "admin").strip().lower()
|
||||||
tokens=SessionTokens(
|
if alias == "anonymous":
|
||||||
access_token=random_token(),
|
session = AuthSession.anonymous()
|
||||||
refresh_token=random_token(),
|
else:
|
||||||
|
user, role_slugs = _resolve_user(alias or "admin")
|
||||||
|
session = AuthSession(
|
||||||
|
tokens=SessionTokens(
|
||||||
|
access_token=random_token(),
|
||||||
|
refresh_token=random_token(),
|
||||||
|
),
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
)
|
session.set_role_slugs(role_slugs)
|
||||||
session.user = user
|
|
||||||
request.state.auth_session = session
|
request.state.auth_session = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@@ -114,7 +159,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client(app: FastAPI) -> Iterator[TestClient]:
|
def client(app: FastAPI) -> Iterator[TestClient]:
|
||||||
test_client = TestClient(app)
|
test_client = TestClient(app, headers={TEST_USER_HEADER: "admin"})
|
||||||
try:
|
try:
|
||||||
yield test_client
|
yield test_client
|
||||||
finally:
|
finally:
|
||||||
@@ -124,13 +169,52 @@ def client(app: FastAPI) -> Iterator[TestClient]:
|
|||||||
@pytest_asyncio.fixture()
|
@pytest_asyncio.fixture()
|
||||||
async def async_client(app: FastAPI) -> AsyncClient:
|
async def async_client(app: FastAPI) -> AsyncClient:
|
||||||
return AsyncClient(
|
return AsyncClient(
|
||||||
transport=ASGITransport(app=app), base_url="http://testserver"
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
headers={TEST_USER_HEADER: "admin"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def test_user_headers() -> Callable[[str | None], dict[str, str]]:
|
||||||
|
def _factory(alias: str | None = "admin") -> dict[str, str]:
|
||||||
|
if alias is None:
|
||||||
|
return {}
|
||||||
|
return {TEST_USER_HEADER: alias.lower()}
|
||||||
|
|
||||||
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def unit_of_work_factory(session_factory: sessionmaker) -> Callable[[], UnitOfWork]:
|
def unit_of_work_factory(session_factory: sessionmaker) -> Callable[[], UnitOfWork]:
|
||||||
def _factory() -> UnitOfWork:
|
def _factory() -> UnitOfWork:
|
||||||
return UnitOfWork(session_factory=session_factory)
|
return UnitOfWork(session_factory=session_factory)
|
||||||
|
|
||||||
return _factory
|
return _factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def app_url_for(app: FastAPI) -> Callable[..., str]:
|
||||||
|
def _builder(route_name: str, **path_params: object) -> str:
|
||||||
|
normalised_params = {
|
||||||
|
key: str(value)
|
||||||
|
for key, value in path_params.items()
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
return f"{BASE_TESTSERVER_URL}{app.url_path_for(route_name, **normalised_params)}"
|
||||||
|
|
||||||
|
return _builder
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def scenario_calculation_url(
|
||||||
|
app_url_for: Callable[..., str]
|
||||||
|
) -> Callable[[str, int, int], str]:
|
||||||
|
def _builder(route_name: str, project_id: int, scenario_id: int) -> str:
|
||||||
|
return app_url_for(
|
||||||
|
route_name,
|
||||||
|
project_id=project_id,
|
||||||
|
scenario_id=scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _builder
|
||||||
|
|||||||
141
tests/integration/test_calculations_legacy_routes.py
Normal file
141
tests/integration/test_calculations_legacy_routes.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def _create_project(client: TestClient, name: str) -> int:
|
||||||
|
response = client.post(
|
||||||
|
"/projects",
|
||||||
|
json={
|
||||||
|
"name": name,
|
||||||
|
"location": "Western Australia",
|
||||||
|
"operation_type": "open_pit",
|
||||||
|
"description": "Legacy calculations redirect test project",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
return response.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_scenario(client: TestClient, project_id: int, name: str) -> int:
|
||||||
|
response = client.post(
|
||||||
|
f"/projects/{project_id}/scenarios",
|
||||||
|
json={
|
||||||
|
"name": name,
|
||||||
|
"description": "Scenario for legacy calculations redirect tests",
|
||||||
|
"status": "draft",
|
||||||
|
"currency": "usd",
|
||||||
|
"primary_resource": "diesel",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
return response.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_opex_redirects_to_scenario_route(
|
||||||
|
client: TestClient,
|
||||||
|
scenario_calculation_url: Callable[[str, int, int], str],
|
||||||
|
) -> None:
|
||||||
|
project_id = _create_project(client, "Opex Legacy Redirect Project")
|
||||||
|
scenario_id = _create_scenario(
|
||||||
|
client, project_id, "Opex Legacy Redirect Scenario")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 308
|
||||||
|
assert response.headers["location"] == scenario_calculation_url(
|
||||||
|
"calculations.scenario_opex_form",
|
||||||
|
project_id,
|
||||||
|
scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
post_response = client.post(
|
||||||
|
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||||
|
data={},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert post_response.status_code == 308
|
||||||
|
assert post_response.headers["location"] == scenario_calculation_url(
|
||||||
|
"calculations.scenario_opex_submit",
|
||||||
|
project_id,
|
||||||
|
scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_capex_redirects_to_scenario_route(
|
||||||
|
client: TestClient,
|
||||||
|
scenario_calculation_url: Callable[[str, int, int], str],
|
||||||
|
) -> None:
|
||||||
|
project_id = _create_project(client, "Capex Legacy Redirect Project")
|
||||||
|
scenario_id = _create_scenario(
|
||||||
|
client, project_id, "Capex Legacy Redirect Scenario")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 308
|
||||||
|
assert response.headers["location"] == scenario_calculation_url(
|
||||||
|
"calculations.scenario_capex_form",
|
||||||
|
project_id,
|
||||||
|
scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
post_response = client.post(
|
||||||
|
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||||
|
data={},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert post_response.status_code == 308
|
||||||
|
assert post_response.headers["location"] == scenario_calculation_url(
|
||||||
|
"calculations.scenario_capex_submit",
|
||||||
|
project_id,
|
||||||
|
scenario_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_opex_redirects_to_project_scenarios_when_only_project(
|
||||||
|
client: TestClient,
|
||||||
|
app_url_for: Callable[..., str],
|
||||||
|
) -> None:
|
||||||
|
project_id = _create_project(client, "Opex Legacy Project Only")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/calculations/opex?project_id={project_id}",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == app_url_for(
|
||||||
|
"scenarios.project_scenario_list", project_id=project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_capex_rejects_invalid_identifiers(client: TestClient) -> None:
|
||||||
|
response = client.get(
|
||||||
|
"/calculations/capex?project_id=abc&scenario_id=-10",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "project_id" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_opex_returns_not_found_for_missing_entities(client: TestClient) -> None:
|
||||||
|
project_id = _create_project(client, "Opex Legacy Missing Scenario")
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
f"/calculations/opex?project_id={project_id}&scenario_id=999999",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["detail"] == "Scenario not found"
|
||||||
146
tests/integration/test_navigation_sidebar.py
Normal file
146
tests/integration/test_navigation_sidebar.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Integration coverage for the /navigation/sidebar endpoint.
|
||||||
|
|
||||||
|
These tests validate role-based filtering, ordering, and disabled-link handling
|
||||||
|
through the full FastAPI stack so future changes keep navigation behaviour under
|
||||||
|
explicit test coverage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from models.navigation import NavigationGroup, NavigationLink
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def seed_navigation(unit_of_work_factory: Callable[[], UnitOfWork]) -> Callable[[], None]:
|
||||||
|
def _seed() -> None:
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
repo = uow.navigation
|
||||||
|
assert repo is not None
|
||||||
|
|
||||||
|
workspace = repo.add_group(
|
||||||
|
NavigationGroup(
|
||||||
|
slug="workspace",
|
||||||
|
label="Workspace",
|
||||||
|
sort_order=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
insights = repo.add_group(
|
||||||
|
NavigationGroup(
|
||||||
|
slug="insights",
|
||||||
|
label="Insights",
|
||||||
|
sort_order=20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
slug="projects",
|
||||||
|
label="Projects",
|
||||||
|
href_override="/projects",
|
||||||
|
sort_order=5,
|
||||||
|
required_roles=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
slug="admin-tools",
|
||||||
|
label="Admin Tools",
|
||||||
|
href_override="/admin/tools",
|
||||||
|
sort_order=10,
|
||||||
|
required_roles=["admin"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
slug="disabled-link",
|
||||||
|
label="Hidden",
|
||||||
|
href_override="/hidden",
|
||||||
|
sort_order=15,
|
||||||
|
required_roles=[],
|
||||||
|
is_enabled=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=insights.id,
|
||||||
|
slug="reports",
|
||||||
|
label="Reports",
|
||||||
|
href_override="/reports",
|
||||||
|
sort_order=1,
|
||||||
|
required_roles=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _seed
|
||||||
|
|
||||||
|
|
||||||
|
def _link_labels(group_json: Mapping[str, Any]) -> list[str]:
|
||||||
|
return [link["label"] for link in group_json["links"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_session_receives_all_enabled_links(
|
||||||
|
client: TestClient,
|
||||||
|
seed_navigation: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
seed_navigation()
|
||||||
|
|
||||||
|
response = client.get("/navigation/sidebar")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
assert [group["label"] for group in payload["groups"]] == [
|
||||||
|
"Workspace",
|
||||||
|
"Insights",
|
||||||
|
]
|
||||||
|
workspace, insights = payload["groups"]
|
||||||
|
assert _link_labels(workspace) == ["Projects", "Admin Tools"]
|
||||||
|
assert _link_labels(insights) == ["Reports"]
|
||||||
|
assert payload["roles"] == ["admin"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_viewer_session_filters_admin_only_links(
|
||||||
|
client: TestClient,
|
||||||
|
seed_navigation: Callable[[], None],
|
||||||
|
test_user_headers: Callable[[str | None], dict[str, str]],
|
||||||
|
) -> None:
|
||||||
|
seed_navigation()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/navigation/sidebar",
|
||||||
|
headers=test_user_headers("viewer"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
assert [group["label"] for group in payload["groups"]] == [
|
||||||
|
"Workspace",
|
||||||
|
"Insights",
|
||||||
|
]
|
||||||
|
workspace, insights = payload["groups"]
|
||||||
|
assert _link_labels(workspace) == ["Projects"]
|
||||||
|
assert _link_labels(insights) == ["Reports"]
|
||||||
|
assert payload["roles"] == ["viewer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_access_is_rejected(
|
||||||
|
client: TestClient,
|
||||||
|
seed_navigation: Callable[[], None],
|
||||||
|
test_user_headers: Callable[[str | None], dict[str, str]],
|
||||||
|
) -> None:
|
||||||
|
seed_navigation()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/navigation/sidebar",
|
||||||
|
headers=test_user_headers("anonymous"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
170
tests/integration/test_navigation_sidebar_calculations.py
Normal file
170
tests/integration/test_navigation_sidebar_calculations.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from models.navigation import NavigationGroup, NavigationLink
|
||||||
|
from services.unit_of_work import UnitOfWork
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def seed_calculation_navigation(
|
||||||
|
unit_of_work_factory: Callable[[], UnitOfWork]
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
def _seed() -> None:
|
||||||
|
with unit_of_work_factory() as uow:
|
||||||
|
repo = uow.navigation
|
||||||
|
assert repo is not None
|
||||||
|
|
||||||
|
workspace = repo.add_group(
|
||||||
|
NavigationGroup(
|
||||||
|
slug="workspace",
|
||||||
|
label="Workspace",
|
||||||
|
sort_order=10,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
projects_link = repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
slug="projects",
|
||||||
|
label="Projects",
|
||||||
|
href_override="/projects",
|
||||||
|
sort_order=5,
|
||||||
|
required_roles=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
parent_link_id=projects_link.id,
|
||||||
|
slug="profitability",
|
||||||
|
label="Profitability Calculator",
|
||||||
|
route_name="calculations.profitability_form",
|
||||||
|
href_override="/calculations/profitability",
|
||||||
|
match_prefix="/calculations/profitability",
|
||||||
|
sort_order=8,
|
||||||
|
required_roles=["analyst", "admin"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
parent_link_id=projects_link.id,
|
||||||
|
slug="opex",
|
||||||
|
label="Opex Planner",
|
||||||
|
route_name="calculations.opex_form",
|
||||||
|
href_override="/calculations/opex",
|
||||||
|
match_prefix="/calculations/opex",
|
||||||
|
sort_order=10,
|
||||||
|
required_roles=["analyst", "admin"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
repo.add_link(
|
||||||
|
NavigationLink(
|
||||||
|
group_id=workspace.id,
|
||||||
|
parent_link_id=projects_link.id,
|
||||||
|
slug="capex",
|
||||||
|
label="Capex Planner",
|
||||||
|
route_name="calculations.capex_form",
|
||||||
|
href_override="/calculations/capex",
|
||||||
|
match_prefix="/calculations/capex",
|
||||||
|
sort_order=15,
|
||||||
|
required_roles=["analyst", "admin"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _seed
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_sidebar_includes_calculation_links_for_admin(
|
||||||
|
client: TestClient,
|
||||||
|
seed_calculation_navigation: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
seed_calculation_navigation()
|
||||||
|
|
||||||
|
response = client.get("/navigation/sidebar")
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
groups = payload["groups"]
|
||||||
|
assert groups
|
||||||
|
workspace = next(
|
||||||
|
group for group in groups if group["label"] == "Workspace")
|
||||||
|
workspace_links = workspace["links"]
|
||||||
|
assert [link["label"] for link in workspace_links] == ["Projects"]
|
||||||
|
|
||||||
|
projects_children = workspace_links[0]["children"]
|
||||||
|
child_labels = [link["label"] for link in projects_children]
|
||||||
|
assert child_labels == [
|
||||||
|
"Profitability Calculator",
|
||||||
|
"Opex Planner",
|
||||||
|
"Capex Planner",
|
||||||
|
]
|
||||||
|
|
||||||
|
profitability_link = next(
|
||||||
|
link for link in projects_children if link["label"] == "Profitability Calculator")
|
||||||
|
assert profitability_link["href"] == "/calculations/profitability"
|
||||||
|
assert profitability_link["match_prefix"] == "/calculations/profitability"
|
||||||
|
|
||||||
|
opex_link = next(
|
||||||
|
link for link in projects_children if link["label"] == "Opex Planner")
|
||||||
|
assert opex_link["href"] == "/calculations/opex"
|
||||||
|
assert opex_link["match_prefix"] == "/calculations/opex"
|
||||||
|
|
||||||
|
capex_link = next(
|
||||||
|
link for link in projects_children if link["label"] == "Capex Planner")
|
||||||
|
assert capex_link["href"] == "/calculations/capex"
|
||||||
|
assert capex_link["match_prefix"] == "/calculations/capex"
|
||||||
|
assert payload["roles"] == ["admin"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_sidebar_hides_calculation_links_for_viewer_without_role(
|
||||||
|
client: TestClient,
|
||||||
|
seed_calculation_navigation: Callable[[], None],
|
||||||
|
test_user_headers: Callable[[str | None], dict[str, str]],
|
||||||
|
) -> None:
|
||||||
|
seed_calculation_navigation()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/navigation/sidebar",
|
||||||
|
headers=test_user_headers("viewer"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
groups = payload["groups"]
|
||||||
|
assert groups
|
||||||
|
workspace = next(
|
||||||
|
group for group in groups if group["label"] == "Workspace")
|
||||||
|
workspace_links = workspace["links"]
|
||||||
|
assert [link["label"] for link in workspace_links] == ["Projects"]
|
||||||
|
assert workspace_links[0]["children"] == []
|
||||||
|
assert payload["roles"] == ["viewer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_sidebar_includes_contextual_urls_when_ids_provided(
|
||||||
|
client: TestClient,
|
||||||
|
seed_calculation_navigation: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
seed_calculation_navigation()
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/navigation/sidebar",
|
||||||
|
params={"project_id": "5", "scenario_id": "11"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
|
||||||
|
workspace = next(
|
||||||
|
group for group in payload["groups"] if group["label"] == "Workspace")
|
||||||
|
projects = workspace["links"][0]
|
||||||
|
capex_link = next(
|
||||||
|
link for link in projects["children"] if link["label"] == "Capex Planner")
|
||||||
|
|
||||||
|
assert capex_link["href"].endswith(
|
||||||
|
"/calculations/projects/5/scenarios/11/calculations/capex"
|
||||||
|
)
|
||||||
|
assert capex_link["match_prefix"] == "/calculations/capex"
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
class TestScenarioLifecycle:
|
class TestScenarioLifecycle:
|
||||||
def test_scenario_lifecycle_flow(self, client: TestClient) -> None:
|
def test_scenario_lifecycle_flow(
|
||||||
|
self,
|
||||||
|
client: TestClient,
|
||||||
|
scenario_calculation_url: Callable[[str, int, int], str],
|
||||||
|
) -> None:
|
||||||
# Create a project to attach scenarios to
|
# Create a project to attach scenarios to
|
||||||
project_response = client.post(
|
project_response = client.post(
|
||||||
"/projects",
|
"/projects",
|
||||||
@@ -36,7 +42,7 @@ class TestScenarioLifecycle:
|
|||||||
project_detail = client.get(f"/projects/{project_id}/view")
|
project_detail = client.get(f"/projects/{project_id}/view")
|
||||||
assert project_detail.status_code == 200
|
assert project_detail.status_code == 200
|
||||||
assert "Lifecycle Scenario" in project_detail.text
|
assert "Lifecycle Scenario" in project_detail.text
|
||||||
assert "<td>Draft</td>" in project_detail.text
|
assert '<span class="status-pill status-pill--draft">Draft</span>' in project_detail.text
|
||||||
|
|
||||||
# Update the scenario through the HTML form
|
# Update the scenario through the HTML form
|
||||||
form_response = client.post(
|
form_response = client.post(
|
||||||
@@ -61,16 +67,28 @@ class TestScenarioLifecycle:
|
|||||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||||
assert scenario_detail.status_code == 200
|
assert scenario_detail.status_code == 200
|
||||||
assert "Lifecycle Scenario Revised" in scenario_detail.text
|
assert "Lifecycle Scenario Revised" in scenario_detail.text
|
||||||
assert "Status: Active" in scenario_detail.text
|
assert "<p class=\"metric-value status-pill status-pill--active\">Active</p>" in scenario_detail.text
|
||||||
assert "CAD" in scenario_detail.text
|
assert "CAD" in scenario_detail.text
|
||||||
assert "Electricity" in scenario_detail.text
|
assert "Electricity" in scenario_detail.text
|
||||||
assert "Revised scenario assumptions" in scenario_detail.text
|
assert "Revised scenario assumptions" in scenario_detail.text
|
||||||
|
scenario_opex_url = scenario_calculation_url(
|
||||||
|
"calculations.scenario_opex_form",
|
||||||
|
project_id,
|
||||||
|
scenario_id,
|
||||||
|
)
|
||||||
|
scenario_capex_url = scenario_calculation_url(
|
||||||
|
"calculations.scenario_capex_form",
|
||||||
|
project_id,
|
||||||
|
scenario_id,
|
||||||
|
)
|
||||||
|
assert f'href="{scenario_opex_url}"' in scenario_detail.text
|
||||||
|
assert f'href="{scenario_capex_url}"' in scenario_detail.text
|
||||||
|
|
||||||
# Project detail page should show the scenario as active with updated currency/resource
|
# Project detail page should show the scenario as active with updated currency/resource
|
||||||
project_detail = client.get(f"/projects/{project_id}/view")
|
project_detail = client.get(f"/projects/{project_id}/view")
|
||||||
assert "<td>Active</td>" in project_detail.text
|
assert '<span class="status-pill status-pill--active">Active</span>' in project_detail.text
|
||||||
assert "<td>CAD</td>" in project_detail.text
|
assert 'CAD' in project_detail.text
|
||||||
assert "<td>Electricity</td>" in project_detail.text
|
assert 'Electricity' in project_detail.text
|
||||||
|
|
||||||
# Attempt to update the scenario with invalid currency to trigger validation error
|
# Attempt to update the scenario with invalid currency to trigger validation error
|
||||||
invalid_update = client.put(
|
invalid_update = client.put(
|
||||||
@@ -84,6 +102,8 @@ class TestScenarioLifecycle:
|
|||||||
# Scenario detail should still show the previous (valid) currency
|
# Scenario detail should still show the previous (valid) currency
|
||||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||||
assert "CAD" in scenario_detail.text
|
assert "CAD" in scenario_detail.text
|
||||||
|
assert f'href="{scenario_opex_url}"' in scenario_detail.text
|
||||||
|
assert f'href="{scenario_capex_url}"' in scenario_detail.text
|
||||||
|
|
||||||
# Archive the scenario through the API
|
# Archive the scenario through the API
|
||||||
archive_response = client.put(
|
archive_response = client.put(
|
||||||
@@ -95,10 +115,18 @@ class TestScenarioLifecycle:
|
|||||||
|
|
||||||
# Scenario detail reflects archived status
|
# Scenario detail reflects archived status
|
||||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||||
assert "Status: Archived" in scenario_detail.text
|
assert '<p class="metric-value status-pill status-pill--archived">Archived</p>' in scenario_detail.text
|
||||||
|
assert f'href="{scenario_opex_url}"' in scenario_detail.text
|
||||||
|
assert f'href="{scenario_capex_url}"' in scenario_detail.text
|
||||||
|
|
||||||
# Project detail metrics and table entries reflect the archived state
|
# Project detail metrics and table entries reflect the archived state
|
||||||
project_detail = client.get(f"/projects/{project_id}/view")
|
project_detail = client.get(f"/projects/{project_id}/view")
|
||||||
assert "<h2>Archived</h2>" in project_detail.text
|
assert "<h2>Archived</h2>" in project_detail.text
|
||||||
assert '<p class="metric-value">1</p>' in project_detail.text
|
assert '<p class="metric-value">1</p>' in project_detail.text
|
||||||
assert "<td>Archived</td>" in project_detail.text
|
assert "Archived" in project_detail.text
|
||||||
|
|
||||||
|
# Scenario portfolio view includes calculator links for each scenario entry
|
||||||
|
scenario_portfolio = client.get(f"/projects/{project_id}/scenarios/ui")
|
||||||
|
assert scenario_portfolio.status_code == 200
|
||||||
|
assert f'href="{scenario_opex_url}"' in scenario_portfolio.text
|
||||||
|
assert f'href="{scenario_capex_url}"' in scenario_portfolio.text
|
||||||
|
|||||||
148
tests/routes/test_navigation_routes.py
Normal file
148
tests/routes/test_navigation_routes.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Tuple, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from dependencies import (
|
||||||
|
get_auth_session,
|
||||||
|
get_navigation_service,
|
||||||
|
require_authenticated_user,
|
||||||
|
)
|
||||||
|
from models import User
|
||||||
|
from routes.navigation import router as navigation_router
|
||||||
|
from services.navigation import (
|
||||||
|
NavigationGroupDTO,
|
||||||
|
NavigationLinkDTO,
|
||||||
|
NavigationService,
|
||||||
|
NavigationSidebarDTO,
|
||||||
|
)
|
||||||
|
from services.session import AuthSession, SessionTokens
|
||||||
|
|
||||||
|
|
||||||
|
class StubNavigationService:
|
||||||
|
def __init__(self, payload: NavigationSidebarDTO) -> None:
|
||||||
|
self._payload = payload
|
||||||
|
self.received_call: dict[str, object] | None = None
|
||||||
|
|
||||||
|
def build_sidebar(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
session: AuthSession,
|
||||||
|
request,
|
||||||
|
include_disabled: bool = False,
|
||||||
|
) -> NavigationSidebarDTO:
|
||||||
|
self.received_call = {
|
||||||
|
"session": session,
|
||||||
|
"request": request,
|
||||||
|
"include_disabled": include_disabled,
|
||||||
|
}
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def navigation_client() -> Tuple[TestClient, StubNavigationService, AuthSession]:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(navigation_router)
|
||||||
|
|
||||||
|
link_dto = NavigationLinkDTO(
|
||||||
|
id=10,
|
||||||
|
label="Projects",
|
||||||
|
href="/projects",
|
||||||
|
match_prefix="/projects",
|
||||||
|
icon=None,
|
||||||
|
tooltip=None,
|
||||||
|
is_external=False,
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
group_dto = NavigationGroupDTO(
|
||||||
|
id=5,
|
||||||
|
label="Workspace",
|
||||||
|
icon="home",
|
||||||
|
tooltip=None,
|
||||||
|
links=[link_dto],
|
||||||
|
)
|
||||||
|
payload = NavigationSidebarDTO(groups=[group_dto], roles=("viewer",))
|
||||||
|
service = StubNavigationService(payload)
|
||||||
|
|
||||||
|
user = cast(User, object())
|
||||||
|
session = AuthSession(
|
||||||
|
tokens=SessionTokens(
|
||||||
|
access_token=secrets.token_urlsafe(16), refresh_token=None),
|
||||||
|
user=user,
|
||||||
|
role_slugs=("viewer",),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides[require_authenticated_user] = lambda: user
|
||||||
|
app.dependency_overrides[get_auth_session] = lambda: session
|
||||||
|
app.dependency_overrides[get_navigation_service] = lambda: cast(
|
||||||
|
NavigationService, service)
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
return client, service, session
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sidebar_navigation_returns_payload(
|
||||||
|
navigation_client: Tuple[TestClient, StubNavigationService, AuthSession]
|
||||||
|
) -> None:
|
||||||
|
client, service, session = navigation_client
|
||||||
|
|
||||||
|
response = client.get("/navigation/sidebar")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert data["roles"] == ["viewer"]
|
||||||
|
assert data["groups"][0]["label"] == "Workspace"
|
||||||
|
assert data["groups"][0]["links"][0]["href"] == "/projects"
|
||||||
|
assert "generated_at" in data
|
||||||
|
datetime.fromisoformat(data["generated_at"])
|
||||||
|
|
||||||
|
assert service.received_call is not None
|
||||||
|
assert service.received_call["session"] is session
|
||||||
|
assert service.received_call["request"] is not None
|
||||||
|
assert service.received_call["include_disabled"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_sidebar_navigation_requires_authentication() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(navigation_router)
|
||||||
|
|
||||||
|
link_dto = NavigationLinkDTO(
|
||||||
|
id=1,
|
||||||
|
label="Placeholder",
|
||||||
|
href="/placeholder",
|
||||||
|
match_prefix="/placeholder",
|
||||||
|
icon=None,
|
||||||
|
tooltip=None,
|
||||||
|
is_external=False,
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
group_dto = NavigationGroupDTO(
|
||||||
|
id=1,
|
||||||
|
label="Group",
|
||||||
|
icon=None,
|
||||||
|
tooltip=None,
|
||||||
|
links=[link_dto],
|
||||||
|
)
|
||||||
|
payload = NavigationSidebarDTO(groups=[group_dto], roles=("anonymous",))
|
||||||
|
service = StubNavigationService(payload)
|
||||||
|
|
||||||
|
def _deny() -> User:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
app.dependency_overrides[get_navigation_service] = lambda: cast(
|
||||||
|
NavigationService, service)
|
||||||
|
app.dependency_overrides[get_auth_session] = AuthSession.anonymous
|
||||||
|
app.dependency_overrides[require_authenticated_user] = _deny
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
response = client.get("/navigation/sidebar")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["detail"] == "Not authenticated"
|
||||||
@@ -35,11 +35,16 @@ class FakeState:
|
|||||||
] = field(default_factory=dict)
|
] = field(default_factory=dict)
|
||||||
financial_inputs: dict[Tuple[int, str],
|
financial_inputs: dict[Tuple[int, str],
|
||||||
Dict[str, Any]] = field(default_factory=dict)
|
Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
navigation_groups: dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
navigation_links: dict[Tuple[int, str],
|
||||||
|
Dict[str, Any]] = field(default_factory=dict)
|
||||||
sequences: Dict[str, int] = field(default_factory=lambda: {
|
sequences: Dict[str, int] = field(default_factory=lambda: {
|
||||||
"users": 0,
|
"users": 0,
|
||||||
"projects": 0,
|
"projects": 0,
|
||||||
"scenarios": 0,
|
"scenarios": 0,
|
||||||
"financial_inputs": 0,
|
"financial_inputs": 0,
|
||||||
|
"navigation_groups": 0,
|
||||||
|
"navigation_links": 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +55,9 @@ class FakeResult:
|
|||||||
def fetchone(self) -> Any | None:
|
def fetchone(self) -> Any | None:
|
||||||
return self._rows[0] if self._rows else None
|
return self._rows[0] if self._rows else None
|
||||||
|
|
||||||
|
def fetchall(self) -> list[Any]:
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
|
||||||
class FakeConnection:
|
class FakeConnection:
|
||||||
def __init__(self, state: FakeState) -> None:
|
def __init__(self, state: FakeState) -> None:
|
||||||
@@ -105,6 +113,13 @@ class FakeConnection:
|
|||||||
rows = [SimpleNamespace(id=record["id"])] if record else []
|
rows = [SimpleNamespace(id=record["id"])] if record else []
|
||||||
return FakeResult(rows)
|
return FakeResult(rows)
|
||||||
|
|
||||||
|
if lower_sql.startswith("select name from roles"):
|
||||||
|
rows = [
|
||||||
|
SimpleNamespace(name=record["name"])
|
||||||
|
for record in self.state.roles.values()
|
||||||
|
]
|
||||||
|
return FakeResult(rows)
|
||||||
|
|
||||||
if lower_sql.startswith("insert into user_roles"):
|
if lower_sql.startswith("insert into user_roles"):
|
||||||
key = (int(params["user_id"]), int(params["role_id"]))
|
key = (int(params["user_id"]), int(params["role_id"]))
|
||||||
self.state.user_roles.add(key)
|
self.state.user_roles.add(key)
|
||||||
@@ -171,6 +186,67 @@ class FakeConnection:
|
|||||||
rows = [SimpleNamespace(id=scenario["id"])] if scenario else []
|
rows = [SimpleNamespace(id=scenario["id"])] if scenario else []
|
||||||
return FakeResult(rows)
|
return FakeResult(rows)
|
||||||
|
|
||||||
|
if lower_sql.startswith("insert into navigation_groups"):
|
||||||
|
slug = params["slug"]
|
||||||
|
record = self.state.navigation_groups.get(slug)
|
||||||
|
if record is None:
|
||||||
|
self.state.sequences["navigation_groups"] += 1
|
||||||
|
record = {
|
||||||
|
"id": self.state.sequences["navigation_groups"],
|
||||||
|
"slug": slug,
|
||||||
|
}
|
||||||
|
record.update(
|
||||||
|
label=params["label"],
|
||||||
|
sort_order=int(params.get("sort_order", 0)),
|
||||||
|
icon=params.get("icon"),
|
||||||
|
tooltip=params.get("tooltip"),
|
||||||
|
is_enabled=bool(params.get("is_enabled", True)),
|
||||||
|
)
|
||||||
|
self.state.navigation_groups[slug] = record
|
||||||
|
return FakeResult([])
|
||||||
|
|
||||||
|
if lower_sql.startswith("select id from navigation_groups where slug"):
|
||||||
|
slug = params["slug"]
|
||||||
|
record = self.state.navigation_groups.get(slug)
|
||||||
|
rows = [SimpleNamespace(id=record["id"])] if record else []
|
||||||
|
return FakeResult(rows)
|
||||||
|
|
||||||
|
if lower_sql.startswith("insert into navigation_links"):
|
||||||
|
group_id = int(params["group_id"])
|
||||||
|
slug = params["slug"]
|
||||||
|
key = (group_id, slug)
|
||||||
|
record = self.state.navigation_links.get(key)
|
||||||
|
if record is None:
|
||||||
|
self.state.sequences["navigation_links"] += 1
|
||||||
|
record = {
|
||||||
|
"id": self.state.sequences["navigation_links"],
|
||||||
|
"group_id": group_id,
|
||||||
|
"slug": slug,
|
||||||
|
}
|
||||||
|
record.update(
|
||||||
|
parent_link_id=(int(params["parent_link_id"]) if params.get(
|
||||||
|
"parent_link_id") is not None else None),
|
||||||
|
label=params["label"],
|
||||||
|
route_name=params.get("route_name"),
|
||||||
|
href_override=params.get("href_override"),
|
||||||
|
match_prefix=params.get("match_prefix"),
|
||||||
|
sort_order=int(params.get("sort_order", 0)),
|
||||||
|
icon=params.get("icon"),
|
||||||
|
tooltip=params.get("tooltip"),
|
||||||
|
required_roles=list(params.get("required_roles") or []),
|
||||||
|
is_enabled=bool(params.get("is_enabled", True)),
|
||||||
|
is_external=bool(params.get("is_external", False)),
|
||||||
|
)
|
||||||
|
self.state.navigation_links[key] = record
|
||||||
|
return FakeResult([])
|
||||||
|
|
||||||
|
if lower_sql.startswith("select id from navigation_links where group_id"):
|
||||||
|
group_id = int(params["group_id"])
|
||||||
|
slug = params["slug"]
|
||||||
|
record = self.state.navigation_links.get((group_id, slug))
|
||||||
|
rows = [SimpleNamespace(id=record["id"])] if record else []
|
||||||
|
return FakeResult(rows)
|
||||||
|
|
||||||
if lower_sql.startswith("insert into financial_inputs"):
|
if lower_sql.startswith("insert into financial_inputs"):
|
||||||
key = (int(params["scenario_id"]), params["name"])
|
key = (int(params["scenario_id"]), params["name"])
|
||||||
record = self.state.financial_inputs.get(key)
|
record = self.state.financial_inputs.get(key)
|
||||||
|
|||||||
250
tests/services/test_navigation_service.py
Normal file
250
tests/services/test_navigation_service.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Iterable, List, cast
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from services.navigation import NavigationService
|
||||||
|
from services.repositories import NavigationRepository
|
||||||
|
from services.session import AuthSession, SessionTokens
|
||||||
|
from models import User
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StubNavigationLink:
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
label: str
|
||||||
|
parent_link_id: int | None = None
|
||||||
|
route_name: str | None = None
|
||||||
|
href_override: str | None = None
|
||||||
|
match_prefix: str | None = None
|
||||||
|
sort_order: int = 0
|
||||||
|
icon: str | None = None
|
||||||
|
tooltip: str | None = None
|
||||||
|
required_roles: List[str] = field(default_factory=list)
|
||||||
|
is_enabled: bool = True
|
||||||
|
is_external: bool = False
|
||||||
|
children: List["StubNavigationLink"] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StubNavigationGroup:
|
||||||
|
id: int
|
||||||
|
slug: str
|
||||||
|
label: str
|
||||||
|
sort_order: int = 0
|
||||||
|
icon: str | None = None
|
||||||
|
tooltip: str | None = None
|
||||||
|
is_enabled: bool = True
|
||||||
|
links: List[StubNavigationLink] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class StubNavigationRepository(NavigationRepository):
|
||||||
|
def __init__(self, groups: Iterable[StubNavigationGroup]) -> None:
|
||||||
|
super().__init__(session=None) # type: ignore[arg-type]
|
||||||
|
self._groups = list(groups)
|
||||||
|
|
||||||
|
def list_groups_with_links(self, *, include_disabled: bool = False):
|
||||||
|
if include_disabled:
|
||||||
|
return list(self._groups)
|
||||||
|
return [group for group in self._groups if group.is_enabled]
|
||||||
|
|
||||||
|
|
||||||
|
class StubRequest:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
path_params: Dict[str, str] | None = None,
|
||||||
|
query_params: Dict[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.path_params = path_params or {}
|
||||||
|
self.query_params = query_params or {}
|
||||||
|
self._url_for_calls: List[tuple[str, Dict[str, str]]] = []
|
||||||
|
|
||||||
|
def url_for(self, name: str, **params: str) -> str:
|
||||||
|
self._url_for_calls.append((name, params))
|
||||||
|
if params:
|
||||||
|
suffix = "_".join(f"{key}-{value}" for key,
|
||||||
|
value in sorted(params.items()))
|
||||||
|
return f"/{name}/{suffix}"
|
||||||
|
return f"/{name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url_for_calls(self) -> List[tuple[str, Dict[str, str]]]:
|
||||||
|
return list(self._url_for_calls)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(*, roles: Iterable[str], authenticated: bool = True) -> AuthSession:
|
||||||
|
tokens = SessionTokens(
|
||||||
|
access_token="token" if authenticated else None, refresh_token=None)
|
||||||
|
user = cast(User, object()) if authenticated else None
|
||||||
|
session = AuthSession(tokens=tokens, user=user, role_slugs=tuple(roles))
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sidebar_filters_links_by_role():
|
||||||
|
visible_link = StubNavigationLink(
|
||||||
|
id=1,
|
||||||
|
slug="projects",
|
||||||
|
label="Projects",
|
||||||
|
href_override="/projects",
|
||||||
|
required_roles=["viewer"],
|
||||||
|
)
|
||||||
|
hidden_link = StubNavigationLink(
|
||||||
|
id=2,
|
||||||
|
slug="admin",
|
||||||
|
label="Admin",
|
||||||
|
href_override="/admin",
|
||||||
|
required_roles=["admin"],
|
||||||
|
)
|
||||||
|
group = StubNavigationGroup(id=1, slug="workspace", label="Workspace", links=[
|
||||||
|
visible_link, hidden_link])
|
||||||
|
|
||||||
|
service = NavigationService(StubNavigationRepository([group]))
|
||||||
|
dto = service.build_sidebar(
|
||||||
|
session=_session(roles=["viewer"]),
|
||||||
|
request=cast(Request, StubRequest()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(dto.groups) == 1
|
||||||
|
assert [link.label for link in dto.groups[0].links] == ["Projects"]
|
||||||
|
assert dto.roles == ("viewer",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sidebar_appends_anonymous_role_for_guests():
|
||||||
|
link = StubNavigationLink(
|
||||||
|
id=1, slug="help", label="Help", href_override="/help")
|
||||||
|
group = StubNavigationGroup(
|
||||||
|
id=1, slug="account", label="Account", links=[link])
|
||||||
|
|
||||||
|
service = NavigationService(StubNavigationRepository([group]))
|
||||||
|
dto = service.build_sidebar(session=AuthSession.anonymous(), request=None)
|
||||||
|
|
||||||
|
assert dto.roles[-1] == "anonymous"
|
||||||
|
assert dto.groups[0].links[0].href.startswith("/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sidebar_resolves_profitability_link_with_context():
|
||||||
|
link = StubNavigationLink(
|
||||||
|
id=1,
|
||||||
|
slug="profitability",
|
||||||
|
label="Profitability",
|
||||||
|
route_name="calculations.profitability_form",
|
||||||
|
href_override="/calculations/profitability",
|
||||||
|
)
|
||||||
|
group = StubNavigationGroup(
|
||||||
|
id=99, slug="insights", label="Insights", links=[link])
|
||||||
|
|
||||||
|
request = StubRequest(path_params={"project_id": "7", "scenario_id": "42"})
|
||||||
|
service = NavigationService(StubNavigationRepository([group]))
|
||||||
|
|
||||||
|
dto = service.build_sidebar(
|
||||||
|
session=_session(roles=["viewer"]),
|
||||||
|
request=cast(Request, request),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert dto.groups[0].links[0].href == "/calculations.profitability_form/project_id-7_scenario_id-42"
|
||||||
|
assert request.url_for_calls[0][0] == "calculations.profitability_form"
|
||||||
|
assert request.url_for_calls[0][1] == {
|
||||||
|
"project_id": "7", "scenario_id": "42"}
|
||||||
|
assert dto.groups[0].links[0].match_prefix == dto.groups[0].links[0].href
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sidebar_resolves_opex_link_with_context():
|
||||||
|
link = StubNavigationLink(
|
||||||
|
id=2,
|
||||||
|
slug="opex",
|
||||||
|
label="Opex",
|
||||||
|
route_name="calculations.opex_form",
|
||||||
|
href_override="/calculations/opex",
|
||||||
|
)
|
||||||
|
group = StubNavigationGroup(
|
||||||
|
id=5, slug="workspace", label="Workspace", links=[link])
|
||||||
|
|
||||||
|
request = StubRequest(path_params={"project_id": "3", "scenario_id": "9"})
|
||||||
|
service = NavigationService(StubNavigationRepository([group]))
|
||||||
|
|
||||||
|
dto = service.build_sidebar(
|
||||||
|
session=_session(roles=["analyst"]),
|
||||||
|
request=cast(Request, request),
|
||||||
|
)
|
||||||
|
|
||||||
|
href = dto.groups[0].links[0].href
|
||||||
|
assert href == "/calculations.opex_form/project_id-3_scenario_id-9"
|
||||||
|
assert request.url_for_calls[0][0] == "calculations.opex_form"
|
||||||
|
assert request.url_for_calls[0][1] == {
|
||||||
|
"project_id": "3", "scenario_id": "9"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sidebar_uses_href_override_when_calculator_context_missing():
|
||||||
|
class ParamAwareStubRequest(StubRequest):
|
||||||
|
# type: ignore[override]
|
||||||
|
def url_for(self, name: str, **params: str) -> str:
|
||||||
|
if name in {
|
||||||
|
"calculations.opex_form",
|
||||||
|
"calculations.capex_form",
|
||||||
|
} and not params:
|
||||||
|
self._url_for_calls.append((name, params))
|
||||||
|
raise RuntimeError("missing params")
|
||||||
|
return super().url_for(name, **params)
|
||||||
|
|
||||||
|
link = StubNavigationLink(
|
||||||
|
id=3,
|
||||||
|
slug="capex",
|
||||||
|
label="Capex",
|
||||||
|
route_name="calculations.capex_form",
|
||||||
|
href_override="/calculations/capex",
|
||||||
|
)
|
||||||
|
group = StubNavigationGroup(
|
||||||
|
id=6, slug="workspace", label="Workspace", links=[link])
|
||||||
|
|
||||||
|
request = ParamAwareStubRequest()
|
||||||
|
service = NavigationService(StubNavigationRepository([group]))
|
||||||
|
|
||||||
|
dto = service.build_sidebar(
|
||||||
|
session=_session(roles=["analyst"]),
|
||||||
|
request=cast(Request, request),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert dto.groups[0].links[0].href == "/calculations/capex"
|
||||||
|
assert request.url_for_calls[-1][0] == "calculations.capex_form"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sidebar_skips_disabled_links_unless_included():
|
||||||
|
enabled_link = StubNavigationLink(
|
||||||
|
id=1,
|
||||||
|
slug="projects",
|
||||||
|
label="Projects",
|
||||||
|
href_override="/projects",
|
||||||
|
)
|
||||||
|
disabled_link = StubNavigationLink(
|
||||||
|
id=2,
|
||||||
|
slug="reports",
|
||||||
|
label="Reports",
|
||||||
|
href_override="/reports",
|
||||||
|
is_enabled=False,
|
||||||
|
)
|
||||||
|
group = StubNavigationGroup(
|
||||||
|
id=5,
|
||||||
|
slug="workspace",
|
||||||
|
label="Workspace",
|
||||||
|
links=[enabled_link, disabled_link],
|
||||||
|
)
|
||||||
|
|
||||||
|
service = NavigationService(StubNavigationRepository([group]))
|
||||||
|
|
||||||
|
default_sidebar = service.build_sidebar(
|
||||||
|
session=_session(roles=["viewer"]),
|
||||||
|
request=cast(Request, StubRequest()),
|
||||||
|
)
|
||||||
|
assert [link.label for link in default_sidebar.groups[0].links] == ["Projects"]
|
||||||
|
|
||||||
|
full_sidebar = service.build_sidebar(
|
||||||
|
session=_session(roles=["viewer"]),
|
||||||
|
request=cast(Request, StubRequest()),
|
||||||
|
include_disabled=True,
|
||||||
|
)
|
||||||
|
assert [link.label for link in full_sidebar.groups[0].links] == [
|
||||||
|
"Projects", "Reports"]
|
||||||
@@ -11,8 +11,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from models import Role, User, UserRole
|
from models import Role, User, UserRole
|
||||||
from dependencies import get_auth_session, require_current_user
|
from dependencies import get_auth_session, get_jwt_settings, require_current_user
|
||||||
from services.security import hash_password
|
from services.security import decode_access_token, hash_password
|
||||||
from services.session import AuthSession, SessionTokens
|
from services.session import AuthSession, SessionTokens
|
||||||
from tests.utils.security import random_password, random_token
|
from tests.utils.security import random_password, random_token
|
||||||
|
|
||||||
@@ -334,6 +334,7 @@ class TestLoginFlowEndToEnd:
|
|||||||
|
|
||||||
# Override to anonymous for login
|
# Override to anonymous for login
|
||||||
app = cast(FastAPI, client.app)
|
app = cast(FastAPI, client.app)
|
||||||
|
original_override = app.dependency_overrides.get(get_auth_session)
|
||||||
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
|
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -347,14 +348,21 @@ class TestLoginFlowEndToEnd:
|
|||||||
"location") == "http://testserver/"
|
"location") == "http://testserver/"
|
||||||
set_cookie_header = login_response.headers.get("set-cookie", "")
|
set_cookie_header = login_response.headers.get("set-cookie", "")
|
||||||
assert "calminer_access_token=" in set_cookie_header
|
assert "calminer_access_token=" in set_cookie_header
|
||||||
|
|
||||||
# Now with cookies, GET / should show dashboard
|
|
||||||
dashboard_response = client.get("/")
|
|
||||||
assert dashboard_response.status_code == 200
|
|
||||||
assert "Dashboard" in dashboard_response.text or "metrics" in dashboard_response.text
|
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.pop(get_auth_session, None)
|
app.dependency_overrides.pop(get_auth_session, None)
|
||||||
|
|
||||||
|
access_cookie = client.cookies.get("calminer_access_token")
|
||||||
|
refresh_cookie = client.cookies.get("calminer_refresh_token")
|
||||||
|
assert access_cookie, "Access token cookie was not set"
|
||||||
|
assert refresh_cookie, "Refresh token cookie was not set"
|
||||||
|
|
||||||
|
jwt_settings = get_jwt_settings()
|
||||||
|
payload = decode_access_token(access_cookie, jwt_settings)
|
||||||
|
assert payload.sub == str(user.id)
|
||||||
|
assert payload.scopes == ["auth"], "Unexpected access token scopes"
|
||||||
|
if original_override is not None:
|
||||||
|
app.dependency_overrides[get_auth_session] = original_override
|
||||||
|
|
||||||
def test_logout_redirects_to_login_and_clears_session(self, client: TestClient) -> None:
|
def test_logout_redirects_to_login_and_clears_session(self, client: TestClient) -> None:
|
||||||
# Assuming authenticated from conftest
|
# Assuming authenticated from conftest
|
||||||
logout_response = client.get("/logout", follow_redirects=False)
|
logout_response = client.get("/logout", follow_redirects=False)
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ class TestAuthenticationRequirements:
|
|||||||
|
|
||||||
def test_ui_project_list_requires_login(self, client, auth_session_context):
|
def test_ui_project_list_requires_login(self, client, auth_session_context):
|
||||||
with auth_session_context(None):
|
with auth_session_context(None):
|
||||||
response = client.get("/projects/ui")
|
response = client.get("/projects/ui", follow_redirects=False)
|
||||||
|
assert response.status_code == status.HTTP_303_SEE_OTHER
|
||||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
assert response.headers["location"].endswith("/login")
|
||||||
|
|
||||||
|
|
||||||
class TestRoleRestrictions:
|
class TestRoleRestrictions:
|
||||||
@@ -194,7 +194,7 @@ class TestRoleRestrictions:
|
|||||||
|
|
||||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
assert response.json()[
|
assert response.json()[
|
||||||
"detail"] == "Insufficient role permissions for this action."
|
"detail"] == "Insufficient permissions for this action."
|
||||||
|
|
||||||
def test_ui_project_edit_accessible_to_manager(
|
def test_ui_project_edit_accessible_to_manager(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from main import app
|
from main import app
|
||||||
|
from scripts.init_db import init_db
|
||||||
|
|
||||||
|
|
||||||
def test_login_form_post_does_not_trigger_json_error():
|
def test_login_form_post_does_not_trigger_json_error():
|
||||||
@@ -8,6 +9,7 @@ def test_login_form_post_does_not_trigger_json_error():
|
|||||||
the JSON "Invalid JSON payload" error which indicates the middleware
|
the JSON "Invalid JSON payload" error which indicates the middleware
|
||||||
attempted to parse non-JSON bodies.
|
attempted to parse non-JSON bodies.
|
||||||
"""
|
"""
|
||||||
|
init_db()
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ def session(engine) -> Iterator[Session]:
|
|||||||
def test_project_scenario_cascade_deletes(session: Session) -> None:
|
def test_project_scenario_cascade_deletes(session: Session) -> None:
|
||||||
project = Project(name="Cascade Mine",
|
project = Project(name="Cascade Mine",
|
||||||
operation_type=MiningOperationType.OTHER)
|
operation_type=MiningOperationType.OTHER)
|
||||||
scenario_a = Scenario(
|
Scenario(
|
||||||
name="Base Case", status=ScenarioStatus.DRAFT, project=project)
|
name="Base Case", status=ScenarioStatus.DRAFT, project=project)
|
||||||
scenario_b = Scenario(
|
Scenario(
|
||||||
name="Expansion", status=ScenarioStatus.DRAFT, project=project)
|
name="Expansion", status=ScenarioStatus.DRAFT, project=project)
|
||||||
|
|
||||||
session.add(project)
|
session.add(project)
|
||||||
|
|||||||
Reference in New Issue
Block a user