Files
portfolio-hub/app/repositories/postgres.py
T
genichin d194873616 feat: implement broker credential repository with upsert and list operations
- Add BrokerCredential to domain models and repositories
- Implement upsert_broker_credential() for credential storage and rotation
- Implement list_broker_credentials() for credential retrieval
- Implement delete_broker_credential() for credential removal
- Add broker_credentials table schema with (user_id, broker) composite key
- Implement credential version tracking and last_rotated_at timestamps
- Add FakeRepo support for broker credential CRUD operations
- Add comprehensive test coverage for broker credential lifecycle
2026-05-14 07:56:37 +00:00

1660 lines
64 KiB
Python

"""Postgres repository implementation for portfolios and ledger."""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
id,
username,
email,
permission,
is_active,
password_hash,
created_at,
updated_at,
last_login_at
FROM users
"""
_SELECT_AUTH_TOKEN = """
SELECT
id,
user_id,
name,
token_prefix,
permission,
token_type,
expires_at,
last_used_at,
revoked_at,
created_at,
runtime_id,
daemon_id
FROM auth_tokens
"""
_SELECT_PORTFOLIO = """
SELECT
portfolio_id,
broker,
status,
name,
symbol,
cash_balance,
asset_quantity,
asset_price,
profit_buffer,
active_strategy_id,
avabile_strategies,
owner_user_id,
created_at,
updated_at,
last_trade_at,
managed_by_daemon_id
FROM portfolios
"""
@dataclass(frozen=True)
class PortfolioDBConfig:
host: str
port: int
dbname: str
user: str
password: str
sslmode: str
connect_timeout_sec: int = 10
application_name: str = "portfoliohub"
class PostgresPortfolioRepository:
def __init__(self, config: PortfolioDBConfig) -> None:
self._config = config
self._connection: psycopg.Connection | None = None
def _connect(self) -> psycopg.Connection:
if self._connection is None:
self._connection = psycopg.connect(
host=self._config.host,
port=self._config.port,
dbname=self._config.dbname,
user=self._config.user,
password=self._config.password,
sslmode=self._config.sslmode,
connect_timeout=self._config.connect_timeout_sec,
application_name=self._config.application_name,
autocommit=True,
)
return self._connection
def close(self) -> None:
if self._connection is not None:
self._connection.close()
self._connection = None
def ping(self) -> None:
with self._connect().cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
def ensure_schema(self) -> None:
conn = self._connect()
with conn.cursor() as cur:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT UNIQUE,
permission TEXT NOT NULL DEFAULT 'read',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_login_at TIMESTAMPTZ
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS auth_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_prefix TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
permission TEXT NOT NULL,
token_type TEXT NOT NULL,
runtime_id TEXT,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS runtime_id TEXT")
cur.execute("ALTER TABLE auth_tokens ADD COLUMN IF NOT EXISTS daemon_id TEXT")
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user_created
ON auth_tokens (user_id, created_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash_active
ON auth_tokens (token_hash)
"""
)
cur.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_tokens_service_name_unique
ON auth_tokens (name)
WHERE token_type = 'service'
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS strategy_sets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '',
nick TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (name, config)
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolios (
portfolio_id TEXT PRIMARY KEY,
broker TEXT NOT NULL DEFAULT 'kis',
status TEXT NOT NULL DEFAULT 'enabled',
name TEXT NOT NULL,
symbol TEXT NOT NULL,
owner_user_id TEXT,
cash_balance DOUBLE PRECISION NOT NULL,
asset_quantity DOUBLE PRECISION NOT NULL,
asset_price DOUBLE PRECISION NOT NULL DEFAULT 0,
profit_buffer DOUBLE PRECISION NOT NULL DEFAULT 0,
active_strategy_id TEXT DEFAULT NULL,
avabile_strategies TEXT[] DEFAULT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_trade_at TIMESTAMPTZ
)
"""
)
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS name TEXT")
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("UPDATE portfolios SET name = symbol WHERE name IS NULL OR name = ''")
cur.execute("ALTER TABLE portfolios ALTER COLUMN name SET NOT NULL")
cur.execute(
"""
CREATE TABLE IF NOT EXISTS portfolio_ledger (
portfolio_id TEXT NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL,
action TEXT NOT NULL,
price DOUBLE PRECISION NOT NULL,
quantity DOUBLE PRECISION NOT NULL,
fee_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
tax_amount DOUBLE PRECISION NOT NULL DEFAULT 0,
cash_balance_after DOUBLE PRECISION NOT NULL,
asset_quantity_after DOUBLE PRECISION NOT NULL,
total_value_after DOUBLE PRECISION NOT NULL,
profit_buffer_after DOUBLE PRECISION NOT NULL DEFAULT 0,
strategy_set_id TEXT NOT NULL DEFAULT 'legacy',
note TEXT NOT NULL DEFAULT ''
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_ledger_portfolio_time
ON portfolio_ledger (portfolio_id, recorded_at DESC)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolios_broker_status_updated
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(
"""
CREATE TABLE IF NOT EXISTS portfolio_tags (
portfolio_id TEXT NOT NULL,
tag_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (portfolio_id, tag_value)
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portfolio_tags_tag_portfolio
ON portfolio_tags (tag_value, portfolio_id)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS daemons (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL,
daemon_slug TEXT NOT NULL,
display_name TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (owner_user_id, daemon_slug)
)
"""
)
cur.execute(
"""
CREATE INDEX IF NOT EXISTS idx_daemons_owner
ON daemons (owner_user_id)
"""
)
cur.execute("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')")
if cur.fetchone()[0]:
cur.execute(
"""
SELECT create_hypertable('portfolio_ledger', 'recorded_at', if_not_exists => TRUE)
"""
)
@staticmethod
def _row_to_user(row: Any) -> User:
if row is None:
raise ValueError("사용자를 찾을 수 없습니다")
return User(
id=row[0],
username=row[1],
email=row[2],
permission=row[3] if len(row) > 3 else "read",
is_active=bool(row[4]) if len(row) > 4 else True,
password_hash=row[5] if len(row) > 5 else None,
created_at=row[6] if len(row) > 6 else None,
updated_at=row[7] if len(row) > 7 else None,
last_login_at=row[8] if len(row) > 8 else None,
)
@staticmethod
def _row_to_auth_token(row: Any) -> AuthToken:
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
return AuthToken(
id=row[0],
user_id=row[1],
name=row[2],
token_prefix=row[3],
permission=row[4],
token_type=row[5],
expires_at=row[6] if len(row) > 6 else None,
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None,
daemon_id=row[11] if len(row) > 11 else None,
)
@staticmethod
def _row_to_broker_credential(row: Any) -> BrokerCredential:
if row is None:
raise ValueError("브로커 인증 정보를 찾을 수 없습니다")
return BrokerCredential(
user_id=row[0],
broker=row[1],
account_ref=row[2],
encrypted_payload=row[3],
credential_version=int(row[4]) if len(row) > 4 else 1,
last_rotated_at=row[5] if len(row) > 5 else None,
)
@staticmethod
def _row_to_portfolio(row: Any) -> Portfolio:
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
raw_available = row[10] if len(row) > 10 else None
if raw_available is None:
avabile_strategies = None
elif isinstance(raw_available, list):
avabile_strategies = [str(item) for item in raw_available]
else:
avabile_strategies = [str(raw_available)]
return Portfolio(
portfolio_id=row[0],
broker=row[1] or "kis",
status=row[2] or "enabled",
name=row[3],
symbol=row[4],
tags=None,
cash_balance=float(row[5]),
asset_quantity=float(row[6]),
price=float(row[7]),
profit_buffer=float(row[8]),
active_strategy_id=row[9] if len(row) > 9 else None,
avabile_strategies=avabile_strategies,
owner_user_id=row[11] if len(row) > 11 else None,
created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 else None,
managed_by_daemon_id=row[15] if len(row) > 15 else None,
)
@staticmethod
def _row_to_portfolio_tag(row: Any) -> PortfolioTag:
if row is None:
raise ValueError("태그를 찾을 수 없습니다")
return PortfolioTag(portfolio_id=row[0], tag=row[1], created_at=row[2] if len(row) > 2 else None)
def _load_tags_for_portfolios(self, cur: psycopg.Cursor, portfolio_ids: list[str]) -> dict[str, list[str]]:
if not portfolio_ids:
return {}
cur.execute(
"""
SELECT portfolio_id, tag_value
FROM portfolio_tags
WHERE portfolio_id = ANY(%s)
ORDER BY tag_value ASC
""",
(portfolio_ids,),
)
tags_by_portfolio: dict[str, list[str]] = {portfolio_id: [] for portfolio_id in portfolio_ids}
for portfolio_id, tag_value in cur.fetchall():
tags_by_portfolio.setdefault(str(portfolio_id), []).append(str(tag_value))
return tags_by_portfolio
def _attach_tags(self, cur: psycopg.Cursor, portfolios: list[Portfolio]) -> list[Portfolio]:
tags_by_portfolio = self._load_tags_for_portfolios(cur, [portfolio.portfolio_id for portfolio in portfolios])
for portfolio in portfolios:
portfolio.tags = tags_by_portfolio.get(portfolio.portfolio_id, [])
return portfolios
@staticmethod
def _row_to_ledger_entry(row: Any) -> LedgerEntry:
return LedgerEntry(
portfolio_id=row[0],
recorded_at=row[1],
action=row[2],
price=float(row[3]),
quantity=float(row[4]),
fee_amount=float(row[5]),
tax_amount=float(row[6]),
cash_balance_after=float(row[7]),
asset_quantity_after=float(row[8]),
total_value_after=float(row[9]),
profit_buffer_after=float(row[10]),
strategy_set_id=row[11] or "legacy",
note=row[12] or "",
)
def _ensure_user_uniqueness(
self,
cur: psycopg.Cursor,
*,
username: str | None = None,
email: str | None = None,
exclude_user_id: str | None = None,
) -> None:
if username is not None:
if exclude_user_id is None:
cur.execute("SELECT 1 FROM users WHERE username = %s", (username,))
else:
cur.execute("SELECT 1 FROM users WHERE username = %s AND id <> %s", (username, exclude_user_id))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 username입니다: {username}")
if email is not None:
if exclude_user_id is None:
cur.execute("SELECT 1 FROM users WHERE email = %s", (email,))
else:
cur.execute("SELECT 1 FROM users WHERE email = %s AND id <> %s", (email, exclude_user_id))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 email입니다: {email}")
def get_user(self, user_id: str) -> User:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE id = %s", (user_id,))
row = cur.fetchone()
return self._row_to_user(row)
def get_user_by_username(self, username: str) -> User | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE username = %s", (username,))
row = cur.fetchone()
if row is None:
return None
return self._row_to_user(row)
def list_users(self) -> list[User]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "ORDER BY created_at DESC, username ASC")
rows = cur.fetchall()
return [self._row_to_user(row) for row in rows]
def create_user(
self,
username: str,
*,
email: str | None = None,
password_hash: str | None = None,
permission: str = "read",
is_active: bool = True,
) -> User:
self.ensure_schema()
with self._connect().cursor() as cur:
self._ensure_user_uniqueness(cur, username=username, email=email)
user_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO users (id, username, email, permission, is_active, password_hash)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at
""",
(user_id, username, email, permission, is_active, password_hash),
)
row = cur.fetchone()
return self._row_to_user(row)
def update_user(
self,
user_id: str,
*,
username: str | None = None,
email: str | None = None,
password_hash: str | None = None,
permission: str | None = None,
is_active: bool | None = None,
) -> User:
self.ensure_schema()
assignments: list[str] = []
params: list[object] = []
with self._connect().cursor() as cur:
cur.execute(_SELECT_USER + "WHERE id = %s", (user_id,))
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError("사용자를 찾을 수 없습니다")
self._ensure_user_uniqueness(cur, username=username, email=email, exclude_user_id=user_id)
if username is not None:
assignments.append("username = %s")
params.append(username)
if email is not None:
assignments.append("email = %s")
params.append(email)
if password_hash is not None:
assignments.append("password_hash = %s")
params.append(password_hash)
if permission is not None:
assignments.append("permission = %s")
params.append(permission)
if is_active is not None:
assignments.append("is_active = %s")
params.append(is_active)
if not assignments:
return self._row_to_user(existing_row)
assignments.append("updated_at = NOW()")
cur.execute(
f"""
UPDATE users
SET {', '.join(assignments)}
WHERE id = %s
RETURNING id, username, email, permission, is_active, password_hash, created_at, updated_at, last_login_at
""",
(*params, user_id),
)
row = cur.fetchone()
return self._row_to_user(row)
def delete_user(self, user_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
cur.execute("DELETE FROM auth_tokens WHERE user_id = %s", (user_id,))
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
def update_user_last_login(self, user_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s",
(user_id,),
)
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
) -> BrokerCredential:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload)
VALUES (%s, %s, %s, %s)
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s
ORDER BY broker ASC
""",
(user_id,),
)
rows = cur.fetchall()
return [self._row_to_broker_credential(row) for row in rows]
def delete_broker_credential(self, user_id: str, broker: str) -> bool:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"DELETE FROM broker_credentials WHERE user_id = %s AND broker = %s RETURNING user_id",
(user_id, broker),
)
row = cur.fetchone()
return row is not None
def create_auth_token(
self,
user_id: str,
*,
name: str,
token_hash: str,
token_prefix: str,
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
daemon_id: str | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, runtime_id, daemon_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, runtime_id, daemon_id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
def list_auth_tokens(self, user_id: str) -> list[AuthToken]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE user_id = %s ORDER BY created_at DESC, id ASC",
(user_id,),
)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def list_service_tokens(
self,
user_id: str | None = None,
*,
include_revoked: bool = False,
) -> list[AuthToken]:
self.ensure_schema()
conditions = ["token_type = 'service'"]
params: list = []
if user_id is not None:
conditions.append("user_id = %s")
params.append(user_id)
if not include_revoked:
conditions.append("revoked_at IS NULL")
where = "WHERE " + " AND ".join(conditions) + " "
with self._connect().cursor() as cur:
cur.execute(_SELECT_AUTH_TOKEN + where + "ORDER BY created_at DESC, id ASC", params)
rows = cur.fetchall()
return [self._row_to_auth_token(row) for row in rows]
def revoke_auth_token(self, user_id: str, token_id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s AND user_id = %s RETURNING id",
(token_id, user_id),
)
row = cur.fetchone()
if row is None:
raise ValueError("토큰을 찾을 수 없습니다")
def get_service_token_by_name(self, name: str) -> AuthToken | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
(name,),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_auth_token(row)
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id, t.daemon_id
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
AND (t.expires_at IS NULL OR t.expires_at > NOW())
""",
(token_hash,),
)
row = cur.fetchone()
if row is None:
return None
cur.execute("UPDATE auth_tokens SET last_used_at = NOW() WHERE id = %s", (row[0],))
return TokenPrincipal(
token_id=row[0],
user_id=row[1],
permission=row[2],
token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
daemon_id=row[5] if len(row) > 5 else None,
)
def get(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
portfolio = self._row_to_portfolio(row)
self._attach_tags(cur, [portfolio])
return portfolio
def get_or_create(
self,
portfolio_id: str,
symbol: str,
*,
name: str | None = None,
broker: str = "kis",
status: str = "enabled",
active_strategy_id: str | None = None,
avabile_strategies: list[str] | None = None,
owner_user_id: str | None = None,
initial_cash: float = 0.0,
) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (portfolio_id) DO NOTHING
""",
(
portfolio_id,
broker,
status,
name or symbol,
symbol,
owner_user_id,
float(initial_cash),
0.0,
0.0,
0.0,
active_strategy_id,
avabile_strategies,
),
)
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
return self._row_to_portfolio(row)
def create_portfolio(
self,
portfolio_id: str,
*,
owner_user_id: str,
broker: str,
status: str,
name: str,
symbol: str,
cash_balance: float,
asset_quantity: float,
asset_price: float,
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
) -> Portfolio:
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 not None:
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
cur.execute(
"""
INSERT INTO portfolios (
portfolio_id, broker, status, name, symbol,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(
portfolio_id,
broker,
status,
name,
symbol,
owner_user_id,
float(cash_balance),
float(asset_quantity),
float(asset_price),
float(profit_buffer),
active_strategy_id,
avabile_strategies,
),
)
row = cur.fetchone()
portfolio = self._row_to_portfolio(row)
portfolio.tags = []
return portfolio
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
self.ensure_schema()
allowed = {
"broker": "broker",
"status": "status",
"name": "name",
"symbol": "symbol",
"asset_price": "asset_price",
"active_strategy_id": "active_strategy_id",
"avabile_strategies": "avabile_strategies",
}
assignments: list[str] = []
params: list[object] = []
for key, value in fields.items():
if value is None or key not in allowed:
continue
if key == "avabile_strategies":
assignments.append("avabile_strategies = %s")
params.append(value)
else:
assignments.append(f"{allowed[key]} = %s")
params.append(value)
if not assignments:
return self.get(portfolio_id)
assignments.append("updated_at = NOW()")
with self._connect().cursor() as cur:
cur.execute(
f"""
UPDATE portfolios
SET {', '.join(assignments)}
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(*params, portfolio_id),
)
row = cur.fetchone()
return self._row_to_portfolio(row)
def list_portfolios(
self,
*,
broker: str | None = None,
status: str | None = None,
owner_user_id: str | None = None,
include_tags: list[str] | None = None,
exclude_tags: list[str] | None = None,
match_mode: str | None = None,
managed_by_daemon_id: str | None = None,
) -> list[Portfolio]:
self.ensure_schema()
query = _SELECT_PORTFOLIO
clauses: list[str] = []
params: list[object] = []
if broker is not None:
clauses.append("broker = %s")
params.append(broker)
if status is not None:
clauses.append("status = %s")
params.append(status)
if owner_user_id is not None:
clauses.append("owner_user_id = %s")
params.append(owner_user_id)
if managed_by_daemon_id is not None:
clauses.append("managed_by_daemon_id = %s")
params.append(managed_by_daemon_id)
normalized_mode = (match_mode or "OR").upper()
if include_tags:
if normalized_mode == "AND":
clauses.append(
"""
portfolio_id IN (
SELECT portfolio_id
FROM portfolio_tags
WHERE tag_value = ANY(%s)
GROUP BY portfolio_id
HAVING COUNT(DISTINCT tag_value) = %s
)
"""
)
params.append(include_tags)
params.append(len(set(include_tags)))
else:
clauses.append(
"portfolio_id IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(include_tags)
if exclude_tags:
clauses.append(
"portfolio_id NOT IN (SELECT DISTINCT portfolio_id FROM portfolio_tags WHERE tag_value = ANY(%s))"
)
params.append(exclude_tags)
if clauses:
query += "WHERE " + " AND ".join(clauses) + " "
query += "ORDER BY updated_at DESC, portfolio_id ASC"
with self._connect().cursor() as cur:
cur.execute(query, tuple(params))
rows = cur.fetchall()
portfolios = [self._row_to_portfolio(row) for row in rows]
self._attach_tags(cur, portfolios)
return portfolios
def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag:
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("포트폴리오를 찾을 수 없습니다")
try:
cur.execute(
"""
INSERT INTO portfolio_tags (portfolio_id, tag_value)
VALUES (%s, %s)
RETURNING portfolio_id, tag_value, created_at
""",
(portfolio_id, tag),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 연결된 태그입니다: {tag}") from exc
row = cur.fetchone()
return self._row_to_portfolio_tag(row)
def list_tags(self, portfolio_id: str) -> list[PortfolioTag]:
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 portfolio_id, tag_value, created_at
FROM portfolio_tags
WHERE portfolio_id = %s
ORDER BY tag_value ASC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_portfolio_tag(row) for row in rows]
def remove_tag(self, portfolio_id: str, *, tag: 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_tags WHERE portfolio_id = %s AND tag_value = %s RETURNING portfolio_id",
(portfolio_id, tag),
)
if cur.fetchone() is None:
raise ValueError("태그를 찾을 수 없습니다")
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
self.ensure_schema()
nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
with self._connect().cursor() as cur:
new_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO strategy_sets (id, name, config, nick)
VALUES (%s, %s, %s, %s)
ON CONFLICT (name, config) DO NOTHING
""",
(new_id, name, config, nick),
)
cur.execute(
"""
SELECT id, name, config, nick, created_at
FROM strategy_sets
WHERE name = %s AND config = %s
""",
(name, config),
)
row = cur.fetchone()
return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4])
def get_strategy_set(self, id: str) -> StrategySet | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"SELECT id, name, config, nick, created_at FROM strategy_sets WHERE id = %s",
(id,),
)
row = cur.fetchone()
if row is None:
return None
return StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4])
def list_strategy_sets(self) -> list[StrategySet]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"SELECT id, name, config, nick, created_at FROM strategy_sets ORDER BY created_at DESC"
)
rows = cur.fetchall()
return [StrategySet(id=row[0], name=row[1], config=row[2], nick=row[3], created_at=row[4]) for row in rows]
def delete_strategy_set(self, id: str) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
# 1. Check if this strategy set is referenced by any portfolio
cur.execute(
"""
SELECT COUNT(*) FROM portfolios
WHERE active_strategy_id = %s
OR avabile_strategies @> ARRAY[%s]
""",
(id, id),
)
count = cur.fetchone()[0]
if count > 0:
raise ValueError(f"Strategy set is in use by {count} portfolio(s) and cannot be deleted")
# 2. If not in use, proceed with deletion
cur.execute("DELETE FROM strategy_sets WHERE id = %s", (id,))
def save(
self,
portfolio: Portfolio,
*,
current_price: float,
action: str,
trade_price: float,
quantity: float,
fee_amount: float,
tax_amount: float,
total_value_after: float,
strategy_set_id: str = "manual",
note: str = "",
) -> None:
self.ensure_schema()
trade_time = datetime.now(timezone.utc)
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET broker = %s,
status = %s,
symbol = %s,
cash_balance = %s,
asset_quantity = %s,
asset_price = %s,
profit_buffer = %s,
active_strategy_id = %s,
avabile_strategies = %s,
updated_at = %s,
last_trade_at = CASE WHEN %s IN ('BUY', 'SELL') THEN %s ELSE last_trade_at END
WHERE portfolio_id = %s
""",
(
portfolio.broker,
portfolio.status,
portfolio.symbol,
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(current_price),
float(portfolio.profit_buffer),
portfolio.active_strategy_id,
portfolio.avabile_strategies,
trade_time,
action,
trade_time,
portfolio.portfolio_id,
),
)
if action in {"BUY", "SELL"}:
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, action, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio.portfolio_id,
trade_time,
action,
float(trade_price),
float(quantity),
float(fee_amount),
float(tax_amount),
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(total_value_after),
float(portfolio.profit_buffer),
strategy_set_id,
note,
),
)
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
portfolio_id, recorded_at, action, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s
ORDER BY recorded_at DESC
LIMIT %s
""",
(portfolio_id, int(limit)),
)
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
def reset(self, portfolio_id: str) -> Portfolio:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
UPDATE portfolios
SET cash_balance = 0,
asset_quantity = 0,
asset_price = 0,
profit_buffer = 0,
active_strategy_id = NULL,
updated_at = NOW(),
last_trade_at = NULL
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(portfolio_id,),
)
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
cur.execute("DELETE FROM portfolio_ledger WHERE portfolio_id = %s", (portfolio_id,))
return self._row_to_portfolio(row)
def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio:
self.ensure_schema()
normalized = str(action).upper()
if normalized not in {"INVEST", "PROFIT", "WITHDRAW"}:
raise ValueError("action은 INVEST, PROFIT, WITHDRAW 중 하나여야 합니다")
if amount <= 0:
raise ValueError("amount는 0보다 커야 합니다")
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
updated_buffer = float(portfolio.profit_buffer)
if normalized == "INVEST":
updated_buffer += float(amount)
else:
updated_buffer -= float(amount)
if updated_buffer < 0:
raise ValueError("수익버퍼 잔액이 부족합니다")
cur.execute(
"""
UPDATE portfolios
SET profit_buffer = %s,
updated_at = NOW()
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(updated_buffer, portfolio_id),
)
updated_row = cur.fetchone()
total_value = float(portfolio.cash_balance) + float(portfolio.asset_quantity) * float(portfolio.price)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, action, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio_id,
normalized,
0.0,
0.0,
0.0,
0.0,
float(portfolio.cash_balance),
float(portfolio.asset_quantity),
float(total_value),
float(updated_buffer),
"manual",
note,
),
)
return self._row_to_portfolio(updated_row)
def transfer_buffer(self, portfolio_id: str, amount: float, direction: str, note: str = "") -> Portfolio:
self.ensure_schema()
normalized = str(direction).lower()
if normalized not in {"to-buffer", "from-buffer"}:
raise ValueError("direction은 to-buffer 또는 from-buffer 여야 합니다")
if amount <= 0:
raise ValueError("amount는 0보다 커야 합니다")
with self._connect().cursor() as cur:
cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,))
row = cur.fetchone()
if row is None:
raise ValueError("포트폴리오를 찾을 수 없습니다")
portfolio = self._row_to_portfolio(row)
cash = float(portfolio.cash_balance)
buf = float(portfolio.profit_buffer)
if normalized == "to-buffer":
if cash < float(amount):
raise ValueError("이체 금액이 현금 잔고를 초과합니다")
cash -= float(amount)
buf += float(amount)
ledger_action = "BUFFER_IN"
else:
if buf < float(amount):
raise ValueError("이체 금액이 수익버퍼 잔고를 초과합니다")
buf -= float(amount)
cash += float(amount)
ledger_action = "BUFFER_OUT"
cur.execute(
"""
UPDATE portfolios
SET cash_balance = %s,
profit_buffer = %s,
updated_at = NOW()
WHERE portfolio_id = %s
RETURNING
portfolio_id, broker, status, name, symbol,
cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies, owner_user_id, created_at, updated_at, last_trade_at
""",
(cash, buf, portfolio_id),
)
updated_row = cur.fetchone()
total_value = float(cash) + float(portfolio.asset_quantity) * float(portfolio.price)
cur.execute(
"""
INSERT INTO portfolio_ledger (
portfolio_id, recorded_at, action, price, quantity, fee_amount,
tax_amount, cash_balance_after, asset_quantity_after,
total_value_after, profit_buffer_after, strategy_set_id, note
) VALUES (%s, NOW(), %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio_id,
ledger_action,
0.0,
0.0,
0.0,
0.0,
float(cash),
float(portfolio.asset_quantity),
float(total_value),
float(buf),
"manual",
note,
),
)
return self._row_to_portfolio(updated_row)
def delete(self, portfolio_id: str, *, clear_ledger: bool = True) -> None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT portfolio_id FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is None:
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 list_portfolios_with_strategies(
self,
owner_user_id: str,
*,
include_disabled: bool = True,
managed_by_daemon_id: 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,
)
def rotate_service_token(self, name: str) -> tuple[str, AuthToken]:
from app.core.security import generate_token, hash_token, token_prefix as _token_prefix
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL",
(name,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
existing = self._row_to_auth_token(existing_row)
new_raw = generate_token()
new_runtime_id = str(uuid.uuid4())
new_hash = hash_token(new_raw)
new_prefix = _token_prefix(new_raw)
cur.execute("UPDATE auth_tokens SET revoked_at = NOW() WHERE id = %s", (existing.id,))
token_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type,
expires_at, runtime_id, daemon_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
""",
(
token_id,
existing.user_id,
name,
new_prefix,
new_hash,
existing.permission,
"service",
existing.expires_at,
new_runtime_id,
existing.daemon_id,
),
)
row = cur.fetchone()
return new_raw, self._row_to_auth_token(row)
def rebind_service_token(self, name: str, daemon_id: str) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
_SELECT_AUTH_TOKEN + "WHERE name = %s AND token_type = 'service' AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1",
(name,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
existing = self._row_to_auth_token(existing_row)
cur.execute(
"""
UPDATE auth_tokens
SET daemon_id = %s
WHERE id = %s
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at, runtime_id, daemon_id
""",
(daemon_id, existing.id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@staticmethod
def _row_to_daemon(row: Any) -> Daemon:
if row is None:
raise ValueError("데몬을 찾을 수 없습니다")
return Daemon(
id=row[0],
owner_user_id=row[1],
daemon_slug=row[2],
display_name=row[3],
is_active=bool(row[4]),
created_at=row[5] if len(row) > 5 else None,
updated_at=row[6] if len(row) > 6 else None,
)
def create_daemon(
self,
owner_user_id: str,
*,
daemon_slug: str,
display_name: str | None = None,
) -> Daemon:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute("SELECT 1 FROM users WHERE id = %s", (owner_user_id,))
if cur.fetchone() is None:
raise ValueError("사용자를 찾을 수 없습니다")
daemon_id = str(uuid.uuid4())
try:
cur.execute(
"""
INSERT INTO daemons (id, owner_user_id, daemon_slug, display_name)
VALUES (%s, %s, %s, %s)
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
""",
(daemon_id, owner_user_id, daemon_slug, display_name),
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 daemon slug입니다: {daemon_slug}") from exc
row = cur.fetchone()
return self._row_to_daemon(row)
def get_daemon_by_id(self, daemon_id: str) -> Daemon | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE id = %s
""",
(daemon_id,),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_daemon(row)
def get_daemon_by_slug(self, owner_user_id: str, daemon_slug: str) -> Daemon | None:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE owner_user_id = %s AND daemon_slug = %s
""",
(owner_user_id, daemon_slug),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_daemon(row)
def list_daemons_for_user(self, owner_user_id: str) -> list[Daemon]:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE owner_user_id = %s ORDER BY created_at ASC
""",
(owner_user_id,),
)
rows = cur.fetchall()
return [self._row_to_daemon(row) for row in rows]
def update_daemon(
self,
daemon_id: str,
*,
display_name: str | None = None,
is_active: bool | None = None,
) -> Daemon:
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
FROM daemons WHERE id = %s
""",
(daemon_id,),
)
existing_row = cur.fetchone()
if existing_row is None:
raise ValueError("데몬을 찾을 수 없습니다")
assignments: list[str] = []
params: list[object] = []
if display_name is not None:
assignments.append("display_name = %s")
params.append(display_name)
if is_active is not None:
assignments.append("is_active = %s")
params.append(is_active)
if not assignments:
return self._row_to_daemon(existing_row)
assignments.append("updated_at = NOW()")
cur.execute(
f"""
UPDATE daemons
SET {', '.join(assignments)}
WHERE id = %s
RETURNING id, owner_user_id, daemon_slug, display_name, is_active, created_at, updated_at
""",
(*params, daemon_id),
)
row = cur.fetchone()
return self._row_to_daemon(row)
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
host=config.host,
port=config.port,
dbname=maintenance_db,
user=config.user,
password=config.password,
sslmode=config.sslmode,
connect_timeout=config.connect_timeout_sec,
application_name=config.application_name,
autocommit=True,
) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
return cur.fetchone() is not None
def create_database_if_missing(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
host=config.host,
port=config.port,
dbname=maintenance_db,
user=config.user,
password=config.password,
sslmode=config.sslmode,
connect_timeout=config.connect_timeout_sec,
application_name=config.application_name,
autocommit=True,
) as conn:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.dbname,))
if cur.fetchone() is not None:
return False
cur.execute(sql.SQL("CREATE DATABASE {}") .format(sql.Identifier(config.dbname)))
return True