mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-10-31 04:02:03 +01:00
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:
9 files changed
+336
-54
No files matched your search
@@ -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")
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
# Example members seed file
|
# Example members seed file
|
||||||
|
# Each member can have multiple nodes with different roles (chat, repeater, etc.)
|
||||||
members:
|
members:
|
||||||
- name: Example Member
|
- name: Example Member
|
||||||
callsign: N0CALL
|
callsign: N0CALL
|
||||||
role: Network Operator
|
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
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
from meshcore_hub.api.auth import RequireAdmin, RequireRead
|
||||||
from meshcore_hub.api.dependencies import DbSession
|
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 (
|
from meshcore_hub.common.schemas.members import (
|
||||||
MemberCreate,
|
MemberCreate,
|
||||||
MemberList,
|
MemberList,
|
||||||
@@ -28,8 +29,14 @@ async def list_members(
|
|||||||
count_query = select(func.count()).select_from(Member)
|
count_query = select(func.count()).select_from(Member)
|
||||||
total = session.execute(count_query).scalar() or 0
|
total = session.execute(count_query).scalar() or 0
|
||||||
|
|
||||||
# Get members
|
# Get members with nodes eagerly loaded
|
||||||
query = select(Member).order_by(Member.name).limit(limit).offset(offset)
|
query = (
|
||||||
|
select(Member)
|
||||||
|
.options(selectinload(Member.nodes))
|
||||||
|
.order_by(Member.name)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
members = session.execute(query).scalars().all()
|
members = session.execute(query).scalars().all()
|
||||||
|
|
||||||
return MemberList(
|
return MemberList(
|
||||||
@@ -47,7 +54,9 @@ async def get_member(
|
|||||||
member_id: str,
|
member_id: str,
|
||||||
) -> MemberRead:
|
) -> MemberRead:
|
||||||
"""Get a specific member by ID."""
|
"""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()
|
member = session.execute(query).scalar_one_or_none()
|
||||||
|
|
||||||
if not member:
|
if not member:
|
||||||
@@ -63,9 +72,6 @@ async def create_member(
|
|||||||
member: MemberCreate,
|
member: MemberCreate,
|
||||||
) -> MemberRead:
|
) -> MemberRead:
|
||||||
"""Create a new member."""
|
"""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
|
# Create member
|
||||||
new_member = Member(
|
new_member = Member(
|
||||||
name=member.name,
|
name=member.name,
|
||||||
@@ -73,9 +79,20 @@ async def create_member(
|
|||||||
role=member.role,
|
role=member.role,
|
||||||
description=member.description,
|
description=member.description,
|
||||||
contact=member.contact,
|
contact=member.contact,
|
||||||
public_key=public_key,
|
|
||||||
)
|
)
|
||||||
session.add(new_member)
|
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.commit()
|
||||||
session.refresh(new_member)
|
session.refresh(new_member)
|
||||||
|
|
||||||
@@ -90,7 +107,9 @@ async def update_member(
|
|||||||
member: MemberUpdate,
|
member: MemberUpdate,
|
||||||
) -> MemberRead:
|
) -> MemberRead:
|
||||||
"""Update a member."""
|
"""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()
|
existing = session.execute(query).scalar_one_or_none()
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -107,8 +126,20 @@ async def update_member(
|
|||||||
existing.description = member.description
|
existing.description = member.description
|
||||||
if member.contact is not None:
|
if member.contact is not None:
|
||||||
existing.contact = member.contact
|
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.commit()
|
||||||
session.refresh(existing)
|
session.refresh(existing)
|
||||||
|
|||||||
@@ -9,11 +9,28 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from meshcore_hub.common.database import DatabaseManager
|
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__)
|
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):
|
class MemberData(BaseModel):
|
||||||
"""Schema for a member entry in the import file."""
|
"""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)
|
role: Optional[str] = Field(default=None, max_length=100)
|
||||||
description: Optional[str] = Field(default=None)
|
description: Optional[str] = Field(default=None)
|
||||||
contact: Optional[str] = Field(default=None, max_length=255)
|
contact: Optional[str] = Field(default=None, max_length=255)
|
||||||
public_key: Optional[str] = Field(default=None)
|
nodes: Optional[list[NodeData]] = 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]]:
|
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
|
- name: Member 1
|
||||||
callsign: M1
|
callsign: M1
|
||||||
- name: Member 2
|
nodes:
|
||||||
callsign: M2
|
- public_key: abc123...
|
||||||
|
node_role: chat
|
||||||
|
|
||||||
2. Object with "members" key:
|
2. Object with "members" key:
|
||||||
|
|
||||||
members:
|
members:
|
||||||
- name: Member 1
|
- name: Member 1
|
||||||
callsign: M1
|
callsign: M1
|
||||||
|
nodes:
|
||||||
|
- public_key: abc123...
|
||||||
|
node_role: chat
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path: Path to the members YAML file
|
file_path: Path to the members YAML file
|
||||||
@@ -107,7 +116,8 @@ def import_members(
|
|||||||
"""Import members from a YAML file into the database.
|
"""Import members from a YAML file into the database.
|
||||||
|
|
||||||
Performs upsert operations based on name - existing members are updated,
|
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:
|
Args:
|
||||||
file_path: Path to the members YAML file
|
file_path: Path to the members YAML file
|
||||||
@@ -155,8 +165,20 @@ def import_members(
|
|||||||
existing.description = member_data["description"]
|
existing.description = member_data["description"]
|
||||||
if member_data.get("contact") is not None:
|
if member_data.get("contact") is not None:
|
||||||
existing.contact = member_data["contact"]
|
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
|
stats["updated"] += 1
|
||||||
logger.debug(f"Updated member: {name}")
|
logger.debug(f"Updated member: {name}")
|
||||||
@@ -168,9 +190,20 @@ def import_members(
|
|||||||
role=member_data.get("role"),
|
role=member_data.get("role"),
|
||||||
description=member_data.get("description"),
|
description=member_data.get("description"),
|
||||||
contact=member_data.get("contact"),
|
contact=member_data.get("contact"),
|
||||||
public_key=member_data.get("public_key"),
|
|
||||||
)
|
)
|
||||||
session.add(new_member)
|
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
|
stats["created"] += 1
|
||||||
logger.debug(f"Created member: {name}")
|
logger.debug(f"Created member: {name}")
|
||||||
|
|
||||||
|
|||||||
@@ -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.telemetry import Telemetry
|
||||||
from meshcore_hub.common.models.event_log import EventLog
|
from meshcore_hub.common.models.event_log import EventLog
|
||||||
from meshcore_hub.common.models.member import Member
|
from meshcore_hub.common.models.member import Member
|
||||||
|
from meshcore_hub.common.models.member_node import MemberNode
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -21,4 +22,5 @@ __all__ = [
|
|||||||
"Telemetry",
|
"Telemetry",
|
||||||
"EventLog",
|
"EventLog",
|
||||||
"Member",
|
"Member",
|
||||||
|
"MemberNode",
|
||||||
]
|
]
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
"""Member model for network member information."""
|
"""Member model for network member information."""
|
||||||
|
|
||||||
from typing import Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import String, Text
|
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
|
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):
|
class Member(Base, UUIDMixin, TimestampMixin):
|
||||||
"""Member model for network member information.
|
"""Member model for network member information.
|
||||||
|
|
||||||
Stores information about network members/operators.
|
Stores information about network members/operators.
|
||||||
|
Members can have multiple associated nodes (chat, repeater, etc.).
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
id: UUID primary key
|
id: UUID primary key
|
||||||
@@ -20,7 +24,7 @@ class Member(Base, UUIDMixin, TimestampMixin):
|
|||||||
role: Member's role in the network (optional)
|
role: Member's role in the network (optional)
|
||||||
description: Additional description (optional)
|
description: Additional description (optional)
|
||||||
contact: Contact information (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
|
created_at: Record creation timestamp
|
||||||
updated_at: Record update timestamp
|
updated_at: Record update timestamp
|
||||||
"""
|
"""
|
||||||
@@ -47,10 +51,11 @@ class Member(Base, UUIDMixin, TimestampMixin):
|
|||||||
String(255),
|
String(255),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
public_key: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(64),
|
# Relationship to member nodes
|
||||||
nullable=True,
|
nodes: Mapped[list["MemberNode"]] = relationship(
|
||||||
index=True,
|
back_populates="member",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
|||||||
@@ -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})>"
|
||||||
@@ -6,6 +6,35 @@ from typing import Optional
|
|||||||
from pydantic import BaseModel, Field
|
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):
|
class MemberCreate(BaseModel):
|
||||||
"""Schema for creating a member."""
|
"""Schema for creating a member."""
|
||||||
|
|
||||||
@@ -34,12 +63,9 @@ class MemberCreate(BaseModel):
|
|||||||
max_length=255,
|
max_length=255,
|
||||||
description="Contact information",
|
description="Contact information",
|
||||||
)
|
)
|
||||||
public_key: Optional[str] = Field(
|
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
min_length=64,
|
description="List of associated nodes",
|
||||||
max_length=64,
|
|
||||||
pattern=r"^[0-9a-fA-F]{64}$",
|
|
||||||
description="Associated node public key (64-char hex)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -71,26 +97,22 @@ class MemberUpdate(BaseModel):
|
|||||||
max_length=255,
|
max_length=255,
|
||||||
description="Contact information",
|
description="Contact information",
|
||||||
)
|
)
|
||||||
public_key: Optional[str] = Field(
|
nodes: Optional[list[MemberNodeCreate]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
min_length=64,
|
description="List of associated nodes (replaces existing nodes)",
|
||||||
max_length=64,
|
|
||||||
pattern=r"^[0-9a-fA-F]{64}$",
|
|
||||||
description="Associated node public key (64-char hex)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MemberRead(BaseModel):
|
class MemberRead(BaseModel):
|
||||||
"""Schema for reading a member."""
|
"""Schema for reading a member."""
|
||||||
|
|
||||||
|
id: str = Field(..., description="Member UUID")
|
||||||
name: str = Field(..., description="Member's display name")
|
name: str = Field(..., description="Member's display name")
|
||||||
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
callsign: Optional[str] = Field(default=None, description="Amateur radio callsign")
|
||||||
role: Optional[str] = Field(default=None, description="Member's role")
|
role: Optional[str] = Field(default=None, description="Member's role")
|
||||||
description: Optional[str] = Field(default=None, description="Description")
|
description: Optional[str] = Field(default=None, description="Description")
|
||||||
contact: Optional[str] = Field(default=None, description="Contact information")
|
contact: Optional[str] = Field(default=None, description="Contact information")
|
||||||
public_key: Optional[str] = Field(
|
nodes: list[MemberNodeRead] = Field(default=[], description="Associated nodes")
|
||||||
default=None, description="Associated node public key"
|
|
||||||
)
|
|
||||||
created_at: datetime = Field(..., description="Creation timestamp")
|
created_at: datetime = Field(..., description="Creation timestamp")
|
||||||
updated_at: datetime = Field(..., description="Last update timestamp")
|
updated_at: datetime = Field(..., description="Last update timestamp")
|
||||||
|
|
||||||
|
|||||||
@@ -315,7 +315,14 @@ def mock_http_client_with_members() -> MockHttpClient:
|
|||||||
"role": "Admin",
|
"role": "Admin",
|
||||||
"description": None,
|
"description": None,
|
||||||
"contact": "alice@example.com",
|
"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",
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
"updated_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",
|
"role": "Member",
|
||||||
"description": None,
|
"description": None,
|
||||||
"contact": None,
|
"contact": None,
|
||||||
"public_key": None,
|
"nodes": [],
|
||||||
"created_at": "2024-01-01T00:00:00Z",
|
"created_at": "2024-01-01T00:00:00Z",
|
||||||
"updated_at": "2024-01-01T00:00:00Z",
|
"updated_at": "2024-01-01T00:00:00Z",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user