Add bulk copy and delete all tags for node replacement workflow

When replacing a node device, users can now:
- Copy All: Copy all tags to a new node (skips existing tags)
- Delete All: Remove all tags from a node after migration

New API endpoints:
- POST /api/v1/nodes/{pk}/tags/copy-to/{dest_pk}
- DELETE /api/v1/nodes/{pk}/tags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-01-11 14:46:51 +00:00
parent 307f3935e0
commit 20d75fe041
4 changed files with 254 additions and 1 deletions
+91
View File
@@ -10,6 +10,7 @@ from meshcore_hub.common.schemas.nodes import (
NodeTagCreate,
NodeTagMove,
NodeTagRead,
NodeTagsCopyResult,
NodeTagUpdate,
)
@@ -194,6 +195,72 @@ async def move_node_tag(
return NodeTagRead.model_validate(node_tag)
@router.post(
"/nodes/{public_key}/tags/copy-to/{dest_public_key}",
response_model=NodeTagsCopyResult,
)
async def copy_all_tags(
_: RequireAdmin,
session: DbSession,
public_key: str,
dest_public_key: str,
) -> NodeTagsCopyResult:
"""Copy all tags from one node to another.
Tags that already exist on the destination node are skipped.
"""
# Check if source and destination are the same
if public_key == dest_public_key:
raise HTTPException(
status_code=400,
detail="Source and destination nodes are the same",
)
# Find source node
source_query = select(Node).where(Node.public_key == public_key)
source_node = session.execute(source_query).scalar_one_or_none()
if not source_node:
raise HTTPException(status_code=404, detail="Source node not found")
# Find destination node
dest_query = select(Node).where(Node.public_key == dest_public_key)
dest_node = session.execute(dest_query).scalar_one_or_none()
if not dest_node:
raise HTTPException(status_code=404, detail="Destination node not found")
# Get existing tags on destination node
existing_query = select(NodeTag.key).where(NodeTag.node_id == dest_node.id)
existing_keys = set(session.execute(existing_query).scalars().all())
# Copy tags
copied = 0
skipped_keys = []
for tag in source_node.tags:
if tag.key in existing_keys:
skipped_keys.append(tag.key)
continue
new_tag = NodeTag(
node_id=dest_node.id,
key=tag.key,
value=tag.value,
value_type=tag.value_type,
)
session.add(new_tag)
copied += 1
session.commit()
return NodeTagsCopyResult(
copied=copied,
skipped=len(skipped_keys),
skipped_keys=skipped_keys,
)
@router.delete("/nodes/{public_key}/tags/{key}", status_code=204)
async def delete_node_tag(
_: RequireAdmin,
@@ -220,3 +287,27 @@ async def delete_node_tag(
session.delete(node_tag)
session.commit()
@router.delete("/nodes/{public_key}/tags")
async def delete_all_node_tags(
_: RequireAdmin,
session: DbSession,
public_key: str,
) -> dict:
"""Delete all tags for a node."""
# Find node
node_query = select(Node).where(Node.public_key == public_key)
node = session.execute(node_query).scalar_one_or_none()
if not node:
raise HTTPException(status_code=404, detail="Node not found")
# Count and delete all tags
count = len(node.tags)
for tag in node.tags:
session.delete(tag)
session.commit()
return {"deleted": count}
+10
View File
@@ -49,6 +49,16 @@ class NodeTagMove(BaseModel):
)
class NodeTagsCopyResult(BaseModel):
"""Schema for bulk copy tags result."""
copied: int = Field(..., description="Number of tags copied")
skipped: int = Field(..., description="Number of tags skipped (already exist)")
skipped_keys: list[str] = Field(
default_factory=list, description="Keys of skipped tags"
)
class NodeTagRead(BaseModel):
"""Schema for reading a node tag."""
+76
View File
@@ -315,3 +315,79 @@ async def admin_delete_node_tag(
redirect_url = _build_redirect_url(public_key, error="Failed to delete tag")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/copy-all", response_class=RedirectResponse)
async def admin_copy_all_tags(
request: Request,
public_key: str = Form(...),
dest_public_key: str = Form(...),
) -> RedirectResponse:
"""Copy all tags from one node to another."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.post(
f"/api/v1/nodes/{public_key}/tags/copy-to/{dest_public_key}",
)
if response.status_code == 200:
data = response.json()
copied = data.get("copied", 0)
skipped = data.get("skipped", 0)
if skipped > 0:
message = f"Copied {copied} tag(s), skipped {skipped} existing"
else:
message = f"Copied {copied} tag(s) successfully"
# Redirect to destination node to show copied tags
redirect_url = _build_redirect_url(dest_public_key, message=message)
elif response.status_code == 400:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
elif response.status_code == 404:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to copy tags: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to copy tags")
return RedirectResponse(url=redirect_url, status_code=303)
@router.post("/node-tags/delete-all", response_class=RedirectResponse)
async def admin_delete_all_tags(
request: Request,
public_key: str = Form(...),
) -> RedirectResponse:
"""Delete all tags from a node."""
_check_admin_enabled(request)
_require_auth(request)
try:
response = await request.app.state.http_client.delete(
f"/api/v1/nodes/{public_key}/tags",
)
if response.status_code == 200:
data = response.json()
deleted = data.get("deleted", 0)
message = f"Deleted {deleted} tag(s) successfully"
redirect_url = _build_redirect_url(public_key, message=message)
elif response.status_code == 404:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
else:
redirect_url = _build_redirect_url(
public_key, error=_get_error_detail(response)
)
except Exception as e:
logger.exception("Failed to delete tags: %s", e)
redirect_url = _build_redirect_url(public_key, error="Failed to delete tags")
return RedirectResponse(url=redirect_url, status_code=303)
@@ -71,7 +71,13 @@
<p class="text-sm opacity-70 font-mono">{{ selected_public_key }}</p>
</div>
</div>
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
<div class="flex gap-2">
{% if tags %}
<button class="btn btn-outline btn-sm" onclick="copyAllModal.showModal()">Copy All</button>
<button class="btn btn-outline btn-error btn-sm" onclick="deleteAllModal.showModal()">Delete All</button>
{% endif %}
<a href="/nodes/{{ selected_public_key }}" class="btn btn-ghost btn-sm">View Node</a>
</div>
</div>
</div>
</div>
@@ -292,6 +298,76 @@
</form>
</dialog>
<!-- Copy All Tags Modal -->
<dialog id="copyAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Copy All Tags to Another Node</h3>
<form method="post" action="/a/node-tags/copy-all" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<p class="mb-4">Copy all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong> to another node.</p>
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Destination Node</span>
</label>
<select name="dest_public_key" class="select select-bordered w-full" required>
<option value="">-- Select destination node --</option>
{% for node in nodes %}
{% if node.public_key != selected_public_key %}
<option value="{{ node.public_key }}">
{{ node.name or 'Unnamed' }} ({{ node.public_key[:8] }}...{{ node.public_key[-4:] }})
</option>
{% endif %}
{% endfor %}
</select>
</div>
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Tags that already exist on the destination node will be skipped. Original tags remain on this node.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="copyAllModal.close()">Cancel</button>
<button type="submit" class="btn btn-primary">Copy Tags</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Delete All Tags Modal -->
<dialog id="deleteAllModal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Delete All Tags</h3>
<form method="post" action="/a/node-tags/delete-all" class="py-4">
<input type="hidden" name="public_key" value="{{ selected_public_key }}">
<p class="mb-4">Are you sure you want to delete all {{ tags|length }} tag(s) from <strong>{{ selected_node.name or 'Unnamed' }}</strong>?</p>
<div class="alert alert-error mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>This action cannot be undone. All tags will be permanently deleted.</span>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick="deleteAllModal.close()">Cancel</button>
<button type="submit" class="btn btn-error">Delete All Tags</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
{% elif selected_public_key and not selected_node %}
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">