diff --git a/alembic/versions/20241205_0001_002_member_nodes.py b/alembic/versions/20241205_0001_002_member_nodes.py index 2f1af56..ebf53ab 100644 --- a/alembic/versions/20241205_0001_002_member_nodes.py +++ b/alembic/versions/20241205_0001_002_member_nodes.py @@ -54,9 +54,7 @@ def upgrade() -> None: # 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" - ) + sa.text("SELECT id, public_key FROM members WHERE public_key IS NOT NULL") ).fetchall() # Insert into member_nodes diff --git a/contrib/seed/ipnet/members.yaml b/contrib/seed/ipnet/members.yaml index a572ed8..0a4e9c5 100644 --- a/contrib/seed/ipnet/members.yaml +++ b/contrib/seed/ipnet/members.yaml @@ -1,22 +1,66 @@ # IPNet Network Members members: - name: Louis - callsign: louis + callsign: Louis role: admin description: IPNet Founder + nodes: + # ip2-rep01 + - public_key: 2337484665ced7e210007e9fd9db98ced0a24a6eab8b4cbe3a06b3a1cea33ca1 + node_role: repeater + # ip2-rep02 + - public_key: 8cb01fff1afc099055af418ce5fc5e60384df9ff763c25dd7e6a5e0922e8df90 + node_role: repeater + # ip2-rep03 + - public_key: 5b565df747913358e24d890b2227de9c35d09763746b6ec326c15ebbf9b8be3b + node_role: repeater + # ip2-sol01 + - public_key: 87eb9487a1a4351e986e55627b2d09c4da61f94d080eaf4d7129caef89886e25 + node_role: repeater + # personal chat node + - public_key: c6e0d85528b4b5d7f53aa7dded2b7e0b9c8f8a5c00acfaad47476ef5f3c7dc47 + node_role: chat - name: Mark callsign: Mark role: member description: IPNet Member + nodes: + - public_key: 22309435fbd9dd1f14870a1895dc854779f6b2af72b08542f6105d264a493ebe + node_role: repeater + - public_key: 9135986b83815ada92883358435cc6528c7db60cb647f9b6547739a1ce5eb1c8 + node_role: repeater + - public_key: 2a4f89e766dfa1758e35a69962c1f6d352b206a5e3562a589155a3ebfe7fc2bb + node_role: repeater + - public_key: e790b73b2d6e377dd0f575c847f3ef42232f610eb9a19af57083fc4f647309ac + node_role: repeater + - public_key: d3c20d962f7384c111fbafad6fbc1c1dc0e5c3ce802fb3ee11020e8d8207ed3a + node_role: repeater + - public_key: b00ce9d218203e96d8557a4d59e06f5de59bbc4dcc4df9c870079d2cb8b5bd80 + node_role: repeater - name: CCZ callsign: CCZ role: member + nodes: + - public_key: e334ec5475789d542ed9e692fbeef7444a371fcc05adcbda1f47ba6a3191b459 + node_role: repeater + - public_key: cc15fb33e98f2e098a543f516f770dc3061a1a6b30f79b84780663bf68ae6b53 + node_role: repeater + - public_key: 20ed75ffc0f9777951716bb3d308d7f041fd2ad32fe2e998e600d0361e1fe2ac + node_role: repeater description: IPNet Member - name: Walshie callsign: Walshie86 role: member description: IPNet Member + nodes: + - public_key: bd7b5ac75f660675b39f368e1dbb6d1dbcefd8bd7a170e21a942954f67c8bf52 + node_role: repeater + - public_key: 9cf300c40112ea34d0a59858270948b27ab6cd87e840de338f3ca782c17537b2 + node_role: repeater - name: Craig callsign: M7XCN role: member description: IPNet Member + nodes: + - public_key: 8accb6d0189ccaffb745ba54793e7fe3edd515edb45554325d957e48c1b9f3b3 + node_role: repeater diff --git a/src/meshcore_hub/api/routes/members.py b/src/meshcore_hub/api/routes/members.py index 1d7af61..809aff7 100644 --- a/src/meshcore_hub/api/routes/members.py +++ b/src/meshcore_hub/api/routes/members.py @@ -6,10 +6,11 @@ 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, MemberNode +from meshcore_hub.common.models import Member, MemberNode, Node from meshcore_hub.common.schemas.members import ( MemberCreate, MemberList, + MemberNodeRead, MemberRead, MemberUpdate, ) @@ -17,6 +18,50 @@ from meshcore_hub.common.schemas.members import ( router = APIRouter() +def _enrich_member_nodes( + member: Member, node_info: dict[str, dict] +) -> list[MemberNodeRead]: + """Enrich member nodes with node details from the database. + + Args: + member: The member with nodes to enrich + node_info: Dict mapping public_key to node details + + Returns: + List of MemberNodeRead with node details populated + """ + enriched_nodes = [] + for mn in member.nodes: + info = node_info.get(mn.public_key, {}) + enriched_nodes.append( + MemberNodeRead( + public_key=mn.public_key, + node_role=mn.node_role, + created_at=mn.created_at, + updated_at=mn.updated_at, + node_name=info.get("name"), + node_adv_type=info.get("adv_type"), + friendly_name=info.get("friendly_name"), + ) + ) + return enriched_nodes + + +def _member_to_read(member: Member, node_info: dict[str, dict]) -> MemberRead: + """Convert a Member model to MemberRead with enriched node data.""" + return MemberRead( + id=member.id, + name=member.name, + callsign=member.callsign, + role=member.role, + description=member.description, + contact=member.contact, + nodes=_enrich_member_nodes(member, node_info), + created_at=member.created_at, + updated_at=member.updated_at, + ) + + @router.get("", response_model=MemberList) async def list_members( _: RequireRead, @@ -37,10 +82,37 @@ async def list_members( .limit(limit) .offset(offset) ) - members = session.execute(query).scalars().all() + members = list(session.execute(query).scalars().all()) + + # Collect all public keys from member nodes + all_public_keys = set() + for m in members: + for mn in m.nodes: + all_public_keys.add(mn.public_key) + + # Fetch node info for all public keys in one query + node_info: dict[str, dict] = {} + if all_public_keys: + node_query = ( + select(Node) + .options(selectinload(Node.tags)) + .where(Node.public_key.in_(all_public_keys)) + ) + nodes = session.execute(node_query).scalars().all() + for node in nodes: + friendly_name = None + for tag in node.tags: + if tag.key == "friendly_name": + friendly_name = tag.value + break + node_info[node.public_key] = { + "name": node.name, + "adv_type": node.adv_type, + "friendly_name": friendly_name, + } return MemberList( - items=[MemberRead.model_validate(m) for m in members], + items=[_member_to_read(m, node_info) for m in members], total=total, limit=limit, offset=offset, @@ -62,7 +134,29 @@ async def get_member( if not member: raise HTTPException(status_code=404, detail="Member not found") - return MemberRead.model_validate(member) + # Fetch node info for member's nodes + node_info: dict[str, dict] = {} + public_keys = [mn.public_key for mn in member.nodes] + if public_keys: + node_query = ( + select(Node) + .options(selectinload(Node.tags)) + .where(Node.public_key.in_(public_keys)) + ) + nodes = session.execute(node_query).scalars().all() + for node in nodes: + friendly_name = None + for tag in node.tags: + if tag.key == "friendly_name": + friendly_name = tag.value + break + node_info[node.public_key] = { + "name": node.name, + "adv_type": node.adv_type, + "friendly_name": friendly_name, + } + + return _member_to_read(member, node_info) @router.post("", response_model=MemberRead, status_code=201) diff --git a/src/meshcore_hub/common/schemas/members.py b/src/meshcore_hub/common/schemas/members.py index 5380d3e..28bf660 100644 --- a/src/meshcore_hub/common/schemas/members.py +++ b/src/meshcore_hub/common/schemas/members.py @@ -30,6 +30,14 @@ class MemberNodeRead(BaseModel): 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") + # Node details (populated from nodes table if available) + node_name: Optional[str] = Field(default=None, description="Node's name from DB") + node_adv_type: Optional[str] = Field( + default=None, description="Node's advertisement type" + ) + friendly_name: Optional[str] = Field( + default=None, description="Node's friendly name tag" + ) class Config: from_attributes = True diff --git a/src/meshcore_hub/web/routes/members.py b/src/meshcore_hub/web/routes/members.py index 8414989..1731d52 100644 --- a/src/meshcore_hub/web/routes/members.py +++ b/src/meshcore_hub/web/routes/members.py @@ -21,6 +21,15 @@ async def members_page(request: Request) -> HTMLResponse: # Fetch members from API members = [] + def node_sort_key(node: dict) -> int: + """Sort nodes: repeater first, then chat, then others.""" + role = (node.get("node_role") or "").lower() + if role == "repeater": + return 0 + if role == "chat": + return 1 + return 2 + try: response = await request.app.state.http_client.get( "/api/v1/members", params={"limit": 100} @@ -28,6 +37,10 @@ async def members_page(request: Request) -> HTMLResponse: if response.status_code == 200: data = response.json() members = data.get("items", []) + # Sort nodes within each member (repeater first, then chat) + for member in members: + if member.get("nodes"): + member["nodes"] = sorted(member["nodes"], key=node_sort_key) except Exception as e: logger.warning(f"Failed to fetch members from API: {e}") context["api_error"] = str(e) diff --git a/src/meshcore_hub/web/templates/members.html b/src/meshcore_hub/web/templates/members.html index 6518583..f375a70 100644 --- a/src/meshcore_hub/web/templates/members.html +++ b/src/meshcore_hub/web/templates/members.html @@ -9,43 +9,55 @@ {% if members %} -
{{ member.role }}
- {% endif %} - {% if member.description %}{{ member.description }}
{% endif %} - {% if member.email or member.discord or member.website %} -+ Contact: {{ member.contact }} +
+ {% endif %} + + {% if member.nodes %} +To display network members, provide a members JSON file using the --members-file option.
To display network members, create a members.yaml file in your seed directory.
Create a JSON file with the following structure:
-{
- "members": [
- {
- "name": "John Doe",
- "callsign": "AB1CD",
- "role": "Network Admin",
- "description": "Manages the main repeater node.",
- "email": "john@example.com",
- "website": "https://example.com"
- },
- {
- "name": "Jane Smith",
- "role": "Member",
- "description": "Regular user in the downtown area."
- }
- ]
-}
+ Create a YAML file at $SEED_HOME/members.yaml with the following structure:
members:
+ - name: John Doe
+ callsign: AB1CD
+ role: Network Admin
+ description: Manages the main repeater node.
+ contact: john@example.com
+ nodes:
+ - public_key: abc123def456... # 64-char hex
+ node_role: repeater
+ - name: Jane Smith
+ role: Member
+ description: Regular user in the downtown area.
+ Run meshcore-hub collector seed to import members, or they will be imported automatically on collector startup.