mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-06 17:02:59 +02:00
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:
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
@@ -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))
|
||||
|
||||
@@ -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...")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user