Files
portfolio-hub/app/api/portfolios.py
T

332 lines
11 KiB
Python

"""Portfolio API routes."""
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from app.core.auth import AuthContext, Permission, authorize_request
from app.core.config import Settings, get_settings
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.portfolio import (
BuyRequest,
CommentCreateRequest,
CommentResponse,
CommentUpdateRequest,
CreatePortfolioRequest,
InvestRequest,
PortfolioDetailResponse,
PortfolioListItem,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
UpdatePortfolioRequest,
)
from app.services.portfolio_service import PortfolioService
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
def _raise_portfolio_error(exc: ValueError) -> None:
detail = str(exc)
if "찾을 수 없습니다" in detail:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
def _get_service(settings: Settings) -> tuple[PortfolioService, object]:
repository = get_portfolio_repository_from_settings(settings)
return PortfolioService(repository), repository
def _close_repository(repository: object) -> None:
if callable(getattr(type(repository), "close", None)):
repository.close()
def _ownership_required(settings: Settings) -> bool:
return settings.db_enabled
def _require_owner_context(auth_context: AuthContext) -> str:
if auth_context.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ownership context is required")
return auth_context.user_id
def _ensure_portfolio_owned(repository: object, portfolio_id: str, auth_context: AuthContext) -> None:
if auth_context.permission == Permission.ADMIN:
return
owner_user_id = _require_owner_context(auth_context)
try:
portfolio = repository.get(portfolio_id)
except ValueError as exc:
_raise_portfolio_error(exc)
if portfolio.owner_user_id != owner_user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
@router.get("", response_model=list[PortfolioListItem])
def list_portfolios(
request: Request,
broker: str | None = None,
status_filter: str | None = None,
settings: Settings = Depends(get_settings),
) -> list[PortfolioListItem]:
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
owner_user_id: str | None = None
if _ownership_required(settings) and auth_context.permission != Permission.ADMIN:
owner_user_id = _require_owner_context(auth_context)
return service.list_portfolios(broker=broker, status=status_filter, owner_user_id=owner_user_id)
finally:
_close_repository(repository)
@router.get("/{portfolio_id}", response_model=PortfolioDetailResponse)
def get_portfolio(
portfolio_id: str,
request: Request,
*,
recent_trades: int = 5,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent_trades)
finally:
_close_repository(repository)
@router.post(
"",
response_model=PortfolioDetailResponse,
status_code=status.HTTP_201_CREATED,
)
def create_portfolio(
payload: CreatePortfolioRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
owner_user_id = auth_context.user_id or "system"
if _ownership_required(settings):
owner_user_id = _require_owner_context(auth_context)
return service.create_portfolio(payload, owner_user_id=owner_user_id)
finally:
_close_repository(repository)
@router.patch("/{portfolio_id}", response_model=PortfolioDetailResponse)
def update_portfolio(
portfolio_id: str,
payload: UpdatePortfolioRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.update_portfolio(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse)
def buy_portfolio_asset(
portfolio_id: str,
payload: BuyRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.buy(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse)
def sell_portfolio_asset(
portfolio_id: str,
payload: SellRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.sell(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/invest", response_model=PortfolioDetailResponse)
def invest_buffer(
portfolio_id: str,
payload: InvestRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.invest(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/transfer", response_model=PortfolioDetailResponse)
def transfer_buffer(
portfolio_id: str,
payload: TransferRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.transfer(portfolio_id, payload)
finally:
_close_repository(repository)
@router.post("/{portfolio_id}/reset", response_model=PortfolioDetailResponse)
def reset_portfolio(
portfolio_id: str,
payload: ResetPortfolioRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioDetailResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.reset(portfolio_id, payload)
finally:
_close_repository(repository)
@router.delete("/{portfolio_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_portfolio(
portfolio_id: str,
request: Request,
*,
keep_ledger: bool = False,
settings: Settings = Depends(get_settings),
) -> Response:
authorize_request(Permission.ADMIN, request, settings)
service, repository = _get_service(settings)
try:
service.delete(portfolio_id, keep_ledger=keep_ledger)
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/{portfolio_id}/comments",
response_model=CommentResponse,
status_code=status.HTTP_201_CREATED,
)
def create_comment(
portfolio_id: str,
payload: CommentCreateRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> CommentResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.create_comment(portfolio_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.get("/{portfolio_id}/comments", response_model=list[CommentResponse])
def list_comments(
portfolio_id: str,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> list[CommentResponse]:
auth_context = authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.list_comments(portfolio_id)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.patch("/{portfolio_id}/comments/{comment_id}", response_model=CommentResponse)
def update_comment(
portfolio_id: str,
comment_id: str,
payload: CommentUpdateRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> CommentResponse:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
return service.update_comment(portfolio_id, comment_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.delete("/{portfolio_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_comment(
portfolio_id: str,
comment_id: str,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> Response:
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
if _ownership_required(settings):
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
service.delete_comment(portfolio_id, comment_id)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)