diff --git a/src/meshcore_hub/api/routes/node_tags.py b/src/meshcore_hub/api/routes/node_tags.py index 30d27dc..204e09a 100644 --- a/src/meshcore_hub/api/routes/node_tags.py +++ b/src/meshcore_hub/api/routes/node_tags.py @@ -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} diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py index b5609a5..8f05575 100644 --- a/src/meshcore_hub/common/schemas/nodes.py +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -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.""" diff --git a/src/meshcore_hub/web/routes/admin.py b/src/meshcore_hub/web/routes/admin.py index 0583c0c..90d0139 100644 --- a/src/meshcore_hub/web/routes/admin.py +++ b/src/meshcore_hub/web/routes/admin.py @@ -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) diff --git a/src/meshcore_hub/web/templates/admin/node_tags.html b/src/meshcore_hub/web/templates/admin/node_tags.html index c7f495f..36639bc 100644 --- a/src/meshcore_hub/web/templates/admin/node_tags.html +++ b/src/meshcore_hub/web/templates/admin/node_tags.html @@ -71,7 +71,13 @@

{{ selected_public_key }}

- View Node +
+ {% if tags %} + + + {% endif %} + View Node +
@@ -292,6 +298,76 @@ + + + + + + + + + + + + {% elif selected_public_key and not selected_node %}