Back: Add edit training API
This commit is contained in:
parent
266aa84e87
commit
dc8cb10eed
8 changed files with 234 additions and 23 deletions
|
|
@ -58,6 +58,7 @@ async def exercise_progress(
|
||||||
primary_change_percent=progress.primary_change_percent,
|
primary_change_percent=progress.primary_change_percent,
|
||||||
rows=[
|
rows=[
|
||||||
SessionRowResponse(
|
SessionRowResponse(
|
||||||
|
training_id=row.session.training_id,
|
||||||
date=row.session.date,
|
date=row.session.date,
|
||||||
trainer=row.session.trainer,
|
trainer=row.session.trainer,
|
||||||
splitted_weight=row.session.splitted_weight,
|
splitted_weight=row.session.splitted_weight,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from app.api.deps import get_training_repository
|
from app.api.deps import get_training_repository
|
||||||
from app.core.database.repository import TrainingRepository
|
from app.core.database.repository import TrainingRepository
|
||||||
from app.core.dto.training import TrainingDTO
|
from app.core.dto.training import TrainingDTO
|
||||||
|
from app.schemas.trainings import TrainingUpdate
|
||||||
from app.core.parsers.obsidian import parse_training_data
|
from app.core.parsers.obsidian import parse_training_data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,6 +24,30 @@ async def get_trainings_list(
|
||||||
return await repository.list()
|
return await repository.list()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{training_id}/", response_model=TrainingDTO)
|
||||||
|
async def get_training(
|
||||||
|
training_id: int,
|
||||||
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
|
):
|
||||||
|
training = await repository.get(training_id)
|
||||||
|
if training is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Training not found")
|
||||||
|
return training
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{training_id}/", response_model=TrainingDTO)
|
||||||
|
async def update_training(
|
||||||
|
training_id: int,
|
||||||
|
payload: TrainingUpdate,
|
||||||
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
|
):
|
||||||
|
"""Replace the training: its date, trainer, exercises and approaches."""
|
||||||
|
training = TrainingDTO.model_validate(payload.model_dump())
|
||||||
|
if not await repository.replace(training_id, training):
|
||||||
|
raise HTTPException(status_code=404, detail="Training not found")
|
||||||
|
return await repository.get(training_id)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{training_id}/")
|
@router.delete("/{training_id}/")
|
||||||
async def delete_training(
|
async def delete_training(
|
||||||
training_id: int,
|
training_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict, List, Optional, Sequence
|
from typing import Dict, List, Optional, Sequence
|
||||||
|
|
||||||
from sqlalchemy import delete, func, insert, select, text
|
from sqlalchemy import delete, func, insert, select, text, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database.tables import approaches, exercises, trainings
|
from app.core.database.tables import approaches, exercises, trainings
|
||||||
|
|
@ -32,31 +32,65 @@ class TrainingRepository:
|
||||||
[{"date": item.date, "trainer": item.trainer} for item in items],
|
[{"date": item.date, "trainer": item.trainer} for item in items],
|
||||||
)
|
)
|
||||||
|
|
||||||
exercise_rows = []
|
await self._insert_exercises(training_ids, items)
|
||||||
exercise_dtos = []
|
|
||||||
for training_id, item in zip(training_ids, items):
|
|
||||||
for exercise in item.exercises:
|
|
||||||
exercise_rows.append(
|
|
||||||
{
|
|
||||||
"training_id": training_id,
|
|
||||||
"name": exercise.name,
|
|
||||||
"splitted_weight": exercise.splitted_weight,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
exercise_dtos.append(exercise)
|
|
||||||
if exercise_rows:
|
|
||||||
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
|
|
||||||
approach_rows = [
|
|
||||||
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
|
|
||||||
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
|
|
||||||
for a in exercise.approaches
|
|
||||||
]
|
|
||||||
if approach_rows:
|
|
||||||
await self._session.execute(insert(approaches), approach_rows)
|
|
||||||
|
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
return training_ids
|
return training_ids
|
||||||
|
|
||||||
|
async def get(self, training_id: int) -> Optional[TrainingDTO]:
|
||||||
|
row = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(trainings).where(trainings.c.id == training_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
exercise_rows = (
|
||||||
|
await self._session.execute(
|
||||||
|
select(exercises)
|
||||||
|
.where(exercises.c.training_id == training_id)
|
||||||
|
.order_by(exercises.c.id)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
approaches_by_exercise = await self._approaches_by_exercise(
|
||||||
|
[exercise.id for exercise in exercise_rows]
|
||||||
|
)
|
||||||
|
return TrainingDTO(
|
||||||
|
id=row.id,
|
||||||
|
date=row.date,
|
||||||
|
trainer=row.trainer,
|
||||||
|
exercises=[
|
||||||
|
ExerciseDTO(
|
||||||
|
id=exercise.id,
|
||||||
|
training_id=exercise.training_id,
|
||||||
|
name=exercise.name,
|
||||||
|
splitted_weight=exercise.splitted_weight,
|
||||||
|
approaches=approaches_by_exercise[exercise.id],
|
||||||
|
)
|
||||||
|
for exercise in exercise_rows
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def replace(self, training_id: int, training: TrainingDTO) -> bool:
|
||||||
|
"""Overwrite the training with its exercises and approaches, False if it doesn't exist.
|
||||||
|
|
||||||
|
Old exercises are deleted and the new ones inserted in the same transaction.
|
||||||
|
"""
|
||||||
|
result = await self._session.execute(
|
||||||
|
update(trainings)
|
||||||
|
.where(trainings.c.id == training_id)
|
||||||
|
.values(date=training.date, trainer=training.trainer)
|
||||||
|
)
|
||||||
|
if not result.rowcount: # type: ignore[attr-defined]
|
||||||
|
await self._session.rollback()
|
||||||
|
return False
|
||||||
|
await self._session.execute(
|
||||||
|
delete(exercises).where(exercises.c.training_id == training_id)
|
||||||
|
)
|
||||||
|
await self._insert_exercises([training_id], [training])
|
||||||
|
await self._session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
async def list(self) -> List[TrainingDTO]:
|
async def list(self) -> List[TrainingDTO]:
|
||||||
training_rows = (
|
training_rows = (
|
||||||
await self._session.execute(
|
await self._session.execute(
|
||||||
|
|
@ -104,6 +138,7 @@ class TrainingRepository:
|
||||||
select(
|
select(
|
||||||
exercises.c.id,
|
exercises.c.id,
|
||||||
exercises.c.splitted_weight,
|
exercises.c.splitted_weight,
|
||||||
|
trainings.c.id.label("training_id"),
|
||||||
trainings.c.date,
|
trainings.c.date,
|
||||||
trainings.c.trainer,
|
trainings.c.trainer,
|
||||||
)
|
)
|
||||||
|
|
@ -117,6 +152,7 @@ class TrainingRepository:
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
ExerciseSessionDTO(
|
ExerciseSessionDTO(
|
||||||
|
training_id=row.training_id,
|
||||||
date=row.date,
|
date=row.date,
|
||||||
trainer=row.trainer,
|
trainer=row.trainer,
|
||||||
splitted_weight=row.splitted_weight,
|
splitted_weight=row.splitted_weight,
|
||||||
|
|
@ -142,6 +178,33 @@ class TrainingRepository:
|
||||||
)
|
)
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
|
|
||||||
|
async def _insert_exercises(
|
||||||
|
self, training_ids: Sequence[int], items: Sequence[TrainingDTO]
|
||||||
|
) -> None:
|
||||||
|
"""Insert exercises and approaches of `items` (training_ids[i] belongs to items[i])."""
|
||||||
|
exercise_rows = []
|
||||||
|
exercise_dtos = []
|
||||||
|
for training_id, item in zip(training_ids, items):
|
||||||
|
for exercise in item.exercises:
|
||||||
|
exercise_rows.append(
|
||||||
|
{
|
||||||
|
"training_id": training_id,
|
||||||
|
"name": exercise.name,
|
||||||
|
"splitted_weight": exercise.splitted_weight,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
exercise_dtos.append(exercise)
|
||||||
|
if not exercise_rows:
|
||||||
|
return
|
||||||
|
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
|
||||||
|
approach_rows = [
|
||||||
|
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
|
||||||
|
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
|
||||||
|
for a in exercise.approaches
|
||||||
|
]
|
||||||
|
if approach_rows:
|
||||||
|
await self._session.execute(insert(approaches), approach_rows)
|
||||||
|
|
||||||
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
|
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
|
||||||
"""Bulk insert, ids come back in the order of `rows`."""
|
"""Bulk insert, ids come back in the order of `rows`."""
|
||||||
result = await self._session.execute(
|
result = await self._session.execute(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class ExerciseNameDTO(DTOBase):
|
||||||
class ExerciseSessionDTO(DTOBase):
|
class ExerciseSessionDTO(DTOBase):
|
||||||
"""One occurrence of an exercise in the history of that exercise."""
|
"""One occurrence of an exercise in the history of that exercise."""
|
||||||
|
|
||||||
|
training_id: int | None = None
|
||||||
date: date
|
date: date
|
||||||
trainer: str | None = None
|
trainer: str | None = None
|
||||||
splitted_weight: bool = False
|
splitted_weight: bool = False
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class SessionMetricsResponse(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SessionRowResponse(BaseModel):
|
class SessionRowResponse(BaseModel):
|
||||||
|
training_id: Optional[int]
|
||||||
date: date
|
date: date
|
||||||
trainer: Optional[str]
|
trainer: Optional[str]
|
||||||
splitted_weight: bool
|
splitted_weight: bool
|
||||||
|
|
|
||||||
36
backend/app/schemas/trainings.py
Normal file
36
backend/app/schemas/trainings.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ApproachUpdate(BaseModel):
|
||||||
|
weight: float = Field(ge=0)
|
||||||
|
reps: int = Field(gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseUpdate(BaseModel):
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
splitted_weight: bool = False
|
||||||
|
approaches: List[ApproachUpdate] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def strip_name(cls, value: str) -> str:
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("name must not be empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingUpdate(BaseModel):
|
||||||
|
"""The whole training as it should be after the edit (PUT replaces it)."""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
trainer: Optional[str] = Field(default=None, max_length=100)
|
||||||
|
exercises: List[ExerciseUpdate] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@field_validator("trainer")
|
||||||
|
@classmethod
|
||||||
|
def blank_trainer_is_none(cls, value: Optional[str]) -> Optional[str]:
|
||||||
|
return value.strip() or None if value else None
|
||||||
|
|
@ -95,11 +95,12 @@ async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercise
|
||||||
session,
|
session,
|
||||||
):
|
):
|
||||||
repository = TrainingRepository(session)
|
repository = TrainingRepository(session)
|
||||||
await repository.save(make_training())
|
training_id = await repository.save(make_training())
|
||||||
|
|
||||||
history = await repository.exercise_history("Жим гантелей лежа")
|
history = await repository.exercise_history("Жим гантелей лежа")
|
||||||
|
|
||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
assert history[0].training_id == training_id
|
||||||
assert history[0].splitted_weight is True
|
assert history[0].splitted_weight is True
|
||||||
assert [a.weight for a in history[0].approaches] == [20]
|
assert [a.weight for a in history[0].approaches] == [20]
|
||||||
assert await repository.exercise_history("Несуществующее") == []
|
assert await repository.exercise_history("Несуществующее") == []
|
||||||
|
|
@ -118,6 +119,50 @@ async def test_delete_cascades_to_exercises_and_approaches(session):
|
||||||
assert await session.scalar(select(func.count(approaches.c.id))) == 3
|
assert await session.scalar(select(func.count(approaches.c.id))) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_returns_training_or_none(session):
|
||||||
|
repository = TrainingRepository(session)
|
||||||
|
training_id = await repository.save(make_training())
|
||||||
|
|
||||||
|
training = await repository.get(training_id)
|
||||||
|
|
||||||
|
assert training is not None
|
||||||
|
assert [e.name for e in training.exercises] == ["Жим ногами", "Жим гантелей лежа"]
|
||||||
|
assert await repository.get(training_id + 1) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_replace_overwrites_training_exercises_and_approaches(session):
|
||||||
|
repository = TrainingRepository(session)
|
||||||
|
training_id = await repository.save(make_training(day=1))
|
||||||
|
other_id = await repository.save(make_training(day=2))
|
||||||
|
|
||||||
|
updated = TrainingDTO(
|
||||||
|
date=date(2025, 2, 3),
|
||||||
|
trainer=None,
|
||||||
|
exercises=[
|
||||||
|
ExerciseDTO(
|
||||||
|
name="Тяга",
|
||||||
|
splitted_weight=True,
|
||||||
|
approaches=[ApproachDTO(weight=30, reps=8)],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert await repository.replace(training_id, updated) is True
|
||||||
|
|
||||||
|
training = await repository.get(training_id)
|
||||||
|
assert (training.date, training.trainer) == (date(2025, 2, 3), None)
|
||||||
|
assert [e.name for e in training.exercises] == ["Тяга"]
|
||||||
|
assert training.exercises[0].splitted_weight is True
|
||||||
|
assert [(a.weight, a.reps) for a in training.exercises[0].approaches] == [(30, 8)]
|
||||||
|
# the other training is untouched; no orphans left from the replaced one
|
||||||
|
assert len((await repository.get(other_id)).exercises) == 2
|
||||||
|
assert await session.scalar(select(func.count(exercises.c.id))) == 3
|
||||||
|
assert await session.scalar(select(func.count(approaches.c.id))) == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_replace_missing_training_returns_false(session):
|
||||||
|
assert await TrainingRepository(session).replace(999, make_training()) is False
|
||||||
|
|
||||||
|
|
||||||
async def test_save_many_is_atomic(session):
|
async def test_save_many_is_atomic(session):
|
||||||
repository = TrainingRepository(session)
|
repository = TrainingRepository(session)
|
||||||
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
|
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
|
||||||
|
|
|
||||||
39
backend/tests/test_core/test_training_schemas.py
Normal file
39
backend/tests/test_core/test_training_schemas.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.schemas.trainings import TrainingUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def payload(**overrides):
|
||||||
|
data = {
|
||||||
|
"date": "2026-10-21",
|
||||||
|
"trainer": " solo ",
|
||||||
|
"exercises": [
|
||||||
|
{"name": " Жим ", "approaches": [{"weight": 0, "reps": 12}]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_name_and_trainer():
|
||||||
|
training = TrainingUpdate.model_validate(payload())
|
||||||
|
assert training.trainer == "solo"
|
||||||
|
assert training.exercises[0].name == "Жим"
|
||||||
|
|
||||||
|
|
||||||
|
def test_blank_trainer_becomes_none():
|
||||||
|
assert TrainingUpdate.model_validate(payload(trainer=" ")).trainer is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"exercises",
|
||||||
|
[
|
||||||
|
[{"name": " ", "approaches": []}],
|
||||||
|
[{"name": "Жим", "approaches": [{"weight": -1, "reps": 5}]}],
|
||||||
|
[{"name": "Жим", "approaches": [{"weight": 10, "reps": 0}]}],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_rejects_invalid_exercises(exercises):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TrainingUpdate.model_validate(payload(exercises=exercises))
|
||||||
Loading…
Reference in a new issue