feat(req-002): implement portfolio tagging and tag filters

This commit is contained in:
2026-04-14 01:06:10 +09:00
parent 2dab17cbb1
commit a776ea3b1d
20 changed files with 1033 additions and 32 deletions
+78 -2
View File
@@ -1,6 +1,6 @@
"""Portfolio API routes."""
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
from app.core.auth import AuthContext, Permission, authorize_request
from app.core.config import Settings, get_settings
@@ -14,6 +14,8 @@ from app.schemas.portfolio import (
InvestRequest,
PortfolioDetailResponse,
PortfolioListItem,
PortfolioTagMutationRequest,
PortfolioTagResponse,
ResetPortfolioRequest,
SellRequest,
TransferRequest,
@@ -26,6 +28,8 @@ 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_409_CONFLICT, detail=detail) from 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
@@ -68,6 +72,9 @@ def list_portfolios(
request: Request,
broker: str | None = None,
status_filter: str | None = None,
include_tags: list[str] | None = Query(default=None, alias="includeTags"),
exclude_tags: list[str] | None = Query(default=None, alias="excludeTags"),
match_mode: str | None = Query(default=None, alias="matchMode"),
settings: Settings = Depends(get_settings),
) -> list[PortfolioListItem]:
auth_context = authorize_request(Permission.READ, request, settings)
@@ -76,7 +83,16 @@ def list_portfolios(
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)
return service.list_portfolios(
broker=broker,
status=status_filter,
owner_user_id=owner_user_id,
include_tags=include_tags,
exclude_tags=exclude_tags,
match_mode=match_mode,
)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@@ -329,3 +345,63 @@ def delete_comment(
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post("/{portfolio_id}/tags", response_model=PortfolioTagResponse, status_code=status.HTTP_201_CREATED)
def add_tag(
portfolio_id: str,
payload: PortfolioTagMutationRequest,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> PortfolioTagResponse:
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.add_tag(portfolio_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.get("/{portfolio_id}/tags", response_model=list[PortfolioTagResponse])
def list_tags(
portfolio_id: str,
request: Request,
*,
settings: Settings = Depends(get_settings),
) -> list[PortfolioTagResponse]:
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_tags(portfolio_id)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
@router.delete("/{portfolio_id}/tags", status_code=status.HTTP_204_NO_CONTENT)
def remove_tag(
portfolio_id: str,
request: Request,
payload: PortfolioTagMutationRequest = Body(...),
*,
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.remove_tag(portfolio_id, payload)
except ValueError as exc:
_raise_portfolio_error(exc)
finally:
_close_repository(repository)
return Response(status_code=status.HTTP_204_NO_CONTENT)