refactor: slim config_profiles router

- Extract CRUD helpers to services/config/crud_service.py
- Extract resolver/default logic to services/config/resolver_service.py
- Slim config_profiles.py from 842 to 474 lines

Quality gates: py_compile passes
This commit is contained in:
Developer
2026-06-05 19:55:09 +00:00
parent 9503f6cb4f
commit de6a6a3b00
3 changed files with 413 additions and 416 deletions
@@ -0,0 +1,157 @@
"""Config profile CRUD service functions."""
import uuid
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.models import ConfigProfile, ConfigProfileInclude, ToolType, UserConfig
from src.models.project import Project
MAX_PROFILE_SIZE_MB = 10
MAX_PROFILE_SIZE_BYTES = MAX_PROFILE_SIZE_MB * 1024 * 1024
def calculate_profile_size(data: dict) -> int:
"""Calculate approximate serialized size of profile data."""
total = 0
for key, value in data.get("env_vars", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for key, value in data.get("runtime_hints", {}).items():
total += len(key.encode("utf-8")) + len(str(value).encode("utf-8"))
for mount in data.get("mounts", []):
total += len(str(mount.get("target", "")).encode("utf-8"))
total += len(str(mount.get("mode", "")).encode("utf-8"))
for path, content in mount.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
for path, content in data.get("files", {}).items():
total += len(path.encode("utf-8")) + len(content.encode("utf-8"))
return total
async def get_profile_with_includes(
session: AsyncSession, profile_id: uuid.UUID
) -> ConfigProfile | None:
"""Fetch a profile with includes eagerly loaded."""
result = await session.execute(
select(ConfigProfile)
.where(ConfigProfile.id == profile_id)
.options(selectinload(ConfigProfile.includes))
)
return result.scalar_one_or_none()
async def check_access(
session: AsyncSession,
user_id: uuid.UUID,
project_id: uuid.UUID | None = None,
tool_type_id: uuid.UUID | None = None,
) -> None:
"""Verify user has access to referenced project and tool type."""
if project_id is not None:
project = await session.get(Project, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
if tool_type_id is not None:
tool_type = await session.get(ToolType, tool_type_id)
if tool_type is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Tool type not found"
)
async def validate_git_mounts(
session: AsyncSession,
user_id: uuid.UUID,
git_mounts: list[Any],
project_id: uuid.UUID | None = None,
) -> None:
"""Validate git mount URLs."""
for mount in git_mounts:
remote_url = mount.get("remote_url")
if not remote_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Git mount missing remote_url",
)
if not remote_url.startswith(("http://", "https://", "git@", "ssh://")):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid git URL: {remote_url}",
)
def profile_to_response(
profile: ConfigProfile, includes: list[ConfigProfileInclude] | None = None
) -> dict:
return {
"id": str(profile.id),
"user_id": str(profile.user_id),
"name": profile.name,
"description": profile.description,
"project_id": str(profile.project_id) if profile.project_id else None,
"tool_type_id": str(profile.tool_type_id) if profile.tool_type_id else None,
"env_vars": profile.env_vars or {},
"runtime_hints": profile.runtime_hints or {},
"mounts": profile.mounts or [],
"git_mounts": profile.git_mounts or [],
"files": profile.files or {},
"is_default": profile.is_default,
"includes": [
{
"id": str(inc.id),
"included_profile_id": str(inc.included_profile_id),
"order_index": inc.order_index,
}
for inc in (includes or profile.includes)
],
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
async def get_or_create_user_config(
session: AsyncSession,
user_id: uuid.UUID,
) -> UserConfig:
"""Get existing user config or create a new one."""
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)
return user_config
async def validate_default_profiles(
session: AsyncSession,
user_id: uuid.UUID,
default_profiles: dict[str, str],
) -> None:
"""Validate that all profile IDs in default_profiles belong to the user."""
for tool_type_id, profile_id_str in default_profiles.items():
try:
profile_uuid = uuid.UUID(profile_id_str)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid profile ID for tool type {tool_type_id}: {profile_id_str}",
)
profile = await session.get(ConfigProfile, profile_uuid)
if profile is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Profile not found: {profile_id_str}",
)
if profile.user_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Profile does not belong to user: {profile_id_str}",
)