36 Commits

Author SHA1 Message Date
4e60168837 Merge https://git.allucanget.biz/allucanget/calminer into develop
All checks were successful
CI / lint (push) Successful in 16s
CI / lint (pull_request) Successful in 16s
CI / test (push) Successful in 1m4s
CI / test (pull_request) Successful in 1m2s
CI / build (push) Successful in 1m49s
CI / build (pull_request) Successful in 1m51s
2025-11-14 20:32:03 +01:00
dae3b59af9 feat(ci): add Kubernetes deployment toggle and update conditions for deployment steps
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m53s
CI / lint (pull_request) Successful in 16s
CI / test (pull_request) Successful in 1m3s
CI / build (pull_request) Successful in 1m51s
2025-11-14 20:14:53 +01:00
839399363e fix(ci): update registry handling and add image push step in CI workflow
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m4s
CI / build (push) Successful in 1m45s
2025-11-14 20:08:26 +01:00
fa8a065138 feat(ci): enhance CI workflow with metadata outputs and add Coolify deployment workflow
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m48s
2025-11-14 19:55:06 +01:00
cd0c0ab416 fix(ci-build): update conditions for push permissions in CI workflow
Some checks failed
CI / lint (push) Failing after 1s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
2025-11-14 19:21:48 +01:00
854b1ac713 Merge pull request 'feat:v2' (#12) from develop into main
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 2m17s
Reviewed-on: #12
2025-11-14 18:02:54 +01:00
25fd13ce69 Merge branch 'main' into develop
All checks were successful
CI / lint (push) Successful in 16s
CI / lint (pull_request) Successful in 16s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m56s
CI / test (pull_request) Successful in 1m3s
CI / build (pull_request) Successful in 1m51s
2025-11-14 18:02:43 +01:00
0fec805db1 Delete templates/dashboard.html
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-14 18:02:33 +01:00
3746062819 chore: remove cicache workflow file
All checks were successful
CI / lint (push) Successful in 17s
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 1m54s
CI / lint (pull_request) Successful in 15s
CI / test (pull_request) Successful in 1m2s
CI / build (pull_request) Successful in 1m46s
2025-11-14 16:34:17 +01:00
958c165721 chore: add .gitattributes for text handling and line endings
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m4s
CI / build (push) Successful in 1m56s
CI / deploy (push) Has been skipped
2025-11-14 14:21:16 +01:00
6e835c83eb fix(Dockerfile): implement fallback mechanisms for apt update and install
All checks were successful
CI / lint (push) Successful in 16s
CI / test (push) Successful in 1m2s
CI / build (push) Successful in 1m49s
CI / deploy (push) Has been skipped
2025-11-14 14:12:02 +01:00
75924fca84 feat(ci): add CI workflows for linting, testing, and building
Some checks failed
CI / lint (push) Successful in 15s
CI / test (push) Successful in 1m2s
CI / build (push) Failing after 29s
CI / deploy (push) Has been skipped
2025-11-14 13:45:10 +01:00
ac9ffddbde fix(ci): downgrade upload-artifact action to v3 for compatibility
Some checks failed
CI / build (push) Failing after 41s
CI / deploy (push) Has been skipped
CI / lint (push) Successful in 15s
CI / test (push) Successful in 1m12s
2025-11-14 13:31:26 +01:00
4e5a4c645d chore: remove Playwright installation steps from CI workflow
Some checks failed
CI / lint (push) Successful in 15s
CI / test (push) Failing after 1m2s
CI / build (push) Has been skipped
CI / deploy (push) Has been skipped
2025-11-14 13:26:33 +01:00
e9678b6736 chore: remove CI workflow file and update test files for improved structure and functionality
Some checks failed
CI / lint (push) Successful in 15s
CI / test (push) Failing after 16s
CI / build (push) Has been skipped
CI / deploy (push) Has been skipped
2025-11-14 13:25:02 +01:00
e5e346b26a Update templates/dashboard.html
Some checks failed
CI / build (push) Has been skipped
CI / test (push) Failing after 17s
CI / deploy (push) Has been skipped
CI / lint (push) Successful in 16s
2025-11-14 13:11:08 +01:00
b0e623d68e fix(tests): use secure token generation for access token in navigation client
Some checks failed
CI / lint (push) Successful in 15s
CI / build (push) Has been skipped
CI / test (push) Failing after 18s
CI / deploy (push) Has been skipped
2025-11-14 13:08:09 +01:00
30dbc13fae fix(init_db): correct SQL syntax for navigation link insertion
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped
2025-11-14 12:51:48 +01:00
31b9a1058a refactor: remove unused imports and streamline code in calculations and navigation services
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 14s
CI / deploy (push) Has been skipped
2025-11-14 12:28:48 +01:00
bcd993d57c feat(changelog): document completion of UI alignment initiative and style consolidation
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped
2025-11-13 22:34:31 +01:00
1262a4a63f Refactor CSS styles and introduce theme variables
- Removed redundant CSS rules and consolidated styles across dashboard, forms, imports, projects, and scenarios.
- Introduced new color variables in theme-default.css for better maintainability and consistency.
- Updated existing styles to utilize new color variables, enhancing the overall design.
- Improved responsiveness and layout of various components, including tables and cards.
- Ensured consistent styling for buttons, links, and headers across the application.
2025-11-13 22:30:58 +01:00
fb6816de00 Add form styles and update button classes for consistency
- Introduced a new CSS file for form styles (forms.css) to enhance form layout and design.
- Removed deprecated button styles from imports.css and updated button classes across templates to use the new utility classes.
- Updated various templates to reflect the new button styles, ensuring a consistent look and feel throughout the application.
- Refactored form-related styles in main.css and removed redundant styles from projects.css and scenarios.css.
- Ensured responsive design adjustments for form actions in smaller viewports.
2025-11-13 21:18:32 +01:00
4d0e1a9989 feat(navigation): Enhance navigation links and add legacy route redirects
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 14s
CI / deploy (push) Has been skipped
- Updated navigation links in `init_db.py` to include href overrides and parent slugs for profitability, opex, and capex planners.
- Modified `NavigationService` to handle child links and href overrides, ensuring proper routing when context is missing.
- Adjusted scenario detail and list templates to use new route names for opex and capex forms, with legacy fallbacks.
- Introduced integration tests for legacy calculation routes to ensure proper redirection and error handling.
- Added tests for navigation sidebar to validate role-based access and link visibility.
- Enhanced navigation sidebar tests to include calculation links and contextual URLs based on project and scenario IDs.
2025-11-13 20:23:53 +01:00
ed8e05147c feat: update status codes and navigation structure in calculations and reports routes 2025-11-13 17:14:17 +01:00
522b1e4105 feat: add scenarios list page with metrics and quick actions
Some checks failed
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / lint (push) Failing after 15s
CI / deploy (push) Has been skipped
- Introduced a new template for listing scenarios associated with a project.
- Added metrics for total, active, draft, and archived scenarios.
- Implemented quick actions for creating new scenarios and reviewing project overview.
- Enhanced navigation with breadcrumbs for better user experience.

refactor: update Opex and Profitability templates for consistency

- Changed titles and button labels for clarity in Opex and Profitability templates.
- Updated form IDs and action URLs for better alignment with new naming conventions.
- Improved navigation links to include scenario and project overviews.

test: add integration tests for Opex calculations

- Created new tests for Opex calculation HTML and JSON flows.
- Validated successful calculations and ensured correct data persistence.
- Implemented tests for currency mismatch and unsupported frequency scenarios.

test: enhance project and scenario route tests

- Added tests to verify scenario list rendering and calculator shortcuts.
- Ensured scenario detail pages link back to the portfolio correctly.
- Validated project detail pages show associated scenarios accurately.
2025-11-13 16:21:36 +01:00
4f00bf0d3c feat: Add CRUD tests for project and scenario models 2025-11-13 11:06:39 +01:00
3551b0356d feat: Add comprehensive test suite for project and scenario models 2025-11-13 11:05:36 +01:00
521a8abc2d feat: Migrate to Pydantic's @field_validator and implement lifespan handler in FastAPI 2025-11-13 09:54:09 +01:00
1feae7ff85 feat: Add Processing Opex functionality
- Introduced OpexValidationError for handling validation errors in processing opex calculations.
- Implemented ProjectProcessingOpexRepository and ScenarioProcessingOpexRepository for managing project and scenario-level processing opex snapshots.
- Enhanced UnitOfWork to include repositories for processing opex.
- Updated sidebar navigation and scenario detail templates to include links to the new Processing Opex Planner.
- Created a new template for the Processing Opex Planner with form handling for input components and parameters.
- Developed integration tests for processing opex calculations, covering HTML and JSON flows, including validation for currency mismatches and unsupported frequencies.
- Added unit tests for the calculation logic, ensuring correct handling of various scenarios and edge cases.
2025-11-13 09:26:57 +01:00
1240b08740 feat: Persist initial capex calculations and enhance navigation links in UI 2025-11-12 23:52:06 +01:00
d9fd82b2e3 feat: Implement initial capex calculation feature
- Added CapexComponentInput, CapexParameters, CapexCalculationRequest, CapexCalculationResult, and related schemas for capex calculations.
- Introduced calculate_initial_capex function to aggregate capex components and compute totals and timelines.
- Created ProjectCapexRepository and ScenarioCapexRepository for managing capex snapshots in the database.
- Developed capex.html template for capturing and displaying initial capex data.
- Registered common Jinja2 filters for formatting currency and percentages.
- Implemented unit and integration tests for capex calculation functionality.
- Updated unit of work to include new repositories for capex management.
2025-11-12 23:51:52 +01:00
6c1570a254 feat: Update favicon handling to use FileResponse and add favicon.ico 2025-11-12 22:42:09 +01:00
b1a6df9f90 feat: Add profitability calculation schemas and service functions
- Introduced Pydantic schemas for profitability calculations in `schemas/calculations.py`.
- Implemented service functions for profitability calculations in `services/calculations.py`.
- Added new exception class `ProfitabilityValidationError` for handling validation errors.
- Created repositories for managing project and scenario profitability snapshots.
- Developed a utility script for verifying authenticated routes.
- Added a new HTML template for the profitability calculator interface.
- Implemented a script to fix user ID sequence in the database.
2025-11-12 22:22:29 +01:00
6d496a599e feat: Resolve test suite regressions and enhance token tamper detection
feat: Add UI router to application for improved routing
style: Update breadcrumb styles in main.css and remove redundant styles from scenarios.css
2025-11-12 20:30:40 +01:00
1199813da0 feat: Add plotly to requirements for enhanced data visualization 2025-11-12 19:42:09 +01:00
edf86a5447 Update templates/dashboard.html
Some checks failed
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
2025-11-12 11:22:33 +01:00
95 changed files with 10916 additions and 1463 deletions

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
* text=auto
Dockerfile text eol=lf

View 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

View 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

View 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
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,105 @@
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: Capture deployment context
id: context
run: |
set -euo pipefail
repo="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}"
if [ -z "$repo" ]; then
repo="$(git remote get-url origin | sed 's#.*/\(.*\)\.git#\1#')"
fi
ref_name="${GITEA_REF_NAME:-${GITHUB_REF_NAME:-}}"
full_ref="${GITEA_REF:-${GITHUB_REF:-}}"
if [ -z "$ref_name" ] && [ -n "$full_ref" ]; then
ref_name="${full_ref##*/}"
fi
if [ -z "$ref_name" ]; then
ref_name="$(git rev-parse --abbrev-ref HEAD)"
fi
sha="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$sha" ]; then
sha="$(git rev-parse HEAD)"
fi
echo "repository=$repo" >> "$GITHUB_OUTPUT"
echo "ref=${ref_name:-main}" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- 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
env:
HEAD_SHA: ${{ steps.context.outputs.sha }}
run: |
set -euo pipefail
api_url="$COOLIFY_BASE_URL/api/v1/applications/${COOLIFY_APPLICATION_ID}/deploy"
payload=$(jq -n --arg sha "$HEAD_SHA" '{ commitSha: $sha }')
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

