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 %} -
+
{% for member in members %}

{{ member.name }} {% if member.callsign %} - {{ member.callsign }} + {{ member.callsign }} {% endif %}

- {% if member.role %} -

{{ member.role }}

- {% endif %} - {% if member.description %}

{{ member.description }}

{% endif %} - {% if member.email or member.discord or member.website %} - @@ -59,31 +71,27 @@

No members configured

-

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.

Members File Format

-

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.

{% endif %} diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py index ed11adb..ef7de24 100644 --- a/tests/test_web/conftest.py +++ b/tests/test_web/conftest.py @@ -321,6 +321,9 @@ def mock_http_client_with_members() -> MockHttpClient: "node_role": "chat", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", + "node_name": "Alice's Node", + "node_adv_type": "chat", + "friendly_name": "Alice Chat", } ], "created_at": "2024-01-01T00:00:00Z", diff --git a/tests/test_web/test_members.py b/tests/test_web/test_members.py index 496ffa0..bbe03dc 100644 --- a/tests/test_web/test_members.py +++ b/tests/test_web/test_members.py @@ -38,4 +38,19 @@ class TestMembersPage: assert "Bob" in response.text assert "W1ABC" in response.text assert "W2XYZ" in response.text - assert "Admin" in response.text + + def test_members_with_nodes_shows_node_links( + self, client_with_members: TestClient + ) -> None: + """Test that members page shows associated nodes with links.""" + response = client_with_members.get("/members") + assert response.status_code == 200 + # Alice has a node associated - check for friendly name display + assert "Alice Chat" in response.text + # Check for partial public key underneath + assert "abc123def456" in response.text + # Check for link to node detail page (full public key) + assert ( + "/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1" + in response.text + )