diff --git a/app/api/portfolios.py b/app/api/portfolios.py index c793468..230384a 100644 --- a/app/api/portfolios.py +++ b/app/api/portfolios.py @@ -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) diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index d3ca1cc..01936dd 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -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}") \ No newline at end of file + 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}") \ No newline at end of file diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index 185ed7c..7817c93 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -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) diff --git a/app/domain/models.py b/app/domain/models.py index 85d1096..8559438 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -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: ... diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index b441a11..4bdc36b 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -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: ... + diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index cbb9481..4422097 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -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( diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 6832b08..2f77f19 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -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") diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index 0feecd8..7308781 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -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), diff --git a/docs/api-examples.md b/docs/api-examples.md index fbd7c7a..5ab64c1 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -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 " \ + -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 " +``` + +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 " \ + -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 " +``` + +Response 204: + +- No content. + ## 5. Strategy Sets ### 5.1 GET /strategy-sets diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index 4f9c1ab..0f1f9ed 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -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 | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index f732bb0..3a1fb6a 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -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 [--infer-symbol ...] [--force]` - `portfoliohub portfolio delete [--keep-ledger] [--force]` +Portfolio comment subcommands: + +- `portfoliohub portfolio comment add --content ` +- `portfoliohub portfolio comment list ` +- `portfoliohub portfolio comment update --content [--force]` +- `portfoliohub portfolio comment delete [--force]` + Strategy subcommands: - `portfoliohub strategy list` diff --git a/migrations/versions/20260410_0009_portfolio_comments_table.py b/migrations/versions/20260410_0009_portfolio_comments_table.py new file mode 100644 index 0000000..0a238a8 --- /dev/null +++ b/migrations/versions/20260410_0009_portfolio_comments_table.py @@ -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") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3a1dcb0..0f5cf38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 \ No newline at end of file + 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 diff --git a/tests/test_portfolio_comments.py b/tests/test_portfolio_comments.py new file mode 100644 index 0000000..d9d30d2 --- /dev/null +++ b/tests/test_portfolio_comments.py @@ -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"