diff --git a/alembic/versions/20241205_0001_002_member_nodes.py b/alembic/versions/20241205_0001_002_member_nodes.py new file mode 100644 index 0000000..2f1af56 --- /dev/null +++ b/alembic/versions/20241205_0001_002_member_nodes.py @@ -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") diff --git a/example/seed/members.yaml b/example/seed/members.yaml index c8670fe..087c9fc 100644 --- a/example/seed/members.yaml +++ b/example/seed/members.yaml @@ -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 diff --git a/src/meshcore_hub/api/routes/members.py b/src/meshcore_hub/api/routes/members.py index 7656004..1d7af61 100644 --- a/src/meshcore_hub/api/routes/members.py +++ b/src/meshcore_hub/api/routes/members.py @@ -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) diff --git a/src/meshcore_hub/collector/member_import.py b/src/meshcore_hub/collector/member_import.py index 4ecaf2f..7898150 100644 --- a/src/meshcore_hub/collector/member_import.py +++ b/src/meshcore_hub/collector/member_import.py @@ -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}") diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index 2363b2d..d1dd8f0 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -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", ] diff --git a/src/meshcore_hub/common/models/member.py b/src/meshcore_hub/common/models/member.py index 177b13e..a33a329 100644 --- a/src/meshcore_hub/common/models/member.py +++ b/src/meshcore_hub/common/models/member.py @@ -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: diff --git a/src/meshcore_hub/common/models/member_node.py b/src/meshcore_hub/common/models/member_node.py new file mode 100644 index 0000000..665d775 --- /dev/null +++ b/src/meshcore_hub/common/models/member_node.py @@ -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"" diff --git a/src/meshcore_hub/common/schemas/members.py b/src/meshcore_hub/common/schemas/members.py index f0c8702..5380d3e 100644 --- a/src/meshcore_hub/common/schemas/members.py +++ b/src/meshcore_hub/common/schemas/members.py @@ -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") diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index aec6c30..ed11adb 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -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", },