feat: asset catalog API, 태그 DB 기초, 마이그레이션 3건 및 도메인/테스트 갱신 (2026-05-19)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user