View File

@@ -41,8 +41,25 @@ if url:
finally:
sock.close()
PY
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
apt-get install -y --no-install-recommends build-essential gcc libpq-dev
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_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
apt-get install -y --no-install-recommends libpq5
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

View File

@@ -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.

Binary file not shown.

View File

@@ -1,11 +1,30 @@
# Changelog
## 2025-11-13
- Completed the UI alignment initiative by consolidating shared form and button styles into `static/css/forms.css` and `static/css/main.css`, introducing the semantic palette in `static/css/theme-default.css`, and spot-checking key pages plus contrast reports.
- Refactored the architecture data model docs by turning `calminer-docs/architecture/08_concepts/02_data_model.md` into a concise overview that links to new detail pages covering SQLAlchemy models, navigation metadata, enumerations, Pydantic schemas, and monitoring tables.
- Nested the calculator navigation under Projects by updating `scripts/init_db.py` seeds, teaching `services/navigation.py` to resolve scenario-scoped hrefs for profitability/opex/capex, and extending sidebar coverage through `tests/integration/test_navigation_sidebar_calculations.py` plus `tests/services/test_navigation_service.py` to validate admin/viewer visibility and contextual URL generation.
- Added navigation sidebar integration coverage by extending `tests/conftest.py` with role-switching headers, seeding admin/viewer test users, and adding `tests/integration/test_navigation_sidebar.py` to assert ordered link rendering for admins, viewer filtering of admin-only entries, and anonymous rejection of the endpoint.
- Finalised the financial data import/export templates by inventorying required fields, defining CSV column specs with validation rules, drafting Excel workbook layouts, documenting end-user workflows in `calminer-docs/userguide/data_import_export.md`, and recording stakeholder review steps alongside updated TODO/DONE tracking.
- Scoped profitability calculator UI under the scenario hierarchy by adding `/calculations/projects/{project_id}/scenarios/{scenario_id}/profitability` GET/POST handlers, updating scenario templates and sidebar navigation to link to the new route, and extending `tests/test_project_scenario_routes.py` with coverage for the scenario path plus legacy redirect behaviour (module run: 14 passed).
- Extended scenario frontend regression coverage by updating `tests/test_project_scenario_routes.py` to assert project/scenario breadcrumbs and calculator navigation, normalising escaped URLs, and re-running the module tests (13 passing).
- Cleared FastAPI and Pydantic deprecation warnings by migrating `scripts/init_db.py` to `@field_validator`, replacing the `main.py` startup hook with a lifespan handler, auditing template response call signatures, confirming HTTP 422 constant usage, and re-running the full pytest suite to ensure a clean warning slate.
- Delivered the capex planner end-to-end: added scaffolded UI in `templates/scenarios/capex.html`, wired GET/POST handlers through `routes/calculations.py`, implemented calculation logic plus snapshot persistence in `services/calculations.py` and `models/capex_snapshot.py`, updated navigation links, and introduced unit tests in `tests/services/test_calculations_capex.py`.
- Updated UI navigation to surface the opex planner by adding the sidebar link in `templates/partials/sidebar_nav.html`, wiring a scenario detail action in `templates/scenarios/detail.html`.
- Completed manual validation of the Capex Planner UI flows (sidebar entry, scenario deep link, validation errors, successful calculation) with results captured in `manual_tests/capex.md`, documented snapshot verification steps, and noted the optional JSON client check for future follow-up.
- Added opex calculation unit tests in `tests/services/test_calculations_opex.py` covering success metrics, currency validation, frequency enforcement, and evaluation horizon extension.
- Documented the Opex Planner workflow in `calminer-docs/userguide/opex_planner.md`, linked it from the user guide index, extended `calminer-docs/architecture/08_concepts/02_data_model.md` with snapshot coverage, and captured the completion in `.github/instructions/DONE.md`.
- Implemented opex integration coverage in `tests/integration/test_opex_calculations.py`, exercising HTML and JSON flows, verifying snapshot persistence, and asserting currency mismatch handling for form and API submissions.
- Executed the full pytest suite with coverage (211 tests) to confirm no regressions or warnings after the opex documentation updates.
- Completed the navigation sidebar API migration by finalising the database-backed service, refactoring `templates/partials/sidebar_nav.html` to consume the endpoint, hydrating via `static/js/navigation_sidebar.js`, and updating HTML route dependencies (`routes/projects.py`, `routes/scenarios.py`, `routes/reports.py`, `routes/imports.py`, `routes/calculations.py`) to use redirect-aware guards so anonymous visitors receive login redirects instead of JSON errors (manual verification via curl across projects, scenarios, reports, and calculations pages).
## 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.
- 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.
@@ -15,7 +34,6 @@
- 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.
@@ -30,7 +48,6 @@
- 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.

