Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Generated
+1191
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user