feat: 태그 HTTP API(BAT-004) 및 pfhub tag CLI + baseline 문서 갱신(BAT-005)
BAT-004 (REQ-004, REQ-005):
- app/api/v1_tags.py: POST /v1/tags, GET /v1/tags, PATCH /v1/tags/{id}/deactivate
- app/api/v1_portfolios.py: POST/DELETE /v1/portfolios/{id}/tags
- app/api/v1_assets.py: POST/DELETE /v1/assets/{id}/tags 엔드포인트 추가
- app/schemas/v1.py: TagItem, TagCreateRequest, TagListResponse, TagApplyRequest/Response 추가
- app/repositories/portfolio_repository.py, postgres.py: append/remove_portfolio_tag_id 추가
- app/main.py: v1_portfolios_router, v1_tags_router 등록
- tests/test_tags_http_api.py: 22개 HTTP API 테스트
BAT-005 (REQ-007, REQ-008):
- app/cli/commands/tag.py: pfhub tag create/list/deactivate/apply/remove 7개 커맨드 신규
- app/cli/main.py: tag_app 등록
- app/cli/commands/portfolio.py: pfhub portfolio tag add/list/remove 완전 제거(breaking change)
- app/cli/core/utils.py: print_tag_table + list[Tag] render 분기 추가
- tests/test_tags_cli.py: REQ-007 AC-1~9 커버 24개 CLI 테스트 신규
- tests/test_cli.py: 제거된 portfolio tag 테스트 2개 삭제
- docs/data-model.md, interface-contract.md, project-structure.md, runtime-flows.md: 태그 모델 baseline 갱신(REQ-008)
- docs/batches/: BAT-004, BAT-005 전체 산출물(release-candidate)
247 passed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ from app.schemas.v1 import (
|
||||
AssetCatalogUpdateRequest,
|
||||
AssetItem,
|
||||
AssetsResponse,
|
||||
TagApplyRequest,
|
||||
TagApplyResponse,
|
||||
TagRefItem,
|
||||
)
|
||||
|
||||
@@ -206,6 +208,65 @@ def deactivate_catalog_asset(
|
||||
return _asset_response(asset)
|
||||
|
||||
|
||||
@router.post("/assets/{asset_id}/tags", response_model=TagApplyResponse)
|
||||
def apply_asset_tag(
|
||||
asset_id: str,
|
||||
body: TagApplyRequest,
|
||||
auth: AuthContext = Depends(require_admin),
|
||||
) -> TagApplyResponse:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
try:
|
||||
repo.get(asset_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
|
||||
tag = repo.get_tag_by_id(body.tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"태그를 찾을 수 없습니다: {body.tag_id}")
|
||||
if not tag.is_admin_tag:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin 생성 태그만 asset에 적용할 수 있습니다")
|
||||
if not tag.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다")
|
||||
|
||||
repo.append_portfolio_tag_id(asset_id, body.tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
return TagApplyResponse(tag_id=body.tag_id, status="applied")
|
||||
|
||||
|
||||
@router.delete("/assets/{asset_id}/tags/{tag_id}", response_model=TagApplyResponse)
|
||||
def remove_asset_tag(
|
||||
asset_id: str,
|
||||
tag_id: str,
|
||||
auth: AuthContext = Depends(require_admin),
|
||||
) -> TagApplyResponse:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
try:
|
||||
repo.get(asset_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"asset을 찾을 수 없습니다: {asset_id}")
|
||||
repo.remove_portfolio_tag_id(asset_id, tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
return TagApplyResponse(tag_id=tag_id, status="removed")
|
||||
|
||||
|
||||
@router.patch("/assets/{asset_id}", response_model=AssetCatalogResponse)
|
||||
def update_catalog_asset(
|
||||
asset_id: str,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""v1 portfolio tag endpoints — apply and remove tags (REQ-005)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.core.auth import AuthContext, Permission, require_trade_write
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import TagApplyRequest, TagApplyResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings
|
||||
return get_portfolio_repository_from_settings(settings or get_settings())
|
||||
|
||||
|
||||
def _close_repo(repo) -> None:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
@router.post("/{portfolio_id}/tags", response_model=TagApplyResponse)
|
||||
def apply_portfolio_tag(
|
||||
portfolio_id: str,
|
||||
body: TagApplyRequest,
|
||||
auth: AuthContext = Depends(require_trade_write),
|
||||
) -> TagApplyResponse:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
try:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
|
||||
|
||||
is_admin = auth.permission == Permission.ADMIN
|
||||
is_owner = portfolio.owner_user_id == auth.user_id
|
||||
if not is_admin and not is_owner:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
|
||||
|
||||
tag = repo.get_tag_by_id(body.tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"태그를 찾을 수 없습니다: {body.tag_id}")
|
||||
|
||||
if not tag.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다")
|
||||
if not is_admin and not tag.is_admin_tag and tag.created_by_user_id != portfolio.owner_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="태그 적용 정책을 위반했습니다")
|
||||
|
||||
repo.append_portfolio_tag_id(portfolio_id, body.tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return TagApplyResponse(tag_id=body.tag_id, status="applied")
|
||||
|
||||
|
||||
@router.delete("/{portfolio_id}/tags/{tag_id}", response_model=TagApplyResponse)
|
||||
def remove_portfolio_tag(
|
||||
portfolio_id: str,
|
||||
tag_id: str,
|
||||
auth: AuthContext = Depends(require_trade_write),
|
||||
) -> TagApplyResponse:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
try:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}")
|
||||
|
||||
is_admin = auth.permission == Permission.ADMIN
|
||||
is_owner = portfolio.owner_user_id == auth.user_id
|
||||
if not is_admin and not is_owner:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
|
||||
|
||||
repo.remove_portfolio_tag_id(portfolio_id, tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return TagApplyResponse(tag_id=tag_id, status="removed")
|
||||
@@ -0,0 +1,108 @@
|
||||
"""v1 tag master endpoints — create, list, deactivate (REQ-004)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import TagCreateRequest, TagItem, TagListResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
|
||||
|
||||
def _get_repo(settings=None):
|
||||
from app.core.config import get_settings
|
||||
return get_portfolio_repository_from_settings(settings or get_settings())
|
||||
|
||||
|
||||
def _close_repo(repo) -> None:
|
||||
if callable(getattr(type(repo), "close", None)):
|
||||
repo.close()
|
||||
|
||||
|
||||
def _tag_to_item(tag) -> TagItem:
|
||||
return TagItem(
|
||||
tag_id=str(tag.tag_id),
|
||||
name=tag.name,
|
||||
is_admin_tag=tag.is_admin_tag,
|
||||
is_active=tag.is_active,
|
||||
created_at=tag.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tags", response_model=TagItem, status_code=201)
|
||||
def create_tag(
|
||||
body: TagCreateRequest,
|
||||
auth: AuthContext = Depends(require_trade_write),
|
||||
) -> TagItem:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
if auth.user_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="user_id required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
name = body.name.strip().lower()
|
||||
is_admin_tag = auth.permission == Permission.ADMIN
|
||||
tag = repo.create_tag(name=name, created_by_user_id=auth.user_id, is_admin_tag=is_admin_tag)
|
||||
except ValueError as exc:
|
||||
if "이미 존재하는 태그 이름입니다" in str(exc):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return _tag_to_item(tag)
|
||||
|
||||
|
||||
@router.get("/tags", response_model=TagListResponse)
|
||||
def list_tags(
|
||||
is_active: bool | None = Query(default=None),
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> TagListResponse:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
if is_active is False:
|
||||
all_tags = repo.list_tags_master(include_inactive=True)
|
||||
tags = [t for t in all_tags if not t.is_active]
|
||||
else:
|
||||
tags = repo.list_tags_master(include_inactive=False)
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
items = [_tag_to_item(t) for t in tags]
|
||||
return TagListResponse(tags=items, total=len(items))
|
||||
|
||||
|
||||
@router.patch("/tags/{tag_id}/deactivate", response_model=TagItem)
|
||||
def deactivate_tag(
|
||||
tag_id: str,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> TagItem:
|
||||
if auth.token_source != "db":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required")
|
||||
repo = _get_repo()
|
||||
try:
|
||||
tag = repo.get_tag_by_id(tag_id)
|
||||
if tag is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"태그를 찾을 수 없습니다: {tag_id}",
|
||||
)
|
||||
is_admin = auth.permission == Permission.ADMIN
|
||||
is_creator = tag.created_by_user_id == auth.user_id
|
||||
if not is_admin and not is_creator:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="권한이 없습니다")
|
||||
if not tag.is_active:
|
||||
return _tag_to_item(tag)
|
||||
result = repo.deactivate_tag(tag_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
return _tag_to_item(result)
|
||||
@@ -15,7 +15,6 @@ from app.schemas.portfolio import (
|
||||
CommentUpdateRequest,
|
||||
CreatePortfolioRequest,
|
||||
InvestRequest,
|
||||
PortfolioTagMutationRequest,
|
||||
ResetPortfolioRequest,
|
||||
SellRequest,
|
||||
TransferRequest,
|
||||
@@ -27,9 +26,7 @@ _NAME_RE = re.compile(r"^[a-zA-Z0-9._:\-]{1,100}$")
|
||||
|
||||
portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
|
||||
comment_app = typer.Typer(help="Portfolio comment history commands.")
|
||||
tag_app = typer.Typer(help="Portfolio tag commands.")
|
||||
portfolio_app.add_typer(comment_app, name="comment")
|
||||
portfolio_app.add_typer(tag_app, name="tag")
|
||||
|
||||
|
||||
def _parse_strategy_params(value: str) -> dict[str, Any]:
|
||||
@@ -434,41 +431,3 @@ def comment_delete(
|
||||
ctx.obj.console.print(f"Deleted comment: {comment_id}")
|
||||
|
||||
|
||||
@tag_app.command("add")
|
||||
def tag_add(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
tag: str = typer.Option(..., "--tag", help="Tag value."),
|
||||
) -> None:
|
||||
detail = _with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.add_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)),
|
||||
)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail)
|
||||
|
||||
|
||||
@tag_app.command("list")
|
||||
def tag_list(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
) -> None:
|
||||
detail = _with_service(ctx, lambda service, app_context: service.list_tags(portfolio_id))
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No tags found.")
|
||||
|
||||
|
||||
@tag_app.command("remove")
|
||||
def tag_remove(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
tag: str = typer.Option(..., "--tag", help="Tag value."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Remove tag {tag!r} from portfolio {portfolio_id}?")
|
||||
_with_service(
|
||||
ctx,
|
||||
lambda service, app_context: service.remove_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)),
|
||||
)
|
||||
if ctx.obj.output_format == "json":
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, {"portfolio_id": portfolio_id, "deleted": tag.strip()})
|
||||
else:
|
||||
ctx.obj.console.print(f"Removed tag: {tag.strip()}")
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Tag master CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typer
|
||||
|
||||
from app.cli.core.utils import execute_command, render_output
|
||||
|
||||
tag_app = typer.Typer(help="Tag master commands.")
|
||||
apply_app = typer.Typer(help="Apply a tag to a target.")
|
||||
remove_app = typer.Typer(help="Remove a tag from a target.")
|
||||
tag_app.add_typer(apply_app, name="apply")
|
||||
tag_app.add_typer(remove_app, name="remove")
|
||||
|
||||
|
||||
def _with_repo(ctx: typer.Context, callback):
|
||||
app_context = ctx.obj
|
||||
repository = app_context.repository_factory(app_context.settings)
|
||||
try:
|
||||
result = execute_command(app_context.console, callback=lambda: callback(repository, app_context))
|
||||
return result
|
||||
finally:
|
||||
if callable(getattr(type(repository), "close", None)):
|
||||
repository.close()
|
||||
|
||||
|
||||
def _confirm(force: bool, message: str) -> None:
|
||||
if force:
|
||||
return
|
||||
confirmed = typer.confirm(message)
|
||||
if not confirmed:
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
|
||||
@tag_app.command("create")
|
||||
def tag_create(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Tag name."),
|
||||
user: str | None = typer.Option(None, "--user", help="Owner username (resolved to user_id)."),
|
||||
admin: bool = typer.Option(False, "--admin", help="Mark tag as admin-only."),
|
||||
) -> None:
|
||||
def callback(repo, app_context):
|
||||
created_by = ""
|
||||
if user:
|
||||
u = repo.get_user_by_username(user)
|
||||
if u is None:
|
||||
raise ValueError(f"사용자를 찾을 수 없습니다: {user}")
|
||||
created_by = u.id
|
||||
tag = repo.create_tag(name=name.strip(), is_admin_tag=admin, created_by_user_id=created_by)
|
||||
app_context.console.print(f"Created tag: {tag.name} ({tag.tag_id})")
|
||||
return tag
|
||||
|
||||
result = _with_repo(ctx, callback)
|
||||
if result is not None and ctx.obj.output_format == "json":
|
||||
import dataclasses
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, dataclasses.asdict(result))
|
||||
|
||||
|
||||
@tag_app.command("list")
|
||||
def tag_list(
|
||||
ctx: typer.Context,
|
||||
inactive: bool = typer.Option(False, "--inactive", help="Include inactive tags (show all)."),
|
||||
) -> None:
|
||||
def callback(repo, app_context):
|
||||
return repo.list_tags_master(include_inactive=inactive)
|
||||
|
||||
result = _with_repo(ctx, callback)
|
||||
render_output(ctx.obj.console, ctx.obj.output_format, result, empty_message="No tags found.")
|
||||
|
||||
|
||||
@tag_app.command("deactivate")
|
||||
def tag_deactivate(
|
||||
ctx: typer.Context,
|
||||
tag_id: str = typer.Argument(..., help="Tag UUID."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Deactivate tag {tag_id}?")
|
||||
|
||||
def callback(repo, app_context):
|
||||
tag = repo.get_tag_by_id(tag_id)
|
||||
if tag is None:
|
||||
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
|
||||
if not tag.is_active:
|
||||
app_context.console.print(f"Tag already inactive: {tag_id}")
|
||||
return None
|
||||
repo.deactivate_tag(tag_id)
|
||||
app_context.console.print(f"Deactivated tag: {tag_id}")
|
||||
return None
|
||||
|
||||
_with_repo(ctx, callback)
|
||||
|
||||
|
||||
@apply_app.command("asset")
|
||||
def apply_asset(
|
||||
ctx: typer.Context,
|
||||
asset_id: str = typer.Argument(..., help="Asset (portfolio) UUID."),
|
||||
tag_id: str = typer.Argument(..., help="Tag UUID."),
|
||||
) -> None:
|
||||
def callback(repo, app_context):
|
||||
tag = repo.get_tag_by_id(tag_id)
|
||||
if tag is None:
|
||||
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
|
||||
if not tag.is_admin_tag:
|
||||
raise ValueError(f"admin 태그만 asset에 적용할 수 있습니다: {tag_id}")
|
||||
if not tag.is_active:
|
||||
raise ValueError(f"비활성 태그는 적용할 수 없습니다: {tag_id}")
|
||||
repo.append_portfolio_tag_id(asset_id, tag_id)
|
||||
app_context.console.print(f"Applied tag {tag_id} to asset {asset_id}")
|
||||
|
||||
_with_repo(ctx, callback)
|
||||
|
||||
|
||||
@apply_app.command("portfolio")
|
||||
def apply_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
tag_id: str = typer.Argument(..., help="Tag UUID."),
|
||||
) -> None:
|
||||
def callback(repo, app_context):
|
||||
tag = repo.get_tag_by_id(tag_id)
|
||||
if tag is None:
|
||||
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
|
||||
if not tag.is_active:
|
||||
raise ValueError(f"비활성 태그는 적용할 수 없습니다: {tag_id}")
|
||||
portfolio = repo.get(portfolio_id)
|
||||
if portfolio is None:
|
||||
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
|
||||
if not (tag.is_admin_tag or tag.created_by_user_id == portfolio.owner_user_id):
|
||||
raise ValueError(f"태그 적용 권한이 없습니다: {tag_id}")
|
||||
repo.append_portfolio_tag_id(portfolio_id, tag_id)
|
||||
app_context.console.print(f"Applied tag {tag_id} to portfolio {portfolio_id}")
|
||||
|
||||
_with_repo(ctx, callback)
|
||||
|
||||
|
||||
@remove_app.command("asset")
|
||||
def remove_asset(
|
||||
ctx: typer.Context,
|
||||
asset_id: str = typer.Argument(..., help="Asset (portfolio) UUID."),
|
||||
tag_id: str = typer.Argument(..., help="Tag UUID."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Remove tag {tag_id} from asset {asset_id}?")
|
||||
|
||||
def callback(repo, app_context):
|
||||
repo.remove_portfolio_tag_id(asset_id, tag_id)
|
||||
app_context.console.print(f"Removed tag {tag_id} from asset {asset_id}")
|
||||
|
||||
_with_repo(ctx, callback)
|
||||
|
||||
|
||||
@remove_app.command("portfolio")
|
||||
def remove_portfolio(
|
||||
ctx: typer.Context,
|
||||
portfolio_id: str = typer.Argument(..., help="Portfolio UUID."),
|
||||
tag_id: str = typer.Argument(..., help="Tag UUID."),
|
||||
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
_confirm(force, f"Remove tag {tag_id} from portfolio {portfolio_id}?")
|
||||
|
||||
def callback(repo, app_context):
|
||||
repo.remove_portfolio_tag_id(portfolio_id, tag_id)
|
||||
app_context.console.print(f"Removed tag {tag_id} from portfolio {portfolio_id}")
|
||||
|
||||
_with_repo(ctx, callback)
|
||||
+24
-1
@@ -13,7 +13,7 @@ from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from app.core.errors import FeatureNotReadyError
|
||||
from app.domain.models import Daemon
|
||||
from app.domain.models import Daemon, Tag
|
||||
from app.schemas.auth import AuthTokenResponse
|
||||
from app.schemas.portfolio import CommentResponse, PortfolioDetailResponse, PortfolioListItem
|
||||
from app.schemas.user import UserResponse
|
||||
@@ -231,6 +231,26 @@ def print_comment_table(console: Console, comments: list[CommentResponse]) -> No
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_tag_table(console: Console, tags: list[Tag]) -> None:
|
||||
table = Table(title="Tags", expand=True)
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Admin", no_wrap=True)
|
||||
table.add_column("Active", no_wrap=True)
|
||||
table.add_column("Created By", no_wrap=True)
|
||||
table.add_column("Created At", no_wrap=True)
|
||||
for tag in tags:
|
||||
table.add_row(
|
||||
tag.tag_id,
|
||||
tag.name,
|
||||
"yes" if tag.is_admin_tag else "no",
|
||||
"yes" if tag.is_active else "no",
|
||||
tag.created_by_user_id or "-",
|
||||
tag.created_at.isoformat(timespec="seconds") if tag.created_at else "N/A",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_comment_detail(console: Console, comment: CommentResponse) -> None:
|
||||
console.print(
|
||||
Panel.fit(
|
||||
@@ -276,6 +296,9 @@ def render_output(console: Console, output_format: str, payload: Any, *, empty_m
|
||||
if all(isinstance(item, AuthTokenResponse) for item in payload):
|
||||
print_service_token_table(console, payload)
|
||||
return
|
||||
if all(isinstance(item, Tag) for item in payload):
|
||||
print_tag_table(console, payload)
|
||||
return
|
||||
return
|
||||
|
||||
if isinstance(payload, PortfolioDetailResponse):
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.cli.commands.health import register_health_commands
|
||||
from app.cli.commands.service_tokens import token_app
|
||||
from app.cli.commands.portfolio import portfolio_app
|
||||
from app.cli.commands.strategy import strategy_app
|
||||
from app.cli.commands.tag import tag_app
|
||||
from app.cli.commands.user import user_app
|
||||
from app.cli.commands.credential import credential_app
|
||||
from app.core.config import Settings, get_settings
|
||||
@@ -53,6 +54,7 @@ def create_app(
|
||||
app.add_typer(asset_app, name="asset")
|
||||
app.add_typer(portfolio_app, name="portfolio")
|
||||
app.add_typer(strategy_app, name="strategy")
|
||||
app.add_typer(tag_app, name="tag")
|
||||
app.add_typer(user_app, name="user")
|
||||
app.add_typer(token_app, name="service-token")
|
||||
app.add_typer(credential_app, name="credential")
|
||||
|
||||
+6
-2
@@ -12,8 +12,10 @@ from app.api.portfolios import router as portfolio_router
|
||||
from app.api.strategy_sets import router as strategy_sets_router
|
||||
from app.api.users import router as users_router
|
||||
from app.api.v1_assets import router as v1_assets_router
|
||||
from app.api.v1_user_info import router as v1_user_info_router
|
||||
from app.api.v1_ledger import router as v1_ledger_router
|
||||
from app.api.v1_portfolios import router as v1_portfolios_router
|
||||
from app.api.v1_tags import router as v1_tags_router
|
||||
from app.api.v1_user_info import router as v1_user_info_router
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import register_exception_handlers
|
||||
from app.core.security import hash_password
|
||||
@@ -79,8 +81,10 @@ def create_app(
|
||||
app.include_router(strategy_sets_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(v1_assets_router)
|
||||
app.include_router(v1_user_info_router)
|
||||
app.include_router(v1_ledger_router)
|
||||
app.include_router(v1_portfolios_router)
|
||||
app.include_router(v1_tags_router)
|
||||
app.include_router(v1_user_info_router)
|
||||
register_exception_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
@@ -209,6 +209,10 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def deactivate_tag(self, tag_id: str) -> Tag: ...
|
||||
|
||||
def append_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None: ...
|
||||
|
||||
def remove_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None: ...
|
||||
|
||||
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
|
||||
|
||||
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ...
|
||||
|
||||
@@ -1718,6 +1718,36 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
|
||||
return self._row_to_tag(row)
|
||||
|
||||
def append_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET tag_ids = array_append(tag_ids, %s::uuid)
|
||||
WHERE portfolio_id = %s AND NOT (%s::uuid = ANY(tag_ids))
|
||||
""",
|
||||
(tag_id, portfolio_id, tag_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
cur.execute("SELECT portfolio_id FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
|
||||
|
||||
def remove_portfolio_tag_id(self, portfolio_id: str, tag_id: str) -> None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolios
|
||||
SET tag_ids = array_remove(tag_ids, %s::uuid)
|
||||
WHERE portfolio_id = %s
|
||||
""",
|
||||
(tag_id, portfolio_id),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
|
||||
|
||||
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
|
||||
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
|
||||
|
||||
|
||||
@@ -9,6 +9,40 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET/POST/PATCH /v1/tags (REQ-004)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TagItem(BaseModel):
|
||||
tag_id: str
|
||||
name: str
|
||||
is_admin_tag: bool
|
||||
is_active: bool
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class TagCreateRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TagListResponse(BaseModel):
|
||||
tags: list[TagItem]
|
||||
total: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST/DELETE /v1/assets/{id}/tags, /v1/portfolios/{id}/tags (REQ-005)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TagApplyRequest(BaseModel):
|
||||
tag_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class TagApplyResponse(BaseModel):
|
||||
tag_id: str
|
||||
status: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/assets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user