View File

@@ -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,
)

View File

@@ -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

62
main.py
View File

@@ -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")

View File

@@ -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),

View File

@@ -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
View 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
View 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
View 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,
)
)

View 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
)
)

View File

@@ -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})"

View File

@@ -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]

View File

@@ -30,6 +30,9 @@ omit = [
"scripts/*",
"main.py",
"routes/reports.py",
"routes/calculations.py",
"services/calculations.py",
"services/importers.py",
"services/reporting.py",
]

View File

@@ -14,3 +14,4 @@ python-jose
python-multipart
openpyxl
prometheus-client
plotly

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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:

View File

@@ -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",

View File

@@ -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
View 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),
)

View File

@@ -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),

View File

@@ -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

View File

@@ -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
View 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",
]

View File

@@ -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,

346
schemas/calculations.py Normal file
View 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
View 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()

View 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())

View 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')

View File

@@ -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)

View File

@@ -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
View 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",
]

View File

@@ -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
View 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)

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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
View 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;
}
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -0,0 +1,230 @@
(function () {
const NAV_ENDPOINT = "/navigation/sidebar";
const SIDEBAR_SELECTOR = ".sidebar-nav";
const DATA_SOURCE_ATTR = "navigationSource";
const ROLE_ATTR = "navigationRoles";
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 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];
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];
}
}
async function hydrateSidebar(navContainer) {
try {
const response = await fetch(NAV_ENDPOINT, {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
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);
} catch (error) {
console.warn("Navigation sidebar hydration failed", error);
}
}
onReady(() => {
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
if (!navContainer) {
return;
}
hydrateSidebar(navContainer);
});
})();

View File

@@ -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";
});
});
}

View File

@@ -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,6 +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_sidebar.js" defer></script>
<script src="/static/js/navigation.js" defer></script>
<script src="/static/js/theme.js"></script>
</body>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">&larr;</button>
<button id="nav-next" class="nav-chevron nav-chevron-next" aria-label="Next page">&rarr;</button>
</div>
{% include "partials/sidebar_nav.html" %}
</div>

View File

@@ -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") }}

View File

@@ -1,67 +1,78 @@
{% 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_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' }}"
>
<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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View 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 &amp; 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 %}

View File

@@ -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 scenarios 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 %}

View File

@@ -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 %}

View 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 %}

View 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 &amp; 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 %}

View 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 &amp; 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 &amp; 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 %}

View File

@@ -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

View 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"

View 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)

View 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

View 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"

View 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 == []

View File

@@ -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

View 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"

View File

@@ -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)

View 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]

View 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)

View 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"]

View File

@@ -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)

View File

@@ -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,

View File

@@ -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")

View File

@@ -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(

View 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"

View 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