Back: Add edit training API

This commit is contained in:
t0xa 2026-10-23 20:21:48 +03:00
parent 266aa84e87
commit dc8cb10eed
8 changed files with 234 additions and 23 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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

View 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

View file

@ -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)

View 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))