Refactor code structure for improved readability and maintainability

This commit is contained in:
Artem Kashaev
2026-05-28 10:36:17 +05:00
commit d5a889ed6d
49 changed files with 6853 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
.ruff_cache/
__pycache__/
**/__pycache__/
*.pyc
.pytest_cache/
+11
View File
@@ -0,0 +1,11 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
EXPOSE 8000
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+44
View File
@@ -0,0 +1,44 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core import settings
from app.models import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,36 @@
"""initial bff schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-05-28 10:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"bff_users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("email", sa.String(length=320), nullable=False),
sa.Column("password_hash", sa.String(length=512), nullable=False),
sa.Column("display_name", sa.String(length=160), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_bff_users_email", "bff_users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_bff_users_email", table_name="bff_users")
op.drop_table("bff_users")
View File
+30
View File
@@ -0,0 +1,30 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://train_watcher:train_watcher@localhost:5432/train_watcher"
logic_base_url: str = "http://localhost:8002"
service_token: str = "dev-service-token-change-me"
jwt_secret: str = "dev-jwt-secret-change-me"
jwt_algorithm: str = "HS256"
access_token_ttl_minutes: int = 60 * 24
s3_endpoint_url: str = "http://localhost:9000"
s3_public_base_url: str = "http://localhost:9000"
s3_access_key_id: str = "minioadmin"
s3_secret_access_key: str = "minioadmin"
s3_bucket: str = "train-watcher-media"
s3_region: str = "us-east-1"
max_upload_bytes: int = 5 * 1024 * 1024
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+34
View File
@@ -0,0 +1,34 @@
from collections.abc import Generator
from time import sleep
from sqlalchemy import MetaData, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
from app.core import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None:
last_error: OperationalError | None = None
for _ in range(attempts):
try:
with engine.begin() as connection:
connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection)
return
except OperationalError as exc:
last_error = exc
sleep(delay_seconds)
if last_error:
raise last_error
def get_db() -> Generator[Session]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+193
View File
@@ -0,0 +1,193 @@
from typing import Annotated, Any
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core import settings
from app.db import create_schema, get_db
from app.models import Base, User
from app.s3 import upload_catalog_image
from app.schemas import MediaUploadRead, TokenRead, UserCreate, UserLogin, UserRead
from app.security import create_access_token, get_current_user, hash_password, verify_password
app = FastAPI(title="Train Watcher BFF", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Db = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[User, Depends(get_current_user)]
@app.on_event("startup")
def on_startup() -> None:
create_schema(Base.metadata)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/auth/register", response_model=TokenRead, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, db: Db) -> TokenRead:
user = User(
email=payload.email.lower(),
password_hash=hash_password(payload.password),
display_name=payload.display_name,
)
db.add(user)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
) from exc
db.refresh(user)
return TokenRead(access_token=create_access_token(user), user=UserRead.model_validate(user))
@app.post("/auth/login", response_model=TokenRead)
def login(payload: UserLogin, db: Db) -> TokenRead:
user = db.scalar(select(User).where(User.email == payload.email.lower()))
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
return TokenRead(access_token=create_access_token(user), user=UserRead.model_validate(user))
@app.post("/auth/logout")
def logout() -> dict[str, str]:
return {"status": "ok"}
@app.get("/me", response_model=UserRead)
def me(user: CurrentUser) -> User:
return user
def logic_headers(user: User) -> dict[str, str]:
return {"X-Service-Token": settings.service_token, "X-User-Id": str(user.id)}
async def logic_request(
method: str,
path: str,
user: User,
*,
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> Any:
async with httpx.AsyncClient(base_url=settings.logic_base_url, timeout=10) as client:
response = await client.request(
method,
path,
headers=logic_headers(user),
json=json,
params=params,
)
if response.status_code >= 400:
try:
detail = response.json().get("detail", response.text)
except ValueError:
detail = response.text
raise HTTPException(status_code=response.status_code, detail=detail)
if not response.content:
return None
return response.json()
@app.post("/media/images", response_model=MediaUploadRead, status_code=status.HTTP_201_CREATED)
async def upload_image(
user: CurrentUser,
entity_type: Annotated[str, Query(pattern="^(equipment|exercise)$")],
file: Annotated[UploadFile, File()],
) -> dict[str, str]:
return await upload_catalog_image(file, user.id, entity_type)
@app.get("/catalog/equipment")
async def list_equipment(user: CurrentUser, search: str | None = None) -> Any:
return await logic_request(
"GET", "/internal/catalog/equipment", user, params={"search": search}
)
@app.post("/catalog/equipment", status_code=status.HTTP_201_CREATED)
async def create_equipment(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/catalog/equipment", user, json=payload)
@app.get("/catalog/exercises")
async def list_exercises(user: CurrentUser, search: str | None = None) -> Any:
return await logic_request(
"GET", "/internal/catalog/exercises", user, params={"search": search}
)
@app.post("/catalog/exercises", status_code=status.HTTP_201_CREATED)
async def create_exercise(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/catalog/exercises", user, json=payload)
@app.get("/workouts")
async def list_workouts(user: CurrentUser) -> Any:
return await logic_request("GET", "/internal/workouts", user)
@app.post("/workouts", status_code=status.HTTP_201_CREATED)
async def create_workout(payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", "/internal/workouts", user, json=payload)
@app.get("/workouts/{workout_id}")
async def get_workout(workout_id: str, user: CurrentUser) -> Any:
return await logic_request("GET", f"/internal/workouts/{workout_id}", user)
@app.patch("/workouts/{workout_id}")
async def update_workout(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("PATCH", f"/internal/workouts/{workout_id}", user, json=payload)
@app.post("/workouts/{workout_id}/items", status_code=status.HTTP_201_CREATED)
async def add_workout_item(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request("POST", f"/internal/workouts/{workout_id}/items", user, json=payload)
@app.post("/workout-items/{item_id}/sets", status_code=status.HTTP_201_CREATED)
async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUser) -> Any:
return await logic_request(
"POST", f"/internal/workout-items/{item_id}/sets", user, json=payload
)
@app.get("/analytics/progression")
async def progression(
user: CurrentUser,
kind: Annotated[str, Query(pattern="^(exercise|equipment)$")] = "exercise",
entity_id: str | None = None,
) -> Any:
return await logic_request(
"GET",
"/internal/analytics/progression",
user,
params={"kind": kind, "entity_id": entity_id},
)
@app.get("/analytics/calories")
async def calories(user: CurrentUser) -> Any:
return await logic_request("GET", "/internal/analytics/calories", user)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import UTC, datetime
from sqlalchemy import DateTime, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
def now() -> datetime:
return datetime.now(UTC)
class User(Base):
__tablename__ = "bff_users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(512))
display_name: Mapped[str] = mapped_column(String(160))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
+58
View File
@@ -0,0 +1,58 @@
import pathlib
import uuid
import boto3
from fastapi import HTTPException, UploadFile, status
from app.core import settings
ALLOWED_CONTENT_TYPES = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
}
def s3_client():
return boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
region_name=settings.s3_region,
)
async def upload_catalog_image(
file: UploadFile,
user_id: uuid.UUID,
entity_type: str,
) -> dict[str, str]:
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Only JPEG, PNG and WEBP images are supported",
)
content = await file.read()
if len(content) > settings.max_upload_bytes:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File is too large",
)
extension = ALLOWED_CONTENT_TYPES[file.content_type]
original_stem = pathlib.Path(file.filename or "image").stem[:48]
object_key = f"users/{user_id}/{entity_type}/pending/{uuid.uuid4()}-{original_stem}{extension}"
s3_client().put_object(
Bucket=settings.s3_bucket,
Key=object_key,
Body=content,
ContentType=file.content_type,
)
public_base = settings.s3_public_base_url.rstrip("/")
return {
"image_s3_key": object_key,
"image_s3_url": f"{public_base}/{settings.s3_bucket}/{object_key}",
}
+35
View File
@@ -0,0 +1,35 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=128)
display_name: str = Field(min_length=1, max_length=160)
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserRead(BaseModel):
id: uuid.UUID
email: EmailStr
display_name: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class TokenRead(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserRead
class MediaUploadRead(BaseModel):
image_s3_url: str
image_s3_key: str
+55
View File
@@ -0,0 +1,55 @@
import uuid
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core import settings
from app.db import get_db
from app.models import User
password_hash = PasswordHash.recommended()
bearer = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
def create_access_token(user: User) -> str:
expires_at = datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes)
payload = {"sub": str(user.id), "email": user.email, "exp": expires_at}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
db: Annotated[Session, Depends(get_db)],
) -> User:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
try:
payload = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
)
user_id = uuid.UUID(payload["sub"])
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
) from exc
user = db.scalar(select(User).where(User.id == user_id))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "train-watcher-bff"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"alembic>=1.16.0",
"boto3>=1.38.23",
"fastapi[standard]>=0.115.12",
"email-validator>=2.2.0",
"httpx>=0.28.1",
"psycopg[binary]>=3.2.9",
"pydantic-settings>=2.9.1",
"pyjwt>=2.10.1",
"pwdlib[argon2]>=0.2.1",
"python-multipart>=0.0.20",
"sqlalchemy>=2.0.41",
]
[dependency-groups]
dev = [
"pytest>=8.3.5",
"ruff>=0.11.11",
"ty>=0.0.1a6",
]
[tool.ruff]
line-length = 100
target-version = "py314"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
+1191
View File
File diff suppressed because it is too large Load Diff