Add member-node association support

Members can now have multiple associated nodes, each with a public_key
and node_role (e.g., 'chat', 'repeater'). This replaces the single
public_key field on members with a one-to-many relationship.

Changes:
- Add MemberNode model for member-node associations
- Update Member model to remove public_key, add nodes relationship
- Update Pydantic schemas with MemberNodeCreate/MemberNodeRead
- Update member_import.py to handle nodes list in seed files
- Update API routes to handle nodes in create/update/read operations
- Add Alembic migration to create member_nodes table and migrate data
- Update example seed file with new format
This commit is contained in:
Claude
2025-12-05 20:34:09 +00:00
parent 0016edbdac
commit a4b13d3456
9 changed files with 336 additions and 54 deletions

View File

@@ -0,0 +1,116 @@
"""Add member_nodes association table
Revision ID: 002
Revises: 001
Create Date: 2024-12-05
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create member_nodes table
op.create_table(
"member_nodes",
sa.Column("id", sa.String(), nullable=False),
sa.Column("member_id", sa.String(36), nullable=False),
sa.Column("public_key", sa.String(64), nullable=False),
sa.Column("node_role", sa.String(50), 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.ForeignKeyConstraint(["member_id"], ["members.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_member_nodes_member_id", "member_nodes", ["member_id"])
op.create_index("ix_member_nodes_public_key", "member_nodes", ["public_key"])
op.create_index(
"ix_member_nodes_member_public_key",
"member_nodes",
["member_id", "public_key"],
)
# Migrate existing public_key data from members to member_nodes
# Get all members with a public_key
connection = op.get_bind()
members_with_keys = connection.execute(
sa.text(
"SELECT id, public_key FROM members WHERE public_key IS NOT NULL"
)
).fetchall()
# Insert into member_nodes
for member_id, public_key in members_with_keys:
# Generate a UUID for the new row
import uuid
node_id = str(uuid.uuid4())
connection.execute(
sa.text(
"""
INSERT INTO member_nodes (id, member_id, public_key, created_at, updated_at)
VALUES (:id, :member_id, :public_key, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
"""
),
{"id": node_id, "member_id": member_id, "public_key": public_key},
)
# Drop the public_key column from members
op.drop_index("ix_members_public_key", table_name="members")
op.drop_column("members", "public_key")
def downgrade() -> None:
# Add public_key column back to members
op.add_column(
"members",
sa.Column("public_key", sa.String(64), nullable=True),
)
op.create_index("ix_members_public_key", "members", ["public_key"])
# Migrate data back - take the first node for each member
connection = op.get_bind()
member_nodes = connection.execute(
sa.text(
"""
SELECT DISTINCT member_id, public_key
FROM member_nodes
WHERE (member_id, created_at) IN (
SELECT member_id, MIN(created_at)
FROM member_nodes
GROUP BY member_id
)
"""
)
).fetchall()
for member_id, public_key in member_nodes:
connection.execute(
sa.text(
"UPDATE members SET public_key = :public_key WHERE id = :member_id"
),
{"public_key": public_key, "member_id": member_id},
)
# Drop member_nodes table
op.drop_table("member_nodes")

View File

@@ -1,6 +1,16 @@
# Example members seed file
# Each member can have multiple nodes with different roles (chat, repeater, etc.)
members:
- name: Example Member
callsign: N0CALL
role: Network Operator
description: Example member entry
description: Example member entry with multiple nodes
nodes:
- public_key: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
node_role: chat
- public_key: fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210
node_role: repeater
- name: Simple Member
callsign: N0CALL2
role: Observer
description: Member without any nodes

View File

@@ -2,10 +2,11 @@
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
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.models import Member, MemberNode
from meshcore_hub.common.schemas.members import (
MemberCreate,
MemberList,
@@ -28,8 +29,14 @@ async def list_members(
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)
# Get members with nodes eagerly loaded
query = (
select(Member)
.options(selectinload(Member.nodes))
.order_by(Member.name)
.limit(limit)
.offset(offset)
)
members = session.execute(query).scalars().all()
return MemberList(
@@ -47,7 +54,9 @@ async def get_member(
member_id: str,
) -> MemberRead:
"""Get a specific member by ID."""
query = select(Member).where(Member.id == member_id)
query = (
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
)
member = session.execute(query).scalar_one_or_none()
if not member:
@@ -63,9 +72,6 @@ async def create_member(
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,
@@ -73,9 +79,20 @@ async def create_member(
role=member.role,
description=member.description,
contact=member.contact,
public_key=public_key,
)
session.add(new_member)
session.flush() # Get the ID for the member
# Add nodes if provided
if member.nodes:
for node_data in member.nodes:
node = MemberNode(
member_id=new_member.id,
public_key=node_data.public_key.lower(),
node_role=node_data.node_role,
)
session.add(node)
session.commit()
session.refresh(new_member)
@@ -90,7 +107,9 @@ async def update_member(
member: MemberUpdate,
) -> MemberRead:
"""Update a member."""
query = select(Member).where(Member.id == member_id)
query = (
select(Member).options(selectinload(Member.nodes)).where(Member.id == member_id)
)
existing = session.execute(query).scalar_one_or_none()
if not existing:
@@ -107,8 +126,20 @@ async def update_member(
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()
# Update nodes if provided (replaces existing nodes)
if member.nodes is not None:
# Clear existing nodes
existing.nodes.clear()
# Add new nodes
for node_data in member.nodes:
node = MemberNode(
member_id=existing.id,
public_key=node_data.public_key.lower(),
node_role=node_data.node_role,
)
existing.nodes.append(node)
session.commit()
session.refresh(existing)

View File

@@ -9,11 +9,28 @@ 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
from meshcore_hub.common.models import Member, MemberNode
logger = logging.getLogger(__name__)
class NodeData(BaseModel):
"""Schema for a node entry in the member import file."""
public_key: str = Field(..., min_length=64, max_length=64)
node_role: Optional[str] = Field(default=None, max_length=50)
@field_validator("public_key")
@classmethod
def validate_public_key(cls, v: str) -> str:
"""Validate and normalize public key."""
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()
class MemberData(BaseModel):
"""Schema for a member entry in the import file."""
@@ -22,19 +39,7 @@ class MemberData(BaseModel):
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()
nodes: Optional[list[NodeData]] = Field(default=None)
def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
@@ -45,14 +50,18 @@ def load_members_file(file_path: str | Path) -> list[dict[str, Any]]:
- name: Member 1
callsign: M1
- name: Member 2
callsign: M2
nodes:
- public_key: abc123...
node_role: chat
2. Object with "members" key:
members:
- name: Member 1
callsign: M1
nodes:
- public_key: abc123...
node_role: chat
Args:
file_path: Path to the members YAML file
@@ -107,7 +116,8 @@ def import_members(
"""Import members from a YAML file into the database.
Performs upsert operations based on name - existing members are updated,
new members are created.
new members are created. Nodes are synced (existing nodes removed and
replaced with new ones from the file).
Args:
file_path: Path to the members YAML file
@@ -155,8 +165,20 @@ def import_members(
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"]
# Sync nodes if provided
if member_data.get("nodes") is not None:
# Remove existing nodes
existing.nodes.clear()
# Add new nodes
for node_data in member_data["nodes"]:
node = MemberNode(
member_id=existing.id,
public_key=node_data["public_key"],
node_role=node_data.get("node_role"),
)
existing.nodes.append(node)
stats["updated"] += 1
logger.debug(f"Updated member: {name}")
@@ -168,9 +190,20 @@ def import_members(
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)
session.flush() # Get the ID for the member
# Add nodes if provided
if member_data.get("nodes"):
for node_data in member_data["nodes"]:
node = MemberNode(
member_id=new_member.id,
public_key=node_data["public_key"],
node_role=node_data.get("node_role"),
)
session.add(node)
stats["created"] += 1
logger.debug(f"Created member: {name}")

View File

@@ -9,6 +9,7 @@ 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
from meshcore_hub.common.models.member_node import MemberNode
__all__ = [
"Base",
@@ -21,4 +22,5 @@ __all__ = [
"Telemetry",
"EventLog",
"Member",
"MemberNode",
]

View File

@@ -1,17 +1,21 @@
"""Member model for network member information."""
from typing import Optional
from typing import TYPE_CHECKING, Optional
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from meshcore_hub.common.models.member_node import MemberNode
class Member(Base, UUIDMixin, TimestampMixin):
"""Member model for network member information.
Stores information about network members/operators.
Members can have multiple associated nodes (chat, repeater, etc.).
Attributes:
id: UUID primary key
@@ -20,7 +24,7 @@ class Member(Base, UUIDMixin, TimestampMixin):
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)
nodes: List of associated MemberNode records
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
@@ -47,10 +51,11 @@ class Member(Base, UUIDMixin, TimestampMixin):
String(255),
nullable=True,
)
public_key: Mapped[Optional[str]] = mapped_column(
String(64),
nullable=True,
index=True,
# Relationship to member nodes
nodes: Mapped[list["MemberNode"]] = relationship(
back_populates="member",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:

View File

@@ -0,0 +1,56 @@
"""MemberNode model for associating nodes with members."""
from typing import TYPE_CHECKING, Optional
from sqlalchemy import ForeignKey, String, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
if TYPE_CHECKING:
from meshcore_hub.common.models.member import Member
class MemberNode(Base, UUIDMixin, TimestampMixin):
"""Association model linking members to their nodes.
A member can have multiple nodes (e.g., chat node, repeater).
Each node is identified by its public_key and has a role.
Attributes:
id: UUID primary key
member_id: Foreign key to the member
public_key: Node's public key (64-char hex)
node_role: Role of the node (e.g., 'chat', 'repeater')
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "member_nodes"
member_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("members.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
public_key: Mapped[str] = mapped_column(
String(64),
nullable=False,
index=True,
)
node_role: Mapped[Optional[str]] = mapped_column(
String(50),
nullable=True,
)
# Relationship back to member
member: Mapped["Member"] = relationship(back_populates="nodes")
# Composite index for efficient lookups
__table_args__ = (
Index("ix_member_nodes_member_public_key", "member_id", "public_key"),
)
def __repr__(self) -> str:
return f"<MemberNode(member_id={self.member_id}, public_key={self.public_key[:8]}..., role={self.node_role})>"

View File

@@ -6,6 +6,35 @@ from typing import Optional
from pydantic import BaseModel, Field
class MemberNodeCreate(BaseModel):
"""Schema for creating a member node association."""
public_key: str = Field(
...,
min_length=64,
max_length=64,
pattern=r"^[0-9a-fA-F]{64}$",
description="Node's public key (64-char hex)",
)
node_role: Optional[str] = Field(
default=None,
max_length=50,
description="Role of the node (e.g., 'chat', 'repeater')",
)
class MemberNodeRead(BaseModel):
"""Schema for reading a member node association."""
public_key: str = Field(..., description="Node's public key")
node_role: Optional[str] = Field(default=None, description="Role of the node")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
class Config:
from_attributes = True
class MemberCreate(BaseModel):
"""Schema for creating a member."""
@@ -34,12 +63,9 @@ class MemberCreate(BaseModel):
max_length=255,
description="Contact information",
)
public_key: Optional[str] = Field(
nodes: Optional[list[MemberNodeCreate]] = Field(
default=None,
min_length=64,
max_length=64,
pattern=r"^[0-9a-fA-F]{64}$",
description="Associated node public key (64-char hex)",
description="List of associated nodes",
)
@@ -71,26 +97,22 @@ class MemberUpdate(BaseModel):
max_length=255,
description="Contact information",
)
public_key: Optional[str] = Field(
nodes: Optional[list[MemberNodeCreate]] = Field(
default=None,
min_length=64,
max_length=64,
pattern=r"^[0-9a-fA-F]{64}$",
description="Associated node public key (64-char hex)",
description="List of associated nodes (replaces existing nodes)",
)
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"
)
nodes: list[MemberNodeRead] = Field(default=[], description="Associated nodes")
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")

View File

@@ -315,7 +315,14 @@ def mock_http_client_with_members() -> MockHttpClient:
"role": "Admin",
"description": None,
"contact": "alice@example.com",
"public_key": None,
"nodes": [
{
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
"node_role": "chat",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
}
],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},
@@ -326,7 +333,7 @@ def mock_http_client_with_members() -> MockHttpClient:
"role": "Member",
"description": None,
"contact": None,
"public_key": None,
"nodes": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
},