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,
|
||||
rows=[
|
||||
SessionRowResponse(
|
||||
training_id=row.session.training_id,
|
||||
date=row.session.date,
|
||||
trainer=row.session.trainer,
|
||||
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.core.database.repository import TrainingRepository
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.schemas.trainings import TrainingUpdate
|
||||
from app.core.parsers.obsidian import parse_training_data
|
||||
|
||||
|
||||
|
|
@ -23,6 +24,30 @@ async def get_trainings_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}/")
|
||||
async def delete_training(
|
||||
training_id: int,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from collections import defaultdict
|
||||
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 app.core.database.tables import approaches, exercises, trainings
|
||||
|
|
@ -32,31 +32,65 @@ class TrainingRepository:
|
|||
[{"date": item.date, "trainer": item.trainer} for item in items],
|
||||
)
|
||||
|
||||
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 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._insert_exercises(training_ids, items)
|
||||
|
||||
await self._session.commit()
|
||||
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]:
|
||||
training_rows = (
|
||||
await self._session.execute(
|
||||
|
|
@ -104,6 +138,7 @@ class TrainingRepository:
|
|||
select(
|
||||
exercises.c.id,
|
||||
exercises.c.splitted_weight,
|
||||
trainings.c.id.label("training_id"),
|
||||
trainings.c.date,
|
||||
trainings.c.trainer,
|
||||
)
|
||||
|
|
@ -117,6 +152,7 @@ class TrainingRepository:
|
|||
)
|
||||
return [
|
||||
ExerciseSessionDTO(
|
||||
training_id=row.training_id,
|
||||
date=row.date,
|
||||
trainer=row.trainer,
|
||||
splitted_weight=row.splitted_weight,
|
||||
|
|
@ -142,6 +178,33 @@ class TrainingRepository:
|
|||
)
|
||||
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]:
|
||||
"""Bulk insert, ids come back in the order of `rows`."""
|
||||
result = await self._session.execute(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class ExerciseNameDTO(DTOBase):
|
|||
class ExerciseSessionDTO(DTOBase):
|
||||
"""One occurrence of an exercise in the history of that exercise."""
|
||||
|
||||
training_id: int | None = None
|
||||
date: date
|
||||
trainer: str | None = None
|
||||
splitted_weight: bool = False
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class SessionMetricsResponse(BaseModel):
|
|||
|
||||
|
||||
class SessionRowResponse(BaseModel):
|
||||
training_id: Optional[int]
|
||||
date: date
|
||||
trainer: Optional[str]
|
||||
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,
|
||||
):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save(make_training())
|
||||
training_id = await repository.save(make_training())
|
||||
|
||||
history = await repository.exercise_history("Жим гантелей лежа")
|
||||
|
||||
assert len(history) == 1
|
||||
assert history[0].training_id == training_id
|
||||
assert history[0].splitted_weight is True
|
||||
assert [a.weight for a in history[0].approaches] == [20]
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
repository = TrainingRepository(session)
|
||||
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