Add admin interface for managing node tags

Implement CRUD operations for NodeTags in the admin interface:

- Add NodeTagMove schema for moving tags between nodes
- Add PUT /nodes/{public_key}/tags/{key}/move API endpoint
- Add web routes at /a/node-tags for tag management
- Create admin templates with node selector and tag management UI
- Support editing, adding, moving, and deleting tags via API calls
- Add comprehensive tests for new functionality

The interface allows selecting a node from a dropdown, viewing its
tags, and performing all CRUD operations including moving a tag
to a different node without having to delete and recreate it.
This commit is contained in:
Claude
2026-01-11 01:34:07 +00:00
parent 741dd3ce84
commit 367f838371
8 changed files with 1142 additions and 14 deletions
+130
View File
@@ -158,3 +158,133 @@ class TestNodeTags:
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 201 # Created
class TestMoveNodeTag:
"""Tests for PUT /nodes/{public_key}/tags/{key}/move endpoint."""
# 64-character public key for testing
DEST_PUBLIC_KEY = "xyz789xyz789xyz789xyz789xyz789xyabc123abc123abc123abc123abc123ab"
def test_move_node_tag_success(
self, client_no_auth, api_db_session, sample_node, sample_node_tag
):
"""Test successfully moving a tag to another node."""
from meshcore_hub.common.models import Node
from datetime import datetime, timezone
# Create a second node with 64-char public key
second_node = Node(
public_key=self.DEST_PUBLIC_KEY,
name="Second Node",
adv_type="CHAT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add(second_node)
api_db_session.commit()
response = client_no_auth.put(
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
json={"new_public_key": second_node.public_key},
)
assert response.status_code == 200
data = response.json()
assert data["key"] == sample_node_tag.key
assert data["value"] == sample_node_tag.value
# Verify tag is no longer on original node
response = client_no_auth.get(
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}"
)
assert response.status_code == 404
# Verify tag is now on new node
response = client_no_auth.get(
f"/api/v1/nodes/{second_node.public_key}/tags/{sample_node_tag.key}"
)
assert response.status_code == 200
def test_move_node_tag_source_not_found(self, client_no_auth):
"""Test moving a tag from a non-existent node."""
response = client_no_auth.put(
"/api/v1/nodes/nonexistent123/tags/somekey/move",
json={"new_public_key": self.DEST_PUBLIC_KEY},
)
assert response.status_code == 404
assert "Source node not found" in response.json()["detail"]
def test_move_node_tag_tag_not_found(self, client_no_auth, sample_node):
"""Test moving a non-existent tag."""
response = client_no_auth.put(
f"/api/v1/nodes/{sample_node.public_key}/tags/nonexistent/move",
json={"new_public_key": self.DEST_PUBLIC_KEY},
)
assert response.status_code == 404
assert "Tag not found" in response.json()["detail"]
def test_move_node_tag_dest_not_found(
self, client_no_auth, sample_node, sample_node_tag
):
"""Test moving a tag to a non-existent destination node."""
# 64-character nonexistent public key
nonexistent_key = (
"1111111111111111111111111111111122222222222222222222222222222222"
)
response = client_no_auth.put(
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
json={"new_public_key": nonexistent_key},
)
assert response.status_code == 404
assert "Destination node not found" in response.json()["detail"]
def test_move_node_tag_conflict(
self, client_no_auth, api_db_session, sample_node, sample_node_tag
):
"""Test moving a tag when destination already has that key."""
from meshcore_hub.common.models import Node, NodeTag
from datetime import datetime, timezone
# Create second node with same tag key
second_node = Node(
public_key=self.DEST_PUBLIC_KEY,
name="Second Node",
adv_type="CHAT",
first_seen=datetime.now(timezone.utc),
)
api_db_session.add(second_node)
api_db_session.commit()
# Add the same tag key to second node
existing_tag = NodeTag(
node_id=second_node.id,
key=sample_node_tag.key, # Same key
value="different value",
)
api_db_session.add(existing_tag)
api_db_session.commit()
response = client_no_auth.put(
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
json={"new_public_key": second_node.public_key},
)
assert response.status_code == 409
assert "already exists on destination" in response.json()["detail"]
def test_move_node_tag_requires_admin(
self, client_with_auth, sample_node, sample_node_tag
):
"""Test that move operation requires admin auth."""
# Without auth
response = client_with_auth.put(
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
json={"new_public_key": self.DEST_PUBLIC_KEY},
)
assert response.status_code == 401
# With read key (not admin)
response = client_with_auth.put(
f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}/move",
json={"new_public_key": self.DEST_PUBLIC_KEY},
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 403