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:
2026-05-20 01:16:13 +00:00
co-authored by Claude Sonnet 4.6
parent 158929b2d9
commit d4b841bc37
29 changed files with 2216 additions and 89 deletions
-41
View File
@@ -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()}")
+165
View File
@@ -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
View File
@@ -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):
+2
View File
@@ -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")