fix: resolve config folders API bugs and test infrastructure
- Fix validation error handler to serialize ValueError objects safely
- Add GET /config-folders/{id} endpoint (was missing)
- Fix project overrides API to accept project_id in body instead of query param
- Add flag_modified for SQLAlchemy JSONB change detection
- Fix DELETE endpoint to return 204 status code
- Fix conftest.py to use single SQLite engine per test
- Install aiosqlite dependency
- Fix frontend ToolWorkshopPage tests button names
Config folders tests: 13/13 passing
Docker build tests: 10/10 passing
Readiness probe tests: 13/13 passing
This commit is contained in:
@@ -139,7 +139,7 @@ async def list_config_folders(
|
||||
}
|
||||
|
||||
|
||||
@router.post("", summary="Create config folder", description="Create a new config folder.")
|
||||
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
|
||||
async def create_config_folder(
|
||||
data: ConfigFolderCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
@@ -224,7 +224,7 @@ async def update_config_folder(
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.")
|
||||
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
@@ -239,11 +239,39 @@ async def delete_config_folder(
|
||||
await session.commit()
|
||||
|
||||
|
||||
class ProjectOverrideWithId(ProjectOverrideCreate):
|
||||
project_id: uuid.UUID = Field(description="Project ID for the override")
|
||||
|
||||
|
||||
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
|
||||
async def get_config_folder(
|
||||
folder_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get a config folder by ID."""
|
||||
folder = await session.get(ConfigFolder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"user_id": str(folder.user_id),
|
||||
"name": folder.name,
|
||||
"description": folder.description,
|
||||
"mount_path": folder.mount_path,
|
||||
"files": folder.files,
|
||||
"project_overrides": folder.project_overrides,
|
||||
"is_active": folder.is_active,
|
||||
"created_at": folder.created_at.isoformat() if folder.created_at else None,
|
||||
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
|
||||
async def add_project_override(
|
||||
folder_id: uuid.UUID,
|
||||
project_id: uuid.UUID,
|
||||
data: ProjectOverrideCreate,
|
||||
data: ProjectOverrideWithId,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
@@ -263,7 +291,10 @@ async def add_project_override(
|
||||
if data.files is not None:
|
||||
override_data["files"] = data.files
|
||||
|
||||
folder.project_overrides[str(project_id)] = override_data
|
||||
# Use a copy to trigger SQLAlchemy change detection on JSONB
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
current_overrides[str(data.project_id)] = override_data
|
||||
folder.project_overrides = current_overrides
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
@@ -292,13 +323,19 @@ async def update_project_override(
|
||||
folder.project_overrides = {}
|
||||
|
||||
# Update override
|
||||
override_data = folder.project_overrides.get(str(project_id), {})
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
override_data = current_overrides.get(str(project_id), {})
|
||||
if data.mount_path is not None:
|
||||
override_data["mount_path"] = data.mount_path
|
||||
if data.files is not None:
|
||||
override_data["files"] = data.files
|
||||
|
||||
folder.project_overrides[str(project_id)] = override_data
|
||||
current_overrides[str(project_id)] = override_data
|
||||
folder.project_overrides = current_overrides
|
||||
|
||||
# Mark the field as modified to ensure SQLAlchemy detects the change
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(folder, "project_overrides")
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
@@ -322,6 +359,14 @@ async def remove_project_override(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
|
||||
|
||||
# Remove override if exists
|
||||
if folder.project_overrides and str(project_id) in folder.project_overrides:
|
||||
del folder.project_overrides[str(project_id)]
|
||||
current_overrides = dict(folder.project_overrides or {})
|
||||
if str(project_id) in current_overrides:
|
||||
del current_overrides[str(project_id)]
|
||||
folder.project_overrides = current_overrides
|
||||
await session.commit()
|
||||
await session.refresh(folder)
|
||||
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
"project_overrides": folder.project_overrides or {},
|
||||
}
|
||||
|
||||
+29
-1
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -59,6 +60,32 @@ app.add_middleware(RequestLoggingMiddleware)
|
||||
app.add_middleware(ExceptionLoggingMiddleware)
|
||||
|
||||
|
||||
def _sanitize_validation_errors(errors):
|
||||
"""Convert validation errors to JSON-safe format."""
|
||||
sanitized = []
|
||||
for error in errors:
|
||||
safe_error = {
|
||||
"type": error.get("type"),
|
||||
"loc": error.get("loc"),
|
||||
"msg": error.get("msg"),
|
||||
"input": str(error.get("input")) if error.get("input") is not None else None,
|
||||
}
|
||||
# Convert ctx to safe format
|
||||
ctx = error.get("ctx")
|
||||
if ctx:
|
||||
safe_ctx = {}
|
||||
for key, value in ctx.items():
|
||||
if isinstance(value, Exception):
|
||||
safe_ctx[key] = str(value)
|
||||
elif isinstance(value, (str, int, float, bool, type(None))):
|
||||
safe_ctx[key] = value
|
||||
else:
|
||||
safe_ctx[key] = str(value)
|
||||
safe_error["ctx"] = safe_ctx
|
||||
sanitized.append(safe_error)
|
||||
return sanitized
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""Log validation errors and return detailed response."""
|
||||
@@ -69,9 +96,10 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
|
||||
request.url.path,
|
||||
errors,
|
||||
)
|
||||
safe_errors = _sanitize_validation_errors(errors)
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={"detail": errors},
|
||||
content={"detail": safe_errors},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user