diff --git a/app/api/portfolios.py b/app/api/portfolios.py index be392b5..b03ec25 100644 --- a/app/api/portfolios.py +++ b/app/api/portfolios.py @@ -1,6 +1,6 @@ """Portfolio API routes.""" -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status from app.core.auth import AuthContext, Permission, authorize_request from app.core.config import Settings, get_settings @@ -14,6 +14,8 @@ from app.schemas.portfolio import ( InvestRequest, PortfolioDetailResponse, PortfolioListItem, + PortfolioTagMutationRequest, + PortfolioTagResponse, ResetPortfolioRequest, SellRequest, TransferRequest, @@ -26,6 +28,8 @@ router = APIRouter(prefix="/portfolios", tags=["portfolios"]) def _raise_portfolio_error(exc: ValueError) -> None: detail = str(exc) + if "이미 연결된 태그입니다" in detail: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc if "찾을 수 없습니다" in detail: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc @@ -68,6 +72,9 @@ def list_portfolios( request: Request, broker: str | None = None, status_filter: str | None = None, + include_tags: list[str] | None = Query(default=None, alias="includeTags"), + exclude_tags: list[str] | None = Query(default=None, alias="excludeTags"), + match_mode: str | None = Query(default=None, alias="matchMode"), settings: Settings = Depends(get_settings), ) -> list[PortfolioListItem]: auth_context = authorize_request(Permission.READ, request, settings) @@ -76,7 +83,16 @@ def list_portfolios( owner_user_id: str | None = None if _ownership_required(settings) and auth_context.permission != Permission.ADMIN: owner_user_id = _require_owner_context(auth_context) - return service.list_portfolios(broker=broker, status=status_filter, owner_user_id=owner_user_id) + return service.list_portfolios( + broker=broker, + status=status_filter, + owner_user_id=owner_user_id, + include_tags=include_tags, + exclude_tags=exclude_tags, + match_mode=match_mode, + ) + except ValueError as exc: + _raise_portfolio_error(exc) finally: _close_repository(repository) @@ -329,3 +345,63 @@ def delete_comment( finally: _close_repository(repository) return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.post("/{portfolio_id}/tags", response_model=PortfolioTagResponse, status_code=status.HTTP_201_CREATED) +def add_tag( + portfolio_id: str, + payload: PortfolioTagMutationRequest, + request: Request, + *, + settings: Settings = Depends(get_settings), +) -> PortfolioTagResponse: + auth_context = authorize_request(Permission.TRADE_WRITE, request, settings) + service, repository = _get_service(settings) + try: + if _ownership_required(settings): + _ensure_portfolio_owned(repository, portfolio_id, auth_context) + return service.add_tag(portfolio_id, payload) + except ValueError as exc: + _raise_portfolio_error(exc) + finally: + _close_repository(repository) + + +@router.get("/{portfolio_id}/tags", response_model=list[PortfolioTagResponse]) +def list_tags( + portfolio_id: str, + request: Request, + *, + settings: Settings = Depends(get_settings), +) -> list[PortfolioTagResponse]: + auth_context = authorize_request(Permission.READ, request, settings) + service, repository = _get_service(settings) + try: + if _ownership_required(settings): + _ensure_portfolio_owned(repository, portfolio_id, auth_context) + return service.list_tags(portfolio_id) + except ValueError as exc: + _raise_portfolio_error(exc) + finally: + _close_repository(repository) + + +@router.delete("/{portfolio_id}/tags", status_code=status.HTTP_204_NO_CONTENT) +def remove_tag( + portfolio_id: str, + request: Request, + payload: PortfolioTagMutationRequest = Body(...), + *, + settings: Settings = Depends(get_settings), +) -> Response: + auth_context = authorize_request(Permission.TRADE_WRITE, request, settings) + service, repository = _get_service(settings) + try: + if _ownership_required(settings): + _ensure_portfolio_owned(repository, portfolio_id, auth_context) + service.remove_tag(portfolio_id, payload) + except ValueError as exc: + _raise_portfolio_error(exc) + finally: + _close_repository(repository) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/cli/commands/portfolio.py b/app/cli/commands/portfolio.py index 01936dd..b599037 100644 --- a/app/cli/commands/portfolio.py +++ b/app/cli/commands/portfolio.py @@ -14,6 +14,7 @@ from app.schemas.portfolio import ( CommentUpdateRequest, CreatePortfolioRequest, InvestRequest, + PortfolioTagMutationRequest, ResetPortfolioRequest, SellRequest, TransferRequest, @@ -24,7 +25,9 @@ from app.services.portfolio_service import PortfolioService portfolio_app = typer.Typer(help="Portfolio lifecycle and trade commands.") comment_app = typer.Typer(help="Portfolio comment history commands.") +tag_app = typer.Typer(help="Portfolio tag commands.") portfolio_app.add_typer(comment_app, name="comment") +portfolio_app.add_typer(tag_app, name="tag") def _parse_strategy_params(value: str) -> dict[str, Any]: @@ -61,8 +64,20 @@ def list_portfolios( ctx: typer.Context, broker: str | None = typer.Option(None, help="Filter by broker."), status: str | None = typer.Option(None, help="Filter by status."), + include_tags: list[str] | None = typer.Option(None, "--include-tag", help="Include tag filter. Repeatable."), + exclude_tags: list[str] | None = typer.Option(None, "--exclude-tag", help="Exclude tag filter. Repeatable."), + match_mode: str | None = typer.Option(None, "--match-mode", help="AND or OR for include tags."), ) -> None: - detail = _with_service(ctx, lambda service, app_context: service.list_portfolios(broker=broker, status=status)) + detail = _with_service( + ctx, + lambda service, app_context: service.list_portfolios( + broker=broker, + status=status, + include_tags=include_tags, + exclude_tags=exclude_tags, + match_mode=match_mode, + ), + ) render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No portfolios found.") @@ -333,4 +348,44 @@ def comment_delete( if ctx.obj.output_format == "json": render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": comment_id, "portfolio_id": portfolio_id}) else: - ctx.obj.console.print(f"Deleted comment: {comment_id}") \ No newline at end of file + ctx.obj.console.print(f"Deleted comment: {comment_id}") + + +@tag_app.command("add") +def tag_add( + ctx: typer.Context, + portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + tag: str = typer.Option(..., "--tag", help="Tag value."), +) -> None: + detail = _with_service( + ctx, + lambda service, app_context: service.add_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)), + ) + render_output(ctx.obj.console, ctx.obj.output_format, detail) + + +@tag_app.command("list") +def tag_list( + ctx: typer.Context, + portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), +) -> None: + detail = _with_service(ctx, lambda service, app_context: service.list_tags(portfolio_id)) + render_output(ctx.obj.console, ctx.obj.output_format, detail, empty_message="No tags found.") + + +@tag_app.command("remove") +def tag_remove( + ctx: typer.Context, + portfolio_id: str = typer.Argument(..., help="Portfolio identifier."), + tag: str = typer.Option(..., "--tag", help="Tag value."), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), +) -> None: + _confirm(force, f"Remove tag {tag!r} from portfolio {portfolio_id}?") + _with_service( + ctx, + lambda service, app_context: service.remove_tag(portfolio_id, PortfolioTagMutationRequest(tag=tag)), + ) + if ctx.obj.output_format == "json": + render_output(ctx.obj.console, ctx.obj.output_format, {"portfolio_id": portfolio_id, "deleted": tag.strip()}) + else: + ctx.obj.console.print(f"Removed tag: {tag.strip()}") \ No newline at end of file diff --git a/app/domain/models.py b/app/domain/models.py index a0030ae..ef49473 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -73,6 +73,7 @@ class Portfolio: status: str name: str symbol: str + tags: list[str] | None = None owner_user_id: str | None = None cash_balance: float = 0.0 asset_quantity: float = 0.0 @@ -111,6 +112,13 @@ class PortfolioComment: updated_at: datetime | None = None +@dataclass +class PortfolioTag: + portfolio_id: str + tag: str + created_at: datetime | None = None + + @runtime_checkable class LiveAssetSource(Protocol): def get_asset_quantity(self, symbol: str) -> float: ... diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 18962a0..4d6c932 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -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, PortfolioComment, StrategySet, TokenPrincipal, User +from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User class PortfolioRepository(Protocol): @@ -126,8 +126,17 @@ class PortfolioRepository(Protocol): 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, ) -> list[Portfolio]: ... + def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: ... + + def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: ... + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: ... + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ... def get_strategy_set(self, id: str) -> StrategySet | None: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index a0a10b6..5ecd26a 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -12,7 +12,7 @@ from typing import Any import psycopg from psycopg import sql -from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, StrategySet, TokenPrincipal, User +from app.domain.models import AuthToken, BotPrincipal, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User _SELECT_USER = """ SELECT @@ -329,6 +329,22 @@ class PostgresPortfolioRepository: 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("SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb')") if cur.fetchone()[0]: cur.execute( @@ -407,6 +423,7 @@ class PostgresPortfolioRepository: 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]), @@ -419,6 +436,35 @@ class PostgresPortfolioRepository: last_trade_at=row[14] if len(row) > 14 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( @@ -780,7 +826,9 @@ class PostgresPortfolioRepository: with self._connect().cursor() as cur: cur.execute(_SELECT_PORTFOLIO + "WHERE portfolio_id = %s", (portfolio_id,)) row = cur.fetchone() - return self._row_to_portfolio(row) + portfolio = self._row_to_portfolio(row) + self._attach_tags(cur, [portfolio]) + return portfolio def get_or_create( self, @@ -875,7 +923,9 @@ class PostgresPortfolioRepository: ), ) row = cur.fetchone() - 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() @@ -925,6 +975,9 @@ class PostgresPortfolioRepository: 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, ) -> list[Portfolio]: self.ensure_schema() query = _SELECT_PORTFOLIO @@ -939,13 +992,92 @@ class PostgresPortfolioRepository: if owner_user_id is not None: clauses.append("owner_user_id = %s") params.append(owner_user_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() - return [self._row_to_portfolio(row) for row in rows] + 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() diff --git a/app/schemas/portfolio.py b/app/schemas/portfolio.py index 12fb573..83add30 100644 --- a/app/schemas/portfolio.py +++ b/app/schemas/portfolio.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class StrategySetResponse(BaseModel): @@ -45,6 +45,7 @@ class PortfolioSummary(BaseModel): status: str name: str symbol: str + tags: list[str] = Field(default_factory=list) owner_user_id: str | None = None cash_balance: float = 0.0 asset_quantity: float = 0.0 @@ -140,6 +141,24 @@ class CommentResponse(BaseModel): updated_at: datetime | None = None +class PortfolioTagMutationRequest(BaseModel): + tag: str = Field(min_length=1) + + @field_validator("tag") + @classmethod + def normalize_tag(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("tag는 비어 있을 수 없습니다") + return normalized + + +class PortfolioTagResponse(BaseModel): + portfolio_id: str + tag: str + created_at: datetime | None = None + + class ErrorResponse(BaseModel): detail: str model_config = ConfigDict(extra="ignore") diff --git a/app/services/portfolio_service.py b/app/services/portfolio_service.py index 3f04d6e..d880049 100644 --- a/app/services/portfolio_service.py +++ b/app/services/portfolio_service.py @@ -7,7 +7,7 @@ import json from fastapi import Depends from app.core.config import Settings, get_settings -from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet +from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet from app.domain.services import ( apply_buy, apply_delete, @@ -28,6 +28,8 @@ from app.schemas.portfolio import ( InvestRequest, PortfolioDetailResponse, PortfolioListItem, + PortfolioTagMutationRequest, + PortfolioTagResponse, ResetPortfolioRequest, SellRequest, TransferRequest, @@ -49,6 +51,7 @@ def _to_portfolio_summary(portfolio: Portfolio): status=portfolio.status, name=portfolio.name, symbol=portfolio.symbol, + tags=list(portfolio.tags or []), owner_user_id=portfolio.owner_user_id, cash_balance=portfolio.cash_balance, asset_quantity=portfolio.asset_quantity, @@ -121,6 +124,31 @@ def _to_comment_response(comment: PortfolioComment) -> CommentResponse: ) +def _to_tag_response(tag: PortfolioTag) -> PortfolioTagResponse: + return PortfolioTagResponse(portfolio_id=tag.portfolio_id, tag=tag.tag, created_at=tag.created_at) + + +def _normalize_tag(value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("tag는 비어 있을 수 없습니다") + return normalized + + +def _normalize_tag_list(values: list[str] | None) -> list[str] | None: + if not values: + return None + normalized: list[str] = [] + seen: set[str] = set() + for value in values: + item = _normalize_tag(value) + if item in seen: + continue + seen.add(item) + normalized.append(item) + return normalized or None + + def _to_detail_response(status_result) -> PortfolioDetailResponse: return PortfolioDetailResponse( portfolio=_to_portfolio_summary(status_result.portfolio), @@ -144,8 +172,21 @@ class PortfolioService: 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, ) -> list[PortfolioListItem]: - portfolios = self._repository.list_portfolios(broker=broker, status=status, owner_user_id=owner_user_id) + normalized_mode = None if match_mode is None else match_mode.upper() + if normalized_mode is not None and normalized_mode not in {"AND", "OR"}: + raise ValueError("matchMode는 AND 또는 OR 여야 합니다") + portfolios = self._repository.list_portfolios( + broker=broker, + status=status, + owner_user_id=owner_user_id, + include_tags=_normalize_tag_list(include_tags), + exclude_tags=_normalize_tag_list(exclude_tags), + match_mode=normalized_mode, + ) return [ PortfolioListItem( **_to_portfolio_summary(portfolio).model_dump(), @@ -302,6 +343,19 @@ class PortfolioService: self._repository.get(portfolio_id) self._repository.delete_comment(portfolio_id, comment_id) + def add_tag(self, portfolio_id: str, payload: PortfolioTagMutationRequest) -> PortfolioTagResponse: + self._repository.get(portfolio_id) + tag = self._repository.add_tag(portfolio_id, tag=_normalize_tag(payload.tag)) + return _to_tag_response(tag) + + def list_tags(self, portfolio_id: str) -> list[PortfolioTagResponse]: + self._repository.get(portfolio_id) + return [_to_tag_response(tag) for tag in self._repository.list_tags(portfolio_id)] + + def remove_tag(self, portfolio_id: str, payload: PortfolioTagMutationRequest) -> None: + self._repository.get(portfolio_id) + self._repository.remove_tag(portfolio_id, tag=_normalize_tag(payload.tag)) + def get_portfolio_service( repository: PortfolioRepository = Depends(get_portfolio_repository), diff --git a/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md b/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md index 5a58a03..7789709 100644 --- a/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md +++ b/docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md @@ -1,6 +1,6 @@ # REQ-002: 포트폴리오 태깅 및 태그 기반 검색 지원 -- Status: Proposed +- Status: Approved - Type: Interface - Priority: Medium - Owner: Justin @@ -12,27 +12,30 @@ ## Requirement - 시스템은 각 포트폴리오에 대해 하나 이상의 태그를 추가하고 제거할 수 있어야 한다. - 시스템은 HTTP API와 CLI를 통해 동일한 포트폴리오 태깅 기능을 제공해야 한다. +- 시스템은 태그 추가 시 입력 문자열의 앞뒤 공백을 제거한 값을 저장 및 비교 기준으로 사용해야 하며, 정규화 결과가 빈 문자열이면 validation 오류를 반환해야 한다. +- 시스템은 태그 문자열의 대소문자, 내부 공백, 네임스페이스 구분자(`:`)를 정규화 과정에서 변경하지 않아야 하며, 앞뒤 공백 제거 외의 추가 정규화는 수행하지 않아야 한다. - 시스템은 둘 이상의 태그를 조건으로 포트폴리오를 검색할 수 있어야 하며, 검색 요청에서 `AND` 또는 `OR` 매칭 방식을 선택할 수 있어야 한다. -- 시스템은 다운스트림 소비자가 태그 기반 목록 조회 시 사용할 수 있도록 포함 태그(`includeTags`)와 제외 태그(`excludeTags`)를 동시에 지원해야 한다. +- 시스템은 HTTP API의 포트폴리오 목록 조회 인터페이스와 CLI의 동일 의미 인터페이스에서 포함 태그(`includeTags`), 제외 태그(`excludeTags`), 포함 태그 매칭 방식(`matchMode`: `AND` 또는 `OR`)을 동시에 지원해야 한다. +- 시스템은 `matchMode`를 `includeTags` 집합에만 적용해야 하며, `excludeTags`는 요청된 태그 중 하나라도 일치하면 해당 포트폴리오를 결과에서 제외해야 한다. - 시스템은 태그 기반 조회에서 `grade:*`, `blacklist:*`와 같은 태그 네임스페이스를 그대로 보존하고 필터 조건으로 사용할 수 있어야 한다. - 시스템은 공백만으로 이루어지지 않은 태그 문자열을 추가 대상으로 받아들여야 한다. -- 시스템은 동일 포트폴리오에 이미 연결된 동일한 태그 문자열을 다시 추가하려는 요청에 대해 `409 Conflict` 또는 이에 대응하는 CLI 오류를 반환해야 하며, 기존 태그 상태를 변경하지 않아야 한다. +- 시스템은 동일 포트폴리오에 이미 연결된 태그와 앞뒤 공백 제거 후 동일한 태그 문자열을 다시 추가하려는 요청에 대해 `409 Conflict` 또는 이에 대응하는 CLI 오류를 반환해야 하며, 기존 태그 상태를 변경하지 않아야 한다. - 시스템은 태그 추가·삭제·검색 요청에 대해 기존 포트폴리오 접근 권한 정책을 적용해야 하며, 인증 실패 또는 권한 부족 시 기존 인증 오류 계약에 따라 요청을 거부해야 한다. - 시스템은 존재하지 않는 `portfolio_id`를 대상으로 한 태그 추가·삭제 요청에 대해 Not Found 오류를 반환해야 한다. - 시스템은 태그 검색 결과에서 각 포트폴리오를 중복 없이 한 번만 반환해야 한다. - 시스템은 태그 문자열 길이 또는 포트폴리오당 태그 개수에 대해 애플리케이션 정의 최대 제한만을 이유로 요청을 거부하지 않아야 한다. ## Acceptance Criteria -1. Given 기존 포트폴리오가 존재하고 권한이 있는 사용자가 API 또는 CLI로 새 태그를 추가할 때, When 공백만으로 이루어지지 않은 태그 문자열을 제출하면, Then 시스템은 해당 포트폴리오에 태그를 연결하고 후속 조회에서 그 태그가 포함된 상태를 반환해야 한다. +1. Given 기존 포트폴리오가 존재하고 권한이 있는 사용자가 API 또는 CLI로 새 태그를 추가할 때, When 공백만으로 이루어지지 않은 태그 문자열을 제출하면, Then 시스템은 입력값의 앞뒤 공백을 제거한 태그를 해당 포트폴리오에 연결하고 후속 조회에서 그 정규화된 태그가 포함된 상태를 반환해야 한다. 2. Given 특정 포트폴리오에 이미 연결된 태그가 있을 때, When 사용자가 API 또는 CLI로 해당 태그를 제거하면, Then 시스템은 성공 응답을 반환하고 후속 조회 및 태그 검색 결과에서 그 연결을 노출하지 않아야 한다. -3. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 둘 이상의 태그와 `AND` 모드를 지정해 검색하면, Then 시스템은 요청한 모든 태그를 포함한 포트폴리오만 반환해야 한다. -4. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 둘 이상의 태그와 `OR` 모드를 지정해 검색하면, Then 시스템은 요청한 태그 중 하나 이상을 포함한 포트폴리오를 반환해야 한다. -5. Given 동일 포트폴리오에 이미 동일한 태그 문자열이 존재할 때, When 사용자가 API 또는 CLI로 같은 태그를 다시 추가하면, Then 시스템은 `409 Conflict` 응답 또는 이에 대응하는 CLI 오류를 반환하고 중복 태그 연결을 생성하지 않아야 한다. +3. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 `includeTags`에 둘 이상의 태그와 `matchMode=AND`를 지정해 검색하면, Then 시스템은 요청한 모든 포함 태그를 가진 포트폴리오만 반환해야 한다. +4. Given 여러 포트폴리오에 서로 다른 태그 조합이 연결되어 있을 때, When 사용자가 API 또는 CLI로 `includeTags`에 둘 이상의 태그와 `matchMode=OR`를 지정해 검색하면, Then 시스템은 요청한 포함 태그 중 하나 이상을 가진 포트폴리오를 반환해야 한다. +5. Given 동일 포트폴리오에 이미 앞뒤 공백 제거 후 동일한 태그 문자열이 존재할 때, When 사용자가 API 또는 CLI로 같은 태그를 다시 추가하면, Then 시스템은 `409 Conflict` 응답 또는 이에 대응하는 CLI 오류를 반환하고 중복 태그 연결을 생성하지 않아야 한다. 6. Given 인증되지 않았거나 해당 포트폴리오에 접근 권한이 없는 사용자가 API 또는 CLI로 태그 추가·삭제·검색을 시도할 때, When 요청을 전송하면, Then 시스템은 기존 인증/인가 계약에 따라 `401` 또는 `403` 응답을 반환하고 태그 상태를 변경하지 않아야 한다. 7. Given 존재하지 않는 `portfolio_id`가 요청에 포함될 때, When 사용자가 API 또는 CLI로 태그 추가 또는 삭제를 시도하면, Then 시스템은 `404` 응답 또는 이에 대응하는 CLI 오류를 반환하고 어떤 태그 연결도 생성·변경하지 않아야 한다. -8. Given 포트폴리오에 이미 여러 태그가 연결되어 있고 사용자가 긴 태그 문자열을 추가할 때, When API 또는 CLI로 태그 추가를 요청하면, Then 시스템은 애플리케이션 정의 최대 길이 제한 또는 최대 개수 제한만을 이유로 요청을 거부하지 않아야 한다. +8. Given 포트폴리오에 이미 여러 태그가 연결되어 있고 사용자가 긴 태그 문자열을 추가할 때, When API 또는 CLI로 태그 추가를 요청하면, Then 시스템은 앞뒤 공백 제거 후 빈 문자열이 아닌 한 애플리케이션 정의 최대 길이 제한 또는 최대 개수 제한만을 이유로 요청을 거부하지 않아야 한다. 9. Given `OR` 모드의 다중 태그 검색에서 하나의 포트폴리오가 두 개 이상의 요청 태그와 동시에 일치할 때, When 사용자가 검색을 수행하면, Then 시스템은 해당 포트폴리오를 결과에 한 번만 반환해야 한다. -10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 포함 조건을 만족하고 제외 조건을 만족하지 않는 항목만 반환해야 한다. +10. Given 소비 서비스가 `includeTags=["grade:A", "grade:B"]`, `excludeTags=["blacklist:trade"]`, `matchMode=OR` 조건으로 목록 조회를 요청한 상태, When 태그 필터 검색을 수행하면, Then 시스템은 `grade:A` 또는 `grade:B` 중 하나 이상을 포함하고 `blacklist:trade`를 하나라도 포함하지 않는 항목만 반환해야 한다. ## Impacted Area - Docs: `docs/technical/api-contracts.md`, `docs/technical/persistence-and-migrations.md`, `docs/SRS/index.md`, `docs/SRS/traceability-matrix.md` @@ -43,10 +46,74 @@ - 본 requirement의 검색 범위는 다중 태그 검색을 포함하며, 호출자는 `AND` 또는 `OR` 모드를 선택할 수 있다. - 태그 최대 길이와 포트폴리오당 최대 태그 개수에 대한 애플리케이션 정의 상한은 두지 않는다. - 동일 포트폴리오에 같은 태그 문자열을 다시 추가하는 요청은 `409 Conflict` 또는 이에 대응하는 CLI 오류로 처리한다. +- 태그 정규화는 앞뒤 공백 제거만 수행하며, 대소문자와 내부 문자열은 보존한다. +- HTTP API와 CLI는 `includeTags`, `excludeTags`, `matchMode`에 대해 동일 의미의 검색 계약을 제공한다. - 특정 소비 서비스의 입력 고정/스냅샷 책임은 소비 서비스 측 requirement에서 관리하며, portfolio-hub는 태그 등록/검색 계약만 제공한다. ## Change Log +### CHG-20260414-02 +- Date: 2026-04-14 +- Author: GitHub Copilot + +#### Change Summary +- REQ-002를 Proposed에서 Approved로 전환했다. + +#### Intent +- 태그 정규화 규칙과 태그 검색 입력 계약이 승인 기준을 충족했음을 반영하기 위함이다. + +#### Acceptance Criteria Delta +- Added: + - 없음 +- Updated: + - Status를 Approved로 전환 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` + - `docs/SRS/index.md` + - `docs/SRS/traceability-matrix.md` +- Modules: + - 없음 +- Tests: + - 없음 + +#### Validation Plan +- 승인 후 technical spec 동기화 시 REQ-002의 정규화 및 검색 계약이 관련 technical 문서에 동일하게 반영되는지 확인한다. + +### CHG-20260414-01 +- Date: 2026-04-14 +- Author: GitHub Copilot + +#### Change Summary +- REQ-002의 태그 정규화 규칙과 태그 검색 입력 계약을 구체화해 승인 blocker를 해소할 수 있도록 보강했다. + +#### Intent +- 태그 중복 판단과 include/exclude 검색 동작이 구현마다 다르게 해석되지 않도록 인터페이스 계약을 고정하기 위함이다. + +#### Acceptance Criteria Delta +- Added: + - 앞뒤 공백 제거 기반 태그 정규화 규칙 + - `includeTags`, `excludeTags`, `matchMode`의 결합 규칙 +- Updated: + - AC1, AC3, AC4, AC5, AC8, AC10의 입력/비교 기준 명확화 +- Removed: + - 없음 + +#### Impacted Area Delta +- Docs: + - `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` + - `docs/SRS/index.md` +- Modules: + - tag input normalization 및 검색 query parser 구현 경로 +- Tests: + - 태그 정규화, 중복 추가 충돌, include/exclude 조합 검색 검증 시나리오 + +#### Validation Plan +- 문서 리뷰 시 태그 정규화 규칙과 `includeTags`/`excludeTags`/`matchMode` 조합 규칙이 각각 독립 테스트로 매핑 가능한지 확인한다. + ### CHG-20260413-05 - Date: 2026-04-13 - Author: GitHub Copilot diff --git a/docs/SRS/index.md b/docs/SRS/index.md index bb521e7..01ff8dd 100644 --- a/docs/SRS/index.md +++ b/docs/SRS/index.md @@ -22,16 +22,16 @@ | REQ ID | Title | Type | Status | Priority | Owner | File | |---|---|---|---|---|---|---| | REQ-001 | 포트폴리오 댓글형 메모 이력 지원 | Interface | Approved | Medium | Justin | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | -| REQ-002 | 포트폴리오 태깅 및 태그 기반 검색 지원 | Interface | Proposed | Medium | Justin | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | +| REQ-002 | 포트폴리오 태깅 및 태그 기반 검색 지원 | Interface | Approved | Medium | Justin | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | | REQ-003 | 포트폴리오 필수 필드 및 거래소/시장 데이터 정의 | Data | Proposed | Medium | Justin | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | | REQ-004 | 포트폴리오 소유주 기반 접근 제어 | Interface | Proposed | High | Justin | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | ## Recent Change Log Summary +- 2026-04-14 — `REQ-002`를 Approved로 전환했다. +- 2026-04-14 — `REQ-002`에 태그 정규화 규칙과 `includeTags`/`excludeTags`/`matchMode` 검색 계약을 추가해 승인 blocker를 줄였다. - 2026-04-14 — `REQ-004`에 사용자 귀속 봇 principal(`bot_principals.owner_user_id`) 및 봇 토큰 실효 사용자 매핑 규칙(AC6, AC7)을 추가했다. - 2026-04-13 — `REQ-004` 포트폴리오 소유주(`owner_user_id`) 및 소유권 기반 접근 제어 requirement를 추가했다. - 2026-04-13 — `REQ-003`에서 태그 필터 기반 조회의 결정성 유지 책임을 소비 서비스(stock-picker)로 이관했다. -- 2026-04-13 — `REQ-003`에서 `sourceVersion`/`sourceTimestamp` 요구를 제거하고 태그 필터 기반 조회 결정성 계약으로 정리했다. -- 2026-04-13 — `REQ-003`의 소비자 표현을 특정 서비스 전용에서 태그 기반 조회 소비 서비스 일반 계약으로 정리했다. ## Status Transition Evidence - `Proposed` → `Approved`: Owner가 지정되고 requirement 문서 리뷰가 완료되며 acceptance criteria가 누락 없이 검토되어야 한다. diff --git a/docs/SRS/traceability-matrix.md b/docs/SRS/traceability-matrix.md index b2214fb..895ec57 100644 --- a/docs/SRS/traceability-matrix.md +++ b/docs/SRS/traceability-matrix.md @@ -12,7 +12,7 @@ | REQ ID | AC Mapping | Requirement File | Implementation Issue | PR | Test Evidence | Requirement Status | Delivery Status | Last Updated | |---|---|---|---|---|---|---|---|---| | REQ-001 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-001_portfolio-comment-history.md` | #2 | TBD | TBD | Approved | In Progress | 2026-04-10 | -| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 | +| REQ-002 | AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9, AC10 | `docs/SRS/Interface/REQ-002_portfolio-tagging-and-search.md` | TBD | TBD | `tests/test_portfolio_tags.py`, `tests/test_cli.py`, local `pytest` pass (65 passed, 2026-04-14) | Approved | Verified | 2026-04-14 | | REQ-003 | AC1, AC2, AC3, AC4, AC5 | `docs/SRS/Data/REQ-003_portfolio-required-fields.md` | TBD | TBD | TBD | Proposed | Planned | 2026-04-10 | | REQ-004 | AC1, AC2, AC3, AC4, AC5, AC6, AC7 | `docs/SRS/Interface/REQ-004_portfolio-ownership-access-control.md` | TBD | TBD | `tests/test_portfolio_ownership.py`, `tests/test_users.py`, `tests/test_auth.py`, `tests/test_portfolio_comments.py`, local `pytest` pass (2026-04-14) | Proposed | In Progress | 2026-04-14 | diff --git a/docs/technical/api-contracts.md b/docs/technical/api-contracts.md index 547efdd..763cfc4 100644 --- a/docs/technical/api-contracts.md +++ b/docs/technical/api-contracts.md @@ -54,6 +54,15 @@ - `POST /portfolios/{portfolio_id}/transfer` - `POST /portfolios/{portfolio_id}/reset` - `DELETE /portfolios/{portfolio_id}` +- `POST /portfolios/{portfolio_id}/tags` +- `GET /portfolios/{portfolio_id}/tags` +- `DELETE /portfolios/{portfolio_id}/tags` + +REQ-002 정렬 메모: + +- 포트폴리오 목록 조회는 반복 query parameter `includeTags`, `excludeTags`, `matchMode`를 지원한다. +- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 구현한다. +- CLI는 `portfolio tag add|list|remove` 및 `portfolio list --include-tag/--exclude-tag/--match-mode` 계약을 제공한다. ### 포트폴리오 댓글 (`REQ-001`) - `POST /portfolios/{portfolio_id}/comments` @@ -106,12 +115,17 @@ - Query: - `broker: string | null` - `status_filter: string | null` + - `includeTags: string[] | null` + - `excludeTags: string[] | null` + - `matchMode: "AND" | "OR" | null` (`includeTags`에만 적용) - 응답 `200`: `PortfolioListItem[]` +- 오류: + - `400`: `matchMode`가 `AND` 또는 `OR`가 아님 예시: ```bash -curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled" \ +curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled&includeTags=grade:A&excludeTags=blacklist:trade&matchMode=OR" \ -H "Authorization: Bearer " ``` @@ -123,6 +137,7 @@ curl -X GET "http://localhost:8000/portfolios?broker=upbit&status_filter=enabled "status": "enabled", "name": "Bitcoin", "symbol": "KRW-BTC", + "tags": ["grade:A"], "cash_balance": 1000000.0, "asset_quantity": 0.1, "price": 100000000.0, @@ -157,6 +172,7 @@ curl -X GET "http://localhost:8000/portfolios/krw-btc?recent_trades=5" \ "status": "enabled", "name": "Bitcoin", "symbol": "KRW-BTC", + "tags": ["grade:A"], "owner_user_id": "user-1", "cash_balance": 1000000.0, "asset_quantity": 0.1, @@ -246,6 +262,16 @@ curl -X PATCH "http://localhost:8000/portfolios/krw-btc" \ - `POST /portfolios/{portfolio_id}/transfer`: `TransferRequest` -> `PortfolioDetailResponse` - `POST /portfolios/{portfolio_id}/reset`: `ResetPortfolioRequest` -> `PortfolioDetailResponse` - `DELETE /portfolios/{portfolio_id}`: admin only, query `keep_ledger: bool = false`, 응답 `204` +- `POST /portfolios/{portfolio_id}/tags`: `PortfolioTagMutationRequest` -> `PortfolioTagResponse` +- `GET /portfolios/{portfolio_id}/tags`: `PortfolioTagResponse[]` +- `DELETE /portfolios/{portfolio_id}/tags`: body `PortfolioTagMutationRequest`, 응답 `204` + +태그 엔드포인트 추가 규칙: + +- `POST`/`DELETE`는 `trade-write`, `GET`은 `read` +- 비관리자 요청은 기존 ownership 검사를 동일하게 적용 +- 입력 `tag`는 앞뒤 공백을 제거한 값을 저장/비교 기준으로 사용 +- 정규화 후 빈 문자열은 `422`, 중복 태그 추가는 `409`, 포트폴리오 또는 태그 미존재는 `404` 대표 요청: @@ -277,6 +303,19 @@ curl -X POST "http://localhost:8000/portfolios/krw-btc/reset" \ curl -X DELETE "http://localhost:8000/portfolios/krw-btc?keep_ledger=false" \ -H "Authorization: Bearer " + +curl -X POST "http://localhost:8000/portfolios/krw-btc/tags" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"tag": " grade:A "}' + +curl -X GET "http://localhost:8000/portfolios/krw-btc/tags" \ + -H "Authorization: Bearer " + +curl -X DELETE "http://localhost:8000/portfolios/krw-btc/tags" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"tag": "grade:A"}' ``` #### 포트폴리오 댓글 (`REQ-001`) @@ -703,7 +742,7 @@ curl -X POST "http://localhost:8000/users" \ ## 문서 동기화 규칙 - `app/api/` 또는 `app/schemas/` 변경 시 본 문서를 같은 변경 단위에서 갱신한다. -- `REQ-002` 태그 검색 API가 구현되면 요청/응답 파라미터와 필터 의미를 본 문서에 추가한다. +- `REQ-002` 태그 검색 구현은 승인된 정규화/필터 의미와 실제 route/CLI 계약을 모두 본 문서에 유지한다. ## Open Implementation Points - 에러 응답 body를 공통 포맷(`error_code`, `message`, `details`)으로 강제할지 결정한다. diff --git a/docs/technical/index.md b/docs/technical/index.md index 759cbac..4486289 100644 --- a/docs/technical/index.md +++ b/docs/technical/index.md @@ -19,6 +19,7 @@ | `settings-and-env-loading.md` | `.env`/환경변수 로딩, 필수 키, CORS/운영 설정 규칙 | N/A | | `architecture-overview.md` | 시스템 개요, 모듈 책임, 런타임 흐름, 테스트/호환성 기준 | `REQ-003` | | `api-contracts.md` | HTTP API 상세 계약, request/response 모델, 대표 payload 예시 | `REQ-001`, `REQ-002`, `REQ-003` | +| `portfolio-tagging-and-search-design.md` | 태그 정규화, API/CLI 검색 파라미터, 모듈 책임, 구현 단계 설계 | `REQ-002` | | `persistence-and-migrations.md` | PostgreSQL/Timescale 연동, 리포지토리 스위치, Alembic 운영, 스키마 baseline | `REQ-001`, `REQ-003` | | `auth-and-token-lifecycle.md` | 로그인/토큰/봇 주체 라이프사이클 및 권한 모델 | N/A | | `portfolio-ownership-and-auth-design.md` | 포트폴리오 소유주(`owner_user_id`) 도입, 소유권 접근 제어, 검증 시나리오 설계 | `REQ-004` | @@ -30,7 +31,7 @@ | REQ ID | Technical Docs | | --- | --- | | `REQ-001` | `api-contracts.md`, `persistence-and-migrations.md` | -| `REQ-002` | `api-contracts.md` | +| `REQ-002` | `api-contracts.md`, `portfolio-tagging-and-search-design.md`, `persistence-and-migrations.md` | | `REQ-003` | `architecture-overview.md`, `api-contracts.md`, `persistence-and-migrations.md` | | `REQ-004` | `auth-and-token-lifecycle.md`, `api-contracts.md`, `persistence-and-migrations.md`, `portfolio-ownership-and-auth-design.md` | diff --git a/docs/technical/persistence-and-migrations.md b/docs/technical/persistence-and-migrations.md index ffb0723..dfd77b6 100644 --- a/docs/technical/persistence-and-migrations.md +++ b/docs/technical/persistence-and-migrations.md @@ -1,6 +1,6 @@ # Persistence and Migrations -- Related REQ: `REQ-001`, `REQ-003`, `REQ-004` +- Related REQ: `REQ-001`, `REQ-002`, `REQ-003`, `REQ-004` ## 목표 DB 기반 영속성 경로를 안정적으로 운영하고, 스키마 변경 이력과 문서 동기화를 일관되게 유지한다. @@ -257,6 +257,48 @@ Conditional migration behavior: - application layer cascade: - 포트폴리오 삭제 시 연관 `portfolio_comments`를 hard delete 한다. +## REQ-002 Tagging Storage Design + +목표: + +- 포트폴리오별 태그 연결을 중복 없이 저장한다. +- 앞뒤 공백 제거 기반 정규화와 검색용 include/exclude 조건을 DB 계층에서 일관되게 지원한다. + +구현 저장 모델: + +- 신규 테이블 `portfolio_tags` + - 복합 PK(`portfolio_id`, `tag_value`) 사용 + - `portfolio_id`: 대상 포트폴리오 + - `tag_value`: 앞뒤 공백 제거 후 저장되는 태그 값 + - `created_at`: 연결 생성 시각 +- 동일 포트폴리오 내 중복 태그는 PK 자체로 방지한다. + +정규화 책임: + +- API/CLI 입력 계층에서 앞뒤 공백 제거를 수행한다. +- repository는 이미 정규화된 `tag_value`를 기준으로 insert/delete/query를 수행한다. +- DB는 `UNIQUE (portfolio_id, tag_value)`로 중복 insert를 방지하고 application은 이를 `409 Conflict`로 매핑한다. + +검색 전략: + +- `includeTags`와 `excludeTags`는 모두 정규화된 태그 값 기준으로 비교한다. +- `matchMode=AND`는 `GROUP BY portfolio_id` + `HAVING COUNT(DISTINCT tag_value) = ` 전략을 기본안으로 둔다. +- `matchMode=OR`는 `WHERE tag_value IN (...)` + distinct portfolio projection을 기본안으로 둔다. +- `excludeTags`는 anti-join 또는 `NOT EXISTS` 전략을 사용한다. + +권장 인덱스: + +- `UNIQUE (portfolio_id, tag_value)` +- `INDEX (tag_value, portfolio_id)` +- owner-scoped 목록 필터와 결합될 수 있으므로 `portfolios.owner_user_id` 인덱스와 함께 실행 계획을 검토한다. + +적용 마이그레이션 단계: + +1. `20260414_0012_portfolio_tags.py`에서 `portfolio_tags` 테이블 생성 +2. `fk_portfolio_tags_portfolio` FK와 `idx_portfolio_tags_tag_portfolio` 인덱스 생성 +3. API/CLI/repository가 동일 revision 기준으로 태그 mutation 및 검색을 사용 +4. 필요 시 exact-match 중심 추가 인덱스를 검토 + ## Validation Notes - 마이그레이션은 빈 DB와 기존 DB 모두에서 연속 적용 가능해야 한다. - 파괴적 변경(drop/rename)은 백필 또는 호환 단계 계획 없이 직접 반영하지 않는다. @@ -276,7 +318,9 @@ Conditional migration behavior: | 20260403_0008 | migrations/versions/20260403_0008_portfolio_name_column.py | Add non-null `portfolios.name` and backfill from `symbol` | | 20260410_0009 | migrations/versions/20260410_0009_portfolio_comments_table.py | Add `portfolio_comments` table for per-portfolio memo history | | 20260413_0010 | migrations/versions/20260413_0010_portfolio_owner_user_id.py | Add `portfolios.owner_user_id`, backfill owner, create owner index, and add owner FK | +| 20260414_0011 | migrations/versions/20260414_0011_bot_principal_owner_user_id.py | Restore `bot_principals.owner_user_id`, backfill owner, create owner index, and add owner FK | +| 20260414_0012 | migrations/versions/20260414_0012_portfolio_tags.py | Add `portfolio_tags`, portfolio FK, and tag lookup index for REQ-002 | ## Open Implementation Points - `portfolio_ledger`의 Timescale hypertable 강제 여부와 보존 정책(retention)을 확정한다. -- `REQ-002` 태그 검색 구현 시 인덱스 전략(B-tree/GiN 등)과 마이그레이션 단계를 추가 정의한다. +- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다. diff --git a/docs/technical/portfolio-tagging-and-search-design.md b/docs/technical/portfolio-tagging-and-search-design.md new file mode 100644 index 0000000..bc3a3fb --- /dev/null +++ b/docs/technical/portfolio-tagging-and-search-design.md @@ -0,0 +1,109 @@ +# Portfolio Tagging and Search Design + +- Related REQ: `REQ-002` + +## 목표 +- 승인된 `REQ-002`를 구현 가능한 내부 설계로 정리한다. +- 태그 정규화, 검색 파라미터 의미, 모듈 책임, 저장소/마이그레이션 방향을 한 문서에서 추적한다. + +## 구현 범위 +- 포트폴리오별 태그 추가/삭제 흐름 +- HTTP API와 CLI의 공통 태그 검색 파라미터 의미 +- repository query 전략과 DB 인덱스 후보 + +제외: + +- 태그 추천, 자동 분류, 네임스페이스 사전 관리 +- 부분 일치/전방 일치/full-text 검색 + +## 정규화 정책 +- 입력 태그는 API/CLI 계층에서 앞뒤 공백을 제거한다. +- 정규화 후 빈 문자열이면 validation 오류로 처리한다. +- 대소문자, 내부 공백, `:` 네임스페이스는 변경하지 않는다. +- 중복 판단은 정규화된 값 기준으로 수행한다. + +예시: + +- `" grade:A "` -> `"grade:A"` +- `"Grade:A"`와 `"grade:A"`는 서로 다른 태그로 취급한다. +- `"blacklist: trade"`의 내부 공백은 보존한다. + +## 모듈 책임 + +API: + +- 대상 파일: `app/api/portfolios.py` +- 책임: + - `POST/GET/DELETE /portfolios/{portfolio_id}/tags` 제공 + - `GET /portfolios`의 반복 query 파라미터 `includeTags`, `excludeTags`, `matchMode` 수신 + - 기존 ownership/auth 정책 재사용 + +CLI: + +- 대상 파일: `app/cli/commands/portfolio.py` +- 책임: + - `portfolio tag add|list|remove` 명령 노출 + - `portfolio list --include-tag --exclude-tag --match-mode` 옵션 제공 + +Schema: + +- 대상 파일: `app/schemas/portfolio.py` +- 책임: + - tag mutation request/response 모델 정의 + - list query 파라미터 contract 정리 + +Service: + +- 대상 파일: `app/services/portfolio_service.py` +- 책임: + - 정규화된 태그 입력 검증 + - repository 호출 orchestration + - duplicate/not-found/auth 오류를 API/CLI가 소비할 수 있는 에러로 유지 + +Repository: + +- 대상 파일: `app/repositories/portfolio_repository.py`, `app/repositories/postgres.py` +- 책임: + - 태그 연결 추가/삭제 + - include/exclude + matchMode query 제공 + - duplicate insert를 명시적 오류로 변환 + +## 검색 파라미터 의미 + +- `includeTags` + - 포함해야 하는 정규화 태그 목록 +- `excludeTags` + - 하나라도 일치하면 제외할 정규화 태그 목록 +- `matchMode` + - `includeTags`에만 적용 + - `AND`: 모든 include tag 필요 + - `OR`: 하나 이상의 include tag 필요 + +처리 순서: + +1. 입력 태그 정규화 +2. ownership/auth 필터 적용 +3. include 조건 계산 +4. exclude 조건 제거 +5. 중복 portfolio 제거 +6. 기존 recency ordering과 충돌 여부 확인 + +## Validation Notes +- duplicate tag add는 `UNIQUE (portfolio_id, tag_value)` 또는 동등한 repository 검증으로 `409`에 매핑해야 한다. +- 존재하지 않는 포트폴리오에 대한 add/remove는 `404`로 매핑해야 한다. +- 검색 결과는 `OR` 조건에서 중복 없이 한 번만 반환해야 한다. +- owner-scoped 조회와 태그 필터를 함께 사용할 때 결과 집합이 ownership 경계를 넘지 않아야 한다. + +## 파일 위치 및 구현 후보 +- API route extension: `app/api/portfolios.py` +- CLI extension: `app/cli/commands/portfolio.py` +- schema extension: `app/schemas/portfolio.py` +- service orchestration: `app/services/portfolio_service.py` +- repository contract: `app/repositories/portfolio_repository.py` +- Postgres query implementation: `app/repositories/postgres.py` +- migration: `migrations/versions/20260414_0012_portfolio_tags.py` + +## 구현 결정 +- 태그 mutation은 기존 포트폴리오 리소스 하위 경로 `/portfolios/{portfolio_id}/tags`로 고정했다. +- `GET /portfolios` 태그 필터는 반복 query parameter 방식으로 구현했다. +- CLI 다중 태그 입력은 반복 옵션 `--include-tag`, `--exclude-tag`로 구현했다. \ No newline at end of file diff --git a/migrations/versions/20260414_0012_portfolio_tags.py b/migrations/versions/20260414_0012_portfolio_tags.py new file mode 100644 index 0000000..8b3bd6d --- /dev/null +++ b/migrations/versions/20260414_0012_portfolio_tags.py @@ -0,0 +1,79 @@ +"""add portfolio tags table + +Revision ID: 20260414_0012 +Revises: 20260414_0011 +Create Date: 2026-04-14 16:10:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + + +revision = "20260414_0012" +down_revision = "20260414_0011" +branch_labels = None +depends_on = None + + +def _table_exists(inspector, table_name: str) -> bool: + return table_name in inspector.get_table_names() + + +def _index_exists(inspector, table_name: str, index_name: str) -> bool: + return any(index["name"] == index_name for index in inspector.get_indexes(table_name)) + + +def _fk_exists(inspector, table_name: str, fk_name: str) -> bool: + return any(fk.get("name") == fk_name for fk in inspector.get_foreign_keys(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _table_exists(inspector, "portfolio_tags"): + op.create_table( + "portfolio_tags", + sa.Column("portfolio_id", sa.Text(), nullable=False), + sa.Column("tag_value", sa.Text(), nullable=False), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("portfolio_id", "tag_value", name="pk_portfolio_tags"), + ) + + inspector = inspect(bind) + if not _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"): + op.create_foreign_key( + "fk_portfolio_tags_portfolio", + "portfolio_tags", + "portfolios", + ["portfolio_id"], + ["portfolio_id"], + ondelete="CASCADE", + ) + + inspector = inspect(bind) + if not _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"): + op.create_index( + "idx_portfolio_tags_tag_portfolio", + "portfolio_tags", + ["tag_value", "portfolio_id"], + unique=False, + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _table_exists(inspector, "portfolio_tags"): + if _fk_exists(inspector, "portfolio_tags", "fk_portfolio_tags_portfolio"): + op.drop_constraint("fk_portfolio_tags_portfolio", "portfolio_tags", type_="foreignkey") + + inspector = inspect(bind) + if _index_exists(inspector, "portfolio_tags", "idx_portfolio_tags_tag_portfolio"): + op.drop_index("idx_portfolio_tags_tag_portfolio", table_name="portfolio_tags") + + op.drop_table("portfolio_tags") \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 568f40c..f59124c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,7 +9,7 @@ from typer.testing import CliRunner from app.cli.main import create_app from app.core.config import Settings -from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, StrategySet, User +from app.domain.models import LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, User class FakeRepo: @@ -21,6 +21,7 @@ class FakeRepo: self._users: dict[str, User] = {} self._user_seq = 1 self._comments: dict[str, PortfolioComment] = {} + self._tags: dict[str, PortfolioTag] = {} self.closed = False def close(self) -> None: @@ -179,6 +180,9 @@ class FakeRepo: 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, ) -> list[Portfolio]: if self._portfolio is None: return [] @@ -188,8 +192,46 @@ class FakeRepo: return [] if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id: return [] + tags = set(self._portfolio.tags or []) + if include_tags: + required = set(include_tags) + mode = (match_mode or "OR").upper() + if mode == "AND" and not required.issubset(tags): + return [] + if mode != "AND" and tags.isdisjoint(required): + return [] + if exclude_tags and tags.intersection(exclude_tags): + return [] return [self._portfolio] + def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: + portfolio = self.get(portfolio_id) + normalized = tag.strip() + key = f"{portfolio_id}:{normalized}" + if key in self._tags: + raise ValueError(f"이미 연결된 태그입니다: {normalized}") + created = datetime.now(timezone.utc) + item = PortfolioTag(portfolio_id=portfolio_id, tag=normalized, created_at=created) + self._tags[key] = item + portfolio.tags = sorted([*list(portfolio.tags or []), normalized]) + return item + + def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: + self.get(portfolio_id) + return sorted( + [tag for tag in self._tags.values() if tag.portfolio_id == portfolio_id], + key=lambda item: item.tag, + ) + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: + portfolio = self.get(portfolio_id) + normalized = tag.strip() + key = f"{portfolio_id}:{normalized}" + if key not in self._tags: + raise ValueError("태그를 찾을 수 없습니다") + del self._tags[key] + portfolio.tags = [item for item in (portfolio.tags or []) if item != normalized] + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: self.strategy_set = StrategySet(id="set-1", name=name, config=config, nick=f"{name}:99914b932bd3") self._strategy_sets[self.strategy_set.id] = self.strategy_set @@ -691,3 +733,39 @@ def test_cli_comment_list_nonexistent_portfolio() -> None: result = runner.invoke(runner.app, ["portfolio", "comment", "list", "nonexistent:id"]) assert result.exit_code == 1 + + +def test_cli_tag_add_and_list() -> None: + repo = FakeRepo(_portfolio()) + runner = build_runner(repo) + + add_result = runner.invoke(runner.app, ["portfolio", "tag", "add", "005930:tv", "--tag", " grade:A "]) + list_result = runner.invoke(runner.app, ["--format", "json", "portfolio", "tag", "list", "005930:tv"]) + + assert add_result.exit_code == 0 + assert "grade:A" in add_result.stdout + assert list_result.exit_code == 0 + payload = json.loads(list_result.stdout) + assert payload[0]["tag"] == "grade:A" + + +def test_cli_list_supports_tag_filters() -> None: + repo = FakeRepo(_portfolio()) + repo.add_tag("005930:tv", tag="grade:A") + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["portfolio", "list", "--include-tag", "grade:A", "--match-mode", "AND"]) + + assert result.exit_code == 0 + assert "005930:tv" in result.stdout + + +def test_cli_tag_remove_flow() -> None: + repo = FakeRepo(_portfolio()) + repo.add_tag("005930:tv", tag="grade:A") + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["portfolio", "tag", "remove", "005930:tv", "--tag", "grade:A", "--force"]) + + assert result.exit_code == 0 + assert "Removed tag: grade:A" in result.stdout diff --git a/tests/test_domain_services.py b/tests/test_domain_services.py index 480869f..e7c44e0 100644 --- a/tests/test_domain_services.py +++ b/tests/test_domain_services.py @@ -63,6 +63,9 @@ class FakeRepo: 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, ) -> list[Portfolio]: if self._portfolio is None: return [] @@ -70,6 +73,15 @@ class FakeRepo: return [] return [self._portfolio] + def add_tag(self, portfolio_id: str, *, tag: str): + raise NotImplementedError() + + def list_tags(self, portfolio_id: str): + return [] + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: + raise NotImplementedError() + def get_strategy_set(self, id: str) -> StrategySet | None: return self.strategy_set if id == self.strategy_set.id else None diff --git a/tests/test_portfolio_comments.py b/tests/test_portfolio_comments.py index be90297..7d665bd 100644 --- a/tests/test_portfolio_comments.py +++ b/tests/test_portfolio_comments.py @@ -50,11 +50,20 @@ class FakeRepo: setattr(portfolio, key, value) return portfolio - def list_portfolios(self, *, broker=None, status=None, owner_user_id=None): + def list_portfolios(self, *, broker=None, status=None, owner_user_id=None, include_tags=None, exclude_tags=None, match_mode=None): if owner_user_id is not None and self._portfolio and self._portfolio.owner_user_id != owner_user_id: return [] return [self._portfolio] if self._portfolio else [] + def add_tag(self, portfolio_id: str, *, tag: str): + raise NotImplementedError() + + def list_tags(self, portfolio_id: str): + return [] + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: + raise NotImplementedError() + def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: return self.strategy_set diff --git a/tests/test_portfolio_ownership.py b/tests/test_portfolio_ownership.py index f346a1d..c47c299 100644 --- a/tests/test_portfolio_ownership.py +++ b/tests/test_portfolio_ownership.py @@ -35,6 +35,29 @@ class FakeRepo: def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: return [] + 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, + ): + if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id: + return [] + return [self._portfolio] + + def add_tag(self, portfolio_id: str, *, tag: str): + raise NotImplementedError() + + def list_tags(self, portfolio_id: str): + return [] + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: + raise NotImplementedError() + def get_strategy_set(self, id: str): if id == self._strategy.id: return self._strategy diff --git a/tests/test_portfolio_tags.py b/tests/test_portfolio_tags.py new file mode 100644 index 0000000..7445cd0 --- /dev/null +++ b/tests/test_portfolio_tags.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +import app.api.portfolios as portfolios_api +from app.core.config import get_settings +from app.domain.models import LedgerEntry, Portfolio, PortfolioTag, StrategySet +from app.main import app + + +class FakeRepo: + def __init__(self, portfolio: Portfolio | None = None) -> None: + self._portfolio = portfolio + self._strategy = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:demo") + self._tags: dict[str, PortfolioTag] = {} + + def close(self) -> None: + return None + + def list_users(self): + return [] + + def get(self, portfolio_id: str) -> Portfolio: + if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: + raise ValueError("포트폴리오를 찾을 수 없습니다") + return self._portfolio + + def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: + return [] + + def get_strategy_set(self, id: str): + return self._strategy if id == self._strategy.id else None + + 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, + ) -> list[Portfolio]: + if self._portfolio is None: + return [] + if owner_user_id is not None and self._portfolio.owner_user_id != owner_user_id: + return [] + tags = set(self._portfolio.tags or []) + if include_tags: + mode = (match_mode or "OR").upper() + required = set(include_tags) + if mode == "AND" and not required.issubset(tags): + return [] + if mode != "AND" and tags.isdisjoint(required): + return [] + if exclude_tags and tags.intersection(exclude_tags): + return [] + return [self._portfolio] + + def add_tag(self, portfolio_id: str, *, tag: str) -> PortfolioTag: + portfolio = self.get(portfolio_id) + key = f"{portfolio_id}:{tag}" + if key in self._tags: + raise ValueError(f"이미 연결된 태그입니다: {tag}") + item = PortfolioTag(portfolio_id=portfolio_id, tag=tag, created_at=datetime.now(timezone.utc)) + self._tags[key] = item + portfolio.tags = sorted([*list(portfolio.tags or []), tag]) + return item + + def list_tags(self, portfolio_id: str) -> list[PortfolioTag]: + self.get(portfolio_id) + return sorted([tag for tag in self._tags.values() if tag.portfolio_id == portfolio_id], key=lambda item: item.tag) + + def remove_tag(self, portfolio_id: str, *, tag: str) -> None: + portfolio = self.get(portfolio_id) + key = f"{portfolio_id}:{tag}" + if key not in self._tags: + raise ValueError("태그를 찾을 수 없습니다") + del self._tags[key] + portfolio.tags = [item for item in (portfolio.tags or []) if item != tag] + + +def _portfolio() -> Portfolio: + return Portfolio( + portfolio_id="p1", + broker="kis", + status="enabled", + name="Samsung Electronics", + symbol="005930", + tags=[], + owner_user_id="owner-1", + cash_balance=1_000_000.0, + asset_quantity=1.0, + price=70_000.0, + profit_buffer=0.0, + active_strategy_id="set-1", + avabile_strategies=["set-1"], + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + +def _setup_client(monkeypatch, repo: FakeRepo) -> TestClient: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") + monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") + monkeypatch.setenv("PORTFOLIOHUB_TRADE_WRITE_TOKENS", "write-token") + monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token") + get_settings.cache_clear() + app.dependency_overrides.clear() + monkeypatch.setattr(portfolios_api, "get_portfolio_repository_from_settings", lambda settings: repo) + return TestClient(app) + + +def test_add_tag_normalizes_and_lists(monkeypatch) -> None: + repo = FakeRepo(_portfolio()) + client = _setup_client(monkeypatch, repo) + + add_response = client.post( + "/portfolios/p1/tags", + headers={"Authorization": "Bearer write-token"}, + json={"tag": " grade:A "}, + ) + list_response = client.get("/portfolios/p1/tags", headers={"Authorization": "Bearer read-token"}) + + app.dependency_overrides.clear() + assert add_response.status_code == 201 + assert add_response.json()["tag"] == "grade:A" + assert list_response.status_code == 200 + assert list_response.json()[0]["tag"] == "grade:A" + + +def test_duplicate_tag_returns_409(monkeypatch) -> None: + repo = FakeRepo(_portfolio()) + repo.add_tag("p1", tag="grade:A") + client = _setup_client(monkeypatch, repo) + + response = client.post( + "/portfolios/p1/tags", + headers={"Authorization": "Bearer write-token"}, + json={"tag": " grade:A "}, + ) + + app.dependency_overrides.clear() + assert response.status_code == 409 + + +def test_portfolio_list_filters_by_include_and_exclude_tags(monkeypatch) -> None: + repo = FakeRepo(_portfolio()) + repo.add_tag("p1", tag="grade:A") + client = _setup_client(monkeypatch, repo) + + include_response = client.get( + "/portfolios", + headers={"Authorization": "Bearer read-token"}, + params=[("includeTags", "grade:A"), ("matchMode", "AND")], + ) + exclude_response = client.get( + "/portfolios", + headers={"Authorization": "Bearer read-token"}, + params=[("excludeTags", "grade:A")], + ) + + app.dependency_overrides.clear() + assert include_response.status_code == 200 + assert include_response.json()[0]["tags"] == ["grade:A"] + assert exclude_response.status_code == 200 + assert exclude_response.json() == [] + + +def test_remove_tag_returns_204(monkeypatch) -> None: + repo = FakeRepo(_portfolio()) + repo.add_tag("p1", tag="grade:A") + client = _setup_client(monkeypatch, repo) + + response = client.request( + "DELETE", + "/portfolios/p1/tags", + headers={"Authorization": "Bearer write-token"}, + json={"tag": "grade:A"}, + ) + + app.dependency_overrides.clear() + assert response.status_code == 204 + assert repo._portfolio is not None + assert repo._portfolio.tags == [] \ No newline at end of file