feat(REQ-001): add portfolio comment history API, CLI, repository, migration, and tests

Agent-Logs-Url: https://github.com/genichin/portfolio-hub/sessions/64fc2cec-78f5-41d1-99a4-45f868781edd

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-10 13:54:18 +00:00
committed by GitHub
co-authored by genichin
parent 4684597353
commit e8f782bec6
14 changed files with 1173 additions and 10 deletions
+88 -1
View File
@@ -1,12 +1,15 @@
"""Portfolio API routes."""
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from app.core.auth import 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,
@@ -21,6 +24,13 @@ 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
@@ -192,3 +202,80 @@ def delete_portfolio(
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:
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
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]:
authorize_request(Permission.READ, request, settings)
service, repository = _get_service(settings)
try:
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:
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
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:
authorize_request(Permission.TRADE_WRITE, request, settings)
service, repository = _get_service(settings)
try:
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)
+71 -1
View File
@@ -10,6 +10,8 @@ import typer
from app.cli.core.utils import execute_command, render_output
from app.schemas.portfolio import (
BuyRequest,
CommentCreateRequest,
CommentUpdateRequest,
CreatePortfolioRequest,
InvestRequest,
ResetPortfolioRequest,
@@ -21,6 +23,8 @@ from app.services.portfolio_service import PortfolioService
portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.")
comment_app = typer.Typer(help="Portfolio comment history commands.")
portfolio_app.add_typer(comment_app, name="comment")
def _parse_strategy_params(value: str) -> dict[str, Any]:
@@ -263,4 +267,70 @@ def delete_portfolio(
if ctx.obj.output_format == "json":
render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": portfolio_id, "keep_ledger": keep_ledger})
else:
ctx.obj.console.print(f"Deleted portfolio: {portfolio_id}")
ctx.obj.console.print(f"Deleted portfolio: {portfolio_id}")
@comment_app.command("add")
def comment_add(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
content: str = typer.Option(..., "--content", help="Comment text (supports multiline)."),
) -> None:
detail = _with_service(
ctx,
lambda service, app_context: service.create_comment(
portfolio_id,
CommentCreateRequest(content=content),
),
)
render_output(ctx.obj.console, ctx.obj.output_format, detail)
@comment_app.command("list")
def comment_list(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
) -> None:
detail = _with_service(
ctx,
lambda service, app_context: service.list_comments(portfolio_id),
)
render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No comments found.")
@comment_app.command("update")
def comment_update(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
comment_id: str = typer.Argument(..., help="Comment identifier."),
content: str = typer.Option(..., "--content", help="Updated comment text."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Update comment {comment_id} for portfolio {portfolio_id}?")
detail = _with_service(
ctx,
lambda service, app_context: service.update_comment(
portfolio_id,
comment_id,
CommentUpdateRequest(content=content),
),
)
render_output(ctx.obj.console, ctx.obj.output_format, detail)
@comment_app.command("delete")
def comment_delete(
ctx: typer.Context,
portfolio_id: str = typer.Argument(..., help="Portfolio identifier."),
comment_id: str = typer.Argument(..., help="Comment identifier."),
force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."),
) -> None:
_confirm(force, f"Delete comment {comment_id} for portfolio {portfolio_id}?")
_with_service(
ctx,
lambda service, app_context: service.delete_comment(portfolio_id, comment_id),
)
if ctx.obj.output_format == "json":
render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": comment_id, "portfolio_id": portfolio_id})
else:
ctx.obj.console.print(f"Deleted comment: {comment_id}")
+43 -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.schemas.portfolio import PortfolioDetailResponse, PortfolioListItem
from app.schemas.portfolio import CommentResponse, PortfolioDetailResponse, PortfolioListItem
from app.schemas.user import UserResponse
T = TypeVar("T")
@@ -162,6 +162,41 @@ def print_user_detail(console: Console, user: UserResponse) -> None:
)
def print_comment_table(console: Console, comments: list[CommentResponse]) -> None:
table = Table(title="Portfolio Comments", expand=True)
table.add_column("ID", style="cyan", no_wrap=True)
table.add_column("Portfolio ID", no_wrap=True)
table.add_column("Content")
table.add_column("Created At", no_wrap=True)
table.add_column("Updated At", no_wrap=True)
for comment in comments:
table.add_row(
comment.id,
comment.portfolio_id,
comment.content,
comment.created_at.isoformat(timespec="seconds") if comment.created_at else "N/A",
comment.updated_at.isoformat(timespec="seconds") if comment.updated_at else "N/A",
)
console.print(table)
def print_comment_detail(console: Console, comment: CommentResponse) -> None:
console.print(
Panel.fit(
"\n".join(
[
f"id: {comment.id}",
f"portfolio_id: {comment.portfolio_id}",
f"content: {comment.content}",
f"created_at: {comment.created_at.isoformat(timespec='seconds') if comment.created_at else 'N/A'}",
f"updated_at: {comment.updated_at.isoformat(timespec='seconds') if comment.updated_at else 'N/A'}",
]
),
title="Comment",
)
)
def print_json(console: Console, payload: Any) -> None:
console.print_json(data=to_plain_data(payload))
@@ -181,6 +216,9 @@ def render_output(console: Console, output_format: str, payload: Any, *, empty_m
if all(isinstance(item, UserResponse) for item in payload):
print_user_table(console, payload)
return
if all(isinstance(item, CommentResponse) for item in payload):
print_comment_table(console, payload)
return
return
if isinstance(payload, PortfolioDetailResponse):
@@ -191,6 +229,10 @@ def render_output(console: Console, output_format: str, payload: Any, *, empty_m
print_user_detail(console, payload)
return
if isinstance(payload, CommentResponse):
print_comment_detail(console, payload)
return
console.print(payload)
+9
View File
@@ -100,6 +100,15 @@ class LedgerEntry:
note: str = ""
@dataclass
class PortfolioComment:
id: str
portfolio_id: str
content: str
created_at: datetime | None = None
updated_at: datetime | None = None
@runtime_checkable
class LiveAssetSource(Protocol):
def get_asset_quantity(self, symbol: str) -> float: ...
+9 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -147,3 +147,11 @@ class PortfolioRepository(Protocol):
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None: ...
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment: ...
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]: ...
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
+100 -1
View File
@@ -12,7 +12,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -263,6 +263,23 @@ class PostgresPortfolioRepository:
ON portfolios (broker, status, updated_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_comments (
id TEXT PRIMARY KEY,
portfolio_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_comments_portfolio_created
ON portfolio_comments (portfolio_id, created_at DESC)
"""
)
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]:
cur.execute(
@@ -1173,8 +1190,90 @@ class PostgresPortfolioRepository:
raise ValueError("포트폴리오를 찾을 수 없습니다")
if clear_ledger:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolio_comments WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
@staticmethod
def _row_to_comment(row: Any) -> PortfolioComment:
if row is None:
raise ValueError("댓글을 찾을 수 없습니다")
return PortfolioComment(
id=row[0],
portfolio_id=row[1],
content=row[2],
created_at=row[3],
updated_at=row[4],
)
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
comment_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO portfolio_comments (id, portfolio_id, content)
VALUES (%s, %s, %s)
RETURNING id, portfolio_id, content, created_at, updated_at
""",
(comment_id, portfolio_id, content),
)
row = cur.fetchone()
return self._row_to_comment(row)
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
SELECT id, portfolio_id, content, created_at, updated_at
FROM portfolio_comments
WHERE portfolio_id = %s
ORDER BY created_at DESC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_comment(row) for row in rows]
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
UPDATE portfolio_comments
SET content = %s, updated_at = NOW()
WHERE id = %s AND portfolio_id = %s
RETURNING id, portfolio_id, content, created_at, updated_at
""",
(content, comment_id, portfolio_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("댓글을 찾을 수 없습니다")
return self._row_to_comment(row)
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"DELETE FROM portfolio_comments WHERE id = %s AND portfolio_id = %s RETURNING id",
(comment_id, portfolio_id),
)
if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다")
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
+16
View File
@@ -123,6 +123,22 @@ class ResetPortfolioRequest(BaseModel):
infer_symbol: str | None = None
class CommentCreateRequest(BaseModel):
content: str
class CommentUpdateRequest(BaseModel):
content: str
class CommentResponse(BaseModel):
id: str
portfolio_id: str
content: str
created_at: datetime | None = None
updated_at: datetime | None = None
class ErrorResponse(BaseModel):
detail: str
model_config = ConfigDict(extra="ignore")
+33 -1
View File
@@ -7,7 +7,7 @@ import json
from fastapi import Depends
from app.core.config import Settings, get_settings
from app.domain.models import LedgerEntry, Portfolio, StrategySet
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet
from app.domain.services import (
apply_buy,
apply_delete,
@@ -21,6 +21,9 @@ from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.portfolio import (
BuyRequest,
CommentCreateRequest,
CommentResponse,
CommentUpdateRequest,
CreatePortfolioRequest,
InvestRequest,
PortfolioDetailResponse,
@@ -107,6 +110,16 @@ def _to_ledger_entry_response(entry: LedgerEntry):
)
def _to_comment_response(comment: PortfolioComment) -> CommentResponse:
return CommentResponse(
id=comment.id,
portfolio_id=comment.portfolio_id,
content=comment.content,
created_at=comment.created_at,
updated_at=comment.updated_at,
)
def _to_detail_response(status_result) -> PortfolioDetailResponse:
return PortfolioDetailResponse(
portfolio=_to_portfolio_summary(status_result.portfolio),
@@ -257,6 +270,25 @@ class PortfolioService:
def delete(self, portfolio_id: str, *, keep_ledger: bool = False) -> None:
apply_delete(self._repository, portfolio_id, clear_ledger=not keep_ledger)
def create_comment(self, portfolio_id: str, payload: CommentCreateRequest) -> CommentResponse:
self._repository.get(portfolio_id)
comment = self._repository.create_comment(portfolio_id, content=payload.content)
return _to_comment_response(comment)
def list_comments(self, portfolio_id: str) -> list[CommentResponse]:
self._repository.get(portfolio_id)
comments = self._repository.list_comments(portfolio_id)
return [_to_comment_response(c) for c in comments]
def update_comment(self, portfolio_id: str, comment_id: str, payload: CommentUpdateRequest) -> CommentResponse:
self._repository.get(portfolio_id)
comment = self._repository.update_comment(portfolio_id, comment_id, content=payload.content)
return _to_comment_response(comment)
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
self._repository.get(portfolio_id)
self._repository.delete_comment(portfolio_id, comment_id)
def get_portfolio_service(
repository: PortfolioRepository = Depends(get_portfolio_repository),