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
+61
View File
@@ -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,
+93
View File
@@ -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")
+108
View File
@@ -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)
-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")
+6 -2
View File
@@ -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
+4
View File
@@ -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]: ...
+30
View File
@@ -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
+34
View File
@@ -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
# ---------------------------------------------------------------------------