feat(tags): add bulk tagging and tag-based filtering
Some checks failed
CI / lint-and-test (push) Failing after 1m24s

- Add include_tags/exclude_tags query params to /api/events
- Add POST /api/events/bulk-tags endpoint with append/replace modes
- Frontend: add Include tags / Exclude tags filter inputs
- Frontend: add Bulk tag matching button with prompt for tag and mode
- Update filter layout to accommodate new tag fields
- Add tests for tag filtering and bulk tag append/replace
This commit is contained in:
2026-04-16 18:50:57 +02:00
parent 6d00d7cf32
commit 3761aa6d74
4 changed files with 219 additions and 12 deletions

View File

@@ -8,6 +8,7 @@ from bson import ObjectId
from database import events_collection
from fastapi import APIRouter, Depends, HTTPException, Query
from models.api import (
BulkTagsRequest,
CommentAddRequest,
FilterOptionsResponse,
PaginatedEventResponse,
@@ -31,10 +32,9 @@ def _decode_cursor(cursor: str) -> tuple[str, str]:
raise HTTPException(status_code=400, detail="Invalid cursor") from exc
@router.get("/events", response_model=PaginatedEventResponse)
def list_events(
def _build_query(
service: str | None = None,
services: list[str] | None = Query(default=None),
services: list[str] | None = None,
actor: str | None = None,
operation: str | None = None,
result: str | None = None,
@@ -42,9 +42,9 @@ def list_events(
end: str | None = None,
search: str | None = None,
cursor: str | None = None,
page_size: int = Query(default=50, ge=1, le=500),
user: dict = Depends(require_auth),
):
include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None,
) -> dict:
filters = []
if service:
@@ -87,6 +87,10 @@ def list_events(
]
}
)
if include_tags:
filters.append({"tags": {"$all": include_tags}})
if exclude_tags:
filters.append({"tags": {"$not": {"$all": exclude_tags}}})
if cursor:
try:
@@ -102,7 +106,38 @@ def list_events(
}
)
query = {"$and": filters} if filters else {}
return {"$and": filters} if filters else {}
@router.get("/events", response_model=PaginatedEventResponse)
def list_events(
service: str | None = None,
services: list[str] | None = Query(default=None),
actor: str | None = None,
operation: str | None = None,
result: str | None = None,
start: str | None = None,
end: str | None = None,
search: str | None = None,
cursor: str | None = None,
page_size: int = Query(default=50, ge=1, le=500),
include_tags: list[str] | None = Query(default=None),
exclude_tags: list[str] | None = Query(default=None),
user: dict = Depends(require_auth),
):
query = _build_query(
service=service,
services=services,
actor=actor,
operation=operation,
result=result,
start=start,
end=end,
search=search,
cursor=cursor,
include_tags=include_tags,
exclude_tags=exclude_tags,
)
safe_page_size = max(1, min(page_size, 500))
@@ -138,6 +173,51 @@ def list_events(
}
@router.post("/events/bulk-tags")
def bulk_tags(
body: BulkTagsRequest,
service: str | None = None,
services: list[str] | None = Query(default=None),
actor: str | None = None,
operation: str | None = None,
result: str | None = None,
start: str | None = None,
end: str | None = None,
search: str | None = None,
include_tags: list[str] | None = Query(default=None),
exclude_tags: list[str] | None = Query(default=None),
user: dict = Depends(require_auth),
):
query = _build_query(
service=service,
services=services,
actor=actor,
operation=operation,
result=result,
start=start,
end=end,
search=search,
include_tags=include_tags,
exclude_tags=exclude_tags,
)
tags = [t.strip() for t in body.tags if t.strip()]
if not tags:
raise HTTPException(status_code=400, detail="No tags provided")
if body.mode == "replace":
update = {"$set": {"tags": tags}}
else:
update = {"$addToSet": {"tags": {"$each": tags}}}
try:
result_obj = events_collection.update_many(query, update)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to update tags: {exc}") from exc
log_action("bulk_tags", "/api/events/bulk-tags", {"tags": tags, "mode": body.mode, "matched": result_obj.matched_count}, user.get("sub", "anonymous"))
return {"matched": result_obj.matched_count, "modified": result_obj.modified_count}
@router.get("/filter-options", response_model=FilterOptionsResponse)
def filter_options(limit: int = Query(default=200, ge=1, le=1000)):
safe_limit = max(1, min(limit, 1000))