Back: connect users to trainings

This commit is contained in:
t0xa 2026-10-23 20:42:35 +03:00
parent 07a774aef4
commit 582a80564e
19 changed files with 389 additions and 79 deletions

View file

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

View file

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

View file

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

View file

@ -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__":

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(',', '');

View file

@ -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,
}));

View file

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

View file

@ -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)} кг` : '—' },

View file

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

View file

@ -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}`));
}
});