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:
co-authored by
genichin
parent
4684597353
commit
e8f782bec6
+88
-1
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
@@ -264,3 +268,69 @@ def delete_portfolio(
|
||||
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}")
|
||||
|
||||
|
||||
@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
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -259,6 +259,104 @@ Response 204:
|
||||
|
||||
- No content.
|
||||
|
||||
### 4.11 POST /portfolios/{portfolio_id}/comments
|
||||
|
||||
Create a new comment for a portfolio.
|
||||
|
||||
Required permission: `trade-write`
|
||||
|
||||
Request:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/portfolios/krw-btc/comments" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "Bought more BTC today.\nLooking good."}'
|
||||
```
|
||||
|
||||
Response 201:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"portfolio_id": "krw-btc",
|
||||
"content": "Bought more BTC today.\nLooking good.",
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.12 GET /portfolios/{portfolio_id}/comments
|
||||
|
||||
List all comments for a portfolio, sorted by most recent first.
|
||||
|
||||
Required permission: `read`
|
||||
|
||||
Request:
|
||||
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/portfolios/krw-btc/comments" \
|
||||
-H "Authorization: Bearer <READ_TOKEN>"
|
||||
```
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"portfolio_id": "krw-btc",
|
||||
"content": "Bought more BTC today.\nLooking good.",
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 4.13 PATCH /portfolios/{portfolio_id}/comments/{comment_id}
|
||||
|
||||
Update an existing comment's content.
|
||||
|
||||
Required permission: `trade-write`
|
||||
|
||||
Request:
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"content": "Updated note: BTC rally continues."}'
|
||||
```
|
||||
|
||||
Response 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"portfolio_id": "krw-btc",
|
||||
"content": "Updated note: BTC rally continues.",
|
||||
"created_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-04-10T01:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.14 DELETE /portfolios/{portfolio_id}/comments/{comment_id}
|
||||
|
||||
Delete a comment.
|
||||
|
||||
Required permission: `trade-write`
|
||||
|
||||
Request:
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8000/portfolios/krw-btc/comments/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
|
||||
-H "Authorization: Bearer <TRADE_WRITE_TOKEN>"
|
||||
```
|
||||
|
||||
Response 204:
|
||||
|
||||
- No content.
|
||||
|
||||
## 5. Strategy Sets
|
||||
|
||||
### 5.1 GET /strategy-sets
|
||||
|
||||
+33
-2
@@ -5,7 +5,7 @@
|
||||
This document defines the database schema in an AI-agent-friendly format.
|
||||
|
||||
- Source of truth: Alembic revisions in `migrations/versions`
|
||||
- Current baseline revision: `20260403_0008`
|
||||
- Current baseline revision: `20260410_0009`
|
||||
- Baseline files:
|
||||
- `migrations/versions/20260403_0001_initial_portfolio_schema.py`
|
||||
- `migrations/versions/20260403_0002_strategy_set_columns.py`
|
||||
@@ -15,6 +15,7 @@ This document defines the database schema in an AI-agent-friendly format.
|
||||
- `migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py`
|
||||
- `migrations/versions/20260403_0007_bot_principal_permission_only.py`
|
||||
- `migrations/versions/20260403_0008_portfolio_name_column.py`
|
||||
- `migrations/versions/20260410_0009_portfolio_comments_table.py`
|
||||
|
||||
## 2. Scope
|
||||
|
||||
@@ -26,6 +27,7 @@ Schema objects covered by baseline revision:
|
||||
- `bot_principals`
|
||||
- `portfolios`
|
||||
- `portfolio_ledger`
|
||||
- `portfolio_comments`
|
||||
- secondary indexes
|
||||
- Timescale hypertable conversion rule for `portfolio_ledger`
|
||||
|
||||
@@ -206,6 +208,29 @@ Constraints:
|
||||
- Primary key: none in baseline
|
||||
- Foreign key constraints: none in baseline
|
||||
|
||||
### 4.6 portfolio_comments
|
||||
|
||||
Purpose:
|
||||
|
||||
- Stores user-authored comment history per portfolio for memo/annotation tracking.
|
||||
- Supports multiline text content.
|
||||
- Comments are isolated per portfolio and listed in reverse chronological order.
|
||||
|
||||
Columns:
|
||||
|
||||
| Column | Type | Null | Default | Constraints / Notes |
|
||||
|---|---|---|---|---|
|
||||
| id | Text | NO | - | Primary Key (UUID) |
|
||||
| portfolio_id | Text | NO | - | Logical reference to `portfolios.portfolio_id` (no FK) |
|
||||
| content | Text | NO | - | Comment text; supports multiline |
|
||||
| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp |
|
||||
| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp |
|
||||
|
||||
Constraints:
|
||||
|
||||
- Primary key: `id`
|
||||
- Foreign key constraints: none
|
||||
|
||||
## 5. Indexes
|
||||
|
||||
### 5.1 ix_users_created_at
|
||||
@@ -257,7 +282,12 @@ Constraints:
|
||||
- Unique: no
|
||||
- Intent: speed list/filter queries by broker/status and recency.
|
||||
|
||||
## 6. TimescaleDB Behavior
|
||||
### 5.8 idx_portfolio_comments_portfolio_created
|
||||
|
||||
- Table: `portfolio_comments`
|
||||
- Columns/expressions: (`portfolio_id`, `created_at DESC`)
|
||||
- Unique: no
|
||||
- Intent: fetch comments per portfolio efficiently in reverse chronological order.
|
||||
|
||||
Conditional behavior in migration upgrade:
|
||||
|
||||
@@ -297,3 +327,4 @@ When schema changes:
|
||||
| 20260403_0006 | migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py | Add `bot_principals` table and `auth_tokens.bot_principal_id` for bot API key management |
|
||||
| 20260403_0007 | migrations/versions/20260403_0007_bot_principal_permission_only.py | Remove `bot_principals.owner_user_id` and enforce bot-local `permission` model |
|
||||
| 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` (asset display name), backfilled from `symbol` |
|
||||
| 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo/comment history |
|
||||
|
||||
@@ -217,6 +217,36 @@ Base routes:
|
||||
- `keep_ledger` (bool, default `false`)
|
||||
- Response `204`: no content
|
||||
|
||||
#### Create Comment
|
||||
|
||||
- `POST /portfolios/{portfolio_id}/comments`
|
||||
- Auth: `trade-write`
|
||||
- Body: `CommentCreateRequest` (`content: str`)
|
||||
- Response `201`: `CommentResponse`
|
||||
- Response `404`: portfolio not found
|
||||
|
||||
#### List Comments
|
||||
|
||||
- `GET /portfolios/{portfolio_id}/comments`
|
||||
- Auth: `read`
|
||||
- Response `200`: array of `CommentResponse`, sorted by `created_at DESC`
|
||||
- Response `404`: portfolio not found
|
||||
|
||||
#### Update Comment
|
||||
|
||||
- `PATCH /portfolios/{portfolio_id}/comments/{comment_id}`
|
||||
- Auth: `trade-write`
|
||||
- Body: `CommentUpdateRequest` (`content: str`)
|
||||
- Response `200`: `CommentResponse`
|
||||
- Response `404`: portfolio or comment not found
|
||||
|
||||
#### Delete Comment
|
||||
|
||||
- `DELETE /portfolios/{portfolio_id}/comments/{comment_id}`
|
||||
- Auth: `trade-write`
|
||||
- Response `204`: no content
|
||||
- Response `404`: portfolio or comment not found
|
||||
|
||||
### 7.3 Strategy Set Endpoints
|
||||
|
||||
#### List Strategy Sets
|
||||
@@ -473,6 +503,14 @@ Bootstrap rule:
|
||||
|
||||
- `infer_symbol: str | null`
|
||||
|
||||
`CommentCreateRequest`
|
||||
|
||||
- `content: str`
|
||||
|
||||
`CommentUpdateRequest`
|
||||
|
||||
- `content: str`
|
||||
|
||||
`CreateStrategySetRequest`
|
||||
|
||||
- `name: str`
|
||||
@@ -529,6 +567,14 @@ Bootstrap rule:
|
||||
|
||||
- `detail: str`
|
||||
|
||||
`CommentResponse`
|
||||
|
||||
- `id: str`
|
||||
- `portfolio_id: str`
|
||||
- `content: str`
|
||||
- `created_at: datetime | null`
|
||||
- `updated_at: datetime | null`
|
||||
|
||||
`BotPrincipalResponse`
|
||||
|
||||
- `id: str`
|
||||
@@ -565,6 +611,13 @@ Portfolio subcommands:
|
||||
- `portfoliohub portfolio reset <portfolio_id> [--infer-symbol ...] [--force]`
|
||||
- `portfoliohub portfolio delete <portfolio_id> [--keep-ledger] [--force]`
|
||||
|
||||
Portfolio comment subcommands:
|
||||
|
||||
- `portfoliohub portfolio comment add <portfolio_id> --content <text>`
|
||||
- `portfoliohub portfolio comment list <portfolio_id>`
|
||||
- `portfoliohub portfolio comment update <portfolio_id> <comment_id> --content <text> [--force]`
|
||||
- `portfoliohub portfolio comment delete <portfolio_id> <comment_id> [--force]`
|
||||
|
||||
Strategy subcommands:
|
||||
|
||||
- `portfoliohub strategy list`
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""portfolio_comments table
|
||||
|
||||
Revision ID: 20260410_0009
|
||||
Revises: 20260403_0008
|
||||
Create Date: 2026-04-10 00:00:00
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260410_0009"
|
||||
down_revision = "20260403_0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"portfolio_comments",
|
||||
sa.Column("id", sa.Text(), primary_key=True),
|
||||
sa.Column("portfolio_id", sa.Text(), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("NOW()")),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_portfolio_comments_portfolio_created",
|
||||
"portfolio_comments",
|
||||
["portfolio_id", sa.text("created_at DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_portfolio_comments_portfolio_created", table_name="portfolio_comments")
|
||||
op.drop_table("portfolio_comments")
|
||||
+154
-1
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import app.cli.commands.health as health_commands
|
||||
@@ -8,7 +9,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from app.cli.main import create_app
|
||||
from app.core.config import Settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, StrategySet, User
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet, User
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
@@ -19,6 +20,7 @@ class FakeRepo:
|
||||
self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set}
|
||||
self._users: dict[str, User] = {}
|
||||
self._user_seq = 1
|
||||
self._comments: dict[str, PortfolioComment] = {}
|
||||
self.closed = False
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -262,6 +264,47 @@ class FakeRepo:
|
||||
if clear_ledger:
|
||||
self.entries = []
|
||||
|
||||
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
now = datetime.now(timezone.utc)
|
||||
comment = PortfolioComment(
|
||||
id=str(uuid.uuid4()),
|
||||
portfolio_id=portfolio_id,
|
||||
content=content,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._comments[comment.id] = comment
|
||||
return comment
|
||||
|
||||
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
return sorted(
|
||||
[c for c in self._comments.values() if c.portfolio_id == portfolio_id],
|
||||
key=lambda c: c.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
comment = self._comments.get(comment_id)
|
||||
if comment is None or comment.portfolio_id != portfolio_id:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
comment.content = content
|
||||
comment.updated_at = datetime.now(timezone.utc)
|
||||
return comment
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
comment = self._comments.get(comment_id)
|
||||
if comment is None or comment.portfolio_id != portfolio_id:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
del self._comments[comment_id]
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(db_enabled=True, allowed_origins_raw="http://localhost:3000")
|
||||
@@ -526,3 +569,113 @@ def test_cli_user_update_and_delete_flow() -> None:
|
||||
assert "alice-updated" in update_result.stdout
|
||||
assert delete_result.exit_code == 0
|
||||
assert f"Deleted user: {user.id}" in delete_result.stdout
|
||||
|
||||
def test_cli_comment_add_and_list() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
add_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "comment", "add", "005930:tv", "--content", "First comment"],
|
||||
)
|
||||
list_result = runner.invoke(runner.app, ["portfolio", "comment", "list", "005930:tv"])
|
||||
|
||||
assert add_result.exit_code == 0
|
||||
assert "First comment" in add_result.stdout
|
||||
assert list_result.exit_code == 0
|
||||
assert "First comment" in list_result.stdout
|
||||
|
||||
|
||||
def test_cli_comment_add_supports_json_output() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "JSON comment"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["content"] == "JSON comment"
|
||||
assert payload["portfolio_id"] == "005930:tv"
|
||||
assert "id" in payload
|
||||
|
||||
|
||||
def test_cli_comment_update_flow() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
add_result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "Original"],
|
||||
)
|
||||
assert add_result.exit_code == 0
|
||||
comment_id = json.loads(add_result.stdout)["id"]
|
||||
|
||||
update_result = runner.invoke(
|
||||
runner.app,
|
||||
[
|
||||
"--format",
|
||||
"json",
|
||||
"portfolio",
|
||||
"comment",
|
||||
"update",
|
||||
"005930:tv",
|
||||
comment_id,
|
||||
"--content",
|
||||
"Updated",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert update_result.exit_code == 0
|
||||
updated = json.loads(update_result.stdout)
|
||||
assert updated["content"] == "Updated"
|
||||
assert updated["id"] == comment_id
|
||||
|
||||
|
||||
def test_cli_comment_delete_flow() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
add_result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", "To delete"],
|
||||
)
|
||||
assert add_result.exit_code == 0
|
||||
comment_id = json.loads(add_result.stdout)["id"]
|
||||
|
||||
delete_result = runner.invoke(
|
||||
runner.app,
|
||||
["portfolio", "comment", "delete", "005930:tv", comment_id, "--force"],
|
||||
)
|
||||
list_result = runner.invoke(runner.app, ["portfolio", "comment", "list", "005930:tv"])
|
||||
|
||||
assert delete_result.exit_code == 0
|
||||
assert comment_id in delete_result.stdout
|
||||
assert "To delete" not in list_result.stdout
|
||||
|
||||
|
||||
def test_cli_comment_multiline_content() -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
|
||||
multiline = "Line 1\nLine 2\nLine 3"
|
||||
result = runner.invoke(
|
||||
runner.app,
|
||||
["--format", "json", "portfolio", "comment", "add", "005930:tv", "--content", multiline],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["content"] == multiline
|
||||
|
||||
|
||||
def test_cli_comment_list_nonexistent_portfolio() -> None:
|
||||
repo = FakeRepo()
|
||||
runner = build_runner(repo)
|
||||
|
||||
result = runner.invoke(runner.app, ["portfolio", "comment", "list", "nonexistent:id"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Tests for portfolio comment history API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import app.api.portfolios as portfolios_api
|
||||
from app.core.config import get_settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet
|
||||
from app.main import app
|
||||
|
||||
|
||||
class FakeRepo:
|
||||
def __init__(self, portfolio: Portfolio | None = None) -> None:
|
||||
self._portfolio = portfolio
|
||||
self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3")
|
||||
self._comments: dict[str, PortfolioComment] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def ensure_schema(self) -> None:
|
||||
pass
|
||||
|
||||
def ping(self) -> None:
|
||||
pass
|
||||
|
||||
def list_users(self):
|
||||
return []
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
return self._portfolio
|
||||
|
||||
def get_or_create(self, portfolio_id: str, symbol: str, **kwargs) -> Portfolio:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_portfolio(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
portfolio = self.get(portfolio_id)
|
||||
for key, value in fields.items():
|
||||
if value is not None:
|
||||
setattr(portfolio, key, value)
|
||||
return portfolio
|
||||
|
||||
def list_portfolios(self, *, broker=None, status=None):
|
||||
return [self._portfolio] if self._portfolio else []
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
return self.strategy_set
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
return self.strategy_set if id == self.strategy_set.id else None
|
||||
|
||||
def list_strategy_sets(self):
|
||||
return [self.strategy_set]
|
||||
|
||||
def delete_strategy_set(self, id: str) -> None:
|
||||
pass
|
||||
|
||||
def save(self, portfolio, *, current_price, action, trade_price, quantity, fee_amount, tax_amount,
|
||||
total_value_after, strategy_set_id="manual", note="") -> None:
|
||||
pass
|
||||
|
||||
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
|
||||
return []
|
||||
|
||||
def reset(self, portfolio_id: str) -> Portfolio:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None:
|
||||
self._portfolio = None
|
||||
|
||||
def get_user(self, user_id: str):
|
||||
raise ValueError("사용자를 찾을 수 없습니다")
|
||||
|
||||
def get_user_by_username(self, username: str):
|
||||
return None
|
||||
|
||||
def create_user(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_user(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def delete_user(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def update_user_last_login(self, user_id: str) -> None:
|
||||
pass
|
||||
|
||||
def create_auth_token(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def list_auth_tokens(self, user_id: str):
|
||||
return []
|
||||
|
||||
def revoke_auth_token(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_bot_principal(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_bot_principal(self, bot_principal_id: str):
|
||||
raise ValueError("봇을 찾을 수 없습니다")
|
||||
|
||||
def list_bot_principals(self, *, is_active=None):
|
||||
return []
|
||||
|
||||
def deactivate_bot_principal(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def list_bot_auth_tokens(self, bot_principal_id: str):
|
||||
return []
|
||||
|
||||
def revoke_bot_auth_token(self, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_token_principal(self, token_hash: str):
|
||||
return None
|
||||
|
||||
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
now = datetime.now(timezone.utc)
|
||||
comment = PortfolioComment(
|
||||
id=str(uuid.uuid4()),
|
||||
portfolio_id=portfolio_id,
|
||||
content=content,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
self._comments[comment.id] = comment
|
||||
return comment
|
||||
|
||||
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
return sorted(
|
||||
[c for c in self._comments.values() if c.portfolio_id == portfolio_id],
|
||||
key=lambda c: c.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
comment = self._comments.get(comment_id)
|
||||
if comment is None or comment.portfolio_id != portfolio_id:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
comment.content = content
|
||||
comment.updated_at = datetime.now(timezone.utc)
|
||||
return comment
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
|
||||
if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
comment = self._comments.get(comment_id)
|
||||
if comment is None or comment.portfolio_id != portfolio_id:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
del self._comments[comment_id]
|
||||
|
||||
|
||||
def _portfolio() -> Portfolio:
|
||||
return Portfolio(
|
||||
portfolio_id="005930:tv",
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="Samsung Electronics",
|
||||
symbol="005930",
|
||||
cash_balance=1_000_000.0,
|
||||
asset_quantity=1.0,
|
||||
price=70_000.0,
|
||||
profit_buffer=50_000.0,
|
||||
active_strategy_id="set-1",
|
||||
avabile_strategies=["set-1"],
|
||||
)
|
||||
|
||||
|
||||
def _setup_client(monkeypatch, repo: FakeRepo) -> TestClient:
|
||||
monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "write-token")
|
||||
monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token")
|
||||
get_settings.cache_clear()
|
||||
app.dependency_overrides.clear()
|
||||
monkeypatch.setattr(portfolios_api, "get_portfolio_repository_from_settings", lambda settings: repo)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_comment_returns_201(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "First comment"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["content"] == "First comment"
|
||||
assert data["portfolio_id"] == "005930:tv"
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
|
||||
def test_list_comments_returns_200_sorted_by_created_desc(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "First"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "Second"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/portfolios/005930:tv/comments",
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 200
|
||||
items = response.json()
|
||||
assert len(items) == 2
|
||||
assert items[0]["content"] == "Second"
|
||||
assert items[1]["content"] == "First"
|
||||
|
||||
|
||||
def test_update_comment_returns_200(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
create_resp = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "Original"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
comment_id = create_resp.json()["id"]
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/portfolios/005930:tv/comments/{comment_id}",
|
||||
json={"content": "Updated"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert update_resp.status_code == 200
|
||||
data = update_resp.json()
|
||||
assert data["content"] == "Updated"
|
||||
assert data["id"] == comment_id
|
||||
|
||||
|
||||
def test_delete_comment_returns_204(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
create_resp = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "To delete"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
comment_id = create_resp.json()["id"]
|
||||
|
||||
delete_resp = client.delete(
|
||||
f"/portfolios/005930:tv/comments/{comment_id}",
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
list_resp = client.get(
|
||||
"/portfolios/005930:tv/comments",
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert delete_resp.status_code == 204
|
||||
assert all(c["id"] != comment_id for c in list_resp.json())
|
||||
|
||||
|
||||
def test_create_comment_nonexistent_portfolio_returns_404(monkeypatch) -> None:
|
||||
repo = FakeRepo()
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/portfolios/nonexistent/comments",
|
||||
json={"content": "Should fail"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_list_comments_nonexistent_portfolio_returns_404(monkeypatch) -> None:
|
||||
repo = FakeRepo()
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.get(
|
||||
"/portfolios/nonexistent/comments",
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_nonexistent_comment_returns_404(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.patch(
|
||||
"/portfolios/005930:tv/comments/no-such-id",
|
||||
json={"content": "Updated"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_delete_nonexistent_comment_returns_404(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.delete(
|
||||
"/portfolios/005930:tv/comments/no-such-id",
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_create_comment_without_token_returns_401(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "No auth"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_create_comment_with_read_token_returns_403(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
response = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "Should be forbidden"},
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_comment_multiline_text_stored_and_retrieved(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
multiline = "Line 1\nLine 2\nLine 3"
|
||||
create_resp = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": multiline},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
comment_id = create_resp.json()["id"]
|
||||
|
||||
list_resp = client.get(
|
||||
"/portfolios/005930:tv/comments",
|
||||
headers={"Authorization": "Bearer read-token"},
|
||||
)
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert create_resp.status_code == 201
|
||||
assert create_resp.json()["content"] == multiline
|
||||
matching = [c for c in list_resp.json() if c["id"] == comment_id]
|
||||
assert matching[0]["content"] == multiline
|
||||
|
||||
|
||||
def test_update_comment_preserves_id_and_updates_updated_at(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
client = _setup_client(monkeypatch, repo)
|
||||
|
||||
create_resp = client.post(
|
||||
"/portfolios/005930:tv/comments",
|
||||
json={"content": "Original"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
original = create_resp.json()
|
||||
comment_id = original["id"]
|
||||
|
||||
update_resp = client.patch(
|
||||
f"/portfolios/005930:tv/comments/{comment_id}",
|
||||
json={"content": "Modified"},
|
||||
headers={"Authorization": "Bearer write-token"},
|
||||
)
|
||||
updated = update_resp.json()
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
assert updated["id"] == comment_id
|
||||
assert updated["content"] == "Modified"
|
||||
Reference in New Issue
Block a user