diff --git a/README.md b/README.md index 9a72ca5..37f0e90 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/architecture/05_building_block_view.md b/docs/architecture/05_building_block_view.md index 7767780..1af3464 100644 --- a/docs/architecture/05_building_block_view.md +++ b/docs/architecture/05_building_block_view.md @@ -4,6 +4,7 @@ description: "Explain the static structure: modules, components, services and th status: draft --- + # 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 diff --git a/docs/architecture/07_deployment_view.md b/docs/architecture/07_deployment_view.md index a24eb15..2c9b171 100644 --- a/docs/architecture/07_deployment_view.md +++ b/docs/architecture/07_deployment_view.md @@ -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
(Web Browsers)] --> B[Web Application Layer
(FastAPI)] + B --> C[Database Layer
(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: diff --git a/docs/architecture/08_concepts.md b/docs/architecture/08_concepts.md index 61cf25e..314df78 100644 --- a/docs/architecture/08_concepts.md +++ b/docs/architecture/08_concepts.md @@ -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. diff --git a/docs/quickstart.md b/docs/quickstart.md index 8feb308..428f245 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.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 diff --git a/main.py b/main.py index f6a4d06..0baa79d 100644 --- a/main.py +++ b/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) diff --git a/models/__init__.py b/models/__init__.py index 69b5612..81d530a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -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 diff --git a/models/application_setting.py b/models/application_setting.py new file mode 100644 index 0000000..36b0ad5 --- /dev/null +++ b/models/application_setting.py @@ -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"" diff --git a/routes/settings.py b/routes/settings.py new file mode 100644 index 0000000..077ba13 --- /dev/null +++ b/routes/settings.py @@ -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_ENTITY, + detail=str(exc), + ) from exc + return CSSSettingsResponse( + variables=values, + env_overrides=env_overrides, + env_sources=env_sources, + ) diff --git a/routes/ui.py b/routes/ui.py index a52ae9d..935f7e9 100644 --- a/routes/ui.py +++ b/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.""" diff --git a/scripts/migrations/000_base.sql b/scripts/migrations/000_base.sql index 7462d68..b2af060 100644 --- a/scripts/migrations/000_base.sql +++ b/scripts/migrations/000_base.sql @@ -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, diff --git a/scripts/migrations/20251025_create_application_setting_table.sql b/scripts/migrations/20251025_create_application_setting_table.sql new file mode 100644 index 0000000..380a14a --- /dev/null +++ b/scripts/migrations/20251025_create_application_setting_table.sql @@ -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; diff --git a/scripts/setup_database.py b/scripts/setup_database.py index 3799d6f..1da38ef 100644 --- a/scripts/setup_database.py +++ b/scripts/setup_database.py @@ -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 diff --git a/services/settings.py b/services/settings.py new file mode 100644 index 0000000..a3ff564 --- /dev/null +++ b/services/settings.py @@ -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 diff --git a/static/css/main.css b/static/css/main.css index dc4a2b5..b5d4b5d 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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)); diff --git a/static/js/settings.js b/static/js/settings.js new file mode 100644 index 0000000..8d7d5c6 --- /dev/null +++ b/static/js/settings.js @@ -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"); + } + }); +})(); diff --git a/templates/partials/base_header.html b/templates/partials/base_header.html index a8d67e2..eba1b1f 100644 --- a/templates/partials/base_header.html +++ b/templates/partials/base_header.html @@ -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"), -] %} - - + {% include "partials/sidebar_nav.html" %} diff --git a/templates/partials/sidebar_nav.html b/templates/partials/sidebar_nav.html new file mode 100644 index 0000000..91e006c --- /dev/null +++ b/templates/partials/sidebar_nav.html @@ -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"}, + ], + }, + ], + }, +] %} + + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..0942acb --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} + +{% block title %}Settings · CalMiner{% endblock %} + +{% block content %} + +
+
+

Currency Management

+

Manage available currencies, symbols, and default selections from the Currency Management page.

+ Go to Currency Management +
+
+

Visual Theme

+

Adjust CalMiner theme colors and preview changes instantly.

+

Changes save to the settings table and apply across the UI after submission. Environment overrides (if configured) remain read-only.

+
+
+ +
+
+
+

Theme Colors

+

Update global CSS variables to customize CalMiner's appearance.

+
+
+
+ {% for key, value in css_variables.items() %} + {% set env_meta = css_env_override_meta.get(key) %} + + {% endfor %} + +
+ + +
+
+ {% from "partials/components.html" import feedback with context %} + {{ feedback("theme-settings-feedback") }} +
+ +
+
+
+

Environment Overrides

+

The following CSS variables are controlled via environment variables and take precedence over database values.

+
+
+ {% if css_env_override_rows %} +
+ + + + + + + + + + {% for row in css_env_override_rows %} + + + + + + {% endfor %} + +
CSS VariableEnvironment VariableValue
{{ row.css_key }}{{ row.env_var }}{{ row.value }}
+
+ {% else %} +

No environment overrides configured.

+ {% endif %} +
+{% endblock %} + +{% block scripts %} + {{ super() }} + + +{% endblock %} diff --git a/tests/e2e/test_smoke.py b/tests/e2e/test_smoke.py index 01c0f18..291d007 100644 --- a/tests/e2e/test_smoke.py +++ b/tests/e2e/test_smoke.py @@ -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) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 435f0df..39e80c4 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -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, diff --git a/tests/unit/test_settings_routes.py b/tests/unit/test_settings_routes.py new file mode 100644 index 0000000..1aa691c --- /dev/null +++ b/tests/unit/test_settings_routes.py @@ -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() diff --git a/tests/unit/test_settings_service.py b/tests/unit/test_settings_service.py new file mode 100644 index 0000000..a244c7c --- /dev/null +++ b/tests/unit/test_settings_service.py @@ -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 diff --git a/tests/unit/test_ui_routes.py b/tests/unit/test_ui_routes.py index 4f4645f..7a56043 100644 --- a/tests/unit/test_ui_routes.py +++ b/tests/unit/test_ui_routes.py @@ -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