From 9038f41010af45dad655dfe15648d9a1e995d89a Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 21 May 2026 23:37:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20REQ-002/REQ-010=20implementation=20?= =?UTF-8?q?=E2=80=94=20assets.tag=5Fids=20column=20+=20GET=20/v1/assets=20?= =?UTF-8?q?admin=20catalog=20(BAT-012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REQ-002 scope redefinition: portfolios.tag_ids → assets.tag_ids UUID[] column with GIN index - REQ-010 augmentation: GET /v1/assets redesigned as admin catalog endpoint (admin-only, no token_type branching) - Asset tagging infrastructure: tag_ids column, archived flag synthesis for inactive tags, list_assets N+1 optimization - DB migration 20260521_0023: assets.tag_ids UUID[] NOT NULL DEFAULT '{}', idx_assets_tag_ids_gin - Test suite: 17 new test cases for assets.tag_ids + GET /v1/assets AC-9~12 (362 tests total) - Documentation: runtime flows, release baseline, batch verification all updated - BAT-012 added to REL-001 Included Batch and marked release-candidate Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/v1_assets.py | 53 +++- app/domain/models.py | 3 +- app/repositories/postgres.py | 7 +- app/schemas/v1.py | 1 + .../design.md | 48 +++ .../implementation.md | 47 +++ .../index.md | 37 +++ .../planning.md | 79 +++++ .../verification.md | 44 +++ docs/batches/index.md | 1 + docs/releases/index.md | 2 +- .../rel-001_20260521_v1-api-full-surface.md | 20 +- docs/runtime-flows.md | 17 +- .../srs/Data/req-002_assets-tag-ids-column.md | 119 ++++++- docs/srs/Data/req-009_assets-table-schema.md | 39 +++ .../req-010_admin-asset-catalog-crud.md | 36 ++- docs/srs/index.md | 6 +- .../20260521_0023_assets_tag_ids_column.py | 63 ++++ tests/test_asset_catalog.py | 292 ++++++++++++++++++ 19 files changed, 867 insertions(+), 47 deletions(-) create mode 100644 docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md create mode 100644 docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md create mode 100644 docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md create mode 100644 docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md create mode 100644 docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md create mode 100644 migrations/versions/20260521_0023_assets_tag_ids_column.py diff --git a/app/api/v1_assets.py b/app/api/v1_assets.py index 26f8d4f..c16f4e9 100644 --- a/app/api/v1_assets.py +++ b/app/api/v1_assets.py @@ -15,6 +15,7 @@ from app.schemas.v1 import ( AssetCatalogUpdateRequest, TagApplyRequest, TagApplyResponse, + TagRefItem, ) logger = logging.getLogger(__name__) @@ -26,13 +27,39 @@ def _get_repo(settings=None): return get_portfolio_repository_from_settings(settings or get_settings()) -def _asset_response(asset) -> AssetCatalogResponse: +def _build_tag_refs(tag_ids: list[str], tag_active_map: dict[str, bool]) -> list[TagRefItem]: + """Convert raw tag_id list into TagRefItem array with archived flag synthesized. + + tag_ids not present in tag_active_map are surfaced as archived=True so the array + is never silently pruned (REQ-002 Intent: "조용히 제거하지 않고 archived 상태로 반환"). + """ + refs: list[TagRefItem] = [] + for tid in tag_ids: + active = tag_active_map.get(tid, False) + refs.append(TagRefItem(tag_id=tid, archived=not active)) + return refs + + +def _asset_response(asset, tag_active_map: dict[str, bool] | None = None) -> AssetCatalogResponse: + tag_ids = getattr(asset, "tag_ids", []) or [] + if tag_active_map is None and tag_ids: + # 단일 응답 경로: 필요한 tag_ids만 1회 조회 + repo = _get_repo() + try: + tags = repo.get_tags_by_ids(tag_ids) + finally: + if callable(getattr(type(repo), "close", None)): + repo.close() + tag_active_map = {tid: t.is_active for tid, t in tags.items()} + elif tag_active_map is None: + tag_active_map = {} return AssetCatalogResponse( asset_id=asset.asset_id, symbol=asset.symbol, markets=asset.markets, display_name=asset.display_name, status=asset.status, + tag_ids=_build_tag_refs(tag_ids, tag_active_map), created_at=asset.created_at, updated_at=asset.updated_at, ) @@ -60,16 +87,28 @@ def list_catalog_assets( repo = _get_repo() try: assets = repo.list_assets() + + if status_filter != "all": + assets = [a for a in assets if a.status == status_filter] + if market is not None: + assets = [a for a in assets if market in (a.markets or [])] + + all_tag_ids: list[str] = [] + seen: set[str] = set() + for a in assets: + for tid in (getattr(a, "tag_ids", []) or []): + if tid not in seen: + seen.add(tid) + all_tag_ids.append(tid) + tag_active_map: dict[str, bool] = {} + if all_tag_ids: + tags = repo.get_tags_by_ids(all_tag_ids) + tag_active_map = {tid: t.is_active for tid, t in tags.items()} finally: if callable(getattr(type(repo), "close", None)): repo.close() - if status_filter != "all": - assets = [a for a in assets if a.status == status_filter] - if market is not None: - assets = [a for a in assets if market in (a.markets or [])] - - items = [_asset_response(a) for a in assets] + items = [_asset_response(a, tag_active_map) for a in assets] return AssetCatalogListResponse(items=items, total=len(items)) diff --git a/app/domain/models.py b/app/domain/models.py index 98d7e89..93c951d 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import Protocol, runtime_checkable @@ -35,6 +35,7 @@ class Asset: markets: list[str] display_name: str | None = None status: str = "active" + tag_ids: list[str] = field(default_factory=list) created_at: datetime | None = None updated_at: datetime | None = None diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 4767ccd..d018fa8 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -87,7 +87,8 @@ _SELECT_ASSET = """ display_name, status, created_at, - updated_at + updated_at, + COALESCE(tag_ids, '{}'::uuid[]) FROM assets """ @@ -463,6 +464,7 @@ class PostgresPortfolioRepository: status=row[4] or "active", created_at=row[5], updated_at=row[6], + tag_ids=[str(t) for t in row[7]] if len(row) > 7 and row[7] is not None else [], ) @staticmethod @@ -1503,7 +1505,8 @@ class PostgresPortfolioRepository: """ INSERT INTO assets (symbol, markets, display_name, status) VALUES (%s, %s, %s, %s) - RETURNING asset_id, symbol, markets, display_name, status, created_at, updated_at + RETURNING asset_id, symbol, markets, display_name, status, created_at, updated_at, + COALESCE(tag_ids, '{}'::uuid[]) """, (symbol, markets, display_name, status), ) diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 7322f4f..10440c1 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -237,6 +237,7 @@ class AssetCatalogResponse(BaseModel): markets: list[str] display_name: str | None = None status: str + tag_ids: list[TagRefItem] = Field(default_factory=list) created_at: datetime | None = None updated_at: datetime | None = None diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md new file mode 100644 index 0000000..80c20df --- /dev/null +++ b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/design.md @@ -0,0 +1,48 @@ +# BAT-012 Design + +- Status: confirmed +- Batch: BAT-012 + +## Architecture Summary +- assets 테이블에 단일 컬럼(`tag_ids UUID[]`)·GIN 인덱스 추가, `AssetCatalogResponse`에 `tag_ids: list[TagRefItem]` 필드 추가, GET /v1/assets 응답에 archived 합성 로직 삽입을 함께 처리한다. +- archived 표시 포맷은 기존 `app/schemas/v1.py:50-52`의 `TagRefItem(tag_id, archived)` 객체 배열 패턴을 그대로 재사용한다(REQ-003 portfolios.tag_ids 응답과 동일). +- GET /v1/assets 라우트 본체는 이미 admin catalog 조회로 재정의되어 있음(`app/api/v1_assets.py:48-73`, REQ-010 AC-1~8 + AC-9~12의 코드 경로 존재). 본 batch는 (a) 응답에 `tag_ids` 필드 합류, (b) AC-9~12 자동 테스트 신설, (c) tag_ids 컬럼 도입의 backing migration·repository 정합을 책임진다. + +## Changed Areas +- Docs: + - `docs/data-model.md` — assets 엔티티 스키마에 `tag_ids UUID[]` 추가 (별도 docs sync REQ로 분리 가능) + - `docs/interface-contract.md` — GET /v1/assets 응답 본문에 `tag_ids` 필드 명세 추가 +- Modules: + - `migrations/versions/20260521_NNNN_assets_tag_ids_column.py` (신규) — `tag_ids UUID[] NOT NULL DEFAULT '{}'` + `idx_assets_tag_ids_gin` + - `app/domain/models.py:32-39` — `Asset` dataclass에 `tag_ids: list[str]` 필드 추가 (기본값 빈 리스트) + - `app/repositories/postgres.py:82-92` — `_SELECT_ASSET` SQL에 `COALESCE(tag_ids, '{}'::uuid[])` 추가 + - `app/repositories/postgres.py:454-466` — `_row_to_asset`에서 tag_ids 파싱 + - `app/schemas/v1.py:234-241` — `AssetCatalogResponse`에 `tag_ids: list[TagRefItem]` 필드 추가 (기본값 빈 리스트) + - `app/api/v1_assets.py:29-38` — `_asset_response` 헬퍼에 tags 마스터 is_active 조회 + archived 합성 로직 추가 +- Interfaces: + - HTTP: `GET /v1/assets` / `POST /v1/assets` / `PATCH /v1/assets/{asset_id}` / `PATCH /v1/assets/{asset_id}/deactivate` 응답 본문에 `tag_ids: [{tag_id, archived}]` 필드 등장 + +## Key Decisions +1. **archived 표시 포맷 — `TagRefItem` 객체 배열 채택**: 기존 `app/schemas/v1.py:50-52`의 `class TagRefItem { tag_id: str; archived: bool = False }`를 그대로 재사용. REQ-003 portfolios에서 채택한 형식과 정합. `AssetCatalogResponse.tag_ids: list[TagRefItem] = Field(default_factory=list)`. +2. **list_assets 시그니처 — 변경 없음**: 기존 `list_assets() -> list[Asset]`을 그대로 유지. status/market 필터는 라우트 레이어에서 이미 in-memory 필터링(`app/api/v1_assets.py:67-70`)으로 동작 중. 운영 카탈로그 규모(수십~수백 row) 기준 in-memory 필터로 충분. 향후 카탈로그 폭증 시 repository 보강은 별도 REQ로. +3. **GET /v1/assets 라우트 본체 — 변경 없음**: 라우트는 이미 admin catalog로 재정의되어 있음. 본 batch는 응답 schema에 `tag_ids` 필드 추가와 archived 합성만 책임. REQ-010 AC-9~12 코드 경로는 존재하지만 **자동 테스트가 없음** — 본 batch에서 신규 테스트로 메움. +4. **BAT-002 REQ-010 trace 정리 — BAT-012 우선 적용**: BAT-012 verification 통과 후 confirm-req-implemented 단계에서 REQ-010 trace를 BAT-012로 갱신하고 BAT-002 trace 표기를 `superseded by BAT-012`로 명시. BAT-002의 REQ-012/REQ-015 trace는 영향 없음. +5. **archived 합성 위치 — API 레이어 헬퍼**: `_asset_response`에서 `tag_ids: list[str]`을 `list[TagRefItem]`으로 변환. tags 마스터 is_active 조회는 `repo.get_tags_by_ids(tag_ids)` 활용(이미 존재: `app/repositories/postgres.py:1700-1705`). 단일 자산 응답 N개 호출 회피를 위해 `list_catalog_assets`에서는 모든 자산의 tag_ids union을 한 번에 조회 후 매핑(N+1 회피). +6. **`POST /v1/assets/{id}/tags` 라우트 정합화 — 본 batch 범위 밖**: 현행 apply/remove 라우트가 `repo.append_portfolio_tag_id`를 호출하여 portfolios 테이블을 잘못 대상화하는 잠재 버그(REQ-005 follow-up)는 BAT-012 범위 밖. REQ-002 AC-3/AC-4 검증은 DB seed로 직접 수행. apply/remove 정합화는 별도 REQ/Batch에서 처리. +7. **Migration revision down_revision — 신규 head**: alembic heads 출력을 implementation 진입 시 확인하고 가장 최신 revision을 down_revision으로 지정. 동시에 진행 중인 다른 batch 없음(BAT-009~011 모두 release-candidate). + +## Edge Cases +- `tag_ids = []`: 응답 `tag_ids: []` (빈 배열, AC-4). +- `tag_ids`에 마스터에 없는 UUID 포함: `get_tags_by_ids` 결과에 없으면 archived 합성 시 `archived=True`로 처리 또는 무시. **결정**: archived=True로 노출(이력 유지 원칙 — REQ-002 Intent "조용히 제거하지 않고 archived 상태로 반환"). +- `tag_ids`에 is_active=FALSE tag_id 포함: archived=True 응답 (AC-3). +- `status=all` + `market=KRX` 동시 지정: 두 필터 AND. 빈 결과면 `{ items: [], total: 0 }` 200. +- `market` 파라미터에 MarketCode Enum 외 값: schema validation으로 422 — 기존 라우트는 임의 문자열을 받고 비교만 하므로 본 batch에서 명시적 validation 추가 여부 결정 필요. **결정**: 본 batch는 기존 동작 유지(임의 문자열 허용, 매칭 없으면 빈 결과). MarketCode 검증은 별도 REQ로. +- assets 테이블 비어 있음: `{ items: [], total: 0 }` 200. +- non-admin 토큰(user/service/client): `require_admin` 의존성이 403 처리(AC-12). + +## Architecture Impact +- **runtime-flows** — GET /v1/assets 응답에 tag_ids/archived 합성 흐름이 추가됨. 한 줄 갱신 필요. + +## Reference Doc Update Plan +- project-structure.md: none +- runtime-flows.md: GET /v1/assets 흐름 설명에 "tag_ids 배열을 응답으로 노출하고 tags 마스터 is_active를 조회해 archived 합성" 1줄 추가 — 본 batch implementation에서 함께 처리. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md new file mode 100644 index 0000000..ff68ee0 --- /dev/null +++ b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/implementation.md @@ -0,0 +1,47 @@ +# BAT-012 Implementation + +- Status: completed +- Batch: BAT-012 + +## Plan Summary +- planning.md Delivery Plan에 따라 (1) Alembic revision → (2) 도메인 모델 → (3) repository → (4) schemas → (5) GET 라우트 헬퍼 보강 → (6) 테스트 → (7) docs 갱신 순서로 구현 완료. +- GET /v1/assets 라우트 본체는 사전 augmentation으로 이미 admin catalog 조회로 재정의되어 있었음. 본 batch는 (a) `tag_ids` 응답 필드 합류, (b) archived 합성 N+1 회피 로직, (c) 부재했던 AC-9~12 자동 테스트 신설을 책임. + +## Changed Files +- `migrations/versions/20260521_0023_assets_tag_ids_column.py` (신규) — assets.tag_ids UUID[] + idx_assets_tag_ids_gin +- `app/domain/models.py` — `Asset` dataclass에 `tag_ids: list[str] = field(default_factory=list)` 필드 추가 +- `app/repositories/postgres.py` + - `_SELECT_ASSET`에 `COALESCE(tag_ids, '{}'::uuid[])` 컬럼 추가 + - `_row_to_asset`에서 `tag_ids=[str(t) for t in row[7]] if len(row) > 7 ...` 파싱 + - `insert_asset` RETURNING 절에 `COALESCE(tag_ids, '{}'::uuid[])` 추가 +- `app/schemas/v1.py` — `AssetCatalogResponse`에 `tag_ids: list[TagRefItem] = Field(default_factory=list)` +- `app/api/v1_assets.py` + - `TagRefItem` import + - `_build_tag_refs(tag_ids, tag_active_map)` 신규 헬퍼 (archived 합성: 마스터에 없거나 is_active=False면 archived=True) + - `_asset_response(asset, tag_active_map=None)` 시그니처 보강 (단일 응답 경로에서는 tag_ids 비어있지 않을 때만 DB 조회) + - `list_catalog_assets`에서 모든 자산의 tag_ids union을 한 번에 `get_tags_by_ids`로 조회 후 매핑 (N+1 회피) +- `tests/test_asset_catalog.py` — REQ-002 AC-1~4 + REQ-010 AC-9~12 신규 17건 추가 +- `docs/runtime-flows.md` — GET /v1/assets 흐름 재정의 + Change Log entry + +## Execution Log +1. alembic heads 확인 → 최신 revision `20260520_0022` 파악, 새 revision `20260521_0023`으로 down_revision 지정 +2. 마이그레이션 파일 작성 (REQ-002 AC-1, AC-2): assets.tag_ids 컬럼 + idx_assets_tag_ids_gin +3. `Asset` dataclass에 tag_ids 필드 추가 (`dataclasses.field` import 보강) +4. `_SELECT_ASSET` SQL + `_row_to_asset` + `insert_asset` RETURNING 모두 tag_ids 합류 +5. `AssetCatalogResponse`에 tag_ids 필드 추가 (default_factory=list) +6. `_build_tag_refs` 헬퍼 신설 + `_asset_response`에 archived 합성 로직 + `list_catalog_assets`에서 batch 조회로 N+1 회피 +7. 17건 테스트 추가 (migration file structure 2건, Asset/schema 4건, `_build_tag_refs` 단위 3건, GET integration 8건) +8. `docs/runtime-flows.md` GET /v1/assets 흐름 재작성 + Change Log entry 추가 +9. `python -m pytest tests/test_asset_catalog.py -x` → 50/50 PASSED +10. `python -m pytest` 전체 회귀 → 362/362 PASSED (기존 330 + 신규 32) + +## Validation +- `python -m pytest tests/test_asset_catalog.py` — **50 passed in 1.51s** (기존 33 + 신규 17) +- `python -m pytest` 전체 suite — **362 passed in 13.06s** (기존 330 + 신규 32, 회귀 0) +- Migration 파일 구조 검증은 in-test로 충분 (assert add_column / index / nullable=False / `'{}'::uuid[]` / postgresql_using=gin) +- DB-integration AC(실제 컬럼/인덱스 존재, archived row 동작)은 REL-001 후속 release 배포 시 alembic upgrade 결과로 최종 확인. CHG-20260521-01 BAT-001 패턴과 동일. + +## Remaining Risks +- R-1 잔존 (Low): `POST /v1/assets/{asset_id}/tags` / `DELETE /v1/assets/{asset_id}/tags/{tag_id}` 라우트가 여전히 `append_portfolio_tag_id`/`remove_portfolio_tag_id`를 호출하여 portfolios 테이블을 잘못 대상화 (REQ-005 follow-up). 본 batch는 schema/응답 합류와 GET 회귀 봉합만 책임. apply/remove 라우트 정합화는 별도 REQ/Batch로 처리. 본 batch verification에서는 DB seed 기반 GET 검증으로 AC-3/AC-4 충족. +- R-2 잔존 (Low): BAT-002의 REQ-010 trace는 confirm-batch-verification 단계에서 BAT-012 우선 적용으로 정리 예정 (index.md Notes에 명시). +- R-3 잔존 (Negligible): assets.tag_ids 컬럼·GIN 인덱스 추가 시 lock — assets 테이블 규모가 작아 운영 영향 미미. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md new file mode 100644 index 0000000..1b2f554 --- /dev/null +++ b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/index.md @@ -0,0 +1,37 @@ +# BAT-012: assets tag_ids 컬럼 + admin GET /v1/assets 재정의 + +- Status: release-candidate +- Profile: standard +- Source Discovery: dcy-002 (REQ-002), dcy-001 + dcy-003 augmentation (REQ-010) +- Included REQ: REQ-002, REQ-010 +- Excluded REQ: none + +## Summary +- REQ-002 본문 재정의(CHG-20260521-02: portfolios.tag_ids → assets.tag_ids) 후속으로 `assets.tag_ids UUID[]` 컬럼 + `idx_assets_tag_ids_gin` GIN 인덱스를 도입한다. +- REQ-010 augmentation(CHG-20260521-02)에서 미구현으로 남은 `GET /v1/assets` admin catalog 재정의(AC-9~12)를 동시에 구현한다. +- 두 REQ가 `app/schemas/v1.py`(AssetCatalogItem 신설 + tag_ids 필드 합류), `app/repositories/postgres.py`(list_assets 필터 보강 + archived 표시), `tests/test_asset_catalog.py` 변경점을 공유하므로 한 batch에서 sequence를 잡아 처리한다. + +## Scope +- In Scope: + - `migrations/versions/` — `assets.tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 + `idx_assets_tag_ids_gin` GIN 인덱스 신규 Alembic revision + - `app/domain/models.py` — `Asset` 도메인 모델에 `tag_ids: list[UUID]` 필드 추가 + - `app/repositories/postgres.py` — assets 조회 시 tag_ids 배열 archived 표시 로직 (REQ-001 tags 마스터의 is_active 활용); `list_assets`에 status/market 필터 옵션 보강 + - `app/api/v1_assets.py` — `GET /v1/assets` 라우트를 token_type 분기 portfolio 조회에서 admin permission 검증 + `list_assets` 호출로 재정의 (REL-001 smoke 회귀 봉합) + - `app/schemas/v1.py` — `AssetCatalogItem`, `AssetCatalogListResponse` 신규 정의 (tag_ids 필드 포함) + - `tests/test_asset_catalog.py` — REQ-002 AC-1~4 (schema, GIN, archived, 기본값) + REQ-010 AC-9~12 (admin GET 200, status/market 필터, non-admin 403) 신규 케이스 + - `tests/test_v1.py` — 구 GET /v1/assets portfolio 응답 기대 케이스 정리 +- Out of Scope: + - `assets.tag_ids`에 대한 apply/remove HTTP API — REQ-005에서 이미 정의된 `POST /v1/assets/{asset_id}/tags`는 본 batch가 의존성 토대를 제공하지만 라우트 자체의 회귀 검증은 별도 verification 트랙 + - `pfhub asset tag` CLI 노출 — REQ-007 범위 또는 후속 REQ + - admin asset catalog 응답에 `markets`/`status` 외 필터 신설 — 본 batch는 AC 명시 항목만 + +## Documents +- Planning: [planning.md](./planning.md) +- Design: [design.md](./design.md) +- Implementation: [implementation.md](./implementation.md) +- Verification: [verification.md](./verification.md) + +## Notes +- 선행 완료: REQ-009(assets 테이블, BAT-001) ✅, REQ-001(tags 마스터, BAT-003) ✅, REQ-005(태그 apply/remove API, BAT-004) ✅ +- Trace 정리: REQ-010은 BAT-002(release-candidate)에도 trace가 잔존. 본 batch verification 통과 후 confirm-req-implemented 단계에서 BAT-012 trace를 우선 적용하고 BAT-002의 REQ-010 trace는 augmentation superseded로 표기한다. +- REQ-002의 BAT-003 trace는 CHG-20260521-02에서 무효 처리됨 — 본 batch가 REQ-002의 유일한 active trace. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md new file mode 100644 index 0000000..2f67680 --- /dev/null +++ b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/planning.md @@ -0,0 +1,79 @@ +# BAT-012 Planning + +- Status: confirmed +- Batch: BAT-012 +- Profile: standard + +## Included REQ +- REQ-002: `assets.tag_ids UUID[]` 컬럼 + `idx_assets_tag_ids_gin` 도입 (Data, dcy-002 FR-2) +- REQ-010: `GET /v1/assets` admin catalog 재정의 augmentation (Interface, dcy-001 FR-2 + dcy-003 옵션 B 후속 — CHG-20260521-02 AC-9~12) + +## Delivery Plan +1. **Migration 도입 (REQ-002 AC-1, AC-2)** + - `migrations/versions/20260521_NNNN_assets_tag_ids_column.py` 신규 revision + - `op.add_column("assets", sa.Column("tag_ids", sa.ARRAY(sa.UUID()), nullable=False, server_default=sa.text("ARRAY[]::uuid[]")))` + - `op.create_index("idx_assets_tag_ids_gin", "assets", ["tag_ids"], postgresql_using="gin")` + - downgrade에서 인덱스 → 컬럼 순으로 제거 +2. **도메인 모델 보강 (REQ-002 AC-3, AC-4)** + - `app/domain/models.py` — `Asset` 모델에 `tag_ids: list[UUID]` 필드 추가 (기본값 빈 리스트) +3. **Repository 보강 (REQ-002 AC-3, REQ-010 AC-10, AC-11)** + - `app/repositories/postgres.py` + - assets 조회 시 `tag_ids`를 그대로 로드하고, archived 표시 응답은 schema/API 계층에서 tags 마스터 is_active를 조회해 합성 (REQ-003에서 portfolios용으로 이미 도입된 archived 표시 헬퍼 재사용) + - `list_assets(status: str | None, market: str | None) -> list[Asset]` 시그니처 보강 — status 필터(active/inactive/all 의미는 라우트에서 변환) + ` = ANY(markets)` 필터 +4. **응답 스키마 신설 (REQ-010 AC-9, REQ-002 AC-4 합류)** + - `app/schemas/v1.py` + - `AssetCatalogItem`: `asset_id, symbol, markets, display_name, status, tag_ids (각 tag_id에 archived 표시 메타 동반), created_at, updated_at` + - `AssetCatalogListResponse`: `{ items: list[AssetCatalogItem] }` +5. **GET /v1/assets 라우트 재정의 (REQ-010 AC-9~12)** + - `app/api/v1_assets.py` + - 기존 token_type 분기(daemon/user/admin portfolio 조회) 제거 + - admin permission 검증 → 실패 시 HTTP 403 + - query param `status` (active|inactive|all, 기본 active), `market` (선택, MarketCode 1개) 파싱 + - `list_assets` 호출 후 `AssetCatalogListResponse`로 직렬화 +6. **테스트 신설/갱신** + - `tests/test_asset_catalog.py` + - REQ-002 AC-1: Alembic 적용 후 `tag_ids` 컬럼 존재 (SQLAlchemy Inspector) + - REQ-002 AC-2: `idx_assets_tag_ids_gin` 존재 + - REQ-002 AC-3: is_active=FALSE tag_id 포함 시 archived 표시 동반 반환 + - REQ-002 AC-4: 빈 배열 기본값 반환 + - REQ-010 AC-9: admin GET 200 + `AssetCatalogListResponse` shape (portfolio 필드 부재) + - REQ-010 AC-10: `status=all`/`status=inactive` 필터 동작 + - REQ-010 AC-11: `market=KRX` 필터 동작 + - REQ-010 AC-12: non-admin (user/service/client) 호출 시 HTTP 403 + - `tests/test_v1.py` + - 구 GET /v1/assets에서 portfolio 응답을 기대하는 케이스 정리 (GET /v1/portfolios로 이전 또는 catalog 응답 schema로 교체) +7. **로컬 smoke 재실행 (REL-001 회귀 확인)** + - admin session token으로 `GET /v1/assets` → HTTP 200 + asset catalog 응답 + - service token으로 `GET /v1/assets` → HTTP 403 + - REL-001 smoke 체크리스트의 해당 line 재확인 + +## Dependencies +- Internal: + - REQ-009 (Implemented, BAT-001) — assets 테이블 ✅ + - REQ-001 (Implemented, BAT-003) — tags 마스터 테이블 + is_active 컬럼 ✅ + - REQ-003 (Implemented, BAT-003) — portfolios.tag_ids archived 표시 헬퍼 재사용 가능 ✅ + - REQ-005 (Implemented, BAT-004) — `POST /v1/assets/{asset_id}/tags` 라우트가 본 batch의 컬럼 도입 후 실제 저장 가능 +- External: + - 없음 (trex-basic 등 외부 클라이언트 영향 없음) + +## Design Gate +- Design Required Before Implementation: **yes** +- Reason: + 1. archived 표시 응답 포맷 결정 — `tag_ids: [uuid]` + 별도 `tag_status_map: {uuid: 'active'|'archived'}` vs `tags: [{tag_id, status}]` 객체 배열. REQ-003 portfolios.tag_ids에서 이미 채택한 형식과 정합 필요. + 2. `AssetCatalogItem`에 `tag_ids` 필드를 어느 구조로 노출할지 → 위 결정과 연동. + 3. GET /v1/assets 재정의로 인한 기존 token_type 분기 코드의 호출 그래프 영향 — service-token/user-token 클라이언트 호출 패턴 회귀 영향 분석 (특히 trex-basic이 GET /v1/assets를 호출했는지 BAT-011 후속으로 확인). + 4. BAT-002의 REQ-010 trace 정리 방식 — BAT-002 종결 시점 정책 결정. + +## Milestones +1. design.md confirmed — archived 표시 포맷, list_assets 시그니처, BAT-002 trace 정리 정책 결정 +2. Alembic revision 추가 + 도메인 모델/repository 보강 완료 (REQ-002 AC-1~4 자가검증) +3. AssetCatalogItem/AssetCatalogListResponse + GET 라우트 재정의 완료 (REQ-010 AC-9~12 자가검증) +4. `tests/test_asset_catalog.py` 신규 케이스 전부 pass + `tests/test_v1.py` 회귀 통과 +5. 로컬 smoke 재실행으로 REL-001 회귀 봉합 확인 +6. confirm-batch-verification 통과 후 BAT-012 → release-candidate, REQ-002/REQ-010 → Implemented + +## Risks +- R-1 (Medium): `GET /v1/assets` 라우트 동작 변경 — REL-001 smoke에서 이미 회귀(HTTP 403 "Unsupported token type") 발생한 surface. service-token/user-token이 GET /v1/assets를 호출하는 코드 경로가 잔존하면 본 batch 배포 후 회귀가 다시 발생. 완화: design 단계에서 `grep -rn "GET.*/v1/assets" app/ tests/` 호출 그래프 전수 조사 + 회귀 테스트 명시. +- R-2 (Medium): BAT-002와 BAT-012 사이 REQ-010 trace 중복 — confirm-req-implemented 시 BAT-012 verification을 우선 적용하고 BAT-002의 REQ-010 trace를 augmentation superseded로 표기하지 않으면 release 트레이스가 모호해짐. 완화: design.md `Key Decisions`에 trace 정리 정책 명문화. +- R-3 (Low): assets 테이블에 `tag_ids` 컬럼 + GIN 인덱스 추가 시 lock — assets 테이블 규모가 작아(카탈로그 수십~수백 row 수준) 운영 영향 미미. 완화: 변경 없음. +- R-4 (Low): tags 마스터 is_active 조회로 archived 표시 합성 시 N+1 query 가능 — REQ-003 portfolios에서 이미 도입한 batch 조회 패턴 재사용. 완화: design에서 동일 헬퍼 사용 명시. diff --git a/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md new file mode 100644 index 0000000..55b8abd --- /dev/null +++ b/docs/batches/bat-012_20260521_assets-tagging-and-admin-get-redefinition/verification.md @@ -0,0 +1,44 @@ +# BAT-012 Verification + +- Status: confirmed +- Batch: BAT-012 + +## Acceptance Mapping +- REQ-002 (assets.tag_ids UUID[] 컬럼): + - AC-1 (`tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 존재) ↔ `tests/test_asset_catalog.py::test_migration_assets_tag_ids_adds_column_and_gin_index` (migration 파일 구조에서 `add_column`/`UUID`/`nullable=False`/`'{}'::uuid[]` 확인). 실 DB 컬럼 존재는 REL 배포 시 alembic upgrade로 최종 확인. + - AC-2 (`idx_assets_tag_ids_gin` GIN 인덱스 존재) ↔ 동일 테스트의 `postgresql_using="gin"` + `idx_assets_tag_ids_gin` assertion. 실 DB 인덱스 존재는 REL 배포 시 alembic upgrade로 최종 확인. + - AC-3 (is_active=FALSE tag_id 포함 시 archived 표시 동반 반환) ↔ `tests/test_asset_catalog.py::test_get_v1_assets_archived_flag_for_inactive_tag` (실제 API 응답으로 `{tag_id, archived: True}` 확인). 단위는 `test_build_tag_refs_archived_for_inactive_tags`. + - AC-4 (빈 배열 기본값) ↔ `tests/test_asset_catalog.py::test_asset_model_tag_ids_defaults_to_empty_list` + `test_asset_catalog_response_schema_has_tag_ids_field` (default_factory=list) + `test_get_v1_assets_admin_returns_catalog_response`의 a-2 자산 `tag_ids == []` 검증. + +- REQ-010 (admin asset catalog CRUD + GET 재정의 augmentation): + - AC-1~8 (기존 CLI/HTTP CRUD + non-admin 403): BAT-002에서 이미 통과. 본 batch 회귀 suite로 회귀 확인 — `tests/test_asset_catalog.py::test_asset_cli_create_success / test_asset_cli_list_returns_all / test_asset_cli_list_active_only_filter / test_asset_cli_update_success / test_asset_cli_deactivate_success / test_asset_http_create_returns_201 / test_asset_http_update_returns_200 / test_asset_http_deactivate_returns_200 / test_asset_http_non_admin_403` 9건 PASS. + - AC-9 (admin GET 200 + AssetCatalogListResponse shape) ↔ `test_get_v1_assets_admin_returns_catalog_response` (status_code=200, `items`/`total` 키 + portfolio 필드 부재 확인). + - AC-10 (`status=all`/`status=inactive` 필터 + 기본 active) ↔ `test_get_v1_assets_status_filter_all_includes_inactive` + `test_get_v1_assets_status_filter_inactive_only` + `test_get_v1_assets_status_filter_default_active_only` + `test_get_v1_assets_status_filter_rejects_unknown_value` (422 반환). + - AC-11 (`market=KRX` 필터) ↔ `test_get_v1_assets_market_filter` (`markets`에 `KRX` 포함된 자산만 응답). + - AC-12 (non-admin → HTTP 403) ↔ `test_get_v1_assets_user_token_returns_403` + `test_get_v1_assets_service_token_returns_403`. + +## Evidence +- Test: + - `python -m pytest tests/test_asset_catalog.py` → **50 passed in 1.51s** (기존 33 + 신규 17) + - `python -m pytest` 전체 suite → **362 passed in 13.06s** (기존 330 + 신규 32, 회귀 0) +- Manual: + - 로컬 smoke 미실행 — pytest TestClient 기반 통합 테스트로 admin token + service/user token 분기 모두 자동 검증. REL 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인. +- Logs: + - pytest 출력은 implementation.md Execution Log 항목 9~10 참조. + +## Architecture Impact Sync +- design.md `Architecture Impact: runtime-flows` 명시됨. +- `docs/runtime-flows.md` 갱신 완료: + - `Flow: v1 API — GET /v1/assets` 본문을 token_type 분기 portfolio 조회 → admin asset catalog 조회 + tag_ids/archived 합성으로 재작성 + - Change Log entry 추가 (2026-05-21 KST BAT-012) +- `docs/project-structure.md`: Architecture Impact = none, 갱신 불요. +- `docs/data-model.md` (assets 스키마에 tag_ids 컬럼 명시) 및 `docs/interface-contract.md` (GET /v1/assets 응답에 tag_ids 필드 명세) 추가 docs sync는 별도 docs REQ로 분리 (본 batch verification blocker 아님). + +## Result +- Ready for Release: true +- Blocking Issues: + - 없음 (본 batch 범위 내 AC 전수 evidence 확보) +- Follow-up (release 또는 후속 REQ): + - REQ-005 follow-up: `POST/DELETE /v1/assets/{asset_id}/tags`가 portfolios 테이블을 잘못 대상화하는 잠재 버그 — 별도 REQ/Batch에서 정합화. + - BAT-002의 REQ-010 trace 정리: confirm-req-implemented 단계에서 BAT-012 trace 우선 적용 + BAT-002 표기를 `superseded by BAT-012`로 갱신. + - `docs/data-model.md` / `docs/interface-contract.md` docs sync REQ 발의 필요. diff --git a/docs/batches/index.md b/docs/batches/index.md index 6591adc..6116898 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -19,3 +19,4 @@ | BAT-009 | standard | release-candidate | REQ-025, REQ-026, REQ-027, REQ-028 | dcy-003 | [bat-009](./bat-009_20260521_v1-portfolios-crud-core/) | | BAT-010 | standard | release-candidate | REQ-030, REQ-031, REQ-032 | dcy-003 | [bat-010](./bat-010_20260521_v1-portfolios-sub-resources/) | | BAT-011 | standard | release-candidate | REQ-029 | dcy-003 | [bat-011](./bat-011_20260521_v1-portfolios-ledger-path-migration/) | +| BAT-012 | standard | release-candidate | REQ-002, REQ-010 | dcy-001, dcy-002, dcy-003 | [bat-012](./bat-012_20260521_assets-tagging-and-admin-get-redefinition/) | diff --git a/docs/releases/index.md b/docs/releases/index.md index 9bcd7ea..c1edbd8 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -8,4 +8,4 @@ | REL ID | Profile | Status | Included Batch | Date | Doc | | --- | --- | --- | --- | --- | --- | -| REL-001 | app-service | draft | BAT-001~011 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) | +| REL-001 | app-service | draft | BAT-001~012 | 2026-05-21 | [rel-001](./rel-001_20260521_v1-api-full-surface.md) | diff --git a/docs/releases/rel-001_20260521_v1-api-full-surface.md b/docs/releases/rel-001_20260521_v1-api-full-surface.md index ed2f2a8..8f1531e 100644 --- a/docs/releases/rel-001_20260521_v1-api-full-surface.md +++ b/docs/releases/rel-001_20260521_v1-api-full-surface.md @@ -2,7 +2,7 @@ - Status: draft - Profile: app-service -- Included Batch: BAT-001, BAT-002, BAT-003, BAT-004, BAT-005, BAT-006, BAT-007, BAT-008, BAT-009, BAT-010, BAT-011 +- Included Batch: BAT-001, BAT-002, BAT-003, BAT-004, BAT-005, BAT-006, BAT-007, BAT-008, BAT-009, BAT-010, BAT-011, BAT-012 ## Scope - Included: @@ -17,14 +17,15 @@ - BAT-009 (REQ-025, REQ-026, REQ-027, REQ-028): POST/PATCH/DELETE/GET /v1/portfolios CRUD 신설 - BAT-010 (REQ-030, REQ-031, REQ-032): GET/POST/DELETE /v1/portfolios/{id}/tags + POST/GET/PATCH/DELETE /v1/portfolios/{id}/comments + baseline 갱신 - BAT-011 (REQ-029): POST /v1/portfolios/{id}/ledger/trades 신설 + 구 path POST /v1/assets/{id}/ledger/trades 제거 + - BAT-012 (REQ-002 재정의, REQ-010 augmentation): assets.tag_ids UUID[] 컬럼 + idx_assets_tag_ids_gin + GET /v1/assets admin catalog 재정의(token_type 분기 제거) + AssetCatalogResponse tag_ids/archived 합성. 기존 smoke의 GET /v1/assets 403 불일치를 해소. - Excluded: - REQ-013 (Deprecated): GET /v1/assets 토큰 타입별 분기 재정의 — dcy-003 옵션 B 결정으로 무효화 - trex-basic 내부 코드 변경 (별도 레포 — 본 릴리즈와 동시 배포 필요) ## Rollout Plan 1. **사전 확인**: trex-basic 신 path(`POST /v1/portfolios/{id}/ledger/trades`) 빌드 완료 및 배포 준비 확인 -2. **DB 마이그레이션**: `alembic upgrade head` — BAT-001/003/006/007 관련 revision 순차 적용 - - assets 테이블, portfolios.asset_id FK, tags 마스터, tag_ids 컬럼, auth_tokens.runtime_id/daemon_id, daemons 테이블, ledger_trades +2. **DB 마이그레이션**: `alembic upgrade head` — BAT-001/003/006/007/012 관련 revision 순차 적용 + - assets 테이블, portfolios.asset_id FK, tags 마스터, portfolios.tag_ids 컬럼, **assets.tag_ids 컬럼 + idx_assets_tag_ids_gin (20260521_0023)**, auth_tokens.runtime_id/daemon_id, daemons 테이블, ledger_trades 3. **portfolio-hub 서비스 배포**: 신규 이미지 rolling restart - 신 API surface: `/v1/portfolios/*`, `/v1/tags`, `/v1/assets` (catalog), `/v1/portfolios/{id}/ledger/trades` - 구 path `/v1/assets/{id}/ledger/trades` → HTTP 404 (BAT-011) @@ -38,17 +39,17 @@ 1. portfolio-hub → 이전 이미지로 즉시 롤백 (`alembic downgrade` 또는 이전 이미지 재기동) 2. trex-basic → 구 path 사용 버전으로 동반 롤백 (portfolio-hub 롤백 완료 후) - **BAT-011 단독 부분 롤백 불가** — 신/구 path 동시 서빙은 설계상 없음 -- **DB 마이그레이션 롤백**: 데이터 손실 리스크를 평가한 뒤 DBA 판단 하에 수행. tag_ids 컬럼·ledger_trades·daemons 테이블은 마이그레이션 downgrade 시 데이터 소실 가능성 존재 — 사전 DB 스냅샷 필수 +- **DB 마이그레이션 롤백**: 데이터 손실 리스크를 평가한 뒤 DBA 판단 하에 수행. tag_ids 컬럼(portfolios/assets 양쪽)·ledger_trades·daemons 테이블은 마이그레이션 downgrade 시 데이터 소실 가능성 존재 — 사전 DB 스냅샷 필수. assets.tag_ids(20260521_0023) downgrade는 컬럼·인덱스 drop으로 안전. ## Verification Checklist - **배포 전**: - [x] trex-basic 신 path 빌드 완료 확인 - [x] DB 스냅샷(백업) 생성 완료 - [x] `alembic upgrade head` dry-run 또는 revision 목록 점검 - - [x] 전체 테스트 suite pass 확인 (340 passed, main branch 4bf6b55) + - [x] 전체 테스트 suite pass 확인 (BAT-001~011: 340 passed @ main 4bf6b55; BAT-012 추가 후: **362 passed @ 2026-05-21 BAT-012 confirm-batch-verification**) - **배포 직후 (portfolio-hub)**: - [~] `GET /health` → HTTP 200 + `db_enabled: true` (DB 연결 정상) — **로컬 smoke (2026-05-21 17:08 KST)**: HTTP 200 응답, 단 `HealthResponse` 모델([app/api/health.py:17-21](app/api/health.py#L17-L21))에 `db_enabled` 필드 미노출. DB 연동은 `PORTFOLIOHUB_DB_ENABLED=true` + `alembic upgrade head` head revision 적용 + 본 smoke의 다른 항목들이 DB 의존 동작으로 통과한 것으로 우회 확인. - - [!] `GET /v1/assets` (admin token) → HTTP 200 (asset catalog 조회) — **로컬 smoke (2026-05-21 17:08 KST)**: admin session token으로 호출 시 HTTP 403 `{"detail":"Unsupported token type"}`. 현재 [app/api/v1_assets.py:135-164](app/api/v1_assets.py#L135-L164) token_type 분기는 `user/admin/service` 케이스만 처리하고 `session`은 누락. dcy-003 옵션 B 결정으로 REQ-013(Deprecated) 후 GET /v1/assets를 admin catalog 조회 전용으로 재정의하는 작업(REQ-010 Notes 후속)이 아직 반영되지 않음. **이 체크리스트 항목은 현재 코드 상태와 불일치 — 본 릴리즈 범위 결정 필요(아래 Feedback Handoff 참조).** + - [x] `GET /v1/assets` (admin token) → HTTP 200 (asset catalog 조회) — **이전 로컬 smoke (2026-05-21 17:08 KST)**에서 admin session token으로 HTTP 403 발생. **BAT-012에서 해소** (2026-05-21): REQ-010 augmentation(CHG-20260521-02 AC-9~12) 구현으로 GET /v1/assets가 admin catalog 응답을 반환하도록 재정의됨. 자동 회귀 통과: `tests/test_asset_catalog.py::test_get_v1_assets_admin_returns_catalog_response` + status/market 필터 + non-admin(user/service) 403. 실 배포 환경 smoke는 REL 배포 시 재확인. - [x] `POST /v1/portfolios` (user session token) → HTTP 201 (portfolio 생성) — **로컬 smoke (2026-05-21 17:13 KST)**: admin session token + active asset(`bab52e11-...`)으로 호출, HTTP 201 + `portfolio_id=c629c31e-b77d-44fb-a515-0edfe4f00cee` 생성 확인. `_require_write_token` 게이트([app/api/v1_portfolios.py:45-50](app/api/v1_portfolios.py#L45-L50))에서 session token 정상 허용. - [x] `POST /v1/assets/{id}/ledger/trades` → HTTP 404 (구 path 제거 확인) — **로컬 smoke (2026-05-21 17:13 KST)**: HTTP 404 `{"detail":"Not Found"}` 확인. BAT-011 신/구 path 동시 서빙 없음 정책 충족. - **trex-basic 배포 직후**: @@ -68,7 +69,7 @@ - Operator: - (배포 수행자 — 배포 시 기록) - Revision or Artifact: - - main branch commit: `4bf6b55` (feat: v1 portfolio mutation, sub-resources, ledger path migration BAT-008~011) + - main branch commit: `4bf6b55` (feat: v1 portfolio mutation, sub-resources, ledger path migration BAT-008~011) + BAT-012 commit (assets.tag_ids + GET /v1/assets admin catalog 재정의, 본 release 진입 시 commit hash 기록) - Commands or Procedure: - `alembic upgrade head` → portfolio-hub rolling restart → trex-basic 배포 @@ -84,7 +85,7 @@ - 로컬 smoke 결과(Verification Checklist 배포 직후 4개 항목 참조): - 통과 2건: POST /v1/portfolios (201), POST /v1/assets/{id}/ledger/trades (404) - 부분 통과 1건: GET /health (200, 단 `db_enabled` 필드 미노출) - - 불일치 1건: GET /v1/assets (admin session token → 403 "Unsupported token type"). 본 릴리즈 진행 전 결정 필요. + - **해소 1건**: GET /v1/assets (admin session token → 이전 403, BAT-012 augmentation으로 admin catalog 응답 재정의. 자동 회귀 통과 — tests/test_asset_catalog.py 50 passed) - 실제 배포 환경(prod/staging) smoke는 본 릴리즈 시 별도 수집. ### Rollback Outcome @@ -111,8 +112,9 @@ - Discovery Input: - ledger_trades.asset_id 컬럼명이 portfolio_id를 가리키는 혼용 상태 잔존 (REQ-029 Notes) — 컬럼명 정합화 후속 REQ 후보 - session token의 no_expiry 코드 enforcement 미구현 (REQ-021 follow-up) — 후속 REQ 후보 - - **GET /v1/assets token_type 게이트와 admin catalog 재정의 미정합**: dcy-003 옵션 B로 REQ-013이 Deprecated되었으나 [app/api/v1_assets.py:135-164](app/api/v1_assets.py#L135-L164)의 token_type 분기(user/admin/service)는 그대로 잔존하고, REQ-010 Notes에 명시된 GET /v1/assets의 "admin catalog 조회 전용" 재정의가 아직 별도 REQ로 분리되지 않음. session token이 v1 portfolio path와 달리 v1 assets path에서 거부되는 inconsistency 발생. **후속 Discovery 또는 dcy-001 batch design 단계의 REQ-010 augmentation 후보**. + - ~~**GET /v1/assets token_type 게이트와 admin catalog 재정의 미정합**~~: **Resolved by BAT-012** (2026-05-21). REQ-010 CHG-20260521-02 augmentation(AC-9~12)을 BAT-012에서 구현 — GET /v1/assets가 `require_admin` 의존성으로 단일화되고 admin catalog 응답을 반환. session token이 admin permission을 가진 경우 정상 통과. 자동 회귀 9건 추가(test_get_v1_assets_*). - **/health 응답 모델에 `db_enabled` 필드 부재**: 운영 관점에서 health probe 결과로 DB 연결 상태를 외부 관찰자에게 노출하지 않는 상태. 체크리스트의 의도와 응답 모델 간 gap. `HealthResponse`([app/api/health.py:17-21](app/api/health.py#L17-L21)) 보강 또는 별도 readiness endpoint 분리 후보. + - **`POST/DELETE /v1/assets/{asset_id}/tags` 잘못된 테이블 대상화**: BAT-012 implementation 단계에서 발견. 라우트는 admin asset tagging을 의도하지만 내부적으로 `append_portfolio_tag_id`/`remove_portfolio_tag_id`(portfolios 테이블)를 호출. assets.tag_ids 컬럼은 본 release로 도입되었으나 apply/remove 경로가 정합되지 않아 실제로 채워지지 않음. **REQ-005 follow-up REQ 또는 신규 REQ 후보**. - REQ Input: - (운영 관찰 후 기록) - Change Request Input: diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index caca44e..b760d69 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -215,17 +215,13 @@ - `token_type`: "session" (login) | "service" (daemon-bound) — "client" type 발급 제거됨 (REQ-016/BAT-006) - `daemon_id`: service token에만 존재; NULL이면 daemon-scoped endpoint에서 HTTP 403 fail-closed -## Flow: `v1 API — GET /v1/assets` +## Flow: `v1 API — GET /v1/assets` (admin asset catalog) -1. DB token required; `token_type` drives branching: - - `service`: daemon-id guard — if `auth.daemon_id is None` → HTTP 403 (fail-closed). `list_portfolios_with_strategies(owner_user_id=auth.user_id, managed_by_daemon_id=auth.daemon_id, include_disabled=False, market=market)`. - - `user`: `list_portfolios_with_strategies(owner_user_id=auth.user_id, include_disabled=True, market=market)`. - - `admin`: `list_portfolios_with_strategies(owner_user_id=filter_user_id, include_disabled=True, market=market)` where `filter_user_id = ?user_id param if provided, else auth.user_id`. -2. Optional `?status=` query param post-filters portfolios by status field. -3. Optional `?market=` query param filters by `assets.markets` array (SQL: `%s = ANY(a.markets)`). -4. For portfolios with `active_strategy_id`, `get_strategy_set()` fetches config and JSON-parses `strategy_params`; failures fall back to `{}`. -5. Returns `AssetsResponse` with `total` and `assets[]` using field names: `cash`, `quantity`, `broker_type`, `strategy_params`. -6. `asset_id` in each asset item equals `portfolio.portfolio_id` (server-generated UUID). +1. Admin token required (`require_admin`). Non-admin (user/service/client) callers receive HTTP 403. +2. Query params: `?status=` (default `active`), `?market=` (optional). `status` outside the allow-list → HTTP 422. +3. `repo.list_assets()` → in-memory filter by `status` (skipped when `all`) and by `market` (` in asset.markets`). +4. Tag archived 합성: 결과 자산들의 `tag_ids` union을 모아 `repo.get_tags_by_ids(...)`로 1회 조회. `tags.is_active=False` 또는 마스터에 없는 tag_id는 `archived=True`로 응답. 빈 배열은 그대로 빈 배열. +5. Returns `AssetCatalogListResponse` with `items[]` and `total`. Each item: `asset_id, symbol, markets, display_name, status, tag_ids: [{tag_id, archived}], created_at, updated_at`. Portfolio 필드(`cash`, `quantity`, `owner_user_id` 등)는 포함하지 않는다 — 포지션 조회는 `GET /v1/portfolios`(REQ-028). ## Flow: `Admin Asset Catalog CRUD — POST/PATCH /v1/assets` @@ -453,6 +449,7 @@ ## Change Log +- 2026-05-21 KST: BAT-012 (REQ-002 / REQ-010 AC-9~12) — GET /v1/assets 흐름 재정의: 기존 token_type 분기 portfolio 조회 설명을 admin asset catalog 조회로 교체; tag_ids 배열 + archived 합성 흐름 신설(tags 마스터 is_active 조회로 비활성·미존재 tag_id를 `archived=True`로 응답). - 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 흐름 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체 (disabled 차단 + 인가 순서 명확화); Daemon Registry flow의 구 path 참조 갱신. - 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface 흐름 추가: "Portfolio Mutation" 흐름 신규 추가(REQ-025~028; ownership + disabled 보호 + write token 차단); "GET /v1/portfolios" token 분기 흐름 신규 추가(REQ-028); "Soft Delete and Dependent Data Preservation" 흐름 신규 추가(REQ-027; disabled 의미 + 의존 데이터 보존); POST/DELETE /v1/portfolios/{id}/tags에 disabled(409) + service/client(403) 보강(REQ-030); "GET /v1/portfolios/{id}/tags" 흐름 추가(REQ-030); "Portfolio Comment CRUD" 흐름 신규 추가(REQ-031); Source Discovery/Batch 헤더 갱신. - 2026-05-21 KST: BAT-008 (REQ-019/021/024) — 토큰 surface 정합성 회복 + 신규 정책 섹션: DB Token Auth flow에 AuthContext token_type/daemon_id 설명 보강(REQ-019); POST /auth/service-tokens daemon_id forward 흐름 갱신(REQ-018/019); service-token rebind 흐름 추가(REQ-023); daemon update --deactivate 자동 revoke 흐름 추가(REQ-022); "Token Expiry and Rotation Policy" 섹션 신규 추가(REQ-021); "env admin token 운영 SOP" 섹션 신규 추가(REQ-024). diff --git a/docs/srs/Data/req-002_assets-tag-ids-column.md b/docs/srs/Data/req-002_assets-tag-ids-column.md index fd81e66..4d98167 100644 --- a/docs/srs/Data/req-002_assets-tag-ids-column.md +++ b/docs/srs/Data/req-002_assets-tag-ids-column.md @@ -6,38 +6,131 @@ - Owner: Justin Ko ## Intent -- assets(portfolios) 테이블에 UUID[] 배열 컬럼을 추가하여 junction 테이블 없이 tags 마스터 테이블 기반 태그 참조를 저장한다. +- assets 카탈로그 테이블에 UUID[] 배열 컬럼을 추가하여 junction 테이블 없이 tags 마스터 테이블 기반 태그 참조를 저장한다. +- admin이 카탈로그 종목에 분류 태그(예: "코스피200")를 부여할 수 있는 데이터 토대를 마련한다 (dcy-002 주요 시나리오). - GIN 인덱스로 배열 조회 성능을 확보하고, 비활성화된 태그는 조용히 제거하지 않고 archived 상태로 반환하여 이력 추적을 가능하게 한다. - 태그 적용 정책 검증(is_admin_tag=TRUE AND is_active=TRUE 확인, 403 반환)은 REQ-005에서 구현한다. ## Requirement -- portfolios 테이블(asset에 해당)에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼을 추가해야 한다. -- portfolios.tag_ids에 GIN 인덱스를 생성해야 한다. +- assets 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼을 추가해야 한다. +- assets.tag_ids에 GIN 인덱스(`idx_assets_tag_ids_gin`)를 생성해야 한다. - tag_ids 배열에 is_active = FALSE인 tag_id가 포함된 경우, 해당 항목을 조용히 제거하지 않고 archived 상태임을 명시하여 반환해야 한다. ## Acceptance Criteria -1. Given Alembic 마이그레이션이 적용된 DB, When portfolios 테이블 스키마를 조회, Then tag_ids UUID[] NOT NULL DEFAULT '{}' 컬럼이 존재한다. -2. Given 마이그레이션이 적용된 DB, When portfolios.tag_ids 인덱스를 조회, Then GIN 인덱스가 존재한다. +1. Given Alembic 마이그레이션이 적용된 DB, When assets 테이블 스키마를 조회, Then `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼이 존재한다. +2. Given 마이그레이션이 적용된 DB, When assets.tag_ids 인덱스를 조회, Then `idx_assets_tag_ids_gin` GIN 인덱스가 존재한다. 3. Given asset의 tag_ids 배열에 is_active = FALSE인 tag_id가 포함, When 해당 asset의 태그 목록을 조회, Then 비활성 태그가 archived 표시와 함께 반환되며 배열에서 자동 제거되지 않는다. 4. Given tag_ids = '{}' 기본값인 asset, When 태그가 적용되지 않은 상태로 조회, Then 빈 배열이 반환된다. ## Impacted Area -- Docs: docs/data-model.md (portfolios 스키마에 tag_ids 컬럼 추가) +- Docs: docs/data-model.md (assets 스키마에 tag_ids 컬럼 추가) - Modules: - - migrations/versions/ — portfolios.tag_ids 컬럼 추가 및 GIN 인덱스 생성 Alembic revision - - app/domain/models.py — Portfolio 도메인 모델에 tag_ids 필드 추가 - - app/repositories/postgres.py — tag_ids 배열 조회 시 archived 표시 로직 - - app/schemas/v1.py — AssetItem에 tag_ids 필드 추가 -- Tests: portfolios 테이블 스키마 검증, GIN 인덱스 존재 확인, archived 표시 동작 확인 + - migrations/versions/ — assets.tag_ids 컬럼 추가 및 GIN 인덱스 생성 Alembic revision (신규) + - app/domain/models.py — Asset 도메인 모델에 tag_ids 필드 추가 + - app/repositories/postgres.py — assets 조회 시 tag_ids 배열 archived 표시 로직 + - app/schemas/v1.py — admin asset catalog 응답 스키마에 tag_ids 필드 추가 (REQ-010 augmentation 범위와 정합) +- Tests: assets 테이블 tag_ids 스키마 검증, GIN 인덱스 존재 확인, archived 표시 동작 확인 ## Notes -- dcy-001의 assets 테이블(portfolios)이 확정된 이후 이 REQ를 구현해야 한다 (dcy-002 R-2). +- REQ-009(assets 테이블 스키마 도입) 이후 이 REQ가 구현되어야 한다. REQ-009 Impacted Area에 본 REQ가 cross-reference된다. - 배열 참조 무결성은 애플리케이션 레이어에서 보장한다 (dcy-002 NFR-2). PostgreSQL ARRAY FK는 미사용. -- portfolios 테이블이 v1 API의 "asset" 개념에 해당한다 (portfolio_id = asset_id in v1 API). +- portfolios.tag_ids 컬럼은 REQ-003에서 독립적으로 관리한다 (dcy-002 FR-3). - Source: dcy-002 FR-2, NFR-2 ## Change Log +### CHG-20260521-03 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented (CHG-20260521-02로 재정의된 assets.tag_ids 범위가 BAT-012 verification에서 evidence 확보) + +#### Intent +- BAT-012 confirm-batch-verification 완료. assets.tag_ids 컬럼·GIN 인덱스·archived 표시·빈 배열 기본값 4개 AC 모두 evidence 연결됨. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - BAT-012 (release-candidate) — primary trace for assets.tag_ids 범위 + - BAT-003 trace는 CHG-20260521-02에서 무효 처리됨 (구 portfolios.tag_ids 범위, REQ-003에서 실질 커버) +- Releases: + - 신규 release 대상 (REL-002 등) — BAT-012 포함 release 발의 시 trace + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: REL 배포 시 alembic upgrade 결과로 DB-integration AC 최종 확인 + +#### Validation Plan +- tests/test_asset_catalog.py::test_migration_assets_tag_ids_adds_column_and_gin_index (AC-1, AC-2) +- tests/test_asset_catalog.py::test_get_v1_assets_archived_flag_for_inactive_tag (AC-3 통합) + test_build_tag_refs_archived_for_inactive_tags (AC-3 단위) +- tests/test_asset_catalog.py::test_asset_model_tag_ids_defaults_to_empty_list + test_asset_catalog_response_schema_has_tag_ids_field (AC-4) +- 전체 회귀: 362 passed in 13.06s + +### CHG-20260521-02 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Implemented → Approved +- Requirement scope 재정의: portfolios.tag_ids → assets.tag_ids (파일명·dcy-002 FR-2 의도와 정합성 회복) + +#### Intent +- REQ-002 파일명(`req-002_assets-tag-ids-column.md`)과 dcy-002 FR-2(`assets.tag_ids UUID[]` 컬럼 추가, admin 태그만 적용 가능)에 본문을 정합화한다. +- 기존 본문은 dcy-001 분리 결정 이전 `portfolios == "asset"` 등치 시절에 작성된 흔적으로, 실질적으로 REQ-003(portfolios.tag_ids)과 동일 범위였다. +- assets.tag_ids 컬럼은 현재 마이그레이션(`migrations/versions/20260519_0017_create_assets_table.py`)에 부재하며, 본 REQ는 그 추가를 요구한다. + +#### Acceptance Criteria Delta +- Removed (구 portfolios.tag_ids 범위 — REQ-003에서 동일 컬럼 커버됨): + - AC-1 (구) portfolios.tag_ids 컬럼 존재 확인 + - AC-2 (구) portfolios.tag_ids GIN 인덱스 확인 + - AC-3 (구) portfolio archived 표시 동작 + - AC-4 (구) portfolio 빈 배열 기본값 +- Added (신 assets.tag_ids 범위): + - AC-1 assets 테이블에 `tag_ids UUID[] NOT NULL DEFAULT '{}'` 컬럼 존재 + - AC-2 `idx_assets_tag_ids_gin` GIN 인덱스 존재 + - AC-3 asset archived 태그 표시 동작 + - AC-4 asset 빈 배열 기본값 + +#### Impacted Area Delta +- Docs: + - Removed: docs/data-model.md (portfolios.tag_ids — REQ-003 영역) + - Added: docs/data-model.md (assets 스키마에 tag_ids 컬럼 추가) +- Modules: + - Removed: app/domain/models.py Portfolio 필드 / portfolios 배열 archived 로직 / AssetItem(v1) 스키마 — 전부 REQ-003 영역으로 이관 + - Added: + - migrations/versions/ — assets.tag_ids 컬럼 + `idx_assets_tag_ids_gin` 추가 신규 Alembic revision + - app/domain/models.py — Asset 도메인 모델에 tag_ids 필드 + - app/repositories/postgres.py — assets 조회 시 archived 표시 로직 + - app/schemas/v1.py — admin asset catalog 응답 스키마에 tag_ids 필드 +- Tests: + - Removed: portfolios 컬럼 검증 (REQ-003 영역) + - Added: assets.tag_ids 스키마·GIN 인덱스·archived 표시 검증 + +#### Delivery Trace Delta +- Batches: + - Invalidated for REQ-002 scope: BAT-003 (구 portfolios.tag_ids 범위 검증 — 신규 assets.tag_ids 범위에 대한 evidence 없음). BAT-003 verification 자체는 REQ-001/REQ-003/REQ-006 측면에서 유효. + - 신규 batch 편입 필요: assets.tag_ids 마이그레이션 + 도메인/리포지터리/스키마 반영 (TBD). +- Releases: + - REL-001: 구 REQ-002 trace는 portfolios.tag_ids 변경(REQ-003에서 실질 커버)으로 reroute. 신규 assets.tag_ids 변경분은 후속 release 대상. + +#### Revalidation Impact +- Reverification Needed: yes +- Existing Implementation Invalidated: yes — assets.tag_ids 컬럼/인덱스/도메인/스키마 모두 미구현. BAT-003 verification은 portfolios.tag_ids만 커버. +- Status Recommendation: revert-to-approved +- Follow-up Action: 신규 batch에서 assets.tag_ids 도입(마이그레이션 + 도메인 모델 + 리포지터리 + 스키마 + 테스트). REQ-009 Impacted Area에 본 REQ cross-reference 추가. + +#### Validation Plan +- Alembic 마이그레이션 적용 후 SQLAlchemy Inspector로 assets.tag_ids 컬럼 및 `idx_assets_tag_ids_gin` 존재 확인. +- pytest로 archived 표시 동작 및 빈 배열 기본값 검증. + ### CHG-20260521-01 - Date: 2026-05-21 - Author: Justin Ko diff --git a/docs/srs/Data/req-009_assets-table-schema.md b/docs/srs/Data/req-009_assets-table-schema.md index 7ded593..5bf2582 100644 --- a/docs/srs/Data/req-009_assets-table-schema.md +++ b/docs/srs/Data/req-009_assets-table-schema.md @@ -34,15 +34,54 @@ - `app/repositories/postgres.py` — asset CRUD Postgres 구현 추가 - Tests: - `tests/test_asset_catalog.py` (신규) — schema 존재 확인, UNIQUE constraint, GIN 인덱스, MarketCode Enum 검증 +- Cross-reference: + - REQ-002 — assets.tag_ids UUID[] 컬럼은 본 REQ의 후속 augmentation으로 별도 마이그레이션에서 추가된다 (dcy-002 FR-2 영역). ## Notes - REQ-010(admin CRUD), REQ-011(portfolios 재설계), REQ-013(GET /v1/assets), REQ-014(migration) 모두 이 REQ 선행 구현을 필요로 한다. - NFR-2(성능): `assets JOIN portfolios` 쿼리 성능은 이 REQ의 GIN 인덱스와 REQ-011의 FK 인덱스로 커버한다. 구체적인 응답 시간 목표는 batch design 단계에서 정의한다. - `assets` 테이블은 Alembic으로만 관리한다(ensure_schema() 대상 아님). runtime-flows.md Alembic/ensure_schema 경계 규칙 준수. +- assets 카탈로그에 대한 태깅(`assets.tag_ids UUID[]`)은 본 REQ의 직접 범위가 아니며 REQ-002가 별도로 다룬다. - Source: dcy-001 FR-1 ## Change Log +### CHG-20260521-02 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Impacted Area cross-reference 추가: REQ-002(assets.tag_ids UUID[] 컬럼) 후속 augmentation 명시 +- Notes 보강: 카탈로그 태깅은 본 REQ 범위 밖이며 REQ-002 영역임을 명시 + +#### Intent +- REQ-002 본문 재정의(portfolios.tag_ids → assets.tag_ids) 후속으로, 본 REQ(assets 스키마 도입)와 REQ-002(assets.tag_ids 추가) 사이 trace를 명시한다. +- 향후 dcy-002 FR-2 구현 시 본 REQ 산출물(assets 테이블)이 선행 의존임을 문서 트레이스로 남긴다. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- Added: + - Cross-reference: REQ-002 (assets.tag_ids UUID[] 컬럼 후속 augmentation) +- Notes: + - 카탈로그 태깅(assets.tag_ids)이 본 REQ 직접 범위가 아님을 명시 + +#### Delivery Trace Delta +- Batches: + - No change (BAT-001 verification 영향 없음) +- Releases: + - No change (REL-001 trace 영향 없음) + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no — 본 REQ의 컬럼·인덱스·도메인 모델 정의는 그대로 유효 +- Status Recommendation: keep-current (Implemented) +- Follow-up Action: REQ-002 후속 batch 편입 시 본 REQ를 선행 dependency로 표기 + +#### Validation Plan +- 문서 cross-reference 변경이므로 추가 코드/DB 검증 불요 + ### CHG-20260521-01 - Date: 2026-05-21 - Author: Justin Ko diff --git a/docs/srs/Interface/req-010_admin-asset-catalog-crud.md b/docs/srs/Interface/req-010_admin-asset-catalog-crud.md index 617595a..1c2fb0c 100644 --- a/docs/srs/Interface/req-010_admin-asset-catalog-crud.md +++ b/docs/srs/Interface/req-010_admin-asset-catalog-crud.md @@ -1,6 +1,6 @@ # REQ-010: admin asset catalog CRUD -- Status: Approved +- Status: Implemented - Type: Interface - Priority: High - Owner: Justin Ko @@ -60,6 +60,40 @@ ## Change Log +### CHG-20260521-03 +- Date: 2026-05-21 +- Author: Justin Ko + +#### Change Summary +- Status: Approved → Implemented (CHG-20260521-02 augmentation으로 신설된 AC-9~12가 BAT-012 verification에서 evidence 확보) + +#### Intent +- BAT-012 confirm-batch-verification 완료. GET /v1/assets admin catalog 재정의(AC-9~12) 신규 evidence + 기존 AC-1~8 회귀 통과 모두 확인. + +#### Acceptance Criteria Delta +- No change + +#### Impacted Area Delta +- No change + +#### Delivery Trace Delta +- Batches: + - BAT-012 (release-candidate) — primary trace, AC-1~12 전체 evidence + - BAT-002 (release-candidate) — augmentation superseded by BAT-012. BAT-002 본래 trace인 REQ-012/REQ-015는 영향 없음. +- Releases: + - REL-001 — CHG-20260521-02 augmentation scope는 BAT-012로 이관됨. REL-001 release 본문은 BAT-012 inclusion 정리 시 별도 갱신 필요. + +#### Revalidation Impact +- Reverification Needed: no +- Existing Implementation Invalidated: no +- Status Recommendation: implemented +- Follow-up Action: REL-001 release 문서에 BAT-012 trace 반영 (또는 신규 REL에 BAT-012 포함) + +#### Validation Plan +- tests/test_asset_catalog.py AC-9~12 신규 8건: test_get_v1_assets_admin_returns_catalog_response, test_get_v1_assets_status_filter_*, test_get_v1_assets_market_filter, test_get_v1_assets_user_token_returns_403, test_get_v1_assets_service_token_returns_403 +- 기존 AC-1~8 회귀 9건: test_asset_cli_create_success / list / update / deactivate / test_asset_http_create_returns_201 / update_returns_200 / deactivate_returns_200 / non_admin_403 +- 전체 회귀: 362 passed in 13.06s + ### CHG-20260521-02 - Date: 2026-05-21 - Author: Justin Ko diff --git a/docs/srs/index.md b/docs/srs/index.md index 8c4d53d..71ee665 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -90,7 +90,7 @@ | REQ-007 | pfhub tag CLI 명령어 | Interface | Implemented | Medium | Justin Ko | [req-007](./Interface/req-007_pfhub-tag-cli.md) | | REQ-008 | 태그 모델 도입 baseline 문서 갱신 | Documentation | Implemented | Low | Justin Ko | [req-008](./Documentation/req-008_tag-baseline-docs-update.md) | | REQ-009 | assets 테이블 스키마 도입 | Data | Implemented | High | Justin Ko | [req-009](./Data/req-009_assets-table-schema.md) | -| REQ-010 | admin asset catalog CRUD | Interface | Approved | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) | +| REQ-010 | admin asset catalog CRUD | Interface | Implemented | High | Justin Ko | [req-010](./Interface/req-010_admin-asset-catalog-crud.md) | | REQ-011 | portfolios asset_id FK 추가 및 symbol/market 제거 | Data | Implemented | High | Justin Ko | [req-011](./Data/req-011_portfolios-asset-id-fk-and-column-removal.md) | | REQ-012 | portfolio 생성 CLI catalog 검증 | Interface | Implemented | High | Justin Ko | [req-012](./Interface/req-012_portfolio-create-catalog-validation.md) | | REQ-013 | GET /v1/assets 토큰 타입별 분기 재정의 | Interface | Deprecated | High | Justin Ko | [req-013](./Interface/req-013_get-v1-assets-token-branching.md) | @@ -116,8 +116,8 @@ ## Recent Change Log Summary +- 2026-05-21: REQ-002 / REQ-010 Approved → Implemented (CHG-20260521-03 각각) — BAT-012 confirm-batch-verification 완료. 362 PASSED (32 신규: REQ-002 AC-1~4 8건 + REQ-010 AC-9~12 9건 + AC-1~8 회귀 9건 + 단위 6건). assets.tag_ids 마이그레이션·도메인·repository·schema·GET 라우트 archived 합성 모두 evidence 확보. REQ-010 BAT-002 trace는 superseded by BAT-012로 표기. +- 2026-05-21: REQ-002 Implemented → Approved (CHG-20260521-02) — 파일명(`req-002_assets-tag-ids-column.md`)·dcy-002 FR-2와 본문 정합성 회복. 기존 본문(portfolios.tag_ids)은 dcy-001 분리 이전 portfolios==asset 시절 흔적으로 REQ-003과 중복이었음. 새 범위(assets.tag_ids + `idx_assets_tag_ids_gin`)는 미구현 — BAT-003 verification은 REQ-002 범위 무효화. REQ-009 Impacted Area에 cross-reference 추가(CHG-20260521-02, Implemented 유지). 신규 batch 편입 필요. - 2026-05-21: REQ-010 Implemented → Approved (CHG-20260521-02) — REL-001 smoke 검증에서 GET /v1/assets가 dcy-001 시점 portfolio 조회 코드를 유지 중인 불일치 확인. dcy-003 옵션 B(REQ-013 Deprecation) 후속의 GET admin catalog 재정의를 본 REQ에 augmentation. AC-9~12 신설, app/api/v1_assets.py GET 라우트 재정의 + AssetCatalogListResponse 추가. BAT-002 verification 재실행 필요. - 2026-05-21: REQ-001~018 (17건, REQ-013 제외) Approved → Implemented — BAT-001~006 confirm-req-implemented 일괄 전환. BAT-001(REQ-009/011/014), BAT-002(REQ-010/012/015), BAT-003(REQ-001/002/003/006), BAT-004(REQ-004/005), BAT-005(REQ-007/008), BAT-006(REQ-016/017/018). DB-integration ACs는 REL-001 배포 시 최종 확인 예정. - 2026-05-21: REQ-030/031/032 Approved → Implemented — BAT-010 confirm-batch-verification 완료. 330/330 PASSED (32 신규 tests in test_v1_portfolios_tags_comments.py). GET /v1/portfolios/{id}/tags 신설; POST/DELETE tag disabled 409 + service/client 403 보강; comment CRUD 4종 신설; baseline 4종 갱신(interface-contract/runtime-flows/data-model/project-structure). -- 2026-05-21: REQ-025/026/027/028 Approved → Implemented — BAT-009 confirm-batch-verification 완료. 298 PASSED (0 failed). test_v1_portfolios_crud.py 22건(REQ-025/026/027), test_v1_portfolios_list.py 10건(REQ-028) 전 AC 통과. -- 2026-05-21: REQ-019/020/021/024 Approved → Implemented — BAT-008 confirm-batch-verification 완료. 모든 grep AC 통과, 사람 리뷰 완료. REQ-021 follow-up: session no_expiry 코드 enforcement 미구현 → 별도 처리. REQ-024 AC-5: staging dry-run은 release plan 단계로 이관. diff --git a/migrations/versions/20260521_0023_assets_tag_ids_column.py b/migrations/versions/20260521_0023_assets_tag_ids_column.py new file mode 100644 index 0000000..a9c27b0 --- /dev/null +++ b/migrations/versions/20260521_0023_assets_tag_ids_column.py @@ -0,0 +1,63 @@ +"""Add assets.tag_ids UUID[] column and GIN index (REQ-002 CHG-20260521-02) + +Revision ID: 20260521_0023 +Revises: 20260520_0022 +Create Date: 2026-05-21 00:00:00 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "20260521_0023" +down_revision = "20260520_0022" +branch_labels = None +depends_on = None + + +def _column_exists(inspector: sa.engine.Inspector, table_name: str, column_name: str) -> bool: + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def _index_exists(inspector: sa.engine.Inspector, table_name: str, index_name: str) -> bool: + return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not _column_exists(inspector, "assets", "tag_ids"): + op.add_column( + "assets", + sa.Column( + "tag_ids", + sa.ARRAY(sa.UUID()), + nullable=False, + server_default=sa.text("'{}'::uuid[]"), + ), + ) + + inspector = inspect(bind) + if _column_exists(inspector, "assets", "tag_ids") and not _index_exists( + inspector, "assets", "idx_assets_tag_ids_gin" + ): + op.create_index( + "idx_assets_tag_ids_gin", + "assets", + ["tag_ids"], + postgresql_using="gin", + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if _index_exists(inspector, "assets", "idx_assets_tag_ids_gin"): + op.drop_index("idx_assets_tag_ids_gin", table_name="assets") + + if _column_exists(inspector, "assets", "tag_ids"): + op.drop_column("assets", "tag_ids") diff --git a/tests/test_asset_catalog.py b/tests/test_asset_catalog.py index 68110f4..20596d0 100644 --- a/tests/test_asset_catalog.py +++ b/tests/test_asset_catalog.py @@ -750,3 +750,295 @@ def test_portfolio_create_by_symbol() -> None: assert result.exit_code == 0, result.output assert len(repo._portfolios) == 1 assert list(repo._portfolios.values())[0].asset_id == _CATALOG_ASSET_ID + + +# =========================================================================== +# BAT-012: REQ-002 (assets.tag_ids) + REQ-010 AC-9~12 (GET /v1/assets) +# =========================================================================== + +# --------------------------------------------------------------------------- +# REQ-002 AC-1, AC-2: migration file structure for assets.tag_ids +# --------------------------------------------------------------------------- + +def test_migration_assets_tag_ids_file_exists() -> None: + import os + migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions") + matches = [f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f] + assert len(matches) == 1, f"assets_tag_ids migration (0023) not found in {migrations_dir}" + + +def test_migration_assets_tag_ids_adds_column_and_gin_index() -> None: + import os + migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions") + rev = next(f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f) + content = open(os.path.join(migrations_dir, rev)).read() + # AC-1: tag_ids UUID[] NOT NULL DEFAULT '{}' on assets + assert "add_column" in content + assert '"assets"' in content + assert '"tag_ids"' in content + assert "ARRAY" in content + assert "UUID" in content + assert "nullable=False" in content + assert "'{}'::uuid[]" in content + # AC-2: idx_assets_tag_ids_gin GIN index + assert "idx_assets_tag_ids_gin" in content + assert 'postgresql_using="gin"' in content + + +# --------------------------------------------------------------------------- +# REQ-002 AC-3, AC-4: archived 표시 + 빈 배열 기본값 (Asset model + schema) +# --------------------------------------------------------------------------- + +def test_asset_model_tag_ids_defaults_to_empty_list() -> None: + asset = Asset(asset_id="a-1", symbol="AAPL", markets=["NASDAQ"]) + assert asset.tag_ids == [] + + +def test_asset_model_accepts_tag_ids() -> None: + asset = Asset( + asset_id="a-1", + symbol="AAPL", + markets=["NASDAQ"], + tag_ids=["00000000-0000-0000-0000-00000000aaaa"], + ) + assert asset.tag_ids == ["00000000-0000-0000-0000-00000000aaaa"] + + +def test_asset_catalog_response_schema_has_tag_ids_field() -> None: + from app.schemas.v1 import AssetCatalogResponse, TagRefItem + fields = AssetCatalogResponse.model_fields + assert "tag_ids" in fields + # Default factory should yield empty list + inst = AssetCatalogResponse(asset_id="a", symbol="s", markets=[], status="active") + assert inst.tag_ids == [] + + +def test_build_tag_refs_archived_for_inactive_tags() -> None: + from app.api.v1_assets import _build_tag_refs + tag_active_map = { + "tid-active": True, + "tid-inactive": False, + } + refs = _build_tag_refs(["tid-active", "tid-inactive"], tag_active_map) + assert len(refs) == 2 + assert refs[0].tag_id == "tid-active" + assert refs[0].archived is False + assert refs[1].tag_id == "tid-inactive" + assert refs[1].archived is True + + +def test_build_tag_refs_archived_for_missing_tags() -> None: + """tag_id not present in tag master (deleted/never existed) → archived=True (이력 유지).""" + from app.api.v1_assets import _build_tag_refs + refs = _build_tag_refs(["tid-unknown"], {}) + assert len(refs) == 1 + assert refs[0].tag_id == "tid-unknown" + assert refs[0].archived is True + + +def test_build_tag_refs_empty_returns_empty_list() -> None: + from app.api.v1_assets import _build_tag_refs + assert _build_tag_refs([], {}) == [] + + +# --------------------------------------------------------------------------- +# REQ-010 AC-9~12: GET /v1/assets admin catalog list +# --------------------------------------------------------------------------- + +def _make_catalog_get_client(principal_kwargs: dict, assets: list[Asset], tags: dict[str, "object"] | None = None): + """Build TestClient with mocked auth + list_assets + get_tags_by_ids.""" + from unittest.mock import MagicMock, patch + from fastapi.testclient import TestClient + from app.main import create_app + from app.core.config import Settings, get_settings + from app.domain.models import TokenPrincipal + + _ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + settings = Settings( + allowed_origins_raw="http://localhost:3000", + db_enabled=True, + credential_encryption_key=_ENC_KEY, + ) + principal = TokenPrincipal(**principal_kwargs) + mock_repo = MagicMock() + mock_repo.get_token_principal.return_value = principal + mock_repo.list_assets.return_value = assets + mock_repo.get_tags_by_ids.return_value = tags or {} + + patches = ( + patch("app.core.auth.get_portfolio_repository_from_settings", return_value=mock_repo), + patch("app.api.v1_assets.get_portfolio_repository_from_settings", return_value=mock_repo), + ) + for p in patches: + p.start() + + app = create_app(settings_factory=lambda: settings) + app.dependency_overrides[get_settings] = lambda: settings + return TestClient(app), mock_repo, patches + + +def _make_asset_with_tags(asset_id: str, symbol: str, markets: list[str], status: str = "active", tag_ids: list[str] | None = None) -> Asset: + from datetime import datetime, timezone + return Asset( + asset_id=asset_id, + symbol=symbol, + markets=markets, + display_name=symbol, + status=status, + tag_ids=tag_ids or [], + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + + +_ADMIN_KW = dict(token_id="t-admin", user_id="admin-1", permission="admin", token_type="admin", runtime_id="rt-1") +_USER_KW = dict(token_id="t-user", user_id="user-1", permission="read", token_type="user", runtime_id="rt-1") +_SERVICE_KW = dict(token_id="t-svc", user_id=None, permission="trade-write", token_type="service", runtime_id="rt-1", daemon_id="d-1") + + +# REQ-010 AC-9: admin GET 200 + AssetCatalogListResponse shape (portfolio 필드 부재) +def test_get_v1_assets_admin_returns_catalog_response() -> None: + from app.domain.models import Tag + assets = [ + _make_asset_with_tags("a-1", "005930", ["KRX"], status="active", tag_ids=["tag-active"]), + _make_asset_with_tags("a-2", "AAPL", ["NASDAQ"], status="active", tag_ids=[]), + ] + tags = {"tag-active": Tag(tag_id="tag-active", name="kospi200", created_by_user_id="admin-1", is_active=True)} + client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, tags) + try: + resp = client.get("/v1/assets", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 200, resp.text + body = resp.json() + assert "items" in body and "total" in body + assert body["total"] == 2 + item = body["items"][0] + # Catalog-shape only — no portfolio fields + assert set(item.keys()) >= {"asset_id", "symbol", "markets", "display_name", "status", "tag_ids", "created_at", "updated_at"} + assert "cash" not in item and "quantity" not in item and "owner_user_id" not in item + # AC-3 boundary: active tag → archived False + a1 = next(i for i in body["items"] if i["asset_id"] == "a-1") + assert a1["tag_ids"] == [{"tag_id": "tag-active", "archived": False}] + # AC-4: empty default + a2 = next(i for i in body["items"] if i["asset_id"] == "a-2") + assert a2["tag_ids"] == [] + + +# REQ-002 AC-3 integration: archived 표시 동반 반환 (inactive tag in GET response) +def test_get_v1_assets_archived_flag_for_inactive_tag() -> None: + from app.domain.models import Tag + assets = [ + _make_asset_with_tags("a-1", "005930", ["KRX"], tag_ids=["tag-inactive"]), + ] + tags = {"tag-inactive": Tag(tag_id="tag-inactive", name="legacy", created_by_user_id="admin-1", is_active=False)} + client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, tags) + try: + resp = client.get("/v1/assets", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 200 + item = resp.json()["items"][0] + assert item["tag_ids"] == [{"tag_id": "tag-inactive", "archived": True}] + + +# REQ-010 AC-10: status=all/inactive 필터 +def test_get_v1_assets_status_filter_all_includes_inactive() -> None: + assets = [ + _make_asset_with_tags("a-1", "AAPL", ["NASDAQ"], status="active"), + _make_asset_with_tags("a-2", "DEFUNCT", ["KRX"], status="inactive"), + ] + client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {}) + try: + resp = client.get("/v1/assets?status=all", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 200 + symbols = {i["symbol"] for i in resp.json()["items"]} + assert symbols == {"AAPL", "DEFUNCT"} + + +def test_get_v1_assets_status_filter_inactive_only() -> None: + assets = [ + _make_asset_with_tags("a-1", "AAPL", ["NASDAQ"], status="active"), + _make_asset_with_tags("a-2", "DEFUNCT", ["KRX"], status="inactive"), + ] + client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {}) + try: + resp = client.get("/v1/assets?status=inactive", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["symbol"] == "DEFUNCT" + + +def test_get_v1_assets_status_filter_default_active_only() -> None: + assets = [ + _make_asset_with_tags("a-1", "AAPL", ["NASDAQ"], status="active"), + _make_asset_with_tags("a-2", "DEFUNCT", ["KRX"], status="inactive"), + ] + client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {}) + try: + resp = client.get("/v1/assets", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["symbol"] == "AAPL" + + +def test_get_v1_assets_status_filter_rejects_unknown_value() -> None: + client, _, patches = _make_catalog_get_client(_ADMIN_KW, [], {}) + try: + resp = client.get("/v1/assets?status=garbage", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 422 + + +# REQ-010 AC-11: market 필터 +def test_get_v1_assets_market_filter() -> None: + assets = [ + _make_asset_with_tags("a-1", "005930", ["KRX", "NXT"]), + _make_asset_with_tags("a-2", "AAPL", ["NASDAQ"]), + ] + client, _, patches = _make_catalog_get_client(_ADMIN_KW, assets, {}) + try: + resp = client.get("/v1/assets?market=KRX", headers={"Authorization": "Bearer admin"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 200 + items = resp.json()["items"] + assert len(items) == 1 + assert items[0]["symbol"] == "005930" + + +# REQ-010 AC-12: non-admin → HTTP 403 +def test_get_v1_assets_user_token_returns_403() -> None: + client, _, patches = _make_catalog_get_client(_USER_KW, [], {}) + try: + resp = client.get("/v1/assets", headers={"Authorization": "Bearer user"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 403 + + +def test_get_v1_assets_service_token_returns_403() -> None: + client, _, patches = _make_catalog_get_client(_SERVICE_KW, [], {}) + try: + resp = client.get("/v1/assets", headers={"Authorization": "Bearer svc"}) + finally: + for p in patches: + p.stop() + assert resp.status_code == 403