Merge pull request #3 from genichin/copilot/req-001-portfolio-comment-history-api-cli

feat(REQ-001): Portfolio comment history — API, CLI, repository, migration
This commit is contained in:
2026-04-10 23:24:56 +09:00
committed by GitHub
14 changed files with 1176 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: ...
+101 -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,91 @@ class PostgresPortfolioRepository:
raise ValueError("포트폴리오를 찾을 수 없습니다")
if clear_ledger:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
# Comments are always removed with the portfolio (no FK constraint, handled in application layer)
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),
+98
View File
@@ -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
+35 -2
View File
@@ -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:
@@ -276,6 +306,8 @@ Current baseline intentionally has no DB-level FK constraints.
- Logical relations are managed by application/repository logic.
- When adding FK constraints in future migrations, update this document and include migration IDs.
- Cascade behavior implemented in application layer:
- Deleting a portfolio also deletes all associated `portfolio_comments` rows (hard delete, no FK constraint).
## 8. Change Management Rule
@@ -297,3 +329,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 |
+53
View File
@@ -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")
+155 -2
View File
@@ -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")
@@ -525,4 +568,114 @@ def test_cli_user_update_and_delete_flow() -> None:
assert update_result.exit_code == 0
assert "alice-updated" in update_result.stdout
assert delete_result.exit_code == 0
assert f"Deleted user: {user.id}" in delete_result.stdout
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
+426
View File
@@ -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"