mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-06-28 22:11:17 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user