feat(api): add tag filtering to nodes and templates endpoints

- Add optional `tags` query parameter to `/nodes` and `/templates` endpoints
- Support filtering by tags in format "key:value" with multiple tags ANDed together
- Example: `?tags=vendor:cisco&tags=model:7200` filters nodes/templates with both tags
- Maintain backward compatibility for existing API usage without tags parameter
This commit is contained in:
YueGuobin 2026-02-22 22:52:28 +08:00
parent 56ece7ce88
commit a3616e6d33
6 changed files with 93 additions and 16 deletions

View File

@ -22,10 +22,10 @@ import aiohttp
import asyncio
import ipaddress
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status, Query
from fastapi.encoders import jsonable_encoder
from fastapi.routing import APIRoute
from typing import List, Callable
from typing import List, Callable, Optional
from uuid import UUID
from gns3server.controller import Controller
@ -135,17 +135,50 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends(
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Node.Audit"))]
)
def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]:
def get_nodes(
project: Project = Depends(dep_project),
tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)")
) -> List[schemas.Node]:
"""
Return all nodes belonging to a given project.
Required privilege: Node.Audit
Query Parameters:
- tags: Filter by tags in format "key:value". Multiple tags are ANDed together.
Example: ?tags=vendor:cisco&tags=model:7200
"""
if project.status == "closed":
# allow to retrieve nodes from a closed project
return project.nodes.values()
return [v.asdict() for v in project.nodes.values()]
nodes = list(project.nodes.values())
else:
nodes = [v.asdict() for v in project.nodes.values()]
# Filter by tags if provided
if tags:
filtered_nodes = []
for node in nodes:
node_dict = node.asdict() if hasattr(node, 'asdict') else node
node_tags = node_dict.get("tags") or {}
# Check if all tag filters match
match = True
for tag_filter in tags:
if ":" in tag_filter:
key, value = tag_filter.split(":", 1)
if node_tags.get(key) != value:
match = False
break
else:
# Check if key exists
if tag_filter not in node_tags:
match = False
break
if match:
filtered_nodes.append(node)
nodes = filtered_nodes
return nodes
@router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))])

View File

@ -26,7 +26,7 @@ import logging
log = logging.getLogger(__name__)
from fastapi import APIRouter, Request, HTTPException, Depends, Response, status
from fastapi import APIRouter, Request, HTTPException, Depends, Response, status, Query
from typing import List, Optional
from uuid import UUID
@ -169,15 +169,43 @@ async def delete_template(
async def get_templates(
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)")
) -> List[schemas.Template]:
"""
Return all templates.
Required privilege: Template.Audit
Query Parameters:
- tags: Filter by tags in format "key:value". Multiple tags are ANDed together.
Example: ?tags=vendor:cisco&tags=model:7200
"""
templates = await TemplatesService(templates_repo).get_templates()
# Filter by tags if provided
if tags:
filtered_templates = []
for template in templates:
template_tags = template.get("tags") or {}
# Check if all tag filters match
match = True
for tag_filter in tags:
if ":" in tag_filter:
key, value = tag_filter.split(":", 1)
if template_tags.get(key) != value:
match = False
break
else:
# Check if key exists
if tag_filter not in template_tags:
match = False
break
if match:
filtered_templates.append(template)
templates = filtered_templates
if current_user.is_superadmin:
return templates
else:

View File

@ -97,6 +97,7 @@ class Node:
self._y = 0
self._z = 1 # default z value is 1
self._locked = False
self._tags = {}
self._ports = None
self._symbol = None
self._custom_adapters = []
@ -218,6 +219,17 @@ class Node:
def properties(self, val):
self._properties = val
@property
def tags(self):
return self._tags
@tags.setter
def tags(self, val):
if isinstance(val, dict):
self._tags = val
else:
self._tags = {}
def _base_config_file_content(self, path):
if not os.path.isabs(path):
path = os.path.join(self.project.controller.configs_path(), path)
@ -823,6 +835,7 @@ class Node:
"port_segment_size": self._port_segment_size,
"first_port_name": self._first_port_name,
"custom_adapters": self._custom_adapters,
"tags": self._tags,
}
if topology_dump:

View File

@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType
from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType, JSON
from sqlalchemy.orm import relationship
from .base import BaseTable, generate_uuid, GUID
@ -36,9 +36,7 @@ class Template(BaseTable):
builtin = Column(Boolean, default=False)
usage = Column(String)
template_type = Column(String)
vendor = Column(String)
model = Column(String)
netmiko_device_type = Column(String)
tags = Column(JSON, default='{}')
compute_id = Column(String)
images = relationship("Image", secondary=image_template_map, back_populates="templates")

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import BaseModel, Field, model_validator
from typing import List, Optional, Union, Any
from typing import List, Optional, Union, Any, Dict
from enum import Enum
from uuid import UUID, uuid4
@ -133,6 +133,10 @@ class NodeBase(BaseModel):
port_segment_size: Optional[int] = Field(None, description="Size of the port segment")
first_port_name: Optional[str] = Field(None, description="Name of the first port")
custom_adapters: Optional[List[CustomAdapter]] = None
tags: Optional[Dict[str, str]] = Field(
default_factory=dict,
description="User-defined metadata tags inherited from template or custom"
)
class NodeCreate(NodeBase):

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import ConfigDict, BaseModel, Field
from typing import Optional, Union
from typing import Optional, Union, Dict
from enum import Enum
from uuid import UUID
@ -48,9 +48,10 @@ class TemplateBase(BaseModel):
template_type: Optional[NodeType] = None
compute_id: Optional[str] = None
usage: Optional[str] = ""
vendor: Optional[str] = Field(None, description="Device vendor (e.g., Cisco, Juniper, Huawei)")
model: Optional[str] = Field(None, description="Device model (e.g., ISR4451-X, MX204, NE40E)")
netmiko_device_type: Optional[str] = Field(None, description="Netmiko device type for automation (e.g., cisco_ios, juniper_junos, huawei)")
tags: Optional[Dict[str, str]] = Field(
default_factory=dict,
description="User-defined metadata tags as key-value pairs (e.g., {'vendor': 'cisco', 'model': '7200', 'netmiko_device_type': 'cisco_ios'})"
)
class TemplateCreate(TemplateBase):