feat: implement profile CRUD validation compatibility and defaults API
- Add ConfigProfile CRUD endpoints with user ownership and access checks - Implement ordered include management with cycle detection - Add mount management with path validation (absolute, no traversal) - Implement compatibility-filtered listing by tool type - Add default profile selection APIs (get/set defaults per tool type) - Fix SQLAlchemy ambiguous foreign key relationships in config models - Add comprehensive integration tests (29 tests, all passing) - Merge upstream profile resolver service changes (task 2.1)
This commit is contained in:
@@ -0,0 +1,878 @@
|
||||
"""Config profile API endpoints."""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.config_include import ConfigInclude
|
||||
from src.models.config_mount import ConfigMount
|
||||
from src.models.config_profile import ConfigProfile
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/config-profiles", tags=["config-profiles"])
|
||||
|
||||
MAX_MOUNT_PATH_LENGTH = 1024
|
||||
MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB
|
||||
MAX_INCLUDES_DEPTH = 10
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ConfigProfileCreate(BaseModel):
|
||||
name: str = Field(description="Profile name (unique per user)")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Profile name cannot be empty")
|
||||
if len(v) > 255:
|
||||
raise ValueError("Profile name must be 255 characters or less")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, description="Profile name")
|
||||
description: str | None = Field(default=None, description="Optional description")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Profile name cannot be empty")
|
||||
if len(v) > 255:
|
||||
raise ValueError("Profile name must be 255 characters or less")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigProfileResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
description: str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ConfigProfileDetailResponse(ConfigProfileResponse):
|
||||
includes: list[dict[str, Any]]
|
||||
mounts: list[dict[str, Any]]
|
||||
|
||||
|
||||
class ConfigIncludeCreate(BaseModel):
|
||||
included_profile_id: str = Field(description="UUID of the profile to include")
|
||||
order_index: int = Field(default=0, description="Order index for include resolution")
|
||||
|
||||
|
||||
class ConfigIncludeUpdate(BaseModel):
|
||||
order_index: int = Field(description="Order index for include resolution")
|
||||
|
||||
|
||||
class ConfigIncludeResponse(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
included_profile_id: str
|
||||
included_profile_name: str | None
|
||||
order_index: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class ConfigMountCreate(BaseModel):
|
||||
target_path: str = Field(description="Absolute target path in container")
|
||||
mode: str = Field(default="rw", description="Mount mode (rw or ro)")
|
||||
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
|
||||
order_index: int = Field(default=0, description="Order index for mount resolution")
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str) -> str:
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Target path must be absolute (start with /)")
|
||||
if ".." in v:
|
||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigMountUpdate(BaseModel):
|
||||
target_path: str | None = Field(default=None, description="Absolute target path in container")
|
||||
mode: str | None = Field(default=None, description="Mount mode (rw or ro)")
|
||||
files: dict[str, str] | None = Field(default=None, description="Files as {path: content}")
|
||||
order_index: int | None = Field(default=None, description="Order index for mount resolution")
|
||||
|
||||
@field_validator("target_path")
|
||||
@classmethod
|
||||
def validate_target_path(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("/"):
|
||||
raise ValueError("Target path must be absolute (start with /)")
|
||||
if ".." in v:
|
||||
raise ValueError("Target path cannot contain parent directory references (..)")
|
||||
if len(v) > MAX_MOUNT_PATH_LENGTH:
|
||||
raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less")
|
||||
return v
|
||||
|
||||
|
||||
class ConfigMountResponse(BaseModel):
|
||||
id: str
|
||||
profile_id: str
|
||||
target_path: str
|
||||
mode: str
|
||||
files: dict[str, str] | None
|
||||
order_index: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class DefaultProfilesUpdate(BaseModel):
|
||||
default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _get_owned_profile(
|
||||
profile_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> ConfigProfile:
|
||||
"""Fetch a config profile and verify ownership."""
|
||||
profile = await session.get(ConfigProfile, profile_id)
|
||||
if profile is None or profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="config profile not found",
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
async def _detect_cycle(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
visited: set[uuid.UUID] | None = None,
|
||||
depth: int = 0,
|
||||
) -> bool:
|
||||
"""Detect cycles in profile includes using DFS.
|
||||
|
||||
Returns True if a cycle is detected.
|
||||
"""
|
||||
if depth > MAX_INCLUDES_DEPTH:
|
||||
return True
|
||||
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if profile_id in visited:
|
||||
return True
|
||||
|
||||
visited.add(profile_id)
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigInclude.included_profile_id).where(
|
||||
ConfigInclude.profile_id == profile_id
|
||||
)
|
||||
)
|
||||
included_ids = result.scalars().all()
|
||||
|
||||
for included_id in included_ids:
|
||||
if await _detect_cycle(session, included_id, visited.copy(), depth + 1):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def _validate_includes_no_cycle(
|
||||
session: AsyncSession,
|
||||
profile_id: uuid.UUID,
|
||||
new_included_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Validate that adding an include wouldn't create a cycle."""
|
||||
if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="adding this include would create a circular reference",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profile CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
summary="List config profiles",
|
||||
description="Get all config profiles for the current user. Optionally filter by tool type compatibility.",
|
||||
)
|
||||
async def list_config_profiles(
|
||||
tool_type_id: str | None = None,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List config profiles for the current user."""
|
||||
query = select(ConfigProfile).where(ConfigProfile.user_id == user_id)
|
||||
|
||||
# If tool_type_id is provided, filter to compatible profiles
|
||||
# For now, all profiles are considered compatible with all tool types
|
||||
# since there's no explicit compatibility matrix. Future enhancement:
|
||||
# could filter by profile tags or mount path patterns.
|
||||
if tool_type_id:
|
||||
# Validate the tool type exists
|
||||
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
|
||||
if tool_type is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="tool type not found",
|
||||
)
|
||||
# All profiles are compatible; just return user's profiles
|
||||
pass
|
||||
|
||||
result = await session.execute(query.order_by(ConfigProfile.name))
|
||||
profiles = result.scalars().all()
|
||||
|
||||
return {
|
||||
"profiles": [
|
||||
{
|
||||
"id": str(p.id),
|
||||
"user_id": str(p.user_id),
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
||||
}
|
||||
for p in profiles
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
summary="Create config profile",
|
||||
description="Create a new config profile.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_config_profile(
|
||||
data: ConfigProfileCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Create a config profile."""
|
||||
# Check for duplicate name
|
||||
existing = await session.scalar(
|
||||
select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == user_id,
|
||||
ConfigProfile.name == data.name,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config profile with name '{data.name}' already exists",
|
||||
)
|
||||
|
||||
profile = ConfigProfile(
|
||||
user_id=user_id,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
)
|
||||
session.add(profile)
|
||||
await session.commit()
|
||||
await session.refresh(profile)
|
||||
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/defaults",
|
||||
summary="Get default profiles",
|
||||
description="Get the current user's default profile assignments per tool type.",
|
||||
)
|
||||
async def get_default_profiles(
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get default profiles for the current user."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
|
||||
if user_config is None:
|
||||
return {"default_profiles": {}}
|
||||
|
||||
return {"default_profiles": user_config.default_profiles}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/defaults",
|
||||
summary="Set default profiles",
|
||||
description="Set the current user's default profile assignments per tool type.",
|
||||
)
|
||||
async def set_default_profiles(
|
||||
data: DefaultProfilesUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Set default profiles for the current user."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
|
||||
if user_config is None:
|
||||
user_config = UserConfig(user_id=user_id, config={})
|
||||
session.add(user_config)
|
||||
|
||||
# Validate all profile IDs belong to the user
|
||||
for tool_type_id, profile_id_str in data.default_profiles.items():
|
||||
profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str))
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"profile {profile_id_str} not found",
|
||||
)
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"profile {profile_id_str} does not belong to user",
|
||||
)
|
||||
|
||||
# SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict
|
||||
user_config.config = {**user_config.config, "default_profiles": data.default_profiles}
|
||||
await session.commit()
|
||||
await session.refresh(user_config)
|
||||
|
||||
return {"default_profiles": user_config.default_profiles}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/defaults/{tool_type_id}",
|
||||
summary="Get default profile for tool type",
|
||||
description="Get the default profile ID for a specific tool type.",
|
||||
)
|
||||
async def get_default_profile_for_tool_type(
|
||||
tool_type_id: str,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get default profile for a specific tool type."""
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
user_config = result.scalar_one_or_none()
|
||||
|
||||
if user_config is None:
|
||||
return {"tool_type_id": tool_type_id, "profile_id": None}
|
||||
|
||||
profile_id = user_config.default_profiles.get(tool_type_id)
|
||||
return {"tool_type_id": tool_type_id, "profile_id": profile_id}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{profile_id}",
|
||||
summary="Get config profile",
|
||||
description="Get a config profile with its includes and mounts.",
|
||||
)
|
||||
async def get_config_profile(
|
||||
profile_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Get a config profile with includes and mounts."""
|
||||
profile = await session.get(
|
||||
ConfigProfile,
|
||||
profile_id,
|
||||
options=[
|
||||
selectinload(ConfigProfile.includes),
|
||||
selectinload(ConfigProfile.mounts),
|
||||
],
|
||||
)
|
||||
if profile is None or profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="config profile not found",
|
||||
)
|
||||
|
||||
# Fetch included profile names
|
||||
includes_data = []
|
||||
for inc in profile.includes:
|
||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||
includes_data.append({
|
||||
"id": str(inc.id),
|
||||
"profile_id": str(inc.profile_id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"included_profile_name": included_profile.name if included_profile else None,
|
||||
"order_index": inc.order_index,
|
||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
||||
})
|
||||
|
||||
mounts_data = [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"profile_id": str(m.profile_id),
|
||||
"target_path": m.target_path,
|
||||
"mode": m.mode,
|
||||
"files": m.files,
|
||||
"mode": m.mode,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
||||
}
|
||||
for m in profile.mounts
|
||||
]
|
||||
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"includes": includes_data,
|
||||
"mounts": mounts_data,
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{profile_id}",
|
||||
summary="Update config profile",
|
||||
description="Update an existing config profile.",
|
||||
)
|
||||
async def update_config_profile(
|
||||
profile_id: uuid.UUID,
|
||||
data: ConfigProfileUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a config profile."""
|
||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
if data.name is not None:
|
||||
# Check for duplicate name
|
||||
existing = await session.scalar(
|
||||
select(ConfigProfile).where(
|
||||
ConfigProfile.user_id == user_id,
|
||||
ConfigProfile.name == data.name,
|
||||
ConfigProfile.id != profile_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"config profile with name '{data.name}' already exists",
|
||||
)
|
||||
profile.name = data.name
|
||||
|
||||
if data.description is not None:
|
||||
profile.description = data.description
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(profile)
|
||||
|
||||
return {
|
||||
"id": str(profile.id),
|
||||
"user_id": str(profile.user_id),
|
||||
"name": profile.name,
|
||||
"description": profile.description,
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{profile_id}",
|
||||
summary="Delete config profile",
|
||||
description="Delete a config profile and all its includes and mounts.",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_config_profile(
|
||||
profile_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Delete a config profile."""
|
||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||
await session.delete(profile)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Include management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/{profile_id}/includes",
|
||||
summary="List profile includes",
|
||||
description="Get all includes for a config profile.",
|
||||
)
|
||||
async def list_profile_includes(
|
||||
profile_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List includes for a config profile."""
|
||||
await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigInclude)
|
||||
.where(ConfigInclude.profile_id == profile_id)
|
||||
.order_by(ConfigInclude.order_index)
|
||||
)
|
||||
includes = result.scalars().all()
|
||||
|
||||
includes_data = []
|
||||
for inc in includes:
|
||||
included_profile = await session.get(ConfigProfile, inc.included_profile_id)
|
||||
includes_data.append({
|
||||
"id": str(inc.id),
|
||||
"profile_id": str(inc.profile_id),
|
||||
"included_profile_id": str(inc.included_profile_id),
|
||||
"included_profile_name": included_profile.name if included_profile else None,
|
||||
"order_index": inc.order_index,
|
||||
"created_at": inc.created_at.isoformat() if inc.created_at else None,
|
||||
"updated_at": inc.updated_at.isoformat() if inc.updated_at else None,
|
||||
})
|
||||
|
||||
return {"includes": includes_data}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{profile_id}/includes",
|
||||
summary="Add profile include",
|
||||
description="Add an include to a config profile.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_profile_include(
|
||||
profile_id: uuid.UUID,
|
||||
data: ConfigIncludeCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Add an include to a config profile."""
|
||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
included_profile_id = uuid.UUID(data.included_profile_id)
|
||||
|
||||
# Cannot include self
|
||||
if included_profile_id == profile_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="a profile cannot include itself",
|
||||
)
|
||||
|
||||
# Verify the included profile exists and belongs to the user
|
||||
included_profile = await session.get(ConfigProfile, included_profile_id)
|
||||
if included_profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="included profile not found",
|
||||
)
|
||||
if included_profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="included profile does not belong to user",
|
||||
)
|
||||
|
||||
# Check for duplicate include
|
||||
existing = await session.scalar(
|
||||
select(ConfigInclude).where(
|
||||
ConfigInclude.profile_id == profile_id,
|
||||
ConfigInclude.included_profile_id == included_profile_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="this include already exists",
|
||||
)
|
||||
|
||||
# Validate no cycles
|
||||
await _validate_includes_no_cycle(session, profile_id, included_profile_id)
|
||||
|
||||
include = ConfigInclude(
|
||||
profile_id=profile_id,
|
||||
included_profile_id=included_profile_id,
|
||||
order_index=data.order_index,
|
||||
)
|
||||
session.add(include)
|
||||
await session.commit()
|
||||
await session.refresh(include)
|
||||
|
||||
return {
|
||||
"id": str(include.id),
|
||||
"profile_id": str(include.profile_id),
|
||||
"included_profile_id": str(include.included_profile_id),
|
||||
"included_profile_name": included_profile.name,
|
||||
"order_index": include.order_index,
|
||||
"created_at": include.created_at.isoformat() if include.created_at else None,
|
||||
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{profile_id}/includes/{include_id}",
|
||||
summary="Update profile include",
|
||||
description="Update the order index of a profile include.",
|
||||
)
|
||||
async def update_profile_include(
|
||||
profile_id: uuid.UUID,
|
||||
include_id: uuid.UUID,
|
||||
data: ConfigIncludeUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a profile include."""
|
||||
await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
include = await session.get(ConfigInclude, include_id)
|
||||
if include is None or include.profile_id != profile_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="include not found",
|
||||
)
|
||||
|
||||
include.order_index = data.order_index
|
||||
await session.commit()
|
||||
await session.refresh(include)
|
||||
|
||||
included_profile = await session.get(ConfigProfile, include.included_profile_id)
|
||||
return {
|
||||
"id": str(include.id),
|
||||
"profile_id": str(include.profile_id),
|
||||
"included_profile_id": str(include.included_profile_id),
|
||||
"included_profile_name": included_profile.name if included_profile else None,
|
||||
"order_index": include.order_index,
|
||||
"created_at": include.created_at.isoformat() if include.created_at else None,
|
||||
"updated_at": include.updated_at.isoformat() if include.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{profile_id}/includes/{include_id}",
|
||||
summary="Remove profile include",
|
||||
description="Remove an include from a config profile.",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def remove_profile_include(
|
||||
profile_id: uuid.UUID,
|
||||
include_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Remove an include from a config profile."""
|
||||
await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
include = await session.get(ConfigInclude, include_id)
|
||||
if include is None or include.profile_id != profile_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="include not found",
|
||||
)
|
||||
|
||||
await session.delete(include)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mount management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/{profile_id}/mounts",
|
||||
summary="List profile mounts",
|
||||
description="Get all mounts for a config profile.",
|
||||
)
|
||||
async def list_profile_mounts(
|
||||
profile_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""List mounts for a config profile."""
|
||||
await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
result = await session.execute(
|
||||
select(ConfigMount)
|
||||
.where(ConfigMount.profile_id == profile_id)
|
||||
.order_by(ConfigMount.order_index)
|
||||
)
|
||||
mounts = result.scalars().all()
|
||||
|
||||
return {
|
||||
"mounts": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"profile_id": str(m.profile_id),
|
||||
"target_path": m.target_path,
|
||||
"files": m.files,
|
||||
"mode": m.mode,
|
||||
"order_index": m.order_index,
|
||||
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
||||
}
|
||||
for m in mounts
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{profile_id}/mounts",
|
||||
summary="Add profile mount",
|
||||
description="Add a mount to a config profile.",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_profile_mount(
|
||||
profile_id: uuid.UUID,
|
||||
data: ConfigMountCreate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Add a mount to a config profile."""
|
||||
profile = await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
# Check for duplicate target_path
|
||||
existing = await session.scalar(
|
||||
select(ConfigMount).where(
|
||||
ConfigMount.profile_id == profile_id,
|
||||
ConfigMount.target_path == data.target_path,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"mount with path '{data.target_path}' already exists",
|
||||
)
|
||||
|
||||
mount = ConfigMount(
|
||||
profile_id=profile_id,
|
||||
target_path=data.target_path,
|
||||
mode=data.mode,
|
||||
files=data.files,
|
||||
order_index=data.order_index,
|
||||
)
|
||||
session.add(mount)
|
||||
await session.commit()
|
||||
await session.refresh(mount)
|
||||
|
||||
return {
|
||||
"id": str(mount.id),
|
||||
"profile_id": str(mount.profile_id),
|
||||
"target_path": mount.target_path,
|
||||
"files": mount.files,
|
||||
"mode": mount.mode,
|
||||
"order_index": mount.order_index,
|
||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{profile_id}/mounts/{mount_id}",
|
||||
summary="Update profile mount",
|
||||
description="Update a mount in a config profile.",
|
||||
)
|
||||
async def update_profile_mount(
|
||||
profile_id: uuid.UUID,
|
||||
mount_id: uuid.UUID,
|
||||
data: ConfigMountUpdate,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Update a profile mount."""
|
||||
await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
mount = await session.get(ConfigMount, mount_id)
|
||||
if mount is None or mount.profile_id != profile_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="mount not found",
|
||||
)
|
||||
|
||||
if data.target_path is not None:
|
||||
# Check for duplicate target_path
|
||||
existing = await session.scalar(
|
||||
select(ConfigMount).where(
|
||||
ConfigMount.profile_id == profile_id,
|
||||
ConfigMount.target_path == data.target_path,
|
||||
ConfigMount.id != mount_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"mount with path '{data.target_path}' already exists",
|
||||
)
|
||||
mount.target_path = data.target_path
|
||||
|
||||
if data.files is not None:
|
||||
mount.files = data.files
|
||||
|
||||
if data.order_index is not None:
|
||||
mount.order_index = data.order_index
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(mount)
|
||||
|
||||
return {
|
||||
"id": str(mount.id),
|
||||
"profile_id": str(mount.profile_id),
|
||||
"target_path": mount.target_path,
|
||||
"files": mount.files,
|
||||
"mode": mount.mode,
|
||||
"order_index": mount.order_index,
|
||||
"created_at": mount.created_at.isoformat() if mount.created_at else None,
|
||||
"updated_at": mount.updated_at.isoformat() if mount.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{profile_id}/mounts/{mount_id}",
|
||||
summary="Remove profile mount",
|
||||
description="Remove a mount from a config profile.",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def remove_profile_mount(
|
||||
profile_id: uuid.UUID,
|
||||
mount_id: uuid.UUID,
|
||||
user_id: uuid.UUID = Depends(get_current_user_id),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Remove a mount from a config profile."""
|
||||
await _get_owned_profile(profile_id, user_id, session)
|
||||
|
||||
mount = await session.get(ConfigMount, mount_id)
|
||||
if mount is None or mount.profile_id != profile_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="mount not found",
|
||||
)
|
||||
|
||||
await session.delete(mount)
|
||||
await session.commit()
|
||||
@@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router
|
||||
from src.api.terminal import router as terminal_router
|
||||
from src.api.instance_proxy import router as instance_proxy_router
|
||||
from src.api.config_folders import router as config_folders_router
|
||||
from src.api.config_profiles import router as config_profiles_router
|
||||
from src.api.tool_configs import router as tool_configs_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
@@ -277,6 +278,7 @@ app.include_router(git_repositories_router)
|
||||
app.include_router(user_config_router)
|
||||
app.include_router(tool_types_router)
|
||||
app.include_router(config_folders_router)
|
||||
app.include_router(config_profiles_router)
|
||||
app.include_router(tool_instances_router)
|
||||
app.include_router(tool_configs_router)
|
||||
app.include_router(sessions_router)
|
||||
|
||||
@@ -45,13 +45,14 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
tool_type: Mapped["ToolType | None"] = relationship()
|
||||
includes: Mapped[list["ConfigInclude"]] = relationship(
|
||||
"ConfigInclude",
|
||||
foreign_keys="ConfigInclude.profile_id",
|
||||
primaryjoin="ConfigProfile.id == ConfigInclude.profile_id",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigInclude.order_index",
|
||||
)
|
||||
mounts: Mapped[list["ConfigMount"]] = relationship(
|
||||
"ConfigMount",
|
||||
primaryjoin="ConfigProfile.id == ConfigMount.profile_id",
|
||||
back_populates="profile",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ConfigMount.order_index",
|
||||
|
||||
Reference in New Issue
Block a user