Move members from web to collector layer with SEED_HOME

- Add Member database model with name, callsign, role, description, contact, and public_key fields
- Add Member Pydantic schemas (MemberCreate, MemberUpdate, MemberRead, MemberList)
- Add members table to initial migration
- Add members API endpoints (GET/POST/PUT/DELETE /api/v1/members)
- Add member_import.py for importing from JSON files
- Update web layer to fetch members from API instead of file
- Add SEED_HOME setting (defaults to ./seed) for seed data files
- Add 'collector seed' command to import node_tags.json and members.json
- Rename tags.json to node_tags.json for consistency
- Move example seed data from example/data/* to example/seed/
- Update tests and configuration
This commit is contained in:
Claude
2025-12-03 23:42:16 +00:00
parent 6095f35bae
commit 0db0ebf9b2
19 changed files with 831 additions and 235 deletions
@@ -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")
+2
View File
@@ -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"])
+133
View File
@@ -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()
+189 -8
View File
@@ -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()
+177
View File
@@ -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
+21 -24
View File
@@ -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."""
@@ -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",
]
+57
View File
@@ -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"<Member(id={self.id}, name={self.name}, callsign={self.callsign})>"
@@ -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",
]
+108
View File
@@ -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")
-3
View File
@@ -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))
+2 -17
View File
@@ -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...")
+13 -37
View File
@@ -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
+8
View File
@@ -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."""
+20 -14
View File
@@ -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"
+55 -37
View File
@@ -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)
+5 -95
View File
@@ -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)