diff --git a/alembic/versions/20241202_0001_001_initial_schema.py b/alembic/versions/20241202_0001_001_initial_schema.py index 760771d..0ddbce2 100644 --- a/alembic/versions/20241202_0001_001_initial_schema.py +++ b/alembic/versions/20241202_0001_001_initial_schema.py @@ -76,6 +76,33 @@ def upgrade() -> None: op.create_index("ix_node_tags_node_id", "node_tags", ["node_id"]) op.create_index("ix_node_tags_key", "node_tags", ["key"]) + # Create members table + op.create_table( + "members", + sa.Column("id", sa.String(), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("callsign", sa.String(20), nullable=True), + sa.Column("role", sa.String(100), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("contact", sa.String(255), nullable=True), + sa.Column("public_key", sa.String(64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_members_name", "members", ["name"]) + op.create_index("ix_members_public_key", "members", ["public_key"]) + # Create messages table op.create_table( "messages", @@ -257,5 +284,6 @@ def downgrade() -> None: op.drop_table("trace_paths") op.drop_table("advertisements") op.drop_table("messages") + op.drop_table("members") op.drop_table("node_tags") op.drop_table("nodes") diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 3d75a9e..ec2daa0 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -15,24 +15,30 @@ # - mock: All components with mock device (for testing) # - all: All production components (requires real device) # - migrate: Run database migrations -# - import-tags: Import node tags from JSON file +# - seed: Import seed data (node_tags.json, members.json) # # Data Directory (DATA_HOME): -# The DATA_HOME environment variable controls where all service data is stored. +# The DATA_HOME environment variable controls where instance 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 +# └── collector/ +# └── meshcore.db # SQLite database # -# Example data files are provided in ./example/data/ +# Seed Directory (SEED_HOME): +# The SEED_HOME environment variable controls where seed data files are read from. +# Default: ./seed (local) or /seed (in containers) # -# To import tags: -# docker compose --profile import-tags run --rm import-tags +# Structure: +# ${SEED_HOME}/ +# ├── node_tags.json # Node tag definitions +# └── members.json # Network member definitions +# +# Example seed files are provided in ./example/seed/ +# +# To import seed data: +# docker compose --profile seed run --rm seed services: # ========================================================================== @@ -180,7 +186,7 @@ services: mqtt: condition: service_healthy volumes: - # Mount data directory (contains collector/meshcore.db and collector/tags.json) + # Mount data directory (contains collector/meshcore.db) - ${DATA_HOME:-./data}:/data environment: - LOG_LEVEL=${LOG_LEVEL:-INFO} @@ -275,16 +281,12 @@ 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:-} @@ -320,25 +322,28 @@ services: command: ["db", "upgrade"] # ========================================================================== - # Import Tags - Import node tags from JSON file + # Seed Data - Import node_tags.json and members.json from SEED_HOME # ========================================================================== - import-tags: + seed: build: context: . dockerfile: Dockerfile - container_name: meshcore-import-tags + container_name: meshcore-seed profiles: - - import-tags + - seed volumes: - # Mount data directory (uses collector/tags.json and collector/meshcore.db) + # Mount data directory for database (read-write) - ${DATA_HOME:-./data}:/data + # Mount seed directory for seed files (read-only) + - ${SEED_HOME:-./seed}:/seed:ro environment: - DATA_HOME=/data + - SEED_HOME=/seed - LOG_LEVEL=${LOG_LEVEL:-INFO} # Explicitly unset to use DATA_HOME-based default path - DATABASE_URL= - # Uses default tags file: /data/collector/tags.json - command: ["collector", "import-tags"] + # Imports both node_tags.json and members.json if they exist + command: ["collector", "seed"] # ========================================================================== # Volumes diff --git a/example/data/web/members.json b/example/seed/members.json similarity index 100% rename from example/data/web/members.json rename to example/seed/members.json diff --git a/example/data/collector/tags.json b/example/seed/node_tags.json similarity index 100% rename from example/data/collector/tags.json rename to example/seed/node_tags.json diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py index e152edc..de4051c 100644 --- a/src/meshcore_hub/api/routes/__init__.py +++ b/src/meshcore_hub/api/routes/__init__.py @@ -10,6 +10,7 @@ from meshcore_hub.api.routes.trace_paths import router as trace_paths_router from meshcore_hub.api.routes.telemetry import router as telemetry_router from meshcore_hub.api.routes.commands import router as commands_router from meshcore_hub.api.routes.dashboard import router as dashboard_router +from meshcore_hub.api.routes.members import router as members_router api_router = APIRouter() @@ -26,3 +27,4 @@ api_router.include_router( api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"]) api_router.include_router(commands_router, prefix="/commands", tags=["Commands"]) api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"]) +api_router.include_router(members_router, prefix="/members", tags=["Members"]) diff --git a/src/meshcore_hub/api/routes/members.py b/src/meshcore_hub/api/routes/members.py new file mode 100644 index 0000000..00f2001 --- /dev/null +++ b/src/meshcore_hub/api/routes/members.py @@ -0,0 +1,133 @@ +"""Member API routes.""" + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireAdmin, RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Member +from meshcore_hub.common.schemas.members import ( + MemberCreate, + MemberList, + MemberRead, + MemberUpdate, +) + +router = APIRouter() + + +@router.get("", response_model=MemberList) +async def list_members( + _: RequireRead, + session: DbSession, + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), +) -> MemberList: + """List all members with pagination.""" + # Get total count + count_query = select(func.count()).select_from(Member) + total = session.execute(count_query).scalar() or 0 + + # Get members + query = select(Member).order_by(Member.name).limit(limit).offset(offset) + members = session.execute(query).scalars().all() + + return MemberList( + items=[MemberRead.model_validate(m) for m in members], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{member_id}", response_model=MemberRead) +async def get_member( + _: RequireRead, + session: DbSession, + member_id: str, +) -> MemberRead: + """Get a specific member by ID.""" + query = select(Member).where(Member.id == member_id) + member = session.execute(query).scalar_one_or_none() + + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + return MemberRead.model_validate(member) + + +@router.post("", response_model=MemberRead, status_code=201) +async def create_member( + _: RequireAdmin, + session: DbSession, + member: MemberCreate, +) -> MemberRead: + """Create a new member.""" + # Normalize public_key to lowercase if provided + public_key = member.public_key.lower() if member.public_key else None + + # Create member + new_member = Member( + name=member.name, + callsign=member.callsign, + role=member.role, + description=member.description, + contact=member.contact, + public_key=public_key, + ) + session.add(new_member) + session.commit() + session.refresh(new_member) + + return MemberRead.model_validate(new_member) + + +@router.put("/{member_id}", response_model=MemberRead) +async def update_member( + _: RequireAdmin, + session: DbSession, + member_id: str, + member: MemberUpdate, +) -> MemberRead: + """Update a member.""" + query = select(Member).where(Member.id == member_id) + existing = session.execute(query).scalar_one_or_none() + + if not existing: + raise HTTPException(status_code=404, detail="Member not found") + + # Update fields + if member.name is not None: + existing.name = member.name + if member.callsign is not None: + existing.callsign = member.callsign + if member.role is not None: + existing.role = member.role + if member.description is not None: + existing.description = member.description + if member.contact is not None: + existing.contact = member.contact + if member.public_key is not None: + existing.public_key = member.public_key.lower() + + session.commit() + session.refresh(existing) + + return MemberRead.model_validate(existing) + + +@router.delete("/{member_id}", status_code=204) +async def delete_member( + _: RequireAdmin, + session: DbSession, + member_id: str, +) -> None: + """Delete a member.""" + query = select(Member).where(Member.id == member_id) + member = session.execute(query).scalar_one_or_none() + + if not member: + raise HTTPException(status_code=404, detail="Member not found") + + session.delete(member) + session.commit() diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index fba618f..789e40c 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -49,6 +49,13 @@ from meshcore_hub.common.logging import configure_logging envvar="DATA_HOME", help="Base data directory (default: ./data)", ) +@click.option( + "--seed-home", + type=str, + default=None, + envvar="SEED_HOME", + help="Directory containing seed data files (default: {data_home}/collector)", +) @click.option( "--database-url", type=str, @@ -71,6 +78,7 @@ def collector( mqtt_password: str | None, prefix: str, data_home: str | None, + seed_home: str | None, database_url: str | None, log_level: str, ) -> None: @@ -93,11 +101,15 @@ def collector( # Get settings to compute effective values settings = get_collector_settings() - # Override data_home if provided + # Build settings overrides + overrides = {} if data_home: - settings = get_collector_settings() - # Re-create settings with data_home override - settings = settings.model_copy(update={"data_home": data_home}) + overrides["data_home"] = data_home + if seed_home: + overrides["seed_home"] = seed_home + + if overrides: + settings = settings.model_copy(update=overrides) # Use effective database URL if not explicitly provided effective_db_url = database_url if database_url else settings.effective_database_url @@ -109,6 +121,7 @@ def collector( ctx.obj["mqtt_password"] = mqtt_password ctx.obj["prefix"] = prefix ctx.obj["data_home"] = data_home or settings.data_home + ctx.obj["seed_home"] = settings.effective_seed_home ctx.obj["database_url"] = effective_db_url ctx.obj["log_level"] = log_level ctx.obj["settings"] = settings @@ -208,6 +221,92 @@ def run_cmd(ctx: click.Context) -> None: ) +@collector.command("seed") +@click.option( + "--no-create-nodes", + is_flag=True, + default=False, + help="Skip tags for nodes that don't exist (default: create nodes)", +) +@click.pass_context +def seed_cmd( + ctx: click.Context, + no_create_nodes: bool, +) -> None: + """Import seed data from SEED_HOME directory. + + Looks for the following files in SEED_HOME: + - node_tags.json: Node tag definitions (keyed by public_key) + - members.json: Network member definitions + + Files that don't exist are skipped. This command is idempotent - + existing records are updated, new records are created. + + SEED_HOME defaults to {DATA_HOME}/collector but can be overridden + with the --seed-home option or SEED_HOME environment variable. + """ + from pathlib import Path + + configure_logging(level=ctx.obj["log_level"]) + + seed_home = ctx.obj["seed_home"] + click.echo(f"Seed home: {seed_home}") + click.echo(f"Database: {ctx.obj['database_url']}") + + from meshcore_hub.common.database import DatabaseManager + from meshcore_hub.collector.tag_import import import_tags + from meshcore_hub.collector.member_import import import_members + + # Initialize database + db = DatabaseManager(ctx.obj["database_url"]) + db.create_tables() + + # Track what was imported + imported_any = False + + # Import node tags if file exists + node_tags_file = Path(seed_home) / "node_tags.json" + if node_tags_file.exists(): + click.echo(f"\nImporting node tags from: {node_tags_file}") + stats = import_tags( + file_path=str(node_tags_file), + db=db, + create_nodes=not no_create_nodes, + ) + click.echo(f" Tags: {stats['created']} created, {stats['updated']} updated") + if stats["nodes_created"]: + click.echo(f" Nodes created: {stats['nodes_created']}") + if stats["errors"]: + for error in stats["errors"]: + click.echo(f" Error: {error}", err=True) + imported_any = True + else: + click.echo(f"\nNo node_tags.json found in {seed_home}") + + # Import members if file exists + members_file = Path(seed_home) / "members.json" + if members_file.exists(): + click.echo(f"\nImporting members from: {members_file}") + stats = import_members( + file_path=str(members_file), + db=db, + ) + click.echo(f" Members: {stats['created']} created, {stats['updated']} updated") + if stats["errors"]: + for error in stats["errors"]: + click.echo(f" Error: {error}", err=True) + imported_any = True + else: + click.echo(f"\nNo members.json found in {seed_home}") + + if not imported_any: + click.echo("\nNo seed files found. Nothing to import.") + else: + click.echo("\nSeed import complete.") + + db.dispose() + + @collector.command("import-tags") @click.argument("file", type=click.Path(), required=False, default=None) @click.option( @@ -228,7 +327,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. + If not provided, defaults to {SEED_HOME}/node_tags.json. Expected JSON format (keyed by public_key): \b @@ -255,15 +354,15 @@ def import_tags_cmd( configure_logging(level=ctx.obj["log_level"]) - # Use effective tags file if not provided + # Use node_tags_file from settings if not provided settings = ctx.obj["settings"] - tags_file = file if file else settings.effective_tags_file + tags_file = file if file else settings.node_tags_file # Check if file exists if not Path(tags_file).exists(): click.echo(f"Tags file not found: {tags_file}") if not file: - click.echo("Specify a file path or create the default tags file.") + click.echo("Specify a file path or create the default node_tags.json.") return click.echo(f"Importing tags from: {tags_file}") @@ -299,3 +398,85 @@ def import_tags_cmd( click.echo(f" - {error}", err=True) db.dispose() + + +@collector.command("import-members") +@click.argument("file", type=click.Path(), required=False, default=None) +@click.pass_context +def import_members_cmd( + ctx: click.Context, + file: str | None, +) -> None: + """Import network members from a JSON file. + + Reads a JSON file containing member definitions and upserts them + into the database. Existing members (matched by name) are updated, + new members are created. + + FILE is the path to the JSON file containing members. + If not provided, defaults to {SEED_HOME}/members.json. + + Expected JSON format (list): + \b + [ + { + "name": "John Doe", + "callsign": "N0CALL", + "role": "Network Operator", + "description": "Example member" + } + ] + + Or with "members" key: + \b + { + "members": [ + {"name": "John Doe", "callsign": "N0CALL", ...} + ] + } + """ + from pathlib import Path + + configure_logging(level=ctx.obj["log_level"]) + + # Use members_file from settings if not provided + settings = ctx.obj["settings"] + members_file = file if file else settings.members_file + + # Check if file exists + if not Path(members_file).exists(): + click.echo(f"Members file not found: {members_file}") + if not file: + click.echo("Specify a file path or create the default members.json.") + return + + click.echo(f"Importing members from: {members_file}") + click.echo(f"Database: {ctx.obj['database_url']}") + + from meshcore_hub.common.database import DatabaseManager + from meshcore_hub.collector.member_import import import_members + + # Initialize database + db = DatabaseManager(ctx.obj["database_url"]) + db.create_tables() + + # Import members + stats = import_members( + file_path=members_file, + db=db, + ) + + # Report results + click.echo("") + click.echo("Import complete:") + click.echo(f" Total members in file: {stats['total']}") + click.echo(f" Members created: {stats['created']}") + click.echo(f" Members updated: {stats['updated']}") + + if stats["errors"]: + click.echo("") + click.echo("Errors:") + for error in stats["errors"]: + click.echo(f" - {error}", err=True) + + db.dispose() diff --git a/src/meshcore_hub/collector/member_import.py b/src/meshcore_hub/collector/member_import.py new file mode 100644 index 0000000..f9634a5 --- /dev/null +++ b/src/meshcore_hub/collector/member_import.py @@ -0,0 +1,177 @@ +"""Import members from JSON file.""" + +import json +import logging +from pathlib import Path +from typing import Any, Optional + +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Member + +logger = logging.getLogger(__name__) + + +class MemberData(BaseModel): + """Schema for a member entry in the import file.""" + + name: str = Field(..., min_length=1, max_length=255) + callsign: Optional[str] = Field(default=None, max_length=20) + role: Optional[str] = Field(default=None, max_length=100) + description: Optional[str] = Field(default=None) + contact: Optional[str] = Field(default=None, max_length=255) + public_key: Optional[str] = Field(default=None) + + @field_validator("public_key") + @classmethod + def validate_public_key(cls, v: Optional[str]) -> Optional[str]: + """Validate and normalize public key if provided.""" + if v is None: + return None + if len(v) != 64: + raise ValueError(f"public_key must be 64 characters, got {len(v)}") + if not all(c in "0123456789abcdefABCDEF" for c in v): + raise ValueError("public_key must be a valid hex string") + return v.lower() + + +def load_members_file(file_path: str | Path) -> list[dict[str, Any]]: + """Load and validate members from a JSON file. + + Supports two formats: + 1. List of member objects: + [{"name": "Member 1", ...}, {"name": "Member 2", ...}] + + 2. Object with "members" key: + {"members": [{"name": "Member 1", ...}, ...]} + + Args: + file_path: Path to the members JSON file + + Returns: + List of validated member dictionaries + + Raises: + FileNotFoundError: If file does not exist + json.JSONDecodeError: If file is not valid JSON + ValueError: If file content is invalid + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"Members file not found: {file_path}") + + with open(path, "r") as f: + data = json.load(f) + + # Handle both formats + if isinstance(data, list): + members_list = data + elif isinstance(data, dict) and "members" in data: + members_list = data["members"] + if not isinstance(members_list, list): + raise ValueError("'members' key must contain a list") + else: + raise ValueError( + "Members file must be a list or an object with 'members' key" + ) + + # Validate each member + validated: list[dict[str, Any]] = [] + for i, member in enumerate(members_list): + if not isinstance(member, dict): + raise ValueError(f"Member at index {i} must be an object") + if "name" not in member: + raise ValueError(f"Member at index {i} must have a 'name' field") + + # Validate using Pydantic model + try: + validated_member = MemberData.model_validate(member) + validated.append(validated_member.model_dump()) + except Exception as e: + raise ValueError(f"Invalid member at index {i}: {e}") + + return validated + + +def import_members( + file_path: str | Path, + db: DatabaseManager, +) -> dict[str, Any]: + """Import members from a JSON file into the database. + + Performs upsert operations based on name - existing members are updated, + new members are created. + + Args: + file_path: Path to the members JSON file + db: Database manager instance + + Returns: + Dictionary with import statistics: + - total: Total number of members in file + - created: Number of new members created + - updated: Number of existing members updated + - errors: List of error messages + """ + stats: dict[str, Any] = { + "total": 0, + "created": 0, + "updated": 0, + "errors": [], + } + + # Load and validate file + try: + members_data = load_members_file(file_path) + except Exception as e: + stats["errors"].append(f"Failed to load members file: {e}") + return stats + + stats["total"] = len(members_data) + + with db.session_scope() as session: + for member_data in members_data: + try: + name = member_data["name"] + + # Find existing member by name + query = select(Member).where(Member.name == name) + existing = session.execute(query).scalar_one_or_none() + + if existing: + # Update existing member + if member_data.get("callsign") is not None: + existing.callsign = member_data["callsign"] + if member_data.get("role") is not None: + existing.role = member_data["role"] + if member_data.get("description") is not None: + existing.description = member_data["description"] + if member_data.get("contact") is not None: + existing.contact = member_data["contact"] + if member_data.get("public_key") is not None: + existing.public_key = member_data["public_key"] + + stats["updated"] += 1 + logger.debug(f"Updated member: {name}") + else: + # Create new member + new_member = Member( + name=name, + callsign=member_data.get("callsign"), + role=member_data.get("role"), + description=member_data.get("description"), + contact=member_data.get("contact"), + public_key=member_data.get("public_key"), + ) + session.add(new_member) + stats["created"] += 1 + logger.debug(f"Created member: {name}") + + except Exception as e: + error_msg = f"Error processing member '{member_data.get('name', 'unknown')}': {e}" + stats["errors"].append(error_msg) + logger.error(error_msg) + + return stats diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index ec7eb70..7e4b179 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -80,10 +80,10 @@ class CollectorSettings(CommonSettings): 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)", + # Seed home directory - contains initial data files (node_tags.json, members.json) + seed_home: str = Field( + default="./seed", + description="Directory containing seed data files (default: ./seed)", ) # Webhook URLs (empty = disabled) @@ -139,13 +139,25 @@ class CollectorSettings(CommonSettings): 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 + def effective_seed_home(self) -> str: + """Get the effective seed home directory.""" from pathlib import Path - return str(Path(self.data_home) / "collector" / "tags.json") + return str(Path(self.seed_home)) + + @property + def node_tags_file(self) -> str: + """Get the path to node_tags.json in seed_home.""" + from pathlib import Path + + return str(Path(self.effective_seed_home) / "node_tags.json") + + @property + def members_file(self) -> str: + """Get the path to members.json in seed_home.""" + from pathlib import Path + + return str(Path(self.effective_seed_home) / "members.json") @field_validator("database_url") @classmethod @@ -232,12 +244,6 @@ class WebSettings(CommonSettings): default=None, description="Discord server link" ) - # 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.""" @@ -245,15 +251,6 @@ class WebSettings(CommonSettings): 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/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index ce64158..2363b2d 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -8,6 +8,7 @@ from meshcore_hub.common.models.advertisement import Advertisement from meshcore_hub.common.models.trace_path import TracePath from meshcore_hub.common.models.telemetry import Telemetry from meshcore_hub.common.models.event_log import EventLog +from meshcore_hub.common.models.member import Member __all__ = [ "Base", @@ -19,4 +20,5 @@ __all__ = [ "TracePath", "Telemetry", "EventLog", + "Member", ] diff --git a/src/meshcore_hub/common/models/member.py b/src/meshcore_hub/common/models/member.py new file mode 100644 index 0000000..177b13e --- /dev/null +++ b/src/meshcore_hub/common/models/member.py @@ -0,0 +1,57 @@ +"""Member model for network member information.""" + +from typing import Optional + +from sqlalchemy import String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin + + +class Member(Base, UUIDMixin, TimestampMixin): + """Member model for network member information. + + Stores information about network members/operators. + + Attributes: + id: UUID primary key + name: Member's display name + callsign: Amateur radio callsign (optional) + role: Member's role in the network (optional) + description: Additional description (optional) + contact: Contact information (optional) + public_key: Associated node public key (optional, 64-char hex) + created_at: Record creation timestamp + updated_at: Record update timestamp + """ + + __tablename__ = "members" + + name: Mapped[str] = mapped_column( + String(255), + nullable=False, + ) + callsign: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + ) + role: Mapped[Optional[str]] = mapped_column( + String(100), + nullable=True, + ) + description: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + ) + contact: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + ) + public_key: Mapped[Optional[str]] = mapped_column( + String(64), + nullable=True, + index=True, + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/schemas/__init__.py b/src/meshcore_hub/common/schemas/__init__.py index ce7bf89..2de871f 100644 --- a/src/meshcore_hub/common/schemas/__init__.py +++ b/src/meshcore_hub/common/schemas/__init__.py @@ -29,6 +29,12 @@ from meshcore_hub.common.schemas.commands import ( SendChannelMessageCommand, SendAdvertCommand, ) +from meshcore_hub.common.schemas.members import ( + MemberCreate, + MemberUpdate, + MemberRead, + MemberList, +) __all__ = [ # Events @@ -56,4 +62,9 @@ __all__ = [ "SendMessageCommand", "SendChannelMessageCommand", "SendAdvertCommand", + # Members + "MemberCreate", + "MemberUpdate", + "MemberRead", + "MemberList", ] diff --git a/src/meshcore_hub/common/schemas/members.py b/src/meshcore_hub/common/schemas/members.py new file mode 100644 index 0000000..ce34be8 --- /dev/null +++ b/src/meshcore_hub/common/schemas/members.py @@ -0,0 +1,108 @@ +"""Pydantic schemas for member API endpoints.""" + +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + + +class MemberCreate(BaseModel): + """Schema for creating a member.""" + + name: str = Field( + ..., + min_length=1, + max_length=255, + description="Member's display name", + ) + callsign: Optional[str] = Field( + default=None, + max_length=20, + description="Amateur radio callsign", + ) + role: Optional[str] = Field( + default=None, + max_length=100, + description="Member's role in the network", + ) + description: Optional[str] = Field( + default=None, + description="Additional description", + ) + contact: Optional[str] = Field( + default=None, + max_length=255, + description="Contact information", + ) + public_key: Optional[str] = Field( + default=None, + min_length=64, + max_length=64, + pattern=r"^[0-9a-fA-F]{64}$", + description="Associated node public key (64-char hex)", + ) + + +class MemberUpdate(BaseModel): + """Schema for updating a member.""" + + name: Optional[str] = Field( + default=None, + min_length=1, + max_length=255, + description="Member's display name", + ) + callsign: Optional[str] = Field( + default=None, + max_length=20, + description="Amateur radio callsign", + ) + role: Optional[str] = Field( + default=None, + max_length=100, + description="Member's role in the network", + ) + description: Optional[str] = Field( + default=None, + description="Additional description", + ) + contact: Optional[str] = Field( + default=None, + max_length=255, + description="Contact information", + ) + public_key: Optional[str] = Field( + default=None, + min_length=64, + max_length=64, + pattern=r"^[0-9a-fA-F]{64}$", + description="Associated node public key (64-char hex)", + ) + + +class MemberRead(BaseModel): + """Schema for reading a member.""" + + id: str = Field(..., description="Member UUID") + name: str = Field(..., description="Member's display name") + callsign: Optional[str] = Field(default=None, description="Amateur radio callsign") + role: Optional[str] = Field(default=None, description="Member's role") + description: Optional[str] = Field(default=None, description="Description") + contact: Optional[str] = Field(default=None, description="Contact information") + public_key: Optional[str] = Field( + default=None, description="Associated node public key" + ) + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + class Config: + from_attributes = True + + +class MemberList(BaseModel): + """Schema for paginated member list response.""" + + items: list[MemberRead] = Field(..., description="List of members") + total: int = Field(..., description="Total number of members") + limit: int = Field(..., description="Page size limit") + offset: int = Field(..., description="Page offset") diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py index 3deaecc..81fabd6 100644 --- a/src/meshcore_hub/web/app.py +++ b/src/meshcore_hub/web/app.py @@ -56,7 +56,6 @@ def create_app( network_radio_config: str | None = None, network_contact_email: str | None = None, network_contact_discord: str | None = None, - members_file: str | None = None, ) -> FastAPI: """Create and configure the web dashboard application. @@ -70,7 +69,6 @@ def create_app( network_radio_config: Radio configuration description network_contact_email: Contact email address network_contact_discord: Discord invite/server info - members_file: Path to members JSON file Returns: Configured FastAPI application @@ -94,7 +92,6 @@ def create_app( app.state.network_radio_config = network_radio_config app.state.network_contact_email = network_contact_email app.state.network_contact_discord = network_contact_discord - app.state.members_file = members_file # Set up templates templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) diff --git a/src/meshcore_hub/web/cli.py b/src/meshcore_hub/web/cli.py index 1a4059a..412de85 100644 --- a/src/meshcore_hub/web/cli.py +++ b/src/meshcore_hub/web/cli.py @@ -95,13 +95,6 @@ import click envvar="NETWORK_CONTACT_DISCORD", help="Discord server info", ) -@click.option( - "--members-file", - type=str, - default=None, - envvar="MEMBERS_FILE", - help="Path to members JSON file (default: {data_home}/web/members.json)", -) @click.option( "--reload", is_flag=True, @@ -124,7 +117,6 @@ def web( network_radio_config: str | None, network_contact_email: str | None, network_contact_discord: str | None, - members_file: str | None, reload: bool, ) -> None: """Run the web dashboard. @@ -132,6 +124,8 @@ def web( Provides a web interface for visualizing network status, browsing nodes, viewing messages, and displaying a node map. + Members are fetched from the API (managed by the collector). + Examples: # Run with defaults @@ -143,9 +137,6 @@ def web( # Run with API authentication meshcore-hub web --api-url http://api.example.com --api-key secret - # Run with members file - meshcore-hub web --members-file /path/to/members.json - # Development mode with auto-reload meshcore-hub web --reload """ @@ -162,10 +153,6 @@ def web( 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 @@ -185,7 +172,6 @@ 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}") - click.echo(f"Members file: {effective_members_file}") click.echo(f"Reload mode: {reload}") click.echo("=" * 50) @@ -215,7 +201,6 @@ def web( network_radio_config=network_radio_config, network_contact_email=network_contact_email, network_contact_discord=network_contact_discord, - members_file=effective_members_file, ) click.echo("\nStarting web dashboard...") diff --git a/src/meshcore_hub/web/routes/members.py b/src/meshcore_hub/web/routes/members.py index 60d9870..8414989 100644 --- a/src/meshcore_hub/web/routes/members.py +++ b/src/meshcore_hub/web/routes/members.py @@ -1,8 +1,6 @@ """Members page route.""" -import json import logging -from pathlib import Path from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse @@ -13,38 +11,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def load_members(members_file: str | None) -> list[dict[str, str]]: - """Load members from JSON file. - - Args: - members_file: Path to members JSON file - - Returns: - List of member dictionaries - """ - if not members_file: - return [] - - try: - path = Path(members_file) - if path.exists(): - with open(path, "r") as f: - data = json.load(f) - # Handle both list and dict with "members" key - if isinstance(data, list): - return list(data) - elif isinstance(data, dict) and "members" in data: - members = data["members"] - if isinstance(members, list): - return list(members) - else: - logger.warning(f"Members file not found: {members_file}") - except Exception as e: - logger.error(f"Failed to load members file: {e}") - - return [] - - @router.get("/members", response_class=HTMLResponse) async def members_page(request: Request) -> HTMLResponse: """Render the members page.""" @@ -52,9 +18,19 @@ async def members_page(request: Request) -> HTMLResponse: context = get_network_context(request) context["request"] = request - # Load members from file - members_file = request.app.state.members_file - members = load_members(members_file) + # Fetch members from API + members = [] + + try: + response = await request.app.state.http_client.get( + "/api/v1/members", params={"limit": 100} + ) + if response.status_code == 200: + data = response.json() + members = data.get("items", []) + except Exception as e: + logger.warning(f"Failed to fetch members from API: {e}") + context["api_error"] = str(e) context["members"] = members diff --git a/tests/e2e/test_full_flow.py b/tests/e2e/test_full_flow.py index 6365aa5..a044be7 100644 --- a/tests/e2e/test_full_flow.py +++ b/tests/e2e/test_full_flow.py @@ -104,6 +104,14 @@ class TestAPIEndpoints: data = response.json() assert "items" in data + def test_list_members(self, api_client: httpx.Client) -> None: + """Test listing members.""" + response = api_client.get("/api/v1/members") + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "total" in data + class TestWebDashboard: """Test web dashboard pages.""" diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index d822216..ad3c50d 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -59,8 +59,13 @@ class TestCollectorSettings: 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" + + # seed_home defaults to ./seed (normalized to "seed") + assert settings.seed_home == "./seed" + assert settings.effective_seed_home == "seed" + # node_tags_file and members_file are derived from effective_seed_home + assert settings.node_tags_file == "seed/node_tags.json" + assert settings.members_file == "seed/members.json" def test_custom_data_home(self) -> None: """Test that custom data_home affects effective paths.""" @@ -71,7 +76,10 @@ class TestCollectorSettings: == "sqlite:////custom/data/collector/meshcore.db" ) assert settings.collector_data_dir == "/custom/data/collector" - assert settings.effective_tags_file == "/custom/data/collector/tags.json" + # seed_home is independent of data_home + assert settings.effective_seed_home == "seed" + assert settings.node_tags_file == "seed/node_tags.json" + assert settings.members_file == "seed/members.json" def test_explicit_database_url_overrides(self) -> None: """Test that explicit database_url overrides the default.""" @@ -82,6 +90,15 @@ class TestCollectorSettings: assert settings.database_url == "postgresql://user@host/db" assert settings.effective_database_url == "postgresql://user@host/db" + def test_explicit_seed_home_overrides(self) -> None: + """Test that explicit seed_home overrides the default.""" + settings = CollectorSettings(_env_file=None, seed_home="/seed/data") + + assert settings.seed_home == "/seed/data" + assert settings.effective_seed_home == "/seed/data" + assert settings.node_tags_file == "/seed/data/node_tags.json" + assert settings.members_file == "/seed/data/members.json" + class TestAPISettings: """Tests for APISettings.""" @@ -127,22 +144,11 @@ class TestWebSettings: assert settings.web_port == 8080 assert settings.api_base_url == "http://localhost:8000" assert settings.network_name == "MeshCore Network" - # 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" diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index 7f36ea3..2d0d933 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -1,8 +1,5 @@ """Web dashboard test fixtures.""" -import json -import tempfile -from pathlib import Path from typing import Any from unittest.mock import MagicMock @@ -139,6 +136,17 @@ class MockHttpClient: }, } + # Default members response (empty) + self._responses["GET:/api/v1/members"] = { + "status_code": 200, + "json": { + "items": [], + "total": 0, + "limit": 100, + "offset": 0, + }, + } + # Health check response self._responses["GET:/health"] = { "status_code": 200, @@ -220,7 +228,6 @@ def web_app(mock_http_client: MockHttpClient) -> Any: network_radio_config="Test Radio Config", network_contact_email="test@example.com", network_contact_discord="https://discord.gg/test", - members_file=None, ) # Override the lifespan to use our mock client @@ -242,37 +249,49 @@ def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient: @pytest.fixture -def members_file() -> Any: - """Create a temporary members JSON file.""" - members_data = { - "members": [ - { - "name": "Alice", - "callsign": "W1ABC", - "role": "Admin", - "contact": "alice@example.com", - }, - { - "name": "Bob", - "callsign": "W2XYZ", - "role": "Member", - "contact": None, - }, - ] - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(members_data, f) - f.flush() - yield f.name - - # Cleanup - Path(f.name).unlink(missing_ok=True) +def mock_http_client_with_members() -> MockHttpClient: + """Create a mock HTTP client with members data.""" + client = MockHttpClient() + client.set_response( + "GET", + "/api/v1/members", + 200, + { + "items": [ + { + "id": "member-1", + "name": "Alice", + "callsign": "W1ABC", + "role": "Admin", + "description": None, + "contact": "alice@example.com", + "public_key": None, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, + { + "id": "member-2", + "name": "Bob", + "callsign": "W2XYZ", + "role": "Member", + "description": None, + "contact": None, + "public_key": None, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + }, + ], + "total": 2, + "limit": 100, + "offset": 0, + }, + ) + return client @pytest.fixture -def web_app_with_members(mock_http_client: MockHttpClient, members_file: str) -> Any: - """Create a web app with a members file configured.""" +def web_app_with_members(mock_http_client_with_members: MockHttpClient) -> Any: + """Create a web app with members API responses configured.""" app = create_app( api_url="http://localhost:8000", api_key="test-api-key", @@ -283,18 +302,17 @@ def web_app_with_members(mock_http_client: MockHttpClient, members_file: str) -> network_radio_config="Test Radio Config", network_contact_email="test@example.com", network_contact_discord="https://discord.gg/test", - members_file=members_file, ) - app.state.http_client = mock_http_client + app.state.http_client = mock_http_client_with_members return app @pytest.fixture def client_with_members( - web_app_with_members: Any, mock_http_client: MockHttpClient + web_app_with_members: Any, mock_http_client_with_members: MockHttpClient ) -> TestClient: - """Create a test client with members file configured.""" - web_app_with_members.state.http_client = mock_http_client + """Create a test client with members API responses configured.""" + web_app_with_members.state.http_client = mock_http_client_with_members return TestClient(web_app_with_members, raise_server_exceptions=True) diff --git a/tests/test_web/test_members.py b/tests/test_web/test_members.py index 2864894..496ffa0 100644 --- a/tests/test_web/test_members.py +++ b/tests/test_web/test_members.py @@ -1,13 +1,7 @@ """Tests for the members page route.""" -import json -import tempfile -from pathlib import Path - from fastapi.testclient import TestClient -from meshcore_hub.web.routes.members import load_members - class TestMembersPage: """Tests for the members page.""" @@ -27,105 +21,21 @@ class TestMembersPage: response = client.get("/members") assert "Test Network" in response.text - def test_members_without_file_shows_empty(self, client: TestClient) -> None: - """Test that members page with no file shows no members.""" + def test_members_without_data_shows_empty(self, client: TestClient) -> None: + """Test that members page with no API data shows no members.""" response = client.get("/members") # Should still render successfully assert response.status_code == 200 - def test_members_with_file_shows_members( + def test_members_with_api_data_shows_members( self, client_with_members: TestClient ) -> None: - """Test that members page with file shows member data.""" + """Test that members page with API data shows member data.""" response = client_with_members.get("/members") assert response.status_code == 200 - # Check for member data + # Check for member data from mock API response assert "Alice" in response.text assert "Bob" in response.text assert "W1ABC" in response.text assert "W2XYZ" in response.text assert "Admin" in response.text - - -class TestLoadMembers: - """Tests for the load_members function.""" - - def test_load_members_none_path(self) -> None: - """Test load_members with None path returns empty list.""" - result = load_members(None) - assert result == [] - - def test_load_members_nonexistent_file(self) -> None: - """Test load_members with nonexistent file returns empty list.""" - result = load_members("/nonexistent/path/members.json") - assert result == [] - - def test_load_members_list_format(self) -> None: - """Test load_members with list format JSON.""" - members_data = [ - {"name": "Alice", "callsign": "W1ABC"}, - {"name": "Bob", "callsign": "W2XYZ"}, - ] - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(members_data, f) - f.flush() - path = f.name - - try: - result = load_members(path) - assert len(result) == 2 - assert result[0]["name"] == "Alice" - assert result[1]["name"] == "Bob" - finally: - Path(path).unlink(missing_ok=True) - - def test_load_members_dict_format(self) -> None: - """Test load_members with dict format JSON (members key).""" - members_data = { - "members": [ - {"name": "Alice", "callsign": "W1ABC"}, - {"name": "Bob", "callsign": "W2XYZ"}, - ] - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(members_data, f) - f.flush() - path = f.name - - try: - result = load_members(path) - assert len(result) == 2 - assert result[0]["name"] == "Alice" - assert result[1]["name"] == "Bob" - finally: - Path(path).unlink(missing_ok=True) - - def test_load_members_invalid_json(self) -> None: - """Test load_members with invalid JSON returns empty list.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - f.write("not valid json {") - f.flush() - path = f.name - - try: - result = load_members(path) - assert result == [] - finally: - Path(path).unlink(missing_ok=True) - - def test_load_members_dict_without_members_key(self) -> None: - """Test load_members with dict but no members key returns empty list.""" - data = {"other_key": "value"} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(data, f) - f.flush() - path = f.name - - try: - result = load_members(path) - assert result == [] - finally: - Path(path).unlink(missing_ok=True)