From dc8cb10eedb6dea43090e497419efdb3773eee0c Mon Sep 17 00:00:00 2001 From: t0xa Date: Fri, 23 Oct 2026 20:21:48 +0300 Subject: [PATCH] Back: Add edit training API --- backend/app/api/v1/exercises.py | 1 + backend/app/api/v1/trainings.py | 25 ++++ backend/app/core/database/repository.py | 107 ++++++++++++++---- backend/app/core/dto/training.py | 1 + backend/app/schemas/exercises.py | 1 + backend/app/schemas/trainings.py | 36 ++++++ backend/tests/test_core/test_repository.py | 47 +++++++- .../tests/test_core/test_training_schemas.py | 39 +++++++ 8 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 backend/app/schemas/trainings.py create mode 100644 backend/tests/test_core/test_training_schemas.py diff --git a/backend/app/api/v1/exercises.py b/backend/app/api/v1/exercises.py index 1a81bd2..7fdbee9 100644 --- a/backend/app/api/v1/exercises.py +++ b/backend/app/api/v1/exercises.py @@ -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, diff --git a/backend/app/api/v1/trainings.py b/backend/app/api/v1/trainings.py index 2f0b956..a4ee575 100644 --- a/backend/app/api/v1/trainings.py +++ b/backend/app/api/v1/trainings.py @@ -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, diff --git a/backend/app/core/database/repository.py b/backend/app/core/database/repository.py index 38dd4dd..185dc0c 100644 --- a/backend/app/core/database/repository.py +++ b/backend/app/core/database/repository.py @@ -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( diff --git a/backend/app/core/dto/training.py b/backend/app/core/dto/training.py index c9e10e8..b0cc135 100644 --- a/backend/app/core/dto/training.py +++ b/backend/app/core/dto/training.py @@ -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 diff --git a/backend/app/schemas/exercises.py b/backend/app/schemas/exercises.py index a9c39ab..6bd4688 100644 --- a/backend/app/schemas/exercises.py +++ b/backend/app/schemas/exercises.py @@ -15,6 +15,7 @@ class SessionMetricsResponse(BaseModel): class SessionRowResponse(BaseModel): + training_id: Optional[int] date: date trainer: Optional[str] splitted_weight: bool diff --git a/backend/app/schemas/trainings.py b/backend/app/schemas/trainings.py new file mode 100644 index 0000000..36fda02 --- /dev/null +++ b/backend/app/schemas/trainings.py @@ -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 diff --git a/backend/tests/test_core/test_repository.py b/backend/tests/test_core/test_repository.py index 122fffc..f7d47ba 100644 --- a/backend/tests/test_core/test_repository.py +++ b/backend/tests/test_core/test_repository.py @@ -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) diff --git a/backend/tests/test_core/test_training_schemas.py b/backend/tests/test_core/test_training_schemas.py new file mode 100644 index 0000000..94360df --- /dev/null +++ b/backend/tests/test_core/test_training_schemas.py @@ -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))