feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция
SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят значения, а не имена, чтобы база читалась так же, как API. Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job. metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою таблицу целиком, два одновременных прогона затёрли бы друг друга.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
"""`fintracker` command line: serve / worker / create-user / openapi / sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from fintracker.config import get_settings
|
||||
|
||||
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
||||
|
||||
|
||||
def _logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=get_settings().log_level.upper(),
|
||||
format="%(asctime)s %(levelname)-5s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8000,
|
||||
reload: bool = typer.Option(False, "--reload", help="dev only"),
|
||||
) -> None:
|
||||
"""Run the HTTP API."""
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("fintracker.api.app:create_app", factory=True, host=host, port=port, reload=reload)
|
||||
|
||||
|
||||
@app.command()
|
||||
def worker() -> None:
|
||||
"""Run the scheduled-sync worker (one instance per deployment)."""
|
||||
_logging()
|
||||
from fintracker.worker.scheduler import serve_forever
|
||||
|
||||
asyncio.run(serve_forever())
|
||||
|
||||
|
||||
@app.command("create-user")
|
||||
def create_user(
|
||||
email: str,
|
||||
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
|
||||
) -> None:
|
||||
"""Create (or reset the password of) the application user."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from fintracker.api.security import hash_password
|
||||
from fintracker.db import get_sessionmaker, reset_engine
|
||||
from fintracker.models import AppUser
|
||||
|
||||
async def _run() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
user = (
|
||||
await session.execute(select(AppUser).where(AppUser.email == email.lower()))
|
||||
).scalar_one_or_none()
|
||||
if user is None:
|
||||
session.add(AppUser(email=email.lower(), password_hash=hash_password(password)))
|
||||
action = "created"
|
||||
else:
|
||||
user.password_hash = hash_password(password)
|
||||
action = "password reset"
|
||||
await session.commit()
|
||||
await reset_engine()
|
||||
typer.echo(f"{email.lower()}: {action}")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
@app.command()
|
||||
def openapi(out: Annotated[Path, typer.Argument()] = Path("openapi.json")) -> None:
|
||||
"""Write the OpenAPI schema to a file (committed as openapi/openapi.json)."""
|
||||
from fintracker.api.app import create_app
|
||||
|
||||
schema = create_app().openapi()
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(schema, indent=2, ensure_ascii=False, sort_keys=True) + "\n")
|
||||
typer.echo(f"wrote {out}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def sync(source: str) -> None:
|
||||
"""Run one source sync in-process (uses the same lock as the worker)."""
|
||||
_logging()
|
||||
from fintracker.db import reset_engine
|
||||
from fintracker.sources import registry
|
||||
from fintracker.worker.runner import Skipped, run_source
|
||||
|
||||
if source not in registry.names():
|
||||
raise typer.BadParameter(f"unknown source {source!r}; known: {registry.names()}")
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
run = await run_source(source, triggered_by="cli")
|
||||
except Skipped:
|
||||
typer.echo(f"{source}: already running elsewhere")
|
||||
raise typer.Exit(1) from None
|
||||
finally:
|
||||
await reset_engine()
|
||||
typer.echo(f"{source}: {run.status.value} counts={run.counts} warnings={run.warnings}")
|
||||
if run.error:
|
||||
typer.echo(run.error, err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
metrics_app = typer.Typer(help="Precomputed metrics.")
|
||||
app.add_typer(metrics_app, name="metrics")
|
||||
|
||||
|
||||
@metrics_app.command("refresh")
|
||||
def metrics_refresh() -> None:
|
||||
"""Rebuild every metric_* table from core data (idempotent)."""
|
||||
_logging()
|
||||
from fintracker.db import get_sessionmaker, reset_engine
|
||||
from fintracker.metrics.refresh import refresh_all
|
||||
|
||||
async def _run() -> None:
|
||||
try:
|
||||
async with get_sessionmaker()() as session:
|
||||
entry = await refresh_all(session, trigger="cli")
|
||||
finally:
|
||||
await reset_engine()
|
||||
if entry.error:
|
||||
typer.echo(entry.error, err=True)
|
||||
raise typer.Exit(1)
|
||||
typer.echo(f"metrics refreshed at {entry.finished_at}")
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
Reference in New Issue
Block a user