Refactor code structure for improved readability and maintainability

This commit is contained in:
Artem Kashaev
2026-05-28 11:20:10 +05:00
parent d5a889ed6d
commit e48b1fc0e9
21 changed files with 171 additions and 1068 deletions
+4 -4
View File
@@ -2,13 +2,13 @@ POSTGRES_USER=train_watcher
POSTGRES_PASSWORD=train_watcher POSTGRES_PASSWORD=train_watcher
POSTGRES_DB=train_watcher POSTGRES_DB=train_watcher
BFF_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher BFF_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@host.docker.internal:5432/train_watcher
LOGIC_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher LOGIC_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@host.docker.internal:5432/train_watcher
LOGIC_BASE_URL=http://logic:8000 LOGIC_BASE_URL=http://host.docker.internal:8002
SERVICE_TOKEN=dev-service-token-change-me SERVICE_TOKEN=dev-service-token-change-me
JWT_SECRET=dev-jwt-secret-change-me JWT_SECRET=dev-jwt-secret-change-me
S3_ENDPOINT_URL=http://minio:9000 S3_ENDPOINT_URL=http://host.docker.internal:9000
S3_PUBLIC_BASE_URL=http://localhost:9000 S3_PUBLIC_BASE_URL=http://localhost:9000
S3_ACCESS_KEY_ID=minioadmin S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin S3_SECRET_ACCESS_KEY=minioadmin
+1
View File
@@ -16,3 +16,4 @@ build/
*.log *.log
coverage/ coverage/
.DS_Store .DS_Store
.kilo
+26
View File
@@ -0,0 +1,26 @@
# Agent Notes
## Repo Shape
- Monorepo split: `frontend/` is the React/Vite app; `services/bff/` owns auth, users, media upload, and frontend-facing API; `services/logic/` owns catalog, workouts, analytics, builtin seed data, and internal APIs.
- Python services are a uv workspace rooted at `services/`; use the shared `services/.venv`, not per-service virtualenvs.
- Keep dev tools (`ruff`, `ty`, `pytest`) in the `services` workspace dev group only. Docker runtime images must not run `uv run` or install dev tools.
## Commands
- Python setup: `cd services && uv sync --all-packages`.
- Python lint: `cd services && uv run ruff check bff logic`.
- Import-check services with the shared venv from each service dir, e.g. `services/.venv/bin/python -c "from app.main import app; print(app.title)"` with cwd `services/bff` or `services/logic`.
- Frontend uses pnpm `10.12.1`; if `pnpm` is unavailable, use `npx pnpm@10.12.1 ...`.
- Frontend checks from repo root: `npx pnpm@10.12.1 --filter train-watcher-frontend lint`, `typecheck`, and `build`.
- Full local stack: `docker compose -f infra/docker-compose.yml up --build`.
## Docker And Infra Gotchas
- Compose intentionally uses `host.docker.internal` for BFF/logic/Postgres/MinIO/frontend proxy paths; do not casually switch these back to service DNS names without testing Docker DNS on this machine.
- Backend Docker build context is `services/`, with `bff/Dockerfile` and `logic/Dockerfile`; both install runtime deps from `services/uv.lock` and start `/app/.venv/bin/uvicorn` directly.
- `services/.dockerignore` excludes `.venv`, `.ruff_cache`, `.pytest_cache`, `.ty`, and `__pycache__`; do not delete these local artifacts just to clean Docker contexts.
- MinIO uses the named volume `minio-object-data`; the older `minio-data` name was abandoned after corrupted local state.
- Frontend API base defaults to `/api`; `frontend/nginx.conf` proxies `/api/` to the BFF port.
## Backend Details
- Both Python apps auto-create SQLAlchemy tables on startup via `create_schema`; Alembic migrations exist but are not run by Compose startup yet.
- Logic internal endpoints require `X-Service-Token` and `X-User-Id`; BFF is responsible for supplying both.
- BFF stores uploaded images in MinIO/S3 and passes `image_s3_url` plus `image_s3_key` to logic catalog records.
+1 -1
View File
@@ -5,7 +5,7 @@ server {
index index.html; index index.html;
location /api/ { location /api/ {
proxy_pass http://bff:8000/; proxy_pass http://host.docker.internal:8001/;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
+18 -8
View File
@@ -25,7 +25,7 @@ services:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
volumes: volumes:
- minio-data:/data - minio-object-data:/data
healthcheck: healthcheck:
test: ["CMD", "mc", "ready", "local"] test: ["CMD", "mc", "ready", "local"]
interval: 5s interval: 5s
@@ -50,10 +50,13 @@ services:
logic: logic:
build: build:
context: ../services/logic context: ../services
dockerfile: logic/Dockerfile
environment: environment:
DATABASE_URL: ${LOGIC_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher} DATABASE_URL: ${LOGIC_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@host.docker.internal:5432/train_watcher}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me} SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
extra_hosts:
- "host.docker.internal:host-gateway"
ports: ports:
- "8002:8000" - "8002:8000"
depends_on: depends_on:
@@ -63,22 +66,26 @@ services:
test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
start_period: 120s
retries: 20 retries: 20
bff: bff:
build: build:
context: ../services/bff context: ../services
dockerfile: bff/Dockerfile
environment: environment:
DATABASE_URL: ${BFF_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher} DATABASE_URL: ${BFF_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@host.docker.internal:5432/train_watcher}
LOGIC_BASE_URL: ${LOGIC_BASE_URL:-http://logic:8000} LOGIC_BASE_URL: ${LOGIC_BASE_URL:-http://host.docker.internal:8002}
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me} SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me} JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000} S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://host.docker.internal:9000}
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-http://localhost:9000} S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-http://localhost:9000}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-train-watcher-media} S3_BUCKET: ${S3_BUCKET:-train-watcher-media}
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
extra_hosts:
- "host.docker.internal:host-gateway"
ports: ports:
- "8001:8000" - "8001:8000"
depends_on: depends_on:
@@ -92,6 +99,7 @@ services:
test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
start_period: 120s
retries: 20 retries: 20
frontend: frontend:
@@ -101,10 +109,12 @@ services:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
ports: ports:
- "5173:80" - "5173:80"
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on: depends_on:
bff: bff:
condition: service_healthy condition: service_healthy
volumes: volumes:
postgres-data: postgres-data:
minio-data: minio-object-data:
+11
View File
@@ -0,0 +1,11 @@
.venv/
.ruff_cache/
.pytest_cache/
.ty/
**/.venv/
**/.ruff_cache/
**/.pytest_cache/
**/.ty/
__pycache__/
**/__pycache__/
*.pyc
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
.ty/
+7 -4
View File
@@ -2,10 +2,13 @@ FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
WORKDIR /app WORKDIR /app
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev COPY bff/pyproject.toml ./bff/pyproject.toml
COPY app ./app COPY logic/pyproject.toml ./logic/pyproject.toml
COPY alembic.ini ./alembic.ini RUN uv sync --frozen --no-dev --all-packages
COPY alembic ./alembic COPY bff/app ./bff/app
COPY bff/alembic.ini ./bff/alembic.ini
COPY bff/alembic ./bff/alembic
WORKDIR /app/bff
EXPOSE 8000 EXPOSE 8000
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1 -2
View File
@@ -1,10 +1,9 @@
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context from alembic import context
from app.core import settings from app.core import settings
from app.models import Base from app.models import Base
from sqlalchemy import engine_from_config, pool
config = context.config config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)
@@ -8,9 +8,8 @@ Create Date: 2026-05-28 10:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_initial" revision: str = "0001_initial"
down_revision: str | None = None down_revision: str | None = None
+9 -2
View File
@@ -7,20 +7,27 @@ from sqlalchemy.orm import Session, sessionmaker
from app.core import settings from app.core import settings
engine = create_engine(settings.database_url, pool_pre_ping=True) engine = create_engine(
settings.database_url,
pool_pre_ping=True,
connect_args={"connect_timeout": 3},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None: def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None:
last_error: OperationalError | None = None last_error: OperationalError | None = None
for _ in range(attempts): for attempt in range(1, attempts + 1):
try: try:
print(f"Connecting to database, attempt {attempt}/{attempts}", flush=True)
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("SELECT 1")) connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection) metadata.create_all(bind=connection)
print("Database schema is ready", flush=True)
return return
except OperationalError as exc: except OperationalError as exc:
last_error = exc last_error = exc
print(f"Database is not ready: {exc}", flush=True)
sleep(delay_seconds) sleep(delay_seconds)
if last_error: if last_error:
raise last_error raise last_error
+2 -13
View File
@@ -16,16 +16,5 @@ dependencies = [
"sqlalchemy>=2.0.41", "sqlalchemy>=2.0.41",
] ]
[dependency-groups] [tool.uv]
dev = [ package = false
"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"]
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
**/__pycache__/ **/__pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
.ty/
+7 -4
View File
@@ -2,10 +2,13 @@ FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
WORKDIR /app WORKDIR /app
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev COPY bff/pyproject.toml ./bff/pyproject.toml
COPY app ./app COPY logic/pyproject.toml ./logic/pyproject.toml
COPY alembic.ini ./alembic.ini RUN uv sync --frozen --no-dev --all-packages
COPY alembic ./alembic COPY logic/app ./logic/app
COPY logic/alembic.ini ./logic/alembic.ini
COPY logic/alembic ./logic/alembic
WORKDIR /app/logic
EXPOSE 8000 EXPOSE 8000
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1 -2
View File
@@ -1,10 +1,9 @@
from logging.config import fileConfig from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context from alembic import context
from app.core import settings from app.core import settings
from app.models import Base from app.models import Base
from sqlalchemy import engine_from_config, pool
config = context.config config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)
@@ -8,9 +8,8 @@ Create Date: 2026-05-28 10:00:00.000000
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0001_initial" revision: str = "0001_initial"
down_revision: str | None = None down_revision: str | None = None
+9 -2
View File
@@ -7,20 +7,27 @@ from sqlalchemy.orm import Session, sessionmaker
from app.core import settings from app.core import settings
engine = create_engine(settings.database_url, pool_pre_ping=True) engine = create_engine(
settings.database_url,
pool_pre_ping=True,
connect_args={"connect_timeout": 3},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None: def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None:
last_error: OperationalError | None = None last_error: OperationalError | None = None
for _ in range(attempts): for attempt in range(1, attempts + 1):
try: try:
print(f"Connecting to database, attempt {attempt}/{attempts}", flush=True)
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text("SELECT 1")) connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection) metadata.create_all(bind=connection)
print("Database schema is ready", flush=True)
return return
except OperationalError as exc: except OperationalError as exc:
last_error = exc last_error = exc
print(f"Database is not ready: {exc}", flush=True)
sleep(delay_seconds) sleep(delay_seconds)
if last_error: if last_error:
raise last_error raise last_error
+2 -13
View File
@@ -10,16 +10,5 @@ dependencies = [
"sqlalchemy>=2.0.41", "sqlalchemy>=2.0.41",
] ]
[dependency-groups] [tool.uv]
dev = [ package = false
"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"]
-1001
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[project]
name = "train-watcher-services"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = []
[tool.uv]
package = false
[tool.uv.workspace]
members = ["bff", "logic"]
[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"]
+43 -8
View File
@@ -2,6 +2,13 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.14" requires-python = ">=3.14"
[manifest]
members = [
"train-watcher-bff",
"train-watcher-logic",
"train-watcher-services",
]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" version = "1.18.4"
@@ -949,7 +956,7 @@ wheels = [
[[package]] [[package]]
name = "train-watcher-bff" name = "train-watcher-bff"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "bff" }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "boto3" }, { name = "boto3" },
@@ -964,13 +971,6 @@ dependencies = [
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.16.0" }, { name = "alembic", specifier = ">=1.16.0" },
@@ -986,6 +986,41 @@ requires-dist = [
{ name = "sqlalchemy", specifier = ">=2.0.41" }, { name = "sqlalchemy", specifier = ">=2.0.41" },
] ]
[[package]]
name = "train-watcher-logic"
version = "0.1.0"
source = { virtual = "logic" }
dependencies = [
{ name = "alembic" },
{ name = "fastapi", extra = ["standard"] },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.16.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pydantic-settings", specifier = ">=2.9.1" },
{ name = "sqlalchemy", specifier = ">=2.0.41" },
]
[[package]]
name = "train-watcher-services"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
{ name = "ty" },
]
[package.metadata]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "pytest", specifier = ">=8.3.5" }, { name = "pytest", specifier = ">=8.3.5" },