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
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Run Tests
|
||||
branches:
|
||||
- main
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEFAULT_BRANCH: main
|
||||
@@ -14,6 +19,8 @@ jobs:
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -26,6 +33,14 @@ jobs:
|
||||
event_name="${GITHUB_EVENT_NAME:-}"
|
||||
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
|
||||
echo "on_default=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
name: Deploy to Server
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Build and Push Docker Image
|
||||
branches:
|
||||
- main
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DEFAULT_BRANCH: main
|
||||
@@ -14,6 +19,8 @@ jobs:
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
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:
|
||||
- name: SSH and deploy
|
||||
uses: appleboy/ssh-action@master
|
||||
@@ -22,7 +29,15 @@ jobs:
|
||||
username: ${{ secrets.SSH_USERNAME }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
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 rm calminer || true
|
||||
docker run -d --name calminer -p 8000:8000 \
|
||||
@@ -33,4 +48,4 @@ jobs:
|
||||
-e DATABASE_PASSWORD=${{ secrets.DATABASE_PASSWORD }} \
|
||||
-e DATABASE_NAME=${{ secrets.DATABASE_NAME }} \
|
||||
-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]
|
||||
|
||||
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:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -10,116 +28,22 @@ jobs:
|
||||
POSTGRES_DB: calminer_ci
|
||||
POSTGRES_USER: calminer
|
||||
POSTGRES_PASSWORD: secret
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U calminer -d calminer_ci"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- name: Prepare Python environment
|
||||
uses: ./.gitea/actions/setup-python-env
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- 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
|
||||
install-playwright: ${{ matrix.target == 'e2e' }}
|
||||
- name: Run tests
|
||||
env:
|
||||
DATABASE_URL: postgresql+psycopg2://calminer:secret@postgres:5432/calminer_ci
|
||||
DATABASE_SCHEMA: public
|
||||
run: pytest
|
||||
run: |
|
||||
if [ "${{ matrix.target }}" = "unit" ]; then
|
||||
pytest tests/unit
|
||||
else
|
||||
pytest tests/e2e
|
||||
fi
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -45,3 +45,6 @@ logs/
|
||||
# SQLite database
|
||||
*.sqlite3
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Documentation & quickstart
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable-next-line MD025 -->
|
||||
# 05 — Building Block View
|
||||
|
||||
## 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.
|
||||
- **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/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.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- `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
|
||||
|
||||
@@ -45,6 +49,7 @@ Refer to the detailed architecture chapters in `docs/architecture/`:
|
||||
- `consumption.py`, `production_output.py`: operational data tables.
|
||||
- `equipment.py`, `maintenance.py`: asset management models.
|
||||
- `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
|
||||
|
||||
|
||||
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).
|
||||
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.
|
||||
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
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
- `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`.
|
||||
- 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)
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ See [Domain Models](08_concepts/08_01_domain_models.md) document for detailed cl
|
||||
- `production_output`: production metrics per scenario.
|
||||
- `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`).
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
- [06 Runtime View](06_runtime_view.md)
|
||||
- [07 Deployment View](07_deployment_view.md)
|
||||
- [Testing & CI](07_deployment/07_01_testing_ci.md.md)
|
||||
- [08 Concepts](08_concepts.md)
|
||||
- [09 Architecture Decisions](09_architecture_decisions.md)
|
||||
- [10 Quality Requirements](10_quality_requirements.md)
|
||||
- [11 Technical Risks](11_technical_risks.md)
|
||||
- [12 Glossary](12_glossary.md)
|
||||
- [13 UI and Style](13_ui_and_style.md)
|
||||
- [14 Testing & CI](14_testing_ci.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`
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
> ℹ️ 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.
|
||||
|
||||
## Database bootstrap workflow
|
||||
@@ -234,7 +245,7 @@ The database contains tables such as `capex`, `opex`, `chemical_consumption`, `f
|
||||
## Where to look next
|
||||
|
||||
- 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)
|
||||
- Implementation plan & roadmap: [Solution strategy](architecture/04_solution_strategy.md)
|
||||
- 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.simulations import router as simulations_router
|
||||
from routes.maintenance import router as maintenance_router
|
||||
from routes.settings import router as settings_router
|
||||
|
||||
# Initialize database schema
|
||||
Base.metadata.create_all(bind=engine)
|
||||
@@ -43,4 +44,5 @@ app.include_router(equipment_router)
|
||||
app.include_router(maintenance_router)
|
||||
app.include_router(reporting_router)
|
||||
app.include_router(currencies_router)
|
||||
app.include_router(settings_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.
|
||||
"""
|
||||
from . import application_setting # 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 models.currency import 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]] = [
|
||||
@@ -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]:
|
||||
grouped: defaultdict[int, list[Dict[str, Any]]] = defaultdict(list)
|
||||
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))
|
||||
|
||||
|
||||
@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)
|
||||
async def currencies_view(request: Request, db: Session = Depends(get_db)):
|
||||
"""Render the currency administration page with full currency context."""
|
||||
|
||||
@@ -27,6 +27,25 @@ SET name = EXCLUDED.name,
|
||||
symbol = EXCLUDED.symbol,
|
||||
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
|
||||
CREATE TABLE IF NOT EXISTS measurement_unit (
|
||||
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
|
||||
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:
|
||||
if args.ensure_database:
|
||||
setup.ensure_database()
|
||||
@@ -1154,8 +1162,10 @@ def main() -> None:
|
||||
"SQLAlchemy schema initialization"
|
||||
):
|
||||
setup.initialize_schema()
|
||||
if args.run_migrations:
|
||||
if should_run_migrations:
|
||||
if ensure_application_connection_for("migration execution"):
|
||||
if auto_run_migrations_reason:
|
||||
logger.info(auto_run_migrations_reason)
|
||||
migrations_path = (
|
||||
Path(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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -142,6 +173,39 @@ body {
|
||||
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 {
|
||||
background-color: var(--color-background);
|
||||
display: flex;
|
||||
@@ -185,6 +249,159 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
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-brand">
|
||||
<span class="brand-logo" aria-hidden="true">CM</span>
|
||||
@@ -20,20 +6,5 @@
|
||||
<span class="brand-subtitle">Mining Planner</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav" aria-label="Primary navigation">
|
||||
{% 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>
|
||||
{% include "partials/sidebar_nav.html" %}
|
||||
</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/scenarios", "Scenario Management · CalMiner", "Create a New Scenario"),
|
||||
("/ui/parameters", "Process Parameters · CalMiner", "Scenario Parameters"),
|
||||
("/ui/settings", "Settings · CalMiner", "Settings"),
|
||||
("/ui/costs", "Costs · CalMiner", "Cost Overview"),
|
||||
("/ui/consumption", "Consumption · CalMiner", "Consumption Tracking"),
|
||||
("/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(
|
||||
f"h1:has-text('{heading}'), h2:has-text('{heading}')")
|
||||
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]:
|
||||
# Ensure all model metadata is registered before creating tables
|
||||
from models import (
|
||||
application_setting,
|
||||
capex,
|
||||
consumption,
|
||||
distribution,
|
||||
@@ -52,6 +53,7 @@ def setup_database() -> Generator[None, None, None]:
|
||||
distribution,
|
||||
equipment,
|
||||
maintenance,
|
||||
application_setting,
|
||||
opex,
|
||||
parameters,
|
||||
production_output,
|
||||
@@ -66,10 +68,13 @@ def setup_database() -> Generator[None, None, None]:
|
||||
|
||||
@pytest.fixture()
|
||||
def db_session() -> Generator[Session, None, None]:
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session = TestingSessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.rollback()
|
||||
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 models.scenario import Scenario
|
||||
from services import settings as settings_service
|
||||
|
||||
|
||||
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", {}))
|
||||
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