feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)

This commit is contained in:
2026-05-19 22:15:55 +00:00
parent e2226e28b6
commit 158929b2d9
33 changed files with 2575 additions and 102 deletions
+19 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Protocol
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
class PortfolioRepository(Protocol):
@@ -181,6 +181,7 @@ class PortfolioRepository(Protocol):
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]: ...
def list_portfolios_with_strategies(
@@ -189,8 +190,25 @@ class PortfolioRepository(Protocol):
*,
include_disabled: bool = True,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]: ...
def create_tag(
self,
*,
name: str,
created_by_user_id: str,
is_admin_tag: bool = False,
) -> Tag: ...
def get_tag_by_id(self, tag_id: str) -> Tag | None: ...
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]: ...
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]: ...
def deactivate_tag(self, tag_id: str) -> Tag: ...
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ...
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ...
+120 -2
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -62,11 +62,23 @@ _SELECT_PORTFOLIO = """
p.updated_at,
p.last_trade_at,
p.managed_by_daemon_id,
p.asset_id
p.asset_id,
COALESCE(p.tag_ids, '{}'::uuid[])
FROM portfolios p
JOIN assets a ON p.asset_id = a.asset_id
"""
_SELECT_TAG = """
SELECT
tag_id,
name,
created_by_user_id,
is_admin_tag,
is_active,
created_at
FROM tags
"""
_SELECT_ASSET = """
SELECT
asset_id,
@@ -235,6 +247,13 @@ class PostgresPortfolioRepository:
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_id TEXT")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS managed_by_daemon_id TEXT")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS asset_id UUID")
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS tag_ids UUID[] NOT NULL DEFAULT '{}'")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolios_tag_ids_gin
ON portfolios USING GIN (tag_ids)
"""
)
cur.execute("UPDATE portfolios SET name = portfolio_id WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
@@ -301,6 +320,19 @@ class PostgresPortfolioRepository:
ON portfolio_tags (tag_value, portfolio_id)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS tags (
tag_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_by_user_id TEXT NOT NULL,
is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(name)
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daemons (
@@ -410,6 +442,7 @@ class PostgresPortfolioRepository:
last_trade_at=row[14] if len(row) > 14 else None,
managed_by_daemon_id=row[15] if len(row) > 15 else None,
asset_id=str(row[16]) if len(row) > 16 and row[16] is not None else None,
tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [],
)
@staticmethod
@@ -432,6 +465,19 @@ class PostgresPortfolioRepository:
updated_at=row[6],
)
@staticmethod
def _row_to_tag(row: Any) -> Tag:
if row is None:
raise ValueError("태그를 찾을 수 없습니다")
return Tag(
tag_id=str(row[0]),
name=row[1],
created_by_user_id=row[2],
is_admin_tag=bool(row[3]),
is_active=bool(row[4]),
created_at=row[5] if len(row) > 5 else None,
)
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
if not portfolio_ids:
return {}
@@ -950,6 +996,7 @@ class PostgresPortfolioRepository:
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]:
self.ensure_schema()
query = _SELECT_PORTFOLIO
@@ -993,6 +1040,9 @@ class PostgresPortfolioRepository:
"p.portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(exclude_tags)
if market is not None:
clauses.append("%s = ANY(a.markets)")
params.append(market)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY p.updated_at DESC, p.portfolio_id ASC"
@@ -1592,14 +1642,82 @@ class PostgresPortfolioRepository:
*,
include_disabled: bool = True,
managed_by_daemon_id: str | None = None,
market: str | None = None,
) -> list[Portfolio]:
status_filter = None if include_disabled else "enabled"
return self.list_portfolios(
owner_user_id=owner_user_id,
status=status_filter,
managed_by_daemon_id=managed_by_daemon_id,
market=market,
)
def create_tag(
self,
*,
name: str,
created_by_user_id: str,
is_admin_tag: bool = False,
) -> Tag:
self.ensure_schema()
with self._connect().cursor() as cur:
try:
cur.execute(
"""
INSERT INTO tags (name, created_by_user_id, is_admin_tag)
VALUES (%s, %s, %s)
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
""",
(name, created_by_user_id, is_admin_tag),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 태그 이름입니다: {name}") from exc
row = cur.fetchone()
return self._row_to_tag(row)
def get_tag_by_id(self, tag_id: str) -> Tag | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_TAG + "WHERE tag_id = %s", (tag_id,))
row = cur.fetchone()
return self._row_to_tag(row) if row is not None else None
def get_tags_by_ids(self, tag_ids: list[str]) -> dict[str, Tag]:
if not tag_ids:
return {}
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_TAG + "WHERE tag_id = ANY(%s)", (tag_ids,))
rows = cur.fetchall()
return {str(row[0]): self._row_to_tag(row) for row in rows}
def list_tags_master(self, *, include_inactive: bool = False) -> list[Tag]:
self.ensure_schema()
query = _SELECT_TAG
if not include_inactive:
query += "WHERE is_active = TRUE "
query += "ORDER BY name ASC"
with self._connect().cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
return [self._row_to_tag(row) for row in rows]
def deactivate_tag(self, tag_id: str) -> Tag:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE tags SET is_active = FALSE
WHERE tag_id = %s
RETURNING tag_id, name, created_by_user_id, is_admin_tag, is_active, created_at
""",
(tag_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError(f"태그를 찾을 수 없습니다: {tag_id}")
return self._row_to_tag(row)
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix