Compare commits
8 Commits
feat/datab
...
300ecebe23
| Author | SHA1 | Date | |
|---|---|---|---|
| 300ecebe23 | |||
| 70db34d088 | |||
| 0550928a2f | |||
| ec56099e2a | |||
| c71908c8d9 | |||
| 75f533b87b | |||
| 5b1322ddbc | |||
| 713c9feebb |
111
.gitea/actions/setup-python-env/action.yml
Normal file
111
.gitea/actions/setup-python-env/action.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: Setup Python Environment
|
||||||
|
description: Configure Python, proxies, dependencies, and optional database setup for CI jobs.
|
||||||
|
author: CalMiner Team
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
description: Python version to install.
|
||||||
|
required: false
|
||||||
|
default: "3.10"
|
||||||
|
install-playwright:
|
||||||
|
description: Install Playwright browsers when true.
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
install-requirements:
|
||||||
|
description: Space-delimited list of requirement files to install.
|
||||||
|
required: false
|
||||||
|
default: "requirements.txt requirements-test.txt"
|
||||||
|
run-db-setup:
|
||||||
|
description: Run database wait and setup scripts when true.
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
db-dry-run:
|
||||||
|
description: Execute setup script dry run before live run when true.
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
- name: Configure apt proxy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PROXY_HOST="http://apt-cacher:3142"
|
||||||
|
if ! curl -fsS --connect-timeout 3 "${PROXY_HOST}" >/dev/null; then
|
||||||
|
PROXY_HOST="http://192.168.88.14:3142"
|
||||||
|
fi
|
||||||
|
echo "Using APT proxy ${PROXY_HOST}"
|
||||||
|
{
|
||||||
|
echo "http_proxy=${PROXY_HOST}"
|
||||||
|
echo "https_proxy=${PROXY_HOST}"
|
||||||
|
echo "HTTP_PROXY=${PROXY_HOST}"
|
||||||
|
echo "HTTPS_PROXY=${PROXY_HOST}"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
sudo tee /etc/apt/apt.conf.d/01proxy >/dev/null <<EOF
|
||||||
|
Acquire::http::Proxy "${PROXY_HOST}";
|
||||||
|
Acquire::https::Proxy "${PROXY_HOST}";
|
||||||
|
EOF
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
requirements="${{ inputs.install-requirements }}"
|
||||||
|
if [ -n "${requirements}" ]; then
|
||||||
|
for requirement in ${requirements}; do
|
||||||
|
if [ -f "${requirement}" ]; then
|
||||||
|
pip install -r "${requirement}"
|
||||||
|
else
|
||||||
|
echo "Requirement file ${requirement} not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: ${{ inputs.install-playwright == 'true' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python -m playwright install --with-deps
|
||||||
|
- name: Wait for database service
|
||||||
|
if: ${{ inputs.run-db-setup == 'true' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
dsn = (
|
||||||
|
f"dbname={os.environ['DATABASE_SUPERUSER_DB']} "
|
||||||
|
f"user={os.environ['DATABASE_SUPERUSER']} "
|
||||||
|
f"password={os.environ['DATABASE_SUPERUSER_PASSWORD']} "
|
||||||
|
f"host={os.environ['DATABASE_HOST']} "
|
||||||
|
f"port={os.environ['DATABASE_PORT']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(30):
|
||||||
|
try:
|
||||||
|
with psycopg2.connect(dsn):
|
||||||
|
break
|
||||||
|
except psycopg2.OperationalError:
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
raise SystemExit("Postgres service did not become available")
|
||||||
|
PY
|
||||||
|
- name: Run database setup (dry run)
|
||||||
|
if: ${{ inputs.run-db-setup == 'true' && inputs.db-dry-run == 'true' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python scripts/setup_database.py --ensure-database --ensure-role --ensure-schema --initialize-schema --run-migrations --seed-data --dry-run -v
|
||||||
|
- name: Run database setup
|
||||||
|
if: ${{ inputs.run-db-setup == 'true' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
python scripts/setup_database.py --ensure-database --ensure-role --ensure-schema --initialize-schema --run-migrations --seed-data -v
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
name: Build and Push Docker Image
|
name: Build and Push Docker Image
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Run Tests
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DEFAULT_BRANCH: main
|
DEFAULT_BRANCH: main
|
||||||
@@ -14,6 +19,8 @@ jobs:
|
|||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -26,6 +33,14 @@ jobs:
|
|||||||
event_name="${GITHUB_EVENT_NAME:-}"
|
event_name="${GITHUB_EVENT_NAME:-}"
|
||||||
sha="${GITHUB_SHA:-}"
|
sha="${GITHUB_SHA:-}"
|
||||||
|
|
||||||
|
if [ -z "$ref_name" ] && [ -n "${WORKFLOW_RUN_HEAD_BRANCH:-}" ]; then
|
||||||
|
ref_name="${WORKFLOW_RUN_HEAD_BRANCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$sha" ] && [ -n "${WORKFLOW_RUN_HEAD_SHA:-}" ]; then
|
||||||
|
sha="${WORKFLOW_RUN_HEAD_SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ]; then
|
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ]; then
|
||||||
echo "on_default=true" >> "$GITHUB_OUTPUT"
|
echo "on_default=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
name: Deploy to Server
|
name: Deploy to Server
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- Build and Push Docker Image
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DEFAULT_BRANCH: main
|
DEFAULT_BRANCH: main
|
||||||
@@ -14,6 +19,8 @@ jobs:
|
|||||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
WORKFLOW_RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
steps:
|
steps:
|
||||||
- name: SSH and deploy
|
- name: SSH and deploy
|
||||||
uses: appleboy/ssh-action@master
|
uses: appleboy/ssh-action@master
|
||||||
@@ -22,7 +29,15 @@ jobs:
|
|||||||
username: ${{ secrets.SSH_USERNAME }}
|
username: ${{ secrets.SSH_USERNAME }}
|
||||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
script: |
|
script: |
|
||||||
docker pull ${{ env.REGISTRY_URL }}/${{ env.REGISTRY_ORG }}/${{ env.REGISTRY_IMAGE_NAME }}:latest
|
IMAGE_SHA="${{ env.WORKFLOW_RUN_HEAD_SHA }}"
|
||||||
|
IMAGE_PATH="${{ env.REGISTRY_URL }}/${{ env.REGISTRY_ORG }}/${{ env.REGISTRY_IMAGE_NAME }}"
|
||||||
|
|
||||||
|
if [ -z "$IMAGE_SHA" ]; then
|
||||||
|
echo "Missing workflow run head SHA; aborting deployment." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker pull "$IMAGE_PATH:$IMAGE_SHA"
|
||||||
docker stop calminer || true
|
docker stop calminer || true
|
||||||
docker rm calminer || true
|
docker rm calminer || true
|
||||||
docker run -d --name calminer -p 8000:8000 \
|
docker run -d --name calminer -p 8000:8000 \
|
||||||
@@ -33,4 +48,4 @@ jobs:
|
|||||||
-e DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
|
-e DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
|
||||||
-e DATABASE_NAME=${{ secrets.DATABASE_NAME }} \
|
-e DATABASE_NAME=${{ secrets.DATABASE_NAME }} \
|
||||||
-e DATABASE_SCHEMA=${{ secrets.DATABASE_SCHEMA }} \
|
-e DATABASE_SCHEMA=${{ secrets.DATABASE_SCHEMA }} \
|
||||||
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USERNAME }}/calminer:latest
|
"$IMAGE_PATH:$IMAGE_SHA"
|
||||||
|
|||||||
@@ -2,7 +2,25 @@ name: Run Tests
|
|||||||
on: [push]
|
on: [push]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
tests:
|
||||||
|
name: ${{ matrix.target }} tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_DRIVER: postgresql
|
||||||
|
DATABASE_HOST: postgres
|
||||||
|
DATABASE_PORT: "5432"
|
||||||
|
DATABASE_NAME: calminer_ci
|
||||||
|
DATABASE_USER: calminer
|
||||||
|
DATABASE_PASSWORD: secret
|
||||||
|
DATABASE_SCHEMA: public
|
||||||
|
DATABASE_SUPERUSER: calminer
|
||||||
|
DATABASE_SUPERUSER_PASSWORD: secret
|
||||||
|
DATABASE_SUPERUSER_DB: calminer_ci
|
||||||
|
DATABASE_URL: postgresql+psycopg2://calminer:secret@postgres:5432/calminer_ci
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [unit, e2e]
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -10,116 +28,22 @@ jobs:
|
|||||||
POSTGRES_DB: calminer_ci
|
POSTGRES_DB: calminer_ci
|
||||||
POSTGRES_USER: calminer
|
POSTGRES_USER: calminer
|
||||||
POSTGRES_PASSWORD: secret
|
POSTGRES_PASSWORD: secret
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd "pg_isready -U calminer -d calminer_ci"
|
--health-cmd "pg_isready -U calminer -d calminer_ci"
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Set up Python
|
- name: Prepare Python environment
|
||||||
uses: actions/setup-python@v5
|
uses: ./.gitea/actions/setup-python-env
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
install-playwright: ${{ matrix.target == 'e2e' }}
|
||||||
- name: Configure apt proxy
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
PROXY_HOST="http://apt-cacher:3142"
|
|
||||||
if ! curl -fsS --connect-timeout 3 "${PROXY_HOST}" >/dev/null; then
|
|
||||||
PROXY_HOST="http://192.168.88.14:3142"
|
|
||||||
fi
|
|
||||||
echo "Using APT proxy ${PROXY_HOST}"
|
|
||||||
echo "http_proxy=${PROXY_HOST}" >> "$GITHUB_ENV"
|
|
||||||
echo "https_proxy=${PROXY_HOST}" >> "$GITHUB_ENV"
|
|
||||||
echo "HTTP_PROXY=${PROXY_HOST}" >> "$GITHUB_ENV"
|
|
||||||
echo "HTTPS_PROXY=${PROXY_HOST}" >> "$GITHUB_ENV"
|
|
||||||
sudo tee /etc/apt/apt.conf.d/01proxy >/dev/null <<EOF
|
|
||||||
Acquire::http::Proxy "${PROXY_HOST}";
|
|
||||||
Acquire::https::Proxy "${PROXY_HOST}";
|
|
||||||
EOF
|
|
||||||
# - name: Cache pip
|
|
||||||
# uses: actions/cache@v4
|
|
||||||
# with:
|
|
||||||
# path: ~/.cache/pip
|
|
||||||
# key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt', 'requirements-test.txt') }}
|
|
||||||
# restore-keys: |
|
|
||||||
# ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
|
|
||||||
# ${{ runner.os }}-pip-
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
pip install -r requirements.txt
|
|
||||||
pip install -r requirements-test.txt
|
|
||||||
- name: Install Playwright browsers
|
|
||||||
run: |
|
|
||||||
python -m playwright install --with-deps
|
|
||||||
- name: Wait for database service
|
|
||||||
env:
|
|
||||||
DATABASE_DRIVER: postgresql
|
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: "5432"
|
|
||||||
DATABASE_NAME: calminer_ci
|
|
||||||
DATABASE_USER: calminer
|
|
||||||
DATABASE_PASSWORD: secret
|
|
||||||
DATABASE_SCHEMA: public
|
|
||||||
DATABASE_SUPERUSER: calminer
|
|
||||||
DATABASE_SUPERUSER_PASSWORD: secret
|
|
||||||
DATABASE_SUPERUSER_DB: calminer_ci
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
dsn = (
|
|
||||||
f"dbname={os.environ['DATABASE_SUPERUSER_DB']} "
|
|
||||||
f"user={os.environ['DATABASE_SUPERUSER']} "
|
|
||||||
f"password={os.environ['DATABASE_SUPERUSER_PASSWORD']} "
|
|
||||||
f"host={os.environ['DATABASE_HOST']} "
|
|
||||||
f"port={os.environ['DATABASE_PORT']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for attempt in range(30):
|
|
||||||
try:
|
|
||||||
with psycopg2.connect(dsn):
|
|
||||||
break
|
|
||||||
except psycopg2.OperationalError:
|
|
||||||
time.sleep(2)
|
|
||||||
else:
|
|
||||||
raise SystemExit("Postgres service did not become available")
|
|
||||||
PY
|
|
||||||
- name: Run database setup (dry run)
|
|
||||||
env:
|
|
||||||
DATABASE_DRIVER: postgresql
|
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: "5432"
|
|
||||||
DATABASE_NAME: calminer_ci
|
|
||||||
DATABASE_USER: calminer
|
|
||||||
DATABASE_PASSWORD: secret
|
|
||||||
DATABASE_SCHEMA: public
|
|
||||||
DATABASE_SUPERUSER: calminer
|
|
||||||
DATABASE_SUPERUSER_PASSWORD: secret
|
|
||||||
DATABASE_SUPERUSER_DB: calminer_ci
|
|
||||||
run: python scripts/setup_database.py --ensure-database --ensure-role --ensure-schema --initialize-schema --run-migrations --seed-data --dry-run -v
|
|
||||||
- name: Run database setup
|
|
||||||
env:
|
|
||||||
DATABASE_DRIVER: postgresql
|
|
||||||
DATABASE_HOST: postgres
|
|
||||||
DATABASE_PORT: "5432"
|
|
||||||
DATABASE_NAME: calminer_ci
|
|
||||||
DATABASE_USER: calminer
|
|
||||||
DATABASE_PASSWORD: secret
|
|
||||||
DATABASE_SCHEMA: public
|
|
||||||
DATABASE_SUPERUSER: calminer
|
|
||||||
DATABASE_SUPERUSER_PASSWORD: secret
|
|
||||||
DATABASE_SUPERUSER_DB: calminer_ci
|
|
||||||
run: python scripts/setup_database.py --ensure-database --ensure-role --ensure-schema --initialize-schema --run-migrations --seed-data -v
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
run: |
|
||||||
DATABASE_URL: postgresql+psycopg2://calminer:secret@postgres:5432/calminer_ci
|
if [ "${{ matrix.target }}" = "unit" ]; then
|
||||||
DATABASE_SCHEMA: public
|
pytest tests/unit
|
||||||
run: pytest
|
else
|
||||||
|
pytest tests/e2e
|
||||||
|
fi
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,3 +45,6 @@ logs/
|
|||||||
# SQLite database
|
# SQLite database
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
test*.db
|
test*.db
|
||||||
|
|
||||||
|
# Docker files
|
||||||
|
.runner
|
||||||
|
|||||||
@@ -21,13 +21,18 @@ A range of features are implemented to support these functionalities.
|
|||||||
- **Unified UI Shell**: Server-rendered templates extend a shared base layout with a persistent left sidebar linking scenarios, parameters, costs, consumption, production, equipment, maintenance, simulations, and reporting views.
|
- **Unified UI Shell**: Server-rendered templates extend a shared base layout with a persistent left sidebar linking scenarios, parameters, costs, consumption, production, equipment, maintenance, simulations, and reporting views.
|
||||||
- **Operations Overview Dashboard**: The root route (`/`) surfaces cross-scenario KPIs, charts, and maintenance reminders with a one-click refresh backed by aggregated loaders.
|
- **Operations Overview Dashboard**: The root route (`/`) surfaces cross-scenario KPIs, charts, and maintenance reminders with a one-click refresh backed by aggregated loaders.
|
||||||
- **Theming Tokens**: Shared CSS variables in `static/css/main.css` centralize the UI color palette for consistent styling and rapid theme tweaks.
|
- **Theming Tokens**: Shared CSS variables in `static/css/main.css` centralize the UI color palette for consistent styling and rapid theme tweaks.
|
||||||
- **Modular Frontend Scripts**: Page-specific interactions now live in `static/js/` modules, keeping templates lean while enabling browser caching and reuse.
|
- **Settings Center**: The Settings landing page exposes visual theme controls and links to currency administration, backed by persisted application settings and environment overrides.
|
||||||
|
- **Modular Frontend Scripts**: Page-specific interactions in `static/js/` modules, keeping templates lean while enabling browser caching and reuse.
|
||||||
- **Monte Carlo Simulation (in progress)**: Services and routes are scaffolded for future stochastic analysis.
|
- **Monte Carlo Simulation (in progress)**: Services and routes are scaffolded for future stochastic analysis.
|
||||||
|
|
||||||
## Documentation & quickstart
|
## Documentation & quickstart
|
||||||
|
|
||||||
This repository contains detailed developer and architecture documentation in the `docs/` folder.
|
This repository contains detailed developer and architecture documentation in the `docs/` folder.
|
||||||
|
|
||||||
|
### Settings overview
|
||||||
|
|
||||||
|
The Settings page (`/ui/settings`) lets administrators adjust global theme colors stored in the `application_setting` table. Changes are instantly applied across the UI. Environment variables prefixed with `CALMINER_THEME_` (for example, `CALMINER_THEME_COLOR_PRIMARY`) automatically override individual CSS variables and render as read-only in the form, ensuring deployment-time overrides take precedence while remaining visible to operators.
|
||||||
|
|
||||||
[Quickstart](docs/quickstart.md) contains developer quickstart, migrations, testing and current status.
|
[Quickstart](docs/quickstart.md) contains developer quickstart, migrations, testing and current status.
|
||||||
|
|
||||||
Key architecture documents: see [architecture](docs/architecture/README.md) for the arc42-based architecture documentation.
|
Key architecture documents: see [architecture](docs/architecture/README.md) for the arc42-based architecture documentation.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description: "Explain the static structure: modules, components, services and th
|
|||||||
status: draft
|
status: draft
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!-- markdownlint-disable-next-line MD025 -->
|
||||||
# 05 — Building Block View
|
# 05 — Building Block View
|
||||||
|
|
||||||
## Architecture overview
|
## Architecture overview
|
||||||
@@ -25,6 +26,7 @@ Refer to the detailed architecture chapters in `docs/architecture/`:
|
|||||||
- leveraging a shared dependency module (`routes/dependencies.get_db`) for SQLAlchemy session management.
|
- leveraging a shared dependency module (`routes/dependencies.get_db`) for SQLAlchemy session management.
|
||||||
- **Models** (`models/`): SQLAlchemy ORM models representing database tables and relationships, encapsulating domain entities like Scenario, CapEx, OpEx, Consumption, ProductionOutput, Equipment, Maintenance, and SimulationResult.
|
- **Models** (`models/`): SQLAlchemy ORM models representing database tables and relationships, encapsulating domain entities like Scenario, CapEx, OpEx, Consumption, ProductionOutput, Equipment, Maintenance, and SimulationResult.
|
||||||
- **Services** (`services/`): business logic layer that processes data, performs calculations, and interacts with models. Key services include reporting calculations and Monte Carlo simulation scaffolding.
|
- **Services** (`services/`): business logic layer that processes data, performs calculations, and interacts with models. Key services include reporting calculations and Monte Carlo simulation scaffolding.
|
||||||
|
- `services/settings.py`: manages application settings backed by the `application_setting` table, including CSS variable defaults, persistence, and environment-driven overrides that surface in both the API and UI.
|
||||||
- **Database** (`config/database.py`): sets up the SQLAlchemy engine and session management for PostgreSQL interactions.
|
- **Database** (`config/database.py`): sets up the SQLAlchemy engine and session management for PostgreSQL interactions.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@@ -32,6 +34,8 @@ Refer to the detailed architecture chapters in `docs/architecture/`:
|
|||||||
- **Templates** (`templates/`): Jinja2 templates for server-rendered HTML views, extending a shared base layout with a persistent sidebar for navigation.
|
- **Templates** (`templates/`): Jinja2 templates for server-rendered HTML views, extending a shared base layout with a persistent sidebar for navigation.
|
||||||
- **Static Assets** (`static/`): CSS and JavaScript files for styling and interactivity. Shared CSS variables in `static/css/main.css` define the color palette, while page-specific JS modules in `static/js/` handle dynamic behaviors.
|
- **Static Assets** (`static/`): CSS and JavaScript files for styling and interactivity. Shared CSS variables in `static/css/main.css` define the color palette, while page-specific JS modules in `static/js/` handle dynamic behaviors.
|
||||||
- **Reusable partials** (`templates/partials/components.html`): macro library that standardises select inputs, feedback/empty states, and table wrappers so pages remain consistent while keeping DOM hooks stable for existing JavaScript modules.
|
- **Reusable partials** (`templates/partials/components.html`): macro library that standardises select inputs, feedback/empty states, and table wrappers so pages remain consistent while keeping DOM hooks stable for existing JavaScript modules.
|
||||||
|
- `templates/settings.html`: Settings hub that renders theme controls and environment override tables using metadata provided by `routes/ui.py`.
|
||||||
|
- `static/js/settings.js`: applies client-side validation, form submission, and live CSS updates for theme changes, respecting environment-managed variables returned by the API.
|
||||||
|
|
||||||
### Middleware & Utilities
|
### Middleware & Utilities
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ Refer to the detailed architecture chapters in `docs/architecture/`:
|
|||||||
- `consumption.py`, `production_output.py`: operational data tables.
|
- `consumption.py`, `production_output.py`: operational data tables.
|
||||||
- `equipment.py`, `maintenance.py`: asset management models.
|
- `equipment.py`, `maintenance.py`: asset management models.
|
||||||
- `simulation_result.py`: stores Monte Carlo iteration outputs.
|
- `simulation_result.py`: stores Monte Carlo iteration outputs.
|
||||||
|
- `application_setting.py`: persists editable application configuration, currently focused on theme variables but designed to store future settings categories.
|
||||||
|
|
||||||
## Service Layer
|
## Service Layer
|
||||||
|
|
||||||
|
|||||||
218
docs/architecture/07_deployment/07_01_testing_ci.md
Normal file
218
docs/architecture/07_deployment/07_01_testing_ci.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Testing, CI and Quality Assurance
|
||||||
|
|
||||||
|
This chapter centralizes the project's testing strategy, CI configuration, and quality targets.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CalMiner uses a combination of unit, integration, and end-to-end tests to ensure quality.
|
||||||
|
|
||||||
|
### Frameworks
|
||||||
|
|
||||||
|
- Backend: pytest for unit and integration tests.
|
||||||
|
- Frontend: pytest with Playwright for E2E tests.
|
||||||
|
- Database: pytest fixtures with psycopg2 for DB tests.
|
||||||
|
|
||||||
|
### Test Types
|
||||||
|
|
||||||
|
- Unit Tests: Test individual functions/modules.
|
||||||
|
- Integration Tests: Test API endpoints and DB interactions.
|
||||||
|
- E2E Tests: Playwright for full user flows.
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- Use Gitea Actions for CI/CD; workflows live under `.gitea/workflows/`.
|
||||||
|
- `test.yml` runs on every push, provisions a temporary Postgres 16 service, waits for readiness, executes the setup script in dry-run and live modes, then fans out into parallel matrix jobs for unit (`pytest tests/unit`) and end-to-end (`pytest tests/e2e`) suites. Playwright browsers install only for the E2E job.
|
||||||
|
- `build-and-push.yml` runs only after the **Run Tests** workflow finishes successfully (triggered via `workflow_run` on `main`). Once tests pass, it builds the Docker image with `docker/build-push-action@v2`, reuses cache-backed layers, and pushes to the Gitea registry.
|
||||||
|
- `deploy.yml` runs only after the build workflow reports success on `main`. It connects to the target host (via `appleboy/ssh-action`), pulls the Docker image tagged with the build commit SHA, and restarts the container with that exact image reference.
|
||||||
|
- Mandatory secrets: `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, `REGISTRY_URL`, `SSH_HOST`, `SSH_USERNAME`, `SSH_PRIVATE_KEY`.
|
||||||
|
- Run tests on pull requests to shared branches; enforce coverage target ≥80% (pytest-cov).
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
- Unit: `pytest tests/unit/`
|
||||||
|
- E2E: `pytest tests/e2e/`
|
||||||
|
- All: `pytest`
|
||||||
|
|
||||||
|
### Test Directory Structure
|
||||||
|
|
||||||
|
Organize tests under the `tests/` directory mirroring the application structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/
|
||||||
|
unit/
|
||||||
|
test_<module>.py
|
||||||
|
e2e/
|
||||||
|
test_<flow>.py
|
||||||
|
fixtures/
|
||||||
|
conftest.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixtures and Test Data
|
||||||
|
|
||||||
|
- Define reusable fixtures in `tests/fixtures/conftest.py`.
|
||||||
|
- Use temporary in-memory databases or isolated schemas for DB tests.
|
||||||
|
- Load sample data via fixtures for consistent test environments.
|
||||||
|
- Leverage the `seeded_ui_data` fixture in `tests/unit/conftest.py` to populate scenarios with related cost, maintenance, and simulation records for deterministic UI route checks.
|
||||||
|
|
||||||
|
### E2E (Playwright) Tests
|
||||||
|
|
||||||
|
The E2E test suite, located in `tests/e2e/`, uses Playwright to simulate user interactions in a live browser environment. These tests are designed to catch issues in the UI, frontend-backend integration, and overall application flow.
|
||||||
|
|
||||||
|
#### Fixtures
|
||||||
|
|
||||||
|
- `live_server`: A session-scoped fixture that launches the FastAPI application in a separate process, making it accessible to the browser.
|
||||||
|
- `playwright_instance`, `browser`, `page`: Standard `pytest-playwright` fixtures for managing the Playwright instance, browser, and individual pages.
|
||||||
|
|
||||||
|
#### Smoke Tests
|
||||||
|
|
||||||
|
- UI Page Loading: `test_smoke.py` contains a parameterized test that systematically navigates to all UI routes to ensure they load without errors, have the correct title, and display a primary heading.
|
||||||
|
- Form Submissions: Each major form in the application has a corresponding test file (e.g., `test_scenarios.py`, `test_costs.py`) that verifies: page loads, create item by filling the form, success message, and UI updates.
|
||||||
|
|
||||||
|
### Running E2E Tests
|
||||||
|
|
||||||
|
To run the Playwright tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/e2e/
|
||||||
|
````
|
||||||
|
|
||||||
|
To run headed mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/e2e/ --headed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocking and Dependency Injection
|
||||||
|
|
||||||
|
- Use `unittest.mock` to mock external dependencies.
|
||||||
|
- Inject dependencies via function parameters or FastAPI's dependency overrides in tests.
|
||||||
|
|
||||||
|
### Code Coverage
|
||||||
|
|
||||||
|
- Install `pytest-cov` to generate coverage reports.
|
||||||
|
- Run with coverage: `pytest --cov --cov-report=term` (use `--cov-report=html` when visualizing hotspots).
|
||||||
|
- Target 95%+ overall coverage. Focus on historically low modules: `services/simulation.py`, `services/reporting.py`, `middleware/validation.py`, and `routes/ui.py`.
|
||||||
|
- Latest snapshot (2025-10-21): `pytest --cov=. --cov-report=term-missing` returns **91%** overall coverage.
|
||||||
|
|
||||||
|
### CI Integration
|
||||||
|
|
||||||
|
`test.yml` encapsulates the steps below:
|
||||||
|
|
||||||
|
- Check out the repository and set up Python 3.10.
|
||||||
|
- Configure the runner's apt proxy (if available), install project dependencies (requirements + test extras), and download Playwright browsers.
|
||||||
|
- Run `pytest` (extend with `--cov` flags when enforcing coverage).
|
||||||
|
|
||||||
|
> The pip cache step is temporarily disabled in `test.yml` until the self-hosted cache service is exposed (see `docs/ci-cache-troubleshooting.md`).
|
||||||
|
|
||||||
|
`build-and-push.yml` adds:
|
||||||
|
|
||||||
|
- Registry login using repository secrets.
|
||||||
|
- Docker image build/push with GHA cache storage (`cache-from/cache-to` set to `type=gha`).
|
||||||
|
|
||||||
|
`deploy.yml` handles:
|
||||||
|
|
||||||
|
- SSH into the deployment host.
|
||||||
|
- Pull the tagged image from the registry.
|
||||||
|
- Stop, remove, and relaunch the `calminer` container exposing port 8000.
|
||||||
|
|
||||||
|
When adding new workflows, mirror this structure to ensure secrets, caching, and deployment steps remain aligned with the production environment.
|
||||||
|
|
||||||
|
## CI Owner Coordination Notes
|
||||||
|
|
||||||
|
### Key Findings
|
||||||
|
|
||||||
|
- Self-hosted runner: ASUS System Product Name chassis with AMD Ryzen 7 7700X (8 physical cores / 16 threads) and 63.2 GB usable RAM; `act_runner` configuration not overridden, so only one workflow job runs concurrently today.
|
||||||
|
- Unit test matrix job: completes 117 pytest cases in roughly 4.1 seconds after Postgres spins up; Docker services consume ~150 MB for `postgres:16-alpine`, with minimal sustained CPU load once tests begin.
|
||||||
|
- End-to-end matrix job: `pytest tests/e2e` averages 21‑22 seconds of execution, but a cold run downloads ~179 MB of apt packages plus ~470 MB of Playwright browser bundles (Chromium, Firefox, WebKit, FFmpeg), exceeding 650 MB network transfer and adding several gigabytes of disk writes if caches are absent.
|
||||||
|
- Both jobs reuse existing Python package caches when available; absent a shared cache service, repeated Playwright installs remain the dominant cost driver for cold executions.
|
||||||
|
|
||||||
|
### Open Questions
|
||||||
|
|
||||||
|
- Can we raise the runner concurrency above the default single job, or provision an additional runner, so the test matrix can execute without serializing queued workflows?
|
||||||
|
- Is there a central cache or artifact service available for Python wheels and Playwright browser bundles to avoid ~650 MB downloads on cold starts?
|
||||||
|
- Are we permitted to bake Playwright browsers into the base runner image, or should we pursue a shared cache/proxy solution instead?
|
||||||
|
|
||||||
|
### Outreach Draft
|
||||||
|
|
||||||
|
```text
|
||||||
|
Subject: CalMiner CI parallelization support
|
||||||
|
|
||||||
|
Hi <CI Owner>,
|
||||||
|
|
||||||
|
We recently updated the CalMiner test workflow to fan out unit and Playwright E2E suites in parallel. While validating the change, we gathered the following:
|
||||||
|
|
||||||
|
- Runner host: ASUS System Product Name with AMD Ryzen 7 7700X (8 cores / 16 threads), ~63 GB RAM, default `act_runner` concurrency (1 job at a time).
|
||||||
|
- Unit job finishes in ~4.1 s once Postgres is ready; light CPU and network usage.
|
||||||
|
- E2E job finishes in ~22 s, but a cold run pulls ~179 MB of apt packages plus ~470 MB of Playwright browser payloads (>650 MB download, several GB disk writes) because we do not have a shared cache yet.
|
||||||
|
|
||||||
|
To move forward, could you help with the following?
|
||||||
|
|
||||||
|
1. Confirm whether we can raise the runner concurrency limit or provision an additional runner so parallel jobs do not queue behind one another.
|
||||||
|
2. Let us know if a central cache (Artifactory, Nexus, etc.) is available for Python wheels and Playwright browser bundles, or if we should consider baking the browsers into the runner image instead.
|
||||||
|
3. Share any guidance on preferred caching or proxy solutions for large binary installs on self-hosted runners.
|
||||||
|
|
||||||
|
Once we have clarity, we can finalize the parallel rollout and update the documentation accordingly.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
<Your Name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Optimization Opportunities
|
||||||
|
|
||||||
|
### `test.yml`
|
||||||
|
|
||||||
|
- Run the apt-proxy setup once via a composite action or preconfigured runner image if additional matrix jobs are added.
|
||||||
|
- Collapse dependency installation into a single `pip install -r requirements-test.txt` call (includes base requirements) once caching is restored.
|
||||||
|
- Investigate caching or pre-baking Playwright browser binaries to eliminate >650 MB cold downloads per run.
|
||||||
|
|
||||||
|
### `build-and-push.yml`
|
||||||
|
|
||||||
|
- Skip QEMU setup or explicitly constrain Buildx to linux/amd64 to reduce startup time.
|
||||||
|
- Enable `cache-from` / `cache-to` settings (registry or `type=gha`) to reuse Docker build layers between runs.
|
||||||
|
|
||||||
|
### `deploy.yml`
|
||||||
|
|
||||||
|
- Extract deployment script into a reusable shell script or compose file to minimize inline secrets and ease multi-environment scaling.
|
||||||
|
- Add a post-deploy health check (e.g., `curl` readiness probe) before declaring success.
|
||||||
|
|
||||||
|
### Priority Overview
|
||||||
|
|
||||||
|
1. Restore shared caching for Python wheels and Playwright browsers once infrastructure exposes the cache service (highest impact on runtime and bandwidth; requires coordination with CI owners).
|
||||||
|
2. Enable Docker layer caching in `build-and-push.yml` to shorten build cycles (medium effort, immediate benefit to release workflows).
|
||||||
|
3. Add post-deploy health verification to `deploy.yml` (low effort, improves confidence in automation).
|
||||||
|
4. Streamline redundant setup steps in `test.yml` (medium effort once cache strategy is in place; consider composite actions or base image updates).
|
||||||
|
|
||||||
|
### Setup Consolidation Opportunities
|
||||||
|
|
||||||
|
- `Run Tests` matrix jobs each execute the apt proxy configuration, pip installs, database wait, and setup scripts. A composite action or shell script wrapper could centralize these routines and parameterize target-specific behavior (unit vs e2e) to avoid copy/paste maintenance as additional jobs (lint, type check) are introduced.
|
||||||
|
- Both the test and build workflows perform a `checkout` step; while unavoidable per workflow, shared git submodules or sparse checkout rules could be encapsulated in a composite action to keep options consistent.
|
||||||
|
- The database setup script currently runs twice (dry-run and live) for every matrix leg. Evaluate whether the dry-run remains necessary once migrations stabilize; if retained, consider adding an environment variable toggle to skip redundant seed operations for read-only suites (e.g., lint).
|
||||||
|
|
||||||
|
### Proposed Shared Setup Action
|
||||||
|
|
||||||
|
- Location: `.gitea/actions/setup-python-env/action.yml` (composite action).
|
||||||
|
- Inputs:
|
||||||
|
- `python-version` (default `3.10`): forwarded to `actions/setup-python`.
|
||||||
|
- `install-playwright` (default `false`): when `true`, run `python -m playwright install --with-deps`.
|
||||||
|
- `install-requirements` (default `requirements.txt requirements-test.txt`): space-delimited list pip installs iterate over.
|
||||||
|
- `run-db-setup` (default `true`): toggles database wait + setup scripts.
|
||||||
|
- `db-dry-run` (default `true`): controls whether the dry-run invocation executes.
|
||||||
|
- Steps encapsulated:
|
||||||
|
1. Set up Python via `actions/setup-python@v5` using provided version.
|
||||||
|
2. Configure apt proxy via shared shell snippet (with graceful fallback when proxy offline).
|
||||||
|
3. Iterate over requirement files and execute `pip install -r <file>`.
|
||||||
|
4. If `install-playwright == true`, install browsers.
|
||||||
|
5. If `run-db-setup == true`, run the wait-for-Postgres python snippet and call `scripts/setup_database.py`, honoring `db-dry-run` toggle.
|
||||||
|
- Usage sketch (in `test.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Prepare Python environment
|
||||||
|
uses: ./.gitea/actions/setup-python-env
|
||||||
|
with:
|
||||||
|
install-playwright: ${{ matrix.target == 'e2e' }}
|
||||||
|
db-dry-run: true
|
||||||
|
```
|
||||||
|
|
||||||
|
- Benefits: centralizes proxy logic and dependency installs, reduces duplication across matrix jobs, and keeps future lint/type-check jobs lightweight by disabling database setup.
|
||||||
|
- Implementation status: action available at `.gitea/actions/setup-python-env` and consumed by `test.yml`; extend to additional workflows as they adopt the shared routine.
|
||||||
|
- Obsolete steps removed: individual apt proxy, dependency install, Playwright, and database setup commands pruned from `test.yml` once the composite action was integrated.
|
||||||
@@ -15,20 +15,39 @@ The CalMiner application is deployed using a multi-tier architecture consisting
|
|||||||
1. **Client Layer**: This layer consists of web browsers that interact with the application through a user interface rendered by Jinja2 templates and enhanced with JavaScript (Chart.js for dashboards).
|
1. **Client Layer**: This layer consists of web browsers that interact with the application through a user interface rendered by Jinja2 templates and enhanced with JavaScript (Chart.js for dashboards).
|
||||||
2. **Web Application Layer**: This layer hosts the FastAPI application, which handles API requests, business logic, and serves HTML templates. It communicates with the database layer for data persistence.
|
2. **Web Application Layer**: This layer hosts the FastAPI application, which handles API requests, business logic, and serves HTML templates. It communicates with the database layer for data persistence.
|
||||||
3. **Database Layer**: This layer consists of a PostgreSQL database that stores all application data, including scenarios, parameters, costs, consumption, production outputs, equipment, maintenance logs, and simulation results.
|
3. **Database Layer**: This layer consists of a PostgreSQL database that stores all application data, including scenarios, parameters, costs, consumption, production outputs, equipment, maintenance logs, and simulation results.
|
||||||
4. **Caching Layer**: This layer uses Redis to cache frequently accessed data and improve application performance.
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Client Layer] --> B[Web Application Layer]
|
||||||
|
B --> C[Database Layer]
|
||||||
|
```
|
||||||
|
|
||||||
## Infrastructure Components
|
## Infrastructure Components
|
||||||
|
|
||||||
The infrastructure components for the application include:
|
The infrastructure components for the application include:
|
||||||
|
|
||||||
- **Web Server**: Hosts the FastAPI application and serves API endpoints.
|
|
||||||
- **Database Server**: PostgreSQL database for persisting application data.
|
|
||||||
- **Static File Server**: Serves static assets such as CSS, JavaScript, and image files.
|
|
||||||
- **Reverse Proxy (optional)**: An Nginx or Apache server can be used as a reverse proxy.
|
- **Reverse Proxy (optional)**: An Nginx or Apache server can be used as a reverse proxy.
|
||||||
- **Containerization**: Docker images are generated via the repository `Dockerfile`, using a multi-stage build to keep the final runtime minimal.
|
- **Containerization**: Docker images are generated via the repository `Dockerfile`, using a multi-stage build to keep the final runtime minimal.
|
||||||
- **CI/CD Pipeline**: Automated pipelines (Gitea Actions) run tests, build/push Docker images, and trigger deployments.
|
- **CI/CD Pipeline**: Automated pipelines (Gitea Actions) run tests, build/push Docker images, and trigger deployments.
|
||||||
|
- **Gitea Actions Workflows**: Located under `.gitea/workflows/`, these workflows handle testing, building, pushing, and deploying the application.
|
||||||
|
- **Gitea Action Runners**: Self-hosted runners execute the CI/CD workflows.
|
||||||
|
- **Testing and Continuous Integration**: Automated tests ensure code quality before deployment, also documented in [Testing & CI](07_deployment/07_01_testing_ci.md.md).
|
||||||
|
- **Docker Infrastructure**: Docker is used to containerize the application for consistent deployment across environments.
|
||||||
|
- **Portainer**: Production deployment environment for managing Docker containers.
|
||||||
|
- **Web Server**: Hosts the FastAPI application and serves API endpoints.
|
||||||
|
- **Database Server**: PostgreSQL database for persisting application data.
|
||||||
|
- **Static File Server**: Serves static assets such as CSS, JavaScript, and image files.
|
||||||
- **Cloud Infrastructure (optional)**: The application can be deployed on cloud platforms.
|
- **Cloud Infrastructure (optional)**: The application can be deployed on cloud platforms.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
W[Web Server] --> DB[Database Server]
|
||||||
|
W --> S[Static File Server]
|
||||||
|
P[Reverse Proxy] --> W
|
||||||
|
C[CI/CD Pipeline] --> W
|
||||||
|
F[Containerization] --> W
|
||||||
|
```
|
||||||
|
|
||||||
## Environments
|
## Environments
|
||||||
|
|
||||||
The application can be deployed in multiple environments to support development, testing, and production:
|
The application can be deployed in multiple environments to support development, testing, and production:
|
||||||
@@ -59,7 +78,7 @@ The production environment is set up for serving live traffic and includes:
|
|||||||
|
|
||||||
## Containerized Deployment Flow
|
## Containerized Deployment Flow
|
||||||
|
|
||||||
The Docker-based deployment path aligns with the solution strategy documented in [04 — Solution Strategy](04_solution_strategy.md) and the CI practices captured in [14 — Testing & CI](14_testing_ci.md).
|
The Docker-based deployment path aligns with the solution strategy documented in [Solution Strategy](04_solution_strategy.md) and the CI practices captured in [Testing & CI](07_deployment/07_01_testing_ci.md.md).
|
||||||
|
|
||||||
### Image Build
|
### Image Build
|
||||||
|
|
||||||
@@ -80,7 +99,7 @@ The Docker-based deployment path aligns with the solution strategy documented in
|
|||||||
- `build-and-push.yml` logs into the container registry, rebuilds the Docker image using GitHub Actions cache-backed layers, and pushes `latest` (and additional tags as required).
|
- `build-and-push.yml` logs into the container registry, rebuilds the Docker image using GitHub Actions cache-backed layers, and pushes `latest` (and additional tags as required).
|
||||||
- `deploy.yml` connects to the target host via SSH, pulls the pushed tag, stops any existing container, and launches the new version.
|
- `deploy.yml` connects to the target host via SSH, pulls the pushed tag, stops any existing container, and launches the new version.
|
||||||
- Required secrets: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, `SSH_HOST`, `SSH_USERNAME`, `SSH_PRIVATE_KEY`.
|
- Required secrets: `REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, `SSH_HOST`, `SSH_USERNAME`, `SSH_PRIVATE_KEY`.
|
||||||
- Extend these workflows when introducing staging/blue-green deployments; keep cross-links with [14 — Testing & CI](14_testing_ci.md) up to date.
|
- Extend these workflows when introducing staging/blue-green deployments; keep cross-links with [Testing & CI](07_deployment/07_01_testing_ci.md.md) up to date.
|
||||||
|
|
||||||
## Integrations and Future Work (deployment-related)
|
## Integrations and Future Work (deployment-related)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ See [Domain Models](08_concepts/08_01_domain_models.md) document for detailed cl
|
|||||||
- `production_output`: production metrics per scenario.
|
- `production_output`: production metrics per scenario.
|
||||||
- `equipment` and `maintenance`: equipment inventory and maintenance events with dates/costs.
|
- `equipment` and `maintenance`: equipment inventory and maintenance events with dates/costs.
|
||||||
- `simulation_result`: staging table for future Monte Carlo outputs (not yet populated by `run_simulation`).
|
- `simulation_result`: staging table for future Monte Carlo outputs (not yet populated by `run_simulation`).
|
||||||
|
- `application_setting`: centralized key/value store for UI and system configuration, supporting typed values, categories, and editability flags so administrators can manage theme variables and future global options without code changes.
|
||||||
|
|
||||||
Foreign keys secure referential integrity between domain tables and their scenarios, enabling per-scenario analytics.
|
Foreign keys secure referential integrity between domain tables and their scenarios, enabling per-scenario analytics.
|
||||||
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
# 14 Testing, CI and Quality Assurance
|
|
||||||
|
|
||||||
This chapter centralizes the project's testing strategy, CI configuration, and quality targets.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
CalMiner uses a combination of unit, integration, and end-to-end tests to ensure quality.
|
|
||||||
|
|
||||||
### Frameworks
|
|
||||||
|
|
||||||
- Backend: pytest for unit and integration tests.
|
|
||||||
- Frontend: pytest with Playwright for E2E tests.
|
|
||||||
- Database: pytest fixtures with psycopg2 for DB tests.
|
|
||||||
|
|
||||||
### Test Types
|
|
||||||
|
|
||||||
- Unit Tests: Test individual functions/modules.
|
|
||||||
- Integration Tests: Test API endpoints and DB interactions.
|
|
||||||
- E2E Tests: Playwright for full user flows.
|
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- Use Gitea Actions for CI/CD; workflows live under `.gitea/workflows/`.
|
|
||||||
- `test.yml` runs on every push, provisions a temporary Postgres 16 service, waits for readiness, executes the setup script in dry-run and live modes, installs Playwright browsers, and finally runs the full pytest suite.
|
|
||||||
- `build-and-push.yml` builds the Docker image with `docker/build-push-action@v2`, reusing GitHub Actions cache-backed layers, and pushes to the Gitea registry.
|
|
||||||
- `deploy.yml` connects to the target host (via `appleboy/ssh-action`) to pull the freshly pushed image and restart the container.
|
|
||||||
- Mandatory secrets: `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`, `REGISTRY_URL`, `SSH_HOST`, `SSH_USERNAME`, `SSH_PRIVATE_KEY`.
|
|
||||||
- Run tests on pull requests to shared branches; enforce coverage target ≥80% (pytest-cov).
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
- Unit: `pytest tests/unit/`
|
|
||||||
- E2E: `pytest tests/e2e/`
|
|
||||||
- All: `pytest`
|
|
||||||
|
|
||||||
### Test Directory Structure
|
|
||||||
|
|
||||||
Organize tests under the `tests/` directory mirroring the application structure:
|
|
||||||
|
|
||||||
````text
|
|
||||||
tests/
|
|
||||||
unit/
|
|
||||||
test_<module>.py
|
|
||||||
e2e/
|
|
||||||
test_<flow>.py
|
|
||||||
fixtures/
|
|
||||||
conftest.py
|
|
||||||
```python
|
|
||||||
|
|
||||||
### Fixtures and Test Data
|
|
||||||
|
|
||||||
- Define reusable fixtures in `tests/fixtures/conftest.py`.
|
|
||||||
- Use temporary in-memory databases or isolated schemas for DB tests.
|
|
||||||
- Load sample data via fixtures for consistent test environments.
|
|
||||||
- Leverage the `seeded_ui_data` fixture in `tests/unit/conftest.py` to populate scenarios with related cost, maintenance, and simulation records for deterministic UI route checks.
|
|
||||||
|
|
||||||
### E2E (Playwright) Tests
|
|
||||||
|
|
||||||
The E2E test suite, located in `tests/e2e/`, uses Playwright to simulate user interactions in a live browser environment. These tests are designed to catch issues in the UI, frontend-backend integration, and overall application flow.
|
|
||||||
|
|
||||||
#### Fixtures
|
|
||||||
|
|
||||||
- `live_server`: A session-scoped fixture that launches the FastAPI application in a separate process, making it accessible to the browser.
|
|
||||||
- `playwright_instance`, `browser`, `page`: Standard `pytest-playwright` fixtures for managing the Playwright instance, browser, and individual pages.
|
|
||||||
|
|
||||||
#### Smoke Tests
|
|
||||||
|
|
||||||
- UI Page Loading: `test_smoke.py` contains a parameterized test that systematically navigates to all UI routes to ensure they load without errors, have the correct title, and display a primary heading.
|
|
||||||
- Form Submissions: Each major form in the application has a corresponding test file (e.g., `test_scenarios.py`, `test_costs.py`) that verifies: page loads, create item by filling the form, success message, and UI updates.
|
|
||||||
|
|
||||||
### Running E2E Tests
|
|
||||||
|
|
||||||
To run the Playwright tests:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/e2e/
|
|
||||||
````
|
|
||||||
|
|
||||||
To run headed mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/e2e/ --headed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mocking and Dependency Injection
|
|
||||||
|
|
||||||
- Use `unittest.mock` to mock external dependencies.
|
|
||||||
- Inject dependencies via function parameters or FastAPI's dependency overrides in tests.
|
|
||||||
|
|
||||||
### Code Coverage
|
|
||||||
|
|
||||||
- Install `pytest-cov` to generate coverage reports.
|
|
||||||
- Run with coverage: `pytest --cov --cov-report=term` (use `--cov-report=html` when visualizing hotspots).
|
|
||||||
- Target 95%+ overall coverage. Focus on historically low modules: `services/simulation.py`, `services/reporting.py`, `middleware/validation.py`, and `routes/ui.py`.
|
|
||||||
- Latest snapshot (2025-10-21): `pytest --cov=. --cov-report=term-missing` returns **91%** overall coverage.
|
|
||||||
|
|
||||||
### CI Integration
|
|
||||||
|
|
||||||
`test.yml` encapsulates the steps below:
|
|
||||||
|
|
||||||
- Check out the repository and set up Python 3.10.
|
|
||||||
- Configure the runner's apt proxy (if available), install project dependencies (requirements + test extras), and download Playwright browsers.
|
|
||||||
- Run `pytest` (extend with `--cov` flags when enforcing coverage).
|
|
||||||
|
|
||||||
> The pip cache step is temporarily disabled in `test.yml` until the self-hosted cache service is exposed (see `docs/ci-cache-troubleshooting.md`).
|
|
||||||
|
|
||||||
`build-and-push.yml` adds:
|
|
||||||
|
|
||||||
- Registry login using repository secrets.
|
|
||||||
- Docker image build/push with GHA cache storage (`cache-from/cache-to` set to `type=gha`).
|
|
||||||
|
|
||||||
`deploy.yml` handles:
|
|
||||||
|
|
||||||
- SSH into the deployment host.
|
|
||||||
- Pull the tagged image from the registry.
|
|
||||||
- Stop, remove, and relaunch the `calminer` container exposing port 8000.
|
|
||||||
|
|
||||||
When adding new workflows, mirror this structure to ensure secrets, caching, and deployment steps remain aligned with the production environment.
|
|
||||||
@@ -16,11 +16,11 @@ This folder mirrors the arc42 chapter structure (adapted to Markdown).
|
|||||||
- [05 Building Block View](05_building_block_view.md)
|
- [05 Building Block View](05_building_block_view.md)
|
||||||
- [06 Runtime View](06_runtime_view.md)
|
- [06 Runtime View](06_runtime_view.md)
|
||||||
- [07 Deployment View](07_deployment_view.md)
|
- [07 Deployment View](07_deployment_view.md)
|
||||||
|
- [Testing & CI](07_deployment/07_01_testing_ci.md.md)
|
||||||
- [08 Concepts](08_concepts.md)
|
- [08 Concepts](08_concepts.md)
|
||||||
- [09 Architecture Decisions](09_architecture_decisions.md)
|
- [09 Architecture Decisions](09_architecture_decisions.md)
|
||||||
- [10 Quality Requirements](10_quality_requirements.md)
|
- [10 Quality Requirements](10_quality_requirements.md)
|
||||||
- [11 Technical Risks](11_technical_risks.md)
|
- [11 Technical Risks](11_technical_risks.md)
|
||||||
- [12 Glossary](12_glossary.md)
|
- [12 Glossary](12_glossary.md)
|
||||||
- [13 UI and Style](13_ui_and_style.md)
|
- [13 UI and Style](13_ui_and_style.md)
|
||||||
- [14 Testing & CI](14_testing_ci.md)
|
|
||||||
- [15 Development Setup](15_development_setup.md)
|
- [15 Development Setup](15_development_setup.md)
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ If you maintain a Postgres or Redis dependency locally, consider authoring a `do
|
|||||||
- **API base URL**: `http://localhost:8000/api`
|
- **API base URL**: `http://localhost:8000/api`
|
||||||
- Key routes include creating scenarios, parameters, costs, consumption, production, equipment, maintenance, and reporting summaries. See the `routes/` directory for full details.
|
- Key routes include creating scenarios, parameters, costs, consumption, production, equipment, maintenance, and reporting summaries. See the `routes/` directory for full details.
|
||||||
|
|
||||||
|
### Theme configuration
|
||||||
|
|
||||||
|
- Open `/ui/settings` to access the Settings dashboard. The **Theme Colors** form lists every CSS variable persisted in the `application_setting` table. Updates apply immediately across the UI once saved.
|
||||||
|
- Use the accompanying API endpoints for automation or integration tests:
|
||||||
|
- `GET /api/settings/css` returns the active variables, defaults, and metadata describing any environment overrides.
|
||||||
|
- `PUT /api/settings/css` accepts a payload such as `{"variables": {"--color-primary": "#112233"}}` and persists the change unless an environment override is in place.
|
||||||
|
- Environment variables prefixed with `CALMINER_THEME_` win over database values. For example, setting `CALMINER_THEME_COLOR_PRIMARY="#112233"` renders the corresponding input read-only and surfaces the override in the Environment Overrides table.
|
||||||
|
- Acceptable values include hex (`#rrggbb` or `#rrggbbaa`), `rgb()/rgba()`, and `hsl()/hsla()` expressions with the expected number of components. Invalid inputs trigger a validation error and the API responds with HTTP 422.
|
||||||
|
|
||||||
## Dashboard Preview
|
## Dashboard Preview
|
||||||
|
|
||||||
1. Start the FastAPI server and navigate to `/`.
|
1. Start the FastAPI server and navigate to `/`.
|
||||||
@@ -70,7 +79,7 @@ E2E tests use Playwright and a session-scoped `live_server` fixture that starts
|
|||||||
|
|
||||||
## Migrations & Baseline
|
## Migrations & Baseline
|
||||||
|
|
||||||
A consolidated baseline migration (`scripts/migrations/000_base.sql`) captures all schema changes required for a fresh installation. The script is idempotent: it creates the `currency` and `measurement_unit` reference tables, ensures consumption and production records expose unit metadata, and enforces the foreign keys used by CAPEX and OPEX.
|
A consolidated baseline migration (`scripts/migrations/000_base.sql`) captures all schema changes required for a fresh installation. The script is idempotent: it creates the `currency` and `measurement_unit` reference tables, provisions the `application_setting` store for configurable UI/system options, ensures consumption and production records expose unit metadata, and enforces the foreign keys used by CAPEX and OPEX.
|
||||||
|
|
||||||
Configure granular database settings in your PowerShell session before running migrations:
|
Configure granular database settings in your PowerShell session before running migrations:
|
||||||
|
|
||||||
@@ -88,6 +97,8 @@ python scripts/setup_database.py --run-migrations --seed-data
|
|||||||
|
|
||||||
The dry-run invocation reports which steps would execute without making changes. The live run applies the baseline (if not already recorded in `schema_migrations`) and seeds the reference data relied upon by the UI and API.
|
The dry-run invocation reports which steps would execute without making changes. The live run applies the baseline (if not already recorded in `schema_migrations`) and seeds the reference data relied upon by the UI and API.
|
||||||
|
|
||||||
|
> ℹ️ When `--seed-data` is supplied without `--run-migrations`, the bootstrap script automatically applies any pending SQL migrations first so the `application_setting` table (and future settings-backed features) are present before seeding.
|
||||||
|
|
||||||
> ℹ️ The application still accepts `DATABASE_URL` as a fallback if the granular variables are not set.
|
> ℹ️ The application still accepts `DATABASE_URL` as a fallback if the granular variables are not set.
|
||||||
|
|
||||||
## Database bootstrap workflow
|
## Database bootstrap workflow
|
||||||
@@ -234,7 +245,7 @@ The database contains tables such as `capex`, `opex`, `chemical_consumption`, `f
|
|||||||
## Where to look next
|
## Where to look next
|
||||||
|
|
||||||
- Architecture overview & chapters: [architecture](architecture/README.md) (per-chapter files under `docs/architecture/`)
|
- Architecture overview & chapters: [architecture](architecture/README.md) (per-chapter files under `docs/architecture/`)
|
||||||
- [Testing & CI](architecture/14_testing_ci.md)
|
- [Testing & CI](architecture/07_deployment/07_01_testing_ci.md.md)
|
||||||
- [Development setup](architecture/15_development_setup.md)
|
- [Development setup](architecture/15_development_setup.md)
|
||||||
- Implementation plan & roadmap: [Solution strategy](architecture/04_solution_strategy.md)
|
- Implementation plan & roadmap: [Solution strategy](architecture/04_solution_strategy.md)
|
||||||
- Routes: [routes](../routes/)
|
- Routes: [routes](../routes/)
|
||||||
|
|||||||
2
main.py
2
main.py
@@ -16,6 +16,7 @@ from routes.reporting import router as reporting_router
|
|||||||
from routes.currencies import router as currencies_router
|
from routes.currencies import router as currencies_router
|
||||||
from routes.simulations import router as simulations_router
|
from routes.simulations import router as simulations_router
|
||||||
from routes.maintenance import router as maintenance_router
|
from routes.maintenance import router as maintenance_router
|
||||||
|
from routes.settings import router as settings_router
|
||||||
|
|
||||||
# Initialize database schema
|
# Initialize database schema
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
@@ -43,4 +44,5 @@ app.include_router(equipment_router)
|
|||||||
app.include_router(maintenance_router)
|
app.include_router(maintenance_router)
|
||||||
app.include_router(reporting_router)
|
app.include_router(reporting_router)
|
||||||
app.include_router(currencies_router)
|
app.include_router(currencies_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
app.include_router(ui_router)
|
app.include_router(ui_router)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
models package initializer. Import the currency model so it's registered
|
models package initializer. Import key models so they're registered
|
||||||
with the shared Base.metadata when the package is imported by tests.
|
with the shared Base.metadata when the package is imported by tests.
|
||||||
"""
|
"""
|
||||||
|
from . import application_setting # noqa: F401
|
||||||
from . import currency # noqa: F401
|
from . import currency # noqa: F401
|
||||||
|
|||||||
29
models/application_setting.py
Normal file
29
models/application_setting.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from config.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationSetting(Base):
|
||||||
|
__tablename__ = "application_setting"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
value_type: Mapped[str] = mapped_column(String(32), nullable=False, default="string")
|
||||||
|
category: Mapped[str] = mapped_column(String(32), nullable=False, default="general")
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
is_editable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ApplicationSetting key={self.key} category={self.category}>"
|
||||||
85
routes/settings.py
Normal file
85
routes/settings.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from routes.dependencies import get_db
|
||||||
|
from services.settings import (
|
||||||
|
CSS_COLOR_DEFAULTS,
|
||||||
|
get_css_color_settings,
|
||||||
|
list_css_env_override_rows,
|
||||||
|
read_css_color_env_overrides,
|
||||||
|
update_css_color_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["Settings"])
|
||||||
|
|
||||||
|
|
||||||
|
class CSSSettingsPayload(BaseModel):
|
||||||
|
variables: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_allowed_keys(self) -> "CSSSettingsPayload":
|
||||||
|
invalid = set(self.variables.keys()) - set(CSS_COLOR_DEFAULTS.keys())
|
||||||
|
if invalid:
|
||||||
|
invalid_keys = ", ".join(sorted(invalid))
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported CSS variables: {invalid_keys}."
|
||||||
|
" Accepted keys align with the default theme variables."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class EnvOverride(BaseModel):
|
||||||
|
css_key: str
|
||||||
|
env_var: str
|
||||||
|
value: str
|
||||||
|
|
||||||
|
|
||||||
|
class CSSSettingsResponse(BaseModel):
|
||||||
|
variables: Dict[str, str]
|
||||||
|
env_overrides: Dict[str, str] = Field(default_factory=dict)
|
||||||
|
env_sources: List[EnvOverride] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/css", response_model=CSSSettingsResponse)
|
||||||
|
def read_css_settings(db: Session = Depends(get_db)) -> CSSSettingsResponse:
|
||||||
|
try:
|
||||||
|
values = get_css_color_settings(db)
|
||||||
|
env_overrides = read_css_color_env_overrides()
|
||||||
|
env_sources = [
|
||||||
|
EnvOverride(**row)
|
||||||
|
for row in list_css_env_override_rows()
|
||||||
|
]
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return CSSSettingsResponse(
|
||||||
|
variables=values,
|
||||||
|
env_overrides=env_overrides,
|
||||||
|
env_sources=env_sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/css", response_model=CSSSettingsResponse, status_code=status.HTTP_200_OK)
|
||||||
|
def update_css_settings(payload: CSSSettingsPayload, db: Session = Depends(get_db)) -> CSSSettingsResponse:
|
||||||
|
try:
|
||||||
|
values = update_css_color_settings(db, payload.variables)
|
||||||
|
env_overrides = read_css_color_env_overrides()
|
||||||
|
env_sources = [
|
||||||
|
EnvOverride(**row)
|
||||||
|
for row in list_css_env_override_rows()
|
||||||
|
]
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return CSSSettingsResponse(
|
||||||
|
variables=values,
|
||||||
|
env_overrides=env_overrides,
|
||||||
|
env_sources=env_sources,
|
||||||
|
)
|
||||||
27
routes/ui.py
27
routes/ui.py
@@ -20,6 +20,12 @@ from routes.dependencies import get_db
|
|||||||
from services.reporting import generate_report
|
from services.reporting import generate_report
|
||||||
from models.currency import Currency
|
from models.currency import Currency
|
||||||
from routes.currencies import DEFAULT_CURRENCY_CODE, _ensure_default_currency
|
from routes.currencies import DEFAULT_CURRENCY_CODE, _ensure_default_currency
|
||||||
|
from services.settings import (
|
||||||
|
CSS_COLOR_DEFAULTS,
|
||||||
|
get_css_color_settings,
|
||||||
|
list_css_env_override_rows,
|
||||||
|
read_css_color_env_overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
CURRENCY_CHOICES: list[Dict[str, Any]] = [
|
CURRENCY_CHOICES: list[Dict[str, Any]] = [
|
||||||
@@ -186,6 +192,20 @@ def _load_currency_settings(db: Session) -> Dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_css_settings(db: Session) -> Dict[str, Any]:
|
||||||
|
variables = get_css_color_settings(db)
|
||||||
|
env_overrides = read_css_color_env_overrides()
|
||||||
|
env_rows = list_css_env_override_rows()
|
||||||
|
env_meta = {row["css_key"]: row for row in env_rows}
|
||||||
|
return {
|
||||||
|
"css_variables": variables,
|
||||||
|
"css_defaults": CSS_COLOR_DEFAULTS,
|
||||||
|
"css_env_overrides": env_overrides,
|
||||||
|
"css_env_override_rows": env_rows,
|
||||||
|
"css_env_override_meta": env_meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_consumption(db: Session) -> Dict[str, Any]:
|
def _load_consumption(db: Session) -> Dict[str, Any]:
|
||||||
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
|
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
|
||||||
for record in (
|
for record in (
|
||||||
@@ -672,6 +692,13 @@ async def reporting_view(request: Request, db: Session = Depends(get_db)):
|
|||||||
return _render(request, "reporting.html", _load_reporting(db))
|
return _render(request, "reporting.html", _load_reporting(db))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ui/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_view(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""Render the settings landing page."""
|
||||||
|
context = _load_css_settings(db)
|
||||||
|
return _render(request, "settings.html", context)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ui/currencies", response_class=HTMLResponse)
|
@router.get("/ui/currencies", response_class=HTMLResponse)
|
||||||
async def currencies_view(request: Request, db: Session = Depends(get_db)):
|
async def currencies_view(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Render the currency administration page with full currency context."""
|
"""Render the currency administration page with full currency context."""
|
||||||
|
|||||||
@@ -27,6 +27,25 @@ SET name = EXCLUDED.name,
|
|||||||
symbol = EXCLUDED.symbol,
|
symbol = EXCLUDED.symbol,
|
||||||
is_active = EXCLUDED.is_active;
|
is_active = EXCLUDED.is_active;
|
||||||
|
|
||||||
|
-- Application-level settings table
|
||||||
|
CREATE TABLE IF NOT EXISTS application_setting (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
key VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
value_type VARCHAR(32) NOT NULL DEFAULT 'string',
|
||||||
|
category VARCHAR(32) NOT NULL DEFAULT 'general',
|
||||||
|
description TEXT,
|
||||||
|
is_editable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_application_setting_key
|
||||||
|
ON application_setting (key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_application_setting_category
|
||||||
|
ON application_setting (category);
|
||||||
|
|
||||||
-- Measurement unit reference table
|
-- Measurement unit reference table
|
||||||
CREATE TABLE IF NOT EXISTS measurement_unit (
|
CREATE TABLE IF NOT EXISTS measurement_unit (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migration: Create application_setting table for configurable application options
|
||||||
|
-- Date: 2025-10-25
|
||||||
|
-- Description: Introduces persistent storage for application-level settings such as theme colors.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS application_setting (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
key VARCHAR(128) NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
value_type VARCHAR(32) NOT NULL DEFAULT 'string',
|
||||||
|
category VARCHAR(32) NOT NULL DEFAULT 'general',
|
||||||
|
description TEXT,
|
||||||
|
is_editable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_application_setting_key
|
||||||
|
ON application_setting (key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_application_setting_category
|
||||||
|
ON application_setting (category);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1141,6 +1141,14 @@ def main() -> None:
|
|||||||
app_validated = True
|
app_validated = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
should_run_migrations = args.run_migrations
|
||||||
|
auto_run_migrations_reason: Optional[str] = None
|
||||||
|
if args.seed_data and not should_run_migrations:
|
||||||
|
should_run_migrations = True
|
||||||
|
auto_run_migrations_reason = (
|
||||||
|
"Seed data requested without explicit --run-migrations; applying migrations first."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.ensure_database:
|
if args.ensure_database:
|
||||||
setup.ensure_database()
|
setup.ensure_database()
|
||||||
@@ -1154,8 +1162,10 @@ def main() -> None:
|
|||||||
"SQLAlchemy schema initialization"
|
"SQLAlchemy schema initialization"
|
||||||
):
|
):
|
||||||
setup.initialize_schema()
|
setup.initialize_schema()
|
||||||
if args.run_migrations:
|
if should_run_migrations:
|
||||||
if ensure_application_connection_for("migration execution"):
|
if ensure_application_connection_for("migration execution"):
|
||||||
|
if auto_run_migrations_reason:
|
||||||
|
logger.info(auto_run_migrations_reason)
|
||||||
migrations_path = (
|
migrations_path = (
|
||||||
Path(args.migrations_dir)
|
Path(args.migrations_dir)
|
||||||
if args.migrations_dir
|
if args.migrations_dir
|
||||||
|
|||||||
208
services/settings.py
Normal file
208
services/settings.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Dict, Mapping
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models.application_setting import ApplicationSetting
|
||||||
|
|
||||||
|
CSS_COLOR_CATEGORY = "theme"
|
||||||
|
CSS_COLOR_VALUE_TYPE = "color"
|
||||||
|
CSS_ENV_PREFIX = "CALMINER_THEME_"
|
||||||
|
|
||||||
|
CSS_COLOR_DEFAULTS: Dict[str, str] = {
|
||||||
|
"--color-background": "#f4f5f7",
|
||||||
|
"--color-surface": "#ffffff",
|
||||||
|
"--color-text-primary": "#2a1f33",
|
||||||
|
"--color-text-secondary": "#624769",
|
||||||
|
"--color-text-muted": "#64748b",
|
||||||
|
"--color-text-subtle": "#94a3b8",
|
||||||
|
"--color-text-invert": "#ffffff",
|
||||||
|
"--color-text-dark": "#0f172a",
|
||||||
|
"--color-text-strong": "#111827",
|
||||||
|
"--color-primary": "#5f320d",
|
||||||
|
"--color-primary-strong": "#7e4c13",
|
||||||
|
"--color-primary-stronger": "#837c15",
|
||||||
|
"--color-accent": "#bff838",
|
||||||
|
"--color-border": "#e2e8f0",
|
||||||
|
"--color-border-strong": "#cbd5e1",
|
||||||
|
"--color-highlight": "#eef2ff",
|
||||||
|
"--color-panel-shadow": "rgba(15, 23, 42, 0.08)",
|
||||||
|
"--color-panel-shadow-deep": "rgba(15, 23, 42, 0.12)",
|
||||||
|
"--color-surface-alt": "#f8fafc",
|
||||||
|
"--color-success": "#047857",
|
||||||
|
"--color-error": "#b91c1c",
|
||||||
|
}
|
||||||
|
|
||||||
|
_COLOR_VALUE_PATTERN = re.compile(
|
||||||
|
r"^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_css_color_settings(db: Session) -> Dict[str, ApplicationSetting]:
|
||||||
|
"""Ensure the CSS color defaults exist in the settings table."""
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
db.query(ApplicationSetting)
|
||||||
|
.filter(ApplicationSetting.key.in_(CSS_COLOR_DEFAULTS.keys()))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_key = {setting.key: setting for setting in existing}
|
||||||
|
|
||||||
|
created = False
|
||||||
|
for key, default_value in CSS_COLOR_DEFAULTS.items():
|
||||||
|
if key in by_key:
|
||||||
|
continue
|
||||||
|
setting = ApplicationSetting(
|
||||||
|
key=key,
|
||||||
|
value=default_value,
|
||||||
|
value_type=CSS_COLOR_VALUE_TYPE,
|
||||||
|
category=CSS_COLOR_CATEGORY,
|
||||||
|
description=f"CSS variable {key}",
|
||||||
|
is_editable=True,
|
||||||
|
)
|
||||||
|
db.add(setting)
|
||||||
|
by_key[key] = setting
|
||||||
|
created = True
|
||||||
|
|
||||||
|
if created:
|
||||||
|
db.commit()
|
||||||
|
for key, setting in by_key.items():
|
||||||
|
db.refresh(setting)
|
||||||
|
|
||||||
|
return by_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_css_color_settings(db: Session) -> Dict[str, str]:
|
||||||
|
"""Return CSS color variables, filling missing values with defaults."""
|
||||||
|
|
||||||
|
settings = ensure_css_color_settings(db)
|
||||||
|
values: Dict[str, str] = {
|
||||||
|
key: settings[key].value if key in settings else default
|
||||||
|
for key, default in CSS_COLOR_DEFAULTS.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
env_overrides = read_css_color_env_overrides(os.environ)
|
||||||
|
if env_overrides:
|
||||||
|
values.update(env_overrides)
|
||||||
|
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def update_css_color_settings(db: Session, updates: Mapping[str, str]) -> Dict[str, str]:
|
||||||
|
"""Persist provided CSS color overrides and return the final values."""
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return get_css_color_settings(db)
|
||||||
|
|
||||||
|
invalid_keys = sorted(set(updates.keys()) - set(CSS_COLOR_DEFAULTS.keys()))
|
||||||
|
if invalid_keys:
|
||||||
|
invalid_list = ", ".join(invalid_keys)
|
||||||
|
raise ValueError(f"Unsupported CSS variables: {invalid_list}")
|
||||||
|
|
||||||
|
normalized: Dict[str, str] = {}
|
||||||
|
for key, value in updates.items():
|
||||||
|
normalized[key] = _normalize_color_value(value)
|
||||||
|
|
||||||
|
settings = ensure_css_color_settings(db)
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
for key, value in normalized.items():
|
||||||
|
setting = settings[key]
|
||||||
|
if setting.value != value:
|
||||||
|
setting.value = value
|
||||||
|
changed = True
|
||||||
|
if setting.value_type != CSS_COLOR_VALUE_TYPE:
|
||||||
|
setting.value_type = CSS_COLOR_VALUE_TYPE
|
||||||
|
changed = True
|
||||||
|
if setting.category != CSS_COLOR_CATEGORY:
|
||||||
|
setting.category = CSS_COLOR_CATEGORY
|
||||||
|
changed = True
|
||||||
|
if not setting.is_editable:
|
||||||
|
setting.is_editable = True
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
db.commit()
|
||||||
|
for key in normalized.keys():
|
||||||
|
db.refresh(settings[key])
|
||||||
|
|
||||||
|
return get_css_color_settings(db)
|
||||||
|
|
||||||
|
|
||||||
|
def read_css_color_env_overrides(
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Return validated CSS overrides sourced from environment variables."""
|
||||||
|
|
||||||
|
if env is None:
|
||||||
|
env = os.environ
|
||||||
|
|
||||||
|
overrides: Dict[str, str] = {}
|
||||||
|
for css_key in CSS_COLOR_DEFAULTS.keys():
|
||||||
|
env_name = css_key_to_env_var(css_key)
|
||||||
|
raw_value = env.get(env_name)
|
||||||
|
if raw_value is None:
|
||||||
|
continue
|
||||||
|
overrides[css_key] = _normalize_color_value(raw_value)
|
||||||
|
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_color_value(value: str) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("Color value must be a string")
|
||||||
|
trimmed = value.strip()
|
||||||
|
if not trimmed:
|
||||||
|
raise ValueError("Color value cannot be empty")
|
||||||
|
if not _COLOR_VALUE_PATTERN.match(trimmed):
|
||||||
|
raise ValueError(
|
||||||
|
"Color value must be a hex code or an rgb/rgba/hsl/hsla expression"
|
||||||
|
)
|
||||||
|
_validate_functional_color(trimmed)
|
||||||
|
return trimmed
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_functional_color(value: str) -> None:
|
||||||
|
lowered = value.lower()
|
||||||
|
if lowered.startswith("rgb(") or lowered.startswith("hsl("):
|
||||||
|
_ensure_component_count(value, expected=3)
|
||||||
|
elif lowered.startswith("rgba(") or lowered.startswith("hsla("):
|
||||||
|
_ensure_component_count(value, expected=4)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_component_count(value: str, expected: int) -> None:
|
||||||
|
if not value.endswith(")"):
|
||||||
|
raise ValueError("Color function expressions must end with a closing parenthesis")
|
||||||
|
inner = value[value.index("(") + 1 : -1]
|
||||||
|
parts = [segment.strip() for segment in inner.split(",")]
|
||||||
|
if len(parts) != expected:
|
||||||
|
raise ValueError(
|
||||||
|
"Color function expressions must provide the expected number of components"
|
||||||
|
)
|
||||||
|
if any(not component for component in parts):
|
||||||
|
raise ValueError("Color function components cannot be empty")
|
||||||
|
|
||||||
|
|
||||||
|
def css_key_to_env_var(css_key: str) -> str:
|
||||||
|
sanitized = css_key.lstrip("-").replace("-", "_").upper()
|
||||||
|
return f"{CSS_ENV_PREFIX}{sanitized}"
|
||||||
|
|
||||||
|
|
||||||
|
def list_css_env_override_rows(
|
||||||
|
env: Mapping[str, str] | None = None,
|
||||||
|
) -> list[Dict[str, str]]:
|
||||||
|
overrides = read_css_color_env_overrides(env)
|
||||||
|
rows: list[Dict[str, str]] = []
|
||||||
|
for css_key, value in overrides.items():
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"css_key": css_key,
|
||||||
|
"env_var": css_key_to_env_var(css_key),
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
@@ -117,6 +117,37 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section + .sidebar-section {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: rgba(255, 255, 255, 0.52);
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -142,6 +173,39 @@ body {
|
|||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-sublinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sublink {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.74);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sublink:hover,
|
||||||
|
.sidebar-sublink:focus {
|
||||||
|
background: rgba(148, 197, 255, 0.18);
|
||||||
|
color: var(--color-text-invert);
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-sublink.is-active {
|
||||||
|
background: rgba(148, 197, 255, 0.28);
|
||||||
|
color: var(--color-text-invert);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.app-main {
|
.app-main {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -185,6 +249,159 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 14px var(--color-panel-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-form-grid {
|
||||||
|
max-width: none;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-form-field {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08);
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-form-field.is-env-override {
|
||||||
|
background: rgba(191, 248, 56, 0.12);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
font-family: "Fira Code", "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field-default {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-field-helper {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-env-flag {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-value-input {
|
||||||
|
font-family: "Fira Code", "Consolas", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-value-input[disabled] {
|
||||||
|
background-color: rgba(148, 197, 255, 0.16);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-preview {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-overrides-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-overrides-table th,
|
||||||
|
.env-overrides-table td {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.env-overrides-table code {
|
||||||
|
font-family: "Fira Code", "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.55rem 1.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-invert);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-link:hover,
|
||||||
|
.button-link:focus {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 18px var(--color-panel-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-metrics-grid {
|
.dashboard-metrics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
|||||||
200
static/js/settings.js
Normal file
200
static/js/settings.js
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
(function () {
|
||||||
|
const dataScript = document.getElementById("theme-settings-data");
|
||||||
|
const form = document.getElementById("theme-settings-form");
|
||||||
|
const feedbackEl = document.getElementById("theme-settings-feedback");
|
||||||
|
const resetBtn = document.getElementById("theme-settings-reset");
|
||||||
|
const panel = document.getElementById("theme-settings");
|
||||||
|
|
||||||
|
if (!dataScript || !form || !feedbackEl || !panel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = panel.getAttribute("data-api");
|
||||||
|
if (!apiUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(dataScript.textContent || "{}");
|
||||||
|
const currentValues = { ...(parsed.variables || {}) };
|
||||||
|
const defaultValues = parsed.defaults || {};
|
||||||
|
let envOverrides = { ...(parsed.envOverrides || {}) };
|
||||||
|
|
||||||
|
const previewElements = new Map();
|
||||||
|
const inputs = Array.from(form.querySelectorAll(".color-value-input"));
|
||||||
|
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const key = input.name;
|
||||||
|
const field = input.closest(".color-form-field");
|
||||||
|
const preview = field ? field.querySelector(".color-preview") : null;
|
||||||
|
if (preview) {
|
||||||
|
previewElements.set(input, preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
|
||||||
|
const overrideValue = envOverrides[key];
|
||||||
|
input.value = overrideValue;
|
||||||
|
input.disabled = true;
|
||||||
|
input.setAttribute("aria-disabled", "true");
|
||||||
|
input.dataset.envOverride = "true";
|
||||||
|
if (field) {
|
||||||
|
field.classList.add("is-env-override");
|
||||||
|
}
|
||||||
|
if (preview) {
|
||||||
|
preview.style.background = overrideValue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const previewEl = previewElements.get(input);
|
||||||
|
if (previewEl) {
|
||||||
|
previewEl.style.background = input.value || defaultValues[key] || "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setFeedback(message, type) {
|
||||||
|
feedbackEl.textContent = message;
|
||||||
|
feedbackEl.classList.remove("hidden", "success", "error");
|
||||||
|
if (type) {
|
||||||
|
feedbackEl.classList.add(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFeedback() {
|
||||||
|
feedbackEl.textContent = "";
|
||||||
|
feedbackEl.classList.add("hidden");
|
||||||
|
feedbackEl.classList.remove("success", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRootVariables(values) {
|
||||||
|
if (!values) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
|
if (typeof key === "string" && typeof value === "string") {
|
||||||
|
root.style.setProperty(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTo(source) {
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const key = input.name;
|
||||||
|
if (input.disabled) {
|
||||||
|
const previewEl = previewElements.get(input);
|
||||||
|
const fallback = envOverrides[key] || currentValues[key];
|
||||||
|
if (previewEl && fallback) {
|
||||||
|
previewEl.style.background = fallback;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
input.value = source[key];
|
||||||
|
const previewEl = previewElements.get(input);
|
||||||
|
if (previewEl) {
|
||||||
|
previewEl.style.background = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize previews to current values after page load.
|
||||||
|
resetTo(currentValues);
|
||||||
|
|
||||||
|
resetBtn?.addEventListener("click", () => {
|
||||||
|
resetTo(defaultValues);
|
||||||
|
clearFeedback();
|
||||||
|
setFeedback("Reverted to default values. Submit to save.", "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
clearFeedback();
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
if (input.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload[input.name] = input.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ variables: payload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = "Unable to save theme settings.";
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData?.detail) {
|
||||||
|
detail = Array.isArray(errorData.detail)
|
||||||
|
? errorData.detail.map((item) => item.msg || item).join("; ")
|
||||||
|
: errorData.detail;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
// Ignore JSON parse errors and use default detail message.
|
||||||
|
}
|
||||||
|
setFeedback(detail, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const variables = data?.variables || {};
|
||||||
|
const responseOverrides = data?.env_overrides || {};
|
||||||
|
|
||||||
|
Object.assign(currentValues, variables);
|
||||||
|
envOverrides = { ...responseOverrides };
|
||||||
|
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
const key = input.name;
|
||||||
|
const field = input.closest(".color-form-field");
|
||||||
|
const previewEl = previewElements.get(input);
|
||||||
|
const isOverride = Object.prototype.hasOwnProperty.call(
|
||||||
|
envOverrides,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isOverride) {
|
||||||
|
const overrideValue = envOverrides[key];
|
||||||
|
input.value = overrideValue;
|
||||||
|
if (!input.disabled) {
|
||||||
|
input.disabled = true;
|
||||||
|
input.setAttribute("aria-disabled", "true");
|
||||||
|
}
|
||||||
|
if (field) {
|
||||||
|
field.classList.add("is-env-override");
|
||||||
|
}
|
||||||
|
if (previewEl) {
|
||||||
|
previewEl.style.background = overrideValue;
|
||||||
|
}
|
||||||
|
} else if (input.disabled) {
|
||||||
|
input.disabled = false;
|
||||||
|
input.removeAttribute("aria-disabled");
|
||||||
|
if (field) {
|
||||||
|
field.classList.remove("is-env-override");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
previewEl &&
|
||||||
|
Object.prototype.hasOwnProperty.call(variables, key)
|
||||||
|
) {
|
||||||
|
previewEl.style.background = variables[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRootVariables(variables);
|
||||||
|
resetTo(variables);
|
||||||
|
setFeedback("Theme colors updated successfully.", "success");
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback("Network error: unable to save settings.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
{% set nav_links = [
|
|
||||||
("/", "Dashboard"),
|
|
||||||
("/ui/scenarios", "Scenarios"),
|
|
||||||
("/ui/parameters", "Parameters"),
|
|
||||||
("/ui/currencies", "Currencies"),
|
|
||||||
("/ui/costs", "Costs"),
|
|
||||||
("/ui/consumption", "Consumption"),
|
|
||||||
("/ui/production", "Production"),
|
|
||||||
("/ui/equipment", "Equipment"),
|
|
||||||
("/ui/maintenance", "Maintenance"),
|
|
||||||
("/ui/simulations", "Simulations"),
|
|
||||||
("/ui/reporting", "Reporting"),
|
|
||||||
] %}
|
|
||||||
|
|
||||||
<div class="sidebar-inner">
|
<div class="sidebar-inner">
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<span class="brand-logo" aria-hidden="true">CM</span>
|
<span class="brand-logo" aria-hidden="true">CM</span>
|
||||||
@@ -20,20 +6,5 @@
|
|||||||
<span class="brand-subtitle">Mining Planner</span>
|
<span class="brand-subtitle">Mining Planner</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav" aria-label="Primary navigation">
|
{% include "partials/sidebar_nav.html" %}
|
||||||
{% set current_path = request.url.path if request else "" %}
|
|
||||||
{% for href, label in nav_links %}
|
|
||||||
{% if href == "/" %}
|
|
||||||
{% set is_active = current_path == "/" %}
|
|
||||||
{% else %}
|
|
||||||
{% set is_active = current_path.startswith(href) %}
|
|
||||||
{% endif %}
|
|
||||||
<a
|
|
||||||
href="{{ href }}"
|
|
||||||
class="sidebar-link{% if is_active %} is-active{% endif %}"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
88
templates/partials/sidebar_nav.html
Normal file
88
templates/partials/sidebar_nav.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{% set nav_groups = [
|
||||||
|
{
|
||||||
|
"label": "Dashboard",
|
||||||
|
"links": [
|
||||||
|
{"href": "/", "label": "Dashboard"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Scenarios",
|
||||||
|
"links": [
|
||||||
|
{"href": "/ui/scenarios", "label": "Overview"},
|
||||||
|
{"href": "/ui/parameters", "label": "Parameters"},
|
||||||
|
{"href": "/ui/costs", "label": "Costs"},
|
||||||
|
{"href": "/ui/consumption", "label": "Consumption"},
|
||||||
|
{"href": "/ui/production", "label": "Production"},
|
||||||
|
{
|
||||||
|
"href": "/ui/equipment",
|
||||||
|
"label": "Equipment",
|
||||||
|
"children": [
|
||||||
|
{"href": "/ui/maintenance", "label": "Maintenance"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Analysis",
|
||||||
|
"links": [
|
||||||
|
{"href": "/ui/simulations", "label": "Simulations"},
|
||||||
|
{"href": "/ui/reporting", "label": "Reporting"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Settings",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"href": "/ui/settings",
|
||||||
|
"label": "Settings",
|
||||||
|
"children": [
|
||||||
|
{"href": "/ui/currencies", "label": "Currency Management"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] %}
|
||||||
|
|
||||||
|
<nav class="sidebar-nav" aria-label="Primary navigation">
|
||||||
|
{% set current_path = request.url.path if request else "" %}
|
||||||
|
{% for group in nav_groups %}
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-section-label">{{ group.label }}</div>
|
||||||
|
<div class="sidebar-section-links">
|
||||||
|
{% for link in group.links %}
|
||||||
|
{% set href = link.href %}
|
||||||
|
{% if href == "/" %}
|
||||||
|
{% set is_active = current_path == "/" %}
|
||||||
|
{% else %}
|
||||||
|
{% set is_active = current_path.startswith(href) %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="sidebar-link-block">
|
||||||
|
<a
|
||||||
|
href="{{ href }}"
|
||||||
|
class="sidebar-link{% if is_active %} is-active{% endif %}"
|
||||||
|
>
|
||||||
|
{{ link.label }}
|
||||||
|
</a>
|
||||||
|
{% if link.children %}
|
||||||
|
<div class="sidebar-sublinks">
|
||||||
|
{% for child in link.children %}
|
||||||
|
{% if child.href == "/" %}
|
||||||
|
{% set child_active = current_path == "/" %}
|
||||||
|
{% else %}
|
||||||
|
{% set child_active = current_path.startswith(child.href) %}
|
||||||
|
{% endif %}
|
||||||
|
<a
|
||||||
|
href="{{ child.href }}"
|
||||||
|
class="sidebar-sublink{% if child_active %} is-active{% endif %}"
|
||||||
|
>
|
||||||
|
{{ child.label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
113
templates/settings.html
Normal file
113
templates/settings.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Settings · CalMiner{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<p class="page-subtitle">Configure platform defaults and administrative options.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="settings-grid">
|
||||||
|
<article class="settings-card">
|
||||||
|
<h2>Currency Management</h2>
|
||||||
|
<p>Manage available currencies, symbols, and default selections from the Currency Management page.</p>
|
||||||
|
<a class="button-link" href="/ui/currencies">Go to Currency Management</a>
|
||||||
|
</article>
|
||||||
|
<article class="settings-card">
|
||||||
|
<h2>Visual Theme</h2>
|
||||||
|
<p>Adjust CalMiner theme colors and preview changes instantly.</p>
|
||||||
|
<p class="settings-card-note">Changes save to the settings table and apply across the UI after submission. Environment overrides (if configured) remain read-only.</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="theme-settings" data-api="/api/settings/css">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Theme Colors</h2>
|
||||||
|
<p class="chart-subtitle">Update global CSS variables to customize CalMiner's appearance.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<form id="theme-settings-form" class="form-grid color-form-grid" novalidate>
|
||||||
|
{% for key, value in css_variables.items() %}
|
||||||
|
{% set env_meta = css_env_override_meta.get(key) %}
|
||||||
|
<label class="color-form-field{% if env_meta %} is-env-override{% endif %}" data-variable="{{ key }}">
|
||||||
|
<span class="color-field-header">
|
||||||
|
<span class="color-field-name">{{ key }}</span>
|
||||||
|
<span class="color-field-default">Default: {{ css_defaults[key] }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="color-field-helper" id="color-helper-{{ loop.index }}">Accepts hex, rgb(a), or hsl(a) values.</span>
|
||||||
|
{% if env_meta %}
|
||||||
|
<span class="color-env-flag">Managed via {{ env_meta.env_var }} (read-only)</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="color-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="{{ key }}"
|
||||||
|
class="color-value-input"
|
||||||
|
value="{{ value }}"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-describedby="color-helper-{{ loop.index }}"
|
||||||
|
{% if env_meta %}disabled aria-disabled="true" data-env-override="true"{% endif %}
|
||||||
|
/>
|
||||||
|
<span class="color-preview" aria-hidden="true" style="background: {{ value }}"></span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button type="submit" class="btn primary">Save Theme</button>
|
||||||
|
<button type="button" class="btn" id="theme-settings-reset">Reset to Defaults</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% from "partials/components.html" import feedback with context %}
|
||||||
|
{{ feedback("theme-settings-feedback") }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="theme-env-overrides">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Environment Overrides</h2>
|
||||||
|
<p class="chart-subtitle">The following CSS variables are controlled via environment variables and take precedence over database values.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% if css_env_override_rows %}
|
||||||
|
<div class="table-container env-overrides-table">
|
||||||
|
<table aria-label="Environment-controlled theme variables">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">CSS Variable</th>
|
||||||
|
<th scope="col">Environment Variable</th>
|
||||||
|
<th scope="col">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in css_env_override_rows %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ row.css_key }}</code></td>
|
||||||
|
<td><code>{{ row.env_var }}</code></td>
|
||||||
|
<td><code>{{ row.value }}</code></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">No environment overrides configured.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script id="theme-settings-data" type="application/json">
|
||||||
|
{{ {
|
||||||
|
"variables": css_variables,
|
||||||
|
"defaults": css_defaults,
|
||||||
|
"envOverrides": css_env_overrides,
|
||||||
|
"envSources": css_env_override_rows
|
||||||
|
} | tojson }}
|
||||||
|
</script>
|
||||||
|
<script src="/static/js/settings.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -7,6 +7,7 @@ UI_ROUTES = [
|
|||||||
("/ui/dashboard", "Dashboard · CalMiner", "Operations Overview"),
|
("/ui/dashboard", "Dashboard · CalMiner", "Operations Overview"),
|
||||||
("/ui/scenarios", "Scenario Management · CalMiner", "Create a New Scenario"),
|
("/ui/scenarios", "Scenario Management · CalMiner", "Create a New Scenario"),
|
||||||
("/ui/parameters", "Process Parameters · CalMiner", "Scenario Parameters"),
|
("/ui/parameters", "Process Parameters · CalMiner", "Scenario Parameters"),
|
||||||
|
("/ui/settings", "Settings · CalMiner", "Settings"),
|
||||||
("/ui/costs", "Costs · CalMiner", "Cost Overview"),
|
("/ui/costs", "Costs · CalMiner", "Cost Overview"),
|
||||||
("/ui/consumption", "Consumption · CalMiner", "Consumption Tracking"),
|
("/ui/consumption", "Consumption · CalMiner", "Consumption Tracking"),
|
||||||
("/ui/production", "Production · CalMiner", "Production Output"),
|
("/ui/production", "Production · CalMiner", "Production Output"),
|
||||||
@@ -27,3 +28,45 @@ def test_ui_pages_load_correctly(page: Page, url: str, title: str, heading: str)
|
|||||||
heading_locator = page.locator(
|
heading_locator = page.locator(
|
||||||
f"h1:has-text('{heading}'), h2:has-text('{heading}')")
|
f"h1:has-text('{heading}'), h2:has-text('{heading}')")
|
||||||
expect(heading_locator.first).to_be_visible()
|
expect(heading_locator.first).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_theme_form_interaction(page: Page):
|
||||||
|
page.goto("/ui/settings")
|
||||||
|
expect(page).to_have_title("Settings · CalMiner")
|
||||||
|
|
||||||
|
env_rows = page.locator("#theme-env-overrides tbody tr")
|
||||||
|
disabled_inputs = page.locator(
|
||||||
|
"#theme-settings-form input.color-value-input[disabled]")
|
||||||
|
env_row_count = env_rows.count()
|
||||||
|
disabled_count = disabled_inputs.count()
|
||||||
|
assert disabled_count == env_row_count
|
||||||
|
|
||||||
|
color_input = page.locator(
|
||||||
|
"#theme-settings-form input[name='--color-primary']")
|
||||||
|
expect(color_input).to_be_visible()
|
||||||
|
expect(color_input).to_be_enabled()
|
||||||
|
|
||||||
|
original_value = color_input.input_value()
|
||||||
|
candidate_values = ("#114455", "#225566")
|
||||||
|
new_value = candidate_values[0] if original_value != candidate_values[0] else candidate_values[1]
|
||||||
|
|
||||||
|
color_input.fill(new_value)
|
||||||
|
page.click("#theme-settings-form button[type='submit']")
|
||||||
|
|
||||||
|
feedback = page.locator("#theme-settings-feedback")
|
||||||
|
expect(feedback).to_contain_text("updated successfully")
|
||||||
|
|
||||||
|
computed_color = page.evaluate(
|
||||||
|
"() => getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim()"
|
||||||
|
)
|
||||||
|
assert computed_color.lower() == new_value.lower()
|
||||||
|
|
||||||
|
page.reload()
|
||||||
|
expect(color_input).to_have_value(new_value)
|
||||||
|
|
||||||
|
color_input.fill(original_value)
|
||||||
|
page.click("#theme-settings-form button[type='submit']")
|
||||||
|
expect(feedback).to_contain_text("updated successfully")
|
||||||
|
|
||||||
|
page.reload()
|
||||||
|
expect(color_input).to_have_value(original_value)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ TestingSessionLocal = sessionmaker(
|
|||||||
def setup_database() -> Generator[None, None, None]:
|
def setup_database() -> Generator[None, None, None]:
|
||||||
# Ensure all model metadata is registered before creating tables
|
# Ensure all model metadata is registered before creating tables
|
||||||
from models import (
|
from models import (
|
||||||
|
application_setting,
|
||||||
capex,
|
capex,
|
||||||
consumption,
|
consumption,
|
||||||
distribution,
|
distribution,
|
||||||
@@ -52,6 +53,7 @@ def setup_database() -> Generator[None, None, None]:
|
|||||||
distribution,
|
distribution,
|
||||||
equipment,
|
equipment,
|
||||||
maintenance,
|
maintenance,
|
||||||
|
application_setting,
|
||||||
opex,
|
opex,
|
||||||
parameters,
|
parameters,
|
||||||
production_output,
|
production_output,
|
||||||
@@ -66,10 +68,13 @@ def setup_database() -> Generator[None, None, None]:
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def db_session() -> Generator[Session, None, None]:
|
def db_session() -> Generator[Session, None, None]:
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
session = TestingSessionLocal()
|
session = TestingSessionLocal()
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
finally:
|
finally:
|
||||||
|
session.rollback()
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
53
tests/unit/test_settings_routes.py
Normal file
53
tests/unit/test_settings_routes.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from services import settings as settings_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db_session")
|
||||||
|
def test_read_css_settings_reflects_env_overrides(
|
||||||
|
api_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
env_var = settings_service.css_key_to_env_var("--color-background")
|
||||||
|
monkeypatch.setenv(env_var, "#123456")
|
||||||
|
|
||||||
|
response = api_client.get("/api/settings/css")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
assert body["variables"]["--color-background"] == "#123456"
|
||||||
|
assert body["env_overrides"]["--color-background"] == "#123456"
|
||||||
|
assert any(
|
||||||
|
source["env_var"] == env_var and source["value"] == "#123456"
|
||||||
|
for source in body["env_sources"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db_session")
|
||||||
|
def test_update_css_settings_persists_changes(
|
||||||
|
api_client: TestClient, db_session: Session
|
||||||
|
) -> None:
|
||||||
|
payload = {"variables": {"--color-primary": "#112233"}}
|
||||||
|
|
||||||
|
response = api_client.put("/api/settings/css", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
|
||||||
|
assert body["variables"]["--color-primary"] == "#112233"
|
||||||
|
|
||||||
|
persisted = settings_service.get_css_color_settings(db_session)
|
||||||
|
assert persisted["--color-primary"] == "#112233"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("db_session")
|
||||||
|
def test_update_css_settings_invalid_value_returns_422(
|
||||||
|
api_client: TestClient
|
||||||
|
) -> None:
|
||||||
|
response = api_client.put(
|
||||||
|
"/api/settings/css",
|
||||||
|
json={"variables": {"--color-primary": "not-a-color"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
body = response.json()
|
||||||
|
assert "color" in body["detail"].lower()
|
||||||
137
tests/unit/test_settings_service.py
Normal file
137
tests/unit/test_settings_service.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from models.application_setting import ApplicationSetting
|
||||||
|
from services import settings as settings_service
|
||||||
|
from services.settings import CSS_COLOR_DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="clean_env")
|
||||||
|
def fixture_clean_env(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]:
|
||||||
|
"""Provide an isolated environment mapping for tests."""
|
||||||
|
|
||||||
|
env: Dict[str, str] = {}
|
||||||
|
monkeypatch.setattr(settings_service, "os", SimpleNamespace(environ=env))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def test_css_key_to_env_var_formatting():
|
||||||
|
assert settings_service.css_key_to_env_var("--color-background") == "CALMINER_THEME_COLOR_BACKGROUND"
|
||||||
|
assert settings_service.css_key_to_env_var("--color-primary-stronger") == "CALMINER_THEME_COLOR_PRIMARY_STRONGER"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"env_key,env_value",
|
||||||
|
[
|
||||||
|
("--color-background", "#ffffff"),
|
||||||
|
("--color-primary", "rgb(10, 20, 30)"),
|
||||||
|
("--color-accent", "rgba(1,2,3,0.5)"),
|
||||||
|
("--color-text-secondary", "hsla(210, 40%, 40%, 1)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_read_css_color_env_overrides_valid_values(clean_env, env_key, env_value):
|
||||||
|
env_var = settings_service.css_key_to_env_var(env_key)
|
||||||
|
clean_env[env_var] = env_value
|
||||||
|
|
||||||
|
overrides = settings_service.read_css_color_env_overrides(clean_env)
|
||||||
|
assert overrides[env_key] == env_value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"invalid_value",
|
||||||
|
[
|
||||||
|
"", # empty
|
||||||
|
"not-a-color", # arbitrary string
|
||||||
|
"#12", # short hex
|
||||||
|
"rgb(1,2)", # malformed rgb
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_read_css_color_env_overrides_invalid_values_raise(clean_env, invalid_value):
|
||||||
|
env_var = settings_service.css_key_to_env_var("--color-background")
|
||||||
|
clean_env[env_var] = invalid_value
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
settings_service.read_css_color_env_overrides(clean_env)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_css_color_env_overrides_ignores_missing(clean_env):
|
||||||
|
overrides = settings_service.read_css_color_env_overrides(clean_env)
|
||||||
|
assert overrides == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_css_env_override_rows_returns_structured_data(clean_env):
|
||||||
|
clean_env[settings_service.css_key_to_env_var("--color-primary")] = "#123456"
|
||||||
|
rows = settings_service.list_css_env_override_rows(clean_env)
|
||||||
|
assert rows == [
|
||||||
|
{
|
||||||
|
"css_key": "--color-primary",
|
||||||
|
"env_var": settings_service.css_key_to_env_var("--color-primary"),
|
||||||
|
"value": "#123456",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_color_value_strips_and_validates():
|
||||||
|
assert settings_service._normalize_color_value(" #abcdef ") == "#abcdef"
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
settings_service._normalize_color_value(123) # type: ignore[arg-type]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
settings_service._normalize_color_value(" ")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
settings_service._normalize_color_value("#12")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_css_color_settings_creates_defaults(db_session: Session):
|
||||||
|
settings_service.ensure_css_color_settings(db_session)
|
||||||
|
|
||||||
|
stored = {
|
||||||
|
record.key: record.value
|
||||||
|
for record in db_session.query(ApplicationSetting).all()
|
||||||
|
}
|
||||||
|
assert set(stored.keys()) == set(CSS_COLOR_DEFAULTS.keys())
|
||||||
|
assert stored == CSS_COLOR_DEFAULTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_css_color_settings_persists_changes(db_session: Session):
|
||||||
|
settings_service.ensure_css_color_settings(db_session)
|
||||||
|
|
||||||
|
updated = settings_service.update_css_color_settings(
|
||||||
|
db_session,
|
||||||
|
{"--color-background": "#000000", "--color-accent": "#abcdef"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated["--color-background"] == "#000000"
|
||||||
|
assert updated["--color-accent"] == "#abcdef"
|
||||||
|
|
||||||
|
stored = {
|
||||||
|
record.key: record.value
|
||||||
|
for record in db_session.query(ApplicationSetting).all()
|
||||||
|
}
|
||||||
|
assert stored["--color-background"] == "#000000"
|
||||||
|
assert stored["--color-accent"] == "#abcdef"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_css_color_settings_respects_env_overrides(
|
||||||
|
db_session: Session, clean_env: Dict[str, str]
|
||||||
|
):
|
||||||
|
settings_service.ensure_css_color_settings(db_session)
|
||||||
|
override_value = "#112233"
|
||||||
|
clean_env[settings_service.css_key_to_env_var("--color-background")] = (
|
||||||
|
override_value
|
||||||
|
)
|
||||||
|
|
||||||
|
values = settings_service.get_css_color_settings(db_session)
|
||||||
|
|
||||||
|
assert values["--color-background"] == override_value
|
||||||
|
|
||||||
|
db_value = (
|
||||||
|
db_session.query(ApplicationSetting)
|
||||||
|
.filter_by(key="--color-background")
|
||||||
|
.one()
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
assert db_value != override_value
|
||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from models.scenario import Scenario
|
from models.scenario import Scenario
|
||||||
|
from services import settings as settings_service
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_route_provides_summary(
|
def test_dashboard_route_provides_summary(
|
||||||
@@ -129,3 +130,36 @@ def test_additional_ui_routes_render_templates(
|
|||||||
|
|
||||||
context = cast(Dict[str, Any], getattr(response, "context", {}))
|
context = cast(Dict[str, Any], getattr(response, "context", {}))
|
||||||
assert context
|
assert context
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_route_provides_css_context(
|
||||||
|
api_client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
env_var = settings_service.css_key_to_env_var("--color-accent")
|
||||||
|
monkeypatch.setenv(env_var, "#abcdef")
|
||||||
|
|
||||||
|
response = api_client.get("/ui/settings")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
template = getattr(response, "template", None)
|
||||||
|
assert template is not None
|
||||||
|
assert template.name == "settings.html"
|
||||||
|
|
||||||
|
context = cast(Dict[str, Any], getattr(response, "context", {}))
|
||||||
|
assert "css_variables" in context
|
||||||
|
assert "css_defaults" in context
|
||||||
|
assert "css_env_overrides" in context
|
||||||
|
assert "css_env_override_rows" in context
|
||||||
|
assert "css_env_override_meta" in context
|
||||||
|
|
||||||
|
assert context["css_variables"]["--color-accent"] == "#abcdef"
|
||||||
|
assert context["css_defaults"]["--color-accent"] == settings_service.CSS_COLOR_DEFAULTS["--color-accent"]
|
||||||
|
assert context["css_env_overrides"]["--color-accent"] == "#abcdef"
|
||||||
|
|
||||||
|
override_rows = context["css_env_override_rows"]
|
||||||
|
assert any(row["env_var"] == env_var for row in override_rows)
|
||||||
|
|
||||||
|
meta = context["css_env_override_meta"]["--color-accent"]
|
||||||
|
assert meta["value"] == "#abcdef"
|
||||||
|
assert meta["env_var"] == env_var
|
||||||
|
|||||||
Reference in New Issue
Block a user