feat(REQ-001): add portfolio comment history API, CLI, repository, migration, and tests
Agent-Logs-Url: https://github.com/genichin/portfolio-hub/sessions/64fc2cec-78f5-41d1-99a4-45f868781edd Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
4684597353
commit
e8f782bec6
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
|
||||
|
||||
|
||||
class PortfolioRepository(Protocol):
|
||||
@@ -147,3 +147,11 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None: ...
|
||||
|
||||
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment: ...
|
||||
|
||||
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]: ...
|
||||
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment: ...
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None: ...
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -263,6 +263,23 @@ class PostgresPortfolioRepository:
|
||||
ON portfolios (broker, status, updated_at DESC)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
portfolio_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_portfolio_comments_portfolio_created
|
||||
ON portfolio_comments (portfolio_id, created_at DESC)
|
||||
"""
|
||||
)
|
||||
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
|
||||
if cur.fetchone()[0]:
|
||||
cur.execute(
|
||||
@@ -1173,8 +1190,90 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
if clear_ledger:
|
||||
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
|
||||
cur.execute("DELETE FROM portfolio_comments WHERE portfolio_id = %s", (portfolio_id,))
|
||||
cur.execute("DELETE FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
|
||||
@staticmethod
|
||||
def _row_to_comment(row: Any) -> PortfolioComment:
|
||||
if row is None:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
return PortfolioComment(
|
||||
id=row[0],
|
||||
portfolio_id=row[1],
|
||||
content=row[2],
|
||||
created_at=row[3],
|
||||
updated_at=row[4],
|
||||
)
|
||||
|
||||
def create_comment(self, portfolio_id: str, *, content: str) -> PortfolioComment:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
comment_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO portfolio_comments (id, portfolio_id, content)
|
||||
VALUES (%s, %s, %s)
|
||||
RETURNING id, portfolio_id, content, created_at, updated_at
|
||||
""",
|
||||
(comment_id, portfolio_id, content),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_comment(row)
|
||||
|
||||
def list_comments(self, portfolio_id: str) -> list[PortfolioComment]:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, portfolio_id, content, created_at, updated_at
|
||||
FROM portfolio_comments
|
||||
WHERE portfolio_id = %s
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(portfolio_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_comment(row) for row in rows]
|
||||
|
||||
def update_comment(self, portfolio_id: str, comment_id: str, *, content: str) -> PortfolioComment:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE portfolio_comments
|
||||
SET content = %s, updated_at = NOW()
|
||||
WHERE id = %s AND portfolio_id = %s
|
||||
RETURNING id, portfolio_id, content, created_at, updated_at
|
||||
""",
|
||||
(content, comment_id, portfolio_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
return self._row_to_comment(row)
|
||||
|
||||
def delete_comment(self, portfolio_id: str, comment_id: str) -> None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
cur.execute(
|
||||
"DELETE FROM portfolio_comments WHERE id = %s AND portfolio_id = %s RETURNING id",
|
||||
(comment_id, portfolio_id),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
|
||||
|
||||
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
with psycopg.connect(
|
||||
|
||||
Reference in New Issue
Block a user