Add admin user management

This commit is contained in:
2026-04-03 12:23:44 +00:00
parent 3d37186fa7
commit 5168e52e4b
18 changed files with 989 additions and 19 deletions
+98
View File
@@ -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}")
+46 -1
View File
@@ -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)
+2
View File
@@ -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