diff --git a/README.md b/README.md index eee5dea..67dfaf0 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ This repository is in bootstrap phase. - FastAPI application entrypoint is available. - Token-based authentication and role checks are implemented. -- Portfolio API contracts are defined. +- Portfolio and user management API contracts are implemented. - Core portfolio domain services are ported from `jsbot-kis`. -- Postgres repository scaffolding is implemented and gated behind `PORTFOLIOHUB_DB_ENABLED=true`. +- Postgres-backed portfolio and user persistence is implemented and gated behind `PORTFOLIOHUB_DB_ENABLED=true`. ## Configuration @@ -60,6 +60,11 @@ The repository now includes a `portfoliohub` CLI that mirrors the main portfolio ```bash portfoliohub --help +portfoliohub user list +portfoliohub user create --username alice --email alice@example.com --force +portfoliohub user get +portfoliohub user update --username alice-updated --force +portfoliohub user delete --force portfoliohub portfolio list portfoliohub portfolio status 005930:tv --recent 10 portfoliohub portfolio create 005930:tv --symbol 005930 --cash-balance 10000000 --force @@ -75,6 +80,11 @@ Supported commands: - `init-db` - `migrate-db` +- `user list` +- `user get` +- `user create` +- `user update` +- `user delete` - `portfolio list` - `portfolio status` - `portfolio create` @@ -148,12 +158,32 @@ PortfolioHub currently supports bearer tokens with three permission levels. - `read`: list and status endpoints - `trade-write`: mutation endpoints such as buy, sell, invest, transfer, reset -- `admin`: delete and future operational actions +- `admin`: delete, user management, and future operational actions + +## User management + +User management is exposed through both HTTP API and CLI. + +- API base route: `/users` +- Permission: `admin` only +- Supported operations: list, get, create, update, delete + +Example API calls: + +```bash +curl -X GET "http://localhost:8000/users" \ + -H "Authorization: Bearer " + +curl -X POST "http://localhost:8000/users" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"username": "alice", "email": "alice@example.com"}' +``` The default `.env.example` includes example tokens for development. ## Planned next steps -1. Implement Postgres/Timescale repository. -2. Port domain logic from `jsbot-kis` services. -3. Add integration tests for full portfolio flows. +1. Apply and verify Alembic migrations against a real PostgreSQL/Timescale instance. +2. Add integration tests for DB-backed portfolio and user flows. +3. Design token-to-user mapping if ownership or user-scoped authorization is needed later. diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..d7baf08 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,75 @@ +"""User management API routes.""" + +from __future__ import annotations + +from typing import NoReturn + +from fastapi import APIRouter, Depends, HTTPException, Response, status + +from app.core.auth import require_admin +from app.domain.models import User +from app.schemas.user import CreateUserRequest, UpdateUserRequest, UserResponse +from app.services.user_service import UserService, get_user_service + +router = APIRouter(prefix="/users", tags=["users"]) + + +def _to_user_response(user: User) -> UserResponse: + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +def _raise_user_error(exc: ValueError) -> NoReturn: + detail = str(exc) + if "찾을 수 없습니다" in detail: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc + if "이미 존재" in detail: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc + + +@router.get("", response_model=list[UserResponse], dependencies=[Depends(require_admin)]) +def list_users(service: UserService = Depends(get_user_service)) -> list[UserResponse]: + return [_to_user_response(user) for user in service.list()] + + +@router.get("/{user_id}", response_model=UserResponse, dependencies=[Depends(require_admin)]) +def get_user(user_id: str, service: UserService = Depends(get_user_service)) -> UserResponse: + try: + return _to_user_response(service.get(user_id)) + except ValueError as exc: + _raise_user_error(exc) + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_admin)]) +def create_user(payload: CreateUserRequest, service: UserService = Depends(get_user_service)) -> UserResponse: + try: + return _to_user_response(service.create(payload)) + except ValueError as exc: + _raise_user_error(exc) + + +@router.patch("/{user_id}", response_model=UserResponse, dependencies=[Depends(require_admin)]) +def update_user( + user_id: str, + payload: UpdateUserRequest, + service: UserService = Depends(get_user_service), +) -> UserResponse: + try: + return _to_user_response(service.update(user_id, payload)) + except ValueError as exc: + _raise_user_error(exc) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(require_admin)]) +def delete_user(user_id: str, service: UserService = Depends(get_user_service)) -> Response: + try: + service.delete(user_id) + except ValueError as exc: + _raise_user_error(exc) + return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/app/cli/commands/user.py b/app/cli/commands/user.py new file mode 100644 index 0000000..ab35b3f --- /dev/null +++ b/app/cli/commands/user.py @@ -0,0 +1,98 @@ +"""User CLI commands.""" + +from __future__ import annotations + +import typer + +from app.cli.core.utils import execute_command, render_output +from app.cli.commands.portfolio import _confirm +from app.schemas.user import CreateUserRequest, UpdateUserRequest, UserResponse +from app.services.user_service import UserService + + +user_app = typer.Typer(help="User management commands.") + + +def _with_service(ctx: typer.Context, callback): + app_context = ctx.obj + repository = app_context.repository_factory(app_context.settings) + service = UserService(repository) + try: + return execute_command(app_context.console, callback=lambda: callback(service, app_context)) + finally: + if hasattr(repository, "close"): + repository.close() + + +def _to_user_response(user) -> UserResponse: + return UserResponse( + id=user.id, + username=user.username, + email=user.email, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@user_app.command("list") +def list_users(ctx: typer.Context) -> None: + users = _with_service(ctx, lambda service, app_context: [_to_user_response(user) for user in service.list()]) + render_output(ctx.obj.console, ctx.obj.output_format, users, empty_message="No users found.") + + +@user_app.command("get") +def get_user( + ctx: typer.Context, + user_id: str = typer.Argument(..., help="User identifier."), +) -> None: + user = _with_service(ctx, lambda service, app_context: _to_user_response(service.get(user_id))) + render_output(ctx.obj.console, ctx.obj.output_format, user) + + +@user_app.command("create") +def create_user( + ctx: typer.Context, + username: str = typer.Option(..., help="Username."), + email: str | None = typer.Option(None, help="Email address."), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), +) -> None: + _confirm(force, f"Create user {username}?") + user = _with_service( + ctx, + lambda service, app_context: _to_user_response( + service.create(CreateUserRequest(username=username, email=email)) + ), + ) + render_output(ctx.obj.console, ctx.obj.output_format, user) + + +@user_app.command("update") +def update_user( + ctx: typer.Context, + user_id: str = typer.Argument(..., help="User identifier."), + username: str | None = typer.Option(None, help="New username."), + email: str | None = typer.Option(None, help="New email address."), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), +) -> None: + _confirm(force, f"Update user {user_id}?") + user = _with_service( + ctx, + lambda service, app_context: _to_user_response( + service.update(user_id, UpdateUserRequest(username=username, email=email)) + ), + ) + render_output(ctx.obj.console, ctx.obj.output_format, user) + + +@user_app.command("delete") +def delete_user( + ctx: typer.Context, + user_id: str = typer.Argument(..., help="User identifier."), + force: bool = typer.Option(False, "--force", help="Skip confirmation prompt."), +) -> None: + _confirm(force, f"Delete user {user_id}?") + _with_service(ctx, lambda service, app_context: service.delete(user_id)) + if ctx.obj.output_format == "json": + render_output(ctx.obj.console, ctx.obj.output_format, {"deleted": user_id}) + else: + ctx.obj.console.print(f"Deleted user: {user_id}") \ No newline at end of file diff --git a/app/cli/core/utils.py b/app/cli/core/utils.py index 1e2d95e..185ed7c 100644 --- a/app/cli/core/utils.py +++ b/app/cli/core/utils.py @@ -14,6 +14,7 @@ from rich.table import Table from app.core.errors import FeatureNotReadyError from app.schemas.portfolio import PortfolioDetailResponse, PortfolioListItem +from app.schemas.user import UserResponse T = TypeVar("T") @@ -126,6 +127,41 @@ def print_portfolio_detail(console: Console, detail: PortfolioDetailResponse) -> console.print(ledger_table) +def print_user_table(console: Console, users: list[UserResponse]) -> None: + table = Table(title="Users", expand=True) + table.add_column("ID", style="cyan", no_wrap=True) + table.add_column("Username", no_wrap=True) + table.add_column("Email") + table.add_column("Created At", no_wrap=True) + table.add_column("Updated At", no_wrap=True) + for user in users: + table.add_row( + user.id, + user.username, + user.email or "N/A", + user.created_at.isoformat(timespec="seconds") if user.created_at else "N/A", + user.updated_at.isoformat(timespec="seconds") if user.updated_at else "N/A", + ) + console.print(table) + + +def print_user_detail(console: Console, user: UserResponse) -> None: + console.print( + Panel.fit( + "\n".join( + [ + f"id: {user.id}", + f"username: {user.username}", + f"email: {user.email or 'N/A'}", + f"created_at: {user.created_at.isoformat(timespec='seconds') if user.created_at else 'N/A'}", + f"updated_at: {user.updated_at.isoformat(timespec='seconds') if user.updated_at else 'N/A'}", + ] + ), + title="User", + ) + ) + + def print_json(console: Console, payload: Any) -> None: console.print_json(data=to_plain_data(payload)) @@ -139,13 +175,22 @@ def render_output(console: Console, output_format: str, payload: Any, *, empty_m if not payload and empty_message: console.print(empty_message) return - print_portfolio_table(console, payload) + if all(isinstance(item, PortfolioListItem) for item in payload): + print_portfolio_table(console, payload) + return + if all(isinstance(item, UserResponse) for item in payload): + print_user_table(console, payload) + return return if isinstance(payload, PortfolioDetailResponse): print_portfolio_detail(console, payload) return + if isinstance(payload, UserResponse): + print_user_detail(console, payload) + return + console.print(payload) diff --git a/app/cli/main.py b/app/cli/main.py index 49e9402..2bbe70e 100644 --- a/app/cli/main.py +++ b/app/cli/main.py @@ -11,6 +11,7 @@ from rich.console import Console from app.cli.commands.health import register_health_commands from app.cli.commands.portfolio import portfolio_app from app.cli.commands.strategy import strategy_app +from app.cli.commands.user import user_app from app.core.config import Settings, get_settings from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.portfolio_repository import PortfolioRepository @@ -47,6 +48,7 @@ def create_app( app.add_typer(portfolio_app, name="portfolio") app.add_typer(strategy_app, name="strategy") + app.add_typer(user_app, name="user") register_health_commands(app) return app diff --git a/app/domain/models.py b/app/domain/models.py index 5cd8dca..747a84c 100644 --- a/app/domain/models.py +++ b/app/domain/models.py @@ -16,6 +16,15 @@ class StrategySet: created_at: datetime | None = None +@dataclass +class User: + id: str + username: str + email: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + @dataclass class Portfolio: portfolio_id: str diff --git a/app/main.py b/app/main.py index 355b95c..8f40041 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.health import router as health_router from app.api.portfolios import router as portfolio_router from app.api.strategy_sets import router as strategy_sets_router +from app.api.users import router as users_router from app.core.config import get_settings from app.core.errors import register_exception_handlers @@ -30,6 +31,7 @@ def create_app() -> FastAPI: app.include_router(health_router) app.include_router(portfolio_router) app.include_router(strategy_sets_router) + app.include_router(users_router) register_exception_handlers(app) return app diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index 609e49c..763408c 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -4,12 +4,22 @@ from __future__ import annotations from typing import Protocol -from app.domain.models import LedgerEntry, Portfolio, StrategySet +from app.domain.models import LedgerEntry, Portfolio, StrategySet, User class PortfolioRepository(Protocol): def ping(self) -> None: ... + def get_user(self, user_id: str) -> User: ... + + def list_users(self) -> list[User]: ... + + def create_user(self, username: str, *, email: str | None = None) -> User: ... + + def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: ... + + def delete_user(self, user_id: str) -> None: ... + def get(self, portfolio_id: str) -> Portfolio: ... def get_or_create( diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 25c2734..76366ce 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -12,7 +12,17 @@ from typing import Any import psycopg from psycopg import sql -from app.domain.models import LedgerEntry, Portfolio, StrategySet +from app.domain.models import LedgerEntry, Portfolio, StrategySet, User + +_SELECT_USER = """ + SELECT + id, + username, + email, + created_at, + updated_at + FROM users +""" _SELECT_PORTFOLIO = """ SELECT @@ -78,6 +88,17 @@ class PostgresPortfolioRepository: def ensure_schema(self) -> None: conn = self._connect() with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) cur.execute( """ CREATE TABLE IF NOT EXISTS strategy_sets ( @@ -148,6 +169,19 @@ class PostgresPortfolioRepository: """ ) + @staticmethod + def _row_to_user(row: Any) -> User: + if row is None: + raise ValueError("사용자를 찾을 수 없습니다") + + return User( + id=row[0], + username=row[1], + email=row[2], + created_at=row[3] if len(row) > 3 else None, + updated_at=row[4] if len(row) > 4 else None, + ) + @staticmethod def _row_to_portfolio(row: Any) -> Portfolio: if row is None: @@ -195,6 +229,103 @@ class PostgresPortfolioRepository: note=row[12] or "", ) + def _ensure_user_uniqueness( + self, + cur: psycopg.Cursor, + *, + username: str | None = None, + email: str | None = None, + exclude_user_id: str | None = None, + ) -> None: + if username is not None: + if exclude_user_id is None: + cur.execute("SELECT 1 FROM users WHERE username = %s", (username,)) + else: + cur.execute("SELECT 1 FROM users WHERE username = %s AND id <> %s", (username, exclude_user_id)) + if cur.fetchone() is not None: + raise ValueError(f"이미 존재하는 username입니다: {username}") + + if email is not None: + if exclude_user_id is None: + cur.execute("SELECT 1 FROM users WHERE email = %s", (email,)) + else: + cur.execute("SELECT 1 FROM users WHERE email = %s AND id <> %s", (email, exclude_user_id)) + if cur.fetchone() is not None: + raise ValueError(f"이미 존재하는 email입니다: {email}") + + def get_user(self, user_id: str) -> User: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute(_SELECT_USER + "WHERE id = %s", (user_id,)) + row = cur.fetchone() + return self._row_to_user(row) + + def list_users(self) -> list[User]: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute(_SELECT_USER + "ORDER BY created_at DESC, username ASC") + rows = cur.fetchall() + return [self._row_to_user(row) for row in rows] + + def create_user(self, username: str, *, email: str | None = None) -> User: + self.ensure_schema() + with self._connect().cursor() as cur: + self._ensure_user_uniqueness(cur, username=username, email=email) + user_id = str(uuid.uuid4()) + cur.execute( + """ + INSERT INTO users (id, username, email) + VALUES (%s, %s, %s) + RETURNING id, username, email, created_at, updated_at + """, + (user_id, username, email), + ) + row = cur.fetchone() + return self._row_to_user(row) + + def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: + self.ensure_schema() + assignments: list[str] = [] + params: list[object] = [] + with self._connect().cursor() as cur: + cur.execute(_SELECT_USER + "WHERE id = %s", (user_id,)) + existing_row = cur.fetchone() + if existing_row is None: + raise ValueError("사용자를 찾을 수 없습니다") + + self._ensure_user_uniqueness(cur, username=username, email=email, exclude_user_id=user_id) + + if username is not None: + assignments.append("username = %s") + params.append(username) + if email is not None: + assignments.append("email = %s") + params.append(email) + + if not assignments: + return self._row_to_user(existing_row) + + assignments.append("updated_at = NOW()") + cur.execute( + f""" + UPDATE users + SET {', '.join(assignments)} + WHERE id = %s + RETURNING id, username, email, created_at, updated_at + """, + (*params, user_id), + ) + row = cur.fetchone() + return self._row_to_user(row) + + def delete_user(self, user_id: str) -> None: + self.ensure_schema() + with self._connect().cursor() as cur: + cur.execute("SELECT 1 FROM users WHERE id = %s", (user_id,)) + if cur.fetchone() is None: + raise ValueError("사용자를 찾을 수 없습니다") + cur.execute("DELETE FROM users WHERE id = %s", (user_id,)) + def get(self, portfolio_id: str) -> Portfolio: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..a20f96d --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,59 @@ +"""User request and response schemas.""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field, field_validator + + +class UserResponse(BaseModel): + id: str + username: str + email: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class CreateUserRequest(BaseModel): + username: str = Field(min_length=1) + email: str | None = None + + @field_validator("username") + @classmethod + def normalize_username(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("username은 비어 있을 수 없습니다") + return normalized + + @field_validator("email") + @classmethod + def normalize_email(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None + + +class UpdateUserRequest(BaseModel): + username: str | None = Field(default=None, min_length=1) + email: str | None = None + + @field_validator("username") + @classmethod + def normalize_username(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise ValueError("username은 비어 있을 수 없습니다") + return normalized + + @field_validator("email") + @classmethod + def normalize_email(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + return normalized or None \ No newline at end of file diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..e06acc2 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,43 @@ +"""User service orchestration.""" + +from __future__ import annotations + +from fastapi import Depends + +from app.core.config import Settings, get_settings +from app.domain.models import User +from app.repositories.factory import get_portfolio_repository_from_settings +from app.repositories.portfolio_repository import PortfolioRepository +from app.schemas.user import CreateUserRequest, UpdateUserRequest + + +def get_user_repository(settings: Settings = Depends(get_settings)) -> PortfolioRepository: + return get_portfolio_repository_from_settings(settings) + + +class UserService: + def __init__(self, repository: PortfolioRepository) -> None: + self._repository = repository + + def list(self) -> list[User]: + return self._repository.list_users() + + def get(self, user_id: str) -> User: + return self._repository.get_user(user_id) + + def create(self, payload: CreateUserRequest) -> User: + return self._repository.create_user(payload.username, email=payload.email) + + def update(self, user_id: str, payload: UpdateUserRequest) -> User: + return self._repository.update_user( + user_id, + username=payload.username, + email=payload.email, + ) + + def delete(self, user_id: str) -> None: + self._repository.delete_user(user_id) + + +def get_user_service(repository: PortfolioRepository = Depends(get_user_repository)) -> UserService: + return UserService(repository) \ No newline at end of file diff --git a/docs/api-examples.md b/docs/api-examples.md index b112226..682e74f 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -259,6 +259,118 @@ Response 204: ### 5.1 GET /strategy-sets +## 6. Users + +All user management endpoints require an admin token. + +### 6.1 GET /users + +Request: + +```bash +curl -X GET "http://localhost:8000/users" \ + -H "Authorization: Bearer " +``` + +Response 200: + +```json +[ + { + "id": "user-1", + "username": "alice", + "email": "alice@example.com", + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z" + } +] +``` + +### 6.2 GET /users/{user_id} + +Request: + +```bash +curl -X GET "http://localhost:8000/users/user-1" \ + -H "Authorization: Bearer " +``` + +Response 200: + +```json +{ + "id": "user-1", + "username": "alice", + "email": "alice@example.com", + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z" +} +``` + +### 6.3 POST /users + +Request: + +```bash +curl -X POST "http://localhost:8000/users" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "alice", + "email": "alice@example.com" + }' +``` + +Response 201: + +```json +{ + "id": "user-1", + "username": "alice", + "email": "alice@example.com", + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:00:00Z" +} +``` + +### 6.4 PATCH /users/{user_id} + +Request: + +```bash +curl -X PATCH "http://localhost:8000/users/user-1" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "username": "alice-updated" + }' +``` + +Response 200: + +```json +{ + "id": "user-1", + "username": "alice-updated", + "email": "alice@example.com", + "created_at": "2026-04-03T00:00:00Z", + "updated_at": "2026-04-03T00:10:00Z" +} +``` + +### 6.5 DELETE /users/{user_id} + +Request: + +```bash +curl -X DELETE "http://localhost:8000/users/user-1" \ + -H "Authorization: Bearer " +``` + +Response 204: + +- No content. + Request: ```bash diff --git a/docs/db-schema-spec.md b/docs/db-schema-spec.md index ba2e21b..2d84ffd 100644 --- a/docs/db-schema-spec.md +++ b/docs/db-schema-spec.md @@ -5,17 +5,19 @@ This document defines the database schema in an AI-agent-friendly format. - Source of truth: Alembic revisions in `migrations/versions` -- Current baseline revision: `20260403_0003` +- Current baseline revision: `20260403_0004` - Baseline files: - `migrations/versions/20260403_0001_initial_portfolio_schema.py` - `migrations/versions/20260403_0002_strategy_set_columns.py` - `migrations/versions/20260403_0003_portfolio_strategy_refs.py` + - `migrations/versions/20260403_0004_users_table.py` ## 2. Scope Schema objects covered by baseline revision: - `strategy_sets` +- `users` - `portfolios` - `portfolio_ledger` - secondary indexes @@ -30,7 +32,29 @@ Schema objects covered by baseline revision: ## 4. Tables -### 4.1 strategy_sets +### 4.1 users + +Purpose: + +- Stores admin-managed user records for operational ownership and future identity mapping. + +Columns: + +| Column | Type | Null | Default | Constraints / Notes | +|---|---|---|---|---| +| id | Text | NO | - | Primary Key | +| username | Text | NO | - | Unique username | +| email | Text | YES | - | Unique when present | +| created_at | DateTime(timezone=True) | NO | NOW() | Creation timestamp | +| updated_at | DateTime(timezone=True) | NO | NOW() | Last update timestamp | + +Constraints: + +- Primary key: `id` +- Unique: `uq_users_username` on (`username`) +- Unique: `uq_users_email` on (`email`) + +### 4.2 strategy_sets Purpose: @@ -52,7 +76,7 @@ Constraints: - Unique: `uq_strategy_sets_name_config` on (`name`, `config`) - Unique: `uq_strategy_sets_nick` on (`nick`) -### 4.2 portfolios +### 4.3 portfolios Purpose: @@ -60,7 +84,7 @@ Purpose: Current baseline note: -- The columns below describe the currently migrated schema through revision `20260403_0003`. +- The columns below describe the currently migrated schema through revision `20260403_0004`. Columns: @@ -90,7 +114,7 @@ Constraint notes: - `active_strategy_id` is a nullable logical reference to `strategy_sets.id` - `avabile_strategies` stores zero or more selectable strategy set IDs when present -### 4.3 portfolio_ledger +### 4.4 portfolio_ledger Purpose: @@ -121,14 +145,21 @@ Constraints: ## 5. Indexes -### 5.1 idx_portfolio_ledger_portfolio_time +### 5.1 ix_users_created_at + +- Table: `users` +- Columns/expressions: (`created_at`) +- Unique: no +- Intent: speed operational listing by recency. + +### 5.2 idx_portfolio_ledger_portfolio_time - Table: `portfolio_ledger` - Columns/expressions: (`portfolio_id`, `recorded_at DESC`) - Unique: no - Intent: fetch recent ledger entries per portfolio efficiently. -### 5.2 idx_portfolios_broker_status_updated +### 5.3 idx_portfolios_broker_status_updated - Table: `portfolios` - Columns/expressions: (`broker`, `status`, `updated_at DESC`) @@ -170,3 +201,4 @@ When schema changes: | 20260403_0001 | migrations/versions/20260403_0001_initial_portfolio_schema.py | Initial schema for strategy sets, portfolios, ledger, indexes, optional hypertable | | 20260403_0002 | migrations/versions/20260403_0002_strategy_set_columns.py | Rename `strategy_name`->`name`, `strategy_config`->`config`, add unique `nick`, handle legacy unnamed unique constraint | | 20260403_0003 | migrations/versions/20260403_0003_portfolio_strategy_refs.py | Remove embedded portfolio strategy fields, add `active_strategy_id` and `avabile_strategies` | +| 20260403_0004 | migrations/versions/20260403_0004_users_table.py | Add `users` table with unique username/email and created_at index | diff --git a/docs/portfoliohub-spec.md b/docs/portfoliohub-spec.md index f8640aa..4a9d8be 100644 --- a/docs/portfoliohub-spec.md +++ b/docs/portfoliohub-spec.md @@ -22,6 +22,7 @@ Primary goals: In scope: - Authenticated portfolio read and write operations +- Admin-managed user CRUD operations - Role-based access control (read, trade-write, admin) - Optional PostgreSQL/Timescale-backed repository via feature flag - Operational commands for DB bootstrap and migration @@ -121,6 +122,7 @@ Base routes: - Health: `/health` - Portfolio: `/portfolios` +- Users: `/users` ### 7.1 Health Endpoint @@ -245,6 +247,47 @@ Note: Since strategy sets are identified by (name, config) uniqueness, updating - See `docs/api-examples.md` for maintained request/response examples. +### 7.5 User Endpoints + +All user management endpoints are admin-only. + +#### List Users + +- `GET /users` +- Auth: `admin` +- Response `200`: array of `UserResponse` + +#### Get User + +- `GET /users/{user_id}` +- Auth: `admin` +- Response `200`: `UserResponse` +- Response `404`: user not found + +#### Create User + +- `POST /users` +- Auth: `admin` +- Body: `CreateUserRequest` +- Response `201`: `UserResponse` +- Response `409`: duplicate username or email + +#### Update User + +- `PATCH /users/{user_id}` +- Auth: `admin` +- Body: `UpdateUserRequest` +- Response `200`: `UserResponse` +- Response `404`: user not found +- Response `409`: duplicate username or email + +#### Delete User + +- `DELETE /users/{user_id}` +- Auth: `admin` +- Response `204`: no content +- Response `404`: user not found + ## 8. Schema Specification ### 8.1 Request Models @@ -278,6 +321,16 @@ Note: Since strategy sets are identified by (name, config) uniqueness, updating `BuyRequest` / `SellRequest` - `quantity: float > 0` + +`CreateUserRequest` + +- `username: str` +- `email: str | null` + +`UpdateUserRequest` + +- `username: str | null` +- `email: str | null` - `price: float > 0` - `fee: float >= 0` - `tax: float >= 0` diff --git a/migrations/env.py b/migrations/env.py index 6f4d92a..d74139e 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -5,6 +5,8 @@ from logging.config import fileConfig from alembic import context from sqlalchemy import engine_from_config, pool +from app.core.config import get_settings + config = context.config if config.config_file_name is not None: @@ -13,8 +15,15 @@ if config.config_file_name is not None: target_metadata = None +def _configure_database_url() -> str: + settings = get_settings() + database_url = settings.active_database_url + config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%")) + return database_url + + def run_migrations_offline() -> None: - url = config.get_main_option("sqlalchemy.url") + url = _configure_database_url() context.configure( url=url, target_metadata=target_metadata, @@ -27,6 +36,7 @@ def run_migrations_offline() -> None: def run_migrations_online() -> None: + _configure_database_url() connectable = engine_from_config( config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", diff --git a/migrations/versions/20260403_0004_users_table.py b/migrations/versions/20260403_0004_users_table.py new file mode 100644 index 0000000..01c8d39 --- /dev/null +++ b/migrations/versions/20260403_0004_users_table.py @@ -0,0 +1,37 @@ +"""add users table + +Revision ID: 20260403_0004 +Revises: 20260403_0003 +Create Date: 2026-04-03 02:00:00 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260403_0004" +down_revision = "20260403_0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("username", sa.Text(), nullable=False), + sa.Column("email", sa.Text(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False, server_default=sa.text("NOW()")), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("username", name="uq_users_username"), + sa.UniqueConstraint("email", name="uq_users_email"), + ) + op.create_index("ix_users_created_at", "users", ["created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_users_created_at", table_name="users") + op.drop_table("users") \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index 27853cc..1e4533c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,7 +8,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, StrategySet +from app.domain.models import LedgerEntry, Portfolio, StrategySet, User class FakeRepo: @@ -17,6 +17,8 @@ class FakeRepo: self.entries: list[LedgerEntry] = [] self.strategy_set = StrategySet(id="set-1", name="target_value", config="{}", nick="target_value:99914b932bd3") self._strategy_sets: dict[str, StrategySet] = {self.strategy_set.id: self.strategy_set} + self._users: dict[str, User] = {} + self._user_seq = 1 self.closed = False def close(self) -> None: @@ -28,6 +30,50 @@ class FakeRepo: def ping(self) -> None: return None + def get_user(self, user_id: str) -> User: + user = self._users.get(user_id) + if user is None: + raise ValueError("사용자를 찾을 수 없습니다") + return user + + def list_users(self) -> list[User]: + return sorted(self._users.values(), key=lambda user: user.username) + + def create_user(self, username: str, *, email: str | None = None) -> User: + if any(user.username == username for user in self._users.values()): + raise ValueError(f"이미 존재하는 username입니다: {username}") + if email is not None and any(user.email == email for user in self._users.values()): + raise ValueError(f"이미 존재하는 email입니다: {email}") + now = datetime.now(timezone.utc) + user = User( + id=f"user-{self._user_seq}", + username=username, + email=email, + created_at=now, + updated_at=now, + ) + self._user_seq += 1 + self._users[user.id] = user + return user + + def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: + user = self.get_user(user_id) + if username is not None and any(item.id != user_id and item.username == username for item in self._users.values()): + raise ValueError(f"이미 존재하는 username입니다: {username}") + if email is not None and any(item.id != user_id and item.email == email for item in self._users.values()): + raise ValueError(f"이미 존재하는 email입니다: {email}") + if username is not None: + user.username = username + if email is not None: + user.email = email + user.updated_at = datetime.now(timezone.utc) + return user + + def delete_user(self, user_id: str) -> None: + if user_id not in self._users: + raise ValueError("사용자를 찾을 수 없습니다") + del self._users[user_id] + def get(self, portfolio_id: str) -> Portfolio: if self._portfolio is None or self._portfolio.portfolio_id != portfolio_id: raise ValueError("포트폴리오를 찾을 수 없습니다") @@ -392,4 +438,48 @@ def test_cli_migrate_db_reports_head(monkeypatch) -> None: result = runner.invoke(runner.app, ["migrate-db"]) assert result.exit_code == 0 - assert "migration_applied: head" in result.stdout \ No newline at end of file + assert "migration_applied: head" in result.stdout + + +def test_cli_user_list_renders_table() -> None: + repo = FakeRepo() + repo.create_user("alice", email="alice@example.com") + runner = build_runner(repo) + + result = runner.invoke(runner.app, ["user", "list"]) + + assert result.exit_code == 0 + assert "Users" in result.stdout + assert "alice" in result.stdout + + +def test_cli_user_create_supports_json() -> None: + repo = FakeRepo() + runner = build_runner(repo) + + result = runner.invoke( + runner.app, + ["--format", "json", "user", "create", "--username", "alice", "--email", "alice@example.com", "--force"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["username"] == "alice" + assert payload["email"] == "alice@example.com" + + +def test_cli_user_update_and_delete_flow() -> None: + repo = FakeRepo() + user = repo.create_user("alice", email="alice@example.com") + runner = build_runner(repo) + + update_result = runner.invoke( + runner.app, + ["user", "update", user.id, "--username", "alice-updated", "--force"], + ) + delete_result = runner.invoke(runner.app, ["user", "delete", user.id, "--force"]) + + assert update_result.exit_code == 0 + assert "alice-updated" in update_result.stdout + assert delete_result.exit_code == 0 + assert f"Deleted user: {user.id}" in delete_result.stdout \ No newline at end of file diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..99930b1 --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +from app.domain.models import User +from app.main import app +from app.services.user_service import UserService, get_user_service + + +class FakeUserRepo: + def __init__(self) -> None: + self.users: dict[str, User] = {} + self.next_id = 1 + + def get_user(self, user_id: str) -> User: + user = self.users.get(user_id) + if user is None: + raise ValueError("사용자를 찾을 수 없습니다") + return user + + def list_users(self) -> list[User]: + return sorted(self.users.values(), key=lambda user: user.username) + + def create_user(self, username: str, *, email: str | None = None) -> User: + if any(user.username == username for user in self.users.values()): + raise ValueError(f"이미 존재하는 username입니다: {username}") + if email is not None and any(user.email == email for user in self.users.values()): + raise ValueError(f"이미 존재하는 email입니다: {email}") + now = datetime.now(timezone.utc) + user = User( + id=f"user-{self.next_id}", + username=username, + email=email, + created_at=now, + updated_at=now, + ) + self.next_id += 1 + self.users[user.id] = user + return user + + def update_user(self, user_id: str, *, username: str | None = None, email: str | None = None) -> User: + user = self.get_user(user_id) + if username is not None and any(item.id != user_id and item.username == username for item in self.users.values()): + raise ValueError(f"이미 존재하는 username입니다: {username}") + if email is not None and any(item.id != user_id and item.email == email for item in self.users.values()): + raise ValueError(f"이미 존재하는 email입니다: {email}") + if username is not None: + user.username = username + if email is not None: + user.email = email + user.updated_at = datetime.now(timezone.utc) + return user + + def delete_user(self, user_id: str) -> None: + if user_id not in self.users: + raise ValueError("사용자를 찾을 수 없습니다") + del self.users[user_id] + + +def _client_with_repo(monkeypatch, repo: FakeUserRepo) -> TestClient: + monkeypatch.setenv("PORTFOLIOHUB_DB_ENABLED", "false") + monkeypatch.setenv("PORTFOLIOHUB_ADMIN_TOKENS", "admin-token") + monkeypatch.setenv("PORTFOLIOHUB_READ_TOKENS", "read-token") + from app.core.config import get_settings + + get_settings.cache_clear() + app.dependency_overrides.clear() + app.dependency_overrides[get_user_service] = lambda: UserService(repo) + return TestClient(app) + + +def test_list_users_requires_admin(monkeypatch) -> None: + client = _client_with_repo(monkeypatch, FakeUserRepo()) + + response = client.get("/users", headers={"Authorization": "Bearer read-token"}) + + app.dependency_overrides.clear() + assert response.status_code == 403 + assert response.json()["detail"] == "Insufficient permission" + + +def test_list_users_returns_payload(monkeypatch) -> None: + repo = FakeUserRepo() + repo.create_user("alice", email="alice@example.com") + client = _client_with_repo(monkeypatch, repo) + + response = client.get("/users", headers={"Authorization": "Bearer admin-token"}) + + app.dependency_overrides.clear() + assert response.status_code == 200 + payload = response.json() + assert len(payload) == 1 + assert payload[0]["username"] == "alice" + + +def test_create_user_rejects_duplicate_username(monkeypatch) -> None: + repo = FakeUserRepo() + repo.create_user("alice") + client = _client_with_repo(monkeypatch, repo) + + response = client.post( + "/users", + headers={"Authorization": "Bearer admin-token"}, + json={"username": "alice", "email": "alice2@example.com"}, + ) + + app.dependency_overrides.clear() + assert response.status_code == 409 + assert "이미 존재하는 username입니다" in response.json()["detail"] + + +def test_get_update_and_delete_user_flow(monkeypatch) -> None: + repo = FakeUserRepo() + user = repo.create_user("alice", email="alice@example.com") + client = _client_with_repo(monkeypatch, repo) + + get_response = client.get(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"}) + update_response = client.patch( + f"/users/{user.id}", + headers={"Authorization": "Bearer admin-token"}, + json={"username": "alice-updated"}, + ) + delete_response = client.delete(f"/users/{user.id}", headers={"Authorization": "Bearer admin-token"}) + + app.dependency_overrides.clear() + assert get_response.status_code == 200 + assert get_response.json()["username"] == "alice" + assert update_response.status_code == 200 + assert update_response.json()["username"] == "alice-updated" + assert delete_response.status_code == 204 \ No newline at end of file