Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
POSTGRES_USER=train_watcher
|
||||
POSTGRES_PASSWORD=train_watcher
|
||||
POSTGRES_DB=train_watcher
|
||||
|
||||
BFF_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher
|
||||
LOGIC_DATABASE_URL=postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher
|
||||
LOGIC_BASE_URL=http://logic:8000
|
||||
SERVICE_TOKEN=dev-service-token-change-me
|
||||
JWT_SECRET=dev-jwt-secret-change-me
|
||||
|
||||
S3_ENDPOINT_URL=http://minio:9000
|
||||
S3_PUBLIC_BASE_URL=http://localhost:9000
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_BUCKET=train-watcher-media
|
||||
S3_REGION=us-east-1
|
||||
|
||||
VITE_API_BASE_URL=/api
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.ty/
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.log
|
||||
coverage/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
@@ -0,0 +1,13 @@
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG VITE_API_BASE_URL=/api
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
COPY package.json ./
|
||||
RUN corepack enable && corepack prepare pnpm@10.12.1 --activate && pnpm install --frozen-lockfile=false
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,25 @@
|
||||
import js from "@eslint/js";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Train Watcher</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://bff:8000/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "train-watcher-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.77.2",
|
||||
"@tanstack/react-router": "^1.120.15",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
|
||||
import { api, type AuthState } from "./api";
|
||||
import type { CatalogEntity, Workout } from "./types";
|
||||
|
||||
type Tab = "dashboard" | "catalog" | "workout" | "history" | "analytics";
|
||||
|
||||
const authStorageKey = "train-watcher-auth";
|
||||
|
||||
function loadAuth(): AuthState | null {
|
||||
const raw = localStorage.getItem(authStorageKey);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as AuthState;
|
||||
} catch {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [auth, setAuth] = useState<AuthState | null>(loadAuth);
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function saveAuth(next: AuthState) {
|
||||
localStorage.setItem(authStorageKey, JSON.stringify(next));
|
||||
setAuth(next);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem(authStorageKey);
|
||||
queryClient.clear();
|
||||
setAuth(null);
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return <AuthScreen onAuth={saveAuth} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div>
|
||||
<p className="eyebrow">Train Watcher</p>
|
||||
<h1>Дневник тренировок</h1>
|
||||
</div>
|
||||
<nav>
|
||||
{([
|
||||
["dashboard", "Обзор"],
|
||||
["catalog", "Каталог"],
|
||||
["workout", "Тренировка"],
|
||||
["history", "История"],
|
||||
["analytics", "Аналитика"],
|
||||
] as Array<[Tab, string]>).map(([key, label]) => (
|
||||
<button className={tab === key ? "active" : ""} key={key} onClick={() => setTab(key)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="profile-card">
|
||||
<span>{auth.user.display_name}</span>
|
||||
<small>{auth.user.email}</small>
|
||||
<button onClick={logout}>Выйти</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main>
|
||||
{tab === "dashboard" && <Dashboard token={auth.accessToken} onStart={() => setTab("workout")} />}
|
||||
{tab === "catalog" && <Catalog token={auth.accessToken} />}
|
||||
{tab === "workout" && <ActiveWorkout token={auth.accessToken} />}
|
||||
{tab === "history" && <History token={auth.accessToken} />}
|
||||
{tab === "analytics" && <Analytics token={auth.accessToken} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthScreen({ onAuth }: { onAuth: (auth: AuthState) => void }) {
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [email, setEmail] = useState("demo@example.com");
|
||||
const [password, setPassword] = useState("password123");
|
||||
const [displayName, setDisplayName] = useState("Demo Athlete");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mode === "login"
|
||||
? api.login({ email, password })
|
||||
: api.register({ email, password, display_name: displayName }),
|
||||
onSuccess: onAuth,
|
||||
onError: (err) => setError(err.message),
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
mutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-layout">
|
||||
<section className="hero-panel">
|
||||
<p className="eyebrow">Progressive overload tracker</p>
|
||||
<h1>Фиксируй подходы, рабочий вес и динамику без лишнего шума.</h1>
|
||||
<p>
|
||||
MVP уже разделен на frontend, BFF и logic-service, а изображения каталога хранятся в MinIO как S3-объекты.
|
||||
</p>
|
||||
</section>
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h2>{mode === "login" ? "Вход" : "Регистрация"}</h2>
|
||||
{mode === "register" && (
|
||||
<label>
|
||||
Имя
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Email
|
||||
<input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button className="primary" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Отправка..." : mode === "login" ? "Войти" : "Создать аккаунт"}
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
|
||||
{mode === "login" ? "Нужна регистрация" : "Уже есть аккаунт"}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function useCatalog(token: string) {
|
||||
const equipment = useQuery({ queryKey: ["equipment"], queryFn: () => api.equipment(token) });
|
||||
const exercises = useQuery({ queryKey: ["exercises"], queryFn: () => api.exercises(token) });
|
||||
return { equipment, exercises };
|
||||
}
|
||||
|
||||
function Dashboard({ token, onStart }: { token: string; onStart: () => void }) {
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
|
||||
const latest = workouts.data?.[0];
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Dashboard</p>
|
||||
<h2>Обзор</h2>
|
||||
</div>
|
||||
<button className="primary" onClick={onStart}>Начать тренировку</button>
|
||||
</div>
|
||||
<div className="stats-grid">
|
||||
<Metric label="Тренировок" value={workouts.data?.length ?? 0} />
|
||||
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
|
||||
<Metric label="Последняя" value={latest ? new Date(latest.started_at).toLocaleDateString("ru-RU") : "нет"} />
|
||||
</div>
|
||||
<section className="card">
|
||||
<h3>Последняя тренировка</h3>
|
||||
{latest ? <WorkoutSummary workout={latest} /> : <p className="muted">Создай первую тренировку, чтобы увидеть историю.</p>}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Catalog({ token }: { token: string }) {
|
||||
const [kind, setKind] = useState<"equipment" | "exercise">("equipment");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { equipment, exercises } = useCatalog(token);
|
||||
const list = kind === "equipment" ? equipment.data : exercises.data;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const image = file ? await api.uploadImage(token, kind, file) : {};
|
||||
const payload = { name, description, ...image };
|
||||
return kind === "equipment" ? api.createEquipment(token, payload) : api.createExercise(token, payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setFile(null);
|
||||
void queryClient.invalidateQueries({ queryKey: [kind === "equipment" ? "equipment" : "exercises"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Catalog</p>
|
||||
<h2>Тренажеры и упражнения</h2>
|
||||
</div>
|
||||
<div className="segmented">
|
||||
<button className={kind === "equipment" ? "active" : ""} onClick={() => setKind("equipment")}>Тренажеры</button>
|
||||
<button className={kind === "exercise" ? "active" : ""} onClick={() => setKind("exercise")}>Упражнения</button>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="card form-grid"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Название
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
<input value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Картинка
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
|
||||
</label>
|
||||
<button className="primary" disabled={createMutation.isPending}>{createMutation.isPending ? "Сохранение..." : "Добавить"}</button>
|
||||
{createMutation.error && <p className="error">{createMutation.error.message}</p>}
|
||||
</form>
|
||||
<div className="catalog-grid">
|
||||
{list?.map((entity) => <CatalogCard entity={entity} key={entity.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveWorkout({ token }: { token: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { equipment, exercises } = useCatalog(token);
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
const [activeWorkout, setActiveWorkout] = useState<Workout | null>(null);
|
||||
const [kind, setKind] = useState<"exercise" | "equipment">("exercise");
|
||||
const [entityId, setEntityId] = useState("");
|
||||
const [activeItemId, setActiveItemId] = useState("");
|
||||
const [weight, setWeight] = useState(60);
|
||||
const [reps, setReps] = useState(8);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: () => api.createWorkout(token),
|
||||
onSuccess: (workout) => {
|
||||
setActiveWorkout(workout);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
},
|
||||
});
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!activeWorkout) throw new Error("Нет активной тренировки");
|
||||
return api.addWorkoutItem(token, activeWorkout.id, {
|
||||
exercise_id: kind === "exercise" ? entityId : null,
|
||||
equipment_id: kind === "equipment" ? entityId : null,
|
||||
planned_working_weight: weight,
|
||||
});
|
||||
},
|
||||
onSuccess: (item) => {
|
||||
setActiveItemId(item.id);
|
||||
setActiveWorkout((workout) => workout ? { ...workout, items: [...workout.items, item] } : workout);
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
},
|
||||
});
|
||||
const addSetMutation = useMutation({
|
||||
mutationFn: () => api.addWorkoutSet(token, activeItemId, { weight, reps }),
|
||||
onSuccess: (workoutSet) => {
|
||||
setActiveWorkout((workout) => {
|
||||
if (!workout) return workout;
|
||||
return {
|
||||
...workout,
|
||||
estimated_calories: workout.estimated_calories + (workoutSet.calories ?? 0),
|
||||
items: workout.items.map((item) =>
|
||||
item.id === activeItemId ? { ...item, sets: [...item.sets, workoutSet] } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ["workouts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["calories"] });
|
||||
},
|
||||
});
|
||||
|
||||
const current = activeWorkout ?? workouts.data?.find((workout) => !workout.finished_at) ?? null;
|
||||
const choices = kind === "exercise" ? exercises.data : equipment.data;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Active workout</p>
|
||||
<h2>Текущая тренировка</h2>
|
||||
</div>
|
||||
<button className="primary" onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>Новая тренировка</button>
|
||||
</div>
|
||||
{current ? (
|
||||
<>
|
||||
<section className="card form-grid">
|
||||
<label>
|
||||
Тип
|
||||
<select value={kind} onChange={(event) => setKind(event.target.value as "exercise" | "equipment")}>
|
||||
<option value="exercise">Упражнение</option>
|
||||
<option value="equipment">Тренажер</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Элемент
|
||||
<select value={entityId} onChange={(event) => setEntityId(event.target.value)}>
|
||||
<option value="">Выбрать</option>
|
||||
{choices?.map((entity) => <option key={entity.id} value={entity.id}>{entity.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Вес
|
||||
<input type="number" value={weight} onChange={(event) => setWeight(Number(event.target.value))} />
|
||||
</label>
|
||||
<label>
|
||||
Повторы
|
||||
<input type="number" value={reps} onChange={(event) => setReps(Number(event.target.value))} />
|
||||
</label>
|
||||
<button className="primary" disabled={!entityId || addItemMutation.isPending} onClick={() => addItemMutation.mutate()}>Добавить элемент</button>
|
||||
<button disabled={!activeItemId || addSetMutation.isPending} onClick={() => addSetMutation.mutate()}>Записать подход</button>
|
||||
</section>
|
||||
<WorkoutSummary workout={current} />
|
||||
</>
|
||||
) : (
|
||||
<section className="card empty-state"><h3>Нет активной тренировки</h3><p>Начни тренировку и добавь упражнения или тренажеры.</p></section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function History({ token }: { token: string }) {
|
||||
const workouts = useQuery({ queryKey: ["workouts"], queryFn: () => api.workouts(token) });
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header"><div><p className="eyebrow">History</p><h2>История</h2></div></div>
|
||||
{workouts.data?.map((workout) => <WorkoutSummary workout={workout} key={workout.id} />)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Analytics({ token }: { token: string }) {
|
||||
const { exercises } = useCatalog(token);
|
||||
const [exerciseId, setExerciseId] = useState("");
|
||||
const progression = useQuery({
|
||||
queryKey: ["progression", exerciseId],
|
||||
queryFn: () => api.progression(token, "exercise", exerciseId || undefined),
|
||||
});
|
||||
const calories = useQuery({ queryKey: ["calories"], queryFn: () => api.calories(token) });
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header"><div><p className="eyebrow">Analytics</p><h2>Прогрессия и калораж</h2></div></div>
|
||||
<section className="card form-grid">
|
||||
<label>
|
||||
Упражнение
|
||||
<select value={exerciseId} onChange={(event) => setExerciseId(event.target.value)}>
|
||||
<option value="">Все упражнения</option>
|
||||
{exercises.data?.map((exercise) => <option key={exercise.id} value={exercise.id}>{exercise.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Metric label="Последний вес" value={progression.data?.last_weight ?? "нет"} />
|
||||
<Metric label="Максимальный вес" value={progression.data?.max_weight ?? "нет"} />
|
||||
<Metric label="Калорий всего" value={Math.round(calories.data?.total_calories ?? 0)} />
|
||||
</section>
|
||||
<section className="card chart-card">
|
||||
<h3>Объем по датам</h3>
|
||||
<div className="bars">
|
||||
{progression.data?.points.map((point) => (
|
||||
<div className="bar-row" key={point.date}>
|
||||
<span>{point.date}</span>
|
||||
<div><i style={{ width: `${Math.min(point.volume / 20, 100)}%` }} /></div>
|
||||
<b>{Math.round(point.volume)}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogCard({ entity }: { entity: CatalogEntity }) {
|
||||
return (
|
||||
<article className="card catalog-card">
|
||||
{entity.image_s3_url ? <img src={entity.image_s3_url} alt="" /> : <div className="image-placeholder">TW</div>}
|
||||
<div>
|
||||
<span className={entity.is_builtin ? "pill" : "pill user"}>{entity.is_builtin ? "стандартное" : "мое"}</span>
|
||||
<h3>{entity.name}</h3>
|
||||
<p>{entity.description || "Без описания"}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkoutSummary({ workout }: { workout: Workout }) {
|
||||
const setCount = useMemo(() => workout.items.reduce((total, item) => total + item.sets.length, 0), [workout.items]);
|
||||
return (
|
||||
<section className="card workout-card">
|
||||
<div>
|
||||
<h3>{new Date(workout.started_at).toLocaleString("ru-RU")}</h3>
|
||||
<p className="muted">{workout.notes || "Без заметок"}</p>
|
||||
</div>
|
||||
<div className="workout-stats">
|
||||
<Metric label="Элементов" value={workout.items.length} />
|
||||
<Metric label="Подходов" value={setCount} />
|
||||
<Metric label="Ккал" value={Math.round(workout.estimated_calories ?? 0)} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Calories, CatalogEntity, Progression, User, Workout, WorkoutItem, WorkoutSet } from "./types";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
export type AuthState = {
|
||||
accessToken: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
if (!(options.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register(payload: { email: string; password: string; display_name: string }) {
|
||||
return request<AuthState>("/auth/register", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
login(payload: { email: string; password: string }) {
|
||||
return request<AuthState>("/auth/login", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
me(token: string) {
|
||||
return request<User>("/me", {}, token);
|
||||
},
|
||||
equipment(token: string) {
|
||||
return request<CatalogEntity[]>("/catalog/equipment", {}, token);
|
||||
},
|
||||
exercises(token: string) {
|
||||
return request<CatalogEntity[]>("/catalog/exercises", {}, token);
|
||||
},
|
||||
uploadImage(token: string, entityType: "equipment" | "exercise", file: File) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return request<{ image_s3_url: string; image_s3_key: string }>(
|
||||
`/media/images?entity_type=${entityType}`,
|
||||
{ method: "POST", body: form },
|
||||
token,
|
||||
);
|
||||
},
|
||||
createEquipment(token: string, payload: Partial<CatalogEntity>) {
|
||||
return request<CatalogEntity>("/catalog/equipment", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
createExercise(token: string, payload: Partial<CatalogEntity>) {
|
||||
return request<CatalogEntity>("/catalog/exercises", { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
workouts(token: string) {
|
||||
return request<Workout[]>("/workouts", {}, token);
|
||||
},
|
||||
createWorkout(token: string, notes?: string) {
|
||||
return request<Workout>("/workouts", { method: "POST", body: JSON.stringify({ notes }) }, token);
|
||||
},
|
||||
updateWorkout(token: string, workoutId: string, payload: Partial<Workout>) {
|
||||
return request<Workout>(`/workouts/${workoutId}`, { method: "PATCH", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
addWorkoutItem(token: string, workoutId: string, payload: Partial<WorkoutItem>) {
|
||||
return request<WorkoutItem>(`/workouts/${workoutId}/items`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
addWorkoutSet(token: string, itemId: string, payload: Partial<WorkoutSet>) {
|
||||
return request<WorkoutSet>(`/workout-items/${itemId}/sets`, { method: "POST", body: JSON.stringify(payload) }, token);
|
||||
},
|
||||
progression(token: string, kind: "exercise" | "equipment", entityId?: string) {
|
||||
const params = new URLSearchParams({ kind });
|
||||
if (entityId) params.set("entity_id", entityId);
|
||||
return request<Progression>(`/analytics/progression?${params.toString()}`, {}, token);
|
||||
},
|
||||
calories(token: string) {
|
||||
return request<Calories>("/analytics/calories", {}, token);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
:root {
|
||||
color: #111827;
|
||||
background: #f4f0e8;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input, select { font: inherit; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 280px 1fr; }
|
||||
.sidebar { background: #151515; color: #f9fafb; padding: 28px; display: flex; flex-direction: column; gap: 28px; }
|
||||
.sidebar h1 { font-size: 28px; line-height: 1; margin: 8px 0 0; }
|
||||
.sidebar nav { display: grid; gap: 8px; }
|
||||
.sidebar nav button, .profile-card button { color: #f9fafb; background: transparent; text-align: left; border-radius: 14px; padding: 12px 14px; }
|
||||
.sidebar nav button.active, .sidebar nav button:hover { background: #dbff5b; color: #151515; }
|
||||
.profile-card { margin-top: auto; display: grid; gap: 6px; padding: 16px; border: 1px solid #333; border-radius: 20px; }
|
||||
.profile-card small { color: #a3a3a3; }
|
||||
main { padding: 32px; }
|
||||
.auth-layout { min-height: 100vh; display: grid; grid-template-columns: 1.2fr 0.8fr; gap: 24px; align-items: center; }
|
||||
.hero-panel { min-height: calc(100vh - 64px); border-radius: 32px; padding: 48px; color: #f9fafb; background: radial-gradient(circle at 20% 20%, #dbff5b 0 12%, transparent 13%), linear-gradient(135deg, #111827, #3f2d20); display: flex; flex-direction: column; justify-content: flex-end; }
|
||||
.hero-panel h1 { font-size: clamp(36px, 6vw, 76px); line-height: 0.95; max-width: 920px; margin: 0; }
|
||||
.hero-panel p:not(.eyebrow) { max-width: 680px; color: #e5e7eb; font-size: 18px; }
|
||||
.auth-card { max-width: 440px; width: 100%; }
|
||||
.stack { display: grid; gap: 24px; }
|
||||
.page-header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.page-header h2, .card h3, .auth-card h2 { margin: 0; }
|
||||
.eyebrow { color: #8a5cf6; font-size: 12px; font-weight: 800; letter-spacing: 0.16em; margin: 0 0 8px; text-transform: uppercase; }
|
||||
.card { background: rgba(255,255,255,0.82); border: 1px solid rgba(17,24,39,0.08); border-radius: 24px; box-shadow: 0 18px 60px rgba(17,24,39,0.08); padding: 22px; }
|
||||
.primary { background: #151515; color: #fff; border-radius: 14px; padding: 12px 18px; font-weight: 800; }
|
||||
.ghost { background: transparent; color: #374151; padding: 10px; }
|
||||
label { display: grid; gap: 8px; font-weight: 700; color: #374151; }
|
||||
input, select { width: 100%; border: 1px solid #d1d5db; border-radius: 14px; padding: 12px 14px; background: #fff; color: #111827; }
|
||||
.error { color: #b91c1c; margin: 0; }
|
||||
.muted { color: #6b7280; }
|
||||
.stats-grid, .catalog-grid, .workout-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.catalog-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.metric { background: #fff; border-radius: 18px; padding: 16px; display: grid; gap: 8px; }
|
||||
.metric span { color: #6b7280; font-size: 13px; }
|
||||
.metric strong { font-size: 26px; }
|
||||
.segmented { display: flex; background: #fff; border-radius: 999px; padding: 4px; }
|
||||
.segmented button { background: transparent; border-radius: 999px; padding: 10px 14px; }
|
||||
.segmented button.active { background: #151515; color: #fff; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; align-items: end; }
|
||||
.catalog-card { overflow: hidden; padding: 0; }
|
||||
.catalog-card img, .image-placeholder { width: 100%; height: 170px; object-fit: cover; background: linear-gradient(135deg, #dbff5b, #8a5cf6); display: grid; place-items: center; font-weight: 900; color: #151515; }
|
||||
.catalog-card div:last-child { padding: 18px; }
|
||||
.catalog-card p { color: #6b7280; }
|
||||
.pill { display: inline-flex; border-radius: 999px; background: #e5e7eb; padding: 5px 10px; font-size: 12px; font-weight: 800; }
|
||||
.pill.user { background: #dcfce7; color: #166534; }
|
||||
.empty-state { text-align: center; padding: 48px; }
|
||||
.workout-card { display: grid; gap: 18px; }
|
||||
.bars { display: grid; gap: 12px; }
|
||||
.bar-row { display: grid; grid-template-columns: 110px 1fr 70px; gap: 12px; align-items: center; }
|
||||
.bar-row div { height: 16px; border-radius: 999px; background: #e5e7eb; overflow: hidden; }
|
||||
.bar-row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a5cf6, #dbff5b); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell { grid-template-columns: 1fr; padding-bottom: 88px; }
|
||||
.sidebar { position: fixed; inset: auto 12px 12px; z-index: 10; border-radius: 24px; padding: 12px; display: block; }
|
||||
.sidebar > div:first-child, .profile-card { display: none; }
|
||||
.sidebar nav { grid-template-columns: repeat(5, 1fr); }
|
||||
.sidebar nav button { text-align: center; padding: 10px 6px; font-size: 12px; }
|
||||
main { padding: 20px; }
|
||||
.auth-layout { grid-template-columns: 1fr; }
|
||||
.hero-panel { min-height: 48vh; padding: 28px; }
|
||||
.catalog-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.form-grid, .stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.page-header { align-items: stretch; flex-direction: column; }
|
||||
.catalog-grid, .workout-stats { grid-template-columns: 1fr; }
|
||||
.bar-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CatalogEntity = {
|
||||
id: string;
|
||||
owner_user_id: string | null;
|
||||
equipment_id?: string | null;
|
||||
name: string;
|
||||
description: string | null;
|
||||
image_s3_url: string | null;
|
||||
image_s3_key: string | null;
|
||||
is_builtin: boolean;
|
||||
default_calories_per_minute?: number | null;
|
||||
};
|
||||
|
||||
export type WorkoutSet = {
|
||||
id: string;
|
||||
workout_item_id: string;
|
||||
set_index: number;
|
||||
weight: number;
|
||||
reps: number;
|
||||
duration_seconds: number | null;
|
||||
calories: number | null;
|
||||
completed_at: string;
|
||||
};
|
||||
|
||||
export type WorkoutItem = {
|
||||
id: string;
|
||||
workout_id: string;
|
||||
exercise_id: string | null;
|
||||
equipment_id: string | null;
|
||||
order_index: number;
|
||||
planned_working_weight: number | null;
|
||||
created_at: string;
|
||||
sets: WorkoutSet[];
|
||||
};
|
||||
|
||||
export type Workout = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
notes: string | null;
|
||||
estimated_calories: number;
|
||||
items: WorkoutItem[];
|
||||
};
|
||||
|
||||
export type Progression = {
|
||||
last_weight: number | null;
|
||||
max_weight: number | null;
|
||||
previous_delta: number | null;
|
||||
points: Array<{ date: string; max_weight: number; volume: number }>;
|
||||
};
|
||||
|
||||
export type Calories = {
|
||||
total_calories: number;
|
||||
workouts: Array<{ id: string; date: string; calories: number }>;
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-train_watcher}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-train_watcher}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-train_watcher}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-train_watcher} -d ${POSTGRES_DB:-train_watcher}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
S3_BUCKET: ${S3_BUCKET:-train-watcher-media}
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 $$S3_ACCESS_KEY_ID $$S3_SECRET_ACCESS_KEY &&
|
||||
mc mb --ignore-existing local/$$S3_BUCKET &&
|
||||
mc anonymous set download local/$$S3_BUCKET
|
||||
"
|
||||
|
||||
logic:
|
||||
build:
|
||||
context: ../services/logic
|
||||
environment:
|
||||
DATABASE_URL: ${LOGIC_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
|
||||
ports:
|
||||
- "8002:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
bff:
|
||||
build:
|
||||
context: ../services/bff
|
||||
environment:
|
||||
DATABASE_URL: ${BFF_DATABASE_URL:-postgresql+psycopg://train_watcher:train_watcher@postgres:5432/train_watcher}
|
||||
LOGIC_BASE_URL: ${LOGIC_BASE_URL:-http://logic:8000}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token-change-me}
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me}
|
||||
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
S3_PUBLIC_BASE_URL: ${S3_PUBLIC_BASE_URL:-http://localhost:9000}
|
||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
S3_BUCKET: ${S3_BUCKET:-train-watcher-media}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
ports:
|
||||
- "8001:8000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logic:
|
||||
condition: service_healthy
|
||||
minio-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "/app/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api}
|
||||
ports:
|
||||
- "5173:80"
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
minio-data:
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "train-watcher",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"scripts": {
|
||||
"dev:frontend": "pnpm --filter train-watcher-frontend dev",
|
||||
"build:frontend": "pnpm --filter train-watcher-frontend build",
|
||||
"lint:frontend": "pnpm --filter train-watcher-frontend lint",
|
||||
"typecheck:frontend": "pnpm --filter train-watcher-frontend typecheck"
|
||||
}
|
||||
}
|
||||
Generated
+2138
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- "frontend"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
Я хочу спланировать веб-приложение с адаптивным интерфейсом.
|
||||
|
||||
Логические объекты:
|
||||
1. пользователи
|
||||
2. тренажеры
|
||||
3. упражнения
|
||||
4. тренировки
|
||||
|
||||
тренажеры и упражнения будут какие-то стандартные,
|
||||
но пользователь также может добавлять свои и прикрепить к ним картинки
|
||||
отображаются в виде карточек
|
||||
|
||||
Открывая меню тренажера/упражнения пользователь может выбрать свой рабочий вес.
|
||||
Также отслеживать количество подходов, которые сделал пользователь и с каким весом каждый подход.
|
||||
Приложение должно отслеживать прогрессию весов, калораж тренировок
|
||||
В перспективе можно будет добавить еще один тип логических объектов - мышцы, и зависимость каждого тренажера/упражнения к мышцам,
|
||||
таким образом приложение сможет составлять прорграмму тренировок исходя из запроса пользователя (но это пока не реализуем)
|
||||
|
||||
|
||||
Стэк:
|
||||
Бэкенд: Python 3.14+uv+ty+ruff+FastAPI+SQLAlchemy+Alembic+Postgres
|
||||
Фронтенд: TypeScript+pnpm+React+(я не особо шарю за фронт, предложи актуальные необходимые технологии)
|
||||
Бэкенд для фронтенда и логику раздели на два сервиса, соедини их через REST API
|
||||
(Впоследствии логический сервис может разростись сильно вширь, так что отделим его от бэкенда для фронтенда)
|
||||
@@ -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
@@ -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,47 @@
|
||||
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,117 @@
|
||||
"""initial logic 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(
|
||||
"logic_equipment",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_url", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_key", sa.Text(), nullable=True),
|
||||
sa.Column("is_builtin", sa.Boolean(), 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_logic_equipment_is_builtin", "logic_equipment", ["is_builtin"])
|
||||
op.create_index("ix_logic_equipment_name", "logic_equipment", ["name"])
|
||||
op.create_index("ix_logic_equipment_owner_user_id", "logic_equipment", ["owner_user_id"])
|
||||
|
||||
op.create_table(
|
||||
"logic_exercises",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("equipment_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_url", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_key", sa.Text(), nullable=True),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False),
|
||||
sa.Column("default_calories_per_minute", sa.Numeric(8, 2), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["equipment_id"], ["logic_equipment.id"]),
|
||||
)
|
||||
op.create_index("ix_logic_exercises_is_builtin", "logic_exercises", ["is_builtin"])
|
||||
op.create_index("ix_logic_exercises_name", "logic_exercises", ["name"])
|
||||
op.create_index("ix_logic_exercises_owner_user_id", "logic_exercises", ["owner_user_id"])
|
||||
|
||||
op.create_table(
|
||||
"logic_workouts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("estimated_calories", sa.Numeric(10, 2), 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_logic_workouts_user_id", "logic_workouts", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"logic_workout_items",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("workout_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("exercise_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("equipment_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("planned_working_weight", sa.Numeric(8, 2), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
|
||||
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
|
||||
name="ck_workout_item_exactly_one_entity",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["equipment_id"], ["logic_equipment.id"]),
|
||||
sa.ForeignKeyConstraint(["exercise_id"], ["logic_exercises.id"]),
|
||||
sa.ForeignKeyConstraint(["workout_id"], ["logic_workouts.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"logic_workout_sets",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("workout_item_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("set_index", sa.Integer(), nullable=False),
|
||||
sa.Column("weight", sa.Numeric(8, 2), nullable=False),
|
||||
sa.Column("reps", sa.Integer(), nullable=False),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("calories", sa.Numeric(8, 2), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["workout_item_id"], ["logic_workout_items.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("logic_workout_sets")
|
||||
op.drop_table("logic_workout_items")
|
||||
op.drop_index("ix_logic_workouts_user_id", table_name="logic_workouts")
|
||||
op.drop_table("logic_workouts")
|
||||
op.drop_index("ix_logic_exercises_owner_user_id", table_name="logic_exercises")
|
||||
op.drop_index("ix_logic_exercises_name", table_name="logic_exercises")
|
||||
op.drop_index("ix_logic_exercises_is_builtin", table_name="logic_exercises")
|
||||
op.drop_table("logic_exercises")
|
||||
op.drop_index("ix_logic_equipment_owner_user_id", table_name="logic_equipment")
|
||||
op.drop_index("ix_logic_equipment_name", table_name="logic_equipment")
|
||||
op.drop_index("ix_logic_equipment_is_builtin", table_name="logic_equipment")
|
||||
op.drop_table("logic_equipment")
|
||||
@@ -0,0 +1,18 @@
|
||||
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"
|
||||
service_token: str = "dev-service-token-change-me"
|
||||
|
||||
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,412 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core import settings
|
||||
from app.db import SessionLocal, create_schema, get_db
|
||||
from app.models import Base, Equipment, Exercise, Workout, WorkoutItem, WorkoutSet
|
||||
from app.schemas import (
|
||||
CaloriesRead,
|
||||
EquipmentCreate,
|
||||
EquipmentRead,
|
||||
ExerciseCreate,
|
||||
ExerciseRead,
|
||||
ProgressionPoint,
|
||||
ProgressionRead,
|
||||
WorkoutCreate,
|
||||
WorkoutItemCreate,
|
||||
WorkoutItemRead,
|
||||
WorkoutRead,
|
||||
WorkoutSetCreate,
|
||||
WorkoutSetRead,
|
||||
WorkoutUpdate,
|
||||
)
|
||||
|
||||
app = FastAPI(title="Train Watcher Logic Service", version="0.1.0")
|
||||
|
||||
|
||||
def require_service_token(x_service_token: Annotated[str | None, Header()] = None) -> None:
|
||||
if x_service_token != settings.service_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid service token",
|
||||
)
|
||||
|
||||
|
||||
def get_user_id(x_user_id: Annotated[str | None, Header()] = None) -> uuid.UUID:
|
||||
if not x_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing X-User-Id header",
|
||||
)
|
||||
try:
|
||||
return uuid.UUID(x_user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid X-User-Id",
|
||||
) from exc
|
||||
|
||||
|
||||
InternalAuth = Depends(require_service_token)
|
||||
Db = Annotated[Session, Depends(get_db)]
|
||||
CurrentUserId = Annotated[uuid.UUID, Depends(get_user_id)]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
create_schema(Base.metadata)
|
||||
with SessionLocal() as db:
|
||||
seed_builtin_catalog(db)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def seed_builtin_catalog(db: Session) -> None:
|
||||
exists = db.scalar(
|
||||
select(func.count()).select_from(Equipment).where(Equipment.is_builtin.is_(True))
|
||||
)
|
||||
if exists:
|
||||
return
|
||||
|
||||
treadmill = Equipment(
|
||||
name="Беговая дорожка",
|
||||
description="Кардио-тренажер для ходьбы и бега.",
|
||||
is_builtin=True,
|
||||
)
|
||||
smith = Equipment(
|
||||
name="Машина Смита",
|
||||
description="Силовая рама с фиксированной траекторией грифа.",
|
||||
is_builtin=True,
|
||||
)
|
||||
db.add_all([treadmill, smith])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Exercise(
|
||||
name="Жим лежа",
|
||||
description="Базовое упражнение для груди, трицепса и передней дельты.",
|
||||
is_builtin=True,
|
||||
default_calories_per_minute=6,
|
||||
),
|
||||
Exercise(
|
||||
name="Приседания",
|
||||
description="Базовое упражнение для ног и корпуса.",
|
||||
is_builtin=True,
|
||||
default_calories_per_minute=8,
|
||||
),
|
||||
Exercise(
|
||||
name="Бег",
|
||||
description="Кардио-нагрузка на беговой дорожке.",
|
||||
equipment_id=treadmill.id,
|
||||
is_builtin=True,
|
||||
default_calories_per_minute=10,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def accessible_equipment(db: Session, user_id: uuid.UUID):
|
||||
return select(Equipment).where(
|
||||
(Equipment.is_builtin.is_(True)) | (Equipment.owner_user_id == user_id)
|
||||
)
|
||||
|
||||
|
||||
def accessible_exercises(db: Session, user_id: uuid.UUID):
|
||||
return select(Exercise).where(
|
||||
(Exercise.is_builtin.is_(True)) | (Exercise.owner_user_id == user_id)
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/catalog/equipment",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=list[EquipmentRead],
|
||||
)
|
||||
def list_equipment(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Equipment]:
|
||||
statement = accessible_equipment(db, user_id).order_by(
|
||||
Equipment.is_builtin.desc(), Equipment.name
|
||||
)
|
||||
if search:
|
||||
statement = statement.where(Equipment.name.ilike(f"%{search}%"))
|
||||
return list(db.scalars(statement))
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/catalog/equipment",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=EquipmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_equipment(payload: EquipmentCreate, db: Db, user_id: CurrentUserId) -> Equipment:
|
||||
equipment = Equipment(owner_user_id=user_id, is_builtin=False, **payload.model_dump())
|
||||
db.add(equipment)
|
||||
db.commit()
|
||||
db.refresh(equipment)
|
||||
return equipment
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/catalog/exercises",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=list[ExerciseRead],
|
||||
)
|
||||
def list_exercises(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Exercise]:
|
||||
statement = accessible_exercises(db, user_id).order_by(
|
||||
Exercise.is_builtin.desc(), Exercise.name
|
||||
)
|
||||
if search:
|
||||
statement = statement.where(Exercise.name.ilike(f"%{search}%"))
|
||||
return list(db.scalars(statement))
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/catalog/exercises",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=ExerciseRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_exercise(payload: ExerciseCreate, db: Db, user_id: CurrentUserId) -> Exercise:
|
||||
if payload.equipment_id:
|
||||
equipment = db.get(Equipment, payload.equipment_id)
|
||||
if not equipment or (not equipment.is_builtin and equipment.owner_user_id != user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||
exercise = Exercise(owner_user_id=user_id, is_builtin=False, **payload.model_dump())
|
||||
db.add(exercise)
|
||||
db.commit()
|
||||
db.refresh(exercise)
|
||||
return exercise
|
||||
|
||||
|
||||
@app.get("/internal/workouts", dependencies=[InternalAuth], response_model=list[WorkoutRead])
|
||||
def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Workout)
|
||||
.where(Workout.user_id == user_id)
|
||||
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
|
||||
.order_by(Workout.started_at.desc())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/workouts",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Workout:
|
||||
workout = Workout(
|
||||
user_id=user_id,
|
||||
started_at=payload.started_at or datetime.now(UTC),
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(workout)
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@app.get("/internal/workouts/{workout_id}", dependencies=[InternalAuth], response_model=WorkoutRead)
|
||||
def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout:
|
||||
workout = db.scalar(
|
||||
select(Workout)
|
||||
.where(Workout.id == workout_id, Workout.user_id == user_id)
|
||||
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
|
||||
)
|
||||
if not workout:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
|
||||
return workout
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/internal/workouts/{workout_id}",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutRead,
|
||||
)
|
||||
def update_workout(
|
||||
workout_id: uuid.UUID, payload: WorkoutUpdate, db: Db, user_id: CurrentUserId
|
||||
) -> Workout:
|
||||
workout = get_workout(workout_id, db, user_id)
|
||||
if payload.finished_at is not None:
|
||||
workout.finished_at = payload.finished_at
|
||||
if payload.notes is not None:
|
||||
workout.notes = payload.notes
|
||||
recalculate_workout_calories(db, workout.id)
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/workouts/{workout_id}/items",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutItemRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_workout_item(
|
||||
workout_id: uuid.UUID, payload: WorkoutItemCreate, db: Db, user_id: CurrentUserId
|
||||
) -> WorkoutItem:
|
||||
workout = get_workout(workout_id, db, user_id)
|
||||
if payload.exercise_id:
|
||||
exercise = db.get(Exercise, payload.exercise_id)
|
||||
if not exercise or (not exercise.is_builtin and exercise.owner_user_id != user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Exercise not found")
|
||||
if payload.equipment_id:
|
||||
equipment = db.get(Equipment, payload.equipment_id)
|
||||
if not equipment or (not equipment.is_builtin and equipment.owner_user_id != user_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
|
||||
|
||||
next_index = payload.order_index
|
||||
if next_index is None:
|
||||
next_index = len(workout.items)
|
||||
item = WorkoutItem(
|
||||
workout_id=workout.id,
|
||||
**payload.model_dump(exclude={"order_index"}),
|
||||
order_index=next_index,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/workout-items/{item_id}/sets",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutSetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_workout_set(
|
||||
item_id: uuid.UUID,
|
||||
payload: WorkoutSetCreate,
|
||||
db: Db,
|
||||
user_id: CurrentUserId,
|
||||
) -> WorkoutSet:
|
||||
item = db.scalar(
|
||||
select(WorkoutItem)
|
||||
.join(Workout)
|
||||
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
|
||||
.options(selectinload(WorkoutItem.sets), selectinload(WorkoutItem.exercise))
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
|
||||
|
||||
calories = payload.calories
|
||||
if calories is None:
|
||||
calories = estimate_set_calories(item, payload)
|
||||
|
||||
workout_set = WorkoutSet(
|
||||
workout_item_id=item.id,
|
||||
set_index=len(item.sets) + 1,
|
||||
weight=payload.weight,
|
||||
reps=payload.reps,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
calories=calories,
|
||||
completed_at=payload.completed_at or datetime.now(UTC),
|
||||
)
|
||||
db.add(workout_set)
|
||||
db.flush()
|
||||
recalculate_workout_calories(db, item.workout_id)
|
||||
db.commit()
|
||||
db.refresh(workout_set)
|
||||
return workout_set
|
||||
|
||||
|
||||
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float:
|
||||
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
|
||||
return round(
|
||||
float(item.exercise.default_calories_per_minute) * payload.duration_seconds / 60,
|
||||
2,
|
||||
)
|
||||
return round((payload.weight * max(payload.reps, 1)) / 120, 2)
|
||||
|
||||
|
||||
def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None:
|
||||
total = db.scalar(
|
||||
select(func.coalesce(func.sum(WorkoutSet.calories), 0))
|
||||
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
|
||||
.where(WorkoutItem.workout_id == workout_id)
|
||||
)
|
||||
workout = db.get(Workout, workout_id)
|
||||
if workout:
|
||||
workout.estimated_calories = float(total or 0)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/analytics/progression",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=ProgressionRead,
|
||||
)
|
||||
def get_progression(
|
||||
db: Db,
|
||||
user_id: CurrentUserId,
|
||||
kind: str = Query(pattern="^(exercise|equipment)$"),
|
||||
entity_id: uuid.UUID | None = None,
|
||||
) -> ProgressionRead:
|
||||
statement = (
|
||||
select(Workout.started_at, WorkoutSet.weight, WorkoutSet.reps)
|
||||
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
|
||||
.join(Workout, WorkoutItem.workout_id == Workout.id)
|
||||
.where(Workout.user_id == user_id)
|
||||
.order_by(Workout.started_at.asc(), WorkoutSet.completed_at.asc())
|
||||
)
|
||||
if entity_id and kind == "exercise":
|
||||
statement = statement.where(WorkoutItem.exercise_id == entity_id)
|
||||
elif entity_id and kind == "equipment":
|
||||
statement = statement.where(WorkoutItem.equipment_id == entity_id)
|
||||
|
||||
rows = list(db.execute(statement))
|
||||
grouped: dict[str, dict[str, float]] = defaultdict(lambda: {"max_weight": 0, "volume": 0})
|
||||
weights: list[float] = []
|
||||
for started_at, weight, reps in rows:
|
||||
date_key = started_at.date().isoformat()
|
||||
numeric_weight = float(weight or 0)
|
||||
grouped[date_key]["max_weight"] = max(grouped[date_key]["max_weight"], numeric_weight)
|
||||
grouped[date_key]["volume"] += numeric_weight * int(reps or 0)
|
||||
weights.append(numeric_weight)
|
||||
|
||||
points = [
|
||||
ProgressionPoint(date=date, max_weight=values["max_weight"], volume=values["volume"])
|
||||
for date, values in sorted(grouped.items())
|
||||
]
|
||||
previous_delta = None
|
||||
if len(weights) >= 2:
|
||||
previous_delta = round(weights[-1] - weights[-2], 2)
|
||||
return ProgressionRead(
|
||||
last_weight=weights[-1] if weights else None,
|
||||
max_weight=max(weights) if weights else None,
|
||||
previous_delta=previous_delta,
|
||||
points=points,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/internal/analytics/calories", dependencies=[InternalAuth], response_model=CaloriesRead)
|
||||
def get_calories(db: Db, user_id: CurrentUserId) -> CaloriesRead:
|
||||
workouts = list(
|
||||
db.scalars(
|
||||
select(Workout).where(Workout.user_id == user_id).order_by(Workout.started_at.desc())
|
||||
)
|
||||
)
|
||||
total = sum(float(workout.estimated_calories or 0) for workout in workouts)
|
||||
return CaloriesRead(
|
||||
total_calories=round(total, 2),
|
||||
workouts=[
|
||||
{
|
||||
"id": str(workout.id),
|
||||
"date": workout.started_at.date().isoformat(),
|
||||
"calories": float(workout.estimated_calories or 0),
|
||||
}
|
||||
for workout in workouts
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def uuid_pk() -> Mapped[uuid.UUID]:
|
||||
return mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
|
||||
|
||||
class Equipment(Base, TimestampMixin):
|
||||
__tablename__ = "logic_equipment"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
|
||||
name: Mapped[str] = mapped_column(String(160), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_url: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_key: Mapped[str | None] = mapped_column(Text)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
|
||||
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="equipment")
|
||||
|
||||
|
||||
class Exercise(Base, TimestampMixin):
|
||||
__tablename__ = "logic_exercises"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
|
||||
equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id"))
|
||||
name: Mapped[str] = mapped_column(String(160), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_url: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_key: Mapped[str | None] = mapped_column(Text)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
default_calories_per_minute: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||
|
||||
equipment: Mapped[Equipment | None] = relationship()
|
||||
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="exercise")
|
||||
|
||||
|
||||
class Workout(Base, TimestampMixin):
|
||||
__tablename__ = "logic_workouts"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
estimated_calories: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
|
||||
|
||||
items: Mapped[list[WorkoutItem]] = relationship(
|
||||
back_populates="workout", cascade="all, delete-orphan", order_by="WorkoutItem.order_index"
|
||||
)
|
||||
|
||||
|
||||
class WorkoutItem(Base):
|
||||
__tablename__ = "logic_workout_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
|
||||
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
|
||||
name="ck_workout_item_exactly_one_entity",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
workout_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("logic_workouts.id", ondelete="CASCADE")
|
||||
)
|
||||
exercise_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_exercises.id"))
|
||||
equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id"))
|
||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
planned_working_weight: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
workout: Mapped[Workout] = relationship(back_populates="items")
|
||||
exercise: Mapped[Exercise | None] = relationship(back_populates="workout_items")
|
||||
equipment: Mapped[Equipment | None] = relationship(back_populates="workout_items")
|
||||
sets: Mapped[list[WorkoutSet]] = relationship(
|
||||
back_populates="workout_item", cascade="all, delete-orphan", order_by="WorkoutSet.set_index"
|
||||
)
|
||||
|
||||
|
||||
class WorkoutSet(Base):
|
||||
__tablename__ = "logic_workout_sets"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
workout_item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("logic_workout_items.id", ondelete="CASCADE")
|
||||
)
|
||||
set_index: Mapped[int] = mapped_column(Integer)
|
||||
weight: Mapped[float] = mapped_column(Numeric(8, 2), default=0)
|
||||
reps: Mapped[int] = mapped_column(Integer, default=0)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||
calories: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
workout_item: Mapped[WorkoutItem] = relationship(back_populates="sets")
|
||||
@@ -0,0 +1,122 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class EquipmentCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
image_s3_url: str | None = None
|
||||
image_s3_key: str | None = None
|
||||
|
||||
|
||||
class EquipmentRead(EquipmentCreate):
|
||||
id: uuid.UUID
|
||||
owner_user_id: uuid.UUID | None
|
||||
is_builtin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ExerciseCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
equipment_id: uuid.UUID | None = None
|
||||
image_s3_url: str | None = None
|
||||
image_s3_key: str | None = None
|
||||
default_calories_per_minute: float | None = None
|
||||
|
||||
|
||||
class ExerciseRead(ExerciseCreate):
|
||||
id: uuid.UUID
|
||||
owner_user_id: uuid.UUID | None
|
||||
is_builtin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkoutCreate(BaseModel):
|
||||
started_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WorkoutUpdate(BaseModel):
|
||||
finished_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WorkoutSetCreate(BaseModel):
|
||||
weight: float = 0
|
||||
reps: int = 0
|
||||
duration_seconds: int | None = None
|
||||
calories: float | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class WorkoutSetRead(WorkoutSetCreate):
|
||||
id: uuid.UUID
|
||||
workout_item_id: uuid.UUID
|
||||
set_index: int
|
||||
completed_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkoutItemCreate(BaseModel):
|
||||
exercise_id: uuid.UUID | None = None
|
||||
equipment_id: uuid.UUID | None = None
|
||||
order_index: int | None = None
|
||||
planned_working_weight: float | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one_entity(self) -> WorkoutItemCreate:
|
||||
if bool(self.exercise_id) == bool(self.equipment_id):
|
||||
raise ValueError("Provide exactly one of exercise_id or equipment_id")
|
||||
return self
|
||||
|
||||
|
||||
class WorkoutItemRead(WorkoutItemCreate):
|
||||
id: uuid.UUID
|
||||
workout_id: uuid.UUID
|
||||
order_index: int
|
||||
created_at: datetime
|
||||
sets: list[WorkoutSetRead] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkoutRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
notes: str | None
|
||||
estimated_calories: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: list[WorkoutItemRead] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProgressionPoint(BaseModel):
|
||||
date: str
|
||||
max_weight: float
|
||||
volume: float
|
||||
|
||||
|
||||
class ProgressionRead(BaseModel):
|
||||
last_weight: float | None
|
||||
max_weight: float | None
|
||||
previous_delta: float | None
|
||||
points: list[ProgressionPoint]
|
||||
|
||||
|
||||
class CaloriesRead(BaseModel):
|
||||
total_calories: float
|
||||
workouts: list[dict[str, str | float]]
|
||||
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "train-watcher-logic"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"alembic>=1.16.0",
|
||||
"fastapi[standard]>=0.115.12",
|
||||
"psycopg[binary]>=3.2.9",
|
||||
"pydantic-settings>=2.9.1",
|
||||
"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
+1001
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user