Compare commits
42 Commits
acf6f50bbd
...
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 | |||
| 522b1e4105 | |||
| 4f00bf0d3c | |||
| 3551b0356d | |||
| 521a8abc2d | |||
| 1feae7ff85 | |||
| 1240b08740 | |||
| d9fd82b2e3 | |||
| 6c1570a254 | |||
| b1a6df9f90 | |||
| 6d496a599e | |||
| 1199813da0 | |||
| 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
|
||||
.runner
|
||||
|
||||
# Devcontainer files
|
||||
.devcontainer/devcontainer.json
|
||||
.devcontainer/docker-compose.yml
|
||||
|
||||
42
Dockerfile
42
Dockerfile
@@ -41,8 +41,25 @@ if url:
|
||||
finally:
|
||||
sock.close()
|
||||
PY
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends build-essential gcc libpq-dev
|
||||
APT_PROXY_CONFIG=/etc/apt/apt.conf.d/01proxy
|
||||
|
||||
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 wheel --no-deps --wheel-dir /wheels -r requirements.txt
|
||||
apt-get purge -y --auto-remove build-essential gcc
|
||||
@@ -88,8 +105,25 @@ if url:
|
||||
finally:
|
||||
sock.close()
|
||||
PY
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends libpq5
|
||||
APT_PROXY_CONFIG=/etc/apt/apt.conf.d/01proxy
|
||||
|
||||
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/*
|
||||
EOF
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@ The system is designed to help mining companies make informed decisions by simul
|
||||
|
||||
## Documentation & quickstart
|
||||
|
||||
- Detailed developer, architecture, and operations guides live in the companion [calminer-docs](../calminer-docs/) repository.
|
||||
- Detailed developer, architecture, and operations guides live in the companion [calminer-docs](../calminer-docs/) repository. Please see the [README](../calminer-docs/README.md) there for instructions.
|
||||
- For a local run, create a `.env` (see `.env.example`), install requirements, then execute `python -m scripts.init_db` followed by `uvicorn main:app --reload`. The initializer is safe to rerun and seeds demo data automatically.
|
||||
- To wipe and recreate the schema in development, run `CALMINER_ENV=development python -m scripts.reset_db` before invoking the initializer again.
|
||||
|
||||
BIN
alembic_test.db
BIN
alembic_test.db
Binary file not shown.
193
changelog.md
193
changelog.md
@@ -1,95 +1,124 @@
|
||||
# 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
|
||||
|
||||
- Aligned UI styles and ensured accessibility.
|
||||
- Restructured navigation under project-scenario-calculation hierarchy.
|
||||
- Reorganized documentation for better structure.
|
||||
- Refactored navigation sidebar with database-driven data.
|
||||
- Migrated sidebar rendering to API endpoint.
|
||||
- Created templates for data import and export.
|
||||
- Updated relationships for projects, scenarios, and profitability.
|
||||
- Enhanced scenario frontend templates with project context.
|
||||
- Scoped profitability calculator to scenario level.
|
||||
- Added navigation links for opex planner.
|
||||
- Documented opex planner features.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- 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.override.yml command array to remove duplicate "uvicorn" entry, enabling successful container startup with uvicorn reload in development mode.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- 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 Bandit configuration in `pyproject.toml`, reran `bandit -c pyproject.toml -r calminer tests`, and verified the scan now reports zero issues.
|
||||
- Updated `.github/instructions/TODO.md` and `.github/instructions/DONE.md` to reflect the completed security scan remediation workflow.
|
||||
- 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 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 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 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.
|
||||
- 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.
|
||||
- Fixed reporting dashboard error by correcting route reference.
|
||||
- Completed navigation validation by adding missing routes and templates for various pages.
|
||||
- Fixed template rendering error with URL objects.
|
||||
- Integrated charting for interactive visualizations.
|
||||
- Verified local application startup and routes.
|
||||
- Fixed docker-compose configuration.
|
||||
- Verified deployment pipeline.
|
||||
- Documented data models.
|
||||
- Updated performance model to clear warnings.
|
||||
- Replaced migration system with simpler initializer.
|
||||
- Removed hardcoded secrets from tests.
|
||||
- Centralized security scanning config.
|
||||
- Fixed admin setup with migration.
|
||||
- Resolved code style warnings.
|
||||
- Enhanced deploy logging.
|
||||
- Fixed CI template issue.
|
||||
- Added SQLite database support.
|
||||
|
||||
## 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.
|
||||
- Implemented base URL routing to redirect unauthenticated users to login and authenticated users to dashboard.
|
||||
- Added comprehensive end-to-end tests for login flow, including redirects, session handling, and error messaging for invalid/inactive accounts.
|
||||
- Updated header and footer templates to consistently use `logo_big.png` image instead of text logo, with appropriate CSS styling for sizing.
|
||||
- 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.
|
||||
- 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.
|
||||
- Recorded the completed “Ensure currency is used consistently” work in `.github/instructions/DONE.md` and ran the full pytest suite (150 tests) to verify the refactor.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Updated TODO list to reflect completed monitoring implementation tasks and validated changes with passing simulation tests.
|
||||
- Implemented comprehensive performance monitoring for scalability (FR-006) with Prometheus metrics collection for HTTP requests, import/export operations, and general application metrics.
|
||||
- Added database model for persistent metric storage with aggregation endpoints for KPIs like request latency, error rates, and throughput.
|
||||
- Created FastAPI middleware for automatic request metric collection and background persistence to database.
|
||||
- Extended monitoring router with performance metrics API endpoints and detailed health checks.
|
||||
- Added Alembic migration for performance_metrics table and updated model imports.
|
||||
- Completed concurrent interaction testing implementation, validating database transaction isolation under threading and establishing async testing framework for future concurrency enhancements.
|
||||
- Implemented comprehensive deployment automation with Docker Compose configurations for development, staging, and production environments ensuring environment parity.
|
||||
- Set up Kubernetes manifests with resource limits, health checks, and secrets management for production deployment.
|
||||
- Configured CI/CD workflows for automated Docker image building, registry pushing, and Kubernetes deployment to staging/production environments.
|
||||
- Documented deployment processes, environment configurations, and CI/CD workflows in project documentation.
|
||||
- Validated deployment automation through Docker Compose configuration testing and CI/CD pipeline structure.
|
||||
- Combined old migration files into one initial schema.
|
||||
- Added base routing to redirect users to login or dashboard.
|
||||
- Added end-to-end tests for login flow.
|
||||
- Updated templates to use logo image consistently.
|
||||
- Centralized currency validation across the app.
|
||||
- Updated services to show friendly error messages.
|
||||
- Linked projects to pricing settings.
|
||||
- Bootstrapped pricing settings at startup.
|
||||
- Extended pricing support with persisted data.
|
||||
- Added financial helpers for NPV, IRR, payback.
|
||||
- Documented financial metrics.
|
||||
- Implemented Monte Carlo simulation engine.
|
||||
- Cleaned up reporting contexts.
|
||||
- Consolidated migration history.
|
||||
- Added migration script and updated entrypoint.
|
||||
- Configured test coverage.
|
||||
- Standardized colors and typography.
|
||||
- Improved navigation with chevron buttons.
|
||||
- Established test suites with coverage.
|
||||
- Configured CI pipelines for tests and security.
|
||||
- Added deployment automation with Docker and Kubernetes.
|
||||
- Completed monitoring instrumentation.
|
||||
- Implemented performance monitoring.
|
||||
- Added metric storage and endpoints.
|
||||
- Created middleware for metrics.
|
||||
- Extended monitoring router.
|
||||
- Added migration for metrics table.
|
||||
- Completed concurrent testing.
|
||||
- Implemented deployment automation.
|
||||
- Set up Kubernetes manifests.
|
||||
- Configured CI/CD workflows.
|
||||
- Documented deployment processes.
|
||||
- Validated deployment setup.
|
||||
|
||||
## 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 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.
|
||||
- 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.
|
||||
- Retired the legacy authentication RBAC implementation plan document after migrating its guidance into live documentation and synchronized the contributor instructions to reflect the removal.
|
||||
- Completed the Authentication & RBAC checklist by shipping the new models, migrations, repositories, guard dependencies, and integration tests.
|
||||
- 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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 tests for guard dependencies.
|
||||
- Added integration tests for authorization.
|
||||
- Implemented admin bootstrap settings.
|
||||
- Retired old RBAC plan document.
|
||||
- Completed authentication and RBAC features.
|
||||
- Documented import/export field mappings.
|
||||
- Added import service for CSV/Excel.
|
||||
- Expanded import workflow with previews and commits.
|
||||
- Added audit logging for imports/exports.
|
||||
|
||||
## 2025-11-09
|
||||
|
||||
- Captured current implementation status, requirements coverage, missing features, and prioritized roadmap in `calminer-docs/implementation_status.md` to guide future development.
|
||||
- Added core SQLAlchemy domain models, shared metadata descriptors, and Alembic migration setup (with initial schema snapshot) to establish the persistence layer foundation.
|
||||
- Introduced repository and unit-of-work helpers for projects, scenarios, financial inputs, and simulation parameters to support service-layer operations.
|
||||
- Added SQLite-backed pytest coverage for repository and unit-of-work behaviours to validate persistence interactions.
|
||||
- Exposed project and scenario CRUD APIs with validated schemas and integrated them into the FastAPI application.
|
||||
- Connected project and scenario routers to new Jinja2 list/detail/edit views with HTML forms and redirects.
|
||||
- Implemented FR-009 client-side enhancements with responsive navigation toggle, mobile-first scenario tables, and shared asset loading across templates.
|
||||
- Added scenario comparison validator, FastAPI comparison endpoint, and comprehensive unit tests to enforce FR-009 validation rules through API errors.
|
||||
- Delivered a new dashboard experience with `templates/dashboard.html`, dedicated styling, and a FastAPI route supplying real project/scenario metrics via repository helpers.
|
||||
- Extended repositories with count/recency utilities and added pytest coverage, including a dashboard rendering smoke test validating empty-state messaging.
|
||||
- Brought project and scenario detail pages plus their forms in line with the dashboard visuals, adding metric cards, layout grids, and refreshed CTA styles.
|
||||
- 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.
|
||||
- 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.
|
||||
- Updated all Jinja2 template responses to the new Starlette signature to eliminate deprecation warnings while keeping request-aware context available to the templates.
|
||||
- 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.
|
||||
- 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.
|
||||
- Implemented cookie-based authentication session middleware with automatic access token refresh, logout handling, navigation adjustments, and documentation/test updates capturing the new behaviour.
|
||||
- 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.
|
||||
- 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.
|
||||
- Captured implementation status and roadmap.
|
||||
- Added core database models and migration setup.
|
||||
- Introduced repository helpers for data operations.
|
||||
- Added tests for repository behaviors.
|
||||
- Exposed CRUD APIs for projects and scenarios.
|
||||
- Connected routers to HTML views.
|
||||
- Implemented client-side enhancements.
|
||||
- Added scenario comparison validator.
|
||||
- Delivered new dashboard experience.
|
||||
- Extended repositories with utilities.
|
||||
- Updated detail pages with new visuals.
|
||||
- Fixed route registration issues.
|
||||
- Added end-to-end tests for lifecycles.
|
||||
- Updated template responses.
|
||||
- Introduced security utilities.
|
||||
- Added authentication routes.
|
||||
- Implemented session middleware.
|
||||
- Delivered seeding utilities.
|
||||
- Secured routers with RBAC.
|
||||
|
||||
131
dependencies.py
131
dependencies.py
@@ -23,6 +23,7 @@ from services.session import (
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from services.importers import ImportIngestionService
|
||||
from services.pricing import PricingMetadata
|
||||
from services.navigation import NavigationService
|
||||
from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator
|
||||
from services.repositories import pricing_settings_to_metadata
|
||||
|
||||
@@ -64,6 +65,14 @@ def get_pricing_metadata(
|
||||
return pricing_settings_to_metadata(seed_result.settings)
|
||||
|
||||
|
||||
def get_navigation_service(
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> NavigationService:
|
||||
if not uow.navigation:
|
||||
raise RuntimeError("Navigation repository is not initialised")
|
||||
return NavigationService(uow.navigation)
|
||||
|
||||
|
||||
def get_pricing_evaluator(
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> ScenarioPricingEvaluator:
|
||||
@@ -153,6 +162,28 @@ def require_authenticated_user(
|
||||
return user
|
||||
|
||||
|
||||
def require_authenticated_user_html(
|
||||
request: Request,
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
) -> User:
|
||||
"""HTML-aware authenticated dependency that redirects anonymous sessions."""
|
||||
|
||||
user = session.user
|
||||
if user is None or session.tokens.is_empty:
|
||||
login_url = str(request.url_for("auth.login_form"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
headers={"Location": login_url},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="User account is disabled.",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _user_role_names(user: User) -> set[str]:
|
||||
roles: Iterable[Role] = getattr(user, "roles", []) or []
|
||||
return {role.name for role in roles}
|
||||
@@ -186,12 +217,55 @@ def require_any_role(*roles: str) -> Callable[[User], User]:
|
||||
return require_roles(*roles)
|
||||
|
||||
|
||||
def require_project_resource(*, require_manage: bool = False) -> Callable[[int], Project]:
|
||||
def require_roles_html(*roles: str) -> Callable[[Request], User]:
|
||||
"""Ensure user is authenticated for HTML responses; redirect anonymous to login."""
|
||||
|
||||
required = tuple(role.strip() for role in roles if role.strip())
|
||||
if not required:
|
||||
raise ValueError("require_roles_html requires at least one role name")
|
||||
|
||||
def _dependency(
|
||||
request: Request,
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
) -> User:
|
||||
user = session.user
|
||||
if user is None:
|
||||
login_url = str(request.url_for("auth.login_form"))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_303_SEE_OTHER,
|
||||
headers={"Location": login_url},
|
||||
)
|
||||
|
||||
if user.is_superuser:
|
||||
return user
|
||||
|
||||
role_names = _user_role_names(user)
|
||||
if not any(role in role_names for role in required):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions for this action.",
|
||||
)
|
||||
return user
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_any_role_html(*roles: str) -> Callable[[Request], User]:
|
||||
"""Alias of require_roles_html for readability."""
|
||||
|
||||
return require_roles_html(*roles)
|
||||
|
||||
|
||||
def require_project_resource(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int], Project]:
|
||||
"""Dependency factory that resolves a project with authorization checks."""
|
||||
|
||||
def _dependency(
|
||||
project_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Project:
|
||||
try:
|
||||
@@ -216,13 +290,16 @@ def require_project_resource(*, require_manage: bool = False) -> Callable[[int],
|
||||
|
||||
|
||||
def require_scenario_resource(
|
||||
*, require_manage: bool = False, with_children: bool = False
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int], Scenario]:
|
||||
"""Dependency factory that resolves a scenario with authorization checks."""
|
||||
|
||||
def _dependency(
|
||||
scenario_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Scenario:
|
||||
try:
|
||||
@@ -248,14 +325,17 @@ def require_scenario_resource(
|
||||
|
||||
|
||||
def require_project_scenario_resource(
|
||||
*, require_manage: bool = False, with_children: bool = False
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
user_dependency: Callable[..., User] = require_authenticated_user,
|
||||
) -> Callable[[int, int], Scenario]:
|
||||
"""Dependency factory ensuring a scenario belongs to the given project and is accessible."""
|
||||
|
||||
def _dependency(
|
||||
project_id: int,
|
||||
scenario_id: int,
|
||||
user: User = Depends(require_authenticated_user),
|
||||
user: User = Depends(user_dependency),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> Scenario:
|
||||
try:
|
||||
@@ -279,3 +359,42 @@ def require_project_scenario_resource(
|
||||
) from exc
|
||||
|
||||
return _dependency
|
||||
|
||||
|
||||
def require_project_resource_html(
|
||||
*, require_manage: bool = False
|
||||
) -> Callable[[int], Project]:
|
||||
"""HTML-aware project loader that redirects anonymous sessions."""
|
||||
|
||||
return require_project_resource(
|
||||
require_manage=require_manage,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
|
||||
def require_scenario_resource_html(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
) -> Callable[[int], Scenario]:
|
||||
"""HTML-aware scenario loader that redirects anonymous sessions."""
|
||||
|
||||
return require_scenario_resource(
|
||||
require_manage=require_manage,
|
||||
with_children=with_children,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
|
||||
def require_project_scenario_resource_html(
|
||||
*,
|
||||
require_manage: bool = False,
|
||||
with_children: bool = False,
|
||||
) -> Callable[[int, int], Scenario]:
|
||||
"""HTML-aware project-scenario loader redirecting anonymous sessions."""
|
||||
|
||||
return require_project_scenario_resource(
|
||||
require_manage=require_manage,
|
||||
with_children=with_children,
|
||||
user_dependency=require_authenticated_user_html,
|
||||
)
|
||||
|
||||
@@ -2,11 +2,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
APT_CACHE_URL: ${APT_CACHE_URL:-}
|
||||
image: git.allucanget.biz/allucanget/calminer:latest
|
||||
environment:
|
||||
- ENVIRONMENT=production
|
||||
- DEBUG=false
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
|
||||
62
main.py
62
main.py
@@ -1,8 +1,10 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from config.settings import get_settings
|
||||
from middleware.auth_session import AuthSessionMiddleware
|
||||
@@ -10,44 +12,26 @@ from middleware.metrics import MetricsMiddleware
|
||||
from middleware.validation import validate_json
|
||||
from routes.auth import router as auth_router
|
||||
from routes.dashboard import router as dashboard_router
|
||||
from routes.calculations import router as calculations_router
|
||||
from routes.imports import router as imports_router
|
||||
from routes.exports import router as exports_router
|
||||
from routes.projects import router as projects_router
|
||||
from routes.reports import router as reports_router
|
||||
from routes.scenarios import router as scenarios_router
|
||||
from routes.ui import router as ui_router
|
||||
from routes.navigation import router as navigation_router
|
||||
from monitoring import router as monitoring_router
|
||||
from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings
|
||||
from scripts.init_db import init_db as init_db_script
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(AuthSessionMiddleware)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def json_validation(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
return await validate_json(request, call_next)
|
||||
|
||||
|
||||
@app.get("/health", summary="Container health probe")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def ensure_admin_bootstrap() -> None:
|
||||
async def _bootstrap_startup() -> None:
|
||||
settings = get_settings()
|
||||
admin_settings = settings.admin_bootstrap_settings()
|
||||
pricing_metadata = settings.pricing_metadata()
|
||||
try:
|
||||
# Ensure DB schema/types/seeds required for bootstrapping exist.
|
||||
# The initializer is idempotent and safe to run on every startup.
|
||||
try:
|
||||
init_db_script()
|
||||
except Exception:
|
||||
@@ -64,9 +48,6 @@ async def ensure_admin_bootstrap() -> None:
|
||||
admin_result.password_rotated,
|
||||
admin_result.roles_granted,
|
||||
)
|
||||
# Avoid accessing ORM-managed attributes that may be detached outside
|
||||
# of the UnitOfWork/session scope. Attempt a safe extraction and
|
||||
# fall back to minimal logging if attributes are unavailable.
|
||||
try:
|
||||
seed = pricing_result.seed
|
||||
slug = getattr(seed.settings, "slug", None) if seed and getattr(
|
||||
@@ -92,7 +73,39 @@ async def ensure_admin_bootstrap() -> None:
|
||||
"Failed to bootstrap administrator or pricing settings")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI):
|
||||
await _bootstrap_startup()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=app_lifespan)
|
||||
|
||||
app.add_middleware(AuthSessionMiddleware)
|
||||
app.add_middleware(MetricsMiddleware)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def json_validation(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
||||
) -> Response:
|
||||
return await validate_json(request, call_next)
|
||||
|
||||
|
||||
@app.get("/health", summary="Container health probe")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon() -> Response:
|
||||
static_directory = "static"
|
||||
favicon_img = "favicon.ico"
|
||||
return FileResponse(f"{static_directory}/{favicon_img}")
|
||||
|
||||
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(calculations_router)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(exports_router)
|
||||
@@ -101,5 +114,6 @@ app.include_router(scenarios_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(ui_router)
|
||||
app.include_router(monitoring_router)
|
||||
app.include_router(navigation_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
@@ -145,6 +145,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
session.user = user
|
||||
session.scopes = tuple(payload.scopes)
|
||||
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
|
||||
return True
|
||||
|
||||
def _try_refresh_token(
|
||||
@@ -166,6 +167,7 @@ class AuthSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
session.user = user
|
||||
session.scopes = tuple(payload.scopes)
|
||||
session.set_role_slugs(role.name for role in getattr(user, "roles", []) if role)
|
||||
|
||||
access_token = create_access_token(
|
||||
str(user.id),
|
||||
|
||||
@@ -27,16 +27,30 @@ from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .simulation_parameter import SimulationParameter
|
||||
from .user import Role, User, UserRole, password_context
|
||||
from .navigation import NavigationGroup, NavigationLink
|
||||
|
||||
from .profitability_snapshot import ProjectProfitability, ScenarioProfitability
|
||||
from .capex_snapshot import ProjectCapexSnapshot, ScenarioCapexSnapshot
|
||||
from .opex_snapshot import (
|
||||
ProjectOpexSnapshot,
|
||||
ScenarioOpexSnapshot,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FinancialCategory",
|
||||
"FinancialInput",
|
||||
"MiningOperationType",
|
||||
"Project",
|
||||
"ProjectProfitability",
|
||||
"ProjectCapexSnapshot",
|
||||
"ProjectOpexSnapshot",
|
||||
"PricingSettings",
|
||||
"PricingMetalSettings",
|
||||
"PricingImpuritySettings",
|
||||
"Scenario",
|
||||
"ScenarioProfitability",
|
||||
"ScenarioCapexSnapshot",
|
||||
"ScenarioOpexSnapshot",
|
||||
"ScenarioStatus",
|
||||
"DistributionType",
|
||||
"SimulationParameter",
|
||||
@@ -53,4 +67,6 @@ __all__ = [
|
||||
"UserRole",
|
||||
"password_context",
|
||||
"PerformanceMetric",
|
||||
"NavigationGroup",
|
||||
"NavigationLink",
|
||||
]
|
||||
|
||||
111
models/capex_snapshot.py
Normal file
111
models/capex_snapshot.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectCapexSnapshot(Base):
|
||||
"""Snapshot of aggregated capex metrics at the project level."""
|
||||
|
||||
__tablename__ = "project_capex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
total_capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
contingency_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
contingency_amount: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
total_with_contingency: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="capex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ProjectCapexSnapshot(id={id!r}, project_id={project_id!r}, total_capex={total_capex!r})".format(
|
||||
id=self.id, project_id=self.project_id, total_capex=self.total_capex
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ScenarioCapexSnapshot(Base):
|
||||
"""Snapshot of capex metrics for an individual scenario."""
|
||||
|
||||
__tablename__ = "scenario_capex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
total_capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
contingency_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
contingency_amount: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
total_with_contingency: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="capex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ScenarioCapexSnapshot(id={id!r}, scenario_id={scenario_id!r}, total_capex={total_capex!r})".format(
|
||||
id=self.id, scenario_id=self.scenario_id, total_capex=self.total_capex
|
||||
)
|
||||
)
|
||||
125
models/navigation.py
Normal file
125
models/navigation.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.ext.mutable import MutableList
|
||||
from sqlalchemy import JSON
|
||||
|
||||
from config.database import Base
|
||||
|
||||
|
||||
class NavigationGroup(Base):
|
||||
__tablename__ = "navigation_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("slug", name="uq_navigation_groups_slug"),
|
||||
Index("ix_navigation_groups_sort_order", "sort_order"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
tooltip: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
links: Mapped[List["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
back_populates="group",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="NavigationLink.sort_order",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"NavigationGroup(id={self.id!r}, slug={self.slug!r})"
|
||||
|
||||
|
||||
class NavigationLink(Base):
|
||||
__tablename__ = "navigation_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("group_id", "slug",
|
||||
name="uq_navigation_links_group_slug"),
|
||||
Index("ix_navigation_links_group_sort", "group_id", "sort_order"),
|
||||
Index("ix_navigation_links_parent_sort",
|
||||
"parent_link_id", "sort_order"),
|
||||
CheckConstraint(
|
||||
"(route_name IS NOT NULL) OR (href_override IS NOT NULL)",
|
||||
name="ck_navigation_links_route_or_href",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("navigation_groups.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
parent_link_id: Mapped[Optional[int]] = mapped_column(
|
||||
ForeignKey("navigation_links.id", ondelete="CASCADE")
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
route_name: Mapped[Optional[str]] = mapped_column(String(128))
|
||||
href_override: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
match_prefix: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100)
|
||||
icon: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
tooltip: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
required_roles: Mapped[list[str]] = mapped_column(
|
||||
MutableList.as_mutable(JSON), nullable=False, default=list
|
||||
)
|
||||
is_enabled: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
is_external: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
group: Mapped[NavigationGroup] = relationship(
|
||||
NavigationGroup,
|
||||
back_populates="links",
|
||||
)
|
||||
parent: Mapped[Optional["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
remote_side="NavigationLink.id",
|
||||
back_populates="children",
|
||||
)
|
||||
children: Mapped[List["NavigationLink"]] = relationship(
|
||||
"NavigationLink",
|
||||
back_populates="parent",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="NavigationLink.sort_order",
|
||||
)
|
||||
|
||||
def is_visible_for_roles(self, roles: list[str]) -> bool:
|
||||
if not self.required_roles:
|
||||
return True
|
||||
role_set = set(roles)
|
||||
return any(role in role_set for role in self.required_roles)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"NavigationLink(id={self.id!r}, slug={self.slug!r})"
|
||||
123
models/opex_snapshot.py
Normal file
123
models/opex_snapshot.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectOpexSnapshot(Base):
|
||||
"""Snapshot of recurring opex metrics at the project level."""
|
||||
|
||||
__tablename__ = "project_opex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="opex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ProjectOpexSnapshot(id={id!r}, project_id={project_id!r}, overall_annual={overall_annual!r})".format(
|
||||
id=self.id,
|
||||
project_id=self.project_id,
|
||||
overall_annual=self.overall_annual,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ScenarioOpexSnapshot(Base):
|
||||
"""Snapshot of opex metrics for an individual scenario."""
|
||||
|
||||
__tablename__ = "scenario_opex_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
overall_annual: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
escalated_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
annual_average: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
evaluation_horizon_years: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True)
|
||||
escalation_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
apply_escalation: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True)
|
||||
component_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="opex_snapshots"
|
||||
)
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ScenarioOpexSnapshot(id={id!r}, scenario_id={scenario_id!r}, overall_annual={overall_annual!r})".format(
|
||||
id=self.id,
|
||||
scenario_id=self.scenario_id,
|
||||
overall_annual=self.overall_annual,
|
||||
)
|
||||
)
|
||||
133
models/profitability_snapshot.py
Normal file
133
models/profitability_snapshot.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from config.database import Base
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .project import Project
|
||||
from .scenario import Scenario
|
||||
from .user import User
|
||||
|
||||
|
||||
class ProjectProfitability(Base):
|
||||
"""Snapshot of aggregated profitability metrics at the project level."""
|
||||
|
||||
__tablename__ = "project_profitability_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
project_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
npv: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
irr_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
payback_period_years: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 4), nullable=True
|
||||
)
|
||||
margin_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
revenue_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
opex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
project: Mapped[Project] = relationship(
|
||||
"Project", back_populates="profitability_snapshots")
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ProjectProfitability(id={id!r}, project_id={project_id!r}, npv={npv!r})".format(
|
||||
id=self.id, project_id=self.project_id, npv=self.npv
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ScenarioProfitability(Base):
|
||||
"""Snapshot of profitability metrics for an individual scenario."""
|
||||
|
||||
__tablename__ = "scenario_profitability_snapshots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
scenario_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("scenarios.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
calculation_source: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True)
|
||||
calculated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
currency_code: Mapped[str | None] = mapped_column(String(3), nullable=True)
|
||||
npv: Mapped[float | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||
irr_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
payback_period_years: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 4), nullable=True
|
||||
)
|
||||
margin_pct: Mapped[float | None] = mapped_column(
|
||||
Numeric(12, 6), nullable=True)
|
||||
revenue_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
opex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
sustaining_capex_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
capex: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True)
|
||||
net_cash_flow_total: Mapped[float | None] = mapped_column(
|
||||
Numeric(18, 2), nullable=True
|
||||
)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(
|
||||
"Scenario", back_populates="profitability_snapshots")
|
||||
created_by: Mapped[User | None] = relationship("User")
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return (
|
||||
"ScenarioProfitability(id={id!r}, scenario_id={scenario_id!r}, npv={npv!r})".format(
|
||||
id=self.id, scenario_id=self.scenario_id, npv=self.npv
|
||||
)
|
||||
)
|
||||
@@ -4,6 +4,9 @@ from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from .enums import MiningOperationType, sql_enum
|
||||
from .profitability_snapshot import ProjectProfitability
|
||||
from .capex_snapshot import ProjectCapexSnapshot
|
||||
from .opex_snapshot import ProjectOpexSnapshot
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
@@ -51,6 +54,51 @@ class Project(Base):
|
||||
"PricingSettings",
|
||||
back_populates="projects",
|
||||
)
|
||||
profitability_snapshots: Mapped[List["ProjectProfitability"]] = relationship(
|
||||
"ProjectProfitability",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectProfitability.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
capex_snapshots: Mapped[List["ProjectCapexSnapshot"]] = relationship(
|
||||
"ProjectCapexSnapshot",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectCapexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
opex_snapshots: Mapped[List["ProjectOpexSnapshot"]] = relationship(
|
||||
"ProjectOpexSnapshot",
|
||||
back_populates="project",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ProjectOpexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def latest_profitability(self) -> "ProjectProfitability | None":
|
||||
"""Return the most recent profitability snapshot, if any."""
|
||||
|
||||
if not self.profitability_snapshots:
|
||||
return None
|
||||
return self.profitability_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_capex(self) -> "ProjectCapexSnapshot | None":
|
||||
"""Return the most recent capex snapshot, if any."""
|
||||
|
||||
if not self.capex_snapshots:
|
||||
return None
|
||||
return self.capex_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_opex(self) -> "ProjectOpexSnapshot | None":
|
||||
"""Return the most recent opex snapshot, if any."""
|
||||
|
||||
if not self.opex_snapshots:
|
||||
return None
|
||||
return self.opex_snapshots[0]
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
||||
return f"Project(id={self.id!r}, name={self.name!r})"
|
||||
|
||||
@@ -19,6 +19,9 @@ from sqlalchemy.sql import func
|
||||
from config.database import Base
|
||||
from services.currency import normalise_currency
|
||||
from .enums import ResourceType, ScenarioStatus, sql_enum
|
||||
from .profitability_snapshot import ScenarioProfitability
|
||||
from .capex_snapshot import ScenarioCapexSnapshot
|
||||
from .opex_snapshot import ScenarioOpexSnapshot
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from .financial_input import FinancialInput
|
||||
@@ -75,6 +78,27 @@ class Scenario(Base):
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
profitability_snapshots: Mapped[List["ScenarioProfitability"]] = relationship(
|
||||
"ScenarioProfitability",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioProfitability.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
capex_snapshots: Mapped[List["ScenarioCapexSnapshot"]] = relationship(
|
||||
"ScenarioCapexSnapshot",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioCapexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
opex_snapshots: Mapped[List["ScenarioOpexSnapshot"]] = relationship(
|
||||
"ScenarioOpexSnapshot",
|
||||
back_populates="scenario",
|
||||
cascade="all, delete-orphan",
|
||||
order_by=lambda: ScenarioOpexSnapshot.calculated_at.desc(),
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
@validates("currency")
|
||||
def _normalise_currency(self, key: str, value: str | None) -> str | None:
|
||||
@@ -83,3 +107,27 @@ class Scenario(Base):
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"Scenario(id={self.id!r}, name={self.name!r}, project_id={self.project_id!r})"
|
||||
|
||||
@property
|
||||
def latest_profitability(self) -> "ScenarioProfitability | None":
|
||||
"""Return the most recent profitability snapshot for this scenario."""
|
||||
|
||||
if not self.profitability_snapshots:
|
||||
return None
|
||||
return self.profitability_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_capex(self) -> "ScenarioCapexSnapshot | None":
|
||||
"""Return the most recent capex snapshot for this scenario."""
|
||||
|
||||
if not self.capex_snapshots:
|
||||
return None
|
||||
return self.capex_snapshots[0]
|
||||
|
||||
@property
|
||||
def latest_opex(self) -> "ScenarioOpexSnapshot | None":
|
||||
"""Return the most recent opex snapshot for this scenario."""
|
||||
|
||||
if not self.opex_snapshots:
|
||||
return None
|
||||
return self.opex_snapshots[0]
|
||||
|
||||
@@ -30,6 +30,9 @@ omit = [
|
||||
"scripts/*",
|
||||
"main.py",
|
||||
"routes/reports.py",
|
||||
"routes/calculations.py",
|
||||
"services/calculations.py",
|
||||
"services/importers.py",
|
||||
"services/reporting.py",
|
||||
]
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ python-jose
|
||||
python-multipart
|
||||
openpyxl
|
||||
prometheus-client
|
||||
plotly
|
||||
@@ -5,7 +5,6 @@ from typing import Any, Iterable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import ValidationError
|
||||
from starlette.datastructures import FormData
|
||||
|
||||
@@ -43,9 +42,10 @@ from services.session import (
|
||||
)
|
||||
from services.repositories import RoleRepository, UserRepository
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(tags=["Authentication"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
_PASSWORD_RESET_SCOPE = "password-reset"
|
||||
_AUTH_SCOPE = "auth"
|
||||
|
||||
2119
routes/calculations.py
Normal file
2119
routes/calculations.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,14 @@ from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
from dependencies import get_current_user, get_unit_of_work
|
||||
from models import ScenarioStatus, User
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
router = APIRouter(tags=["Dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
|
||||
def _format_timestamp(moment: datetime | None) -> str | None:
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import get_unit_of_work, require_any_role
|
||||
from schemas.exports import (
|
||||
@@ -24,10 +23,12 @@ from services.export_serializers import (
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from models.import_export_log import ImportExportLog
|
||||
from monitoring.metrics import observe_export
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/exports", tags=["exports"])
|
||||
templates = create_templates()
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -49,7 +50,6 @@ async def export_modal(
|
||||
submit_url = request.url_for(
|
||||
"export_projects" if dataset == "projects" else "export_scenarios"
|
||||
)
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"exports/modal.html",
|
||||
|
||||
@@ -5,9 +5,12 @@ from io import BytesIO
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from fastapi import Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import get_import_ingestion_service, require_roles
|
||||
from dependencies import (
|
||||
get_import_ingestion_service,
|
||||
require_roles,
|
||||
require_roles_html,
|
||||
)
|
||||
from models import User
|
||||
from schemas.imports import (
|
||||
ImportCommitRequest,
|
||||
@@ -17,9 +20,10 @@ from schemas.imports import (
|
||||
ScenarioImportPreviewResponse,
|
||||
)
|
||||
from services.importers import ImportIngestionService, UnsupportedImportFormat
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/imports", tags=["Imports"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
|
||||
@@ -32,7 +36,7 @@ MANAGE_ROLES = ("project_manager", "admin")
|
||||
)
|
||||
def import_dashboard(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
63
routes/navigation.py
Normal file
63
routes/navigation.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from dependencies import (
|
||||
get_auth_session,
|
||||
get_navigation_service,
|
||||
require_authenticated_user,
|
||||
)
|
||||
from models import User
|
||||
from schemas.navigation import (
|
||||
NavigationGroupSchema,
|
||||
NavigationLinkSchema,
|
||||
NavigationSidebarResponse,
|
||||
)
|
||||
from services.navigation import NavigationGroupDTO, NavigationLinkDTO, NavigationService
|
||||
from services.session import AuthSession
|
||||
|
||||
router = APIRouter(prefix="/navigation", tags=["Navigation"])
|
||||
|
||||
|
||||
def _to_link_schema(dto: NavigationLinkDTO) -> NavigationLinkSchema:
|
||||
return NavigationLinkSchema(
|
||||
id=dto.id,
|
||||
label=dto.label,
|
||||
href=dto.href,
|
||||
match_prefix=dto.match_prefix,
|
||||
icon=dto.icon,
|
||||
tooltip=dto.tooltip,
|
||||
is_external=dto.is_external,
|
||||
children=[_to_link_schema(child) for child in dto.children],
|
||||
)
|
||||
|
||||
|
||||
def _to_group_schema(dto: NavigationGroupDTO) -> NavigationGroupSchema:
|
||||
return NavigationGroupSchema(
|
||||
id=dto.id,
|
||||
label=dto.label,
|
||||
icon=dto.icon,
|
||||
tooltip=dto.tooltip,
|
||||
links=[_to_link_schema(link) for link in dto.links],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sidebar",
|
||||
response_model=NavigationSidebarResponse,
|
||||
name="navigation.sidebar",
|
||||
)
|
||||
async def get_sidebar_navigation(
|
||||
request: Request,
|
||||
_: User = Depends(require_authenticated_user),
|
||||
session: AuthSession = Depends(get_auth_session),
|
||||
service: NavigationService = Depends(get_navigation_service),
|
||||
) -> NavigationSidebarResponse:
|
||||
dto = service.build_sidebar(session=session, request=request)
|
||||
return NavigationSidebarResponse(
|
||||
groups=[_to_group_schema(group) for group in dto.groups],
|
||||
roles=list(dto.roles),
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
@@ -4,23 +4,26 @@ from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_any_role_html,
|
||||
require_project_resource,
|
||||
require_project_resource_html,
|
||||
require_roles,
|
||||
require_roles_html,
|
||||
)
|
||||
from models import MiningOperationType, Project, ScenarioStatus, User
|
||||
from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate
|
||||
from services.exceptions import EntityConflictError
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["Projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -79,7 +82,7 @@ def create_project(
|
||||
)
|
||||
def project_list_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
projects = _require_project_repo(uow).list(with_children=True)
|
||||
@@ -101,7 +104,8 @@ def project_list_page(
|
||||
name="projects.create_project_form",
|
||||
)
|
||||
def create_project_form(
|
||||
request: Request, _: User = Depends(require_roles(*MANAGE_ROLES))
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -122,7 +126,7 @@ def create_project_form(
|
||||
)
|
||||
def create_project_submit(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
name: str = Form(...),
|
||||
location: str | None = Form(None),
|
||||
operation_type: str = Form(...),
|
||||
@@ -221,7 +225,8 @@ def delete_project(
|
||||
)
|
||||
def view_project(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
project: Project = Depends(require_project_resource_html()),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
project = _require_project_repo(uow).get(project.id, with_children=True)
|
||||
@@ -256,8 +261,9 @@ def view_project(
|
||||
)
|
||||
def edit_project_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
project: Project = Depends(
|
||||
require_project_resource(require_manage=True)
|
||||
require_project_resource_html(require_manage=True)
|
||||
),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
@@ -283,8 +289,9 @@ def edit_project_form(
|
||||
)
|
||||
def edit_project_submit(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
project: Project = Depends(
|
||||
require_project_resource(require_manage=True)
|
||||
require_project_resource_html(require_manage=True)
|
||||
),
|
||||
name: str = Form(...),
|
||||
location: str | None = Form(None),
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_any_role_html,
|
||||
require_project_resource,
|
||||
require_scenario_resource,
|
||||
require_project_resource_html,
|
||||
require_scenario_resource_html,
|
||||
)
|
||||
from models import Project, Scenario, User
|
||||
from services.exceptions import EntityNotFoundError, ScenarioValidationError
|
||||
@@ -24,96 +26,10 @@ from services.reporting import (
|
||||
validate_percentiles,
|
||||
)
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(prefix="/reports", tags=["Reports"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Add custom Jinja2 filters
|
||||
|
||||
|
||||
def format_datetime(value):
|
||||
"""Format a datetime object for display in templates."""
|
||||
if not isinstance(value, datetime):
|
||||
return ""
|
||||
if value.tzinfo is None:
|
||||
# Assume UTC if no timezone
|
||||
from datetime import timezone
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
# Format as readable date/time
|
||||
return value.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def currency_display(value, currency_code):
|
||||
"""Format a numeric value with currency symbol/code."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
# Format the number
|
||||
if isinstance(value, (int, float)):
|
||||
formatted_value = f"{value:,.2f}"
|
||||
else:
|
||||
formatted_value = str(value)
|
||||
|
||||
# Add currency code
|
||||
if currency_code:
|
||||
return f"{currency_code} {formatted_value}"
|
||||
return formatted_value
|
||||
|
||||
|
||||
def format_metric(value, metric_name, currency_code=None):
|
||||
"""Format metric values appropriately based on metric type."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
# For currency-related metrics, use currency formatting
|
||||
currency_metrics = {'npv', 'inflows', 'outflows',
|
||||
'net', 'total_inflows', 'total_outflows', 'total_net'}
|
||||
if metric_name in currency_metrics and currency_code:
|
||||
return currency_display(value, currency_code)
|
||||
|
||||
# For percentage metrics
|
||||
percentage_metrics = {'irr', 'payback_period'}
|
||||
if metric_name in percentage_metrics:
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}%"
|
||||
return f"{value}%"
|
||||
|
||||
# Default numeric formatting
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:,.2f}"
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def percentage_display(value):
|
||||
"""Format a value as a percentage."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}%"
|
||||
|
||||
return f"{value}%"
|
||||
|
||||
|
||||
def period_display(value):
|
||||
"""Format a period value (like payback period)."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
if value == int(value):
|
||||
return f"{int(value)} years"
|
||||
return f"{value:.1f} years"
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
templates.env.filters['format_datetime'] = format_datetime
|
||||
templates.env.filters['currency_display'] = currency_display
|
||||
templates.env.filters['format_metric'] = format_metric
|
||||
templates.env.filters['percentage_display'] = percentage_display
|
||||
templates.env.filters['period_display'] = period_display
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -167,7 +83,7 @@ def project_summary_report(
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
@@ -220,7 +136,7 @@ def project_scenario_comparison_report(
|
||||
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||
if len(unique_ids) < 2:
|
||||
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.",
|
||||
)
|
||||
if fmt.lower() != "json":
|
||||
@@ -234,7 +150,7 @@ def project_scenario_comparison_report(
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
@@ -242,7 +158,7 @@ def project_scenario_comparison_report(
|
||||
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||
except ScenarioValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
@@ -313,7 +229,7 @@ def scenario_distribution_report(
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
@@ -335,8 +251,8 @@ def scenario_distribution_report(
|
||||
)
|
||||
def project_summary_page(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
project: Project = Depends(require_project_resource_html()),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
@@ -370,7 +286,7 @@ def project_summary_page(
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
@@ -399,8 +315,8 @@ def project_summary_page(
|
||||
)
|
||||
def project_scenario_comparison_page(
|
||||
request: Request,
|
||||
project: Project = Depends(require_project_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
project: Project = Depends(require_project_resource_html()),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
scenario_ids: list[int] = Query(
|
||||
..., alias="scenario_ids", description="Repeatable scenario identifier."),
|
||||
@@ -421,7 +337,7 @@ def project_scenario_comparison_page(
|
||||
unique_ids = list(dict.fromkeys(scenario_ids))
|
||||
if len(unique_ids) < 2:
|
||||
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.",
|
||||
)
|
||||
|
||||
@@ -430,7 +346,7 @@ def project_scenario_comparison_page(
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
@@ -438,7 +354,7 @@ def project_scenario_comparison_page(
|
||||
scenarios = uow.validate_scenarios_for_comparison(unique_ids)
|
||||
except ScenarioValidationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
@@ -476,8 +392,10 @@ def project_scenario_comparison_page(
|
||||
)
|
||||
def scenario_distribution_page(
|
||||
request: Request,
|
||||
scenario: Scenario = Depends(require_scenario_resource()),
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource_html()
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
include: str | None = Query(
|
||||
None,
|
||||
@@ -501,7 +419,7 @@ def scenario_distribution_page(
|
||||
percentile_values = validate_percentiles(percentiles)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -6,14 +6,16 @@ from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import (
|
||||
get_pricing_metadata,
|
||||
get_unit_of_work,
|
||||
require_any_role,
|
||||
require_any_role_html,
|
||||
require_roles,
|
||||
require_roles_html,
|
||||
require_scenario_resource,
|
||||
require_scenario_resource_html,
|
||||
)
|
||||
from models import ResourceType, Scenario, ScenarioStatus, User
|
||||
from schemas.scenario import (
|
||||
@@ -31,9 +33,10 @@ from services.exceptions import (
|
||||
)
|
||||
from services.pricing import PricingMetadata
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(tags=["Scenarios"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -170,6 +173,63 @@ def create_scenario_for_project(
|
||||
return _to_read_model(created)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/scenarios/ui",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
name="scenarios.project_scenario_list",
|
||||
)
|
||||
def project_scenario_list_page(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
try:
|
||||
project = _require_project_repo(uow).get(
|
||||
project_id, with_children=True)
|
||||
except EntityNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
scenarios = sorted(
|
||||
project.scenarios,
|
||||
key=lambda scenario: scenario.updated_at or scenario.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
scenario_totals = {
|
||||
"total": len(scenarios),
|
||||
"active": sum(
|
||||
1 for scenario in scenarios if scenario.status == ScenarioStatus.ACTIVE
|
||||
),
|
||||
"draft": sum(
|
||||
1 for scenario in scenarios if scenario.status == ScenarioStatus.DRAFT
|
||||
),
|
||||
"archived": sum(
|
||||
1 for scenario in scenarios if scenario.status == ScenarioStatus.ARCHIVED
|
||||
),
|
||||
"latest_update": max(
|
||||
(
|
||||
scenario.updated_at or scenario.created_at
|
||||
for scenario in scenarios
|
||||
if scenario.updated_at or scenario.created_at
|
||||
),
|
||||
default=None,
|
||||
),
|
||||
}
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"scenarios/list.html",
|
||||
{
|
||||
"project": project,
|
||||
"scenarios": scenarios,
|
||||
"scenario_totals": scenario_totals,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/scenarios/{scenario_id}", response_model=ScenarioRead)
|
||||
def get_scenario(
|
||||
scenario: Scenario = Depends(require_scenario_resource()),
|
||||
@@ -263,7 +323,7 @@ def _scenario_form_state(
|
||||
def create_scenario_form(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
) -> HTMLResponse:
|
||||
@@ -301,7 +361,7 @@ def create_scenario_form(
|
||||
def create_scenario_submit(
|
||||
project_id: int,
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
name: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
status_value: str = Form(ScenarioStatus.DRAFT.value),
|
||||
@@ -374,6 +434,7 @@ def create_scenario_submit(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": str(exc),
|
||||
"error_field": "currency",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -408,7 +469,8 @@ def create_scenario_submit(
|
||||
"cancel_url": request.url_for(
|
||||
"projects.view_project", project_id=project_id
|
||||
),
|
||||
"error": "Scenario could not be created.",
|
||||
"error": "Scenario with this name already exists for this project.",
|
||||
"error_field": "name",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
@@ -428,8 +490,9 @@ def create_scenario_submit(
|
||||
)
|
||||
def view_scenario(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource(with_children=True)
|
||||
require_scenario_resource_html(with_children=True)
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
) -> HTMLResponse:
|
||||
@@ -469,8 +532,9 @@ def view_scenario(
|
||||
)
|
||||
def edit_scenario_form(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource(require_manage=True)
|
||||
require_scenario_resource_html(require_manage=True)
|
||||
),
|
||||
uow: UnitOfWork = Depends(get_unit_of_work),
|
||||
metadata: PricingMetadata = Depends(get_pricing_metadata),
|
||||
@@ -503,8 +567,9 @@ def edit_scenario_form(
|
||||
)
|
||||
def edit_scenario_submit(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
scenario: Scenario = Depends(
|
||||
require_scenario_resource(require_manage=True)
|
||||
require_scenario_resource_html(require_manage=True)
|
||||
),
|
||||
name: str = Form(...),
|
||||
description: str | None = Form(None),
|
||||
@@ -569,6 +634,7 @@ def edit_scenario_submit(
|
||||
"scenarios.view_scenario", scenario_id=scenario.id
|
||||
),
|
||||
"error": str(exc),
|
||||
"error_field": "currency",
|
||||
"default_currency": metadata.default_currency,
|
||||
},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
147
routes/template_filters.py
Normal file
147
routes/template_filters.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from services.navigation import NavigationService
|
||||
from services.session import AuthSession
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_datetime(value: Any) -> str:
|
||||
"""Render datetime values consistently for templates."""
|
||||
if not isinstance(value, datetime):
|
||||
return ""
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
|
||||
def currency_display(value: Any, currency_code: str | None) -> str:
|
||||
"""Format numeric values with currency context."""
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, (int, float)):
|
||||
formatted_value = f"{value:,.2f}"
|
||||
else:
|
||||
formatted_value = str(value)
|
||||
if currency_code:
|
||||
return f"{currency_code} {formatted_value}"
|
||||
return formatted_value
|
||||
|
||||
|
||||
def format_metric(value: Any, metric_name: str, currency_code: str | None = None) -> str:
|
||||
"""Format metrics according to their semantic type."""
|
||||
if value is None:
|
||||
return "—"
|
||||
|
||||
currency_metrics = {
|
||||
"npv",
|
||||
"inflows",
|
||||
"outflows",
|
||||
"net",
|
||||
"total_inflows",
|
||||
"total_outflows",
|
||||
"total_net",
|
||||
}
|
||||
if metric_name in currency_metrics and currency_code:
|
||||
return currency_display(value, currency_code)
|
||||
|
||||
percentage_metrics = {"irr", "payback_period"}
|
||||
if metric_name in percentage_metrics:
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}%"
|
||||
return f"{value}%"
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:,.2f}"
|
||||
|
||||
return str(value)
|
||||
|
||||
|
||||
def percentage_display(value: Any) -> str:
|
||||
"""Format numeric values as percentages."""
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}%"
|
||||
return f"{value}%"
|
||||
|
||||
|
||||
def period_display(value: Any) -> str:
|
||||
"""Format period values in years."""
|
||||
if value is None:
|
||||
return "—"
|
||||
if isinstance(value, (int, float)):
|
||||
if value == int(value):
|
||||
return f"{int(value)} years"
|
||||
return f"{value:.1f} years"
|
||||
return str(value)
|
||||
|
||||
|
||||
def register_common_filters(templates: Jinja2Templates) -> None:
|
||||
templates.env.filters["format_datetime"] = format_datetime
|
||||
templates.env.filters["currency_display"] = currency_display
|
||||
templates.env.filters["format_metric"] = format_metric
|
||||
templates.env.filters["percentage_display"] = percentage_display
|
||||
templates.env.filters["period_display"] = period_display
|
||||
|
||||
|
||||
def _sidebar_navigation_for_request(request: Request | None):
|
||||
if request is None:
|
||||
return None
|
||||
|
||||
cached = getattr(request.state, "_navigation_sidebar_dto", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
session_context = getattr(request.state, "auth_session", None)
|
||||
if isinstance(session_context, AuthSession):
|
||||
session = session_context
|
||||
else:
|
||||
session = AuthSession.anonymous()
|
||||
|
||||
try:
|
||||
with UnitOfWork() as uow:
|
||||
if not uow.navigation:
|
||||
logger.debug("Navigation repository unavailable for sidebar rendering")
|
||||
sidebar_dto = None
|
||||
else:
|
||||
service = NavigationService(uow.navigation)
|
||||
sidebar_dto = service.build_sidebar(session=session, request=request)
|
||||
except Exception: # pragma: no cover - defensive fallback for templates
|
||||
logger.exception("Failed to build sidebar navigation during template render")
|
||||
sidebar_dto = None
|
||||
|
||||
setattr(request.state, "_navigation_sidebar_dto", sidebar_dto)
|
||||
return sidebar_dto
|
||||
|
||||
|
||||
def register_navigation_globals(templates: Jinja2Templates) -> None:
|
||||
templates.env.globals["get_sidebar_navigation"] = _sidebar_navigation_for_request
|
||||
|
||||
|
||||
def create_templates() -> Jinja2Templates:
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
register_common_filters(templates)
|
||||
register_navigation_globals(templates)
|
||||
return templates
|
||||
|
||||
|
||||
__all__ = [
|
||||
"format_datetime",
|
||||
"currency_display",
|
||||
"format_metric",
|
||||
"percentage_display",
|
||||
"period_display",
|
||||
"register_common_filters",
|
||||
"register_navigation_globals",
|
||||
"create_templates",
|
||||
]
|
||||
16
routes/ui.py
16
routes/ui.py
@@ -2,13 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from dependencies import require_any_role, require_roles
|
||||
from dependencies import require_any_role_html, require_roles_html
|
||||
from models import User
|
||||
from routes.template_filters import create_templates
|
||||
|
||||
router = APIRouter(tags=["UI"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
templates = create_templates()
|
||||
|
||||
READ_ROLES = ("viewer", "analyst", "project_manager", "admin")
|
||||
MANAGE_ROLES = ("project_manager", "admin")
|
||||
@@ -22,7 +22,7 @@ MANAGE_ROLES = ("project_manager", "admin")
|
||||
)
|
||||
def simulations_dashboard(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -41,7 +41,7 @@ def simulations_dashboard(
|
||||
)
|
||||
def reporting_dashboard(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -60,7 +60,7 @@ def reporting_dashboard(
|
||||
)
|
||||
def settings_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -79,7 +79,7 @@ def settings_page(
|
||||
)
|
||||
def theme_settings_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_any_role(*READ_ROLES)),
|
||||
_: User = Depends(require_any_role_html(*READ_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
@@ -98,7 +98,7 @@ def theme_settings_page(
|
||||
)
|
||||
def currencies_page(
|
||||
request: Request,
|
||||
_: User = Depends(require_roles(*MANAGE_ROLES)),
|
||||
_: User = Depends(require_roles_html(*MANAGE_ROLES)),
|
||||
) -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
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
|
||||
346
schemas/calculations.py
Normal file
346
schemas/calculations.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""Pydantic schemas for calculation workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field, PositiveFloat, ValidationError, field_validator
|
||||
|
||||
from services.pricing import PricingResult
|
||||
|
||||
|
||||
class ImpurityInput(BaseModel):
|
||||
"""Impurity configuration row supplied by the client."""
|
||||
|
||||
name: str = Field(..., min_length=1)
|
||||
value: float | None = Field(None, ge=0)
|
||||
threshold: float | None = Field(None, ge=0)
|
||||
penalty: float | None = Field(None)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _normalise_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class ProfitabilityCalculationRequest(BaseModel):
|
||||
"""Request payload for profitability calculations."""
|
||||
|
||||
metal: str = Field(..., min_length=1)
|
||||
ore_tonnage: PositiveFloat
|
||||
head_grade_pct: float = Field(..., gt=0, le=100)
|
||||
recovery_pct: float = Field(..., gt=0, le=100)
|
||||
payable_pct: float | None = Field(None, gt=0, le=100)
|
||||
reference_price: PositiveFloat
|
||||
treatment_charge: float = Field(0, ge=0)
|
||||
smelting_charge: float = Field(0, ge=0)
|
||||
moisture_pct: float = Field(0, ge=0, le=100)
|
||||
moisture_threshold_pct: float | None = Field(None, ge=0, le=100)
|
||||
moisture_penalty_per_pct: float | None = None
|
||||
premiums: float = Field(0)
|
||||
fx_rate: PositiveFloat = Field(1)
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
opex: float = Field(0, ge=0)
|
||||
sustaining_capex: float = Field(0, ge=0)
|
||||
capex: float = Field(0, ge=0)
|
||||
discount_rate: float | None = Field(None, ge=0, le=100)
|
||||
periods: int = Field(10, ge=1, le=120)
|
||||
impurities: List[ImpurityInput] = Field(default_factory=list)
|
||||
|
||||
@field_validator("currency_code")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("metal")
|
||||
@classmethod
|
||||
def _normalise_metal(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
|
||||
class ProfitabilityCosts(BaseModel):
|
||||
"""Aggregated cost components for profitability output."""
|
||||
|
||||
opex_total: float
|
||||
sustaining_capex_total: float
|
||||
capex: float
|
||||
|
||||
|
||||
class ProfitabilityMetrics(BaseModel):
|
||||
"""Financial KPIs yielded by the profitability calculation."""
|
||||
|
||||
npv: float | None
|
||||
irr: float | None
|
||||
payback_period: float | None
|
||||
margin: float | None
|
||||
|
||||
|
||||
class CashFlowEntry(BaseModel):
|
||||
"""Normalized cash flow row for reporting and charting."""
|
||||
|
||||
period: int
|
||||
revenue: float
|
||||
opex: float
|
||||
sustaining_capex: float
|
||||
net: float
|
||||
|
||||
|
||||
class ProfitabilityCalculationResult(BaseModel):
|
||||
"""Response body summarizing profitability calculation outputs."""
|
||||
|
||||
pricing: PricingResult
|
||||
costs: ProfitabilityCosts
|
||||
metrics: ProfitabilityMetrics
|
||||
cash_flows: list[CashFlowEntry]
|
||||
currency: str | None
|
||||
|
||||
|
||||
class CapexComponentInput(BaseModel):
|
||||
"""Capex component entry supplied by the UI."""
|
||||
|
||||
id: int | None = Field(default=None, ge=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
category: str = Field(..., min_length=1)
|
||||
amount: float = Field(..., ge=0)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
spend_year: int | None = Field(None, ge=0, le=120)
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _normalise_category(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _trim_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CapexParameters(BaseModel):
|
||||
"""Global parameters applied to capex calculations."""
|
||||
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
contingency_pct: float | None = Field(0, ge=0, le=100)
|
||||
discount_rate_pct: float | None = Field(None, ge=0, le=100)
|
||||
evaluation_horizon_years: int | None = Field(10, ge=1, le=100)
|
||||
|
||||
@field_validator("currency_code")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class CapexCalculationOptions(BaseModel):
|
||||
"""Optional behaviour flags for capex calculations."""
|
||||
|
||||
persist: bool = False
|
||||
|
||||
|
||||
class CapexCalculationRequest(BaseModel):
|
||||
"""Request payload for capex aggregation."""
|
||||
|
||||
components: List[CapexComponentInput] = Field(default_factory=list)
|
||||
parameters: CapexParameters = Field(
|
||||
default_factory=CapexParameters, # type: ignore[arg-type]
|
||||
)
|
||||
options: CapexCalculationOptions = Field(
|
||||
default_factory=CapexCalculationOptions, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class CapexCategoryBreakdown(BaseModel):
|
||||
"""Breakdown entry describing category totals."""
|
||||
|
||||
category: str
|
||||
amount: float = Field(..., ge=0)
|
||||
share: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class CapexTotals(BaseModel):
|
||||
"""Aggregated totals for capex workflows."""
|
||||
|
||||
overall: float = Field(..., ge=0)
|
||||
contingency_pct: float = Field(0, ge=0, le=100)
|
||||
contingency_amount: float = Field(..., ge=0)
|
||||
with_contingency: float = Field(..., ge=0)
|
||||
by_category: List[CapexCategoryBreakdown] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CapexTimelineEntry(BaseModel):
|
||||
"""Spend profile entry grouped by year."""
|
||||
|
||||
year: int
|
||||
spend: float = Field(..., ge=0)
|
||||
cumulative: float = Field(..., ge=0)
|
||||
|
||||
|
||||
class CapexCalculationResult(BaseModel):
|
||||
"""Response body for capex calculations."""
|
||||
|
||||
totals: CapexTotals
|
||||
timeline: List[CapexTimelineEntry] = Field(default_factory=list)
|
||||
components: List[CapexComponentInput] = Field(default_factory=list)
|
||||
parameters: CapexParameters
|
||||
options: CapexCalculationOptions
|
||||
currency: str | None
|
||||
|
||||
|
||||
class OpexComponentInput(BaseModel):
|
||||
"""opex component entry supplied by the UI."""
|
||||
|
||||
id: int | None = Field(default=None, ge=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
category: str = Field(..., min_length=1)
|
||||
unit_cost: float = Field(..., ge=0)
|
||||
quantity: float = Field(..., ge=0)
|
||||
frequency: str = Field(..., min_length=1)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
period_start: int | None = Field(None, ge=0, le=240)
|
||||
period_end: int | None = Field(None, ge=0, le=240)
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
@field_validator("currency")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
@field_validator("category")
|
||||
@classmethod
|
||||
def _normalise_category(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@field_validator("frequency")
|
||||
@classmethod
|
||||
def _normalise_frequency(cls, value: str) -> str:
|
||||
return value.strip().lower()
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _trim_name(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class OpexParameters(BaseModel):
|
||||
"""Global parameters applied to opex calculations."""
|
||||
|
||||
currency_code: str | None = Field(None, min_length=3, max_length=3)
|
||||
escalation_pct: float | None = Field(None, ge=0, le=100)
|
||||
discount_rate_pct: float | None = Field(None, ge=0, le=100)
|
||||
evaluation_horizon_years: int | None = Field(10, ge=1, le=100)
|
||||
apply_escalation: bool = True
|
||||
|
||||
@field_validator("currency_code")
|
||||
@classmethod
|
||||
def _uppercase_currency(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class OpexOptions(BaseModel):
|
||||
"""Optional behaviour flags for opex calculations."""
|
||||
|
||||
persist: bool = False
|
||||
snapshot_notes: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class OpexCalculationRequest(BaseModel):
|
||||
"""Request payload for opex aggregation."""
|
||||
|
||||
components: List[OpexComponentInput] = Field(
|
||||
default_factory=list)
|
||||
parameters: OpexParameters = Field(
|
||||
default_factory=OpexParameters, # type: ignore[arg-type]
|
||||
)
|
||||
options: OpexOptions = Field(
|
||||
default_factory=OpexOptions, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class OpexCategoryBreakdown(BaseModel):
|
||||
"""Category breakdown for opex totals."""
|
||||
|
||||
category: str
|
||||
annual_cost: float = Field(..., ge=0)
|
||||
share: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class OpexTimelineEntry(BaseModel):
|
||||
"""Timeline entry representing cost over evaluation periods."""
|
||||
|
||||
period: int
|
||||
base_cost: float = Field(..., ge=0)
|
||||
escalated_cost: float | None = Field(None, ge=0)
|
||||
|
||||
|
||||
class OpexMetrics(BaseModel):
|
||||
"""Derived KPIs for opex outputs."""
|
||||
|
||||
annual_average: float | None
|
||||
cost_per_ton: float | None
|
||||
|
||||
|
||||
class OpexTotals(BaseModel):
|
||||
"""Aggregated totals for opex."""
|
||||
|
||||
overall_annual: float = Field(..., ge=0)
|
||||
escalated_total: float | None = Field(None, ge=0)
|
||||
escalation_pct: float | None = Field(None, ge=0, le=100)
|
||||
by_category: List[OpexCategoryBreakdown] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class OpexCalculationResult(BaseModel):
|
||||
"""Response body summarising opex calculations."""
|
||||
|
||||
totals: OpexTotals
|
||||
timeline: List[OpexTimelineEntry] = Field(default_factory=list)
|
||||
metrics: OpexMetrics
|
||||
components: List[OpexComponentInput] = Field(
|
||||
default_factory=list)
|
||||
parameters: OpexParameters
|
||||
options: OpexOptions
|
||||
currency: str | None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ImpurityInput",
|
||||
"ProfitabilityCalculationRequest",
|
||||
"ProfitabilityCosts",
|
||||
"ProfitabilityMetrics",
|
||||
"CashFlowEntry",
|
||||
"ProfitabilityCalculationResult",
|
||||
"CapexComponentInput",
|
||||
"CapexParameters",
|
||||
"CapexCalculationOptions",
|
||||
"CapexCalculationRequest",
|
||||
"CapexCategoryBreakdown",
|
||||
"CapexTotals",
|
||||
"CapexTimelineEntry",
|
||||
"CapexCalculationResult",
|
||||
"OpexComponentInput",
|
||||
"OpexParameters",
|
||||
"OpexOptions",
|
||||
"OpexCalculationRequest",
|
||||
"OpexCategoryBreakdown",
|
||||
"OpexTimelineEntry",
|
||||
"OpexMetrics",
|
||||
"OpexTotals",
|
||||
"OpexCalculationResult",
|
||||
"ValidationError",
|
||||
]
|
||||
36
schemas/navigation.py
Normal file
36
schemas/navigation.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NavigationLinkSchema(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
href: str
|
||||
match_prefix: str | None = Field(default=None)
|
||||
icon: str | None = Field(default=None)
|
||||
tooltip: str | None = Field(default=None)
|
||||
is_external: bool = Field(default=False)
|
||||
children: List["NavigationLinkSchema"] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NavigationGroupSchema(BaseModel):
|
||||
id: int
|
||||
label: str
|
||||
icon: str | None = Field(default=None)
|
||||
tooltip: str | None = Field(default=None)
|
||||
links: List[NavigationLinkSchema] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NavigationSidebarResponse(BaseModel):
|
||||
groups: List[NavigationGroupSchema]
|
||||
roles: List[str] = Field(default_factory=list)
|
||||
generated_at: datetime
|
||||
|
||||
|
||||
NavigationLinkSchema.model_rebuild()
|
||||
NavigationGroupSchema.model_rebuild()
|
||||
NavigationSidebarResponse.model_rebuild()
|
||||
112
scripts/_route_verification.py
Normal file
112
scripts/_route_verification.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Utility script to verify key authenticated routes respond without errors."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from http.client import HTTPConnection
|
||||
from http.cookies import SimpleCookie
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8000
|
||||
|
||||
cookies: Dict[str, str] = {}
|
||||
|
||||
|
||||
def _update_cookies(headers: List[Tuple[str, str]]) -> None:
|
||||
for name, value in headers:
|
||||
if name.lower() != "set-cookie":
|
||||
continue
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(value)
|
||||
for key, morsel in cookie.items():
|
||||
cookies[key] = morsel.value
|
||||
|
||||
|
||||
def _cookie_header() -> str | None:
|
||||
if not cookies:
|
||||
return None
|
||||
return "; ".join(f"{key}={value}" for key, value in cookies.items())
|
||||
|
||||
|
||||
def request(method: str, path: str, *, body: bytes | None = None, headers: Dict[str, str] | None = None) -> Tuple[int, Dict[str, str], bytes]:
|
||||
conn = HTTPConnection(HOST, PORT, timeout=10)
|
||||
prepared_headers = {"User-Agent": "route-checker"}
|
||||
if headers:
|
||||
prepared_headers.update(headers)
|
||||
cookie_header = _cookie_header()
|
||||
if cookie_header:
|
||||
prepared_headers["Cookie"] = cookie_header
|
||||
|
||||
conn.request(method, path, body=body, headers=prepared_headers)
|
||||
resp = conn.getresponse()
|
||||
payload = resp.read()
|
||||
status = resp.status
|
||||
reason = resp.reason
|
||||
response_headers = {name: value for name, value in resp.getheaders()}
|
||||
_update_cookies(list(resp.getheaders()))
|
||||
conn.close()
|
||||
print(f"{method} {path} -> {status} {reason}")
|
||||
return status, response_headers, payload
|
||||
|
||||
|
||||
def main() -> int:
|
||||
status, _, _ = request("GET", "/login")
|
||||
if status != 200:
|
||||
print("Unexpected status for GET /login", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin")
|
||||
admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "M11ffpgm.")
|
||||
login_payload = urllib.parse.urlencode(
|
||||
{"username": admin_username, "password": admin_password}
|
||||
).encode()
|
||||
status, headers, _ = request(
|
||||
"POST",
|
||||
"/login",
|
||||
body=login_payload,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if status not in {200, 303}:
|
||||
print("Login failed", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
location = headers.get("Location", "/")
|
||||
redirect_path = urllib.parse.urlsplit(location).path or "/"
|
||||
request("GET", redirect_path)
|
||||
|
||||
request("GET", "/")
|
||||
request("GET", "/projects/ui")
|
||||
|
||||
status, headers, body = request(
|
||||
"GET",
|
||||
"/projects",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
projects: List[dict] = []
|
||||
if headers.get("Content-Type", "").startswith("application/json"):
|
||||
projects = json.loads(body.decode())
|
||||
|
||||
if projects:
|
||||
project_id = projects[0]["id"]
|
||||
request("GET", f"/projects/{project_id}/view")
|
||||
status, headers, body = request(
|
||||
"GET",
|
||||
f"/projects/{project_id}/scenarios",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
scenarios: List[dict] = []
|
||||
if headers.get("Content-Type", "").startswith("application/json"):
|
||||
scenarios = json.loads(body.decode())
|
||||
if scenarios:
|
||||
scenario_id = scenarios[0]["id"]
|
||||
request("GET", f"/scenarios/{scenario_id}/view")
|
||||
|
||||
print("Cookies:", cookies)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
15
scripts/apply_users_sequence_fix.py
Normal file
15
scripts/apply_users_sequence_fix.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from sqlalchemy import create_engine, text
|
||||
from config.database import DATABASE_URL
|
||||
|
||||
engine = create_engine(DATABASE_URL, future=True)
|
||||
sqls = [
|
||||
"CREATE SEQUENCE IF NOT EXISTS users_id_seq;",
|
||||
"ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq');",
|
||||
"SELECT setval('users_id_seq', COALESCE((SELECT MAX(id) FROM users), 1));",
|
||||
"ALTER SEQUENCE users_id_seq OWNED BY users.id;",
|
||||
]
|
||||
with engine.begin() as conn:
|
||||
for s in sqls:
|
||||
print('EXECUTING:', s)
|
||||
conn.execute(text(s))
|
||||
print('SEQUENCE fix applied')
|
||||
@@ -22,10 +22,11 @@ import os
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from sqlalchemy import create_engine, text
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import JSON, create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.sql import bindparam
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
password_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
@@ -116,6 +117,40 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon TEXT,
|
||||
tooltip TEXT,
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
|
||||
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
|
||||
slug TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
route_name TEXT,
|
||||
href_override TEXT,
|
||||
match_prefix TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon TEXT,
|
||||
tooltip TEXT,
|
||||
required_roles TEXT NOT NULL DEFAULT '[]',
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_external INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (group_id, slug)
|
||||
);
|
||||
""",
|
||||
# pricing_settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pricing_settings (
|
||||
@@ -268,6 +303,41 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
CONSTRAINT uq_user_roles_user_role UNIQUE (user_id, role_id)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon VARCHAR(64),
|
||||
tooltip VARCHAR(255),
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT uq_navigation_groups_slug UNIQUE (slug)
|
||||
);
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS navigation_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES navigation_groups(id) ON DELETE CASCADE,
|
||||
parent_link_id INTEGER REFERENCES navigation_links(id) ON DELETE CASCADE,
|
||||
slug VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(128) NOT NULL,
|
||||
route_name VARCHAR(128),
|
||||
href_override VARCHAR(512),
|
||||
match_prefix VARCHAR(512),
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
icon VARCHAR(64),
|
||||
tooltip VARCHAR(255),
|
||||
required_roles JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
is_external BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
CONSTRAINT uq_navigation_links_group_slug UNIQUE (group_id, slug)
|
||||
);
|
||||
""",
|
||||
# pricing_settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pricing_settings (
|
||||
@@ -384,6 +454,9 @@ def _get_table_ddls(is_sqlite: bool) -> List[str]:
|
||||
|
||||
|
||||
# Seeds
|
||||
TABLE_DDLS: List[str] = _get_table_ddls(is_sqlite=False)
|
||||
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
{"id": 1, "name": "admin", "display_name": "Administrator",
|
||||
"description": "Full platform access with user management rights."},
|
||||
@@ -451,7 +524,7 @@ class UserSeed(BaseModel):
|
||||
is_active: bool = True
|
||||
is_superuser: bool = False
|
||||
|
||||
@validator("password")
|
||||
@field_validator("password")
|
||||
def password_min_len(cls, v: str) -> str:
|
||||
if not v or len(v) < 8:
|
||||
raise ValueError("password must be at least 8 characters")
|
||||
@@ -468,6 +541,236 @@ class PricingSeed(BaseModel):
|
||||
moisture_penalty_per_pct: float
|
||||
|
||||
|
||||
class NavigationGroupSeed(BaseModel):
|
||||
slug: str
|
||||
label: str
|
||||
sort_order: int = 100
|
||||
icon: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class NavigationLinkSeed(BaseModel):
|
||||
slug: str
|
||||
group_slug: str
|
||||
label: str
|
||||
route_name: Optional[str] = None
|
||||
href_override: Optional[str] = None
|
||||
match_prefix: Optional[str] = None
|
||||
sort_order: int = 100
|
||||
icon: Optional[str] = None
|
||||
tooltip: Optional[str] = None
|
||||
required_roles: list[str] = Field(default_factory=list)
|
||||
is_enabled: bool = True
|
||||
is_external: bool = False
|
||||
parent_slug: Optional[str] = None
|
||||
|
||||
@field_validator("required_roles", mode="after")
|
||||
def _normalise_roles(cls, value: list[str]) -> list[str]:
|
||||
normalised = []
|
||||
for role in value:
|
||||
if not role:
|
||||
continue
|
||||
slug = role.strip().lower()
|
||||
if slug and slug not in normalised:
|
||||
normalised.append(slug)
|
||||
return normalised
|
||||
|
||||
@field_validator("route_name")
|
||||
def _route_or_href(cls, value: Optional[str], info):
|
||||
href = info.data.get("href_override")
|
||||
if not value and not href:
|
||||
raise ValueError(
|
||||
"navigation link requires route_name or href_override")
|
||||
return value
|
||||
|
||||
|
||||
DEFAULT_NAVIGATION_GROUPS: list[NavigationGroupSeed] = [
|
||||
NavigationGroupSeed(
|
||||
slug="workspace",
|
||||
label="Workspace",
|
||||
sort_order=10,
|
||||
icon="briefcase",
|
||||
tooltip="Primary work hub",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="insights",
|
||||
label="Insights",
|
||||
sort_order=20,
|
||||
icon="insights",
|
||||
tooltip="Analytics and reports",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="configuration",
|
||||
label="Configuration",
|
||||
sort_order=30,
|
||||
icon="cog",
|
||||
tooltip="Administration and settings",
|
||||
),
|
||||
NavigationGroupSeed(
|
||||
slug="account",
|
||||
label="Account",
|
||||
sort_order=40,
|
||||
icon="user",
|
||||
tooltip="Session management",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_NAVIGATION_LINKS: list[NavigationLinkSeed] = [
|
||||
NavigationLinkSeed(
|
||||
slug="dashboard",
|
||||
group_slug="workspace",
|
||||
label="Dashboard",
|
||||
route_name="dashboard.home",
|
||||
match_prefix="/",
|
||||
sort_order=10,
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="projects",
|
||||
group_slug="workspace",
|
||||
label="Projects",
|
||||
route_name="projects.project_list_page",
|
||||
match_prefix="/projects",
|
||||
sort_order=20,
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="project-create",
|
||||
group_slug="workspace",
|
||||
label="New Project",
|
||||
route_name="projects.create_project_form",
|
||||
match_prefix="/projects/create",
|
||||
sort_order=30,
|
||||
required_roles=["project_manager", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="imports",
|
||||
group_slug="workspace",
|
||||
label="Imports",
|
||||
href_override="/imports/ui",
|
||||
match_prefix="/imports",
|
||||
sort_order=40,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="profitability",
|
||||
group_slug="workspace",
|
||||
label="Profitability Calculator",
|
||||
route_name="calculations.profitability_form",
|
||||
href_override="/calculations/profitability",
|
||||
match_prefix="/calculations/profitability",
|
||||
sort_order=50,
|
||||
required_roles=["analyst", "admin"],
|
||||
parent_slug="projects",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="opex",
|
||||
group_slug="workspace",
|
||||
label="Opex Planner",
|
||||
route_name="calculations.opex_form",
|
||||
href_override="/calculations/opex",
|
||||
match_prefix="/calculations/opex",
|
||||
sort_order=60,
|
||||
required_roles=["analyst", "admin"],
|
||||
parent_slug="projects",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="capex",
|
||||
group_slug="workspace",
|
||||
label="Capex Planner",
|
||||
route_name="calculations.capex_form",
|
||||
href_override="/calculations/capex",
|
||||
match_prefix="/calculations/capex",
|
||||
sort_order=70,
|
||||
required_roles=["analyst", "admin"],
|
||||
parent_slug="projects",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="simulations",
|
||||
group_slug="insights",
|
||||
label="Simulations",
|
||||
href_override="/ui/simulations",
|
||||
match_prefix="/ui/simulations",
|
||||
sort_order=10,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="reporting",
|
||||
group_slug="insights",
|
||||
label="Reporting",
|
||||
href_override="/ui/reporting",
|
||||
match_prefix="/ui/reporting",
|
||||
sort_order=20,
|
||||
required_roles=["analyst", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="settings",
|
||||
group_slug="configuration",
|
||||
label="Settings",
|
||||
href_override="/ui/settings",
|
||||
match_prefix="/ui/settings",
|
||||
sort_order=10,
|
||||
required_roles=["admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="themes",
|
||||
group_slug="configuration",
|
||||
label="Themes",
|
||||
href_override="/theme-settings",
|
||||
match_prefix="/theme-settings",
|
||||
sort_order=20,
|
||||
required_roles=["admin"],
|
||||
parent_slug="settings",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="currencies",
|
||||
group_slug="configuration",
|
||||
label="Currency Management",
|
||||
href_override="/ui/currencies",
|
||||
match_prefix="/ui/currencies",
|
||||
sort_order=30,
|
||||
required_roles=["admin"],
|
||||
parent_slug="settings",
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="logout",
|
||||
group_slug="account",
|
||||
label="Logout",
|
||||
route_name="auth.logout",
|
||||
match_prefix="/logout",
|
||||
sort_order=10,
|
||||
required_roles=["viewer", "analyst", "project_manager", "admin"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="login",
|
||||
group_slug="account",
|
||||
label="Login",
|
||||
route_name="auth.login_form",
|
||||
match_prefix="/login",
|
||||
sort_order=10,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="register",
|
||||
group_slug="account",
|
||||
label="Register",
|
||||
route_name="auth.register_form",
|
||||
match_prefix="/register",
|
||||
sort_order=20,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
NavigationLinkSeed(
|
||||
slug="forgot-password",
|
||||
group_slug="account",
|
||||
label="Forgot Password",
|
||||
route_name="auth.password_reset_request_form",
|
||||
match_prefix="/forgot-password",
|
||||
sort_order=30,
|
||||
required_roles=["anonymous"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
DEFAULT_PROJECTS: list[ProjectSeed] = [
|
||||
ProjectSeed(
|
||||
name="Helios Copper",
|
||||
@@ -525,7 +828,7 @@ DEFAULT_FINANCIAL_INPUTS: list[FinancialInputSeed] = [
|
||||
FinancialInputSeed(
|
||||
project_name="Helios Copper",
|
||||
scenario_name="Base Case",
|
||||
name="Processing Opex",
|
||||
name="Opex",
|
||||
category="opex",
|
||||
cost_bucket="operating_variable",
|
||||
amount=Decimal("75000000"),
|
||||
@@ -784,6 +1087,198 @@ def ensure_default_pricing(engine: Engine, is_sqlite: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def seed_navigation(engine: Engine, is_sqlite: bool) -> None:
|
||||
group_insert_sql = text(
|
||||
"""
|
||||
INSERT INTO navigation_groups (slug, label, sort_order, icon, tooltip, is_enabled)
|
||||
VALUES (:slug, :label, :sort_order, :icon, :tooltip, :is_enabled)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
label = EXCLUDED.label,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
icon = EXCLUDED.icon,
|
||||
tooltip = EXCLUDED.tooltip,
|
||||
is_enabled = EXCLUDED.is_enabled
|
||||
"""
|
||||
)
|
||||
|
||||
link_insert_sql = text(
|
||||
"""
|
||||
INSERT INTO navigation_links (
|
||||
group_id, parent_link_id, slug, label, route_name, href_override,
|
||||
match_prefix, sort_order, icon, tooltip, required_roles, is_enabled, is_external
|
||||
)
|
||||
VALUES (
|
||||
:group_id, :parent_link_id, :slug, :label, :route_name, :href_override,
|
||||
:match_prefix, :sort_order, :icon, :tooltip, :required_roles, :is_enabled, :is_external
|
||||
)
|
||||
ON CONFLICT (group_id, slug) DO UPDATE SET
|
||||
parent_link_id = EXCLUDED.parent_link_id,
|
||||
label = EXCLUDED.label,
|
||||
route_name = EXCLUDED.route_name,
|
||||
href_override = EXCLUDED.href_override,
|
||||
match_prefix = EXCLUDED.match_prefix,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
icon = EXCLUDED.icon,
|
||||
tooltip = EXCLUDED.tooltip,
|
||||
required_roles = EXCLUDED.required_roles,
|
||||
is_enabled = EXCLUDED.is_enabled,
|
||||
is_external = EXCLUDED.is_external
|
||||
"""
|
||||
)
|
||||
link_insert_sql = link_insert_sql.bindparams(
|
||||
bindparam("required_roles", type_=JSON)
|
||||
)
|
||||
|
||||
with engine.begin() as conn:
|
||||
role_rows = conn.execute(text("SELECT name FROM roles")).fetchall()
|
||||
available_roles = {row.name for row in role_rows}
|
||||
|
||||
def resolve_roles(raw_roles: list[str]) -> list[str]:
|
||||
if not raw_roles:
|
||||
return []
|
||||
|
||||
resolved: list[str] = []
|
||||
missing: list[str] = []
|
||||
for slug in raw_roles:
|
||||
if slug == "anonymous":
|
||||
if slug not in resolved:
|
||||
resolved.append(slug)
|
||||
continue
|
||||
if slug in available_roles:
|
||||
if slug not in resolved:
|
||||
resolved.append(slug)
|
||||
else:
|
||||
missing.append(slug)
|
||||
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Navigation seed roles %s are missing; defaulting link access to admin only",
|
||||
", ".join(missing),
|
||||
)
|
||||
if "admin" in available_roles and "admin" not in resolved:
|
||||
resolved.append("admin")
|
||||
|
||||
return resolved
|
||||
|
||||
group_ids: dict[str, int] = {}
|
||||
for group_seed in DEFAULT_NAVIGATION_GROUPS:
|
||||
conn.execute(
|
||||
group_insert_sql,
|
||||
group_seed.model_dump(),
|
||||
)
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM navigation_groups WHERE slug = :slug"),
|
||||
{"slug": group_seed.slug},
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
group_ids[group_seed.slug] = row.id
|
||||
|
||||
if not group_ids:
|
||||
logger.warning(
|
||||
"Navigation seeding skipped because no groups were inserted")
|
||||
return
|
||||
|
||||
link_ids: dict[tuple[str, str], int] = {}
|
||||
parent_pending: list[NavigationLinkSeed] = []
|
||||
|
||||
for link_seed in DEFAULT_NAVIGATION_LINKS:
|
||||
if link_seed.parent_slug:
|
||||
parent_pending.append(link_seed)
|
||||
continue
|
||||
|
||||
group_id = group_ids.get(link_seed.group_slug)
|
||||
if group_id is None:
|
||||
logger.warning(
|
||||
"Skipping navigation link '%s' because group '%s' is missing",
|
||||
link_seed.slug,
|
||||
link_seed.group_slug,
|
||||
)
|
||||
continue
|
||||
|
||||
resolved_roles = resolve_roles(link_seed.required_roles)
|
||||
|
||||
payload = {
|
||||
"group_id": group_id,
|
||||
"parent_link_id": None,
|
||||
"slug": link_seed.slug,
|
||||
"label": link_seed.label,
|
||||
"route_name": link_seed.route_name,
|
||||
"href_override": link_seed.href_override,
|
||||
"match_prefix": link_seed.match_prefix,
|
||||
"sort_order": link_seed.sort_order,
|
||||
"icon": link_seed.icon,
|
||||
"tooltip": link_seed.tooltip,
|
||||
"required_roles": resolved_roles,
|
||||
"is_enabled": link_seed.is_enabled,
|
||||
"is_external": link_seed.is_external,
|
||||
}
|
||||
conn.execute(link_insert_sql, payload)
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
|
||||
),
|
||||
{"group_id": group_id, "slug": link_seed.slug},
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
link_ids[(link_seed.group_slug, link_seed.slug)] = row.id
|
||||
|
||||
for link_seed in parent_pending:
|
||||
group_id = group_ids.get(link_seed.group_slug)
|
||||
if group_id is None:
|
||||
logger.warning(
|
||||
"Skipping child navigation link '%s' because group '%s' is missing",
|
||||
link_seed.slug,
|
||||
link_seed.group_slug,
|
||||
)
|
||||
continue
|
||||
|
||||
parent_key = (link_seed.group_slug, link_seed.parent_slug or "")
|
||||
parent_id = link_ids.get(parent_key)
|
||||
if parent_id is None:
|
||||
parent_row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
|
||||
),
|
||||
{"group_id": group_id, "slug": link_seed.parent_slug},
|
||||
).fetchone()
|
||||
parent_id = parent_row.id if parent_row else None
|
||||
|
||||
if parent_id is None:
|
||||
logger.warning(
|
||||
"Skipping child navigation link '%s' because parent '%s' is missing",
|
||||
link_seed.slug,
|
||||
link_seed.parent_slug,
|
||||
)
|
||||
continue
|
||||
|
||||
resolved_roles = resolve_roles(link_seed.required_roles)
|
||||
|
||||
payload = {
|
||||
"group_id": group_id,
|
||||
"parent_link_id": parent_id,
|
||||
"slug": link_seed.slug,
|
||||
"label": link_seed.label,
|
||||
"route_name": link_seed.route_name,
|
||||
"href_override": link_seed.href_override,
|
||||
"match_prefix": link_seed.match_prefix,
|
||||
"sort_order": link_seed.sort_order,
|
||||
"icon": link_seed.icon,
|
||||
"tooltip": link_seed.tooltip,
|
||||
"required_roles": resolved_roles,
|
||||
"is_enabled": link_seed.is_enabled,
|
||||
"is_external": link_seed.is_external,
|
||||
}
|
||||
conn.execute(link_insert_sql, payload)
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT id FROM navigation_links WHERE group_id = :group_id AND slug = :slug"
|
||||
),
|
||||
{"group_id": group_id, "slug": link_seed.slug},
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
link_ids[(link_seed.group_slug, link_seed.slug)] = row.id
|
||||
|
||||
|
||||
def _project_id_by_name(conn, project_name: str) -> Optional[int]:
|
||||
row = conn.execute(
|
||||
text("SELECT id FROM projects WHERE name = :name"),
|
||||
@@ -960,6 +1455,7 @@ def init_db(database_url: Optional[str] = None) -> None:
|
||||
seed_roles(engine, is_sqlite)
|
||||
seed_admin_user(engine, is_sqlite)
|
||||
ensure_default_pricing(engine, is_sqlite)
|
||||
seed_navigation(engine, is_sqlite)
|
||||
ensure_default_projects(engine, is_sqlite)
|
||||
ensure_default_scenarios(engine, is_sqlite)
|
||||
ensure_default_financial_inputs(engine, is_sqlite)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Service layer utilities."""
|
||||
|
||||
from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult
|
||||
from .calculations import calculate_profitability
|
||||
|
||||
__all__ = [
|
||||
"calculate_pricing",
|
||||
"PricingInput",
|
||||
"PricingMetadata",
|
||||
"PricingResult",
|
||||
"calculate_profitability",
|
||||
]
|
||||
|
||||
535
services/calculations.py
Normal file
535
services/calculations.py
Normal file
@@ -0,0 +1,535 @@
|
||||
"""Service functions for financial calculations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from statistics import fmean
|
||||
|
||||
from services.currency import CurrencyValidationError, normalise_currency
|
||||
from services.exceptions import (
|
||||
CapexValidationError,
|
||||
OpexValidationError,
|
||||
ProfitabilityValidationError,
|
||||
)
|
||||
from services.financial import (
|
||||
CashFlow,
|
||||
ConvergenceError,
|
||||
PaybackNotReachedError,
|
||||
internal_rate_of_return,
|
||||
net_present_value,
|
||||
payback_period,
|
||||
)
|
||||
from services.pricing import PricingInput, PricingMetadata, PricingResult, calculate_pricing
|
||||
from schemas.calculations import (
|
||||
CapexCalculationRequest,
|
||||
CapexCalculationResult,
|
||||
CapexCategoryBreakdown,
|
||||
CapexComponentInput,
|
||||
CapexTotals,
|
||||
CapexTimelineEntry,
|
||||
CashFlowEntry,
|
||||
OpexCalculationRequest,
|
||||
OpexCalculationResult,
|
||||
OpexCategoryBreakdown,
|
||||
OpexComponentInput,
|
||||
OpexMetrics,
|
||||
OpexParameters,
|
||||
OpexTotals,
|
||||
OpexTimelineEntry,
|
||||
ProfitabilityCalculationRequest,
|
||||
ProfitabilityCalculationResult,
|
||||
ProfitabilityCosts,
|
||||
ProfitabilityMetrics,
|
||||
)
|
||||
|
||||
|
||||
_FREQUENCY_MULTIPLIER = {
|
||||
"daily": 365,
|
||||
"weekly": 52,
|
||||
"monthly": 12,
|
||||
"quarterly": 4,
|
||||
"annually": 1,
|
||||
}
|
||||
|
||||
|
||||
def _build_pricing_input(
|
||||
request: ProfitabilityCalculationRequest,
|
||||
) -> PricingInput:
|
||||
"""Construct a pricing input instance including impurity overrides."""
|
||||
|
||||
impurity_values: dict[str, float] = {}
|
||||
impurity_thresholds: dict[str, float] = {}
|
||||
impurity_penalties: dict[str, float] = {}
|
||||
|
||||
for impurity in request.impurities:
|
||||
code = impurity.name.strip()
|
||||
if not code:
|
||||
continue
|
||||
code = code.upper()
|
||||
if impurity.value is not None:
|
||||
impurity_values[code] = float(impurity.value)
|
||||
if impurity.threshold is not None:
|
||||
impurity_thresholds[code] = float(impurity.threshold)
|
||||
if impurity.penalty is not None:
|
||||
impurity_penalties[code] = float(impurity.penalty)
|
||||
|
||||
pricing_input = PricingInput(
|
||||
metal=request.metal,
|
||||
ore_tonnage=request.ore_tonnage,
|
||||
head_grade_pct=request.head_grade_pct,
|
||||
recovery_pct=request.recovery_pct,
|
||||
payable_pct=request.payable_pct,
|
||||
reference_price=request.reference_price,
|
||||
treatment_charge=request.treatment_charge,
|
||||
smelting_charge=request.smelting_charge,
|
||||
moisture_pct=request.moisture_pct,
|
||||
moisture_threshold_pct=request.moisture_threshold_pct,
|
||||
moisture_penalty_per_pct=request.moisture_penalty_per_pct,
|
||||
impurity_ppm=impurity_values,
|
||||
impurity_thresholds=impurity_thresholds,
|
||||
impurity_penalty_per_ppm=impurity_penalties,
|
||||
premiums=request.premiums,
|
||||
fx_rate=request.fx_rate,
|
||||
currency_code=request.currency_code,
|
||||
)
|
||||
|
||||
return pricing_input
|
||||
|
||||
|
||||
def _generate_cash_flows(
|
||||
*,
|
||||
periods: int,
|
||||
net_per_period: float,
|
||||
capex: float,
|
||||
) -> tuple[list[CashFlow], list[CashFlowEntry]]:
|
||||
"""Create cash flow structures for financial metric calculations."""
|
||||
|
||||
cash_flow_models: list[CashFlow] = [
|
||||
CashFlow(amount=-capex, period_index=0)
|
||||
]
|
||||
cash_flow_entries: list[CashFlowEntry] = [
|
||||
CashFlowEntry(
|
||||
period=0,
|
||||
revenue=0.0,
|
||||
opex=0.0,
|
||||
sustaining_capex=0.0,
|
||||
net=-capex,
|
||||
)
|
||||
]
|
||||
|
||||
for period in range(1, periods + 1):
|
||||
cash_flow_models.append(
|
||||
CashFlow(amount=net_per_period, period_index=period))
|
||||
cash_flow_entries.append(
|
||||
CashFlowEntry(
|
||||
period=period,
|
||||
revenue=0.0,
|
||||
opex=0.0,
|
||||
sustaining_capex=0.0,
|
||||
net=net_per_period,
|
||||
)
|
||||
)
|
||||
|
||||
return cash_flow_models, cash_flow_entries
|
||||
|
||||
|
||||
def calculate_profitability(
|
||||
request: ProfitabilityCalculationRequest,
|
||||
*,
|
||||
metadata: PricingMetadata,
|
||||
) -> ProfitabilityCalculationResult:
|
||||
"""Calculate profitability metrics using pricing inputs and cost data."""
|
||||
|
||||
if request.periods <= 0:
|
||||
raise ProfitabilityValidationError(
|
||||
"Evaluation periods must be at least 1.", ["periods"]
|
||||
)
|
||||
|
||||
pricing_input = _build_pricing_input(request)
|
||||
try:
|
||||
pricing_result: PricingResult = calculate_pricing(
|
||||
pricing_input, metadata=metadata
|
||||
)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ProfitabilityValidationError(
|
||||
str(exc), ["currency_code"]) from exc
|
||||
|
||||
periods = request.periods
|
||||
revenue_total = float(pricing_result.net_revenue)
|
||||
revenue_per_period = revenue_total / periods
|
||||
|
||||
processing_total = float(request.opex) * periods
|
||||
sustaining_total = float(request.sustaining_capex) * periods
|
||||
capex = float(request.capex)
|
||||
|
||||
net_per_period = (
|
||||
revenue_per_period
|
||||
- float(request.opex)
|
||||
- float(request.sustaining_capex)
|
||||
)
|
||||
|
||||
cash_flow_models, cash_flow_entries = _generate_cash_flows(
|
||||
periods=periods,
|
||||
net_per_period=net_per_period,
|
||||
capex=capex,
|
||||
)
|
||||
|
||||
# Update per-period entries to include explicit costs for presentation
|
||||
for entry in cash_flow_entries[1:]:
|
||||
entry.revenue = revenue_per_period
|
||||
entry.opex = float(request.opex)
|
||||
entry.sustaining_capex = float(request.sustaining_capex)
|
||||
entry.net = net_per_period
|
||||
|
||||
discount_rate = (request.discount_rate or 0.0) / 100.0
|
||||
|
||||
npv_value = net_present_value(discount_rate, cash_flow_models)
|
||||
|
||||
try:
|
||||
irr_value = internal_rate_of_return(cash_flow_models) * 100.0
|
||||
except (ValueError, ZeroDivisionError, ConvergenceError):
|
||||
irr_value = None
|
||||
|
||||
try:
|
||||
payback_value = payback_period(cash_flow_models)
|
||||
except (ValueError, PaybackNotReachedError):
|
||||
payback_value = None
|
||||
|
||||
total_costs = processing_total + sustaining_total + capex
|
||||
total_net = revenue_total - total_costs
|
||||
|
||||
if revenue_total == 0:
|
||||
margin_value = None
|
||||
else:
|
||||
margin_value = (total_net / revenue_total) * 100.0
|
||||
|
||||
currency = request.currency_code or pricing_result.currency
|
||||
try:
|
||||
currency = normalise_currency(currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise ProfitabilityValidationError(
|
||||
str(exc), ["currency_code"]) from exc
|
||||
|
||||
costs = ProfitabilityCosts(
|
||||
opex_total=processing_total,
|
||||
sustaining_capex_total=sustaining_total,
|
||||
capex=capex,
|
||||
)
|
||||
|
||||
metrics = ProfitabilityMetrics(
|
||||
npv=npv_value,
|
||||
irr=irr_value,
|
||||
payback_period=payback_value,
|
||||
margin=margin_value,
|
||||
)
|
||||
|
||||
return ProfitabilityCalculationResult(
|
||||
pricing=pricing_result,
|
||||
costs=costs,
|
||||
metrics=metrics,
|
||||
cash_flows=cash_flow_entries,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
def calculate_initial_capex(
|
||||
request: CapexCalculationRequest,
|
||||
) -> CapexCalculationResult:
|
||||
"""Aggregate capex components into totals and timelines."""
|
||||
|
||||
if not request.components:
|
||||
raise CapexValidationError(
|
||||
"At least one capex component is required for calculation.",
|
||||
["components"],
|
||||
)
|
||||
|
||||
parameters = request.parameters
|
||||
|
||||
base_currency = parameters.currency_code
|
||||
if base_currency:
|
||||
try:
|
||||
base_currency = normalise_currency(base_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
overall = 0.0
|
||||
category_totals: dict[str, float] = defaultdict(float)
|
||||
timeline_totals: dict[int, float] = defaultdict(float)
|
||||
normalised_components: list[CapexComponentInput] = []
|
||||
|
||||
for index, component in enumerate(request.components):
|
||||
amount = float(component.amount)
|
||||
overall += amount
|
||||
|
||||
category_totals[component.category] += amount
|
||||
|
||||
spend_year = component.spend_year or 0
|
||||
timeline_totals[spend_year] += amount
|
||||
|
||||
component_currency = component.currency
|
||||
if component_currency:
|
||||
try:
|
||||
component_currency = normalise_currency(component_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), [f"components[{index}].currency"]
|
||||
) from exc
|
||||
|
||||
if base_currency is None and component_currency:
|
||||
base_currency = component_currency
|
||||
elif (
|
||||
base_currency is not None
|
||||
and component_currency is not None
|
||||
and component_currency != base_currency
|
||||
):
|
||||
raise CapexValidationError(
|
||||
(
|
||||
"Component currency does not match the global currency. "
|
||||
f"Expected {base_currency}, got {component_currency}."
|
||||
),
|
||||
[f"components[{index}].currency"],
|
||||
)
|
||||
|
||||
normalised_components.append(
|
||||
CapexComponentInput(
|
||||
id=component.id,
|
||||
name=component.name,
|
||||
category=component.category,
|
||||
amount=amount,
|
||||
currency=component_currency,
|
||||
spend_year=component.spend_year,
|
||||
notes=component.notes,
|
||||
)
|
||||
)
|
||||
|
||||
contingency_pct = float(parameters.contingency_pct or 0.0)
|
||||
contingency_amount = overall * (contingency_pct / 100.0)
|
||||
grand_total = overall + contingency_amount
|
||||
|
||||
category_breakdowns: list[CapexCategoryBreakdown] = []
|
||||
if category_totals:
|
||||
for category, total in sorted(category_totals.items()):
|
||||
share = (total / overall * 100.0) if overall else None
|
||||
category_breakdowns.append(
|
||||
CapexCategoryBreakdown(
|
||||
category=category,
|
||||
amount=total,
|
||||
share=share,
|
||||
)
|
||||
)
|
||||
|
||||
cumulative = 0.0
|
||||
timeline_entries: list[CapexTimelineEntry] = []
|
||||
for year, spend in sorted(timeline_totals.items()):
|
||||
cumulative += spend
|
||||
timeline_entries.append(
|
||||
CapexTimelineEntry(year=year, spend=spend, cumulative=cumulative)
|
||||
)
|
||||
|
||||
try:
|
||||
currency = normalise_currency(base_currency) if base_currency else None
|
||||
except CurrencyValidationError as exc:
|
||||
raise CapexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
totals = CapexTotals(
|
||||
overall=overall,
|
||||
contingency_pct=contingency_pct,
|
||||
contingency_amount=contingency_amount,
|
||||
with_contingency=grand_total,
|
||||
by_category=category_breakdowns,
|
||||
)
|
||||
|
||||
return CapexCalculationResult(
|
||||
totals=totals,
|
||||
timeline=timeline_entries,
|
||||
components=normalised_components,
|
||||
parameters=parameters,
|
||||
options=request.options,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
def calculate_opex(
|
||||
request: OpexCalculationRequest,
|
||||
) -> OpexCalculationResult:
|
||||
"""Aggregate opex components into annual totals and timeline."""
|
||||
|
||||
if not request.components:
|
||||
raise OpexValidationError(
|
||||
"At least one opex component is required for calculation.",
|
||||
["components"],
|
||||
)
|
||||
|
||||
parameters: OpexParameters = request.parameters
|
||||
base_currency = parameters.currency_code
|
||||
if base_currency:
|
||||
try:
|
||||
base_currency = normalise_currency(base_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise OpexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
evaluation_horizon = parameters.evaluation_horizon_years or 1
|
||||
if evaluation_horizon <= 0:
|
||||
raise OpexValidationError(
|
||||
"Evaluation horizon must be at least 1 year.",
|
||||
["parameters.evaluation_horizon_years"],
|
||||
)
|
||||
|
||||
escalation_pct = float(parameters.escalation_pct or 0.0)
|
||||
apply_escalation = bool(parameters.apply_escalation)
|
||||
|
||||
category_totals: dict[str, float] = defaultdict(float)
|
||||
timeline_totals: dict[int, float] = defaultdict(float)
|
||||
timeline_escalated: dict[int, float] = defaultdict(float)
|
||||
normalised_components: list[OpexComponentInput] = []
|
||||
|
||||
max_period_end = evaluation_horizon
|
||||
|
||||
for index, component in enumerate(request.components):
|
||||
frequency = component.frequency.lower()
|
||||
multiplier = _FREQUENCY_MULTIPLIER.get(frequency)
|
||||
if multiplier is None:
|
||||
raise OpexValidationError(
|
||||
f"Unsupported frequency '{component.frequency}'.",
|
||||
[f"components[{index}].frequency"],
|
||||
)
|
||||
|
||||
unit_cost = float(component.unit_cost)
|
||||
quantity = float(component.quantity)
|
||||
annual_cost = unit_cost * quantity * multiplier
|
||||
|
||||
period_start = component.period_start or 1
|
||||
period_end = component.period_end or evaluation_horizon
|
||||
if period_end < period_start:
|
||||
raise OpexValidationError(
|
||||
(
|
||||
"Component period_end must be greater than or equal to "
|
||||
"period_start."
|
||||
),
|
||||
[f"components[{index}].period_end"],
|
||||
)
|
||||
|
||||
max_period_end = max(max_period_end, period_end)
|
||||
|
||||
component_currency = component.currency
|
||||
if component_currency:
|
||||
try:
|
||||
component_currency = normalise_currency(component_currency)
|
||||
except CurrencyValidationError as exc:
|
||||
raise OpexValidationError(
|
||||
str(exc), [f"components[{index}].currency"]
|
||||
) from exc
|
||||
|
||||
if base_currency is None and component_currency:
|
||||
base_currency = component_currency
|
||||
elif (
|
||||
base_currency is not None
|
||||
and component_currency is not None
|
||||
and component_currency != base_currency
|
||||
):
|
||||
raise OpexValidationError(
|
||||
(
|
||||
"Component currency does not match the global currency. "
|
||||
f"Expected {base_currency}, got {component_currency}."
|
||||
),
|
||||
[f"components[{index}].currency"],
|
||||
)
|
||||
|
||||
category_totals[component.category] += annual_cost
|
||||
|
||||
for period in range(period_start, period_end + 1):
|
||||
timeline_totals[period] += annual_cost
|
||||
|
||||
normalised_components.append(
|
||||
OpexComponentInput(
|
||||
id=component.id,
|
||||
name=component.name,
|
||||
category=component.category,
|
||||
unit_cost=unit_cost,
|
||||
quantity=quantity,
|
||||
frequency=frequency,
|
||||
currency=component_currency,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
notes=component.notes,
|
||||
)
|
||||
)
|
||||
|
||||
evaluation_horizon = max(evaluation_horizon, max_period_end)
|
||||
|
||||
try:
|
||||
currency = normalise_currency(base_currency) if base_currency else None
|
||||
except CurrencyValidationError as exc:
|
||||
raise OpexValidationError(
|
||||
str(exc), ["parameters.currency_code"]
|
||||
) from exc
|
||||
|
||||
timeline_entries: list[OpexTimelineEntry] = []
|
||||
escalated_values: list[float] = []
|
||||
overall_annual = timeline_totals.get(1, 0.0)
|
||||
escalated_total = 0.0
|
||||
|
||||
for period in range(1, evaluation_horizon + 1):
|
||||
base_cost = timeline_totals.get(period, 0.0)
|
||||
if apply_escalation:
|
||||
factor = (1 + escalation_pct / 100.0) ** (period - 1)
|
||||
else:
|
||||
factor = 1.0
|
||||
escalated_cost = base_cost * factor
|
||||
timeline_escalated[period] = escalated_cost
|
||||
escalated_total += escalated_cost
|
||||
timeline_entries.append(
|
||||
OpexTimelineEntry(
|
||||
period=period,
|
||||
base_cost=base_cost,
|
||||
escalated_cost=escalated_cost if apply_escalation else None,
|
||||
)
|
||||
)
|
||||
escalated_values.append(escalated_cost)
|
||||
|
||||
category_breakdowns: list[OpexCategoryBreakdown] = []
|
||||
total_base = sum(category_totals.values())
|
||||
for category, total in sorted(category_totals.items()):
|
||||
share = (total / total_base * 100.0) if total_base else None
|
||||
category_breakdowns.append(
|
||||
OpexCategoryBreakdown(
|
||||
category=category,
|
||||
annual_cost=total,
|
||||
share=share,
|
||||
)
|
||||
)
|
||||
|
||||
metrics = OpexMetrics(
|
||||
annual_average=fmean(escalated_values) if escalated_values else None,
|
||||
cost_per_ton=None,
|
||||
)
|
||||
|
||||
totals = OpexTotals(
|
||||
overall_annual=overall_annual,
|
||||
escalated_total=escalated_total if apply_escalation else None,
|
||||
escalation_pct=escalation_pct if apply_escalation else None,
|
||||
by_category=category_breakdowns,
|
||||
)
|
||||
|
||||
return OpexCalculationResult(
|
||||
totals=totals,
|
||||
timeline=timeline_entries,
|
||||
metrics=metrics,
|
||||
components=normalised_components,
|
||||
parameters=parameters,
|
||||
options=request.options,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"calculate_profitability",
|
||||
"calculate_initial_capex",
|
||||
"calculate_opex",
|
||||
]
|
||||
@@ -26,3 +26,36 @@ class ScenarioValidationError(Exception):
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class ProfitabilityValidationError(Exception):
|
||||
"""Raised when profitability calculation inputs fail domain validation."""
|
||||
|
||||
message: str
|
||||
field_errors: Sequence[str] | None = None
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class CapexValidationError(Exception):
|
||||
"""Raised when capex calculation inputs fail domain validation."""
|
||||
|
||||
message: str
|
||||
field_errors: Sequence[str] | None = None
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
|
||||
@dataclass(eq=False)
|
||||
class OpexValidationError(Exception):
|
||||
"""Raised when opex calculation inputs fail domain validation."""
|
||||
|
||||
message: str
|
||||
field_errors: Sequence[str] | None = None
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover - mirrors message for logging
|
||||
return self.message
|
||||
|
||||
203
services/navigation.py
Normal file
203
services/navigation.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable, List, Sequence
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from models.navigation import NavigationLink
|
||||
from services.repositories import NavigationRepository
|
||||
from services.session import AuthSession
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationLinkDTO:
|
||||
id: int
|
||||
label: str
|
||||
href: str
|
||||
match_prefix: str | None
|
||||
icon: str | None
|
||||
tooltip: str | None
|
||||
is_external: bool
|
||||
children: List["NavigationLinkDTO"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationGroupDTO:
|
||||
id: int
|
||||
label: str
|
||||
icon: str | None
|
||||
tooltip: str | None
|
||||
links: List[NavigationLinkDTO] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NavigationSidebarDTO:
|
||||
groups: List[NavigationGroupDTO]
|
||||
roles: tuple[str, ...]
|
||||
|
||||
|
||||
class NavigationService:
|
||||
"""Build navigation payloads filtered for the current session."""
|
||||
|
||||
def __init__(self, repository: NavigationRepository) -> None:
|
||||
self._repository = repository
|
||||
|
||||
def build_sidebar(
|
||||
self,
|
||||
*,
|
||||
session: AuthSession,
|
||||
request: Request | None = None,
|
||||
include_disabled: bool = False,
|
||||
) -> NavigationSidebarDTO:
|
||||
roles = self._collect_roles(session)
|
||||
groups = self._repository.list_groups_with_links(
|
||||
include_disabled=include_disabled
|
||||
)
|
||||
context = self._derive_context(request)
|
||||
|
||||
mapped_groups: List[NavigationGroupDTO] = []
|
||||
for group in groups:
|
||||
if not include_disabled and not group.is_enabled:
|
||||
continue
|
||||
mapped_links = self._map_links(
|
||||
group.links,
|
||||
roles,
|
||||
request=request,
|
||||
include_disabled=include_disabled,
|
||||
context=context,
|
||||
)
|
||||
if not mapped_links and not include_disabled:
|
||||
continue
|
||||
mapped_groups.append(
|
||||
NavigationGroupDTO(
|
||||
id=group.id,
|
||||
label=group.label,
|
||||
icon=group.icon,
|
||||
tooltip=group.tooltip,
|
||||
links=mapped_links,
|
||||
)
|
||||
)
|
||||
return NavigationSidebarDTO(groups=mapped_groups, roles=roles)
|
||||
|
||||
def _map_links(
|
||||
self,
|
||||
links: Sequence[NavigationLink],
|
||||
roles: Iterable[str],
|
||||
*,
|
||||
request: Request | None,
|
||||
include_disabled: bool,
|
||||
context: dict[str, str | None],
|
||||
include_children: bool = False,
|
||||
) -> List[NavigationLinkDTO]:
|
||||
resolved_roles = tuple(roles)
|
||||
mapped: List[NavigationLinkDTO] = []
|
||||
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):
|
||||
continue
|
||||
if not self._link_visible(link, resolved_roles, include_disabled):
|
||||
continue
|
||||
href = self._resolve_href(link, request=request, context=context)
|
||||
if not href:
|
||||
continue
|
||||
children = self._map_links(
|
||||
link.children,
|
||||
resolved_roles,
|
||||
request=request,
|
||||
include_disabled=include_disabled,
|
||||
context=context,
|
||||
include_children=True,
|
||||
)
|
||||
match_prefix = link.match_prefix or href
|
||||
mapped.append(
|
||||
NavigationLinkDTO(
|
||||
id=link.id,
|
||||
label=link.label,
|
||||
href=href,
|
||||
match_prefix=match_prefix,
|
||||
icon=link.icon,
|
||||
tooltip=link.tooltip,
|
||||
is_external=link.is_external,
|
||||
children=children,
|
||||
)
|
||||
)
|
||||
return mapped
|
||||
|
||||
@staticmethod
|
||||
def _collect_roles(session: AuthSession) -> tuple[str, ...]:
|
||||
roles = tuple((session.role_slugs or ()) if session else ())
|
||||
if session and session.is_authenticated:
|
||||
return roles
|
||||
if "anonymous" in roles:
|
||||
return roles
|
||||
return roles + ("anonymous",)
|
||||
|
||||
@staticmethod
|
||||
def _derive_context(request: Request | None) -> dict[str, str | None]:
|
||||
if request is None:
|
||||
return {"project_id": None, "scenario_id": None}
|
||||
project_id = request.path_params.get(
|
||||
"project_id") if hasattr(request, "path_params") else None
|
||||
scenario_id = request.path_params.get(
|
||||
"scenario_id") if hasattr(request, "path_params") else None
|
||||
if not project_id:
|
||||
project_id = request.query_params.get("project_id")
|
||||
if not scenario_id:
|
||||
scenario_id = request.query_params.get("scenario_id")
|
||||
return {"project_id": project_id, "scenario_id": scenario_id}
|
||||
|
||||
def _resolve_href(
|
||||
self,
|
||||
link: NavigationLink,
|
||||
*,
|
||||
request: Request | None,
|
||||
context: dict[str, str | None],
|
||||
) -> str | None:
|
||||
if link.route_name:
|
||||
if request is None:
|
||||
fallback = link.href_override
|
||||
if fallback:
|
||||
return fallback
|
||||
# Fallback to route name when no request is available
|
||||
return f"/{link.route_name.replace('.', '/')}"
|
||||
requires_context = link.slug in {
|
||||
"profitability",
|
||||
"profitability-calculator",
|
||||
"opex",
|
||||
"capex",
|
||||
}
|
||||
if requires_context:
|
||||
project_id = context.get("project_id")
|
||||
scenario_id = context.get("scenario_id")
|
||||
if project_id and scenario_id:
|
||||
try:
|
||||
return str(
|
||||
request.url_for(
|
||||
link.route_name,
|
||||
project_id=project_id,
|
||||
scenario_id=scenario_id,
|
||||
)
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
try:
|
||||
return str(request.url_for(link.route_name))
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return link.href_override
|
||||
return link.href_override
|
||||
|
||||
@staticmethod
|
||||
def _link_visible(
|
||||
link: NavigationLink,
|
||||
roles: Iterable[str],
|
||||
include_disabled: bool,
|
||||
) -> bool:
|
||||
role_tuple = tuple(roles)
|
||||
if not include_disabled and not link.is_enabled:
|
||||
return False
|
||||
if not link.required_roles:
|
||||
return True
|
||||
role_set = set(role_tuple)
|
||||
return any(role in role_set for role in link.required_roles)
|
||||
@@ -15,8 +15,16 @@ from models import (
|
||||
PricingImpuritySettings,
|
||||
PricingMetalSettings,
|
||||
PricingSettings,
|
||||
ProjectCapexSnapshot,
|
||||
ProjectProfitability,
|
||||
ProjectOpexSnapshot,
|
||||
NavigationGroup,
|
||||
NavigationLink,
|
||||
Role,
|
||||
Scenario,
|
||||
ScenarioCapexSnapshot,
|
||||
ScenarioProfitability,
|
||||
ScenarioOpexSnapshot,
|
||||
ScenarioStatus,
|
||||
SimulationParameter,
|
||||
User,
|
||||
@@ -32,6 +40,54 @@ def _enum_value(e):
|
||||
return getattr(e, "value", e)
|
||||
|
||||
|
||||
class NavigationRepository:
|
||||
"""Persistence operations for navigation metadata."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def list_groups_with_links(
|
||||
self,
|
||||
*,
|
||||
include_disabled: bool = False,
|
||||
) -> Sequence[NavigationGroup]:
|
||||
stmt = (
|
||||
select(NavigationGroup)
|
||||
.options(
|
||||
selectinload(NavigationGroup.links)
|
||||
.selectinload(NavigationLink.children)
|
||||
)
|
||||
.order_by(NavigationGroup.sort_order, NavigationGroup.id)
|
||||
)
|
||||
if not include_disabled:
|
||||
stmt = stmt.where(NavigationGroup.is_enabled.is_(True))
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def get_group_by_slug(self, slug: str) -> NavigationGroup | None:
|
||||
stmt = select(NavigationGroup).where(NavigationGroup.slug == slug)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def get_link_by_slug(
|
||||
self,
|
||||
slug: str,
|
||||
*,
|
||||
group_id: int | None = None,
|
||||
) -> NavigationLink | None:
|
||||
stmt = select(NavigationLink).where(NavigationLink.slug == slug)
|
||||
if group_id is not None:
|
||||
stmt = stmt.where(NavigationLink.group_id == group_id)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def add_group(self, group: NavigationGroup) -> NavigationGroup:
|
||||
self.session.add(group)
|
||||
self.session.flush()
|
||||
return group
|
||||
|
||||
def add_link(self, link: NavigationLink) -> NavigationLink:
|
||||
self.session.add(link)
|
||||
self.session.flush()
|
||||
return link
|
||||
|
||||
class ProjectRepository:
|
||||
"""Persistence operations for Project entities."""
|
||||
|
||||
@@ -367,6 +423,310 @@ class ScenarioRepository:
|
||||
self.session.delete(scenario)
|
||||
|
||||
|
||||
class ProjectProfitabilityRepository:
|
||||
"""Persistence operations for project-level profitability snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ProjectProfitability) -> ProjectProfitability:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectProfitability]:
|
||||
stmt = (
|
||||
select(ProjectProfitability)
|
||||
.where(ProjectProfitability.project_id == project_id)
|
||||
.order_by(ProjectProfitability.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectProfitability | None:
|
||||
stmt = (
|
||||
select(ProjectProfitability)
|
||||
.where(ProjectProfitability.project_id == project_id)
|
||||
.order_by(ProjectProfitability.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectProfitability).where(
|
||||
ProjectProfitability.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project profitability snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioProfitabilityRepository:
|
||||
"""Persistence operations for scenario-level profitability snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ScenarioProfitability) -> ScenarioProfitability:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioProfitability]:
|
||||
stmt = (
|
||||
select(ScenarioProfitability)
|
||||
.where(ScenarioProfitability.scenario_id == scenario_id)
|
||||
.order_by(ScenarioProfitability.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioProfitability | None:
|
||||
stmt = (
|
||||
select(ScenarioProfitability)
|
||||
.where(ScenarioProfitability.scenario_id == scenario_id)
|
||||
.order_by(ScenarioProfitability.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioProfitability).where(
|
||||
ScenarioProfitability.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario profitability snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ProjectCapexRepository:
|
||||
"""Persistence operations for project-level capex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ProjectCapexSnapshot) -> ProjectCapexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectCapexSnapshot]:
|
||||
stmt = (
|
||||
select(ProjectCapexSnapshot)
|
||||
.where(ProjectCapexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectCapexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectCapexSnapshot | None:
|
||||
stmt = (
|
||||
select(ProjectCapexSnapshot)
|
||||
.where(ProjectCapexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectCapexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectCapexSnapshot).where(
|
||||
ProjectCapexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project capex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioCapexRepository:
|
||||
"""Persistence operations for scenario-level capex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(self, snapshot: ScenarioCapexSnapshot) -> ScenarioCapexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioCapexSnapshot]:
|
||||
stmt = (
|
||||
select(ScenarioCapexSnapshot)
|
||||
.where(ScenarioCapexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioCapexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioCapexSnapshot | None:
|
||||
stmt = (
|
||||
select(ScenarioCapexSnapshot)
|
||||
.where(ScenarioCapexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioCapexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioCapexSnapshot).where(
|
||||
ScenarioCapexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario capex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ProjectOpexRepository:
|
||||
"""Persistence operations for project-level opex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(
|
||||
self, snapshot: ProjectOpexSnapshot
|
||||
) -> ProjectOpexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ProjectOpexSnapshot]:
|
||||
stmt = (
|
||||
select(ProjectOpexSnapshot)
|
||||
.where(ProjectOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectOpexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_project(
|
||||
self,
|
||||
project_id: int,
|
||||
) -> ProjectOpexSnapshot | None:
|
||||
stmt = (
|
||||
select(ProjectOpexSnapshot)
|
||||
.where(ProjectOpexSnapshot.project_id == project_id)
|
||||
.order_by(ProjectOpexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ProjectOpexSnapshot).where(
|
||||
ProjectOpexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Project opex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class ScenarioOpexRepository:
|
||||
"""Persistence operations for scenario-level opex snapshots."""
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
def create(
|
||||
self, snapshot: ScenarioOpexSnapshot
|
||||
) -> ScenarioOpexSnapshot:
|
||||
self.session.add(snapshot)
|
||||
self.session.flush()
|
||||
return snapshot
|
||||
|
||||
def list_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
*,
|
||||
limit: int | None = None,
|
||||
) -> Sequence[ScenarioOpexSnapshot]:
|
||||
stmt = (
|
||||
select(ScenarioOpexSnapshot)
|
||||
.where(ScenarioOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioOpexSnapshot.calculated_at.desc())
|
||||
)
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
return self.session.execute(stmt).scalars().all()
|
||||
|
||||
def latest_for_scenario(
|
||||
self,
|
||||
scenario_id: int,
|
||||
) -> ScenarioOpexSnapshot | None:
|
||||
stmt = (
|
||||
select(ScenarioOpexSnapshot)
|
||||
.where(ScenarioOpexSnapshot.scenario_id == scenario_id)
|
||||
.order_by(ScenarioOpexSnapshot.calculated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return self.session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def delete(self, snapshot_id: int) -> None:
|
||||
stmt = select(ScenarioOpexSnapshot).where(
|
||||
ScenarioOpexSnapshot.id == snapshot_id
|
||||
)
|
||||
entity = self.session.execute(stmt).scalar_one_or_none()
|
||||
if entity is None:
|
||||
raise EntityNotFoundError(
|
||||
f"Scenario opex snapshot {snapshot_id} not found"
|
||||
)
|
||||
self.session.delete(entity)
|
||||
|
||||
|
||||
class FinancialInputRepository:
|
||||
"""Persistence operations for FinancialInput entities."""
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hmac import compare_digest
|
||||
from typing import Any, Dict, Iterable, Literal, Type
|
||||
|
||||
from jose import ExpiredSignatureError, JWTError, jwt
|
||||
@@ -176,6 +177,14 @@ def _decode_token(
|
||||
except JWTError as exc: # pragma: no cover - jose error bubble
|
||||
raise TokenDecodeError("Unable to decode token") from exc
|
||||
|
||||
expected_token = jwt.encode(
|
||||
decoded,
|
||||
settings.secret_key,
|
||||
algorithm=settings.algorithm,
|
||||
)
|
||||
if not compare_digest(token, expected_token):
|
||||
raise TokenDecodeError("Token contents have been altered.")
|
||||
|
||||
try:
|
||||
payload = _model_validate(TokenPayload, decoded)
|
||||
except ValidationError as exc:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional, TYPE_CHECKING
|
||||
from typing import Iterable, Literal, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
@@ -67,6 +67,7 @@ class AuthSession:
|
||||
tokens: SessionTokens
|
||||
user: Optional["User"] = None
|
||||
scopes: tuple[str, ...] = ()
|
||||
role_slugs: tuple[str, ...] = ()
|
||||
issued_access_token: Optional[str] = None
|
||||
issued_refresh_token: Optional[str] = None
|
||||
clear_cookies: bool = False
|
||||
@@ -77,7 +78,10 @@ class AuthSession:
|
||||
|
||||
@classmethod
|
||||
def anonymous(cls) -> "AuthSession":
|
||||
return cls(tokens=SessionTokens(access_token=None, refresh_token=None))
|
||||
return cls(
|
||||
tokens=SessionTokens(access_token=None, refresh_token=None),
|
||||
role_slugs=(),
|
||||
)
|
||||
|
||||
def issue_tokens(
|
||||
self,
|
||||
@@ -100,6 +104,10 @@ class AuthSession:
|
||||
self.tokens = SessionTokens(access_token=None, refresh_token=None)
|
||||
self.user = None
|
||||
self.scopes = ()
|
||||
self.role_slugs = ()
|
||||
|
||||
def set_role_slugs(self, roles: Iterable[str]) -> None:
|
||||
self.role_slugs = tuple(dict.fromkeys(role.strip().lower() for role in roles if role))
|
||||
|
||||
|
||||
def extract_session_tokens(request: Request, strategy: SessionStrategy) -> SessionTokens:
|
||||
|
||||
@@ -13,14 +13,21 @@ from services.repositories import (
|
||||
PricingSettingsRepository,
|
||||
PricingSettingsSeedResult,
|
||||
ProjectRepository,
|
||||
ProjectProfitabilityRepository,
|
||||
ProjectOpexRepository,
|
||||
ProjectCapexRepository,
|
||||
RoleRepository,
|
||||
ScenarioRepository,
|
||||
ScenarioProfitabilityRepository,
|
||||
ScenarioOpexRepository,
|
||||
ScenarioCapexRepository,
|
||||
SimulationParameterRepository,
|
||||
UserRepository,
|
||||
ensure_admin_user as ensure_admin_user_record,
|
||||
ensure_default_pricing_settings,
|
||||
ensure_default_roles,
|
||||
pricing_settings_to_metadata,
|
||||
NavigationRepository,
|
||||
)
|
||||
from services.scenario_validation import ScenarioComparisonValidator
|
||||
|
||||
@@ -36,9 +43,16 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.scenarios: ScenarioRepository | None = None
|
||||
self.financial_inputs: FinancialInputRepository | None = None
|
||||
self.simulation_parameters: SimulationParameterRepository | None = None
|
||||
self.project_profitability: ProjectProfitabilityRepository | None = None
|
||||
self.project_capex: ProjectCapexRepository | None = None
|
||||
self.project_opex: ProjectOpexRepository | None = None
|
||||
self.scenario_profitability: ScenarioProfitabilityRepository | None = None
|
||||
self.scenario_capex: ScenarioCapexRepository | None = None
|
||||
self.scenario_opex: ScenarioOpexRepository | None = None
|
||||
self.users: UserRepository | None = None
|
||||
self.roles: RoleRepository | None = None
|
||||
self.pricing_settings: PricingSettingsRepository | None = None
|
||||
self.navigation: NavigationRepository | None = None
|
||||
|
||||
def __enter__(self) -> "UnitOfWork":
|
||||
self.session = self._session_factory()
|
||||
@@ -47,9 +61,21 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.financial_inputs = FinancialInputRepository(self.session)
|
||||
self.simulation_parameters = SimulationParameterRepository(
|
||||
self.session)
|
||||
self.project_profitability = ProjectProfitabilityRepository(
|
||||
self.session)
|
||||
self.project_capex = ProjectCapexRepository(self.session)
|
||||
self.project_opex = ProjectOpexRepository(
|
||||
self.session)
|
||||
self.scenario_profitability = ScenarioProfitabilityRepository(
|
||||
self.session
|
||||
)
|
||||
self.scenario_capex = ScenarioCapexRepository(self.session)
|
||||
self.scenario_opex = ScenarioOpexRepository(
|
||||
self.session)
|
||||
self.users = UserRepository(self.session)
|
||||
self.roles = RoleRepository(self.session)
|
||||
self.pricing_settings = PricingSettingsRepository(self.session)
|
||||
self.navigation = NavigationRepository(self.session)
|
||||
self._scenario_validator = ScenarioComparisonValidator()
|
||||
return self
|
||||
|
||||
@@ -65,9 +91,16 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]):
|
||||
self.scenarios = None
|
||||
self.financial_inputs = None
|
||||
self.simulation_parameters = None
|
||||
self.project_profitability = None
|
||||
self.project_capex = None
|
||||
self.project_opex = None
|
||||
self.scenario_profitability = None
|
||||
self.scenario_capex = None
|
||||
self.scenario_opex = None
|
||||
self.users = None
|
||||
self.roles = None
|
||||
self.pricing_settings = None
|
||||
self.navigation = None
|
||||
|
||||
def flush(self) -> None:
|
||||
if not self.session:
|
||||
|
||||
@@ -2,17 +2,6 @@
|
||||
--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 {
|
||||
display: grid;
|
||||
gap: var(--dashboard-gap);
|
||||
@@ -20,36 +9,6 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: var(--dashboard-gap);
|
||||
@@ -67,16 +26,6 @@
|
||||
gap: var(--dashboard-gap);
|
||||
}
|
||||
|
||||
.table-link {
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.table-link:hover,
|
||||
.table-link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -107,7 +56,9 @@
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
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 color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||
}
|
||||
|
||||
.links-list a {
|
||||
@@ -128,23 +79,4 @@
|
||||
.grid-sidebar {
|
||||
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 {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: rgba(21, 27, 35, 0.85);
|
||||
background-color: var(--color-surface-overlay);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -11,7 +12,7 @@
|
||||
}
|
||||
|
||||
.import-upload__dropzone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -19,8 +20,10 @@
|
||||
}
|
||||
|
||||
.import-upload__dropzone.dragover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(0, 123, 255, 0.05);
|
||||
border-color: #f6c648;
|
||||
border-color: var(--color-brand-bright);
|
||||
background-color: rgba(241, 178, 26, 0.08);
|
||||
background-color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.import-upload__actions {
|
||||
@@ -35,18 +38,6 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
@@ -55,9 +46,9 @@
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text-invert);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -66,15 +57,18 @@
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
background-color: #198754;
|
||||
background-color: var(--success);
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
background-color: #dc3545;
|
||||
background-color: var(--danger);
|
||||
background-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
background-color: #0d6efd;
|
||||
background-color: var(--info);
|
||||
background-color: var(--color-info);
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--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);
|
||||
/* Radii & layout */
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--panel-radius: var(--radius);
|
||||
--table-radius: var(--radius-sm);
|
||||
--container: 1180px;
|
||||
--muted: var(--muted);
|
||||
--color-text-subtle: rgba(169, 180, 192, 0.6);
|
||||
--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);
|
||||
|
||||
/* Spacing & typography */
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
@@ -31,18 +14,13 @@
|
||||
--space-lg: 1.5rem;
|
||||
--space-xl: 2rem;
|
||||
--space-2xl: 3rem;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.25rem;
|
||||
--font-size-xl: 1.5rem;
|
||||
--font-size-2xl: 2rem;
|
||||
--panel-radius: var(--radius);
|
||||
--table-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -59,6 +37,13 @@ body {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
@@ -166,6 +151,36 @@ a {
|
||||
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 {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -183,7 +198,7 @@ a {
|
||||
|
||||
.metrics-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
.metrics-table tr:last-child td,
|
||||
@@ -194,23 +209,30 @@ a {
|
||||
.definition-list {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
gap: 1.25rem 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.definition-list div {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
grid-template-columns: minmax(140px, 0.6fr) minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.definition-list dt {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.definition-list dd {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
@@ -240,6 +262,13 @@ a {
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -285,6 +314,201 @@ a {
|
||||
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 {
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
@@ -302,6 +526,26 @@ a {
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a::after {
|
||||
content: ">";
|
||||
margin-left: 0.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -347,17 +591,17 @@ a.sidebar-brand:focus {
|
||||
.sidebar-nav-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-chevron {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 1.2rem;
|
||||
font-size: 4.5rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -368,8 +612,9 @@ a.sidebar-brand:focus {
|
||||
|
||||
.nav-chevron:hover,
|
||||
.nav-chevron:focus {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(255, 255, 255, 1);
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.nav-chevron:disabled {
|
||||
@@ -524,7 +769,7 @@ a.sidebar-brand:focus {
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
@@ -866,36 +1111,6 @@ a.sidebar-brand:focus {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -903,37 +1118,101 @@ a.sidebar-brand:focus {
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
background-color: var(--brand);
|
||||
color: var(--color-text-dark);
|
||||
background-color: var(--btn-secondary-bg);
|
||||
color: var(--btn-secondary-color);
|
||||
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:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 10px var(--color-panel-shadow);
|
||||
background-color: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background-color: var(--brand-2);
|
||||
color: var(--color-text-dark);
|
||||
.btn--primary,
|
||||
.btn.primary,
|
||||
.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:focus {
|
||||
background-color: var(--brand-3);
|
||||
.btn.primary:focus,
|
||||
.btn.btn-primary:hover,
|
||||
.btn.btn-primary:focus {
|
||||
background-color: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.btn.btn-link {
|
||||
background: var(--brand);
|
||||
color: var(--color-text-dark);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--brand);
|
||||
margin-bottom: 0.5rem;
|
||||
.btn--secondary,
|
||||
.btn.secondary,
|
||||
.btn.btn-secondary {
|
||||
background-color: var(--btn-secondary-bg);
|
||||
border-color: var(--btn-secondary-border);
|
||||
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 {
|
||||
@@ -1031,7 +1310,7 @@ tbody tr:nth-child(even) {
|
||||
|
||||
.site-footer {
|
||||
background-color: var(--brand);
|
||||
color: var(--color-text-invert);
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
@@ -1056,6 +1335,19 @@ tbody tr:nth-child(even) {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
footer p {
|
||||
margin: 0;
|
||||
}
|
||||
footer a {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
footer a:hover,
|
||||
footer a:focus {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
align-items: center;
|
||||
@@ -1122,10 +1414,62 @@ tbody tr:nth-child(even) {
|
||||
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) {
|
||||
.app-sidebar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@@ -1155,8 +1499,16 @@ tbody tr:nth-child(even) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-nav-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-link-block {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
flex: 1 1 140px;
|
||||
flex: 1 1 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1538,10 @@ tbody tr:nth-child(even) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.sidebar-open .app-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
body.sidebar-open .app-sidebar {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -1194,7 +1550,7 @@ tbody tr:nth-child(even) {
|
||||
width: min(320px, 82vw);
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
z-index: 900;
|
||||
z-index: 999;
|
||||
box-shadow: 0 12px 30px rgba(8, 14, 25, 0.4);
|
||||
}
|
||||
|
||||
@@ -1202,9 +1558,4 @@ tbody tr:nth-child(even) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.sidebar-open .app-main {
|
||||
position: relative;
|
||||
z-index: 950;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,103 @@
|
||||
: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);
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
.project-card {
|
||||
background: var(--color-surface-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.project-card:hover,
|
||||
.project-card:focus-within {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 22px 45px var(--color-panel-shadow-deep);
|
||||
}
|
||||
|
||||
.project-card__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.project-card__title a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.project-card__title a:hover,
|
||||
.project-card__title a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.project-card__type {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.project-card__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-subtle);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.project-card__meta {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.project-card__meta div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.project-card__meta dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.project-card__meta dd {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.project-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-card__links {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.project-card__links .btn--link {
|
||||
padding: 3px 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.project-metrics {
|
||||
@@ -18,39 +107,9 @@
|
||||
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 {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--color-surface-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.75rem;
|
||||
@@ -59,34 +118,43 @@
|
||||
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 {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
background: var(--color-surface-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.project-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-scenarios-card__header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -103,41 +171,6 @@
|
||||
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-align: right;
|
||||
}
|
||||
@@ -147,42 +180,4 @@
|
||||
grid-template-columns: 1.1fr 1.9fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.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,49 +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);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: var(--brand-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.scenario-metrics {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
@@ -51,36 +5,6 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -107,11 +31,13 @@
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
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 {
|
||||
background: rgba(21, 27, 35, 0.85);
|
||||
background: var(--color-surface-overlay);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -121,25 +47,85 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: var(--table-radius);
|
||||
.scenario-form .card {
|
||||
background: rgba(21, 27, 35, 0.9);
|
||||
background: color-mix(in srgb, var(--color-surface-default) 90%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.scenario-form .card h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.table-responsive .table {
|
||||
min-width: 640px;
|
||||
.scenario-layout {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.table-responsive::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
.scenario-column {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.table-responsive::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 999px;
|
||||
.quick-actions-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-portfolio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.scenario-portfolio__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-context-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.scenario-context-card .definition-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scenario-defaults {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-defaults li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.scenario-defaults li strong {
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
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) {
|
||||
@@ -151,10 +137,6 @@
|
||||
.scenario-filters .filter-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-responsive .table {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -162,34 +144,9 @@
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.scenario-layout {
|
||||
grid-template-columns: 1.1fr 1.9fr;
|
||||
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);
|
||||
}
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
@@ -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;
|
||||
}
|
||||
});
|
||||
493
static/js/navigation_sidebar.js
Normal file
493
static/js/navigation_sidebar.js
Normal file
@@ -0,0 +1,493 @@
|
||||
(function () {
|
||||
const NAV_ENDPOINT = "/navigation/sidebar";
|
||||
const SIDEBAR_SELECTOR = ".sidebar-nav";
|
||||
const DATA_SOURCE_ATTR = "navigationSource";
|
||||
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) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", callback, { once: true });
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function isActivePath(pathname, matchPrefix) {
|
||||
if (!matchPrefix) {
|
||||
return false;
|
||||
}
|
||||
if (matchPrefix === "/") {
|
||||
return pathname === "/";
|
||||
}
|
||||
return pathname.startsWith(matchPrefix);
|
||||
}
|
||||
|
||||
function createAnchor({
|
||||
href,
|
||||
label,
|
||||
matchPrefix,
|
||||
tooltip,
|
||||
isExternal,
|
||||
isActive,
|
||||
className,
|
||||
}) {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.className = className + (isActive ? " is-active" : "");
|
||||
anchor.dataset.matchPrefix = matchPrefix || href;
|
||||
if (tooltip) {
|
||||
anchor.title = tooltip;
|
||||
}
|
||||
if (isExternal) {
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener noreferrer";
|
||||
anchor.classList.add("is-external");
|
||||
}
|
||||
anchor.textContent = label;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function buildLinkBlock(link, pathname) {
|
||||
if (!link || !link.href) {
|
||||
return null;
|
||||
}
|
||||
const matchPrefix = link.match_prefix || link.matchPrefix || link.href;
|
||||
const isActive = isActivePath(pathname, matchPrefix);
|
||||
|
||||
const block = document.createElement("div");
|
||||
block.className = "sidebar-link-block";
|
||||
if (typeof link.id === "number") {
|
||||
block.dataset.linkId = String(link.id);
|
||||
}
|
||||
|
||||
const anchor = createAnchor({
|
||||
href: link.href,
|
||||
label: link.label,
|
||||
matchPrefix,
|
||||
tooltip: link.tooltip,
|
||||
isExternal: Boolean(link.is_external ?? link.isExternal),
|
||||
isActive,
|
||||
className: "sidebar-link",
|
||||
});
|
||||
block.appendChild(anchor);
|
||||
|
||||
const children = Array.isArray(link.children) ? link.children : [];
|
||||
if (children.length > 0) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "sidebar-sublinks";
|
||||
for (const child of children) {
|
||||
if (!child || !child.href) {
|
||||
continue;
|
||||
}
|
||||
const childMatch =
|
||||
child.match_prefix || child.matchPrefix || child.href;
|
||||
const childActive = isActivePath(pathname, childMatch);
|
||||
const childAnchor = createAnchor({
|
||||
href: child.href,
|
||||
label: child.label,
|
||||
matchPrefix: childMatch,
|
||||
tooltip: child.tooltip,
|
||||
isExternal: Boolean(child.is_external ?? child.isExternal),
|
||||
isActive: childActive,
|
||||
className: "sidebar-sublink",
|
||||
});
|
||||
container.appendChild(childAnchor);
|
||||
}
|
||||
if (container.children.length > 0) {
|
||||
block.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function buildGroupSection(group, pathname) {
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const links = Array.isArray(group.links) ? group.links : [];
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section";
|
||||
if (typeof group.id === "number") {
|
||||
section.dataset.groupId = String(group.id);
|
||||
}
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = group.label;
|
||||
section.appendChild(label);
|
||||
|
||||
const linksContainer = document.createElement("div");
|
||||
linksContainer.className = "sidebar-section-links";
|
||||
|
||||
for (const link of links) {
|
||||
const block = buildLinkBlock(link, pathname);
|
||||
if (block) {
|
||||
linksContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
if (linksContainer.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
section.appendChild(linksContainer);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildEmptyState() {
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section sidebar-empty-state";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = "Navigation";
|
||||
section.appendChild(label);
|
||||
|
||||
const copyWrapper = document.createElement("div");
|
||||
copyWrapper.className = "sidebar-section-links";
|
||||
|
||||
const copy = document.createElement("p");
|
||||
copy.className = "sidebar-empty-copy";
|
||||
copy.textContent = "Navigation is unavailable.";
|
||||
copyWrapper.appendChild(copy);
|
||||
|
||||
section.appendChild(copyWrapper);
|
||||
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) {
|
||||
const pathname = window.location.pathname;
|
||||
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
||||
navContainer.replaceChildren();
|
||||
|
||||
const rendered = [];
|
||||
for (const group of groups) {
|
||||
const section = buildGroupSection(group, pathname);
|
||||
if (section) {
|
||||
rendered.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
if (rendered.length === 0) {
|
||||
navContainer.appendChild(buildEmptyState());
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
configureChevronButtons([]);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of rendered) {
|
||||
navContainer.appendChild(section);
|
||||
}
|
||||
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client";
|
||||
const roles = Array.isArray(payload?.roles) ? payload.roles : [];
|
||||
if (roles.length > 0) {
|
||||
navContainer.dataset[ROLE_ATTR] = roles.join(",");
|
||||
} else {
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
}
|
||||
|
||||
configureChevronButtons(flattenNavigation(groups));
|
||||
}
|
||||
|
||||
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 {
|
||||
const response = await fetch(NAV_ENDPOINT, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (!cached || !cached.payload) {
|
||||
configureChevronButtons([]);
|
||||
}
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
console.warn(
|
||||
"Navigation sidebar hydration failed with status",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderSidebar(navContainer, payload);
|
||||
const payloadRoles = Array.isArray(payload?.roles)
|
||||
? payload.roles
|
||||
: roles || [];
|
||||
saveCache(serialiseRoles(payloadRoles), payload);
|
||||
} catch (error) {
|
||||
console.warn("Navigation sidebar hydration failed", error);
|
||||
if (!cached || !cached.payload) {
|
||||
configureChevronButtons([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onReady(() => {
|
||||
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
|
||||
if (!navContainer) {
|
||||
configureChevronButtons([]);
|
||||
return;
|
||||
}
|
||||
hydrateSidebar(navContainer);
|
||||
});
|
||||
})();
|
||||
@@ -1,14 +1,35 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const table = document.querySelector("[data-project-table]");
|
||||
const rows = table ? Array.from(table.querySelectorAll("tbody tr")) : [];
|
||||
const container = document.querySelector("[data-project-table]");
|
||||
const filterInput = document.querySelector("[data-project-filter]");
|
||||
|
||||
if (table && filterInput) {
|
||||
const resolveFilterItems = () => {
|
||||
if (!container) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = Array.from(
|
||||
container.querySelectorAll("[data-project-entry]")
|
||||
);
|
||||
|
||||
if (entries.length) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (container.tagName === "TABLE") {
|
||||
return Array.from(container.querySelectorAll("tbody tr"));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const filterItems = resolveFilterItems();
|
||||
|
||||
if (container && filterInput && filterItems.length) {
|
||||
filterInput.addEventListener("input", () => {
|
||||
const query = filterInput.value.trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
const match = row.textContent.toLowerCase().includes(query);
|
||||
row.style.display = match ? "" : "none";
|
||||
filterItems.forEach((item) => {
|
||||
const match = item.textContent.toLowerCase().includes(query);
|
||||
item.style.display = match ? "" : "none";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}CalMiner{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/theme-default.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 %}
|
||||
</head>
|
||||
@@ -41,7 +43,7 @@
|
||||
<script src="/static/js/exports.js" defer></script>
|
||||
<script src="/static/js/imports.js" defer></script>
|
||||
<script src="/static/js/notifications.js" defer></script>
|
||||
<script src="/static/js/navigation.js" defer></script>
|
||||
<script src="/static/js/navigation_sidebar.js" defer></script>
|
||||
<script src="/static/js/theme.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {%
|
||||
block head_extra %}
|
||||
{% extends "base.html" %} {% block title %}Dashboard · CalMiner{% endblock %} {% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/dashboard.css" />
|
||||
{% endblock %} {% block content %}
|
||||
<section class="page-header dashboard-header">
|
||||
@@ -165,12 +164,12 @@ block head_extra %}
|
||||
</header>
|
||||
<ul class="links-list">
|
||||
<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>
|
||||
<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><a href="mailto:support@example.com">Contact Support</a></li>
|
||||
<li><a href="mailto:calminer@allucanget.biz">Contact Support</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -9,7 +9,7 @@
|
||||
<h5 class="modal-title">Export {{ dataset|capitalize }}</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
class="btn btn--ghost btn--icon"
|
||||
data-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
@@ -40,10 +40,10 @@
|
||||
>
|
||||
</div>
|
||||
<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
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">Download</button>
|
||||
<button type="submit" class="btn btn--primary">Download</button>
|
||||
</div>
|
||||
<p class="form-error hidden" data-export-error></p>
|
||||
</form>
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
{% include "partials/import_preview_table.html" %}
|
||||
|
||||
<div class="import-actions hidden" data-import-actions>
|
||||
<button class="btn primary" data-import-commit disabled>Commit Import</button>
|
||||
<button class="btn" data-import-cancel>Cancel</button>
|
||||
<button class="btn btn--primary" data-import-commit disabled>Commit Import</button>
|
||||
<button class="btn btn--secondary" data-import-cancel>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn primary">Login</button>
|
||||
<button type="submit" class="btn btn--primary">Login</button>
|
||||
</form>
|
||||
<p>Don't have an account? <a href="/register">Register here</a></p>
|
||||
<p><a href="/forgot-password">Forgot password?</a></p>
|
||||
|
||||
@@ -6,9 +6,5 @@
|
||||
<span class="brand-subtitle">Mining Planner</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="sidebar-nav-controls">
|
||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">←</button>
|
||||
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">→</button>
|
||||
</div>
|
||||
{% include "partials/sidebar_nav.html" %}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="import-upload__dropzone" data-import-dropzone>
|
||||
<span class="icon-upload" aria-hidden="true"></span>
|
||||
<p>Drag & drop CSV/XLSX files here or</p>
|
||||
<label class="btn secondary">
|
||||
<label class="btn btn--secondary">
|
||||
Browse
|
||||
<input type="file" name="import-file" accept=".csv,.xlsx" hidden />
|
||||
</label>
|
||||
@@ -17,8 +17,8 @@
|
||||
</div>
|
||||
|
||||
<div class="import-upload__actions">
|
||||
<button type="button" class="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--primary" data-import-upload-trigger disabled>Upload & Preview</button>
|
||||
<button type="button" class="btn btn--secondary" data-import-reset hidden>Reset</button>
|
||||
</div>
|
||||
|
||||
{{ feedback("import-upload-feedback", hidden=True, role="alert") }}
|
||||
|
||||
@@ -1,67 +1,80 @@
|
||||
{% set dashboard_href = request.url_for('dashboard.home') if request else '/' %}
|
||||
{% set projects_href = request.url_for('projects.project_list_page') if request
|
||||
else '/projects/ui' %} {% set project_create_href =
|
||||
request.url_for('projects.create_project_form') if request else
|
||||
'/projects/create' %} {% set auth_session = request.state.auth_session if
|
||||
request else None %} {% set is_authenticated = auth_session and
|
||||
auth_session.is_authenticated %} {% if is_authenticated %} {% set logout_href =
|
||||
request.url_for('auth.logout') if request else '/logout' %} {% set account_links
|
||||
= [ {"href": logout_href, "label": "Logout", "match_prefix": "/logout"} ] %} {%
|
||||
else %} {% set login_href = request.url_for('auth.login_form') if request else
|
||||
'/login' %} {% set register_href = request.url_for('auth.register_form') if
|
||||
request else '/register' %} {% set forgot_href =
|
||||
request.url_for('auth.password_reset_request_form') if request else
|
||||
'/forgot-password' %} {% set account_links = [ {"href": login_href, "label":
|
||||
"Login", "match_prefix": "/login"}, {"href": register_href, "label": "Register",
|
||||
"match_prefix": "/register"}, {"href": forgot_href, "label": "Forgot Password",
|
||||
"match_prefix": "/forgot-password"} ] %} {% endif %} {% set nav_groups = [ {
|
||||
"label": "Workspace", "links": [ {"href": dashboard_href, "label": "Dashboard",
|
||||
"match_prefix": "/"}, {"href": projects_href, "label": "Projects",
|
||||
"match_prefix": "/projects"}, {"href": project_create_href, "label": "New
|
||||
Project", "match_prefix": "/projects/create"}, {"href": "/imports/ui", "label":
|
||||
"Imports", "match_prefix": "/imports"} ] }, { "label": "Insights", "links": [
|
||||
{"href": "/ui/simulations", "label": "Simulations"}, {"href": "/ui/reporting",
|
||||
"label": "Reporting"} ] }, { "label": "Configuration", "links": [ { "href":
|
||||
"/ui/settings", "label": "Settings", "children": [ {"href": "/theme-settings",
|
||||
"label": "Themes"}, {"href": "/ui/currencies", "label": "Currency Management"} ]
|
||||
} ] }, { "label": "Account", "links": account_links } ] %}
|
||||
{% 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 current_path = request.url.path if request else '' %}
|
||||
|
||||
<nav class="sidebar-nav" aria-label="Primary navigation">
|
||||
{% set current_path = request.url.path if request else '' %} {% for group in
|
||||
nav_groups %} {% if group.links %}
|
||||
<div class="sidebar-section">
|
||||
<nav
|
||||
class="sidebar-nav"
|
||||
aria-label="Primary navigation"
|
||||
data-navigation-source="{{ 'server' if sidebar_nav else 'fallback' }}"
|
||||
data-navigation-roles="{{ nav_roles | join(',') }}"
|
||||
>
|
||||
<div class="sidebar-nav-controls">
|
||||
<button id="nav-prev" class="nav-chevron nav-chevron-prev" aria-label="Previous page">ᐊ</button>
|
||||
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">ᐅ</button>
|
||||
</div>
|
||||
{% if nav_groups %}
|
||||
{% for group in nav_groups %}
|
||||
{% if group.links %}
|
||||
<div class="sidebar-section" data-group-id="{{ group.id }}">
|
||||
<div class="sidebar-section-label">{{ group.label }}</div>
|
||||
<div class="sidebar-section-links">
|
||||
{% for link in group.links %} {% set href = link.href | string %} {% set
|
||||
match_prefix = link.get('match_prefix', href) | string %} {% if
|
||||
match_prefix == '/' %} {% set is_active = current_path == '/' %} {% else
|
||||
%} {% set is_active = current_path.startswith(match_prefix) %} {% endif %}
|
||||
<div class="sidebar-link-block">
|
||||
{% for link in group.links %}
|
||||
{% set href = link.href %}
|
||||
{% if href %}
|
||||
{% set match_prefix = link.match_prefix or href %}
|
||||
{% if match_prefix == '/' %}
|
||||
{% set is_active = current_path == '/' %}
|
||||
{% else %}
|
||||
{% set is_active = current_path.startswith(match_prefix) %}
|
||||
{% endif %}
|
||||
<div class="sidebar-link-block" data-link-id="{{ link.id }}">
|
||||
<a
|
||||
href="{{ href }}"
|
||||
class="sidebar-link{% if is_active %} is-active{% endif %}"
|
||||
class="sidebar-link{% if is_active %} is-active{% endif %}{% if link.is_external %} is-external{% endif %}"
|
||||
data-match-prefix="{{ match_prefix }}"
|
||||
{% if link.tooltip %}title="{{ link.tooltip }}"{% endif %}
|
||||
{% if link.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}
|
||||
>
|
||||
{{ link.label }}
|
||||
</a>
|
||||
{% if link.children %}
|
||||
<div class="sidebar-sublinks">
|
||||
{% for child in link.children %} {% set child_prefix =
|
||||
child.get('match_prefix', child.href) | string %} {% if child_prefix
|
||||
== '/' %} {% set child_active = current_path == '/' %} {% else %} {%
|
||||
set child_active = current_path.startswith(child_prefix) %} {% endif
|
||||
%}
|
||||
{% for child in link.children %}
|
||||
{% set child_href = child.href %}
|
||||
{% if child_href %}
|
||||
{% set child_prefix = child.match_prefix or child_href %}
|
||||
{% if child_prefix == '/' %}
|
||||
{% set child_active = current_path == '/' %}
|
||||
{% else %}
|
||||
{% set child_active = current_path.startswith(child_prefix) %}
|
||||
{% endif %}
|
||||
<a
|
||||
href="{{ child.href | string }}"
|
||||
class="sidebar-sublink{% if child_active %} is-active{% endif %}"
|
||||
href="{{ child_href }}"
|
||||
class="sidebar-sublink{% if child_active %} is-active{% endif %}{% if child.is_external %} is-external{% endif %}"
|
||||
data-match-prefix="{{ child_prefix }}"
|
||||
{% if child.tooltip %}title="{{ child.tooltip }}"{% endif %}
|
||||
{% if child.is_external %}target="_blank" rel="noopener noreferrer"{% endif %}
|
||||
>
|
||||
{{ child.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %} {% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="sidebar-section sidebar-empty-state">
|
||||
<div class="sidebar-section-label">Navigation</div>
|
||||
<div class="sidebar-section-links">
|
||||
<p class="sidebar-empty-copy">Navigation is unavailable.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
<p class="text-muted">{{ project.operation_type.value.replace('_', ' ') | title }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" 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--secondary" href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Manage Scenarios</a>
|
||||
<a class="btn btn--secondary" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit Project</a>
|
||||
<a class="btn btn--primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -46,6 +47,7 @@
|
||||
</section>
|
||||
|
||||
<div class="project-layout">
|
||||
<div class="project-column">
|
||||
<section class="card">
|
||||
<h2>Project Overview</h2>
|
||||
<dl class="definition-list">
|
||||
@@ -72,39 +74,64 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header class="card-header">
|
||||
<section class="card project-actions-card">
|
||||
<h2>Next Steps</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Capture a new scenario</a>
|
||||
<p>Create an additional assumption set under this project.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.project_scenario_list', project_id=project.id) }}">Review scenario portfolio</a>
|
||||
<p>Compare scenarios and jump into calculators with inherited context.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Update project details</a>
|
||||
<p>Revise metadata or operation type for reporting.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card project-scenarios-card">
|
||||
<header class="project-scenarios-card__header">
|
||||
<div>
|
||||
<h2>Scenarios</h2>
|
||||
<a class="btn" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
<p class="text-muted">Project scenarios inherit pricing and provide entry points to profitability planning.</p>
|
||||
</div>
|
||||
<a class="btn btn--secondary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
</header>
|
||||
{% if scenarios %}
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Currency</th>
|
||||
<th>Primary Resource</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<ul class="scenario-list">
|
||||
{% for scenario in scenarios %}
|
||||
<tr>
|
||||
<td>{{ scenario.name }}</td>
|
||||
<td>{{ scenario.status.value.title() }}</td>
|
||||
<td>{{ scenario.currency or '—' }}</td>
|
||||
<td>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</td>
|
||||
<td class="text-right">
|
||||
<a class="table-link" href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">View</a>
|
||||
<a class="table-link" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<li class="scenario-item">
|
||||
<div class="scenario-item__body">
|
||||
<div class="scenario-item__header">
|
||||
<h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
|
||||
<span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
|
||||
</div>
|
||||
<dl class="scenario-item__meta">
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<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.edit_scenario_form', scenario_id=scenario.id) }}">Edit</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -16,26 +16,21 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form class="form project-form" method="post" action="{{ form_action }}">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if project %}Edit Project{% else %}Create Project{% endif %}</h1>
|
||||
<p class="text-muted">Provide core information about the mining project.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn primary" type="submit">Save Project</button>
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn btn--primary" type="submit">Save Project</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="form project-form" method="post" action="{{ form_action }}">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
@@ -63,8 +58,8 @@
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn primary" type="submit">Save Project</button>
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn btn--primary" type="submit">Save Project</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -17,29 +17,50 @@
|
||||
class="form-control"
|
||||
placeholder="Filter projects..."
|
||||
data-project-filter
|
||||
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>
|
||||
</section>
|
||||
|
||||
{% if projects %}
|
||||
<table class="projects-table" data-project-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Type</th>
|
||||
<th>Scenarios</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<section class="projects-grid" data-project-table>
|
||||
{% for project in projects %}
|
||||
<tr>
|
||||
<td class="table-cell-actions">
|
||||
{{ project.name }}
|
||||
<article class="project-card" data-project-entry>
|
||||
<header class="project-card__header">
|
||||
<h2 class="project-card__title">
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
</h2>
|
||||
<span class="project-card__type badge">{{ project.operation_type.value.replace('_', ' ') | title }}</span>
|
||||
</header>
|
||||
|
||||
<p class="project-card__description">
|
||||
{{ project.description or 'No description provided yet.' }}
|
||||
</p>
|
||||
|
||||
<dl class="project-card__meta">
|
||||
<div>
|
||||
<dt>Scenarios</dt>
|
||||
<dd><span class="badge badge-pill">{{ project.scenario_count }}</span></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ project.updated_at.strftime('%Y-%m-%d') if project.updated_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<footer class="project-card__footer">
|
||||
<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('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>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
class="btn btn--ghost"
|
||||
data-export-trigger
|
||||
data-export-target="projects"
|
||||
title="Export projects dataset"
|
||||
@@ -47,18 +68,10 @@
|
||||
<span aria-hidden="true">⇩</span>
|
||||
<span class="sr-only">Export</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ project.location or '—' }}</td>
|
||||
<td>{{ project.operation_type.value.replace('_', ' ') | title }}</td>
|
||||
<td>{{ project.scenario_count }}</td>
|
||||
<td class="text-right">
|
||||
<a class="btn btn-link" href="{{ url_for('projects.view_project', project_id=project.id) }}">View</a>
|
||||
<a class="btn btn-link" href="{{ url_for('projects.edit_project_form', project_id=project.id) }}">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
</footer>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% else %}
|
||||
<p>No projects yet. <a href="{{ url_for('projects.create_project_form') }}">Create your first project.</a></p>
|
||||
{% endif %}
|
||||
|
||||
@@ -36,7 +36,7 @@ content %}
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" name="password" required />
|
||||
</div>
|
||||
<button type="submit" class="btn primary">Register</button>
|
||||
<button type="submit" class="btn btn--primary">Register</button>
|
||||
</form>
|
||||
<p>Already have an account? <a href="/login">Login here</a></p>
|
||||
</div>
|
||||
|
||||
271
templates/scenarios/capex.html
Normal file
271
templates/scenarios/capex.html
Normal file
@@ -0,0 +1,271 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Capex Planner · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
{% if project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
{% endif %}
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Capex Planner</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Capex Planner</h1>
|
||||
<p class="text-muted">Plan capital requirements for {{ scenario.name if scenario else 'this scenario' }}.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if scenario_url %}
|
||||
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="submit" form="capex-form">Save & Calculate</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<h2 class="sr-only">Submission errors</h2>
|
||||
<ul>
|
||||
{% for message in errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notices %}
|
||||
<div class="alert alert-info">
|
||||
<ul>
|
||||
{% for message in notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="capex-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
||||
<input type="hidden" name="options[persist]" value="{{ '1' if options is defined and options and options.persist else '' }}" />
|
||||
|
||||
<div class="layout-two-column stackable">
|
||||
<section class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Capex Components</h2>
|
||||
<p class="section-subtitle">Break down initial capital items with category, amount, and timing.</p>
|
||||
</header>
|
||||
|
||||
<div class="table-actions">
|
||||
<button class="btn btn--secondary" type="button" data-action="add-component">Add Component</button>
|
||||
</div>
|
||||
|
||||
{% if component_errors is defined and component_errors %}
|
||||
<div class="alert alert-error slim" role="alert" aria-live="polite">
|
||||
<h3 class="sr-only">Component issues</h3>
|
||||
<ul>
|
||||
{% for message in component_errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if component_notices is defined and component_notices %}
|
||||
<div class="alert alert-info slim" role="status" aria-live="polite">
|
||||
<ul>
|
||||
{% for message in component_notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive horizontal-scroll">
|
||||
<table class="input-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Component</th>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Currency</th>
|
||||
<th scope="col">Spend Year</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th class="sr-only" scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set component_entries = (components if components is defined and components else [{}]) %}
|
||||
{% for component in component_entries %}
|
||||
<tr data-row-index="{{ loop.index0 }}">
|
||||
<td>
|
||||
<input type="hidden" name="components[{{ loop.index0 }}][id]" value="{{ component.id or '' }}" />
|
||||
<select name="components[{{ loop.index0 }}][category]">
|
||||
{% for option in category_options %}
|
||||
<option value="{{ option.value }}" {% if component.category == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="components[{{ loop.index0 }}][name]" value="{{ component.name or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][amount]" value="{{ component.amount or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" maxlength="3" name="components[{{ loop.index0 }}][currency]" value="{{ component.currency or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="1" name="components[{{ loop.index0 }}][spend_year]" value="{{ component.spend_year or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="components[{{ loop.index0 }}][notes]" value="{{ component.notes or '' }}" />
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn btn--link" type="button" data-action="remove-component">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="muted">Add rows for each capital item. Amounts should reflect pre-contingency values.</p>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Global Parameters</h2>
|
||||
<p class="section-subtitle">Configure defaults applied across components.</p>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="capex_currency_code">Default Currency</label>
|
||||
<input id="capex_currency_code" name="parameters[currency_code]" type="text" maxlength="3" value="{{ (parameters.currency_code if parameters is defined and parameters else None) or (currency_code if currency_code is defined else None) or (scenario.currency if scenario else None) or (project.currency if project else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="contingency_pct">Contingency (%)</label>
|
||||
<input id="contingency_pct" name="parameters[contingency_pct]" type="number" min="0" max="100" step="0.01" value="{{ (parameters.contingency_pct if parameters is defined and parameters else '') }}" />
|
||||
<p class="field-help">Applied across component totals.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discount_rate">Discount Rate (%)</label>
|
||||
<input id="discount_rate" name="parameters[discount_rate_pct]" type="number" min="0" max="100" step="0.01" value="{{ (parameters.discount_rate_pct if parameters is defined and parameters else None) or (scenario.discount_rate if scenario else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="evaluation_horizon_years">Evaluation Horizon (years)</label>
|
||||
<input id="evaluation_horizon_years" name="parameters[evaluation_horizon_years]" type="number" min="1" step="1" value="{{ (parameters.evaluation_horizon_years if parameters is defined and parameters else None) or (default_horizon if default_horizon is defined else '') }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Assumptions</h3>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Categories Configured</dt>
|
||||
<dd>{{ category_options | length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ last_updated_at or '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="muted">Defaults reflect scenario preferences. Adjust before calculating.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Capex Summary</h2>
|
||||
<p class="section-subtitle">Calculated totals and categorized breakdowns.</p>
|
||||
</header>
|
||||
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Total Capex</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.overall | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Contingency Applied</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.contingency_amount | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Grand Total</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.with_contingency | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{% if result.totals.by_category %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in result.totals.by_category %}
|
||||
<tr>
|
||||
<th scope="row">{{ row.category }}</th>
|
||||
<td>{{ row.amount | currency_display(result.currency) }}</td>
|
||||
<td>{{ row.share | percentage_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.timeline %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Year</th>
|
||||
<th scope="col">Spend</th>
|
||||
<th scope="col">Cumulative</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.timeline %}
|
||||
<tr>
|
||||
<th scope="row">{{ entry.year }}</th>
|
||||
<td>{{ entry.spend | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.cumulative | currency_display(result.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">Provide component details and calculate to see capex totals.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Visualizations</h2>
|
||||
<p class="section-subtitle">Charts render after calculations complete.</p>
|
||||
</header>
|
||||
<div id="capex-category-chart" class="chart-container"></div>
|
||||
<div id="capex-timeline-chart" class="chart-container"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -13,17 +13,37 @@
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
{% set scenario_list_href = url_for('scenarios.project_scenario_list', project_id=project.id) %}
|
||||
{% if project and scenario %}
|
||||
{% set profitability_href = url_for('calculations.profitability_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.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 %}
|
||||
<div>
|
||||
<h1>{{ scenario.name }}</h1>
|
||||
<p class="text-muted">Status: {{ scenario.status.value.title() }}</p>
|
||||
<p class="text-muted">
|
||||
Part of <a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ url_for('projects.view_project', project_id=project.id) }}">Back to Project</a>
|
||||
<a class="btn primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
|
||||
<a class="btn btn--secondary" href="{{ scenario_list_href }}">Scenario Portfolio</a>
|
||||
<a class="btn btn--secondary" href="{{ profitability_href }}">Profitability Calculator</a>
|
||||
<a class="btn btn--secondary" href="{{ opex_href }}">Opex Planner</a>
|
||||
<a class="btn btn--secondary" href="{{ capex_href }}">Capex Planner</a>
|
||||
<a class="btn btn--primary" href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Edit Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scenario-metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Status</h2>
|
||||
<p class="metric-value status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</p>
|
||||
<span class="metric-caption">Lifecycle state</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Financial Inputs</h2>
|
||||
<p class="metric-value">{{ scenario_metrics.financial_count }}</p>
|
||||
@@ -39,16 +59,12 @@
|
||||
<p class="metric-value">{{ scenario_metrics.currency or '—' }}</p>
|
||||
<span class="metric-caption">Financial reporting</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Primary Resource</h2>
|
||||
<p class="metric-value">{{ scenario_metrics.primary_resource or '—' }}</p>
|
||||
<span class="metric-caption">Scenario focus</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="scenario-layout">
|
||||
<div class="scenario-column">
|
||||
<section class="card">
|
||||
<h2>Scenario Details</h2>
|
||||
<h2>Scenario Overview</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Description</dt>
|
||||
@@ -56,14 +72,16 @@
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>
|
||||
{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}
|
||||
</dd>
|
||||
<dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Discount Rate</dt>
|
||||
<dd>{{ scenario.discount_rate or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario_metrics.primary_resource or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ scenario.updated_at.strftime('%Y-%m-%d %H:%M') if scenario.updated_at else '—' }}</dd>
|
||||
@@ -71,6 +89,25 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card quick-actions-card">
|
||||
<h2>Next Steps</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ profitability_href }}">Run profitability analysis</a>
|
||||
<p>Uses this scenario’s assumptions as defaults.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ scenario_list_href }}">Browse all project scenarios</a>
|
||||
<p>Compare assumption sets and launch calculators in context.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.edit_scenario_form', scenario_id=scenario.id) }}">Update scenario assumptions</a>
|
||||
<p>Adjust dates, status, or drivers before recalculations.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Financial Inputs</h2>
|
||||
{% if financial_inputs %}
|
||||
|
||||
@@ -16,26 +16,71 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
|
||||
<p class="text-muted">Configure assumptions and metadata for this scenario.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn primary" type="submit">Save Scenario</button>
|
||||
</div>
|
||||
</header>
|
||||
{% set error = error | default(None) %}
|
||||
{% set error_field = error_field | default(None) %}
|
||||
{% set currency_error = error if error_field == 'currency' else None %}
|
||||
{% set name_error = error if error_field == 'name' else None %}
|
||||
|
||||
{% if error %}
|
||||
{% if error and not error_field %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>{% if scenario %}Edit Scenario{% else %}Create Scenario{% endif %}</h1>
|
||||
<p class="text-muted">Scenarios inherit pricing defaults from <strong>{{ project.name }}</strong>.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn btn--primary" type="submit">Save Scenario</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card scenario-context-card">
|
||||
<h2>Project Context</h2>
|
||||
<p class="field-help">Defaults below come from project pricing. Leave optional fields blank to reuse shared assumptions.</p>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Project</dt>
|
||||
<dd><a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type.value.replace('_', ' ') | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default Currency</dt>
|
||||
<dd>{{ default_currency or 'Not configured' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ul class="scenario-defaults">
|
||||
<li>
|
||||
<strong>Status Guidance</strong>
|
||||
<span>Draft scenarios remain internal; switch to Active once this represents your baseline, and Archive when retiring assumptions.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Baseline Reminder</strong>
|
||||
<span>Keep a single Active scenario to serve as the default baseline when launching profitability or planner workflows.</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Currency Tip</strong>
|
||||
<span>If you leave currency empty, CalMiner applies the project default shown above.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Scenario Overview</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<div class="form-group{% if name_error %} form-group--error{% endif %}">
|
||||
<label for="name">Name</label>
|
||||
<input id="name" name="name" type="text" required value="{{ scenario.name if scenario else '' }}" />
|
||||
<input id="name" name="name" type="text" required value="{{ scenario.name if scenario else '' }}" {% if name_error %}aria-invalid="true"{% endif %} />
|
||||
{% if name_error %}
|
||||
<p class="field-error">{{ name_error }}</p>
|
||||
{% else %}
|
||||
<p class="field-help">Name must be unique within {{ project.name }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -45,14 +90,17 @@
|
||||
<option value="{{ value }}" {% if scenario and scenario.status.value == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Use Draft while iterating, set Active for your go-to baseline, and Archive to keep historical context.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-group{% if currency_error %} form-group--error{% endif %}">
|
||||
<label for="currency">Currency</label>
|
||||
{% set currency_prefill = scenario.currency if scenario and scenario.currency else default_currency %}
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{{ currency_prefill or '' }}" placeholder="{{ default_currency or '' }}" />
|
||||
{% if default_currency %}
|
||||
<p class="field-help">Defaults to {{ default_currency }} when left blank.</p>
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{{ currency_prefill or '' }}" placeholder="{{ default_currency or '' }}" {% if currency_error %}aria-invalid="true"{% endif %} />
|
||||
{% if currency_error %}
|
||||
<p class="field-error">{{ currency_error }}</p>
|
||||
{% else %}
|
||||
<p class="field-help">Use a three-letter ISO code (e.g., USD). Defaults to {{ default_currency or 'the project currency' }}.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -64,32 +112,41 @@
|
||||
<option value="{{ value }}" {% if scenario and scenario.primary_resource and scenario.primary_resource.value == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Optional. Helps planners prioritise inputs tied to this commodity.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Assumptions & Timeline</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="start_date">Start Date</label>
|
||||
<input id="start_date" name="start_date" type="date" value="{{ scenario.start_date if scenario else '' }}" />
|
||||
<p class="field-help">Optional. Use to align calculations with anticipated project kickoff.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_date">End Date</label>
|
||||
<input id="end_date" name="end_date" type="date" value="{{ scenario.end_date if scenario else '' }}" />
|
||||
<p class="field-help">Optional. Leave blank for open-ended scenarios.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discount_rate">Discount Rate (%)</label>
|
||||
<input id="discount_rate" name="discount_rate" type="number" step="0.01" value="{{ scenario.discount_rate if scenario else '' }}" />
|
||||
<p class="field-help">Leave empty to reuse the project default during profitability calculations.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="5">{{ scenario.description if scenario else '' }}</textarea>
|
||||
<textarea id="description" name="description" rows="5" placeholder="Describe the key drivers or differences for this scenario.">{{ scenario.description if scenario else '' }}</textarea>
|
||||
<p class="field-help">Summarise what distinguishes this scenario for collaborators and future audits.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="btn" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn primary" type="submit">Save Scenario</button>
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Cancel</a>
|
||||
<button class="btn btn--primary" type="submit">Save Scenario</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
142
templates/scenarios/list.html
Normal file
142
templates/scenarios/list.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Scenarios · {{ project.name }} · CalMiner{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/static/css/scenarios.css" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
<span aria-current="page">Scenarios</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Scenarios</h1>
|
||||
<p class="text-muted">Assumption sets and calculators for {{ project.name }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a class="btn btn--secondary" href="{{ url_for('projects.view_project', project_id=project.id) }}">Project Overview</a>
|
||||
<a class="btn btn--primary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">New Scenario</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="scenario-metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Total Scenarios</h2>
|
||||
<p class="metric-value">{{ scenario_totals.total }}</p>
|
||||
<span class="metric-caption">Across this project</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Active</h2>
|
||||
<p class="metric-value">{{ scenario_totals.active }}</p>
|
||||
<span class="metric-caption">Currently live analyses</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Draft</h2>
|
||||
<p class="metric-value">{{ scenario_totals.draft }}</p>
|
||||
<span class="metric-caption">Awaiting validation</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Archived</h2>
|
||||
<p class="metric-value">{{ scenario_totals.archived }}</p>
|
||||
<span class="metric-caption">Historical references</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="scenario-layout">
|
||||
<div class="scenario-column">
|
||||
<section class="card">
|
||||
<h2>Project Context</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Project</dt>
|
||||
<dd>{{ project.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Operation Type</dt>
|
||||
<dd>{{ project.operation_type.value.replace('_', ' ') | title }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ project.location or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Latest Update</dt>
|
||||
<dd>{{ scenario_totals.latest_update.strftime('%Y-%m-%d %H:%M') if scenario_totals.latest_update else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card quick-actions-card">
|
||||
<h2>Quick Actions</h2>
|
||||
<ul class="quick-link-list">
|
||||
<li>
|
||||
<a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Capture a new scenario</a>
|
||||
<p>Add additional assumption sets for profitability planning.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">Review project overview</a>
|
||||
<p>Cross-check project metadata before running calculators.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card scenario-portfolio">
|
||||
<header class="scenario-portfolio__header">
|
||||
<div>
|
||||
<h2>Scenario Portfolio</h2>
|
||||
<p class="text-muted">Each scenario below inherits pricing defaults and links directly into calculators.</p>
|
||||
</div>
|
||||
<a class="btn btn--secondary" href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Add Scenario</a>
|
||||
</header>
|
||||
{% if scenarios %}
|
||||
<ul class="scenario-list">
|
||||
{% for scenario in scenarios %}
|
||||
{% set profitability_href = url_for('calculations.profitability_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.scenario_capex_form', project_id=project.id, scenario_id=scenario.id) %}
|
||||
<li class="scenario-item">
|
||||
<div class="scenario-item__body">
|
||||
<div class="scenario-item__header">
|
||||
<h3><a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a></h3>
|
||||
<span class="status-pill status-pill--{{ scenario.status.value }}">{{ scenario.status.value.title() }}</span>
|
||||
</div>
|
||||
<dl class="scenario-item__meta">
|
||||
<div>
|
||||
<dt>Currency</dt>
|
||||
<dd>{{ scenario.currency or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Resource</dt>
|
||||
<dd>{{ scenario.primary_resource.value.replace('_', ' ') | title if scenario.primary_resource else '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Timeline</dt>
|
||||
<dd>{{ scenario.start_date or '—' }} → {{ scenario.end_date or '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Updated</dt>
|
||||
<dd>{{ (scenario.updated_at or scenario.created_at).strftime('%Y-%m-%d %H:%M') if scenario.updated_at or scenario.created_at else '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<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.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="{{ opex_href }}">Opex</a>
|
||||
<a class="btn btn--link" href="{{ capex_href }}">Capex</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="empty-state">No scenarios yet. <a href="{{ url_for('scenarios.create_scenario_form', project_id=project.id) }}">Create the first scenario.</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
312
templates/scenarios/opex.html
Normal file
312
templates/scenarios/opex.html
Normal file
@@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Opex Planner · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
{% if project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
{% endif %}
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Opex Planner</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Opex Planner</h1>
|
||||
<p class="text-muted">Capture recurring operational costs and review annual totals with escalation assumptions.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if scenario_url %}
|
||||
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="submit" form="opex-form">Save & Calculate</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<h2 class="sr-only">Submission errors</h2>
|
||||
<ul>
|
||||
{% for message in errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notices %}
|
||||
<div class="alert alert-info">
|
||||
<ul>
|
||||
{% for message in notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="opex-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
||||
<input type="hidden" name="options[persist]" value="{{ '1' if options and options.persist else '' }}" />
|
||||
|
||||
<div class="layout-two-column stackable">
|
||||
<section class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Opex Components</h2>
|
||||
<p class="section-subtitle">List recurring cost items with frequency, unit cost, and quantities.</p>
|
||||
</header>
|
||||
|
||||
<div class="table-actions">
|
||||
<button class="btn btn--secondary" type="button" data-action="add-opex-component">Add Component</button>
|
||||
</div>
|
||||
|
||||
{% if component_errors %}
|
||||
<div class="alert alert-error slim" role="alert" aria-live="polite">
|
||||
<h3 class="sr-only">Component issues</h3>
|
||||
<ul>
|
||||
{% for message in component_errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if component_notices %}
|
||||
<div class="alert alert-info slim" role="status" aria-live="polite">
|
||||
<ul>
|
||||
{% for message in component_notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-responsive horizontal-scroll">
|
||||
<table class="input-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Component</th>
|
||||
<th scope="col">Unit Cost</th>
|
||||
<th scope="col">Quantity</th>
|
||||
<th scope="col">Frequency</th>
|
||||
<th scope="col">Currency</th>
|
||||
<th scope="col">Start Period</th>
|
||||
<th scope="col">End Period</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set component_entries = (components if components else [{}]) %}
|
||||
{% for component in component_entries %}
|
||||
<tr data-row-index="{{ loop.index0 }}">
|
||||
<td>
|
||||
<input type="hidden" name="components[{{ loop.index0 }}][id]" value="{{ component.id or '' }}" />
|
||||
<select name="components[{{ loop.index0 }}][category]">
|
||||
{% for option in category_options %}
|
||||
<option value="{{ option.value }}" {% if component.category == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="components[{{ loop.index0 }}][name]" value="{{ component.name or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][unit_cost]" value="{{ component.unit_cost or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="0" step="0.01" name="components[{{ loop.index0 }}][quantity]" value="{{ component.quantity or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<select name="components[{{ loop.index0 }}][frequency]">
|
||||
{% for option in frequency_options %}
|
||||
<option value="{{ option.value }}" {% if component.frequency == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" maxlength="3" name="components[{{ loop.index0 }}][currency]" value="{{ component.currency or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_start]" value="{{ component.period_start or '' }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" step="1" name="components[{{ loop.index0 }}][period_end]" value="{{ component.period_end or '' }}" />
|
||||
</td>
|
||||
<td class="row-actions">
|
||||
<button class="btn btn--link" type="button" data-action="remove-opex-component">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="muted">Use start and end periods to indicate when the cost applies within the evaluation horizon.</p>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<header class="section-header">
|
||||
<h2>Global Parameters</h2>
|
||||
<p class="section-subtitle">Control escalation and discount assumptions applied to totals.</p>
|
||||
</header>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="opex_currency_code">Default Currency</label>
|
||||
<input id="opex_currency_code" name="parameters[currency_code]" type="text" maxlength="3" value="{{ parameters.currency_code or currency_code or (scenario.currency if scenario else '') or (project.currency if project else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex_escalation_pct">Escalation (%)</label>
|
||||
<input id="opex_escalation_pct" name="parameters[escalation_pct]" type="number" min="0" max="100" step="0.01" value="{{ parameters.escalation_pct if parameters.escalation_pct is not none else '' }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex_discount_rate_pct">Discount Rate (%)</label>
|
||||
<input id="opex_discount_rate_pct" name="parameters[discount_rate_pct]" type="number" min="0" max="100" step="0.01" value="{{ parameters.discount_rate_pct if parameters.discount_rate_pct is not none else (scenario.discount_rate if scenario else '') }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex_horizon_years">Evaluation Horizon (years)</label>
|
||||
<input id="opex_horizon_years" name="parameters[evaluation_horizon_years]" type="number" min="1" step="1" value="{{ parameters.evaluation_horizon_years if parameters.evaluation_horizon_years is not none else default_horizon }}" />
|
||||
</div>
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="parameters[apply_escalation]" value="1" {% if parameters.apply_escalation %}checked{% endif %} />
|
||||
Apply escalation to timeline totals
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="snapshot_notes">Snapshot Notes</label>
|
||||
<textarea id="snapshot_notes" name="options[snapshot_notes]" rows="3">{{ options.snapshot_notes or '' }}</textarea>
|
||||
<p class="field-help">Optional. Appears alongside persisted snapshots.</p>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Assumptions</h3>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Categories Configured</dt>
|
||||
<dd>{{ category_options | length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Frequencies Supported</dt>
|
||||
<dd>{{ frequency_options | length }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last Updated</dt>
|
||||
<dd>{{ last_updated_at or '—' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p class="muted">Defaults reflect scenario preferences. Adjust before calculating.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Opex Summary</h2>
|
||||
<p class="section-subtitle">Annual totals, escalation impacts, and category breakdowns.</p>
|
||||
</header>
|
||||
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Annual Opex Total</h3>
|
||||
<p class="metric">
|
||||
<strong>{{ result.totals.overall_annual | currency_display(result.currency) }}</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Escalated Total</h3>
|
||||
<p class="metric">
|
||||
<strong>
|
||||
{% if result.totals.escalated_total is not none %}
|
||||
{{ result.totals.escalated_total | currency_display(result.currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</strong>
|
||||
</p>
|
||||
</article>
|
||||
<article class="report-card">
|
||||
<h3>Annual Average (Escalated)</h3>
|
||||
<p class="metric">
|
||||
<strong>
|
||||
{% if result.metrics.annual_average is not none %}
|
||||
{{ result.metrics.annual_average | currency_display(result.currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</strong>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Category Breakdown</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Annual Cost</th>
|
||||
<th scope="col">Share (%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.totals.by_category %}
|
||||
<tr>
|
||||
<td>{{ entry.category | title }}</td>
|
||||
<td>{{ entry.annual_cost | currency_display(result.currency) }}</td>
|
||||
<td>{% if entry.share is not none %}{{ entry.share | round(2) }}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Timeline</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Period</th>
|
||||
<th scope="col">Base Cost</th>
|
||||
<th scope="col">Escalated Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.timeline %}
|
||||
<tr>
|
||||
<td>{{ entry.period }}</td>
|
||||
<td>{{ entry.base_cost | currency_display(result.currency) }}</td>
|
||||
<td>
|
||||
{% if entry.escalated_cost is not none %}
|
||||
{{ entry.escalated_cost | currency_display(result.currency) }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Run the calculation to populate summary metrics and timeline insights.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
345
templates/scenarios/profitability.html
Normal file
345
templates/scenarios/profitability.html
Normal file
@@ -0,0 +1,345 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Profitability Calculator · CalMiner{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{{ url_for('projects.project_list_page') }}">Projects</a>
|
||||
{% if project %}
|
||||
<a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a>
|
||||
{% endif %}
|
||||
{% if scenario %}
|
||||
<a href="{{ url_for('scenarios.view_scenario', scenario_id=scenario.id) }}">{{ scenario.name }}</a>
|
||||
{% endif %}
|
||||
<span aria-current="page">Profitability Calculator</span>
|
||||
</nav>
|
||||
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Profitability Calculator</h1>
|
||||
<p class="text-muted">Evaluate revenue, costs, and key financial metrics for a scenario.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{% if scenario_url %}
|
||||
<a class="btn btn--secondary" href="{{ scenario_url }}">Scenario Overview</a>
|
||||
{% elif project_url %}
|
||||
<a class="btn btn--secondary" href="{{ project_url }}">Project Overview</a>
|
||||
{% elif cancel_url %}
|
||||
<a class="btn btn--secondary" href="{{ cancel_url }}">Back</a>
|
||||
{% endif %}
|
||||
{% if scenario_portfolio_url %}
|
||||
<a class="btn btn--secondary" href="{{ scenario_portfolio_url }}">Scenario Portfolio</a>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="submit" form="profitability-form">Run Calculation</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<h2 class="sr-only">Submission errors</h2>
|
||||
<ul>
|
||||
{% for message in errors %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if notices %}
|
||||
<div class="alert alert-info">
|
||||
<ul>
|
||||
{% for message in notices %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout-two-column">
|
||||
<section class="panel">
|
||||
<h2>Input Parameters</h2>
|
||||
<form id="profitability-form" class="form scenario-form" method="post" action="{{ form_action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}" />
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="metal">Commodity</label>
|
||||
<select id="metal" name="metal" required>
|
||||
{% for metal in supported_metals %}
|
||||
<option value="{{ metal.value }}" {% if data.metal == metal.value %}selected{% endif %}>{{ metal.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ore_tonnage">Ore Tonnage (t)</label>
|
||||
<input id="ore_tonnage" name="ore_tonnage" type="number" min="0" step="0.01" value="{{ data.ore_tonnage }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="head_grade_pct">Head Grade (%)</label>
|
||||
<input id="head_grade_pct" name="head_grade_pct" type="number" min="0" max="100" step="0.01" value="{{ data.head_grade_pct }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="recovery_pct">Recovery (%)</label>
|
||||
<input id="recovery_pct" name="recovery_pct" type="number" min="0" max="100" step="0.01" value="{{ data.recovery_pct }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="payable_pct">Payable (%)</label>
|
||||
<input id="payable_pct" name="payable_pct" type="number" min="0" max="100" step="0.01" value="{{ data.payable_pct or metadata.default_payable_pct }}" />
|
||||
<p class="field-help">Default {{ metadata.default_payable_pct or 100 }}% if blank.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="reference_price">Reference Price</label>
|
||||
<input id="reference_price" name="reference_price" type="number" min="0" step="0.01" value="{{ data.reference_price }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="fx_rate">FX Rate</label>
|
||||
<input id="fx_rate" name="fx_rate" type="number" min="0" step="0.0001" value="{{ data.fx_rate or 1 }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="currency_code">Scenario Currency</label>
|
||||
<input id="currency_code" name="currency_code" type="text" maxlength="3" value="{{ data.currency_code or scenario.currency or project.currency }}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="form-fieldset">
|
||||
<legend>Processing Charges</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="treatment_charge">Treatment Charge</label>
|
||||
<input id="treatment_charge" name="treatment_charge" type="number" min="0" step="0.01" value="{{ data.treatment_charge }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smelting_charge">Smelting Charge</label>
|
||||
<input id="smelting_charge" name="smelting_charge" type="number" min="0" step="0.01" value="{{ data.smelting_charge }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="opex">Opex (per period)</label>
|
||||
<input id="opex" name="opex" type="number" min="0" step="0.01" value="{{ data.opex }}" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-fieldset">
|
||||
<legend>Penalties & Premiums</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="moisture_pct">Moisture (%)</label>
|
||||
<input id="moisture_pct" name="moisture_pct" type="number" min="0" max="100" step="0.01" value="{{ data.moisture_pct }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="moisture_threshold_pct">Moisture Threshold (%)</label>
|
||||
<input id="moisture_threshold_pct" name="moisture_threshold_pct" type="number" min="0" max="100" step="0.01" value="{{ data.moisture_threshold_pct or metadata.moisture_threshold_pct }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="moisture_penalty_per_pct">Moisture Penalty / %</label>
|
||||
<input id="moisture_penalty_per_pct" name="moisture_penalty_per_pct" type="number" step="0.01" value="{{ data.moisture_penalty_per_pct or metadata.moisture_penalty_per_pct }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="premiums">Premiums / Credits</label>
|
||||
<input id="premiums" name="premiums" type="number" step="0.01" value="{{ data.premiums }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="impurity-table">
|
||||
<label>Impurities</label>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Content (ppm)</th>
|
||||
<th scope="col">Threshold (ppm)</th>
|
||||
<th scope="col">Penalty / ppm</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set impurity_entries = data.impurities or metadata_impurities %}
|
||||
{% for impurity in impurity_entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="text" name="impurities[{{ loop.index0 }}][name]" value="{{ impurity.name }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][value]" value="{{ impurity.value }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][threshold]" value="{{ impurity.threshold }}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.01" min="0" name="impurities[{{ loop.index0 }}][penalty]" value="{{ impurity.penalty }}" />
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="muted">No impurity penalties configured.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="form-fieldset">
|
||||
<legend>Capital & Discounting</legend>
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label for="capex">Capex</label>
|
||||
<input id="capex" name="capex" type="number" min="0" step="0.01" value="{{ data.capex }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sustaining_capex">Sustaining Capex (per period)</label>
|
||||
<input id="sustaining_capex" name="sustaining_capex" type="number" min="0" step="0.01" value="{{ data.sustaining_capex }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="discount_rate">Discount Rate (%)</label>
|
||||
<input id="discount_rate" name="discount_rate" type="number" min="0" max="100" step="0.01" value="{{ data.discount_rate or scenario.discount_rate }}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="periods">Evaluation Periods</label>
|
||||
<input id="periods" name="periods" type="number" min="1" step="1" value="{{ data.periods or default_periods }}" />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<h2>Assumption Summary</h2>
|
||||
<dl class="definition-list">
|
||||
<div>
|
||||
<dt>Default Payable</dt>
|
||||
<dd>{{ metadata.default_payable_pct or 100 }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Moisture Threshold</dt>
|
||||
<dd>{{ metadata.moisture_threshold_pct or 0 }}%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Moisture Penalty</dt>
|
||||
<dd>{{ metadata.moisture_penalty_per_pct or 0 }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Base Currency</dt>
|
||||
<dd>{{ metadata.default_currency or "—" }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{% if metadata_impurities %}
|
||||
<h3>Configured Impurities</h3>
|
||||
<ul class="metric-list compact">
|
||||
{% for impurity in metadata_impurities %}
|
||||
<li>
|
||||
<span>{{ impurity.name }}</span>
|
||||
<strong>Threshold {{ impurity.threshold }} ppm · Penalty {{ impurity.penalty }}</strong>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<p class="muted">
|
||||
Adjust values to reflect contract terms. Leave fields blank to use defaults sourced from pricing metadata.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Calculation Results</h2>
|
||||
<p class="section-subtitle">Outputs reflect the latest submission.</p>
|
||||
</header>
|
||||
|
||||
{% if result %}
|
||||
<div class="report-grid">
|
||||
<article class="report-card">
|
||||
<h3>Revenue Summary</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Payable Metal</span>
|
||||
<strong>{{ result.pricing.payable_metal_tonnes | default('—') }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Gross Revenue</span>
|
||||
<strong>{{ result.pricing.gross_revenue | currency_display(result.pricing.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Net Revenue</span>
|
||||
<strong>{{ result.pricing.net_revenue | currency_display(result.pricing.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h3>Cost Breakdown</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>Opex</span>
|
||||
<strong>{{ result.costs.opex_total | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Sustaining Capex</span>
|
||||
<strong>{{ result.costs.sustaining_capex_total | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Capex</span>
|
||||
<strong>{{ result.costs.capex | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="report-card">
|
||||
<h3>Key Metrics</h3>
|
||||
<ul class="metric-list">
|
||||
<li>
|
||||
<span>NPV</span>
|
||||
<strong>{{ result.metrics.npv | currency_display(result.currency) }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>IRR</span>
|
||||
<strong>{{ result.metrics.irr | percentage_display }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Payback</span>
|
||||
<strong>{{ result.metrics.payback_period | period_display }}</strong>
|
||||
</li>
|
||||
<li>
|
||||
<span>Margin</span>
|
||||
<strong>{{ result.metrics.margin | percentage_display }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{% if result.cash_flows %}
|
||||
<table class="metrics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Period</th>
|
||||
<th scope="col">Revenue</th>
|
||||
<th scope="col">Opex</th>
|
||||
<th scope="col">Sustaining Capex</th>
|
||||
<th scope="col">Net Cash Flow</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in result.cash_flows %}
|
||||
<tr>
|
||||
<th scope="row">{{ entry.period }}</th>
|
||||
<td>{{ entry.revenue | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.opex | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.sustaining_capex | currency_display(result.currency) }}</td>
|
||||
<td>{{ entry.net | currency_display(result.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">Provide inputs and run the profitability calculator to see scenario metrics.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<header class="section-header">
|
||||
<h2>Visualisations</h2>
|
||||
<p class="section-subtitle">Charts render after calculations complete.</p>
|
||||
</header>
|
||||
<div id="profitability-chart" class="chart-container"></div>
|
||||
<div id="cashflow-chart" class="chart-container"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -17,16 +17,24 @@ from dependencies import get_auth_session, get_import_ingestion_service, get_uni
|
||||
from models import User
|
||||
from routes.auth import router as auth_router
|
||||
from routes.dashboard import router as dashboard_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.scenarios import router as scenarios_router
|
||||
from routes.imports import router as imports_router
|
||||
from routes.exports import router as exports_router
|
||||
from routes.reports import router as reports_router
|
||||
from routes.ui import router as ui_router
|
||||
from services.importers import ImportIngestionService
|
||||
from services.unit_of_work import UnitOfWork
|
||||
from services.session import AuthSession, SessionTokens
|
||||
from tests.utils.security import random_password, random_token
|
||||
|
||||
BASE_TESTSERVER_URL = "http://testserver"
|
||||
|
||||
|
||||
TEST_USER_HEADER = "X-Test-User"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine() -> Iterator[Engine]:
|
||||
@@ -56,11 +64,14 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(auth_router)
|
||||
application.include_router(dashboard_router)
|
||||
application.include_router(calculations_router)
|
||||
application.include_router(projects_router)
|
||||
application.include_router(navigation_router)
|
||||
application.include_router(scenarios_router)
|
||||
application.include_router(imports_router)
|
||||
application.include_router(exports_router)
|
||||
application.include_router(reports_router)
|
||||
application.include_router(ui_router)
|
||||
|
||||
def _override_uow() -> Iterator[UnitOfWork]:
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
@@ -81,26 +92,64 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
||||
] = _override_ingestion_service
|
||||
|
||||
with UnitOfWork(session_factory=session_factory) as uow:
|
||||
assert uow.users is not None
|
||||
uow.ensure_default_roles()
|
||||
user = User(
|
||||
assert uow.users is not None and uow.roles is not None
|
||||
roles = {role.name: role for role in uow.ensure_default_roles()}
|
||||
admin_user = User(
|
||||
email="test-superuser@example.com",
|
||||
username="test-superuser",
|
||||
password_hash=User.hash_password(random_password()),
|
||||
is_active=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
uow.users.create(user)
|
||||
user = uow.users.get(user.id, with_roles=True)
|
||||
viewer_user = User(
|
||||
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:
|
||||
alias = request.headers.get(TEST_USER_HEADER, "admin").strip().lower()
|
||||
if alias == "anonymous":
|
||||
session = AuthSession.anonymous()
|
||||
else:
|
||||
user, role_slugs = _resolve_user(alias or "admin")
|
||||
session = AuthSession(
|
||||
tokens=SessionTokens(
|
||||
access_token=random_token(),
|
||||
refresh_token=random_token(),
|
||||
),
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
session.user = user
|
||||
session.set_role_slugs(role_slugs)
|
||||
request.state.auth_session = session
|
||||
return session
|
||||
|
||||
@@ -110,7 +159,7 @@ def app(session_factory: sessionmaker) -> FastAPI:
|
||||
|
||||
@pytest.fixture()
|
||||
def client(app: FastAPI) -> Iterator[TestClient]:
|
||||
test_client = TestClient(app)
|
||||
test_client = TestClient(app, headers={TEST_USER_HEADER: "admin"})
|
||||
try:
|
||||
yield test_client
|
||||
finally:
|
||||
@@ -120,13 +169,52 @@ def client(app: FastAPI) -> Iterator[TestClient]:
|
||||
@pytest_asyncio.fixture()
|
||||
async def async_client(app: FastAPI) -> 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()
|
||||
def unit_of_work_factory(session_factory: sessionmaker) -> Callable[[], UnitOfWork]:
|
||||
def _factory() -> UnitOfWork:
|
||||
return UnitOfWork(session_factory=session_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"
|
||||
193
tests/integration/test_capex_calculations.py
Normal file
193
tests/integration/test_capex_calculations.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str) -> int:
|
||||
response = client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"name": name,
|
||||
"location": "Nevada",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Project for capex testing",
|
||||
},
|
||||
)
|
||||
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": "Capex scenario",
|
||||
"status": "draft",
|
||||
"currency": "usd",
|
||||
"primary_resource": "diesel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_capex_calculation_html_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Capex HTML Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Capex HTML Scenario")
|
||||
|
||||
form_page = client.get(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}"
|
||||
)
|
||||
assert form_page.status_code == 200
|
||||
assert "Capex Planner" in form_page.text
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Crusher",
|
||||
"components[0][category]": "equipment",
|
||||
"components[0][amount]": "1200000",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][spend_year]": "0",
|
||||
"components[1][name]": "Conveyor",
|
||||
"components[1][category]": "equipment",
|
||||
"components[1][amount]": "800000",
|
||||
"components[1][currency]": "USD",
|
||||
"components[1][spend_year]": "1",
|
||||
"parameters[currency_code]": "USD",
|
||||
"parameters[contingency_pct]": "5",
|
||||
"parameters[discount_rate_pct]": "8",
|
||||
"parameters[evaluation_horizon_years]": "5",
|
||||
"options[persist]": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Capex calculation completed successfully." in response.text
|
||||
assert "Capex Summary" in response.text
|
||||
assert "$1,200,000.00" in response.text or "1,200,000" in response.text
|
||||
assert "USD" in response.text
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_capex is not None
|
||||
assert uow.scenario_capex is not None
|
||||
|
||||
project_snapshots = uow.project_capex.list_for_project(project_id)
|
||||
scenario_snapshots = uow.scenario_capex.list_for_scenario(scenario_id)
|
||||
|
||||
assert len(project_snapshots) == 1
|
||||
assert len(scenario_snapshots) == 1
|
||||
|
||||
project_snapshot = project_snapshots[0]
|
||||
scenario_snapshot = scenario_snapshots[0]
|
||||
|
||||
assert project_snapshot.total_capex is not None
|
||||
assert project_snapshot.contingency_pct is not None
|
||||
assert project_snapshot.total_with_contingency is not None
|
||||
assert float(project_snapshot.total_capex) == pytest.approx(2_000_000)
|
||||
assert float(project_snapshot.contingency_pct) == pytest.approx(5.0)
|
||||
assert float(project_snapshot.total_with_contingency) == pytest.approx(
|
||||
2_100_000)
|
||||
assert project_snapshot.component_count == 2
|
||||
assert project_snapshot.currency_code == "USD"
|
||||
|
||||
assert scenario_snapshot.total_capex is not None
|
||||
assert scenario_snapshot.contingency_amount is not None
|
||||
assert scenario_snapshot.total_with_contingency is not None
|
||||
assert float(scenario_snapshot.total_capex) == pytest.approx(2_000_000)
|
||||
assert float(
|
||||
scenario_snapshot.contingency_amount) == pytest.approx(100_000)
|
||||
assert float(scenario_snapshot.total_with_contingency) == pytest.approx(
|
||||
2_100_000)
|
||||
assert scenario_snapshot.component_count == 2
|
||||
assert scenario_snapshot.currency_code == "USD"
|
||||
assert scenario_snapshot.payload is not None
|
||||
|
||||
|
||||
def test_capex_calculation_json_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Capex JSON Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Capex JSON Scenario")
|
||||
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Camp",
|
||||
"category": "infrastructure",
|
||||
"amount": 600000,
|
||||
"currency": "USD",
|
||||
"spend_year": 0,
|
||||
},
|
||||
{
|
||||
"name": "Power",
|
||||
"category": "infrastructure",
|
||||
"amount": 400000,
|
||||
"currency": "USD",
|
||||
"spend_year": 1,
|
||||
},
|
||||
],
|
||||
"parameters": {
|
||||
"currency_code": "USD",
|
||||
"contingency_pct": 12.5,
|
||||
"discount_rate_pct": 6.5,
|
||||
"evaluation_horizon_years": 4,
|
||||
},
|
||||
"options": {"persist": True},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/capex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["currency"] == "USD"
|
||||
assert data["totals"]["overall"] == 1_000_000
|
||||
assert data["totals"]["contingency_pct"] == pytest.approx(12.5)
|
||||
assert data["totals"]["contingency_amount"] == pytest.approx(125000)
|
||||
assert data["totals"]["with_contingency"] == pytest.approx(1_125_000)
|
||||
|
||||
by_category = {row["category"]: row for row in data["totals"]["by_category"]}
|
||||
assert by_category["infrastructure"]["amount"] == pytest.approx(1_000_000)
|
||||
assert by_category["infrastructure"]["share"] == pytest.approx(100)
|
||||
|
||||
assert len(data["timeline"]) == 2
|
||||
assert data["timeline"][0]["year"] == 0
|
||||
assert data["timeline"][0]["spend"] == pytest.approx(600_000)
|
||||
assert data["timeline"][1]["cumulative"] == pytest.approx(1_000_000)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_capex is not None
|
||||
assert uow.scenario_capex is not None
|
||||
|
||||
scenario_snapshot = uow.scenario_capex.latest_for_scenario(scenario_id)
|
||||
project_snapshot = uow.project_capex.latest_for_project(project_id)
|
||||
|
||||
assert scenario_snapshot is not None
|
||||
assert project_snapshot is not None
|
||||
|
||||
assert scenario_snapshot.total_capex is not None
|
||||
assert project_snapshot.total_with_contingency is not None
|
||||
assert float(scenario_snapshot.total_capex) == pytest.approx(1_000_000)
|
||||
assert float(project_snapshot.total_with_contingency) == pytest.approx(
|
||||
1_125_000)
|
||||
|
||||
assert scenario_snapshot.payload is not None
|
||||
payload = scenario_snapshot.payload or {}
|
||||
result_payload = payload.get("result", {})
|
||||
assert result_payload.get("currency") == "USD"
|
||||
assert result_payload.get("totals", {}).get(
|
||||
"with_contingency") == pytest.approx(1_125_000)
|
||||
assert result_payload.get("totals", {}).get(
|
||||
"overall") == pytest.approx(1_000_000)
|
||||
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"
|
||||
310
tests/integration/test_opex_calculations.py
Normal file
310
tests/integration/test_opex_calculations.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from services.unit_of_work import UnitOfWork
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str) -> int:
|
||||
response = client.post(
|
||||
"/projects",
|
||||
json={
|
||||
"name": name,
|
||||
"location": "Nevada",
|
||||
"operation_type": "open_pit",
|
||||
"description": "Project for opex testing",
|
||||
},
|
||||
)
|
||||
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": "Processing opex scenario",
|
||||
"status": "draft",
|
||||
"currency": "usd",
|
||||
"primary_resource": "diesel",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_opex_calculation_html_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Opex HTML Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Opex HTML Scenario")
|
||||
|
||||
form_page = client.get(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}"
|
||||
)
|
||||
assert form_page.status_code == 200
|
||||
assert "Opex Planner" in form_page.text
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Power",
|
||||
"components[0][category]": "energy",
|
||||
"components[0][unit_cost]": "1000",
|
||||
"components[0][quantity]": "1",
|
||||
"components[0][frequency]": "monthly",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][period_start]": "1",
|
||||
"components[0][period_end]": "3",
|
||||
"components[1][name]": "Maintenance",
|
||||
"components[1][category]": "maintenance",
|
||||
"components[1][unit_cost]": "2500",
|
||||
"components[1][quantity]": "1",
|
||||
"components[1][frequency]": "quarterly",
|
||||
"components[1][currency]": "USD",
|
||||
"components[1][period_start]": "1",
|
||||
"components[1][period_end]": "2",
|
||||
"parameters[currency_code]": "USD",
|
||||
"parameters[escalation_pct]": "5",
|
||||
"parameters[discount_rate_pct]": "3",
|
||||
"parameters[evaluation_horizon_years]": "3",
|
||||
"parameters[apply_escalation]": "1",
|
||||
"options[persist]": "1",
|
||||
"options[snapshot_notes]": "Opex HTML flow",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Opex calculation completed successfully." in response.text
|
||||
assert "Opex Summary" in response.text
|
||||
assert "$22,000.00" in response.text or "22,000" in response.text
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_opex is not None
|
||||
assert uow.scenario_opex is not None
|
||||
|
||||
project_snapshots = uow.project_opex.list_for_project(
|
||||
project_id)
|
||||
scenario_snapshots = uow.scenario_opex.list_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert len(project_snapshots) == 1
|
||||
assert len(scenario_snapshots) == 1
|
||||
|
||||
project_snapshot = project_snapshots[0]
|
||||
scenario_snapshot = scenario_snapshots[0]
|
||||
|
||||
assert project_snapshot.overall_annual is not None
|
||||
assert float(
|
||||
project_snapshot.overall_annual) == pytest.approx(22_000.0)
|
||||
assert project_snapshot.escalated_total is not None
|
||||
assert float(
|
||||
project_snapshot.escalated_total) == pytest.approx(58_330.0)
|
||||
assert project_snapshot.apply_escalation is True
|
||||
assert project_snapshot.component_count == 2
|
||||
assert project_snapshot.currency_code == "USD"
|
||||
|
||||
assert scenario_snapshot.overall_annual is not None
|
||||
assert float(
|
||||
scenario_snapshot.overall_annual) == pytest.approx(22_000.0)
|
||||
assert scenario_snapshot.escalated_total is not None
|
||||
assert float(
|
||||
scenario_snapshot.escalated_total) == pytest.approx(58_330.0)
|
||||
assert scenario_snapshot.apply_escalation is True
|
||||
assert scenario_snapshot.component_count == 2
|
||||
assert scenario_snapshot.currency_code == "USD"
|
||||
|
||||
|
||||
def test_opex_calculation_json_flow(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
) -> None:
|
||||
project_id = _create_project(client, "Opex JSON Project")
|
||||
scenario_id = _create_scenario(client, project_id, "Opex JSON Scenario")
|
||||
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Reagents",
|
||||
"category": "materials",
|
||||
"unit_cost": 400,
|
||||
"quantity": 10,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 3,
|
||||
},
|
||||
{
|
||||
"name": "Labor",
|
||||
"category": "labor",
|
||||
"unit_cost": 1500,
|
||||
"quantity": 4,
|
||||
"frequency": "weekly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 3,
|
||||
},
|
||||
{
|
||||
"name": "Maintenance",
|
||||
"category": "maintenance",
|
||||
"unit_cost": 12000,
|
||||
"quantity": 1,
|
||||
"frequency": "annually",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 3,
|
||||
},
|
||||
],
|
||||
"parameters": {
|
||||
"currency_code": "USD",
|
||||
"escalation_pct": 4,
|
||||
"discount_rate_pct": 2,
|
||||
"evaluation_horizon_years": 3,
|
||||
"apply_escalation": True,
|
||||
},
|
||||
"options": {"persist": True, "snapshot_notes": "Processing opex JSON flow"},
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
data = response.json()
|
||||
assert data["currency"] == "USD"
|
||||
|
||||
expected_overall = 372_000.0
|
||||
escalation_factor = 1 + (payload["parameters"]["escalation_pct"] / 100.0)
|
||||
expected_timeline = [
|
||||
expected_overall * (escalation_factor ** i) for i in range(payload["parameters"]["evaluation_horizon_years"])
|
||||
]
|
||||
expected_escalated_total = sum(expected_timeline)
|
||||
expected_average = expected_escalated_total / len(expected_timeline)
|
||||
|
||||
assert data["totals"]["overall_annual"] == pytest.approx(expected_overall)
|
||||
assert data["totals"]["escalated_total"] == pytest.approx(
|
||||
expected_escalated_total)
|
||||
assert data["totals"]["escalation_pct"] == pytest.approx(4.0)
|
||||
|
||||
by_category = {entry["category"] : entry for entry in data["totals"]["by_category"]}
|
||||
assert by_category["materials"]["annual_cost"] == pytest.approx(48_000.0)
|
||||
assert by_category["labor"]["annual_cost"] == pytest.approx(312_000.0)
|
||||
assert by_category["maintenance"]["annual_cost"] == pytest.approx(12_000.0)
|
||||
|
||||
assert len(data["timeline"]) == 3
|
||||
for index, entry in enumerate(data["timeline"], start=0):
|
||||
assert entry["period"] == index + 1
|
||||
assert entry["base_cost"] == pytest.approx(expected_overall)
|
||||
assert entry["escalated_cost"] == pytest.approx(
|
||||
expected_timeline[index])
|
||||
|
||||
assert data["metrics"]["annual_average"] == pytest.approx(expected_average)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_opex is not None
|
||||
assert uow.scenario_opex is not None
|
||||
|
||||
project_snapshot = uow.project_opex.latest_for_project(
|
||||
project_id)
|
||||
scenario_snapshot = uow.scenario_opex.latest_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert project_snapshot is not None
|
||||
assert scenario_snapshot is not None
|
||||
|
||||
assert project_snapshot.overall_annual is not None
|
||||
assert float(project_snapshot.overall_annual) == pytest.approx(
|
||||
expected_overall)
|
||||
assert project_snapshot.escalated_total is not None
|
||||
assert float(project_snapshot.escalated_total) == pytest.approx(
|
||||
expected_escalated_total)
|
||||
assert project_snapshot.apply_escalation is True
|
||||
|
||||
assert scenario_snapshot.annual_average is not None
|
||||
assert float(scenario_snapshot.annual_average) == pytest.approx(
|
||||
expected_average)
|
||||
assert scenario_snapshot.apply_escalation is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["form", "json"])
|
||||
def test_opex_calculation_currency_mismatch(
|
||||
client: TestClient,
|
||||
unit_of_work_factory: Callable[[], UnitOfWork],
|
||||
content_type: str,
|
||||
) -> None:
|
||||
project_id = _create_project(
|
||||
client, f"Opex {content_type.title()} Error Project")
|
||||
scenario_id = _create_scenario(
|
||||
client, project_id, f"Opex {content_type.title()} Error Scenario")
|
||||
|
||||
if content_type == "json":
|
||||
payload = {
|
||||
"components": [
|
||||
{
|
||||
"name": "Power",
|
||||
"category": "energy",
|
||||
"unit_cost": 500,
|
||||
"quantity": 1,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 2,
|
||||
}
|
||||
],
|
||||
"parameters": {"currency_code": "CAD"},
|
||||
"options": {"persist": True},
|
||||
}
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert "Component currency does not match" in body.get("message", "")
|
||||
assert any(
|
||||
"components[0].currency" in entry for entry in body.get("errors", []))
|
||||
else:
|
||||
response = client.post(
|
||||
f"/calculations/opex?project_id={project_id}&scenario_id={scenario_id}",
|
||||
data={
|
||||
"components[0][name]": "Power",
|
||||
"components[0][category]": "energy",
|
||||
"components[0][unit_cost]": "500",
|
||||
"components[0][quantity]": "1",
|
||||
"components[0][frequency]": "monthly",
|
||||
"components[0][currency]": "USD",
|
||||
"components[0][period_start]": "1",
|
||||
"components[0][period_end]": "2",
|
||||
"parameters[currency_code]": "CAD",
|
||||
"options[persist]": "1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert hasattr(response, "context")
|
||||
context = getattr(response, "context", {}) or {}
|
||||
combined_errors = [
|
||||
str(entry)
|
||||
for entry in (
|
||||
(context.get("errors") or [])
|
||||
+ (context.get("component_errors") or [])
|
||||
)
|
||||
]
|
||||
assert any(
|
||||
"components[0].currency" in entry for entry in combined_errors)
|
||||
|
||||
with unit_of_work_factory() as uow:
|
||||
assert uow.project_opex is not None
|
||||
assert uow.scenario_opex is not None
|
||||
|
||||
project_snapshots = uow.project_opex.list_for_project(
|
||||
project_id)
|
||||
scenario_snapshots = uow.scenario_opex.list_for_scenario(
|
||||
scenario_id)
|
||||
|
||||
assert project_snapshots == []
|
||||
assert scenario_snapshots == []
|
||||
@@ -1,10 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
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
|
||||
project_response = client.post(
|
||||
"/projects",
|
||||
@@ -36,7 +42,7 @@ class TestScenarioLifecycle:
|
||||
project_detail = client.get(f"/projects/{project_id}/view")
|
||||
assert project_detail.status_code == 200
|
||||
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
|
||||
form_response = client.post(
|
||||
@@ -61,16 +67,28 @@ class TestScenarioLifecycle:
|
||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||
assert scenario_detail.status_code == 200
|
||||
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 "Electricity" 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 = client.get(f"/projects/{project_id}/view")
|
||||
assert "<td>Active</td>" in project_detail.text
|
||||
assert "<td>CAD</td>" in project_detail.text
|
||||
assert "<td>Electricity</td>" in project_detail.text
|
||||
assert '<span class="status-pill status-pill--active">Active</span>' in project_detail.text
|
||||
assert 'CAD' in project_detail.text
|
||||
assert 'Electricity' in project_detail.text
|
||||
|
||||
# Attempt to update the scenario with invalid currency to trigger validation error
|
||||
invalid_update = client.put(
|
||||
@@ -84,6 +102,8 @@ class TestScenarioLifecycle:
|
||||
# Scenario detail should still show the previous (valid) currency
|
||||
scenario_detail = client.get(f"/scenarios/{scenario_id}/view")
|
||||
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_response = client.put(
|
||||
@@ -95,10 +115,18 @@ class TestScenarioLifecycle:
|
||||
|
||||
# Scenario detail reflects archived status
|
||||
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 = client.get(f"/projects/{project_id}/view")
|
||||
assert "<h2>Archived</h2>" 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)
|
||||
financial_inputs: dict[Tuple[int, str],
|
||||
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: {
|
||||
"users": 0,
|
||||
"projects": 0,
|
||||
"scenarios": 0,
|
||||
"financial_inputs": 0,
|
||||
"navigation_groups": 0,
|
||||
"navigation_links": 0,
|
||||
})
|
||||
|
||||
|
||||
@@ -50,6 +55,9 @@ class FakeResult:
|
||||
def fetchone(self) -> Any | None:
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self) -> list[Any]:
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, state: FakeState) -> None:
|
||||
@@ -105,6 +113,13 @@ class FakeConnection:
|
||||
rows = [SimpleNamespace(id=record["id"])] if record else []
|
||||
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"):
|
||||
key = (int(params["user_id"]), int(params["role_id"]))
|
||||
self.state.user_roles.add(key)
|
||||
@@ -171,6 +186,67 @@ class FakeConnection:
|
||||
rows = [SimpleNamespace(id=scenario["id"])] if scenario else []
|
||||
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"):
|
||||
key = (int(params["scenario_id"]), params["name"])
|
||||
record = self.state.financial_inputs.get(key)
|
||||
|
||||
93
tests/services/test_calculations_capex.py
Normal file
93
tests/services/test_calculations_capex.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import pytest
|
||||
|
||||
from schemas.calculations import (
|
||||
CapexCalculationOptions,
|
||||
CapexCalculationRequest,
|
||||
CapexComponentInput,
|
||||
CapexParameters,
|
||||
)
|
||||
from services.calculations import calculate_initial_capex
|
||||
from services.exceptions import CapexValidationError
|
||||
|
||||
|
||||
def _component(**kwargs) -> CapexComponentInput:
|
||||
defaults = {
|
||||
"id": None,
|
||||
"name": "Component",
|
||||
"category": "equipment",
|
||||
"amount": 1_000_000.0,
|
||||
"currency": "USD",
|
||||
"spend_year": 0,
|
||||
"notes": None,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return CapexComponentInput(**defaults)
|
||||
|
||||
|
||||
def test_calculate_initial_capex_success():
|
||||
request = CapexCalculationRequest(
|
||||
components=[
|
||||
_component(name="Crusher", category="equipment",
|
||||
amount=1_200_000, spend_year=0),
|
||||
_component(name="Conveyor", category="equipment",
|
||||
amount=800_000, spend_year=1),
|
||||
_component(name="Camp", category="infrastructure",
|
||||
amount=600_000, spend_year=1, currency="usd"),
|
||||
],
|
||||
parameters=CapexParameters(
|
||||
currency_code="USD",
|
||||
contingency_pct=10,
|
||||
discount_rate_pct=8,
|
||||
evaluation_horizon_years=5,
|
||||
),
|
||||
options=CapexCalculationOptions(persist=True),
|
||||
)
|
||||
|
||||
result = calculate_initial_capex(request)
|
||||
|
||||
assert result.currency == "USD"
|
||||
assert result.options.persist is True
|
||||
|
||||
assert result.totals.overall == pytest.approx(2_600_000)
|
||||
assert result.totals.contingency_pct == pytest.approx(10)
|
||||
assert result.totals.contingency_amount == pytest.approx(260_000)
|
||||
assert result.totals.with_contingency == pytest.approx(2_860_000)
|
||||
|
||||
by_category = {row.category: row for row in result.totals.by_category}
|
||||
assert by_category["equipment"].amount == pytest.approx(2_000_000)
|
||||
assert by_category["infrastructure"].amount == pytest.approx(600_000)
|
||||
assert by_category["equipment"].share == pytest.approx(76.923, rel=1e-3)
|
||||
assert by_category["infrastructure"].share == pytest.approx(
|
||||
23.077, rel=1e-3)
|
||||
|
||||
timeline = {(entry.year, entry.spend): entry.cumulative for entry in result.timeline}
|
||||
assert timeline[(0, 1_200_000)] == pytest.approx(1_200_000)
|
||||
assert timeline[(1, 1_400_000)] == pytest.approx(2_600_000)
|
||||
|
||||
assert len(result.components) == 3
|
||||
assert result.components[2].currency == "USD"
|
||||
|
||||
|
||||
def test_calculate_initial_capex_currency_mismatch_raises():
|
||||
request = CapexCalculationRequest(
|
||||
components=[
|
||||
_component(amount=500_000, currency="USD"),
|
||||
],
|
||||
parameters=CapexParameters(currency_code="CAD"),
|
||||
)
|
||||
|
||||
with pytest.raises(CapexValidationError) as exc:
|
||||
calculate_initial_capex(request)
|
||||
|
||||
assert "Component currency does not match" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_initial_capex_requires_components():
|
||||
request = CapexCalculationRequest(components=[])
|
||||
|
||||
with pytest.raises(CapexValidationError) as exc:
|
||||
calculate_initial_capex(request)
|
||||
|
||||
assert "At least one capex component" in exc.value.message
|
||||
assert exc.value.field_errors and "components" in exc.value.field_errors[0]
|
||||
159
tests/services/test_calculations_opex.py
Normal file
159
tests/services/test_calculations_opex.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import pytest
|
||||
|
||||
from schemas.calculations import (
|
||||
OpexCalculationRequest,
|
||||
OpexComponentInput,
|
||||
OpexOptions,
|
||||
OpexParameters,
|
||||
)
|
||||
from services.calculations import calculate_opex
|
||||
from services.exceptions import OpexValidationError
|
||||
|
||||
|
||||
def _component(**overrides) -> OpexComponentInput:
|
||||
defaults = {
|
||||
"id": None,
|
||||
"name": "Component",
|
||||
"category": "energy",
|
||||
"unit_cost": 1000.0,
|
||||
"quantity": 1.0,
|
||||
"frequency": "monthly",
|
||||
"currency": "USD",
|
||||
"period_start": 1,
|
||||
"period_end": 1,
|
||||
"notes": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return OpexComponentInput(**defaults)
|
||||
|
||||
|
||||
def test_calculate_opex_success():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(
|
||||
name="Power",
|
||||
category="energy",
|
||||
unit_cost=1000.0,
|
||||
quantity=1,
|
||||
frequency="monthly",
|
||||
period_start=1,
|
||||
period_end=3,
|
||||
),
|
||||
_component(
|
||||
name="Maintenance",
|
||||
category="maintenance",
|
||||
unit_cost=2500.0,
|
||||
quantity=1,
|
||||
frequency="quarterly",
|
||||
period_start=1,
|
||||
period_end=2,
|
||||
),
|
||||
],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=5,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
apply_escalation=True,
|
||||
),
|
||||
options=OpexOptions(persist=True, snapshot_notes=None),
|
||||
)
|
||||
|
||||
result = calculate_opex(request)
|
||||
|
||||
assert result.currency == "USD"
|
||||
assert result.options.persist is True
|
||||
|
||||
assert result.totals.overall_annual == pytest.approx(22_000.0)
|
||||
assert result.totals.escalated_total == pytest.approx(58_330.0, rel=1e-4)
|
||||
assert result.totals.escalation_pct == pytest.approx(5.0)
|
||||
|
||||
categories = {entry.category: entry for entry in result.totals.by_category}
|
||||
assert categories["energy"].annual_cost == pytest.approx(12_000.0)
|
||||
assert categories["maintenance"].annual_cost == pytest.approx(10_000.0)
|
||||
|
||||
assert len(result.timeline) == 3
|
||||
timeline = {entry.period: entry for entry in result.timeline}
|
||||
assert timeline[1].base_cost == pytest.approx(22_000.0)
|
||||
assert timeline[2].base_cost == pytest.approx(22_000.0)
|
||||
assert timeline[3].base_cost == pytest.approx(12_000.0)
|
||||
assert timeline[1].escalated_cost == pytest.approx(22_000.0)
|
||||
assert timeline[2].escalated_cost == pytest.approx(23_100.0, rel=1e-4)
|
||||
assert timeline[3].escalated_cost == pytest.approx(13_230.0, rel=1e-4)
|
||||
|
||||
assert result.metrics.annual_average == pytest.approx(
|
||||
19_443.3333, rel=1e-4)
|
||||
|
||||
assert len(result.components) == 2
|
||||
assert result.components[0].frequency == "monthly"
|
||||
assert result.components[1].frequency == "quarterly"
|
||||
|
||||
|
||||
def test_calculate_opex_currency_mismatch():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(currency="USD")],
|
||||
parameters=OpexParameters(
|
||||
currency_code="CAD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=10,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Component currency does not match" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].currency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_unsupported_frequency():
|
||||
request = OpexCalculationRequest(
|
||||
components=[_component(frequency="biweekly")],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
escalation_pct=None,
|
||||
discount_rate_pct=None,
|
||||
evaluation_horizon_years=2,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "Unsupported frequency" in exc.value.message
|
||||
assert exc.value.field_errors and "components[0].frequency" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_requires_components():
|
||||
request = OpexCalculationRequest(components=[])
|
||||
|
||||
with pytest.raises(OpexValidationError) as exc:
|
||||
calculate_opex(request)
|
||||
|
||||
assert "At least one opex component" in exc.value.message
|
||||
assert exc.value.field_errors and "components" in exc.value.field_errors[0]
|
||||
|
||||
|
||||
def test_calculate_opex_extends_evaluation_horizon():
|
||||
request = OpexCalculationRequest(
|
||||
components=[
|
||||
_component(period_start=1, period_end=4),
|
||||
],
|
||||
parameters=OpexParameters(
|
||||
currency_code="USD",
|
||||
discount_rate_pct=0,
|
||||
escalation_pct=0,
|
||||
evaluation_horizon_years=2,
|
||||
apply_escalation=False,
|
||||
),
|
||||
)
|
||||
|
||||
result = calculate_opex(request)
|
||||
|
||||
assert len(result.timeline) == 4
|
||||
assert result.timeline[-1].period == 4
|
||||
assert all(entry.escalated_cost is None for entry in result.timeline)
|
||||
assert result.timeline[-1].base_cost == pytest.approx(12_000.0)
|
||||
assert result.metrics.annual_average == pytest.approx(
|
||||
12_000.0, rel=1e-4)
|
||||
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 models import Role, User, UserRole
|
||||
from dependencies import get_auth_session, require_current_user
|
||||
from services.security import hash_password
|
||||
from dependencies import get_auth_session, get_jwt_settings, require_current_user
|
||||
from services.security import decode_access_token, hash_password
|
||||
from services.session import AuthSession, SessionTokens
|
||||
from tests.utils.security import random_password, random_token
|
||||
|
||||
@@ -334,6 +334,7 @@ class TestLoginFlowEndToEnd:
|
||||
|
||||
# Override to anonymous for login
|
||||
app = cast(FastAPI, client.app)
|
||||
original_override = app.dependency_overrides.get(get_auth_session)
|
||||
app.dependency_overrides[get_auth_session] = lambda: AuthSession.anonymous(
|
||||
)
|
||||
try:
|
||||
@@ -347,14 +348,21 @@ class TestLoginFlowEndToEnd:
|
||||
"location") == "http://testserver/"
|
||||
set_cookie_header = login_response.headers.get("set-cookie", "")
|
||||
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:
|
||||
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:
|
||||
# Assuming authenticated from conftest
|
||||
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):
|
||||
with auth_session_context(None):
|
||||
response = client.get("/projects/ui")
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
response = client.get("/projects/ui", follow_redirects=False)
|
||||
assert response.status_code == status.HTTP_303_SEE_OTHER
|
||||
assert response.headers["location"].endswith("/login")
|
||||
|
||||
|
||||
class TestRoleRestrictions:
|
||||
@@ -194,7 +194,7 @@ class TestRoleRestrictions:
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json()[
|
||||
"detail"] == "Insufficient role permissions for this action."
|
||||
"detail"] == "Insufficient permissions for this action."
|
||||
|
||||
def test_ui_project_edit_accessible_to_manager(
|
||||
self,
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models import MiningOperationType
|
||||
|
||||
|
||||
class TestDashboardRoute:
|
||||
def test_renders_empty_state(self, client: TestClient) -> None:
|
||||
@@ -17,9 +19,18 @@ class TestDashboardRoute:
|
||||
|
||||
class TestProjectUIRoutes:
|
||||
def test_projects_ui_page_resolves(self, client: TestClient) -> None:
|
||||
create_payload = {
|
||||
"name": "UI Project",
|
||||
"location": "Peru",
|
||||
"operation_type": MiningOperationType.OPEN_PIT.value,
|
||||
"description": "Project for UI validation",
|
||||
}
|
||||
client.post("/projects", json=create_payload)
|
||||
|
||||
response = client.get("/projects/ui")
|
||||
assert response.status_code == 200
|
||||
assert "Projects" in response.text
|
||||
assert "project-card" in response.text
|
||||
|
||||
def test_projects_create_form_resolves(self, client: TestClient) -> None:
|
||||
response = client.get("/projects/create")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from main import app
|
||||
from scripts.init_db import init_db
|
||||
|
||||
|
||||
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
|
||||
attempted to parse non-JSON bodies.
|
||||
"""
|
||||
init_db()
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
|
||||
142
tests/test_project_scenario_models.py
Normal file
142
tests/test_project_scenario_models.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from config.database import Base
|
||||
from models import (
|
||||
MiningOperationType,
|
||||
Project,
|
||||
ProjectProfitability,
|
||||
Scenario,
|
||||
ScenarioProfitability,
|
||||
ScenarioStatus,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine() -> Iterator:
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(engine) -> Iterator[Session]:
|
||||
TestingSession = sessionmaker(
|
||||
bind=engine, expire_on_commit=False, future=True)
|
||||
session = TestingSession()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def test_project_scenario_cascade_deletes(session: Session) -> None:
|
||||
project = Project(name="Cascade Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
Scenario(
|
||||
name="Base Case", status=ScenarioStatus.DRAFT, project=project)
|
||||
Scenario(
|
||||
name="Expansion", status=ScenarioStatus.DRAFT, project=project)
|
||||
|
||||
session.add(project)
|
||||
session.commit()
|
||||
|
||||
assert session.scalar(select(Project).where(
|
||||
Project.id == project.id)) is not None
|
||||
assert len(session.scalars(select(Scenario).where(
|
||||
Scenario.project_id == project.id)).all()) == 2
|
||||
|
||||
session.delete(project)
|
||||
session.commit()
|
||||
|
||||
assert session.scalar(select(Project).where(
|
||||
Project.id == project.id)) is None
|
||||
assert session.scalars(select(Scenario)).first() is None
|
||||
|
||||
|
||||
def test_scenario_unique_name_per_project(session: Session) -> None:
|
||||
project = Project(name="Uniqueness Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario = Scenario(name="Duplicated",
|
||||
status=ScenarioStatus.DRAFT, project=project)
|
||||
session.add_all([project, scenario])
|
||||
session.commit()
|
||||
|
||||
duplicate = Scenario(
|
||||
name="Duplicated", status=ScenarioStatus.DRAFT, project=project)
|
||||
session.add(duplicate)
|
||||
|
||||
with pytest.raises(IntegrityError):
|
||||
session.commit()
|
||||
session.rollback()
|
||||
|
||||
|
||||
def test_latest_profitability_helpers(session: Session) -> None:
|
||||
project = Project(name="Hierarchy Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario = Scenario(name="Economic Model",
|
||||
status=ScenarioStatus.DRAFT, project=project)
|
||||
session.add_all([project, scenario])
|
||||
session.commit()
|
||||
|
||||
base_time = datetime.now(timezone.utc)
|
||||
scenario_snapshot_old = ScenarioProfitability(
|
||||
scenario=scenario,
|
||||
npv=1_000_000,
|
||||
calculated_at=base_time,
|
||||
)
|
||||
scenario_snapshot_new = ScenarioProfitability(
|
||||
scenario=scenario,
|
||||
npv=2_500_000,
|
||||
calculated_at=base_time + timedelta(hours=6),
|
||||
)
|
||||
project_snapshot_old = ProjectProfitability(
|
||||
project=project,
|
||||
npv=5_000_000,
|
||||
calculated_at=base_time,
|
||||
)
|
||||
project_snapshot_new = ProjectProfitability(
|
||||
project=project,
|
||||
npv=7_500_000,
|
||||
calculated_at=base_time + timedelta(hours=12),
|
||||
)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
scenario_snapshot_old,
|
||||
scenario_snapshot_new,
|
||||
project_snapshot_old,
|
||||
project_snapshot_new,
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
|
||||
session.refresh(scenario)
|
||||
session.refresh(project)
|
||||
|
||||
assert scenario.latest_profitability is scenario_snapshot_new
|
||||
assert project.latest_profitability is project_snapshot_new
|
||||
|
||||
|
||||
def test_currency_normalisation_on_model(session: Session) -> None:
|
||||
project = Project(name="Forex Mine",
|
||||
operation_type=MiningOperationType.OTHER)
|
||||
scenario = Scenario(
|
||||
name="Currency Case", status=ScenarioStatus.DRAFT, project=project, currency="usd")
|
||||
|
||||
session.add_all([project, scenario])
|
||||
session.commit()
|
||||
|
||||
session.refresh(scenario)
|
||||
|
||||
assert scenario.currency == "USD"
|
||||
337
tests/test_project_scenario_routes.py
Normal file
337
tests/test_project_scenario_routes.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import status
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from models import MiningOperationType, ResourceType, ScenarioStatus
|
||||
|
||||
|
||||
def _create_project(client: TestClient, name: str = "Alpha Project") -> dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"location": "Chile",
|
||||
"operation_type": MiningOperationType.OPEN_PIT.value,
|
||||
"description": "Initial feasibility study",
|
||||
}
|
||||
response = client.post("/projects", json=payload)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def _create_scenario(
|
||||
client: TestClient,
|
||||
project_id: int,
|
||||
*,
|
||||
name: str = "Scenario A",
|
||||
status: ScenarioStatus = ScenarioStatus.DRAFT,
|
||||
currency: str | None = "USD",
|
||||
primary_resource: ResourceType | None = ResourceType.DIESEL,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"name": name,
|
||||
"status": status.value,
|
||||
}
|
||||
if currency:
|
||||
payload["currency"] = currency
|
||||
if primary_resource:
|
||||
payload["primary_resource"] = primary_resource.value
|
||||
|
||||
response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_project_crud_cycle(client: TestClient) -> None:
|
||||
project = _create_project(client)
|
||||
project_id = project["id"]
|
||||
|
||||
fetch_response = client.get(f"/projects/{project_id}")
|
||||
assert fetch_response.status_code == 200
|
||||
assert fetch_response.json()["name"] == "Alpha Project"
|
||||
|
||||
list_response = client.get("/projects")
|
||||
project_ids = {item["id"] for item in list_response.json()}
|
||||
assert project_id in project_ids
|
||||
|
||||
update_payload = {
|
||||
"description": "Updated project description", "location": "Peru"}
|
||||
update_response = client.put(
|
||||
f"/projects/{project_id}", json=update_payload)
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json(
|
||||
)["description"] == "Updated project description"
|
||||
assert update_response.json()["location"] == "Peru"
|
||||
|
||||
delete_response = client.delete(f"/projects/{project_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
missing_response = client.get(f"/projects/{project_id}")
|
||||
assert missing_response.status_code == 404
|
||||
|
||||
|
||||
def test_project_creation_conflict_returns_409(client: TestClient) -> None:
|
||||
_create_project(client, name="Conflict Project")
|
||||
conflict_payload = {
|
||||
"name": "Conflict Project",
|
||||
"location": "Canada",
|
||||
"operation_type": MiningOperationType.OTHER.value,
|
||||
"description": "Duplicate entry",
|
||||
}
|
||||
response = client.post("/projects", json=conflict_payload)
|
||||
assert response.status_code == 409
|
||||
assert "violates" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_project_requires_valid_operation_type(client: TestClient) -> None:
|
||||
invalid_payload = {
|
||||
"name": "Invalid Operation",
|
||||
"location": "Australia",
|
||||
"operation_type": "INVALID",
|
||||
"description": "Bad op type",
|
||||
}
|
||||
response = client.post("/projects", json=invalid_payload)
|
||||
assert response.status_code == 422
|
||||
body = response.json()
|
||||
assert body["detail"][0]["loc"][-1] == "operation_type"
|
||||
|
||||
|
||||
def test_scenario_crud_cycle(client: TestClient) -> None:
|
||||
project = _create_project(client)
|
||||
project_id = project["id"]
|
||||
|
||||
creation_payload = {
|
||||
"name": "Scenario A",
|
||||
"description": "Base case assumptions",
|
||||
"status": ScenarioStatus.DRAFT.value,
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2026-01-01",
|
||||
"discount_rate": 8.5,
|
||||
"currency": "usd",
|
||||
"primary_resource": ResourceType.DIESEL.value,
|
||||
}
|
||||
create_response = client.post(
|
||||
f"/projects/{project_id}/scenarios",
|
||||
json=creation_payload,
|
||||
)
|
||||
assert create_response.status_code == 201, create_response.text
|
||||
scenario = create_response.json()
|
||||
scenario_id = scenario["id"]
|
||||
assert scenario["currency"] == "USD"
|
||||
assert scenario["project_id"] == project_id
|
||||
|
||||
list_response = client.get(f"/projects/{project_id}/scenarios")
|
||||
assert list_response.status_code == 200
|
||||
listed_ids = {item["id"] for item in list_response.json()}
|
||||
assert scenario_id in listed_ids
|
||||
|
||||
update_payload = {"description": "Revised assumptions",
|
||||
"status": ScenarioStatus.ACTIVE.value}
|
||||
update_response = client.put(
|
||||
f"/scenarios/{scenario_id}", json=update_payload)
|
||||
assert update_response.status_code == 200
|
||||
updated = update_response.json()
|
||||
assert updated["description"] == "Revised assumptions"
|
||||
assert updated["status"] == ScenarioStatus.ACTIVE.value
|
||||
|
||||
delete_response = client.delete(f"/scenarios/{scenario_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
missing_response = client.get(f"/scenarios/{scenario_id}")
|
||||
assert missing_response.status_code == 404
|
||||
|
||||
|
||||
def test_create_scenario_requires_existing_project(client: TestClient) -> None:
|
||||
payload = {
|
||||
"name": "Orphan Scenario",
|
||||
"status": ScenarioStatus.DRAFT.value,
|
||||
}
|
||||
response = client.post("/projects/999/scenarios", json=payload)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_scenario_conflict_returns_409(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Scenario Container")
|
||||
project_id = project["id"]
|
||||
|
||||
payload = {"name": "Duplicate Scenario"}
|
||||
first_response = client.post(
|
||||
f"/projects/{project_id}/scenarios", json=payload)
|
||||
assert first_response.status_code == 201
|
||||
|
||||
conflict_response = client.post(
|
||||
f"/projects/{project_id}/scenarios", json=payload)
|
||||
assert conflict_response.status_code == 409
|
||||
assert "constraints" in conflict_response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_create_scenario_invalid_currency_returns_422(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Currency Project")
|
||||
project_id = project["id"]
|
||||
payload = {
|
||||
"name": "Bad Currency",
|
||||
"currency": "zz",
|
||||
}
|
||||
response = client.post(f"/projects/{project_id}/scenarios", json=payload)
|
||||
assert response.status_code == 422
|
||||
detail = response.json()["detail"][0]
|
||||
assert detail["loc"][-1] == "currency"
|
||||
assert "invalid currency code" in detail["msg"].lower()
|
||||
|
||||
|
||||
def test_list_scenarios_missing_project_returns_404(client: TestClient) -> None:
|
||||
response = client.get("/projects/424242/scenarios")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_project_detail_page_renders_scenario_list(client: TestClient) -> None:
|
||||
project = _create_project(client, name="UI Detail Project")
|
||||
project_id = project["id"]
|
||||
|
||||
_create_scenario(
|
||||
client,
|
||||
project_id,
|
||||
name="Scenario UI",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(f"/projects/{project_id}/view")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "scenario-list" in body
|
||||
assert "status-pill--active" in body
|
||||
|
||||
|
||||
def test_scenario_list_page_shows_calculator_shortcuts(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Portfolio Project")
|
||||
project_id = project["id"]
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project_id,
|
||||
name="Portfolio Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(f"/projects/{project_id}/scenarios/ui")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
unescaped = html.unescape(body)
|
||||
|
||||
assert "Scenario Portfolio" in body
|
||||
assert project["name"] in body
|
||||
assert scenario["name"] in body
|
||||
assert f"projects/{project_id}/view" in unescaped
|
||||
assert f"scenarios/{scenario['id']}/view" in unescaped
|
||||
expected_calc_fragment = (
|
||||
f"calculations/projects/{project_id}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert expected_calc_fragment in unescaped
|
||||
|
||||
|
||||
def test_scenario_detail_page_links_back_to_portfolio(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Detail Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Detail Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
primary_resource=ResourceType.ELECTRICITY,
|
||||
)
|
||||
|
||||
response = client.get(f"/scenarios/{scenario['id']}/view")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
unescaped = html.unescape(body)
|
||||
|
||||
assert project["name"] in body
|
||||
assert scenario["name"] in body
|
||||
assert "Scenario Overview" in body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in unescaped
|
||||
assert (
|
||||
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
in unescaped
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_form_includes_project_context_guidance(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Form Project")
|
||||
|
||||
response = client.get(f"/projects/{project['id']}/scenarios/new")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert "Project Context" in body
|
||||
assert project["name"] in body
|
||||
assert "Status Guidance" in body
|
||||
assert "Baseline Reminder" in body
|
||||
assert "Defaults to" in body
|
||||
|
||||
|
||||
def test_calculator_headers_surface_scenario_navigation(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Calc Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Calc Scenario",
|
||||
status=ScenarioStatus.DRAFT,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
profitability = client.get(
|
||||
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert profitability.status_code == 200
|
||||
profitability_body = html.unescape(profitability.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in profitability_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in profitability_body
|
||||
assert (
|
||||
f"calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
in profitability_body
|
||||
)
|
||||
|
||||
capex = client.get(
|
||||
f"/calculations/capex?project_id={project['id']}&scenario_id={scenario['id']}"
|
||||
)
|
||||
assert capex.status_code == 200
|
||||
capex_body = html.unescape(capex.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in capex_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in capex_body
|
||||
|
||||
opex = client.get(
|
||||
f"/calculations/opex?project_id={project['id']}&scenario_id={scenario['id']}"
|
||||
)
|
||||
assert opex.status_code == 200
|
||||
opex_body = html.unescape(opex.text)
|
||||
assert f"scenarios/{scenario['id']}/view" in opex_body
|
||||
assert f"projects/{project['id']}/scenarios/ui" in opex_body
|
||||
|
||||
|
||||
def test_profitability_legacy_endpoint_redirects_to_scenario_path(client: TestClient) -> None:
|
||||
project = _create_project(client, name="Redirect Project")
|
||||
scenario = _create_scenario(
|
||||
client,
|
||||
project["id"],
|
||||
name="Redirect Scenario",
|
||||
status=ScenarioStatus.ACTIVE,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/calculations/profitability?project_id={project['id']}&scenario_id={scenario['id']}",
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT
|
||||
location = response.headers.get("location")
|
||||
assert location is not None
|
||||
expected_path = (
|
||||
f"/calculations/projects/{project['id']}/scenarios/{scenario['id']}/profitability"
|
||||
)
|
||||
assert expected_path in location
|
||||
Reference in New Issue
Block a user