refactor: simplify data model by removing unused fields and constraints
- Remove runtime_id from AuthToken model - Remove market field from Portfolio model - Remove BrokerCredential and LedgerTrade unused models - Remove unique constraint on (owner_user_id, name) - Simplify postgres repository schema and queries - Clean up auth_service implementation - Update test suite for simplified model
This commit is contained in:
@@ -78,18 +78,12 @@ def issue_service_token(
|
||||
name=name,
|
||||
expires_at=issued.auth_token.expires_at,
|
||||
revoked=issued.auth_token.revoked_at is not None,
|
||||
runtime_id=issued.auth_token.runtime_id,
|
||||
token=issued.token,
|
||||
)
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
# 발급 시 1회에 한해 원문 token 출력 (이후 마스킹된 값만 표시됨)
|
||||
if ctx.obj.output_format == "json":
|
||||
from app.cli.core.utils import render_output
|
||||
render_output(ctx.obj.console, "json", response.model_dump(mode="json"))
|
||||
else:
|
||||
ctx.obj.console.print("[bold yellow]⚠ 토큰 원문은 지금 1회만 표시됩니다. 안전한 곳에 저장하세요.[/bold yellow]")
|
||||
ctx.obj.console.print(f"[bold green]token:[/bold green] {issued.token}")
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
if ctx.obj.output_format != "json":
|
||||
ctx.obj.console.print(f"masked_token: {mask_token_text(issued.token)}")
|
||||
|
||||
|
||||
@token_app.command("revoke")
|
||||
@@ -114,36 +108,3 @@ def revoke_service_token(
|
||||
revoked_at=revoked_at,
|
||||
)
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
|
||||
@token_app.command("rotate")
|
||||
def rotate_service_token(
|
||||
ctx: typer.Context,
|
||||
name: str = typer.Argument(..., help="Service token name to rotate."),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
|
||||
) -> None:
|
||||
if not force:
|
||||
confirmed = typer.confirm(f"서비스 토큰 '{name}'을 갱신하시겠습니까?")
|
||||
if not confirmed:
|
||||
raise typer.Exit(code=0)
|
||||
|
||||
def _rotate(service: AuthService, app_context):
|
||||
return service.rotate_service_token(name)
|
||||
|
||||
issued = _with_service(ctx, _rotate)
|
||||
response = ServiceTokenIssueCliResponse(
|
||||
token_id=issued.auth_token.id,
|
||||
name=name,
|
||||
expires_at=issued.auth_token.expires_at,
|
||||
revoked=issued.auth_token.revoked_at is not None,
|
||||
runtime_id=issued.auth_token.runtime_id,
|
||||
token=issued.token,
|
||||
)
|
||||
|
||||
if ctx.obj.output_format == "json":
|
||||
from app.cli.core.utils import render_output
|
||||
render_output(ctx.obj.console, "json", response.model_dump(mode="json"))
|
||||
else:
|
||||
ctx.obj.console.print("[bold yellow]⚠ 갱신된 토큰 원문은 지금 1회만 표시됩니다. 안전한 곳에 저장하세요.[/bold yellow]")
|
||||
ctx.obj.console.print(f"[bold green]token:[/bold green] {issued.token}")
|
||||
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
|
||||
|
||||
+72
-74
@@ -39,22 +39,29 @@ def to_plain_data(value: Any) -> Any:
|
||||
|
||||
|
||||
def print_portfolio_table(console: Console, portfolios: list[PortfolioListItem]) -> None:
|
||||
for i, item in enumerate(portfolios):
|
||||
if i > 0:
|
||||
console.print()
|
||||
lines = [
|
||||
f"id: {item.portfolio_id}",
|
||||
f"broker: {item.broker}",
|
||||
f"status: {item.status}",
|
||||
f"symbol: {item.symbol}",
|
||||
f"cash: {format_currency(item.cash_balance)}",
|
||||
f"qty: {item.asset_quantity:g}",
|
||||
f"price: {format_currency(item.price)}",
|
||||
f"buffer: {format_currency(item.profit_buffer)}",
|
||||
f"total: {format_currency(item.total_value)}",
|
||||
]
|
||||
for line in lines:
|
||||
console.print(line)
|
||||
table = Table(title="Portfolios", expand=True)
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Broker")
|
||||
table.add_column("Status", no_wrap=True)
|
||||
table.add_column("Symbol", no_wrap=True)
|
||||
table.add_column("Cash", justify="right")
|
||||
table.add_column("Qty", justify="right")
|
||||
table.add_column("Price", justify="right")
|
||||
table.add_column("Buffer", justify="right")
|
||||
table.add_column("Total", justify="right")
|
||||
for item in portfolios:
|
||||
table.add_row(
|
||||
item.portfolio_id,
|
||||
item.broker,
|
||||
item.status,
|
||||
item.symbol,
|
||||
format_currency(item.cash_balance),
|
||||
f"{item.asset_quantity:g}",
|
||||
format_currency(item.price),
|
||||
format_currency(item.profit_buffer),
|
||||
format_currency(item.total_value),
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> None:
|
||||
@@ -122,69 +129,60 @@ def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) ->
|
||||
|
||||
|
||||
def print_user_table(console: Console, users: list[UserResponse]) -> None:
|
||||
for i, user in enumerate(users, 1):
|
||||
lines = [
|
||||
f"username: {user.username}",
|
||||
f"id: {user.id}",
|
||||
f"email: {user.email or 'N/A'}",
|
||||
f"permission: {user.permission}",
|
||||
f"is_active: {'true' if user.is_active else 'false'}",
|
||||
f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}",
|
||||
f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}",
|
||||
]
|
||||
for line in lines:
|
||||
console.print(line)
|
||||
if i < len(users):
|
||||
console.print()
|
||||
table = Table(title="Users", expand=True)
|
||||
table.add_column("ID", style="cyan", no_wrap=True)
|
||||
table.add_column("Username", no_wrap=True)
|
||||
table.add_column("Email")
|
||||
table.add_column("Created At", no_wrap=True)
|
||||
table.add_column("Updated At", no_wrap=True)
|
||||
for user in users:
|
||||
table.add_row(
|
||||
user.id,
|
||||
user.username,
|
||||
user.email or "N/A",
|
||||
user.created_at.isoformat(timespec="seconds") if user.created_at else "N/A",
|
||||
user.updated_at.isoformat(timespec="seconds") if user.updated_at else "N/A",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_user_detail(console: Console, user: UserResponse) -> None:
|
||||
lines = [
|
||||
f"id: {user.id}",
|
||||
f"username: {user.username}",
|
||||
f"email: {user.email or 'N/A'}",
|
||||
f"permission: {user.permission}",
|
||||
f"is_active: {'true' if user.is_active else 'false'}",
|
||||
f"last_login_at: {user.last_login_at.isoformat(timespec='seconds') if user.last_login_at else 'N/A'}",
|
||||
f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}",
|
||||
f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}",
|
||||
]
|
||||
service_token_count = getattr(user, "service_token_count", None)
|
||||
service_token_names = getattr(user, "service_token_names", None)
|
||||
if service_token_count is not None and service_token_names is not None:
|
||||
rendered_names = ", ".join(service_token_names) if service_token_names else "N/A"
|
||||
lines.append(f"service_token_count: {service_token_count}")
|
||||
lines.append(f"service_token_names: {rendered_names}")
|
||||
for line in lines:
|
||||
console.print(line)
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"\n".join(
|
||||
[
|
||||
f"id: {user.id}",
|
||||
f"username: {user.username}",
|
||||
f"email: {user.email or 'N/A'}",
|
||||
f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}",
|
||||
f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}",
|
||||
]
|
||||
),
|
||||
title="User",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_service_token_list(console: Console, tokens: list[AuthTokenResponse]) -> None:
|
||||
for i, token in enumerate(tokens, 1):
|
||||
status = "[red]revoked[/red]" if token.revoked_at else "[green]active[/green]"
|
||||
expires = token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never"
|
||||
last_used = token.last_used_at.isoformat(timespec="seconds") if token.last_used_at else "-"
|
||||
revoked_at = token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-"
|
||||
created_at = token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A"
|
||||
lines = [
|
||||
f"[bold cyan]{token.name}[/bold cyan] ({status})",
|
||||
f" token_prefix: {token.token_prefix} [dim](full token shown only at issue time)[/dim]",
|
||||
f" id : {token.id}",
|
||||
f" user_id : {token.user_id}",
|
||||
f" perm : {token.permission}",
|
||||
f" expires : {expires}",
|
||||
f" last_used: {last_used}",
|
||||
f" created : {created_at}",
|
||||
]
|
||||
if token.revoked_at:
|
||||
lines.append(f" revoked : {revoked_at}")
|
||||
console.print("\n".join(lines))
|
||||
if i < len(tokens):
|
||||
console.print("[dim]─[/dim]" * 40)
|
||||
|
||||
|
||||
# Keep old name as alias for backward compatibility
|
||||
print_service_token_table = print_service_token_list
|
||||
def print_service_token_table(console: Console, tokens: list[AuthTokenResponse]) -> None:
|
||||
table = Table(title="Service Tokens", expand=True)
|
||||
table.add_column("Name", style="cyan", no_wrap=True)
|
||||
table.add_column("Permission", no_wrap=True)
|
||||
table.add_column("User ID", no_wrap=True)
|
||||
table.add_column("Prefix", no_wrap=True)
|
||||
table.add_column("Expires At", no_wrap=True)
|
||||
table.add_column("Revoked At", no_wrap=True)
|
||||
table.add_column("Created At", no_wrap=True)
|
||||
for token in tokens:
|
||||
table.add_row(
|
||||
token.name,
|
||||
token.permission,
|
||||
token.user_id,
|
||||
token.token_prefix,
|
||||
token.expires_at.isoformat(timespec="seconds") if token.expires_at else "never",
|
||||
token.revoked_at.isoformat(timespec="seconds") if token.revoked_at else "-",
|
||||
token.created_at.isoformat(timespec="seconds") if token.created_at else "N/A",
|
||||
)
|
||||
console.print(table)
|
||||
|
||||
|
||||
def print_comment_table(console: Console, comments: list[CommentResponse]) -> None:
|
||||
|
||||
@@ -65,14 +65,6 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def revoke_auth_token(self, user_id: str, token_id: str) -> None: ...
|
||||
|
||||
def rotate_service_token(
|
||||
self,
|
||||
token_id: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
runtime_id: str,
|
||||
) -> AuthToken: ...
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None: ...
|
||||
|
||||
def get_token_principal(self, token_hash: str) -> TokenPrincipal | None: ...
|
||||
|
||||
+55
-345
@@ -11,7 +11,7 @@ from typing import Any
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
|
||||
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
|
||||
|
||||
_SELECT_USER = """
|
||||
SELECT
|
||||
@@ -38,8 +38,7 @@ _SELECT_AUTH_TOKEN = """
|
||||
expires_at,
|
||||
last_used_at,
|
||||
revoked_at,
|
||||
created_at,
|
||||
runtime_id
|
||||
created_at
|
||||
FROM auth_tokens
|
||||
"""
|
||||
|
||||
@@ -59,8 +58,7 @@ _SELECT_PORTFOLIO = """
|
||||
owner_user_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_trade_at,
|
||||
market
|
||||
last_trade_at
|
||||
FROM portfolios
|
||||
"""
|
||||
|
||||
@@ -198,21 +196,6 @@ class PostgresPortfolioRepository:
|
||||
cur.execute("ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS owner_user_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(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'uq_portfolios_owner_name'
|
||||
AND table_name = 'portfolios'
|
||||
) THEN
|
||||
ALTER TABLE portfolios
|
||||
ADD CONSTRAINT uq_portfolios_owner_name UNIQUE (owner_user_id, name);
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS portfolio_ledger (
|
||||
@@ -317,7 +300,6 @@ class PostgresPortfolioRepository:
|
||||
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,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -350,7 +332,6 @@ class PostgresPortfolioRepository:
|
||||
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,
|
||||
market=row[15] if len(row) > 15 else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -551,7 +532,6 @@ class PostgresPortfolioRepository:
|
||||
permission: str,
|
||||
token_type: str,
|
||||
expires_at: datetime | None = None,
|
||||
runtime_id: str | None = None,
|
||||
) -> AuthToken:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -562,14 +542,14 @@ class PostgresPortfolioRepository:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO auth_tokens (
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
|
||||
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%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
|
||||
expires_at, last_used_at, revoked_at, created_at
|
||||
""",
|
||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
|
||||
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_auth_token(row)
|
||||
@@ -615,31 +595,6 @@ class PostgresPortfolioRepository:
|
||||
if row is None:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
|
||||
def rotate_service_token(
|
||||
self,
|
||||
token_id: str,
|
||||
token_hash: str,
|
||||
token_prefix: str,
|
||||
runtime_id: str,
|
||||
) -> AuthToken:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE auth_tokens
|
||||
SET token_hash = %s, token_prefix = %s, runtime_id = %s
|
||||
WHERE id = %s AND token_type = 'service' AND revoked_at IS NULL
|
||||
RETURNING
|
||||
id, user_id, name, token_prefix, permission, token_type,
|
||||
expires_at, last_used_at, revoked_at, created_at, runtime_id
|
||||
""",
|
||||
(token_hash, token_prefix, runtime_id, token_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
raise ValueError("토큰을 찾을 수 없습니다")
|
||||
return self._row_to_auth_token(row)
|
||||
|
||||
def get_service_token_by_name(self, name: str) -> AuthToken | None:
|
||||
self.ensure_schema()
|
||||
with self._connect().cursor() as cur:
|
||||
@@ -657,7 +612,7 @@ class PostgresPortfolioRepository:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
|
||||
SELECT t.id, t.user_id, t.permission, t.token_type
|
||||
FROM auth_tokens t
|
||||
WHERE token_hash = %s
|
||||
AND t.revoked_at IS NULL
|
||||
@@ -674,7 +629,6 @@ class PostgresPortfolioRepository:
|
||||
user_id=row[1],
|
||||
permission=row[2],
|
||||
token_type=row[3],
|
||||
runtime_id=row[4] if len(row) > 4 else None,
|
||||
)
|
||||
|
||||
def get(self, portfolio_id: str) -> Portfolio:
|
||||
@@ -732,11 +686,12 @@ class PostgresPortfolioRepository:
|
||||
|
||||
def create_portfolio(
|
||||
self,
|
||||
name: str,
|
||||
portfolio_id: str,
|
||||
*,
|
||||
owner_user_id: str,
|
||||
broker: str,
|
||||
status: str,
|
||||
name: str,
|
||||
symbol: str,
|
||||
cash_balance: float,
|
||||
asset_quantity: float,
|
||||
@@ -745,58 +700,42 @@ class PostgresPortfolioRepository:
|
||||
active_strategy_id: str | None,
|
||||
avabile_strategies: list[str] | None,
|
||||
) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
portfolio_id = str(uuid.uuid4())
|
||||
try:
|
||||
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)
|
||||
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 = []
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
|
||||
raise ValueError(f"이미 사용 중인 이름입니다: {name}") from exc
|
||||
raise
|
||||
return portfolio
|
||||
|
||||
def get_portfolio_by_name_and_user(self, owner_user_id: str, name: str) -> Portfolio | 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 not None:
|
||||
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
|
||||
cur.execute(
|
||||
_SELECT_PORTFOLIO + "WHERE owner_user_id = %s AND name = %s",
|
||||
(owner_user_id, name),
|
||||
"""
|
||||
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()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_portfolio(row)
|
||||
portfolio = self._row_to_portfolio(row)
|
||||
portfolio.tags = []
|
||||
return portfolio
|
||||
|
||||
def update_portfolio(self, portfolio_id: str, **fields: object) -> Portfolio:
|
||||
self.ensure_schema()
|
||||
@@ -824,27 +763,20 @@ class PostgresPortfolioRepository:
|
||||
return self.get(portfolio_id)
|
||||
|
||||
assignments.append("updated_at = NOW()")
|
||||
try:
|
||||
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()
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
if "uq_portfolios_owner_name" in msg or "unique constraint" in msg.lower():
|
||||
new_name = fields.get("name", portfolio_id)
|
||||
raise ValueError(f"이미 사용 중인 이름입니다: {new_name}") from exc
|
||||
raise
|
||||
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(
|
||||
@@ -1364,228 +1296,6 @@ class PostgresPortfolioRepository:
|
||||
if cur.fetchone() is None:
|
||||
raise ValueError("댓글을 찾을 수 없습니다")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Broker credentials
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@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]),
|
||||
last_rotated_at=row[5] if len(row) > 5 else None,
|
||||
)
|
||||
|
||||
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
|
||||
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 AND broker = %s
|
||||
""",
|
||||
(user_id, broker),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_broker_credential(row)
|
||||
|
||||
def list_broker_credentials(self, user_id: str) -> list[BrokerCredential]:
|
||||
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 upsert_broker_credential(
|
||||
self,
|
||||
user_id: str,
|
||||
broker: str,
|
||||
*,
|
||||
account_ref: str,
|
||||
encrypted_payload: str,
|
||||
) -> BrokerCredential:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
|
||||
VALUES (%s, %s, %s, %s, 1, NOW())
|
||||
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 delete_broker_credential(self, user_id: str, broker: str) -> bool:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
DELETE FROM broker_credentials
|
||||
WHERE user_id = %s AND broker = %s
|
||||
""",
|
||||
(user_id, broker),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Ledger trades
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _row_to_ledger_trade(row: Any) -> LedgerTrade:
|
||||
if row is None:
|
||||
raise ValueError("원장 거래를 찾을 수 없습니다")
|
||||
return LedgerTrade(
|
||||
ledger_entry_id=int(row[0]),
|
||||
asset_id=row[1],
|
||||
order_id=row[2],
|
||||
execution_id=row[3],
|
||||
side=row[4],
|
||||
quantity=float(row[5]),
|
||||
price=float(row[6]),
|
||||
fee_amount=float(row[7]),
|
||||
tax_amount=float(row[8]),
|
||||
executed_at=row[9],
|
||||
recorded_at=row[10] if len(row) > 10 else None,
|
||||
)
|
||||
|
||||
def insert_ledger_trade(
|
||||
self,
|
||||
asset_id: str,
|
||||
order_id: str,
|
||||
execution_id: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
executed_at: datetime,
|
||||
*,
|
||||
fee_amount: float = 0.0,
|
||||
tax_amount: float = 0.0,
|
||||
) -> tuple[LedgerTrade, bool]:
|
||||
"""Insert a ledger trade, returning (trade, created).
|
||||
|
||||
Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id)
|
||||
triplet already exists (idempotent).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
with self._connect().cursor() as cur:
|
||||
try:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ledger_trades
|
||||
(asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING
|
||||
ledger_entry_id, asset_id, order_id, execution_id, side,
|
||||
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
|
||||
""",
|
||||
(
|
||||
asset_id,
|
||||
order_id,
|
||||
execution_id,
|
||||
side,
|
||||
Decimal(str(quantity)),
|
||||
Decimal(str(price)),
|
||||
Decimal(str(fee_amount)),
|
||||
Decimal(str(tax_amount)),
|
||||
executed_at,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_ledger_trade(row), True
|
||||
except psycopg.errors.UniqueViolation:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, asset_id, order_id, execution_id, side,
|
||||
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
|
||||
FROM ledger_trades
|
||||
WHERE asset_id = %s AND order_id = %s AND execution_id = %s
|
||||
""",
|
||||
(asset_id, order_id, execution_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return self._row_to_ledger_trade(row), False
|
||||
|
||||
def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]:
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
ledger_entry_id, asset_id, order_id, execution_id, side,
|
||||
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
|
||||
FROM ledger_trades
|
||||
WHERE asset_id = %s
|
||||
ORDER BY executed_at DESC, ledger_entry_id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(asset_id, limit),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return [self._row_to_ledger_trade(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Portfolio+strategy_set join query for /v1/assets
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_portfolios_with_strategies(
|
||||
self,
|
||||
owner_user_id: str | None = None,
|
||||
*,
|
||||
broker: str | None = None,
|
||||
include_disabled: bool = False,
|
||||
) -> list[Portfolio]:
|
||||
"""Return portfolios with tags attached. Optionally filter by owner or broker."""
|
||||
conditions: list[str] = []
|
||||
params: list[object] = []
|
||||
if not include_disabled:
|
||||
conditions.append("p.status != 'disabled'")
|
||||
if owner_user_id is not None:
|
||||
conditions.append("p.owner_user_id = %s")
|
||||
params.append(owner_user_id)
|
||||
if broker is not None:
|
||||
conditions.append("p.broker = %s")
|
||||
params.append(broker)
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
with self._connect().cursor() as cur:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
p.portfolio_id, p.broker, p.status, p.name, p.symbol,
|
||||
p.cash_balance, p.asset_quantity, p.asset_price, p.profit_buffer,
|
||||
p.active_strategy_id, p.avabile_strategies, p.owner_user_id,
|
||||
p.created_at, p.updated_at, p.last_trade_at, p.market
|
||||
FROM portfolios p
|
||||
{where}
|
||||
ORDER BY p.updated_at DESC, p.portfolio_id ASC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
portfolios = [self._row_to_portfolio(row) for row in rows]
|
||||
self._attach_tags(cur, portfolios)
|
||||
return portfolios
|
||||
|
||||
|
||||
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
|
||||
with psycopg.connect(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -132,7 +131,6 @@ class AuthService:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
|
||||
raw_token = generate_token()
|
||||
runtime_id = str(uuid.uuid4())
|
||||
try:
|
||||
auth_token = self._repository.create_auth_token(
|
||||
user.id,
|
||||
@@ -142,7 +140,6 @@ class AuthService:
|
||||
permission=permission,
|
||||
token_type="service",
|
||||
expires_at=expires_at,
|
||||
runtime_id=runtime_id,
|
||||
)
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc
|
||||
@@ -155,24 +152,6 @@ class AuthService:
|
||||
self._repository.revoke_auth_token(existing.user_id, existing.id)
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
def rotate_service_token(self, name: str) -> IssuedServiceToken:
|
||||
existing = self._repository.get_service_token_by_name(name)
|
||||
if existing is None:
|
||||
raise ValueError(f"서비스 토큰을 찾을 수 없습니다: {name}")
|
||||
if existing.revoked_at is not None:
|
||||
raise ValueError(f"이미 폐기된 서비스 토큰입니다: {name}")
|
||||
if existing.expires_at is not None:
|
||||
raise ValueError(f"만료일이 설정된 토큰은 rotate할 수 없습니다: {name}. revoke 후 재발급하세요.")
|
||||
raw_token = generate_token()
|
||||
new_runtime_id = str(uuid.uuid4())
|
||||
auth_token = self._repository.rotate_service_token(
|
||||
existing.id,
|
||||
token_hash=hash_token(raw_token),
|
||||
token_prefix=token_prefix(raw_token),
|
||||
runtime_id=new_runtime_id,
|
||||
)
|
||||
return IssuedServiceToken(token=raw_token, auth_token=auth_token)
|
||||
|
||||
def list_service_tokens(
|
||||
self,
|
||||
username: str | None = None,
|
||||
|
||||
Reference in New Issue
Block a user