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

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

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-04-10 13:54:18 +00:00
committed by GitHub
co-authored by genichin
parent 4684597353
commit e8f782bec6
14 changed files with 1173 additions and 10 deletions
+9 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -147,3 +147,11 @@ class PortfolioRepository(Protocol):
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None: ...
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment: ...
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]: ...
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
+100 -1
View File
@@ -12,7 +12,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -263,6 +263,23 @@ class PostgresPortfolioRepository:
ON portfolios (broker, status, updated_at DESC)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_comments (
id TEXT PRIMARY KEY,
portfolio_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_comments_portfolio_created
ON portfolio_comments (portfolio_id, created_at DESC)
"""
)
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]:
cur.execute(
@@ -1173,8 +1190,90 @@ class PostgresPortfolioRepository:
raise ValueError("포트폴리오를 찾을 수 없습니다")
if clear_ledger:
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolio_comments WHERE portfolio_id = %s", (portfolio_id,))
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
@staticmethod
def _row_to_comment(row: Any) -> PortfolioComment:
if row is None:
raise ValueError("댓글을 찾을 수 없습니다")
return PortfolioComment(
id=row[0],
portfolio_id=row[1],
content=row[2],
created_at=row[3],
updated_at=row[4],
)
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
comment_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO portfolio_comments (id, portfolio_id, content)
VALUES (%s, %s, %s)
RETURNING id, portfolio_id, content, created_at, updated_at
""",
(comment_id, portfolio_id, content),
)
row = cur.fetchone()
return self._row_to_comment(row)
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
SELECT id, portfolio_id, content, created_at, updated_at
FROM portfolio_comments
WHERE portfolio_id = %s
ORDER BY created_at DESC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_comment(row) for row in rows]
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"""
UPDATE portfolio_comments
SET content = %s, updated_at = NOW()
WHERE id = %s AND portfolio_id = %s
RETURNING id, portfolio_id, content, created_at, updated_at
""",
(content, comment_id, portfolio_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("댓글을 찾을 수 없습니다")
return self._row_to_comment(row)
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute(
"DELETE FROM portfolio_comments WHERE id = %s AND portfolio_id = %s RETURNING id",
(comment_id, portfolio_id),
)
if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다")
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(