Back: connect users to trainings
This commit is contained in:
parent
07a774aef4
commit
582a80564e
19 changed files with 389 additions and 79 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(',', '');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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)} кг` : '—' },
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue