diff --git a/CLAUDE.md b/CLAUDE.md index aa58219..6072b45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,8 @@ docker compose exec fastapi-app mypy app/ ### Database +Trainings belong to a trainee (`trainings.trainee_id`, required) and optionally have a trainer (`trainings.trainer_id`, NULL = solo); both are users (role `trainee` / `trainer`). A user with trainings can't be deleted. `python -m app.cli import` takes the trainer from the note header and matches it with a trainer user by "First Last". + The backend uses PostgreSQL through async SQLAlchemy (asyncpg). tables are described with SQLAlchemy Core (no ORM) in `backend/app/core/database/tables.py`, `TrainingRepository` (`backend/app/core/database/repository.py`) is the data access layer, and a session is injected into endpoints with `Depends(get_session)`. Schema migrations are handled by Alembic (`alembic.ini`, `migrations/`); the URL is taken from `app/config.py`. Configuration is managed through `backend/app/config.py` using Pydantic settings with `.env` file support. diff --git a/backend/app/api/v1/trainings.py b/backend/app/api/v1/trainings.py index a4ee575..e1f8430 100644 --- a/backend/app/api/v1/trainings.py +++ b/backend/app/api/v1/trainings.py @@ -2,9 +2,11 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException -from app.api.deps import get_training_repository +from app.api.deps import get_training_repository, get_user_repository from app.core.database.repository import TrainingRepository +from app.core.database.user_repository import UserRepository from app.core.dto.training import TrainingDTO +from app.core.dto.user import UserRole from app.schemas.trainings import TrainingUpdate from app.core.parsers.obsidian import parse_training_data @@ -24,6 +26,16 @@ async def get_trainings_list( return await repository.list() +async def _check_participant( + repository: UserRepository, user_id: int, role: UserRole +) -> None: + user = await repository.get(user_id) + if user is None or user.role != role: + raise HTTPException( + status_code=422, detail=f"User {user_id} is not an existing {role.value}" + ) + + @router.get("/{training_id}/", response_model=TrainingDTO) async def get_training( training_id: int, @@ -40,8 +52,12 @@ async def update_training( training_id: int, payload: TrainingUpdate, repository: TrainingRepository = Depends(get_training_repository), + user_repository: UserRepository = Depends(get_user_repository), ): - """Replace the training: its date, trainer, exercises and approaches.""" + """Replace the training: its date, trainee, trainer, exercises and approaches.""" + await _check_participant(user_repository, payload.trainee_id, UserRole.trainee) + if payload.trainer_id is not None: + await _check_participant(user_repository, payload.trainer_id, UserRole.trainer) training = TrainingDTO.model_validate(payload.model_dump()) if not await repository.replace(training_id, training): raise HTTPException(status_code=404, detail="Training not found") diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 28ed61d..495ff10 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -3,7 +3,11 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status from app.api.deps import get_user_repository -from app.core.database.user_repository import EmailTakenError, UserRepository +from app.core.database.user_repository import ( + EmailTakenError, + UserInUseError, + UserRepository, +) from app.core.dto.user import UserDTO, UserRole from app.core.security import hash_password from app.schemas.users import UserCreate, UserUpdate @@ -75,6 +79,10 @@ async def delete_user( user_id: int, repository: UserRepository = Depends(get_user_repository), ): - if not await repository.delete(user_id): + try: + deleted = await repository.delete(user_id) + except UserInUseError: + raise HTTPException(status_code=409, detail="User has trainings") + if not deleted: raise HTTPException(status_code=404, detail="User not found") return {"status": "ok"} diff --git a/backend/app/cli.py b/backend/app/cli.py index 09d8354..c0fd861 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -1,23 +1,66 @@ """Command line helpers. - python -m app.cli import [--replace] + python -m app.cli import [--replace] [--trainee-email EMAIL] -Imports all trainings from the Obsidian and Apple notes into the database. -The schema must already exist (`alembic upgrade head`). +Imports all trainings from the Obsidian and Apple notes into the database as trainings of one +trainee. The trainer from a note header is matched with a user of the trainer role by +"First Last" ("соло" means no trainer). The schema must already exist (`alembic upgrade head`). """ import argparse import asyncio import logging +from typing import List, Optional, Set from app.config import settings from app.core.database.connection import create_engine, create_session_factory from app.core.database.repository import TrainingRepository +from app.core.database.user_repository import UserRepository +from app.core.dto.training import TrainingDTO +from app.core.dto.user import UserRole from app.core.parsers.apple import AppleNotesParser from app.core.parsers.obsidian import ObsidianNotesParser -async def import_notes(replace: bool) -> None: +SOLO_NAMES = {"solo", "соло"} + + +async def assign_participants( + session, trainings: List[TrainingDTO], trainee_email: Optional[str] +) -> None: + """Set trainee_id and trainer_id on the parsed trainings.""" + users = await UserRepository(session).list() + trainees = [user for user in users if user.role == UserRole.trainee] + if trainee_email: + matching = [u for u in trainees if u.email == trainee_email.strip().lower()] + if not matching: + raise SystemExit(f"No trainee with email {trainee_email}") + trainee = matching[0] + elif len(trainees) == 1: + trainee = trainees[0] + else: + raise SystemExit("There is more than one trainee, pass --trainee-email") + + trainer_ids = { + f"{user.first_name} {user.last_name}".strip().casefold(): user.id + for user in users + if user.role == UserRole.trainer + } + unknown: Set[str] = set() + for training in trainings: + training.trainee_id = trainee.id + name = (training.trainer or "").strip().casefold() + if name in SOLO_NAMES or not name: + training.trainer_id = None + elif name in trainer_ids: + training.trainer_id = trainer_ids[name] + else: + unknown.add(training.trainer or "") + if unknown: + raise SystemExit(f"No trainer users for: {', '.join(sorted(unknown))}") + + +async def import_notes(replace: bool, trainee_email: Optional[str]) -> None: parsers = [ObsidianNotesParser(), AppleNotesParser()] trainings = [] for parser in parsers: @@ -32,6 +75,7 @@ async def import_notes(replace: bool) -> None: try: async with create_session_factory(engine)() as session: repository = TrainingRepository(session) + await assign_participants(session, trainings, trainee_email) existing = await repository.count() if existing and not replace: raise SystemExit( @@ -54,10 +98,14 @@ def main() -> None: import_parser.add_argument( "--replace", action="store_true", help="delete existing trainings first" ) + import_parser.add_argument( + "--trainee-email", + help="whose trainings these are (not needed when there is a single trainee)", + ) args = arg_parser.parse_args() if args.command == "import": - asyncio.run(import_notes(replace=args.replace)) + asyncio.run(import_notes(args.replace, args.trainee_email)) if __name__ == "__main__": diff --git a/backend/app/core/database/repository.py b/backend/app/core/database/repository.py index 185dc0c..cc73f79 100644 --- a/backend/app/core/database/repository.py +++ b/backend/app/core/database/repository.py @@ -4,7 +4,7 @@ from typing import Dict, List, Optional, Sequence from sqlalchemy import delete, func, insert, select, text, update from sqlalchemy.ext.asyncio import AsyncSession -from app.core.database.tables import approaches, exercises, trainings +from app.core.database.tables import approaches, exercises, trainings, users from app.core.dto.training import ( ApproachDTO, ExerciseDTO, @@ -13,6 +13,20 @@ from app.core.dto.training import ( TrainingDTO, ) +_trainer = users.alias("trainer") +# "First Last", the email if the name is empty +_trainer_name = func.coalesce( + func.nullif(func.trim(_trainer.c.first_name + " " + _trainer.c.last_name), ""), + _trainer.c.email, +).label("trainer") + + +def _trainings_query(): + """Trainings with the trainer's name (a solo training has no trainer).""" + return select(trainings, _trainer_name).outerjoin( + _trainer, _trainer.c.id == trainings.c.trainer_id + ) + class TrainingRepository: """Storage for trainings together with their exercises and approaches (SQLAlchemy Core).""" @@ -27,9 +41,18 @@ class TrainingRepository: """Save all trainings in a single transaction, returns their ids.""" if not items: return [] + if any(item.trainee_id is None for item in items): + raise ValueError("trainee_id is required to save a training") training_ids = await self._insert_returning_ids( trainings, - [{"date": item.date, "trainer": item.trainer} for item in items], + [ + { + "date": item.date, + "trainee_id": item.trainee_id, + "trainer_id": item.trainer_id, + } + for item in items + ], ) await self._insert_exercises(training_ids, items) @@ -40,7 +63,7 @@ class TrainingRepository: async def get(self, training_id: int) -> Optional[TrainingDTO]: row = ( await self._session.execute( - select(trainings).where(trainings.c.id == training_id) + _trainings_query().where(trainings.c.id == training_id) ) ).first() if row is None: @@ -58,6 +81,8 @@ class TrainingRepository: return TrainingDTO( id=row.id, date=row.date, + trainee_id=row.trainee_id, + trainer_id=row.trainer_id, trainer=row.trainer, exercises=[ ExerciseDTO( @@ -79,7 +104,11 @@ class TrainingRepository: result = await self._session.execute( update(trainings) .where(trainings.c.id == training_id) - .values(date=training.date, trainer=training.trainer) + .values( + date=training.date, + trainee_id=training.trainee_id, + trainer_id=training.trainer_id, + ) ) if not result.rowcount: # type: ignore[attr-defined] await self._session.rollback() @@ -94,7 +123,9 @@ class TrainingRepository: async def list(self) -> List[TrainingDTO]: training_rows = ( await self._session.execute( - select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc()) + _trainings_query().order_by( + trainings.c.date.desc(), trainings.c.id.desc() + ) ) ).all() approaches_by_exercise = await self._approaches_by_exercise() @@ -116,6 +147,8 @@ class TrainingRepository: TrainingDTO( id=row.id, date=row.date, + trainee_id=row.trainee_id, + trainer_id=row.trainer_id, trainer=row.trainer, exercises=exercises_by_training[row.id], ) @@ -140,9 +173,10 @@ class TrainingRepository: exercises.c.splitted_weight, trainings.c.id.label("training_id"), trainings.c.date, - trainings.c.trainer, + _trainer_name, ) .join(trainings, trainings.c.id == exercises.c.training_id) + .outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id) .where(exercises.c.name == name) .order_by(trainings.c.date.desc(), exercises.c.id.desc()) ) diff --git a/backend/app/core/database/tables.py b/backend/app/core/database/tables.py index bcebf33..dbac926 100644 --- a/backend/app/core/database/tables.py +++ b/backend/app/core/database/tables.py @@ -22,7 +22,10 @@ trainings = Table( metadata, Column("id", Integer, primary_key=True), Column("date", Date, nullable=False, index=True), - Column("trainer", String(100)), + # RESTRICT: a user who has trainings can't be deleted by accident + Column("trainee_id", ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True), + # No trainer means a solo training + Column("trainer_id", ForeignKey("users.id", ondelete="RESTRICT"), index=True), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), ) diff --git a/backend/app/core/database/user_repository.py b/backend/app/core/database/user_repository.py index 9706540..8d8ea45 100644 --- a/backend/app/core/database/user_repository.py +++ b/backend/app/core/database/user_repository.py @@ -12,6 +12,10 @@ class EmailTakenError(Exception): """A user with this email already exists.""" +class UserInUseError(Exception): + """The user is referenced by trainings.""" + + class UserRepository: def __init__(self, session: AsyncSession) -> None: self._session = session @@ -76,6 +80,12 @@ class UserRepository: return UserDTO.model_validate(row) if row else None async def delete(self, user_id: int) -> bool: - result = await self._session.execute(delete(users).where(users.c.id == user_id)) - await self._session.commit() + try: + result = await self._session.execute( + delete(users).where(users.c.id == user_id) + ) + await self._session.commit() + except IntegrityError as error: + await self._session.rollback() + raise UserInUseError(user_id) from error return bool(result.rowcount) # type: ignore[attr-defined] diff --git a/backend/app/core/dto/training.py b/backend/app/core/dto/training.py index b0cc135..9580c95 100644 --- a/backend/app/core/dto/training.py +++ b/backend/app/core/dto/training.py @@ -25,7 +25,9 @@ class ExerciseDTO(DTOBase): class TrainingDTO(DTOBase): id: int | None = None date: date - trainer: str | None = None + trainee_id: int | None = None # None only for trainings that are not saved yet + trainer_id: int | None = None # None: a solo training + trainer: str | None = None # the trainer's name, filled in when read from the DB exercises: list[ExerciseDTO] = Field(default_factory=list) diff --git a/backend/app/schemas/trainings.py b/backend/app/schemas/trainings.py index 36fda02..d103129 100644 --- a/backend/app/schemas/trainings.py +++ b/backend/app/schemas/trainings.py @@ -27,10 +27,6 @@ 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) + trainee_id: int + trainer_id: Optional[int] = None # None: a solo training 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/migrations/versions/2026_10_23_1900-b1c2d3e4f5a6_link_trainings_to_users.py b/backend/migrations/versions/2026_10_23_1900-b1c2d3e4f5a6_link_trainings_to_users.py new file mode 100644 index 0000000..4e9092f --- /dev/null +++ b/backend/migrations/versions/2026_10_23_1900-b1c2d3e4f5a6_link_trainings_to_users.py @@ -0,0 +1,82 @@ +"""link trainings to users + +Every existing training gets a trainee, trainings with a trainer (anything but solo) get the +trainer; the free-text trainer column is dropped. + +Revision ID: b1c2d3e4f5a6 +Revises: 97535a10551d +Create Date: 2026-10-23 19:00:00.000000 + +""" +import secrets +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from app.core.security import hash_password + + +# revision identifiers, used by Alembic. +revision: str = 'b1c2d3e4f5a6' +down_revision: Union[str, Sequence[str], None] = '97535a10551d' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +TRAINEE = {"email": "pro100ton@gmail.com", "first_name": "Антон", "last_name": "Шалимов", "role": "trainee"} +TRAINER = {"email": "panstepan@spirit.ru", "first_name": "Пан", "last_name": "Степан", "role": "trainer"} +SOLO_NAMES = ("solo", "соло") + + +def _ensure_user(connection, user: dict) -> int: + """Create the user with a random password unless the email is already taken.""" + connection.execute( + sa.text( + "INSERT INTO users (email, password_hash, first_name, last_name, role) " + "VALUES (:email, :password_hash, :first_name, :last_name, :role) " + "ON CONFLICT (email) DO NOTHING" + ), + {**user, "password_hash": hash_password(secrets.token_urlsafe(16))}, + ) + return connection.execute( + sa.text("SELECT id FROM users WHERE email = :email"), {"email": user["email"]} + ).scalar_one() + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column('trainings', sa.Column('trainee_id', sa.Integer(), nullable=True)) + op.add_column('trainings', sa.Column('trainer_id', sa.Integer(), nullable=True)) + + connection = op.get_bind() + trainee_id = _ensure_user(connection, TRAINEE) + trainer_id = _ensure_user(connection, TRAINER) + connection.execute(sa.text("UPDATE trainings SET trainee_id = :id"), {"id": trainee_id}) + connection.execute( + sa.text( + "UPDATE trainings SET trainer_id = :id " + "WHERE trainer IS NOT NULL AND lower(trainer) NOT IN :solo" + ).bindparams(sa.bindparam("solo", expanding=True)), + {"id": trainer_id, "solo": list(SOLO_NAMES)}, + ) + + op.alter_column('trainings', 'trainee_id', nullable=False) + op.create_index(op.f('ix_trainings_trainee_id'), 'trainings', ['trainee_id'], unique=False) + op.create_index(op.f('ix_trainings_trainer_id'), 'trainings', ['trainer_id'], unique=False) + op.create_foreign_key(None, 'trainings', 'users', ['trainee_id'], ['id'], ondelete='RESTRICT') + op.create_foreign_key(None, 'trainings', 'users', ['trainer_id'], ['id'], ondelete='RESTRICT') + op.drop_column('trainings', 'trainer') + + +def downgrade() -> None: + """Downgrade schema.""" + op.add_column('trainings', sa.Column('trainer', sa.String(length=100), nullable=True)) + op.execute( + "UPDATE trainings SET trainer = CASE WHEN trainer_id IS NULL THEN 'соло' ELSE " + "(SELECT trim(first_name || ' ' || last_name) FROM users WHERE users.id = trainings.trainer_id) END" + ) + # dropping a column drops the foreign key on it + op.drop_index(op.f('ix_trainings_trainer_id'), table_name='trainings') + op.drop_index(op.f('ix_trainings_trainee_id'), table_name='trainings') + op.drop_column('trainings', 'trainer_id') + op.drop_column('trainings', 'trainee_id') diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index daa7bbc..9f0b77f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,7 +18,8 @@ from sqlalchemy.ext.asyncio import create_async_engine from app.config import settings from app.core.database.connection import create_engine, create_session_factory -from app.core.database.repository import TrainingRepository +from app.core.database.user_repository import UserRepository +from app.core.dto.user import UserRole TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL") @@ -62,8 +63,26 @@ async def session(alembic_config): await asyncio.to_thread(command.upgrade, alembic_config, "head") engine = create_engine(TEST_DATABASE_URL) async with create_session_factory(engine)() as session: - await TrainingRepository(session).delete_all() - await session.execute(text("TRUNCATE users RESTART IDENTITY")) + # the migration creates the users of the real data, tests start from nothing + await session.execute(text("TRUNCATE users, trainings RESTART IDENTITY CASCADE")) await session.commit() yield session await engine.dispose() + + +@pytest.fixture +async def trainee(session): + return await UserRepository(session).create( + email="athlete@example.com", password_hash="h", role=UserRole.trainee + ) + + +@pytest.fixture +async def trainer(session): + return await UserRepository(session).create( + email="coach@example.com", + password_hash="h", + first_name="Пан", + last_name="Степан", + role=UserRole.trainer, + ) diff --git a/backend/tests/test_core/test_repository.py b/backend/tests/test_core/test_repository.py index f7d47ba..ef3471a 100644 --- a/backend/tests/test_core/test_repository.py +++ b/backend/tests/test_core/test_repository.py @@ -6,23 +6,30 @@ when TEST_DATABASE_URL is not set. import asyncio from datetime import date +from typing import Optional import pytest from alembic import command -from sqlalchemy import func, select +from sqlalchemy import func, select, text from sqlalchemy.exc import DBAPIError +from app.core.database.connection import create_engine from app.core.database.tables import approaches, exercises from app.core.database.repository import TrainingRepository +from app.core.database.user_repository import UserInUseError, UserRepository from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO from app.core.parsers.apple import AppleNotesParser from app.core.parsers.obsidian import ObsidianNotesParser +from tests.conftest import TEST_DATABASE_URL -def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO: +def make_training( + trainee_id: int, day: int = 1, trainer_id: Optional[int] = None +) -> TrainingDTO: return TrainingDTO( date=date(2025, 1, day), - trainer=trainer, + trainee_id=trainee_id, + trainer_id=trainer_id, exercises=[ ExerciseDTO( name="Жим ногами", @@ -40,16 +47,17 @@ def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO: ) -async def test_save_and_list_roundtrip(session): +async def test_save_and_list_roundtrip(session, trainee): repository = TrainingRepository(session) - training_id = await repository.save(make_training()) + training_id = await repository.save(make_training(trainee.id)) trainings = await repository.list() assert len(trainings) == 1 saved = trainings[0] assert saved.id == training_id - assert (saved.date, saved.trainer) == (date(2025, 1, 1), "solo") + assert (saved.date, saved.trainee_id) == (date(2025, 1, 1), trainee.id) + assert (saved.trainer_id, saved.trainer) == (None, None) # solo assert [e.name for e in saved.exercises] == ["Жим ногами", "Жим гантелей лежа"] assert saved.exercises[1].splitted_weight is True assert [(a.weight, a.reps) for a in saved.exercises[0].approaches] == [ @@ -58,16 +66,16 @@ async def test_save_and_list_roundtrip(session): ] -async def test_list_is_ordered_by_date_desc(session): +async def test_list_is_ordered_by_date_desc(session, trainee): repository = TrainingRepository(session) - await repository.save_many([make_training(day=1), make_training(day=3)]) + await repository.save_many([make_training(trainee.id, day=1), make_training(trainee.id, day=3)]) assert [t.date.day for t in await repository.list()] == [3, 1] -async def test_exercise_names_are_distinct_counted_and_sorted(session): +async def test_exercise_names_are_distinct_counted_and_sorted(session, trainee): repository = TrainingRepository(session) - await repository.save_many([make_training(day=1), make_training(day=3)]) + await repository.save_many([make_training(trainee.id, day=1), make_training(trainee.id, day=3)]) names = await repository.list_exercise_names() @@ -77,25 +85,28 @@ async def test_exercise_names_are_distinct_counted_and_sorted(session): ] -async def test_exercise_history_is_newest_first(session): +async def test_exercise_history_is_newest_first(session, trainee, trainer): repository = TrainingRepository(session) await repository.save_many( - [make_training(day=1), make_training(day=3, trainer="c153")] + [ + make_training(trainee.id, day=1), + make_training(trainee.id, day=3, trainer_id=trainer.id), + ] ) history = await repository.exercise_history("Жим ногами") assert [item.date.day for item in history] == [3, 1] - assert [item.trainer for item in history] == ["c153", "solo"] + assert [item.trainer for item in history] == ["Пан Степан", None] assert [(a.weight, a.reps) for a in history[0].approaches] == [(100, 12), (110, 10)] assert history[0].splitted_weight is False async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercises( - session, + session, trainee ): repository = TrainingRepository(session) - training_id = await repository.save(make_training()) + training_id = await repository.save(make_training(trainee.id)) history = await repository.exercise_history("Жим гантелей лежа") @@ -106,10 +117,10 @@ async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercise assert await repository.exercise_history("Несуществующее") == [] -async def test_delete_cascades_to_exercises_and_approaches(session): +async def test_delete_cascades_to_exercises_and_approaches(session, trainee): repository = TrainingRepository(session) keep_id, delete_id = await repository.save_many( - [make_training(day=1), make_training(day=2)] + [make_training(trainee.id, day=1), make_training(trainee.id, day=2)] ) assert await repository.delete([delete_id]) == 1 @@ -119,9 +130,9 @@ 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): +async def test_get_returns_training_or_none(session, trainee): repository = TrainingRepository(session) - training_id = await repository.save(make_training()) + training_id = await repository.save(make_training(trainee.id)) training = await repository.get(training_id) @@ -130,14 +141,15 @@ async def test_get_returns_training_or_none(session): assert await repository.get(training_id + 1) is None -async def test_replace_overwrites_training_exercises_and_approaches(session): +async def test_replace_overwrites_training_exercises_and_approaches(session, trainee, trainer): repository = TrainingRepository(session) - training_id = await repository.save(make_training(day=1)) - other_id = await repository.save(make_training(day=2)) + training_id = await repository.save(make_training(trainee.id, day=1)) + other_id = await repository.save(make_training(trainee.id, day=2)) updated = TrainingDTO( date=date(2025, 2, 3), - trainer=None, + trainee_id=trainee.id, + trainer_id=trainer.id, exercises=[ ExerciseDTO( name="Тяга", @@ -149,7 +161,7 @@ async def test_replace_overwrites_training_exercises_and_approaches(session): 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 (training.date, training.trainer) == (date(2025, 2, 3), "Пан Степан") 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)] @@ -159,23 +171,26 @@ async def test_replace_overwrites_training_exercises_and_approaches(session): 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_replace_missing_training_returns_false(session, trainee): repository = TrainingRepository(session) - broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100) + assert await repository.replace(999, make_training(trainee.id)) is False + + +async def test_save_many_is_atomic(session, trainee): + repository = TrainingRepository(session) + broken = make_training(trainee.id, day=2, trainer_id=999) # no such user with pytest.raises(DBAPIError): - await repository.save_many([make_training(day=1), broken]) + await repository.save_many([make_training(trainee.id, day=1), broken]) await session.rollback() assert await repository.count() == 0 -async def test_all_notes_can_be_saved(session): +async def test_all_notes_can_be_saved(session, trainee): trainings = ObsidianNotesParser().parse_file() + AppleNotesParser().parse_file() + for training in trainings: + training.trainee_id = trainee.id repository = TrainingRepository(session) ids = await repository.save_many(trainings) @@ -193,3 +208,52 @@ async def test_migrations_match_models(alembic_config): async def test_downgrade_and_upgrade(alembic_config): await asyncio.to_thread(command.downgrade, alembic_config, "base") await asyncio.to_thread(command.upgrade, alembic_config, "head") + + +async def test_user_with_trainings_cannot_be_deleted(session, trainee, trainer): + await TrainingRepository(session).save( + make_training(trainee.id, trainer_id=trainer.id) + ) + users = UserRepository(session) + + for user in (trainee, trainer): + with pytest.raises(UserInUseError): + await users.delete(user.id) + assert len(await users.list()) == 2 + + +async def test_migration_links_existing_trainings_to_users(alembic_config): + """Trainings written before the users existed: everything is the trainee's, only the + non-solo ones get the trainer.""" + previous = "97535a10551d" + await asyncio.to_thread(command.upgrade, alembic_config, "head") + await asyncio.to_thread(command.downgrade, alembic_config, previous) + engine = create_engine(TEST_DATABASE_URL) + async with engine.begin() as connection: + await connection.execute(text("TRUNCATE trainings, users RESTART IDENTITY CASCADE")) + await connection.execute( + text( + "INSERT INTO trainings (date, trainer) VALUES " + "('2025-01-01', 'solo'), ('2025-01-02', 'соло'), ('2025-01-03', 'с153'), " + "('2025-01-04', NULL)" + ) + ) + await asyncio.to_thread(command.upgrade, alembic_config, "head") + async with engine.connect() as connection: + rows = ( + await connection.execute( + text( + "SELECT t.date, trainee.email, trainer.email FROM trainings t " + "JOIN users trainee ON trainee.id = t.trainee_id " + "LEFT JOIN users trainer ON trainer.id = t.trainer_id ORDER BY t.date" + ) + ) + ).all() + await engine.dispose() + + assert [(row[1], row[2]) for row in rows] == [ + ("pro100ton@gmail.com", None), + ("pro100ton@gmail.com", None), + ("pro100ton@gmail.com", "panstepan@spirit.ru"), + ("pro100ton@gmail.com", None), + ] diff --git a/backend/tests/test_core/test_training_schemas.py b/backend/tests/test_core/test_training_schemas.py index 94360df..fd4682e 100644 --- a/backend/tests/test_core/test_training_schemas.py +++ b/backend/tests/test_core/test_training_schemas.py @@ -7,7 +7,8 @@ from app.schemas.trainings import TrainingUpdate def payload(**overrides): data = { "date": "2026-10-21", - "trainer": " solo ", + "trainee_id": 1, + "trainer_id": None, "exercises": [ {"name": " Жим ", "approaches": [{"weight": 0, "reps": 12}]}, ], @@ -16,14 +17,17 @@ def payload(**overrides): return data -def test_strips_name_and_trainer(): +def test_strips_name_and_solo_training_has_no_trainer(): training = TrainingUpdate.model_validate(payload()) - assert training.trainer == "solo" + assert training.trainer_id is None assert training.exercises[0].name == "Жим" -def test_blank_trainer_becomes_none(): - assert TrainingUpdate.model_validate(payload(trainer=" ")).trainer is None +def test_trainee_is_required(): + data = payload() + del data["trainee_id"] + with pytest.raises(ValidationError): + TrainingUpdate.model_validate(data) @pytest.mark.parametrize( diff --git a/frontend/js/common.js b/frontend/js/common.js index 0679fe8..e64723e 100644 --- a/frontend/js/common.js +++ b/frontend/js/common.js @@ -2,6 +2,9 @@ const API = '/api/v1'; +/** What a training without a trainer is called */ +export const SOLO = 'соло'; + export async function api(path, options) { const response = await fetch(API + path, options); if (!response.ok) { @@ -34,6 +37,11 @@ export function formatDate(iso) { return `${day}.${month}.${year}`; } +/** "First Last" of a user, the email when the name is empty */ +export function userName(user) { + return `${user.first_name} ${user.last_name}`.trim() || user.email; +} + /** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */ export function formatDateTime(iso) { return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }).replace(',', ''); diff --git a/frontend/js/edit-dialog.js b/frontend/js/edit-dialog.js index f1e56fd..1b14104 100644 --- a/frontend/js/edit-dialog.js +++ b/frontend/js/edit-dialog.js @@ -15,11 +15,13 @@ export function editButton(trainingId) { export async function openEditDialog(trainingId, onChange) { const { body, hide } = openModal('Редактирование тренировки'); try { - const [training, exerciseNames] = await Promise.all([ + const [training, exerciseNames, trainees, trainers] = await Promise.all([ api(`/trainings/${trainingId}/`), api('/exercises/'), + api('/users/?role=trainee'), + api('/users/?role=trainer'), ]); - body.replaceChildren(editForm(training, exerciseNames, { + body.replaceChildren(editForm(training, exerciseNames, { trainees, trainers }, { onDone: () => { hide(); onChange(); }, onCancel: hide, })); diff --git a/frontend/js/edit-form.js b/frontend/js/edit-form.js index ee27e6a..3a1f9b3 100644 --- a/frontend/js/edit-form.js +++ b/frontend/js/edit-form.js @@ -1,6 +1,6 @@ -// Form for editing one training: date, trainer, exercises and their approaches +// Form for editing one training: date, trainee, trainer, exercises and their approaches -import { alert, api, h } from './common.js'; +import { alert, api, h, SOLO, userName } from './common.js'; const jsonRequest = (method, body) => ({ method, @@ -59,7 +59,8 @@ function exerciseCard(exercise) { function readForm(form) { return { date: form.elements.date.value, - trainer: form.elements.trainer.value, + trainee_id: Number(form.elements.trainee_id.value), + trainer_id: form.elements.trainer_id.value ? Number(form.elements.trainer_id.value) : null, exercises: [...form.querySelectorAll('.exercise-card')].map((card) => ({ name: card.querySelector('[name=name]').value, splitted_weight: card.querySelector('[name=splitted_weight]').checked, @@ -71,8 +72,17 @@ function readForm(form) { }; } -/** onDone() is called after the training was saved or deleted, onCancel() on "Отмена" */ -export function editForm(training, exerciseNames, { onDone, onCancel }) { +function userSelect(name, users, selectedId, emptyTitle) { + return h('select', { class: 'form-select', name, required: !emptyTitle }, + emptyTitle ? h('option', { value: '', selected: selectedId === null }, emptyTitle) : null, + ...users.map((user) => h('option', { value: user.id, selected: user.id === selectedId }, userName(user)))); +} + +/** + * trainees / trainers: users to choose from. onDone() is called after the training was saved + * or deleted, onCancel() on "Отмена". + */ +export function editForm(training, exerciseNames, { trainees, trainers }, { onDone, onCancel }) { const exercisesBox = h('div', {}, ...training.exercises.map(exerciseCard)); const addExercise = h('button', { type: 'button', class: 'btn btn-outline-secondary mb-3' }, '+ упражнение'); addExercise.addEventListener('click', () => { @@ -90,8 +100,9 @@ export function editForm(training, exerciseNames, { onDone, onCancel }) { const form = h('form', {}, h('datalist', { id: 'edit-exercise-names' }, ...exerciseNames.map((item) => h('option', { value: item.name }))), h('div', { class: 'row g-3 mb-3' }, - field('Дата', h('input', { type: 'date', class: 'form-control', name: 'date', required: true, value: training.date }), 'col-6 col-md-3'), - field('Тренер', h('input', { type: 'text', class: 'form-control', name: 'trainer', maxlength: 100, value: training.trainer || '' }), 'col-6 col-md-3')), + field('Дата', h('input', { type: 'date', class: 'form-control', name: 'date', required: true, value: training.date }), 'col-12 col-md-3'), + field('Тренирующийся', userSelect('trainee_id', trainees, training.trainee_id), 'col-12 col-md-3'), + field('Тренер', userSelect('trainer_id', trainers, training.trainer_id, `${SOLO} (без тренера)`), 'col-12 col-md-3')), exercisesBox, addExercise, h('div', { class: 'd-flex gap-2 align-items-center' }, diff --git a/frontend/js/exercises.js b/frontend/js/exercises.js index d02d264..60d9be2 100644 --- a/frontend/js/exercises.js +++ b/frontend/js/exercises.js @@ -1,4 +1,4 @@ -import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js'; +import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js'; import { editButton } from './edit-dialog.js'; import { createTable } from './table.js'; @@ -106,7 +106,7 @@ function progressView(progress) { const columns = [ { title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) }, - { title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || '', cell: (row) => row.trainer || '' }, + { title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || SOLO, cell: (row) => row.trainer || SOLO }, { title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) }, ...(weighted ? [ { title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' }, diff --git a/frontend/js/trainings-view.js b/frontend/js/trainings-view.js index 59f576a..ad69065 100644 --- a/frontend/js/trainings-view.js +++ b/frontend/js/trainings-view.js @@ -1,6 +1,6 @@ // One table for all trainings: a row per exercise -import { alert, approachChips, formatDate, h, perArmBadge } from './common.js'; +import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js'; import { editButton } from './edit-dialog.js'; import { createTable } from './table.js'; @@ -28,7 +28,7 @@ export function trainingsView(trainings) { rows, columns: [ { title: 'Дата', className: 'text-nowrap date', sort: (row) => row.training.date, cell: (row) => formatDate(row.training.date) }, - { title: 'Тренер', className: 'text-nowrap', sort: (row) => row.training.trainer || '', cell: (row) => row.training.trainer || '' }, + { title: 'Тренер', className: 'text-nowrap', sort: (row) => row.training.trainer || SOLO, cell: (row) => row.training.trainer || SOLO }, { title: 'Упражнение', sort: (row) => row.exercise?.name || '', diff --git a/frontend/js/user-dialog.js b/frontend/js/user-dialog.js index f5fe1b4..91e9b28 100644 --- a/frontend/js/user-dialog.js +++ b/frontend/js/user-dialog.js @@ -85,7 +85,8 @@ function userForm(user, { onDone, onCancel }) { await api(`/users/${user.id}/`, jsonRequest('DELETE')); onDone(); } catch (error) { - status.replaceChildren(alert('danger', `Не удалось удалить: ${errorText(error)}`)); + const reason = error.status === 409 ? 'у пользователя есть тренировки' : errorText(error); + status.replaceChildren(alert('danger', `Не удалось удалить: ${reason}`)); } });