This commit is contained in:
2026-03-17 18:32:44 +03:00
commit efcd4a8dfd
209 changed files with 33355 additions and 0 deletions
View File
+3
View File
@@ -0,0 +1,3 @@
from app.api.actions.router import router
__all__ = ["router"]
+49
View File
@@ -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)
+21
View File
@@ -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
+47
View File
@@ -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
+92
View File
@@ -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,
)
+79
View File
@@ -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
+13
View File
@@ -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)
+84
View File
@@ -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(),
},
}
+72
View File
@@ -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(),
},
}
+1
View File
@@ -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
+13
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from app.api.executions.router import router
__all__ = ["router"]
+168
View File
@@ -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
+9
View File
@@ -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)
View File
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
router = APIRouter()
@router.get("/ping")
async def ping():
return {"status": "ok"}
+3
View File
@@ -0,0 +1,3 @@
from app.api.pipelines.router import router
__all__ = ["router"]
+114
View File
@@ -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
+52
View File
@@ -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
+54
View File
@@ -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)
+17
View File
@@ -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)
+83
View File
@@ -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,
)
+205
View File
@@ -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,
)
+54
View File
@@ -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": "Пользователь успешно деактивирован"}
+21
View File
@@ -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
+27
View File
@@ -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
+51
View File
@@ -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
+44
View File
@@ -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": "Пароль успешно обновлен"}
+51
View File
@@ -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