Compare commits
2 Commits
feat/datab
...
75f533b87b
| Author | SHA1 | Date | |
|---|---|---|---|
| 75f533b87b | |||
| 5b1322ddbc |
@@ -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
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ 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<br/>(Web Browsers)] --> B[Web Application Layer<br/>(FastAPI)]
|
||||
B --> C[Database Layer<br/>(PostgreSQL)]
|
||||
```
|
||||
|
||||
## Infrastructure Components
|
||||
|
||||
@@ -29,6 +34,16 @@ The infrastructure components for the application include:
|
||||
- **CI/CD Pipeline**: Automated pipelines (Gitea Actions) run tests, build/push Docker images, and trigger deployments.
|
||||
- **Cloud Infrastructure (optional)**: The application can be deployed on cloud platforms.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Web Server] --> B[Database Server]
|
||||
A --> C[Static File Server]
|
||||
A --> D[Reverse Proxy]
|
||||
A --> E[Containerization]
|
||||
A --> F[CI/CD Pipeline]
|
||||
A --> G[Cloud Infrastructure]
|
||||
```
|
||||
|
||||
## Environments
|
||||
|
||||
The application can be deployed in multiple environments to support development, testing, and production:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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