upload
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from app.api.actions.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.actions.dependencies import get_active_action_or_404
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Actions"])
|
||||
|
||||
|
||||
@router.delete("/{action_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_action(
|
||||
action_id: UUID,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
try:
|
||||
action = await get_active_action_or_404(session, action_id, current_user)
|
||||
except HTTPException:
|
||||
log_business_event(
|
||||
"action_delete_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
action_id=str(action_id),
|
||||
reason="action_not_found_or_forbidden",
|
||||
)
|
||||
raise
|
||||
|
||||
action.is_deleted = True
|
||||
await session.commit()
|
||||
await session.refresh(action)
|
||||
|
||||
log_business_event(
|
||||
"action_deleted",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
action_id=str(action.id),
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Action, ActionIngestStatus, User, UserRole
|
||||
|
||||
|
||||
async def get_active_action_or_404(
|
||||
session: AsyncSession,
|
||||
action_id: UUID,
|
||||
current_user: User,
|
||||
) -> Action:
|
||||
action = await session.get(Action, action_id)
|
||||
if action is None or action.is_deleted or action.ingest_status != ActionIngestStatus.SUCCEEDED:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Action not found")
|
||||
if current_user.role != UserRole.ADMIN and action.user_id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Action not found")
|
||||
return action
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.actions.dependencies import get_active_action_or_404
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.action_sch import ActionDetailResponse
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Actions"])
|
||||
|
||||
|
||||
@router.get("/{action_id}", response_model=ActionDetailResponse)
|
||||
async def get_action(
|
||||
action_id: UUID,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
try:
|
||||
action = await get_active_action_or_404(session, action_id, current_user)
|
||||
except HTTPException:
|
||||
log_business_event(
|
||||
"action_fetch_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
action_id=str(action_id),
|
||||
reason="action_not_found_or_forbidden",
|
||||
)
|
||||
raise
|
||||
|
||||
log_business_event(
|
||||
"action_fetched",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
action_id=str(action.id),
|
||||
action_method=action.method.value if action.method is not None else None,
|
||||
action_path=action.path,
|
||||
)
|
||||
return action
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import Action, ActionIngestStatus, User
|
||||
from app.schemas.capability_sch import ActionIngestWithCapabilitiesResponse
|
||||
from app.services.capability_service import CapabilityService
|
||||
from app.services.openapi_service import OpenAPIService
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Actions"])
|
||||
|
||||
|
||||
@router.post("/ingest", response_model=ActionIngestWithCapabilitiesResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def ingest_actions(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
payload = await file.read()
|
||||
try:
|
||||
document = OpenAPIService.load_document(payload)
|
||||
ingestion_result = OpenAPIService.extract_actions_with_failures(document, source_filename=file.filename)
|
||||
except ValueError as exc:
|
||||
log_business_event(
|
||||
"actions_ingest_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
source_filename=file.filename,
|
||||
file_size_bytes=len(payload),
|
||||
reason="invalid_openapi_document",
|
||||
details=str(exc),
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
action_payloads = ingestion_result["succeeded"] + ingestion_result["failed"]
|
||||
if not action_payloads:
|
||||
log_business_event(
|
||||
"actions_ingest_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
source_filename=file.filename,
|
||||
file_size_bytes=len(payload),
|
||||
reason="no_supported_operations",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No supported HTTP operations found in OpenAPI file")
|
||||
|
||||
actions = [Action(user_id=current_user.id, **action_payload) for action_payload in action_payloads]
|
||||
session.add_all(actions)
|
||||
await session.flush()
|
||||
|
||||
succeeded_actions = [action for action in actions if action.ingest_status == ActionIngestStatus.SUCCEEDED]
|
||||
failed_actions = [action for action in actions if action.ingest_status == ActionIngestStatus.FAILED]
|
||||
|
||||
capability_service = CapabilityService(session)
|
||||
capabilities = await capability_service.create_from_actions(
|
||||
succeeded_actions,
|
||||
owner_user_id=current_user.id,
|
||||
refresh=False,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
for action in actions:
|
||||
await session.refresh(action)
|
||||
for capability in capabilities:
|
||||
await session.refresh(capability)
|
||||
|
||||
log_business_event(
|
||||
"actions_ingested",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
source_filename=file.filename,
|
||||
file_size_bytes=len(payload),
|
||||
succeeded_count=len(succeeded_actions),
|
||||
failed_count=len(failed_actions),
|
||||
created_capabilities_count=len(capabilities),
|
||||
)
|
||||
|
||||
return ActionIngestWithCapabilitiesResponse(
|
||||
succeeded_count=len(succeeded_actions),
|
||||
failed_count=len(failed_actions),
|
||||
created_capabilities_count=len(capabilities),
|
||||
succeeded_actions=succeeded_actions,
|
||||
failed_actions=failed_actions,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import Action, ActionIngestStatus, HttpMethod, User, UserRole
|
||||
from app.schemas.action_sch import ActionListItemResponse
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Actions"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ActionListItemResponse], include_in_schema=False)
|
||||
async def list_actions(
|
||||
request: Request,
|
||||
method: HttpMethod | None = Query(default=None),
|
||||
owner_id: UUID | None = Query(default=None),
|
||||
source_filename: str | None = Query(default=None),
|
||||
search: str | None = Query(default=None, min_length=1),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
query = (
|
||||
select(Action)
|
||||
.where(Action.is_deleted.is_(False))
|
||||
.where(Action.ingest_status == ActionIngestStatus.SUCCEEDED)
|
||||
.order_by(Action.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
if current_user.role == UserRole.ADMIN:
|
||||
if owner_id is not None:
|
||||
query = query.where(Action.user_id == owner_id)
|
||||
else:
|
||||
query = query.where(Action.user_id == current_user.id)
|
||||
|
||||
if method is not None:
|
||||
query = query.where(Action.method == method)
|
||||
|
||||
if source_filename:
|
||||
query = query.where(Action.source_filename == source_filename)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Action.operation_id.ilike(search_pattern),
|
||||
Action.path.ilike(search_pattern),
|
||||
Action.summary.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
actions = list(result.scalars().all())
|
||||
|
||||
log_business_event(
|
||||
"actions_listed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
method=method.value if method is not None else None,
|
||||
owner_id=str(owner_id) if owner_id is not None else None,
|
||||
source_filename=source_filename,
|
||||
search=search,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
result_count=len(actions),
|
||||
)
|
||||
|
||||
return actions
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.actions.delete_action import router as delete_action_router
|
||||
from app.api.actions.get_action import router as get_action_router
|
||||
from app.api.actions.ingest_actions import router as ingest_actions_router
|
||||
from app.api.actions.list_actions import router as list_actions_router
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/actions", tags=["Actions"])
|
||||
router.include_router(ingest_actions_router)
|
||||
router.include_router(list_actions_router)
|
||||
router.include_router(get_action_router)
|
||||
router.include_router(delete_action_router)
|
||||
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.auth_sch import LoginIn
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.hashing import verify_password
|
||||
from app.utils.token_manager import create_access_token
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/login", status_code=status.HTTP_200_OK)
|
||||
async def login(
|
||||
data: LoginIn,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email = data.email.strip().lower()
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
result = await session.execute(select(User).where(func.lower(User.email) == email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
log_business_event(
|
||||
"auth_login_failed",
|
||||
trace_id=trace_id,
|
||||
email=email,
|
||||
reason="user_not_found",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"message": "Invalid email or password"},
|
||||
)
|
||||
|
||||
if not verify_password(data.password, user.hashed_password):
|
||||
log_business_event(
|
||||
"auth_login_failed",
|
||||
trace_id=trace_id,
|
||||
email=email,
|
||||
reason="invalid_password",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"message": "Invalid email or password"},
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
log_business_event(
|
||||
"auth_login_blocked",
|
||||
trace_id=trace_id,
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
reason="user_inactive",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail={"message": "User account is deactivated"},
|
||||
)
|
||||
|
||||
token, expires_in = create_access_token(sub=str(user.id), role=user.role.value)
|
||||
log_business_event(
|
||||
"auth_login_succeeded",
|
||||
trace_id=trace_id,
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
role=user.role.value,
|
||||
)
|
||||
|
||||
return {
|
||||
"accessToken": token,
|
||||
"expiresIn": expires_in,
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"fullName": user.full_name,
|
||||
"role": user.role.value,
|
||||
"isActive": user.is_active,
|
||||
"createdAt": user.created_at.isoformat(),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User, UserRole
|
||||
from app.schemas.auth_sch import RegisterIn
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.hashing import hash_password
|
||||
from app.utils.token_manager import create_access_token
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/auth", tags=["Auth"])
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
data: RegisterIn,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
email = data.email.strip().lower()
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
|
||||
result = await session.execute(select(User).where(func.lower(User.email) == email))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
if existing_user is not None:
|
||||
log_business_event(
|
||||
"auth_register_failed",
|
||||
trace_id=trace_id,
|
||||
email=email,
|
||||
reason="email_already_exists",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"message": "Email already exists. Please login."},
|
||||
)
|
||||
|
||||
user = User(
|
||||
email=email,
|
||||
full_name=data.full_name,
|
||||
hashed_password=hash_password(data.password),
|
||||
role=UserRole.USER,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
token, expires_in = create_access_token(sub=str(user.id), role=user.role.value)
|
||||
log_business_event(
|
||||
"auth_register_succeeded",
|
||||
trace_id=trace_id,
|
||||
user_id=str(user.id),
|
||||
email=user.email,
|
||||
role=user.role.value,
|
||||
)
|
||||
|
||||
return {
|
||||
"accessToken": token,
|
||||
"expiresIn": expires_in,
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"fullName": user.full_name,
|
||||
"role": user.role.value,
|
||||
"isActive": user.is_active,
|
||||
"createdAt": user.created_at.isoformat(),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User, UserRole
|
||||
from app.schemas.capability_sch import CapabilityResponse, CreateCompositeCapabilityRequest
|
||||
from app.services.capability_service import (
|
||||
CapabilityService,
|
||||
CompositeRecipeValidationError,
|
||||
)
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Capabilities"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/composite",
|
||||
response_model=CapabilityResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_composite_capability(
|
||||
payload: CreateCompositeCapabilityRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
capability_service = CapabilityService(session)
|
||||
try:
|
||||
capability = await capability_service.create_validated_composite_capability(
|
||||
owner_user_id=current_user.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
input_schema=payload.input_schema,
|
||||
output_schema=payload.output_schema,
|
||||
recipe=payload.recipe.model_dump(mode="python"),
|
||||
include_all=current_user.role == UserRole.ADMIN,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(capability)
|
||||
recipe_dump = payload.recipe.model_dump(mode="python")
|
||||
recipe_steps = recipe_dump.get("steps") if isinstance(recipe_dump, dict) else None
|
||||
log_business_event(
|
||||
"composite_capability_created",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
capability_id=str(capability.id),
|
||||
capability_name=capability.name,
|
||||
recipe_steps_count=len(recipe_steps) if isinstance(recipe_steps, list) else None,
|
||||
)
|
||||
return capability
|
||||
except CompositeRecipeValidationError as exc:
|
||||
await session.rollback()
|
||||
log_business_event(
|
||||
"composite_capability_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
capability_name=payload.name,
|
||||
reason="validation_failed",
|
||||
errors_count=len(exc.errors),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "Composite recipe validation failed",
|
||||
"errors": exc.errors,
|
||||
},
|
||||
) from exc
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Capability, User, UserRole
|
||||
from app.services.capability_service import CapabilityService
|
||||
|
||||
|
||||
async def get_capability_or_404(
|
||||
session: AsyncSession,
|
||||
capability_id: UUID,
|
||||
current_user: User,
|
||||
) -> Capability:
|
||||
capability_service = CapabilityService(session)
|
||||
capability = await capability_service.get_capability(
|
||||
capability_id,
|
||||
owner_user_id=current_user.id,
|
||||
include_all=current_user.role == UserRole.ADMIN,
|
||||
)
|
||||
if capability is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Capability not found")
|
||||
return capability
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.capabilities.dependencies import get_capability_or_404
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.capability_sch import CapabilityResponse
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Capabilities"])
|
||||
|
||||
|
||||
@router.get("/{capability_id}", response_model=CapabilityResponse)
|
||||
async def get_capability(
|
||||
capability_id: UUID,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
try:
|
||||
capability = await get_capability_or_404(session, capability_id, current_user)
|
||||
except HTTPException:
|
||||
log_business_event(
|
||||
"capability_fetch_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
capability_id=str(capability_id),
|
||||
reason="capability_not_found_or_forbidden",
|
||||
)
|
||||
raise
|
||||
|
||||
log_business_event(
|
||||
"capability_fetched",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
capability_id=str(capability.id),
|
||||
capability_type=capability.type.value if hasattr(capability.type, "value") else str(capability.type),
|
||||
)
|
||||
return capability
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User, UserRole
|
||||
from app.schemas.capability_sch import CapabilityResponse
|
||||
from app.services.capability_service import CapabilityService
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Capabilities"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CapabilityResponse])
|
||||
async def list_capabilities(
|
||||
request: Request,
|
||||
action_id: UUID | None = Query(default=None),
|
||||
owner_id: UUID | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
capability_service = CapabilityService(session)
|
||||
action_ids = [action_id] if action_id is not None else None
|
||||
include_all = current_user.role == UserRole.ADMIN
|
||||
owner_user_id = owner_id if include_all and owner_id is not None else current_user.id
|
||||
|
||||
capabilities = await capability_service.get_capabilities(
|
||||
action_ids=action_ids,
|
||||
owner_user_id=owner_user_id,
|
||||
include_all=include_all and owner_id is None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
log_business_event(
|
||||
"capabilities_listed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
owner_id=str(owner_user_id) if owner_user_id is not None else None,
|
||||
action_id=str(action_id) if action_id is not None else None,
|
||||
include_all=include_all and owner_id is None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
result_count=len(capabilities),
|
||||
)
|
||||
|
||||
return capabilities
|
||||
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.capabilities.create_composite_capability import (
|
||||
router as create_composite_capability_router,
|
||||
)
|
||||
from app.api.capabilities.get_capability import router as get_capability_router
|
||||
from app.api.capabilities.list_capabilities import router as list_capabilities_router
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/capabilities", tags=["Capabilities"])
|
||||
router.include_router(list_capabilities_router)
|
||||
router.include_router(create_composite_capability_router)
|
||||
router.include_router(get_capability_router)
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.api.executions.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import ExecutionRun, ExecutionStepRun, Pipeline, User, UserRole
|
||||
from app.schemas.execution_sch import ExecutionRunDetailResponse, ExecutionStepRunResponse
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Executions"])
|
||||
KNOWN_HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
REQUEST_BODY_METHODS = {"POST", "PUT", "PATCH"}
|
||||
|
||||
|
||||
def _extract_method(request_snapshot: dict[str, Any] | None) -> str | None:
|
||||
if not isinstance(request_snapshot, dict):
|
||||
return None
|
||||
|
||||
method_raw = request_snapshot.get("method")
|
||||
if not isinstance(method_raw, str):
|
||||
return None
|
||||
|
||||
method = method_raw.upper()
|
||||
if method in KNOWN_HTTP_METHODS:
|
||||
return method
|
||||
return None
|
||||
|
||||
|
||||
def _extract_status_code(response_snapshot: dict[str, Any] | None) -> int | None:
|
||||
if not isinstance(response_snapshot, dict):
|
||||
return None
|
||||
|
||||
status_code_raw = response_snapshot.get("status_code")
|
||||
if isinstance(status_code_raw, int):
|
||||
return status_code_raw
|
||||
if isinstance(status_code_raw, str) and status_code_raw.isdigit():
|
||||
return int(status_code_raw)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_accepted_payload(
|
||||
*,
|
||||
method: str | None,
|
||||
request_snapshot: dict[str, Any] | None,
|
||||
) -> Any:
|
||||
if method not in REQUEST_BODY_METHODS:
|
||||
return None
|
||||
if not isinstance(request_snapshot, dict):
|
||||
return None
|
||||
return request_snapshot.get("json_body")
|
||||
|
||||
|
||||
def _extract_output_payload(response_snapshot: dict[str, Any] | None) -> Any:
|
||||
if not isinstance(response_snapshot, dict):
|
||||
return None
|
||||
return response_snapshot.get("body")
|
||||
|
||||
|
||||
def _build_step_run_response(step_run: ExecutionStepRun) -> ExecutionStepRunResponse:
|
||||
status_value = step_run.status.value if hasattr(step_run.status, "value") else step_run.status
|
||||
base = ExecutionStepRunResponse(
|
||||
step=step_run.step,
|
||||
name=step_run.name,
|
||||
capability_id=step_run.capability_id,
|
||||
action_id=step_run.action_id,
|
||||
status=status_value,
|
||||
resolved_inputs=step_run.resolved_inputs,
|
||||
request_snapshot=step_run.request_snapshot,
|
||||
response_snapshot=step_run.response_snapshot,
|
||||
error=step_run.error,
|
||||
started_at=step_run.started_at,
|
||||
finished_at=step_run.finished_at,
|
||||
duration_ms=step_run.duration_ms,
|
||||
created_at=step_run.created_at,
|
||||
updated_at=step_run.updated_at,
|
||||
)
|
||||
request_snapshot = base.request_snapshot if isinstance(base.request_snapshot, dict) else None
|
||||
response_snapshot = base.response_snapshot if isinstance(base.response_snapshot, dict) else None
|
||||
method = _extract_method(request_snapshot)
|
||||
status_code = _extract_status_code(response_snapshot)
|
||||
accepted_payload = _extract_accepted_payload(method=method, request_snapshot=request_snapshot)
|
||||
output_payload = _extract_output_payload(response_snapshot)
|
||||
return base.model_copy(
|
||||
update={
|
||||
"method": method,
|
||||
"status_code": status_code,
|
||||
"accepted_payload": accepted_payload,
|
||||
"output_payload": output_payload,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=ExecutionRunDetailResponse)
|
||||
async def get_execution(
|
||||
run_id: UUID,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
run = await session.get(ExecutionRun, run_id)
|
||||
if run is None:
|
||||
log_business_event(
|
||||
"execution_fetch_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
run_id=str(run_id),
|
||||
reason="run_not_found",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Execution run not found")
|
||||
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
is_owner = run.initiated_by == current_user.id
|
||||
if not is_owner and run.initiated_by is None:
|
||||
pipeline = await session.get(Pipeline, run.pipeline_id)
|
||||
is_owner = pipeline is not None and pipeline.created_by == current_user.id
|
||||
if not is_owner:
|
||||
log_business_event(
|
||||
"execution_fetch_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
run_id=str(run.id),
|
||||
pipeline_id=str(run.pipeline_id),
|
||||
reason="run_not_found_or_forbidden",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Execution run not found")
|
||||
|
||||
step_query = (
|
||||
select(ExecutionStepRun)
|
||||
.where(ExecutionStepRun.run_id == run.id)
|
||||
.order_by(ExecutionStepRun.step.asc(), ExecutionStepRun.created_at.asc())
|
||||
)
|
||||
step_result = await session.execute(step_query)
|
||||
step_runs = list(step_result.scalars().all())
|
||||
|
||||
log_business_event(
|
||||
"execution_fetched",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
run_id=str(run.id),
|
||||
pipeline_id=str(run.pipeline_id),
|
||||
result_status=run.status.value,
|
||||
step_count=len(step_runs),
|
||||
)
|
||||
|
||||
return ExecutionRunDetailResponse(
|
||||
id=run.id,
|
||||
pipeline_id=run.pipeline_id,
|
||||
status=run.status.value,
|
||||
inputs=run.inputs or {},
|
||||
summary=run.summary,
|
||||
error=run.error,
|
||||
started_at=run.started_at,
|
||||
finished_at=run.finished_at,
|
||||
created_at=run.created_at,
|
||||
updated_at=run.updated_at,
|
||||
steps=[
|
||||
_build_step_run_response(step_run)
|
||||
for step_run in step_runs
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import ExecutionRun, Pipeline, User, UserRole
|
||||
from app.schemas.execution_sch import ExecutionRunListItemResponse
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Executions"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ExecutionRunListItemResponse])
|
||||
async def list_executions(
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
query = select(ExecutionRun).order_by(ExecutionRun.created_at.desc())
|
||||
|
||||
if current_user.role != UserRole.ADMIN:
|
||||
query = query.join(Pipeline, Pipeline.id == ExecutionRun.pipeline_id).where(
|
||||
or_(
|
||||
ExecutionRun.initiated_by == current_user.id,
|
||||
and_(
|
||||
ExecutionRun.initiated_by.is_(None),
|
||||
Pipeline.created_by == current_user.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
result = await session.execute(query)
|
||||
runs = list(result.scalars().all())
|
||||
log_business_event(
|
||||
"executions_listed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
result_count=len(runs),
|
||||
)
|
||||
return runs
|
||||
@@ -0,0 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.executions.get_execution import router as get_execution_router
|
||||
from app.api.executions.list_executions import router as list_executions_router
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/executions", tags=["Executions"])
|
||||
router.include_router(list_executions_router)
|
||||
router.include_router(get_execution_router)
|
||||
@@ -0,0 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping():
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,3 @@
|
||||
from app.api.pipelines.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.pipeline_chat_sch import PipelineGenerateRequest, PipelineGenerateResponse
|
||||
from app.services.pipeline_dialog_service import DialogAccessError, PipelineDialogService
|
||||
from app.services.pipeline_service import PipelineService
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Pipelines"])
|
||||
|
||||
|
||||
@router.post("/generate", response_model=PipelineGenerateResponse)
|
||||
async def generate_pipeline(
|
||||
payload: PipelineGenerateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
log_business_event(
|
||||
"pipeline_prompt_received",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(payload.dialog_id),
|
||||
message_len=len(payload.message),
|
||||
capability_ids_count=len(payload.capability_ids or []),
|
||||
)
|
||||
|
||||
service = PipelineService(session)
|
||||
dialog_service = PipelineDialogService(session)
|
||||
try:
|
||||
await dialog_service.append_user_message(
|
||||
dialog_id=payload.dialog_id,
|
||||
user_id=current_user.id,
|
||||
content=payload.message,
|
||||
)
|
||||
dialog = await dialog_service.get_dialog(
|
||||
dialog_id=payload.dialog_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
except DialogAccessError as exc:
|
||||
detail = str(exc)
|
||||
log_business_event(
|
||||
"pipeline_prompt_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(payload.dialog_id),
|
||||
reason=detail,
|
||||
)
|
||||
if "denied" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
||||
|
||||
try:
|
||||
result = await service.generate(
|
||||
dialog_id=payload.dialog_id,
|
||||
message=payload.message,
|
||||
user_id=current_user.id,
|
||||
capability_ids=payload.capability_ids,
|
||||
previous_pipeline_id=dialog.last_pipeline_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
if "ollama" in str(exc).lower():
|
||||
message_ru = "Не удалось обратиться к локальной модели Ollama. Проверьте OLLAMA_HOST/OLLAMA_MODEL и повторите запрос."
|
||||
result = {
|
||||
"status": "cannot_build",
|
||||
"message_ru": message_ru,
|
||||
"chat_reply_ru": message_ru,
|
||||
"pipeline_id": None,
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"missing_requirements": ["ollama_unavailable"],
|
||||
"context_summary": None,
|
||||
}
|
||||
else:
|
||||
raise
|
||||
|
||||
response_payload = PipelineGenerateResponse(**result)
|
||||
try:
|
||||
await dialog_service.append_assistant_message(
|
||||
dialog_id=payload.dialog_id,
|
||||
user_id=current_user.id,
|
||||
content=response_payload.chat_reply_ru or response_payload.message_ru,
|
||||
assistant_payload=response_payload.model_dump(mode="json", exclude_none=True),
|
||||
)
|
||||
except DialogAccessError as exc:
|
||||
detail = str(exc)
|
||||
log_business_event(
|
||||
"pipeline_prompt_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(payload.dialog_id),
|
||||
reason=detail,
|
||||
)
|
||||
if "denied" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
||||
|
||||
log_business_event(
|
||||
"pipeline_prompt_processed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(payload.dialog_id),
|
||||
result_status=response_payload.status,
|
||||
pipeline_id=str(response_payload.pipeline_id) if response_payload.pipeline_id else None,
|
||||
)
|
||||
|
||||
return response_payload
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.pipeline_chat_sch import (
|
||||
PipelineDialogHistoryResponse,
|
||||
PipelineDialogMessageResponse,
|
||||
)
|
||||
from app.services.pipeline_dialog_service import DialogAccessError, PipelineDialogService
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Pipelines"])
|
||||
|
||||
|
||||
@router.get("/dialogs/{dialog_id}/history", response_model=PipelineDialogHistoryResponse)
|
||||
async def get_pipeline_dialog_history(
|
||||
dialog_id: UUID,
|
||||
request: Request,
|
||||
limit: int = Query(default=30, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
dialog_service = PipelineDialogService(session)
|
||||
try:
|
||||
dialog, messages = await dialog_service.get_history(
|
||||
dialog_id=dialog_id,
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
except DialogAccessError as exc:
|
||||
detail = str(exc)
|
||||
log_business_event(
|
||||
"pipeline_dialog_history_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(dialog_id),
|
||||
reason=detail,
|
||||
)
|
||||
if "denied" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
||||
|
||||
response = PipelineDialogHistoryResponse(
|
||||
dialog_id=dialog.id,
|
||||
title=dialog.title,
|
||||
messages=[
|
||||
PipelineDialogMessageResponse(
|
||||
id=message.id,
|
||||
role=message.role.value,
|
||||
content=message.content,
|
||||
assistant_payload=message.assistant_payload,
|
||||
created_at=message.created_at,
|
||||
)
|
||||
for message in messages
|
||||
],
|
||||
)
|
||||
|
||||
log_business_event(
|
||||
"pipeline_dialog_history_viewed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(dialog.id),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
message_count=len(response.messages),
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.pipeline_chat_sch import PipelineDialogListItemResponse
|
||||
from app.services.pipeline_dialog_service import PipelineDialogService
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Pipelines"])
|
||||
|
||||
|
||||
@router.get("/dialogs", response_model=list[PipelineDialogListItemResponse])
|
||||
async def list_pipeline_dialogs(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
dialog_service = PipelineDialogService(session)
|
||||
dialogs = await dialog_service.list_dialogs(
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
response = [
|
||||
PipelineDialogListItemResponse(
|
||||
dialog_id=dialog.id,
|
||||
title=dialog.title,
|
||||
last_status=dialog.last_status,
|
||||
last_pipeline_id=dialog.last_pipeline_id,
|
||||
last_message_preview=dialog.last_message_preview,
|
||||
created_at=dialog.created_at,
|
||||
updated_at=dialog.updated_at,
|
||||
)
|
||||
for dialog in dialogs
|
||||
]
|
||||
log_business_event(
|
||||
"pipeline_dialogs_listed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
result_count=len(response),
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.pipeline_chat_sch import DialogResetRequest, DialogResetResponse
|
||||
from app.services.pipeline_dialog_service import DialogAccessError, PipelineDialogService
|
||||
from app.services.pipeline_service import PipelineService
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Pipelines"])
|
||||
|
||||
|
||||
@router.post("/dialog/reset", response_model=DialogResetResponse)
|
||||
async def reset_pipeline_dialog(
|
||||
payload: DialogResetRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
dialog_service = PipelineDialogService(session)
|
||||
try:
|
||||
await dialog_service.get_dialog(
|
||||
dialog_id=payload.dialog_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
except DialogAccessError as exc:
|
||||
detail = str(exc)
|
||||
log_business_event(
|
||||
"pipeline_dialog_reset_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(payload.dialog_id),
|
||||
reason=detail,
|
||||
)
|
||||
if "denied" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=detail) from exc
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
||||
|
||||
service = PipelineService(session)
|
||||
result = await service.reset_dialog(payload.dialog_id)
|
||||
log_business_event(
|
||||
"pipeline_dialog_reset",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
dialog_id=str(payload.dialog_id),
|
||||
result_status=result.get("status") if isinstance(result, dict) else None,
|
||||
)
|
||||
return DialogResetResponse(**result)
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.pipelines.generate import router as generate_router
|
||||
from app.api.pipelines.get_dialog_history import router as get_dialog_history_router
|
||||
from app.api.pipelines.list_dialogs import router as list_dialogs_router
|
||||
from app.api.pipelines.reset_dialog import router as reset_dialog_router
|
||||
from app.api.pipelines.run import router as run_router
|
||||
from app.api.pipelines.update_graph import router as update_graph_router
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/pipelines", tags=["Pipelines"])
|
||||
router.include_router(generate_router)
|
||||
router.include_router(list_dialogs_router)
|
||||
router.include_router(get_dialog_history_router)
|
||||
router.include_router(reset_dialog_router)
|
||||
router.include_router(run_router)
|
||||
router.include_router(update_graph_router)
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import Pipeline, User, UserRole
|
||||
from app.schemas.execution_sch import RunPipelineRequest, RunPipelineResponse
|
||||
from app.services.execution_service import ExecutionService, ExecutionServiceError
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Pipelines"])
|
||||
|
||||
|
||||
@router.post("/{pipeline_id}/run", response_model=RunPipelineResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
async def run_pipeline(
|
||||
pipeline_id: UUID,
|
||||
payload: RunPipelineRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
pipeline = await session.get(Pipeline, pipeline_id)
|
||||
if pipeline is None:
|
||||
log_business_event(
|
||||
"pipeline_run_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline_id),
|
||||
reason="pipeline_not_found",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pipeline not found")
|
||||
|
||||
if current_user.role != UserRole.ADMIN and pipeline.created_by != current_user.id:
|
||||
log_business_event(
|
||||
"pipeline_run_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline_id),
|
||||
reason="pipeline_not_owned",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pipeline not found")
|
||||
|
||||
service = ExecutionService(session)
|
||||
try:
|
||||
run = await service.create_run(
|
||||
pipeline_id=pipeline_id,
|
||||
inputs=payload.inputs,
|
||||
initiated_by=current_user.id,
|
||||
)
|
||||
except ExecutionServiceError as exc:
|
||||
message = str(exc)
|
||||
log_business_event(
|
||||
"pipeline_run_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline_id),
|
||||
reason=message,
|
||||
)
|
||||
if "not found" in message.lower():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message) from exc
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message) from exc
|
||||
|
||||
ExecutionService.start_background_execution(run.id)
|
||||
log_business_event(
|
||||
"pipeline_run_started",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(run.pipeline_id),
|
||||
run_id=str(run.id),
|
||||
inputs_count=len(payload.inputs or {}),
|
||||
)
|
||||
|
||||
return RunPipelineResponse(
|
||||
run_id=run.id,
|
||||
pipeline_id=run.pipeline_id,
|
||||
status=run.status.value,
|
||||
)
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import Pipeline, User, UserRole
|
||||
from app.schemas.pipeline_chat_sch import (
|
||||
PipelineGraphUpdateRequest,
|
||||
PipelineGraphUpdateResponse,
|
||||
)
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
|
||||
router = APIRouter(tags=["Pipelines"])
|
||||
|
||||
|
||||
def _graph_has_cycle(steps: set[int], edges: list[dict[str, int | str]]) -> bool:
|
||||
adjacency: dict[int, set[int]] = {step: set() for step in steps}
|
||||
for edge in edges:
|
||||
src = edge["from_step"]
|
||||
dst = edge["to_step"]
|
||||
if isinstance(src, int) and isinstance(dst, int):
|
||||
adjacency.setdefault(src, set()).add(dst)
|
||||
|
||||
visiting: set[int] = set()
|
||||
visited: set[int] = set()
|
||||
|
||||
def dfs(step: int) -> bool:
|
||||
if step in visiting:
|
||||
return True
|
||||
if step in visited:
|
||||
return False
|
||||
visiting.add(step)
|
||||
for neighbor in adjacency.get(step, set()):
|
||||
if dfs(neighbor):
|
||||
return True
|
||||
visiting.remove(step)
|
||||
visited.add(step)
|
||||
return False
|
||||
|
||||
return any(dfs(step) for step in adjacency)
|
||||
|
||||
|
||||
def _sync_node_connections(
|
||||
nodes: list[dict[str, object]],
|
||||
edges: list[dict[str, int | str]],
|
||||
) -> None:
|
||||
incoming_by_step: dict[int, set[int]] = defaultdict(set)
|
||||
outgoing_by_step: dict[int, set[int]] = defaultdict(set)
|
||||
incoming_types_by_step: dict[int, set[tuple[int, str]]] = defaultdict(set)
|
||||
|
||||
for edge in edges:
|
||||
src = edge.get("from_step")
|
||||
dst = edge.get("to_step")
|
||||
edge_type = edge.get("type")
|
||||
if not isinstance(src, int) or not isinstance(dst, int) or not isinstance(edge_type, str):
|
||||
continue
|
||||
|
||||
outgoing_by_step[src].add(dst)
|
||||
incoming_by_step[dst].add(src)
|
||||
incoming_types_by_step[dst].add((src, edge_type))
|
||||
|
||||
for node in nodes:
|
||||
step = node.get("step")
|
||||
if not isinstance(step, int):
|
||||
node["input_connected_from"] = []
|
||||
node["output_connected_to"] = []
|
||||
node["input_data_type_from_previous"] = []
|
||||
continue
|
||||
|
||||
node["input_connected_from"] = sorted(incoming_by_step.get(step, set()))
|
||||
node["output_connected_to"] = sorted(outgoing_by_step.get(step, set()))
|
||||
node["input_data_type_from_previous"] = [
|
||||
{"from_step": src, "type": edge_type}
|
||||
for src, edge_type in sorted(incoming_types_by_step.get(step, set()))
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{pipeline_id}/graph", response_model=PipelineGraphUpdateResponse)
|
||||
async def update_pipeline_graph(
|
||||
pipeline_id: UUID,
|
||||
payload: PipelineGraphUpdateRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
|
||||
pipeline = await session.get(Pipeline, pipeline_id)
|
||||
if pipeline is None:
|
||||
log_business_event(
|
||||
"pipeline_graph_update_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline_id),
|
||||
reason="pipeline_not_found",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pipeline not found")
|
||||
|
||||
if current_user.role != UserRole.ADMIN and pipeline.created_by != current_user.id:
|
||||
log_business_event(
|
||||
"pipeline_graph_update_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline_id),
|
||||
reason="pipeline_not_owned",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pipeline not found")
|
||||
|
||||
nodes = [node.model_dump(mode="json") for node in payload.nodes]
|
||||
edges = [edge.model_dump(mode="json") for edge in payload.edges]
|
||||
|
||||
validation_errors: list[str] = []
|
||||
steps: set[int] = set()
|
||||
for node in nodes:
|
||||
step = node.get("step")
|
||||
if not isinstance(step, int):
|
||||
validation_errors.append("graph: invalid_step")
|
||||
continue
|
||||
if step in steps:
|
||||
validation_errors.append(f"graph: duplicate_node_step:{step}")
|
||||
continue
|
||||
steps.add(step)
|
||||
|
||||
normalized_edges: list[dict[str, int | str]] = []
|
||||
seen_edges: set[tuple[int, int, str]] = set()
|
||||
|
||||
for edge in edges:
|
||||
src = edge.get("from_step")
|
||||
dst = edge.get("to_step")
|
||||
edge_type = str(edge.get("type") or "").strip()
|
||||
|
||||
if not isinstance(src, int) or not isinstance(dst, int):
|
||||
validation_errors.append("graph: invalid_edge_reference")
|
||||
continue
|
||||
|
||||
if src not in steps or dst not in steps:
|
||||
validation_errors.append(f"graph: edge_to_missing_node:{src}->{dst}")
|
||||
continue
|
||||
|
||||
if src == dst:
|
||||
validation_errors.append(f"graph: self_loop:{src}")
|
||||
continue
|
||||
|
||||
if not edge_type:
|
||||
validation_errors.append("graph: invalid_edge_type")
|
||||
continue
|
||||
|
||||
edge_key = (src, dst, edge_type)
|
||||
if edge_key in seen_edges:
|
||||
validation_errors.append(
|
||||
f"graph: duplicate_edge:{src}->{dst}:{edge_type}"
|
||||
)
|
||||
continue
|
||||
|
||||
seen_edges.add(edge_key)
|
||||
normalized_edges.append({"from_step": src, "to_step": dst, "type": edge_type})
|
||||
|
||||
if normalized_edges and _graph_has_cycle(steps, normalized_edges):
|
||||
validation_errors.append("graph: cycle")
|
||||
|
||||
if validation_errors:
|
||||
log_business_event(
|
||||
"pipeline_graph_update_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline_id),
|
||||
reason="invalid_graph",
|
||||
errors=sorted(set(validation_errors)),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"message": "Invalid pipeline graph",
|
||||
"errors": sorted(set(validation_errors)),
|
||||
},
|
||||
)
|
||||
|
||||
_sync_node_connections(nodes, normalized_edges)
|
||||
|
||||
pipeline.nodes = nodes
|
||||
pipeline.edges = normalized_edges
|
||||
await session.commit()
|
||||
await session.refresh(pipeline)
|
||||
|
||||
log_business_event(
|
||||
"pipeline_graph_updated",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
pipeline_id=str(pipeline.id),
|
||||
nodes_count=len(nodes),
|
||||
edges_count=len(normalized_edges),
|
||||
)
|
||||
|
||||
return PipelineGraphUpdateResponse(
|
||||
pipeline_id=pipeline.id,
|
||||
nodes=pipeline.nodes,
|
||||
edges=pipeline.edges,
|
||||
updated_at=pipeline.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User, UserRole
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
router = APIRouter(tags=["Users"])
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_200_OK)
|
||||
async def delete_user(
|
||||
user_id: UUID,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
|
||||
if current_user.role != UserRole.ADMIN and current_user.id != user_id:
|
||||
log_business_event(
|
||||
"user_deactivation_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
target_user_id=str(user_id),
|
||||
reason="forbidden",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Нет доступа")
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
log_business_event(
|
||||
"user_deactivation_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
target_user_id=str(user_id),
|
||||
reason="target_user_not_found",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
user.is_active = False
|
||||
await session.commit()
|
||||
|
||||
log_business_event(
|
||||
"user_deactivated",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
target_user_id=str(user.id),
|
||||
)
|
||||
|
||||
return {"message": "Пользователь успешно деактивирован"}
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.models import User
|
||||
from app.schemas.users_sch import UserResponse
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
router = APIRouter(tags=["Users"])
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
log_business_event(
|
||||
"user_profile_viewed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
)
|
||||
return current_user
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User, UserRole
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import check_permissions
|
||||
from app.schemas.users_sch import UserResponse
|
||||
|
||||
router = APIRouter(tags=["Users"])
|
||||
|
||||
@router.get("/", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(check_permissions([UserRole.ADMIN])),
|
||||
):
|
||||
result = await session.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
log_business_event(
|
||||
"users_listed",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
result_count=len(users),
|
||||
)
|
||||
return users
|
||||
@@ -0,0 +1,51 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.users_sch import UserResponse, UserUpdateMe
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
router = APIRouter(tags=["Users"])
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
data: UserUpdateMe,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
|
||||
if data.email and data.email != current_user.email:
|
||||
stmt = select(User).where(User.email == data.email)
|
||||
result = await session.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
log_business_event(
|
||||
"user_profile_update_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
reason="email_already_exists",
|
||||
requested_email=data.email,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Пользователь с таким email уже существует",
|
||||
)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(current_user, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(current_user)
|
||||
log_business_event(
|
||||
"user_profile_updated",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
updated_fields=sorted(update_data.keys()),
|
||||
)
|
||||
return current_user
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User
|
||||
from app.schemas.users_sch import PasswordUpdate
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.hashing import hash_password, verify_password
|
||||
from app.utils.token_manager import get_current_user
|
||||
|
||||
router = APIRouter(tags=["Users"])
|
||||
|
||||
|
||||
@router.patch("/me/password", status_code=status.HTTP_200_OK)
|
||||
async def update_password(
|
||||
data: PasswordUpdate,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
|
||||
if not verify_password(data.old_password, current_user.hashed_password):
|
||||
log_business_event(
|
||||
"user_password_update_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
reason="invalid_current_password",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный текущий пароль",
|
||||
)
|
||||
|
||||
current_user.hashed_password = hash_password(data.new_password)
|
||||
await session.commit()
|
||||
|
||||
log_business_event(
|
||||
"user_password_updated",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
)
|
||||
|
||||
return {"message": "Пароль успешно обновлен"}
|
||||
@@ -0,0 +1,51 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.session import get_session
|
||||
from app.models import User, UserRole
|
||||
from app.schemas.users_sch import UserResponse, UserUpdate
|
||||
from app.utils.business_logger import log_business_event
|
||||
from app.utils.token_manager import check_permissions
|
||||
|
||||
router = APIRouter(tags=["Users"])
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: UUID,
|
||||
data: UserUpdate,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(check_permissions([UserRole.ADMIN])),
|
||||
):
|
||||
trace_id = getattr(request.state, "traceId", None)
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
log_business_event(
|
||||
"user_update_rejected",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
target_user_id=str(user_id),
|
||||
reason="target_user_not_found",
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
log_business_event(
|
||||
"user_updated",
|
||||
trace_id=trace_id,
|
||||
user_id=str(current_user.id),
|
||||
target_user_id=str(user.id),
|
||||
updated_fields=sorted(update_data.keys()),
|
||||
)
|
||||
|
||||
return user
|
||||
Reference in New Issue
Block a user