From 862820bbd35f79438a72156f20045a1d1c26a38f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 19:14:13 +0000 Subject: [PATCH] Add DATA_HOME configuration for centralized data directory management - Add DATA_HOME setting to CommonSettings (default: ./data) - Update CollectorSettings with: - effective_database_url property (default: sqlite:///{DATA_HOME}/collector/meshcore.db) - effective_tags_file property (default: {DATA_HOME}/collector/tags.json) - collector_data_dir property - Update APISettings with effective_database_url property - Update WebSettings with: - effective_members_file property (default: {DATA_HOME}/web/members.json) - web_data_dir property - Update CLI commands (collector, api, web) to: - Accept --data-home option - Use effective_* properties for defaults - Auto-create data directories on startup - Update docker-compose.yml.example to use DATA_HOME volume mounts - Update .env.example with DATA_HOME documentation - Update PLAN.md and AGENTS.md with data directory structure docs - Add comprehensive tests for new configuration properties --- .env.example | 25 ++++++-- AGENTS.md | 26 +++++++-- PLAN.md | 19 +++++- docker-compose.yml.example | 45 +++++++------- src/meshcore_hub/api/cli.py | 31 ++++++++-- src/meshcore_hub/collector/cli.py | 65 ++++++++++++++++++--- src/meshcore_hub/common/config.py | 97 +++++++++++++++++++++++++------ src/meshcore_hub/web/cli.py | 35 +++++++++-- tests/test_common/test_config.py | 82 +++++++++++++++++++++++--- 9 files changed, 350 insertions(+), 75 deletions(-) diff --git a/.env.example b/.env.example index 5749460..3258614 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,23 @@ # MESHCORE_IMAGE=ghcr.io/ipnet-mesh/meshcore-hub:v1.0.0 MESHCORE_IMAGE= +# =================== +# Data Directory +# =================== + +# Base directory for all service data (collector DB, tags, members, etc.) +# Default: ./data (relative to docker-compose.yml location) +# Inside containers this is mapped to /data +# +# Structure: +# ${DATA_HOME}/ +# ├── collector/ +# │ ├── meshcore.db # SQLite database +# │ └── tags.json # Node tags for import +# └── web/ +# └── members.json # Network members list +DATA_HOME=./data + # =================== # Common Settings # =================== @@ -72,10 +89,10 @@ NETWORK_RADIO_CONFIG= NETWORK_CONTACT_EMAIL= NETWORK_CONTACT_DISCORD= -# Path to members JSON file (mounted into container) -# For production: use ./data/members.json -# For testing with example data: use ./example/data/members.json -MEMBERS_FILE_PATH=./example/data/members.json +# Members file location (optional override) +# Default: ${DATA_HOME}/web/members.json +# Only set this if you want to use a different location +# MEMBERS_FILE=/custom/path/to/members.json # =================== # Webhook Settings diff --git a/AGENTS.md b/AGENTS.md index 489c66a..ba82af0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,9 +283,12 @@ meshcore-hub/ │ │ └── tags.json # Example node tags data │ └── web/ │ └── members.json # Example network members data -├── data/ # Runtime data (gitignored) -│ ├── collector/ # Collector data (tags.json) -│ └── web/ # Web data (members.json) +├── data/ # Runtime data (gitignored, DATA_HOME default) +│ ├── collector/ # Collector data +│ │ ├── meshcore.db # SQLite database +│ │ └── tags.json # Node tags for import +│ └── web/ # Web data +│ └── members.json # Network members list ├── Dockerfile # Docker build configuration ├── docker-compose.yml # Docker Compose services (gitignored) └── docker-compose.yml.example # Docker Compose template @@ -433,11 +436,26 @@ meshcore-hub interface --mode receiver --mock See [PLAN.md](PLAN.md#configuration-environment-variables) for complete list. Key variables: +- `DATA_HOME` - Base directory for all service data (default: `./data`) - `MQTT_HOST`, `MQTT_PORT`, `MQTT_PREFIX` - MQTT broker connection -- `DATABASE_URL` - SQLAlchemy database URL +- `DATABASE_URL` - SQLAlchemy database URL (default: `sqlite:///{DATA_HOME}/collector/meshcore.db`) - `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys - `LOG_LEVEL` - Logging verbosity +### Data Directory Structure + +The `DATA_HOME` environment variable controls where all service data is stored: +``` +${DATA_HOME}/ +├── collector/ +│ ├── meshcore.db # SQLite database +│ └── tags.json # Node tags for import +└── web/ + └── members.json # Network members list +``` + +Services automatically create their subdirectories if they don't exist. + ### Webhook Configuration The collector supports forwarding events to external HTTP endpoints: diff --git a/PLAN.md b/PLAN.md index 23241d0..57e7950 100644 --- a/PLAN.md +++ b/PLAN.md @@ -456,6 +456,7 @@ meshcore/+/command/request_telemetry ### Common | Variable | Default | Description | |----------|---------|-------------| +| DATA_HOME | ./data | Base directory for service data | | LOG_LEVEL | INFO | Logging level | | MQTT_HOST | localhost | MQTT broker host | | MQTT_PORT | 1883 | MQTT broker port | @@ -463,6 +464,17 @@ meshcore/+/command/request_telemetry | MQTT_PASSWORD | | MQTT password (optional) | | MQTT_PREFIX | meshcore | Topic prefix | +### Data Directory Structure +The `DATA_HOME` environment variable controls where all service data is stored: +``` +${DATA_HOME}/ +├── collector/ +│ ├── meshcore.db # SQLite database +│ └── tags.json # Node tags for import +└── web/ + └── members.json # Network members list +``` + ### Interface | Variable | Default | Description | |----------|---------|-------------| @@ -474,7 +486,8 @@ meshcore/+/command/request_telemetry ### Collector | Variable | Default | Description | |----------|---------|-------------| -| DATABASE_URL | sqlite:///./meshcore.db | SQLAlchemy URL | +| DATABASE_URL | sqlite:///{DATA_HOME}/collector/meshcore.db | SQLAlchemy URL | +| TAGS_FILE | {DATA_HOME}/collector/tags.json | Path to tags JSON file | ### API | Variable | Default | Description | @@ -483,7 +496,7 @@ meshcore/+/command/request_telemetry | API_PORT | 8000 | API bind port | | API_READ_KEY | | Read-only API key | | API_ADMIN_KEY | | Admin API key | -| DATABASE_URL | sqlite:///./meshcore.db | SQLAlchemy URL | +| DATABASE_URL | sqlite:///{DATA_HOME}/collector/meshcore.db | SQLAlchemy URL | ### Web Dashboard | Variable | Default | Description | @@ -500,7 +513,7 @@ meshcore/+/command/request_telemetry | NETWORK_RADIO_CONFIG | | Radio config details | | NETWORK_CONTACT_EMAIL | | Contact email | | NETWORK_CONTACT_DISCORD | | Discord link | -| MEMBERS_FILE | members.json | Path to members JSON | +| MEMBERS_FILE | {DATA_HOME}/web/members.json | Path to members JSON | --- diff --git a/docker-compose.yml.example b/docker-compose.yml.example index eb76d96..31f35f9 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -17,9 +17,14 @@ # - migrate: Run database migrations # - import-tags: Import node tags from JSON file # -# Data Directory Structure: -# ./data/ +# Data Directory (DATA_HOME): +# The DATA_HOME environment variable controls where all service data is stored. +# Default: ./data (local) or /data (in containers) +# +# Structure: +# ${DATA_HOME}/ # ├── collector/ +# │ ├── meshcore.db # SQLite database # │ └── tags.json # Node tags for import # └── web/ # └── members.json # Network members list @@ -175,9 +180,8 @@ services: mqtt: condition: service_healthy volumes: - - meshcore_data:/data - # Mount collector data directory for tags.json (optional) - - ${COLLECTOR_DATA_PATH:-./data/collector}:/app/collector-data:ro + # Mount data directory (contains collector/meshcore.db and collector/tags.json) + - ${DATA_HOME:-./data}:/data environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - MQTT_HOST=${MQTT_HOST:-mqtt} @@ -185,7 +189,7 @@ services: - MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_PASSWORD=${MQTT_PASSWORD:-} - MQTT_PREFIX=${MQTT_PREFIX:-meshcore} - - DATABASE_URL=sqlite:////data/meshcore.db + - DATA_HOME=/data # Webhook configuration - WEBHOOK_ADVERTISEMENT_URL=${WEBHOOK_ADVERTISEMENT_URL:-} - WEBHOOK_ADVERTISEMENT_SECRET=${WEBHOOK_ADVERTISEMENT_SECRET:-} @@ -227,7 +231,8 @@ services: ports: - "${API_PORT:-8000}:8000" volumes: - - meshcore_data:/data + # Mount data directory (uses collector/meshcore.db) + - ${DATA_HOME:-./data}:/data environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - MQTT_HOST=${MQTT_HOST:-mqtt} @@ -235,7 +240,7 @@ services: - MQTT_USERNAME=${MQTT_USERNAME:-} - MQTT_PASSWORD=${MQTT_PASSWORD:-} - MQTT_PREFIX=${MQTT_PREFIX:-meshcore} - - DATABASE_URL=sqlite:////data/meshcore.db + - DATA_HOME=/data - API_HOST=0.0.0.0 - API_PORT=8000 - API_READ_KEY=${API_READ_KEY:-} @@ -266,12 +271,16 @@ services: condition: service_healthy ports: - "${WEB_PORT:-8080}:8080" + volumes: + # Mount data directory (uses web/members.json) + - ${DATA_HOME:-./data}:/data environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} - API_BASE_URL=http://api:8000 - API_KEY=${API_READ_KEY:-} - WEB_HOST=0.0.0.0 - WEB_PORT=8080 + - DATA_HOME=/data - NETWORK_NAME=${NETWORK_NAME:-MeshCore Network} - NETWORK_CITY=${NETWORK_CITY:-} - NETWORK_COUNTRY=${NETWORK_COUNTRY:-} @@ -279,10 +288,6 @@ services: - NETWORK_RADIO_CONFIG=${NETWORK_RADIO_CONFIG:-} - NETWORK_CONTACT_EMAIL=${NETWORK_CONTACT_EMAIL:-} - NETWORK_CONTACT_DISCORD=${NETWORK_CONTACT_DISCORD:-} - - MEMBERS_FILE=${MEMBERS_FILE:-/app/web-data/members.json} - volumes: - # Mount web data directory for members.json (optional) - - ${WEB_DATA_PATH:-./data/web}:/app/web-data:ro command: ["web"] healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] @@ -302,9 +307,10 @@ services: profiles: - migrate volumes: - - meshcore_data:/data + # Mount data directory (uses collector/meshcore.db) + - ${DATA_HOME:-./data}:/data environment: - - DATABASE_URL=sqlite:////data/meshcore.db + - DATA_HOME=/data command: ["db", "upgrade"] # ========================================================================== @@ -318,12 +324,13 @@ services: profiles: - import-tags volumes: - - meshcore_data:/data - - ${COLLECTOR_DATA_PATH:-./data/collector}:/app/collector-data:ro + # Mount data directory (uses collector/tags.json and collector/meshcore.db) + - ${DATA_HOME:-./data}:/data environment: - - DATABASE_URL=sqlite:////data/meshcore.db + - DATA_HOME=/data - LOG_LEVEL=${LOG_LEVEL:-INFO} - command: ["collector", "import-tags", "/app/collector-data/tags.json"] + # Uses default tags file: /data/collector/tags.json + command: ["collector", "import-tags"] # ========================================================================== # Volumes @@ -333,5 +340,3 @@ volumes: name: meshcore_mosquitto_data mosquitto_log: name: meshcore_mosquitto_log - meshcore_data: - name: meshcore_data diff --git a/src/meshcore_hub/api/cli.py b/src/meshcore_hub/api/cli.py index 9df0011..62d987b 100644 --- a/src/meshcore_hub/api/cli.py +++ b/src/meshcore_hub/api/cli.py @@ -18,12 +18,19 @@ import click envvar="API_PORT", help="API server port", ) +@click.option( + "--data-home", + type=str, + default=None, + envvar="DATA_HOME", + help="Base data directory (default: ./data)", +) @click.option( "--database-url", type=str, - default="sqlite:///./meshcore.db", + default=None, envvar="DATABASE_URL", - help="Database connection URL", + help="Database connection URL (default: sqlite:///{data_home}/collector/meshcore.db)", ) @click.option( "--read-key", @@ -78,7 +85,8 @@ def api( ctx: click.Context, host: str, port: int, - database_url: str, + data_home: str | None, + database_url: str | None, read_key: str | None, admin_key: str | None, mqtt_host: str, @@ -108,14 +116,27 @@ def api( """ import uvicorn + from meshcore_hub.common.config import get_api_settings from meshcore_hub.api.app import create_app + # Get settings to compute effective values + settings = get_api_settings() + + # Override data_home if provided + if data_home: + settings = settings.model_copy(update={"data_home": data_home}) + + # Use effective database URL if not explicitly provided + effective_db_url = database_url if database_url else settings.effective_database_url + effective_data_home = data_home or settings.data_home + click.echo("=" * 50) click.echo("MeshCore Hub API Server") click.echo("=" * 50) click.echo(f"Host: {host}") click.echo(f"Port: {port}") - click.echo(f"Database: {database_url}") + click.echo(f"Data home: {effective_data_home}") + click.echo(f"Database: {effective_db_url}") click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {mqtt_prefix})") click.echo(f"Read key configured: {read_key is not None}") click.echo(f"Admin key configured: {admin_key is not None}") @@ -144,7 +165,7 @@ def api( else: # For production, create app directly app = create_app( - database_url=database_url, + database_url=effective_db_url, read_key=read_key, admin_key=admin_key, mqtt_host=mqtt_host, diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index b7e8bf3..97951d5 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -42,12 +42,19 @@ from meshcore_hub.common.logging import configure_logging envvar="MQTT_PREFIX", help="MQTT topic prefix", ) +@click.option( + "--data-home", + type=str, + default=None, + envvar="DATA_HOME", + help="Base data directory (default: ./data)", +) @click.option( "--database-url", type=str, - default="sqlite:///./meshcore.db", + default=None, envvar="DATABASE_URL", - help="Database connection URL", + help="Database connection URL (default: sqlite:///{data_home}/collector/meshcore.db)", ) @click.option( "--log-level", @@ -63,7 +70,8 @@ def collector( mqtt_username: str | None, mqtt_password: str | None, prefix: str, - database_url: str, + data_home: str | None, + database_url: str | None, log_level: str, ) -> None: """Collector component for storing MeshCore events. @@ -80,14 +88,30 @@ def collector( When invoked without a subcommand, runs the collector service. """ + from meshcore_hub.common.config import get_collector_settings + + # Get settings to compute effective values + settings = get_collector_settings() + + # Override data_home if provided + if data_home: + settings = get_collector_settings() + # Re-create settings with data_home override + settings = settings.model_copy(update={"data_home": data_home}) + + # Use effective database URL if not explicitly provided + effective_db_url = database_url if database_url else settings.effective_database_url + ctx.ensure_object(dict) ctx.obj["mqtt_host"] = mqtt_host ctx.obj["mqtt_port"] = mqtt_port ctx.obj["mqtt_username"] = mqtt_username ctx.obj["mqtt_password"] = mqtt_password ctx.obj["prefix"] = prefix - ctx.obj["database_url"] = database_url + ctx.obj["data_home"] = data_home or settings.data_home + ctx.obj["database_url"] = effective_db_url ctx.obj["log_level"] = log_level + ctx.obj["settings"] = settings # If no subcommand, run the collector service if ctx.invoked_subcommand is None: @@ -97,8 +121,9 @@ def collector( mqtt_username=mqtt_username, mqtt_password=mqtt_password, prefix=prefix, - database_url=database_url, + database_url=effective_db_url, log_level=log_level, + data_home=data_home or settings.data_home, ) @@ -110,6 +135,7 @@ def _run_collector_service( prefix: str, database_url: str, log_level: str, + data_home: str, ) -> None: """Run the collector service. @@ -119,9 +145,16 @@ def _run_collector_service( - WEBHOOK_CHANNEL_MESSAGE_URL: Override for channel messages - WEBHOOK_DIRECT_MESSAGE_URL: Override for direct messages """ + from pathlib import Path + configure_logging(level=log_level) + # Ensure data directory exists + collector_data_dir = Path(data_home) / "collector" + collector_data_dir.mkdir(parents=True, exist_ok=True) + click.echo("Starting MeshCore Collector") + click.echo(f"Data home: {data_home}") click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})") click.echo(f"Database: {database_url}") @@ -171,11 +204,12 @@ def run_cmd(ctx: click.Context) -> None: prefix=ctx.obj["prefix"], database_url=ctx.obj["database_url"], log_level=ctx.obj["log_level"], + data_home=ctx.obj["data_home"], ) @collector.command("import-tags") -@click.argument("file", type=click.Path(exists=True)) +@click.argument("file", type=click.Path(exists=True), required=False, default=None) @click.option( "--no-create-nodes", is_flag=True, @@ -185,7 +219,7 @@ def run_cmd(ctx: click.Context) -> None: @click.pass_context def import_tags_cmd( ctx: click.Context, - file: str, + file: str | None, no_create_nodes: bool, ) -> None: """Import node tags from a JSON file. @@ -194,6 +228,7 @@ def import_tags_cmd( into the database. Existing tags are updated, new tags are created. FILE is the path to the JSON file containing tags. + If not provided, defaults to {DATA_HOME}/collector/tags.json. Expected JSON format: \b @@ -210,9 +245,21 @@ def import_tags_cmd( Supported value_type: string, number, boolean, coordinate """ + from pathlib import Path + configure_logging(level=ctx.obj["log_level"]) - click.echo(f"Importing tags from: {file}") + # Use effective tags file if not provided + settings = ctx.obj["settings"] + tags_file = file if file else settings.effective_tags_file + + # Check if file exists when using default + if not file and not Path(tags_file).exists(): + click.echo(f"Tags file not found: {tags_file}") + click.echo("Specify a file path or create the default tags file.") + return + + click.echo(f"Importing tags from: {tags_file}") click.echo(f"Database: {ctx.obj['database_url']}") from meshcore_hub.common.database import DatabaseManager @@ -224,7 +271,7 @@ def import_tags_cmd( # Import tags stats = import_tags( - file_path=file, + file_path=tags_file, db=db, create_nodes=not no_create_nodes, ) diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index f46a365..ec7eb70 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -33,6 +33,12 @@ class CommonSettings(BaseSettings): extra="ignore", ) + # Data home directory (base for all service data directories) + data_home: str = Field( + default="./data", + description="Base directory for service data (e.g., ./data or /data)", + ) + # Logging log_level: LogLevel = Field(default=LogLevel.INFO, description="Logging level") @@ -68,10 +74,16 @@ class InterfaceSettings(CommonSettings): class CollectorSettings(CommonSettings): """Settings for the Collector component.""" - # Database - database_url: str = Field( - default="sqlite:///./meshcore.db", - description="SQLAlchemy database URL", + # Database - default uses data_home/collector/meshcore.db + database_url: Optional[str] = Field( + default=None, + description="SQLAlchemy database URL (default: sqlite:///{data_home}/collector/meshcore.db)", + ) + + # Tags file for import - default uses data_home/collector/tags.json + tags_file: Optional[str] = Field( + default=None, + description="Path to tags JSON file (default: {data_home}/collector/tags.json)", ) # Webhook URLs (empty = disabled) @@ -109,12 +121,37 @@ class CollectorSettings(CommonSettings): default=2.0, description="Retry backoff multiplier" ) + @property + def collector_data_dir(self) -> str: + """Get the collector data directory path.""" + from pathlib import Path + + return str(Path(self.data_home) / "collector") + + @property + def effective_database_url(self) -> str: + """Get the effective database URL, using default if not set.""" + if self.database_url: + return self.database_url + from pathlib import Path + + db_path = Path(self.data_home) / "collector" / "meshcore.db" + return f"sqlite:///{db_path}" + + @property + def effective_tags_file(self) -> str: + """Get the effective tags file path, using default if not set.""" + if self.tags_file: + return self.tags_file + from pathlib import Path + + return str(Path(self.data_home) / "collector" / "tags.json") + @field_validator("database_url") @classmethod - def validate_database_url(cls, v: str) -> str: + def validate_database_url(cls, v: Optional[str]) -> Optional[str]: """Validate database URL format.""" - if not v: - raise ValueError("Database URL cannot be empty") + # None is allowed - will use default return v @@ -125,10 +162,10 @@ class APISettings(CommonSettings): api_host: str = Field(default="0.0.0.0", description="API server host") api_port: int = Field(default=8000, description="API server port") - # Database - database_url: str = Field( - default="sqlite:///./meshcore.db", - description="SQLAlchemy database URL", + # Database - default uses data_home/collector/meshcore.db (same as collector) + database_url: Optional[str] = Field( + default=None, + description="SQLAlchemy database URL (default: sqlite:///{data_home}/collector/meshcore.db)", ) # Authentication @@ -137,12 +174,21 @@ class APISettings(CommonSettings): default=None, description="Admin API key (full access)" ) + @property + def effective_database_url(self) -> str: + """Get the effective database URL, using default if not set.""" + if self.database_url: + return self.database_url + from pathlib import Path + + db_path = Path(self.data_home) / "collector" / "meshcore.db" + return f"sqlite:///{db_path}" + @field_validator("database_url") @classmethod - def validate_database_url(cls, v: str) -> str: + def validate_database_url(cls, v: Optional[str]) -> Optional[str]: """Validate database URL format.""" - if not v: - raise ValueError("Database URL cannot be empty") + # None is allowed - will use default return v @@ -186,11 +232,28 @@ class WebSettings(CommonSettings): default=None, description="Discord server link" ) - # Members file - members_file: str = Field( - default="members.json", description="Path to members JSON file" + # Members file - default uses data_home/web/members.json + members_file: Optional[str] = Field( + default=None, + description="Path to members JSON file (default: {data_home}/web/members.json)", ) + @property + def web_data_dir(self) -> str: + """Get the web data directory path.""" + from pathlib import Path + + return str(Path(self.data_home) / "web") + + @property + def effective_members_file(self) -> str: + """Get the effective members file path, using default if not set.""" + if self.members_file: + return self.members_file + from pathlib import Path + + return str(Path(self.data_home) / "web" / "members.json") + def get_common_settings() -> CommonSettings: """Get common settings instance.""" diff --git a/src/meshcore_hub/web/cli.py b/src/meshcore_hub/web/cli.py index eb8a196..1a4059a 100644 --- a/src/meshcore_hub/web/cli.py +++ b/src/meshcore_hub/web/cli.py @@ -32,6 +32,13 @@ import click envvar="API_KEY", help="API key for queries", ) +@click.option( + "--data-home", + type=str, + default=None, + envvar="DATA_HOME", + help="Base data directory (default: ./data)", +) @click.option( "--network-name", type=str, @@ -93,7 +100,7 @@ import click type=str, default=None, envvar="MEMBERS_FILE", - help="Path to members JSON file", + help="Path to members JSON file (default: {data_home}/web/members.json)", ) @click.option( "--reload", @@ -108,6 +115,7 @@ def web( port: int, api_url: str, api_key: str | None, + data_home: str | None, network_name: str, network_city: str | None, network_country: str | None, @@ -142,14 +150,34 @@ def web( meshcore-hub web --reload """ import uvicorn + from pathlib import Path + from meshcore_hub.common.config import get_web_settings from meshcore_hub.web.app import create_app + # Get settings to compute effective values + settings = get_web_settings() + + # Override data_home if provided + if data_home: + settings = settings.model_copy(update={"data_home": data_home}) + + # Use effective members file if not explicitly provided + effective_members_file = ( + members_file if members_file else settings.effective_members_file + ) + effective_data_home = data_home or settings.data_home + + # Ensure web data directory exists + web_data_dir = Path(effective_data_home) / "web" + web_data_dir.mkdir(parents=True, exist_ok=True) + click.echo("=" * 50) click.echo("MeshCore Hub Web Dashboard") click.echo("=" * 50) click.echo(f"Host: {host}") click.echo(f"Port: {port}") + click.echo(f"Data home: {effective_data_home}") click.echo(f"API URL: {api_url}") click.echo(f"API key configured: {api_key is not None}") click.echo(f"Network: {network_name}") @@ -157,8 +185,7 @@ def web( click.echo(f"Location: {network_city}, {network_country}") if network_lat != 0.0 or network_lon != 0.0: click.echo(f"Map center: {network_lat}, {network_lon}") - if members_file: - click.echo(f"Members file: {members_file}") + click.echo(f"Members file: {effective_members_file}") click.echo(f"Reload mode: {reload}") click.echo("=" * 50) @@ -188,7 +215,7 @@ def web( network_radio_config=network_radio_config, network_contact_email=network_contact_email, network_contact_discord=network_contact_discord, - members_file=members_file, + members_file=effective_members_file, ) click.echo("\nStarting web dashboard...") diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index 77686cb..d822216 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -1,7 +1,5 @@ """Tests for configuration settings.""" -import pytest - from meshcore_hub.common.config import ( CommonSettings, InterfaceSettings, @@ -20,6 +18,7 @@ class TestCommonSettings: """Test default setting values without .env file influence.""" settings = CommonSettings(_env_file=None) + assert settings.data_home == "./data" assert settings.log_level == LogLevel.INFO assert settings.mqtt_host == "localhost" assert settings.mqtt_port == 1883 @@ -27,6 +26,12 @@ class TestCommonSettings: assert settings.mqtt_password is None assert settings.mqtt_prefix == "meshcore" + def test_custom_data_home(self) -> None: + """Test custom DATA_HOME setting.""" + settings = CommonSettings(_env_file=None, data_home="/custom/data") + + assert settings.data_home == "/custom/data" + class TestInterfaceSettings: """Tests for InterfaceSettings.""" @@ -48,12 +53,34 @@ class TestCollectorSettings: """Test default setting values without .env file influence.""" settings = CollectorSettings(_env_file=None) - assert settings.database_url == "sqlite:///./meshcore.db" + # database_url is None by default, effective_database_url computes it + assert settings.database_url is None + # Path normalizes ./data to data + assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db" + assert settings.data_home == "./data" + assert settings.collector_data_dir == "data/collector" + assert settings.tags_file is None + assert settings.effective_tags_file == "data/collector/tags.json" - def test_database_url_validation(self) -> None: - """Test database URL validation.""" - with pytest.raises(ValueError): - CollectorSettings(_env_file=None, database_url="") + def test_custom_data_home(self) -> None: + """Test that custom data_home affects effective paths.""" + settings = CollectorSettings(_env_file=None, data_home="/custom/data") + + assert ( + settings.effective_database_url + == "sqlite:////custom/data/collector/meshcore.db" + ) + assert settings.collector_data_dir == "/custom/data/collector" + assert settings.effective_tags_file == "/custom/data/collector/tags.json" + + def test_explicit_database_url_overrides(self) -> None: + """Test that explicit database_url overrides the default.""" + settings = CollectorSettings( + _env_file=None, database_url="postgresql://user@host/db" + ) + + assert settings.database_url == "postgresql://user@host/db" + assert settings.effective_database_url == "postgresql://user@host/db" class TestAPISettings: @@ -65,10 +92,29 @@ class TestAPISettings: assert settings.api_host == "0.0.0.0" assert settings.api_port == 8000 - assert settings.database_url == "sqlite:///./meshcore.db" + # database_url is None by default, effective_database_url computes it + assert settings.database_url is None + # Path normalizes ./data to data + assert settings.effective_database_url == "sqlite:///data/collector/meshcore.db" assert settings.api_read_key is None assert settings.api_admin_key is None + def test_custom_data_home(self) -> None: + """Test that custom data_home affects effective database path.""" + settings = APISettings(_env_file=None, data_home="/custom/data") + + assert ( + settings.effective_database_url + == "sqlite:////custom/data/collector/meshcore.db" + ) + + def test_explicit_database_url_overrides(self) -> None: + """Test that explicit database_url overrides the default.""" + settings = APISettings(_env_file=None, database_url="postgresql://user@host/db") + + assert settings.database_url == "postgresql://user@host/db" + assert settings.effective_database_url == "postgresql://user@host/db" + class TestWebSettings: """Tests for WebSettings.""" @@ -81,4 +127,22 @@ class TestWebSettings: assert settings.web_port == 8080 assert settings.api_base_url == "http://localhost:8000" assert settings.network_name == "MeshCore Network" - assert settings.members_file == "members.json" + # members_file is None by default, effective_members_file computes it + assert settings.members_file is None + # Path normalizes ./data to data + assert settings.effective_members_file == "data/web/members.json" + assert settings.web_data_dir == "data/web" + + def test_custom_data_home(self) -> None: + """Test that custom data_home affects effective paths.""" + settings = WebSettings(_env_file=None, data_home="/custom/data") + + assert settings.effective_members_file == "/custom/data/web/members.json" + assert settings.web_data_dir == "/custom/data/web" + + def test_explicit_members_file_overrides(self) -> None: + """Test that explicit members_file overrides the default.""" + settings = WebSettings(_env_file=None, members_file="/path/to/members.json") + + assert settings.members_file == "/path/to/members.json" + assert settings.effective_members_file == "/path/to/members.json"