mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 09:23:07 +02:00
Merge pull request #22 from ipnet-mesh/claude/add-member-node-association-01JezMA7XzvwsX37rMNoBmvo
Updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,43 +9,55 @@
|
||||
</div>
|
||||
|
||||
{% if members %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 items-start">
|
||||
{% for member in members %}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
{{ member.name }}
|
||||
{% if member.callsign %}
|
||||
<span class="badge badge-secondary">{{ member.callsign }}</span>
|
||||
<span class="badge badge-success">{{ member.callsign }}</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if member.role %}
|
||||
<p class="text-sm opacity-70">{{ member.role }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.description %}
|
||||
<p class="mt-2">{{ member.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.email or member.discord or member.website %}
|
||||
<div class="card-actions justify-start mt-4">
|
||||
{% if member.email %}
|
||||
<a href="mailto:{{ member.email }}" class="btn btn-ghost btn-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Email
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if member.website %}
|
||||
<a href="{{ member.website }}" target="_blank" class="btn btn-ghost btn-xs">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
Website
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if member.contact %}
|
||||
<p class="text-sm mt-2">
|
||||
<span class="opacity-70">Contact:</span> {{ member.contact }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if member.nodes %}
|
||||
<div class="mt-4 space-y-2">
|
||||
{% for node in member.nodes %}
|
||||
{% set adv_type = node.node_adv_type or node.node_role %}
|
||||
<a href="/nodes/{{ node.public_key }}" class="flex items-center gap-3 p-2 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
||||
<span class="text-lg" title="{{ adv_type or 'Unknown' }}">
|
||||
{% if adv_type and adv_type|lower == 'chat' %}
|
||||
💬
|
||||
{% elif adv_type and adv_type|lower == 'repeater' %}
|
||||
📡
|
||||
{% elif adv_type and adv_type|lower == 'room' %}
|
||||
🪧
|
||||
{% elif adv_type %}
|
||||
📍
|
||||
{% else %}
|
||||
📦
|
||||
{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
{% if node.friendly_name or node.node_name %}
|
||||
<div class="font-medium text-sm">{{ node.friendly_name or node.node_name }}</div>
|
||||
<div class="font-mono text-xs opacity-60">{{ node.public_key[:12] }}...</div>
|
||||
{% else %}
|
||||
<div class="font-mono text-sm">{{ node.public_key[:12] }}...</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -59,31 +71,27 @@
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">No members configured</h3>
|
||||
<p class="text-sm">To display network members, provide a members JSON file using the <code>--members-file</code> option.</p>
|
||||
<p class="text-sm">To display network members, create a members.yaml file in your seed directory.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Members File Format</h2>
|
||||
<p class="mb-4">Create a JSON file with the following structure:</p>
|
||||
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}</code></pre>
|
||||
<p class="mb-4">Create a YAML file at <code>$SEED_HOME/members.yaml</code> with the following structure:</p>
|
||||
<pre class="bg-base-200 p-4 rounded-box text-sm overflow-x-auto"><code>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.</code></pre>
|
||||
<p class="mt-4 text-sm opacity-70">Run <code>meshcore-hub collector seed</code> to import members, or they will be imported automatically on collector startup.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user