Compare commits

..

No commits in common. "main" and "7-Add-database-to-project" have entirely different histories.

59 changed files with 513 additions and 3128 deletions

View file

@ -10,9 +10,3 @@ PYTHONPATH=/app
# Development settings
DEBUG=true
# Auth
# Signs access tokens. Generate: python -c "import secrets; print(secrets.token_urlsafe(48))"
JWT_SECRET=change-me-to-a-long-random-string
# Set to true in production (HTTPS only), the web client's refresh cookie is then Secure
COOKIE_SECURE=false

View file

@ -38,8 +38,6 @@ docker compose down -v
```
Rebuild (`--build`) is needed only after changing `requirements*.txt`, `Dockerfile` or `pyproject.toml`.
The backend `Dockerfile` has two targets: `dev` (default in compose: pytest, mypy, tests, notes) and `prod` (runtime dependencies and app code only). Production images are built with `docker build --target prod backend`; the prod image has no `data/`, so the dev-only `GET /api/v1/trainings/obsidian/` doesn't work there.
### Testing
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
```bash
@ -73,23 +71,15 @@ docker compose exec fastapi-app mypy app/
- `Approach` - single set with weight and reps
**API Structure** (backend, JSON only):
- `/api/v1/auth/` (login, refresh, logout, me), `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/`
- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/`
**Frontend** (`frontend/`):
- `index.html`, `exercises.html`, `parse.html`, `users.html`, `login.html` - pages; `js/auth.js` (tokens, refresh, logout) and `js/page.js` (every page imports it first: requires a login, fills the sidebar, exports `user`); `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal)
- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (search/sort/paging), one module per page
- `vendor/` - Bootstrap CSS and Chart.js (the only third-party code)
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
### Authentication
Access token: JWT (HS256, 15 min, `JWT_SECRET` in `.env`), sent as `Authorization: Bearer`. Refresh token: random opaque string, 30 days, only its hash is stored (`refresh_tokens`); it works once (rotation), reusing a used one revokes the whole chain. `POST /auth/login/` with `client: "web"` puts the refresh token only into an httpOnly `SameSite=Strict` cookie (path `/api/v1/auth`, `Secure` when `COOKIE_SECURE=true`, set it in production with HTTPS); `client: "native"` (iOS) returns it in the body for the Keychain. The web frontend keeps the access token in `sessionStorage`.
There is no registration: only a superuser creates users (`/api/v1/users/`). The first login: `docker compose exec fastapi-app python -m app.cli set-password EMAIL [--superuser]`. Access: a superuser sees and changes everything; a trainee sees and changes their trainings; a trainer only sees the trainings where they are the trainer. Exercise statistics are about the user's own trainings (`?trainee_id=` narrows to one trainee).
### 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

@ -1,37 +1,32 @@
# Two images from one file:
# docker build --target prod . - runtime only: no pytest/mypy, no tests, no personal notes
# docker build --target dev . - prod + dev tools, tests and notes (docker compose uses it)
FROM python:3.13-slim AS base
FROM python:3.13-slim
# Set working directory
WORKDIR /app
ENV PYTHONPATH=/app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt requirements-dev.txt ./
COPY alembic.ini ./
COPY ./app ./app
COPY ./migrations ./migrations
EXPOSE 8000
FROM base AS prod
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# The last stage is the default target. The project is developed only in Docker, so the dev
# tools (pytest, mypy) live in this image.
FROM base AS dev
COPY requirements-dev.txt pyproject.toml ./
# Install Python dependencies (the project is run only in Docker, so dev tools
# like pytest and mypy are part of the image)
RUN pip install --no-cache-dir -r requirements-dev.txt
# Copy application code
COPY pyproject.toml alembic.ini ./
COPY ./app ./app
COPY ./migrations ./migrations
COPY ./data ./data
COPY ./tests ./tests
# Set Python path
ENV PYTHONPATH=/app
# Expose port
EXPOSE 8000
# Command will be overridden by docker-compose
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,61 +1,11 @@
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.connection import get_session
from app.core.database.repository import TrainingRepository
from app.core.database.token_repository import RefreshTokenRepository
from app.core.database.user_repository import UserRepository
from app.core.dto.user import UserDTO
from app.core.security import decode_access_token
# auto_error=False: a missing header gets the same 401 as a bad token
bearer_scheme = HTTPBearer(auto_error=False)
def get_training_repository(
session: AsyncSession = Depends(get_session),
) -> TrainingRepository:
return TrainingRepository(session)
def get_user_repository(
session: AsyncSession = Depends(get_session),
) -> UserRepository:
return UserRepository(session)
def get_token_repository(
session: AsyncSession = Depends(get_session),
) -> RefreshTokenRepository:
return RefreshTokenRepository(session)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
repository: UserRepository = Depends(get_user_repository),
) -> UserDTO:
"""The user of the access token. The user is read from the database on every request, so
a deactivated user loses access at once, not when the token expires."""
unauthorized = HTTPException(
status_code=401,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials is None:
raise unauthorized
try:
user_id = int(decode_access_token(credentials.credentials)["sub"])
except (jwt.PyJWTError, ValueError):
raise unauthorized
user = await repository.get(user_id)
if user is None or not user.is_active:
raise unauthorized
return user
async def require_superuser(user: UserDTO = Depends(get_current_user)) -> UserDTO:
if not user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser only")
return user

View file

@ -1,123 +0,0 @@
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
from app.api.deps import get_current_user, get_token_repository, get_user_repository
from app.config import settings
from app.core.database.token_repository import RefreshTokenRepository
from app.core.database.user_repository import UserRepository
from app.core.dto.user import UserDTO
from app.core.security import (
DUMMY_PASSWORD_HASH,
create_access_token,
hash_token,
new_refresh_token,
verify_password,
)
from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
router = APIRouter()
REFRESH_COOKIE = "refresh_token"
# The cookie is sent only to the auth endpoints
COOKIE_PATH = "/api/v1/auth"
def _invalid_credentials() -> HTTPException:
return HTTPException(status_code=401, detail="Invalid email or password")
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
REFRESH_COOKIE,
token,
max_age=settings.REFRESH_TOKEN_TTL_DAYS * 24 * 3600,
path=COOKIE_PATH,
httponly=True,
secure=settings.COOKIE_SECURE,
samesite="strict",
)
async def _issue_tokens(
user: UserDTO,
family_id: str,
web: bool,
response: Response,
tokens: RefreshTokenRepository,
) -> TokenResponse:
refresh_token = new_refresh_token()
await tokens.create(
user_id=user.id,
family_id=family_id,
token_hash=hash_token(refresh_token),
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
if web:
_set_refresh_cookie(response, refresh_token)
return TokenResponse(
access_token=create_access_token(user.id, user.role.value),
expires_in=settings.ACCESS_TOKEN_TTL_MINUTES * 60,
refresh_token=None if web else refresh_token,
)
@router.post("/login/", response_model=TokenResponse)
async def login(
payload: LoginRequest,
response: Response,
users: UserRepository = Depends(get_user_repository),
tokens: RefreshTokenRepository = Depends(get_token_repository),
):
found = await users.get_credentials(payload.email.strip().lower())
# Check the password even for an unknown email: the response time must not tell them apart
user, password_hash = found if found else (None, DUMMY_PASSWORD_HASH)
if not verify_password(payload.password, password_hash) or user is None or not user.is_active:
raise _invalid_credentials()
await users.update(user.id, {"last_login": datetime.now(timezone.utc)})
return await _issue_tokens(user, uuid.uuid4().hex, payload.client == "web", response, tokens)
@router.post("/refresh/", response_model=TokenResponse)
async def refresh(
response: Response,
payload: Optional[RefreshRequest] = None,
cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE),
users: UserRepository = Depends(get_user_repository),
tokens: RefreshTokenRepository = Depends(get_token_repository),
):
"""Exchange a refresh token for a new pair. The old refresh token stops working."""
body_token = payload.refresh_token if payload else None
token = body_token or cookie_token
web = body_token is None
claim = await tokens.claim(hash_token(token)) if token else None
user = await users.get(claim.user_id) if claim else None
if claim is None or user is None or not user.is_active:
if claim is not None:
await tokens.revoke_all_of_user(claim.user_id)
response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH)
raise HTTPException(status_code=401, detail="Invalid refresh token")
return await _issue_tokens(user, claim.family_id, web, response, tokens)
@router.post("/logout/")
async def logout(
response: Response,
payload: Optional[RefreshRequest] = None,
cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE),
tokens: RefreshTokenRepository = Depends(get_token_repository),
):
"""Revoke the refresh token of this login. The access token expires by itself in minutes."""
token = (payload.refresh_token if payload else None) or cookie_token
if token:
await tokens.revoke_family_of(hash_token(token))
response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH)
return {"status": "ok"}
@router.get("/me/", response_model=UserDTO)
async def me(user: UserDTO = Depends(get_current_user)):
return user

View file

@ -2,10 +2,9 @@ from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import get_current_user, get_training_repository
from app.api.deps import get_training_repository
from app.core.database.repository import TrainingRepository
from app.core.dto.training import ExerciseNameDTO
from app.core.dto.user import UserDTO, UserRole
from app.core.metrics import Record, summarize
from app.schemas.exercises import (
ExerciseProgressResponse,
@ -17,57 +16,36 @@ from app.schemas.exercises import (
router = APIRouter()
def stats_trainee_id(user: UserDTO, trainee_id: Optional[int]) -> Optional[int]:
"""Whose trainings the statistics are about: a trainee's own unless another one is asked
for (which, like everything else, is limited to the trainings the user may see)."""
if trainee_id is None and user.role == UserRole.trainee:
return user.id
return trainee_id
def record_response(record: Optional[Record]) -> Optional[RecordResponse]:
return RecordResponse(value=record.value, date=record.date) if record else None
@router.get("/", response_model=List[ExerciseNameDTO])
async def list_exercise_names(
trainee_id: Optional[int] = None,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
):
"""Distinct exercise names with the number of times each was done."""
return await repository.list_exercise_names(
viewer=user, trainee_id=stats_trainee_id(user, trainee_id)
)
return await repository.list_exercise_names()
@router.get("/progress/", response_model=ExerciseProgressResponse)
async def exercise_progress(
name: str,
trainee_id: Optional[int] = None,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
):
"""The whole history of the exercise with progress metrics.
The name is matched exactly, but case-insensitively: what was typed may differ in case.
"""
trainee_id = stats_trainee_id(user, trainee_id)
names_by_casefold = {
item.name.casefold(): item.name
for item in await repository.list_exercise_names(
viewer=user, trainee_id=trainee_id
)
for item in await repository.list_exercise_names()
}
exact_name = names_by_casefold.get(name.strip().casefold())
if exact_name is None:
raise HTTPException(status_code=404, detail="Exercise not found")
progress = summarize(
await repository.exercise_history(
exact_name, viewer=user, trainee_id=trainee_id
)
)
progress = summarize(await repository.exercise_history(exact_name))
return ExerciseProgressResponse(
name=exact_name,
weighted=progress.weighted,
@ -80,8 +58,6 @@ async def exercise_progress(
primary_change_percent=progress.primary_change_percent,
rows=[
SessionRowResponse(
training_id=row.session.training_id,
trainee_id=row.session.trainee_id,
date=row.session.date,
trainer=row.session.trainer,
splitted_weight=row.session.splitted_weight,

View file

@ -1,18 +1,9 @@
from fastapi import APIRouter, Depends
from app.api.deps import get_current_user
from app.api.v1 import auth, exercises, parser, health, trainings, users
from fastapi import APIRouter
from app.api.v1 import exercises, parser, health, trainings
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(
parser.router,
prefix="/parser",
tags=["parser"],
dependencies=[Depends(get_current_user)],
)
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
api_router.include_router(users.router, prefix="/users", tags=["users"])

View file

@ -1,125 +1,34 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException
from app.api.deps import (
get_current_user,
get_training_repository,
get_user_repository,
require_superuser,
)
from app.api.deps import get_training_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 UserDTO, UserRole
from app.core.parsers.obsidian import parse_training_data
from app.schemas.trainings import TrainingUpdate
router = APIRouter()
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}"
)
async def _get_editable_training(
training_id: int, user: UserDTO, repository: TrainingRepository
) -> TrainingDTO:
"""A training the user may change: their own (as the trainee), or any for a superuser.
The trainer can only see the training. A training the user can't see at all is a 404.
"""
training = await repository.get(training_id, viewer=user)
if training is None:
raise HTTPException(status_code=404, detail="Training not found")
if not (user.is_superuser or training.trainee_id == user.id):
raise HTTPException(status_code=403, detail="Only the trainee can change a training")
return training
@router.get("/obsidian/", dependencies=[Depends(require_superuser)])
@router.get("/obsidian/")
async def obsidian_trainings_list():
return {"data": parse_training_data()}
@router.get("/", response_model=List[TrainingDTO])
async def get_trainings_list(
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
):
"""Trainings the user takes part in (all of them for a superuser)."""
return await repository.list(viewer=user)
@router.get("/{training_id}/", response_model=TrainingDTO)
async def get_training(
training_id: int,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
):
training = await repository.get(training_id, viewer=user)
if training is None:
raise HTTPException(status_code=404, detail="Training not found")
return training
@router.post("/", response_model=TrainingDTO, status_code=status.HTTP_201_CREATED)
async def create_training(
payload: TrainingUpdate,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
"""Create a training. Only a superuser may create it for another trainee."""
if payload.trainee_id != user.id:
if not user.is_superuser:
raise HTTPException(
status_code=403, detail="Only a superuser can create a training for another user"
)
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_id = await repository.save(TrainingDTO.model_validate(payload.model_dump()))
return await repository.get(training_id)
@router.put("/{training_id}/", response_model=TrainingDTO)
async def update_training(
training_id: int,
payload: TrainingUpdate,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
user_repository: UserRepository = Depends(get_user_repository),
):
"""Replace the training: its date, trainee, trainer, exercises and approaches."""
current = await _get_editable_training(training_id, user, repository)
if payload.trainee_id != current.trainee_id:
if not user.is_superuser:
raise HTTPException(
status_code=403, detail="Only a superuser can change the trainee"
)
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")
return await repository.get(training_id)
return await repository.list()
@router.delete("/{training_id}/")
async def delete_training(
training_id: int,
user: UserDTO = Depends(get_current_user),
repository: TrainingRepository = Depends(get_training_repository),
):
await _get_editable_training(training_id, user, repository)
await repository.delete([training_id])
deleted = await repository.delete([training_id])
if not deleted:
raise HTTPException(status_code=404, detail="Training not found")
return {"status": "ok"}

View file

@ -1,107 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from app.api.deps import get_current_user, get_user_repository, require_superuser
from app.core.database.user_repository import (
EmailTakenError,
UserInUseError,
UserRepository,
)
from app.core.dto.user import TrainerDTO, UserDTO, UserRole
from app.core.security import hash_password
from app.schemas.users import UserCreate, UserUpdate
router = APIRouter()
EMAIL_TAKEN = HTTPException(status_code=409, detail="Email already registered")
@router.post("/", response_model=UserDTO, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superuser)])
async def create_user(
payload: UserCreate,
repository: UserRepository = Depends(get_user_repository),
):
try:
return await repository.create(
email=payload.email,
password_hash=hash_password(payload.password),
first_name=payload.first_name,
last_name=payload.last_name,
role=payload.role,
)
except EmailTakenError:
raise EMAIL_TAKEN
@router.get("/", response_model=List[UserDTO], dependencies=[Depends(require_superuser)])
async def list_users(
role: Optional[UserRole] = None,
repository: UserRepository = Depends(get_user_repository),
):
return await repository.list(role)
@router.get("/trainers/", response_model=List[TrainerDTO])
async def list_trainers(
_: UserDTO = Depends(get_current_user),
repository: UserRepository = Depends(get_user_repository),
):
"""Trainers to pick from in a training form: any signed-in user may ask for them."""
return [
TrainerDTO(
id=user.id,
name=f"{user.first_name} {user.last_name}".strip() or user.email,
)
for user in await repository.list(UserRole.trainer)
if user.is_active
]
@router.get("/{user_id}/", response_model=UserDTO, dependencies=[Depends(require_superuser)])
async def get_user(
user_id: int,
repository: UserRepository = Depends(get_user_repository),
):
user = await repository.get(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}/", response_model=UserDTO, dependencies=[Depends(require_superuser)])
async def update_user(
user_id: int,
payload: UserUpdate,
repository: UserRepository = Depends(get_user_repository),
):
values = payload.model_dump(exclude_unset=True)
if "password" in values:
values["password_hash"] = hash_password(values.pop("password"))
# null makes no sense for these columns
if any(values.get(name, "") is None for name in ("email", "role", "is_active", "password_hash")):
raise HTTPException(status_code=422, detail="Field must not be null")
try:
user = await repository.update(user_id, values)
except EmailTakenError:
raise EMAIL_TAKEN
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.delete("/{user_id}/", dependencies=[Depends(require_superuser)])
async def delete_user(
user_id: int,
current: UserDTO = Depends(require_superuser),
repository: UserRepository = Depends(get_user_repository),
):
if user_id == current.id:
raise HTTPException(status_code=400, detail="You can't delete yourself")
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,74 +1,23 @@
"""Command line helpers.
python -m app.cli import [--replace] [--trainee-email EMAIL]
python -m app.cli set-password EMAIL [--superuser]
python -m app.cli import [--replace]
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`).
`set-password` asks for a new password of the user (and with --superuser makes them a
superuser). It is the way to get a working login: users are created only by superusers, and
the users made by migrations have random passwords.
Imports all trainings from the Obsidian and Apple notes into the database.
The schema must already exist (`alembic upgrade head`).
"""
import argparse
import asyncio
import getpass
import logging
from typing import Any, Dict, 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.token_repository import RefreshTokenRepository
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.security import hash_password
from app.core.parsers.apple import AppleNotesParser
from app.core.parsers.obsidian import ObsidianNotesParser
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:
async def import_notes(replace: bool) -> None:
parsers = [ObsidianNotesParser(), AppleNotesParser()]
trainings = []
for parser in parsers:
@ -83,7 +32,6 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> 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(
@ -98,26 +46,6 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
print(f"Imported {len(trainings)} trainings")
async def set_password(email: str, password: str, superuser: bool) -> None:
engine = create_engine(settings.get_postgres_database_url())
try:
async with create_session_factory(engine)() as session:
users = UserRepository(session)
found = await users.get_credentials(email.strip().lower())
if found is None:
raise SystemExit(f"No user with email {email}")
user, _ = found
values: Dict[str, Any] = {"password_hash": hash_password(password)}
if superuser:
values["is_superuser"] = True
await users.update(user.id, values)
# Whoever was logged in with the old password is logged out
await RefreshTokenRepository(session).revoke_all_of_user(user.id)
finally:
await engine.dispose()
print(f"Password of {email} is changed")
def main() -> None:
logging.basicConfig(level=logging.WARNING)
arg_parser = argparse.ArgumentParser(prog="app.cli")
@ -126,26 +54,10 @@ 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)",
)
password_parser = subcommands.add_parser("set-password", help="set a user's password")
password_parser.add_argument("email")
password_parser.add_argument(
"--superuser", action="store_true", help="also make the user a superuser"
)
args = arg_parser.parse_args()
if args.command == "import":
asyncio.run(import_notes(args.replace, args.trainee_email))
elif args.command == "set-password":
password = getpass.getpass("New password: ")
if len(password) < 8:
raise SystemExit("The password must be at least 8 characters long")
if password != getpass.getpass("Repeat: "):
raise SystemExit("The passwords differ")
asyncio.run(set_password(args.email, password, args.superuser))
asyncio.run(import_notes(replace=args.replace))
if __name__ == "__main__":

View file

@ -11,13 +11,6 @@ class Settings(BaseSettings):
version: str = "0.0.1"
debug: bool = False
# Auth: JWT_SECRET signs access tokens, it must be long and random (see .env.example)
JWT_SECRET: str = ""
ACCESS_TOKEN_TTL_MINUTES: int = 15
REFRESH_TOKEN_TTL_DAYS: int = 30
# The refresh cookie of the web client is sent only over HTTPS when this is on
COOKIE_SECURE: bool = False
# Postgres database settings
POSTGRES_DATABASE_URL: Optional[str] = None
POSTGRES_DATABASE_HOST: str = "localhost"

View file

@ -1,10 +1,10 @@
from collections import defaultdict
from typing import Dict, List, Optional, Sequence
from sqlalchemy import ColumnElement, delete, func, insert, or_, select, text, true, update
from sqlalchemy import delete, func, insert, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.tables import approaches, exercises, trainings, users
from app.core.database.tables import approaches, exercises, trainings
from app.core.dto.training import (
ApproachDTO,
ExerciseDTO,
@ -12,37 +12,6 @@ from app.core.dto.training import (
ExerciseSessionDTO,
TrainingDTO,
)
from app.core.dto.user import UserDTO
_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
)
def _scope(viewer: Optional[UserDTO], trainee_id: Optional[int]) -> List[ColumnElement]:
"""Conditions on `trainings`: what the viewer may see, narrowed to one trainee if asked.
A superuser sees everything, anyone else the trainings where they are the trainee or the
trainer. No viewer means no restriction (imports, tests).
"""
conditions: List[ColumnElement] = []
if viewer is not None and not viewer.is_superuser:
conditions.append(
or_(trainings.c.trainee_id == viewer.id, trainings.c.trainer_id == viewer.id)
)
if trainee_id is not None:
conditions.append(trainings.c.trainee_id == trainee_id)
return conditions
class TrainingRepository:
@ -58,108 +27,47 @@ 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,
"trainee_id": item.trainee_id,
"trainer_id": item.trainer_id,
}
for item in items
],
[{"date": item.date, "trainer": item.trainer} for item in items],
)
await self._insert_exercises(training_ids, items)
exercise_rows = []
exercise_dtos = []
for training_id, item in zip(training_ids, items):
for exercise in item.exercises:
exercise_rows.append(
{
"training_id": training_id,
"name": exercise.name,
"splitted_weight": exercise.splitted_weight,
}
)
exercise_dtos.append(exercise)
if exercise_rows:
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
approach_rows = [
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
for a in exercise.approaches
]
if approach_rows:
await self._session.execute(insert(approaches), approach_rows)
await self._session.commit()
return training_ids
async def get(
self, training_id: int, *, viewer: Optional[UserDTO] = None
) -> Optional[TrainingDTO]:
row = (
await self._session.execute(
_trainings_query().where(
trainings.c.id == training_id, *_scope(viewer, None)
)
)
).first()
if row is None:
return None
exercise_rows = (
await self._session.execute(
select(exercises)
.where(exercises.c.training_id == training_id)
.order_by(exercises.c.id)
)
).all()
approaches_by_exercise = await self._approaches_by_exercise(
[exercise.id for exercise in exercise_rows]
)
return TrainingDTO(
id=row.id,
date=row.date,
trainee_id=row.trainee_id,
trainer_id=row.trainer_id,
trainer=row.trainer,
exercises=[
ExerciseDTO(
id=exercise.id,
training_id=exercise.training_id,
name=exercise.name,
splitted_weight=exercise.splitted_weight,
approaches=approaches_by_exercise[exercise.id],
)
for exercise in exercise_rows
],
)
async def replace(self, training_id: int, training: TrainingDTO) -> bool:
"""Overwrite the training with its exercises and approaches, False if it doesn't exist.
Old exercises are deleted and the new ones inserted in the same transaction.
"""
result = await self._session.execute(
update(trainings)
.where(trainings.c.id == training_id)
.values(
date=training.date,
trainee_id=training.trainee_id,
trainer_id=training.trainer_id,
)
)
if not result.rowcount: # type: ignore[attr-defined]
await self._session.rollback()
return False
await self._session.execute(
delete(exercises).where(exercises.c.training_id == training_id)
)
await self._insert_exercises([training_id], [training])
await self._session.commit()
return True
async def list(self, *, viewer: Optional[UserDTO] = None) -> List[TrainingDTO]:
async def list(self) -> List[TrainingDTO]:
training_rows = (
await self._session.execute(
_trainings_query()
.where(*_scope(viewer, None))
.order_by(trainings.c.date.desc(), trainings.c.id.desc())
select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc())
)
).all()
exercise_rows = (
await self._session.execute(
select(exercises)
.where(exercises.c.training_id.in_([row.id for row in training_rows]))
.order_by(exercises.c.id)
)
).all()
approaches_by_exercise = await self._approaches_by_exercise(
[row.id for row in exercise_rows]
)
approaches_by_exercise = await self._approaches_by_exercise()
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list)
exercise_rows = await self._session.execute(
select(exercises).order_by(exercises.c.id)
)
for row in exercise_rows:
exercises_by_training[row.training_id].append(
ExerciseDTO(
@ -174,49 +82,33 @@ 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],
)
for row in training_rows
]
async def list_exercise_names(
self, *, viewer: Optional[UserDTO] = None, trainee_id: Optional[int] = None
) -> List[ExerciseNameDTO]:
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
"""Distinct exercise names with the number of times each was done."""
query = (
select(exercises.c.name, func.count(exercises.c.id).label("times"))
.join(trainings, trainings.c.id == exercises.c.training_id)
.where(*_scope(viewer, trainee_id))
.group_by(exercises.c.name)
)
query = select(
exercises.c.name, func.count(exercises.c.id).label("times")
).group_by(exercises.c.name)
result = await self._session.execute(query)
names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result]
# Sorted here, not in SQL: the order must not depend on DB collation
return sorted(names, key=lambda item: item.name.casefold())
async def exercise_history(
self,
name: str,
*,
viewer: Optional[UserDTO] = None,
trainee_id: Optional[int] = None,
) -> List[ExerciseSessionDTO]:
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]:
"""Every occurrence of the exercise, newest first."""
query = (
select(
exercises.c.id,
exercises.c.splitted_weight,
trainings.c.id.label("training_id"),
trainings.c.trainee_id,
trainings.c.date,
_trainer_name,
trainings.c.trainer,
)
.join(trainings, trainings.c.id == exercises.c.training_id)
.outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id)
.where(exercises.c.name == name, *_scope(viewer, trainee_id))
.where(exercises.c.name == name)
.order_by(trainings.c.date.desc(), exercises.c.id.desc())
)
rows = (await self._session.execute(query)).all()
@ -225,8 +117,6 @@ class TrainingRepository:
)
return [
ExerciseSessionDTO(
training_id=row.training_id,
trainee_id=row.trainee_id,
date=row.date,
trainer=row.trainer,
splitted_weight=row.splitted_weight,
@ -252,33 +142,6 @@ class TrainingRepository:
)
await self._session.commit()
async def _insert_exercises(
self, training_ids: Sequence[int], items: Sequence[TrainingDTO]
) -> None:
"""Insert exercises and approaches of `items` (training_ids[i] belongs to items[i])."""
exercise_rows = []
exercise_dtos = []
for training_id, item in zip(training_ids, items):
for exercise in item.exercises:
exercise_rows.append(
{
"training_id": training_id,
"name": exercise.name,
"splitted_weight": exercise.splitted_weight,
}
)
exercise_dtos.append(exercise)
if not exercise_rows:
return
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
approach_rows = [
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
for a in exercise.approaches
]
if approach_rows:
await self._session.execute(insert(approaches), approach_rows)
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
"""Bulk insert, ids come back in the order of `rows`."""
result = await self._session.execute(

View file

@ -3,7 +3,6 @@ from sqlalchemy import (
Column,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Integer,
@ -13,8 +12,6 @@ from sqlalchemy import (
func,
)
from app.core.dto.user import UserRole
metadata = MetaData()
trainings = Table(
@ -22,10 +19,7 @@ trainings = Table(
metadata,
Column("id", Integer, primary_key=True),
Column("date", Date, nullable=False, index=True),
# 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("trainer", String(100)),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
@ -58,44 +52,3 @@ approaches = Table(
Column("reps", Integer, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
users = Table(
"users",
metadata,
Column("id", Integer, primary_key=True),
Column("email", String(254), nullable=False, unique=True, index=True),
Column("password_hash", String(255), nullable=False),
Column("first_name", String(100), nullable=False, server_default=""),
Column("last_name", String(100), nullable=False, server_default=""),
# VARCHAR + CHECK instead of a native PostgreSQL enum: adding a role is a plain migration
Column(
"role",
Enum(
UserRole,
name="user_role",
native_enum=False,
length=20,
create_constraint=True,
values_callable=lambda roles: [role.value for role in roles],
),
nullable=False,
server_default=UserRole.trainee.value,
),
Column("is_active", Boolean, nullable=False, server_default="true"),
Column("is_superuser", Boolean, nullable=False, server_default="false"),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("last_login", DateTime(timezone=True)),
)
refresh_tokens = Table(
"refresh_tokens",
metadata,
Column("id", Integer, primary_key=True),
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
# Tokens issued by rotation from one login share a family: a reused token revokes it all
Column("family_id", String(32), nullable=False, index=True),
Column("token_hash", String(64), nullable=False, unique=True),
Column("expires_at", DateTime(timezone=True), nullable=False),
Column("revoked_at", DateTime(timezone=True)),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
)

View file

@ -1,96 +0,0 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from sqlalchemy import func, insert, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.tables import refresh_tokens
@dataclass
class RefreshClaim:
user_id: int
family_id: str
class RefreshTokenRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(
self, *, user_id: int, family_id: str, token_hash: str, expires_at: datetime
) -> None:
await self._session.execute(
insert(refresh_tokens).values(
user_id=user_id,
family_id=family_id,
token_hash=token_hash,
expires_at=expires_at,
)
)
await self._session.commit()
async def claim(self, token_hash: str) -> Optional[RefreshClaim]:
"""Use up a valid refresh token (it can be used only once).
The check and the revocation are one UPDATE, so of two simultaneous requests with the
same token only one wins. A token that was already used up means it leaked (or was
replayed): the whole family is revoked and None is returned.
"""
row = (
await self._session.execute(
update(refresh_tokens)
.where(
refresh_tokens.c.token_hash == token_hash,
refresh_tokens.c.revoked_at.is_(None),
refresh_tokens.c.expires_at > func.now(),
)
.values(revoked_at=func.now())
.returning(refresh_tokens.c.user_id, refresh_tokens.c.family_id)
)
).first()
if row is not None:
await self._session.commit()
return RefreshClaim(row.user_id, row.family_id)
used = (
await self._session.execute(
select(refresh_tokens.c.family_id).where(
refresh_tokens.c.token_hash == token_hash,
refresh_tokens.c.revoked_at.is_not(None),
)
)
).first()
if used is not None:
await self._revoke_family(used.family_id)
await self._session.commit()
return None
async def revoke_family_of(self, token_hash: str) -> None:
"""Log out: revoke the token and everything issued from it."""
family = (
await self._session.execute(
select(refresh_tokens.c.family_id).where(
refresh_tokens.c.token_hash == token_hash
)
)
).first()
if family is not None:
await self._revoke_family(family.family_id)
await self._session.commit()
async def revoke_all_of_user(self, user_id: int) -> None:
await self._session.execute(
update(refresh_tokens)
.where(refresh_tokens.c.user_id == user_id, refresh_tokens.c.revoked_at.is_(None))
.values(revoked_at=func.now())
)
await self._session.commit()
async def _revoke_family(self, family_id: str) -> None:
await self._session.execute(
update(refresh_tokens)
.where(refresh_tokens.c.family_id == family_id, refresh_tokens.c.revoked_at.is_(None))
.values(revoked_at=func.now())
)

View file

@ -1,98 +0,0 @@
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import delete, insert, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database.tables import users
from app.core.dto.user import UserDTO, UserRole
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
async def create(
self,
*,
email: str,
password_hash: str,
first_name: str = "",
last_name: str = "",
role: UserRole = UserRole.trainee,
) -> UserDTO:
query = (
insert(users)
.values(
email=email,
password_hash=password_hash,
first_name=first_name,
last_name=last_name,
role=role,
)
.returning(users)
)
try:
row = (await self._session.execute(query)).one()
await self._session.commit()
except IntegrityError as error:
await self._session.rollback()
raise EmailTakenError(email) from error
return UserDTO.model_validate(row)
async def get(self, user_id: int) -> Optional[UserDTO]:
row = (
await self._session.execute(select(users).where(users.c.id == user_id))
).first()
return UserDTO.model_validate(row) if row else None
async def get_credentials(self, email: str) -> Optional[Tuple[UserDTO, str]]:
"""The user and the hash of the password, for logging in."""
row = (
await self._session.execute(select(users).where(users.c.email == email))
).first()
return (UserDTO.model_validate(row), row.password_hash) if row else None
async def list(self, role: Optional[UserRole] = None) -> List[UserDTO]:
query = select(users).order_by(users.c.id)
if role is not None:
query = query.where(users.c.role == role)
return [UserDTO.model_validate(row) for row in await self._session.execute(query)]
async def update(self, user_id: int, values: Dict[str, Any]) -> Optional[UserDTO]:
"""Change the given columns, None if there is no such user."""
if not values:
return await self.get(user_id)
try:
row = (
await self._session.execute(
update(users)
.where(users.c.id == user_id)
.values(**values)
.returning(users)
)
).first()
await self._session.commit()
except IntegrityError as error:
await self._session.rollback()
raise EmailTakenError(values.get("email")) from error
return UserDTO.model_validate(row) if row else None
async def delete(self, user_id: int) -> bool:
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,9 +25,7 @@ class ExerciseDTO(DTOBase):
class TrainingDTO(DTOBase):
id: int | None = None
date: date
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
trainer: str | None = None
exercises: list[ExerciseDTO] = Field(default_factory=list)
@ -41,8 +39,6 @@ class ExerciseNameDTO(DTOBase):
class ExerciseSessionDTO(DTOBase):
"""One occurrence of an exercise in the history of that exercise."""
training_id: int | None = None
trainee_id: int | None = None
date: date
trainer: str | None = None
splitted_weight: bool = False

View file

@ -1,32 +0,0 @@
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, ConfigDict
class UserRole(str, Enum):
trainer = "trainer"
trainee = "trainee"
class UserDTO(BaseModel):
"""A user without the password hash: safe to return from the API."""
model_config = ConfigDict(from_attributes=True)
id: int
email: str
first_name: str
last_name: str
role: UserRole
is_active: bool
is_superuser: bool
created_at: datetime
last_login: datetime | None = None
class TrainerDTO(BaseModel):
"""What any signed-in user may know about a trainer: enough to pick one in a form."""
id: int
name: str

View file

@ -1,83 +0,0 @@
"""Passwords and tokens.
Passwords are hashed with scrypt from the standard library. Stored format:
scrypt$<n>$<r>$<p>$<salt, base64>$<hash, base64>. The parameters are stored with the hash, so
they can be raised later without invalidating existing passwords.
Access tokens are short-lived JWTs (HS256). Refresh tokens are opaque random strings, only their
hash is stored in the database.
"""
import base64
import hashlib
import hmac
import secrets
from datetime import datetime, timedelta, timezone
import jwt
from app.config import settings
_N, _R, _P = 2**14, 8, 1
_KEY_LENGTH = 32
JWT_ALGORITHM = "HS256"
def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes:
return hashlib.scrypt(
password.encode(), salt=salt, n=n, r=r, p=p, dklen=_KEY_LENGTH,
maxmem=128 * n * r * 2,
)
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
key = _derive(password, salt, _N, _R, _P)
encode = lambda value: base64.b64encode(value).decode() # noqa: E731
return f"scrypt${_N}${_R}${_P}${encode(salt)}${encode(key)}"
def verify_password(password: str, stored: str) -> bool:
try:
scheme, n, r, p, salt, key = stored.split("$")
if scheme != "scrypt":
return False
expected = base64.b64decode(key)
actual = _derive(password, base64.b64decode(salt), int(n), int(r), int(p))
except ValueError:
return False
return hmac.compare_digest(actual, expected)
# Verified when the email is unknown, so that a login takes the same time either way
DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
def create_access_token(user_id: int, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iat": now,
"exp": now + timedelta(minutes=settings.ACCESS_TOKEN_TTL_MINUTES),
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict:
"""Payload of a valid access token, jwt.PyJWTError otherwise (bad signature, expired...)."""
return jwt.decode(
token,
settings.JWT_SECRET,
algorithms=[JWT_ALGORITHM],
options={"require": ["sub", "exp"]},
)
def new_refresh_token() -> str:
return secrets.token_urlsafe(48)
def hash_token(token: str) -> str:
# The token is random with 384 bits of entropy: a plain fast hash is enough
return hashlib.sha256(token.encode()).hexdigest()

View file

@ -15,8 +15,6 @@ logging.basicConfig(
@asynccontextmanager
async def lifespan(app: FastAPI):
if len(settings.JWT_SECRET) < 32:
raise RuntimeError("JWT_SECRET must be set to a random string of 32+ characters")
# Schema is managed by Alembic: run `alembic upgrade head` before starting the app
engine = create_engine(settings.get_postgres_database_url())
app.state.session_factory = create_session_factory(engine)

View file

@ -1,24 +0,0 @@
from typing import Literal, Optional
from pydantic import BaseModel
class LoginRequest(BaseModel):
email: str
password: str
# "web": the refresh token goes only into an httpOnly cookie, script code never sees it.
# "native" (iOS...): it is returned in the body, the app keeps it in the Keychain.
client: Literal["web", "native"] = "native"
class RefreshRequest(BaseModel):
"""Native clients send the refresh token in the body, the web client in the cookie."""
refresh_token: Optional[str] = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int # seconds
refresh_token: Optional[str] = None # None for the web client: it is in the cookie

View file

@ -15,8 +15,6 @@ class SessionMetricsResponse(BaseModel):
class SessionRowResponse(BaseModel):
training_id: Optional[int]
trainee_id: Optional[int]
date: date
trainer: Optional[str]
splitted_weight: bool

View file

@ -1,32 +0,0 @@
from datetime import date
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
class ApproachUpdate(BaseModel):
weight: float = Field(ge=0)
reps: int = Field(gt=0)
class ExerciseUpdate(BaseModel):
name: str = Field(max_length=255)
splitted_weight: bool = False
approaches: List[ApproachUpdate] = Field(default_factory=list)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("name must not be empty")
return value
class TrainingUpdate(BaseModel):
"""The whole training as it should be after the edit (PUT replaces it)."""
date: date
trainee_id: int
trainer_id: Optional[int] = None # None: a solo training
exercises: List[ExerciseUpdate] = Field(default_factory=list)

View file

@ -1,41 +0,0 @@
import re
from typing import Optional
from pydantic import BaseModel, Field, field_validator
from app.core.dto.user import UserRole
EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _normalize_email(value: str) -> str:
value = value.strip().lower()
if not EMAIL_PATTERN.match(value) or len(value) > 254:
raise ValueError("invalid email")
return value
class UserCreate(BaseModel):
email: str
password: str = Field(min_length=8, max_length=128)
first_name: str = Field(default="", max_length=100)
last_name: str = Field(default="", max_length=100)
role: UserRole = UserRole.trainee
_email = field_validator("email")(_normalize_email)
class UserUpdate(BaseModel):
"""Partial update: only the fields that were sent are changed."""
email: Optional[str] = None
password: Optional[str] = Field(default=None, min_length=8, max_length=128)
first_name: Optional[str] = Field(default=None, max_length=100)
last_name: Optional[str] = Field(default=None, max_length=100)
role: Optional[UserRole] = None
is_active: Optional[bool] = None
@field_validator("email")
@classmethod
def normalize_email(cls, value: Optional[str]) -> Optional[str]:
return None if value is None else _normalize_email(value)

View file

@ -1,4 +1,4 @@
**08.09.2023 (Пан Степан)**
**08.09.2023 (с28)**
| Упражнение | Вес | Подходы |
| ---------------------------------- | ------------------ | ----------- |
@ -8,7 +8,7 @@
| Жим от груди лежа (гантели) | 16х2 | 12-12-12-12 |
| Брусья (гравитрон) | 47 | 12-9-9 |
**11.09.2023 (Пан Степан)**
**11.09.2023 (с29)**
| Упражнение | Вес | Подходы |
| ------------------------ | -------------- | ----------------- |
@ -18,7 +18,7 @@
| Трицепс машина | 77 | 12-12-12-10 |
| Молоты стоя | 9x2 | 12-12-12-12 |
**15.09.2023 (Пан Степан)** 
**15.09.2023 (с30)** 
| Упражнение | Вес | Подходы |
| ---------------------------------- | ----- | ----------- |
@ -28,7 +28,7 @@
| Жим от груди лежа (гантели) | 14x2-16x2 | 12-12-12-12 |
| Сведение рук пек дек| 37-27 | 10-9-12 |
**18.09.2023 (Пан Степан)**
**18.09.2023 (с31)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -37,7 +37,7 @@
|Дельт-машина|37|15-15-15-15|
|Гантели|18|10х2-10х2-10х2|
**21.09.2023 (Пан Степан)**
**21.09.2023 (с32)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -47,7 +47,7 @@
|Жим от плеч вверх (гантели)|14x2-16x2|12-12-11-10|
|Отжимания||8-8-8|
**23.09.2023 (Пан Степан)**
**23.09.2023 (с33)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -57,7 +57,7 @@
|Дельт-машина|37-37-47-47|12-15-12-12|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-10|
**23.09.2023 (Пан Степан)**
**23.09.2023 (с34)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -68,7 +68,7 @@
|Баттерфляй|47|12-12-12|
|Брусья (гравитрон)|47|8-8-8|
**02.10.2023 (соло)**
**02.10.2023 (solo)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -80,7 +80,7 @@
|Подъем штанги стоя |20|12-12-11|
|Трицепс машина|67-77|15-12-12|
**04.10.2023 (Пан Степан)**
**04.10.2023 (с36)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -90,7 +90,7 @@
|Отжимания|+5|4-5-4|
|Подъем ног в висе||10-10-8|
**06.10.2023 (Пан Степан)**
**06.10.2023 (с37)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -100,7 +100,7 @@
|Махи рукой в кроссовере|6.5x2|12-15-14|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
**09.10.2023 (Пан Степан)**
**09.10.2023 (с38)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -110,7 +110,7 @@
|Жим от груди сидя в тренажере (кирпичики)|67-77|12-12-12-12|
|Брусья (гравитрон)|47|10-8-10-10|
**11.10.2023 (Пан Степан)**
**11.10.2023 (с39)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -121,7 +121,7 @@
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-10|
|Отведение в рук в наклоне||12-12-12|
**16.10.2023 (Пан Степан)**
**16.10.2023 (с40)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -131,7 +131,7 @@
|Жим от плеч вверх (гантели)|14х14|12-12-12-12|
|Брусья (гравитрон)|47|11-12-9|
**18.10.2023 (Пан Степан)**
**18.10.2023 (с41)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -141,7 +141,7 @@
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12-10|
|Подъем ног в висе||10-10-9|
**20.10.2023 (соло)**
**20.10.2023 (solo)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -152,7 +152,7 @@
|Сведение рук пек дек|27-27-37-37|15-15-15-15|
|Подъем ног в висе||10-8-7|
**23.10.2023 (соло)**
**23.10.2023 (solo)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -162,7 +162,7 @@
|Отведение рук пек дек|17|15-12-12-12|
|Жим от плеч вверх в тренажере (кирпичики)|27|15-12-12-9|
**25.10.2023 (Пан Степан)**
**25.10.2023 (с42)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -172,7 +172,7 @@
|Баттерфляй|47-57|15-12-9-10|
|Жим от груди лежа (гантели)|16x2-16x2-14x2|11-10-10|
**27.10.2023 (Пан Степан)**
**27.10.2023 (с43)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -182,7 +182,7 @@
|Отведение рук пек дек|17|12-12-12|
|Скручивания на мяче||12-12-12|
**27.10.2023 (Пан Степан)**
**27.10.2023 (с44)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -193,7 +193,7 @@
|Брусья (гравитрон)|47|12-12-11|
|Разведение рук с гантелей из-за головы|6|10-10-10|
**01.11.2023 (Пан Степан)**
**01.11.2023 (с45)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -203,7 +203,7 @@
|Дельт-машина |37|15-15-15-15|
|Жим от плеч вверх в тренажере (кирпичики)|27|10-10-10-8|
**06.11.2023 (Пан Степан)**
**06.11.2023 (с46)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -215,7 +215,7 @@
|Разгибание кик-бек в наклоне|5х2|12-12-12|
**10.11.2023 (Пан Степан)** 
**10.11.2023 (с48)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -225,7 +225,7 @@
|Махи гантелей в стороны стоя|7х2-8х2|15-15-15-15|
|Разведение рук в наклоне|5х2|15-15-15|
**13.11.2023 (Пан Степан)** 
**13.11.2023 (с49)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -236,7 +236,7 @@
|Брусья (гравитрон)|37|12-12-8|
|Отжимания на трицепс в кроссовере|27|12-12-12|
**15.11.2023 (Пан Степан)** 
**15.11.2023 (с50)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -245,7 +245,7 @@
|Баттерфляй|47|15-15-14|
|Молоты стоя|9x2|12-12-12|
**17.11.2023 (Пан Степан)** 
**17.11.2023 (с51)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -255,7 +255,7 @@
|Махи рукой в кроссовере|9x2-11x2|12-12-12-12|
|Жим от плеч вверх (гантели)|14x2|9-8-10-10|
**20.11.2023 (Пан Степан)** 
**20.11.2023 (с52)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -265,7 +265,7 @@
|Отжимания на брусьях|0|6-7-6|
|Отжимания на трицепс в кроссовере|39|15-12-12-12|
**22.11.2023 (соло)** 
**22.11.2023 (solo)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -276,7 +276,7 @@
|Молоты стоя|9x2-10x2|12-12-11-11|
|Скручивания на мяче||15-12-12-12|
**24.11.2023 (Пан Степан)** 
**24.11.2023 (с53)** 
| Упражнение | Вес | Подходы |
| -------------------------------- | ------- | ----------- |
@ -287,7 +287,7 @@
| Жим от плеч вверх (гантели)| 12x2 | 12-12-12-12 |
| | | |
**27.11.2023 (Пан Степан)** 
**27.11.2023 (с54)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -296,7 +296,7 @@
|Тяга горизонтального блока (1 рука)|31.7x2-36.2x2|12-12-12-12|
|Брусья|0-0-черная-черная|6-6-7-7|
**29.11.2023 (Пан Степан)** 
**29.11.2023 (с55)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -306,7 +306,7 @@
|Подъем штанги стоя|20|12-11-9-9|
|Молоты стоя|20х2-20х2-18х2|10-9-12|
**01.12.2023 (Пан Степан)** 
**01.12.2023 (с56)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -316,7 +316,7 @@
|Махи рукой в кроссовере |9x2-13x2|12-10-10|
|Разведение рук пек дек|27|10-8-8|
**11.12.2023 (Пан Степан)** 
**11.12.2023 (с57)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -346,7 +346,7 @@
|Жим от плеч вверх (гантели)|13x2|15-12-9-9|
|Отведение рук пек дек|17-27-27-17|15-11-10-12|
**20.12.2023 (Пан Степан)**
**20.12.2023 (с58)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -356,7 +356,7 @@
|Сведение рук пек дек|20|12-12-10|
|Тяга гантели в наклоне|18x2|12-11-10|
**23.12.2023 (Пан Степан)**
**23.12.2023 (с59)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -365,7 +365,7 @@
|Махи рукой в кроссовере|11x2|12-12-12|
|Разведение рук пек дек|17|12-12-12-12|
**27.12.2023 (Пан Степан)**
**27.12.2023 (с60)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -375,7 +375,7 @@
|Сведение рук пек дек|37-47-47-37|12-10-10-10|
|Гравитрон|36-57|10-10-7|
**27.12.2023 (Пан Степан)**
**27.12.2023 (с61)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -396,7 +396,7 @@
|Сведение рук пек дек|27-37|12-12-10-7|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-9|
**15.01.2024 (Пан Степан)**
**15.01.2024 (с63)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -406,7 +406,7 @@
|Подъем штанги стоя|20|13-12-12|
|Жим от плеч вверх (гантели)|20х2|7-7-12|
**17.01.2024 (Пан Степан)**
**17.01.2024 (с64)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -416,7 +416,7 @@
|Отжимания на трицепс в кроссовере|36.2|13-12-12|
|Трицепс машина|67-67-57|12-9-10|
**19.01.2024 (Пан Степан)**
**19.01.2024 (с65)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -426,7 +426,7 @@
|Разведение рук пек дек|27|12-12-10|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-10|
**22.01.2024 (Пан Степан)**
**22.01.2024 (с66)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -437,7 +437,7 @@
|Тяга горизонтального блока (1 рука)|18x2-22x2|12-12-12-12|
|Баттерфляй|47-57|12-12-9|
**27.01.2024 (Пан Степан-7)**
**27.01.2024 (с67-7)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -448,7 +448,7 @@
|Махи рукой в кроссовере|9x2-11x2|12-12-12|
|Разведение рук пек дек|27|10|
**29.01.2024 (Пан Степан-8)**
**29.01.2024 (с68-8)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -458,7 +458,7 @@
|Сведение рук пек дек|37-37-47-37|15-15-8-11|
|Тяга вертикального блока обратным хватом|49.8|12-10-8|
**31.01.2024 (Пан Степан-9)**
**31.01.2024 (с69-9)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -468,7 +468,7 @@
|Дельт-машина|27|12-12-12-12|
|Жим от плеч вверх (гантели)|14x2|12-12-11-10|
**02.02.2024 (Пан Степан-10)**
**02.02.2024 (с70-10)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -477,7 +477,7 @@
|Тяга горизонтального блока|49.8-54.5|12-10-12|
|Тяга гантели в наклоне|20x2|12-12-12|
**19.02.2024 (Пан Степан-11)**
**19.02.2024 (с71-11)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -488,7 +488,7 @@
|Баттерфляй|57-47|10-10-9|
|Брусья|47|10-10|
**21.02.2024 (Пан Степан-12)**
**21.02.2024 (с72-12)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -498,7 +498,7 @@
|Дельт-машина|47-47-37-37|12-11-12-12|
|Отведение рук в стороны с гантелями в наклоне 90⁰|14ш|12-12-12|
**26.02.2024 (Пан Степан-13)**
**26.02.2024 (с73-13)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -509,7 +509,7 @@
|Баттерфляй|47|12-11-10|
|Отжимания||9-8|
**28.02.2024 (Пан Степан-14)**
**28.02.2024 (с74-14)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -520,7 +520,7 @@
|Дельт-машина|16|12-12-12|
|Разведение рук в наклоне|6x2|12-12-12|
**01.03.2024 (Пан Степан-15)**
**01.03.2024 (с75-15)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -530,7 +530,7 @@
|Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12|
|Сведение рук пек дек|47-37|6-11-10|
**04.03.2024 (Пан Степан-16)**
**04.03.2024 (с76-16)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -540,7 +540,7 @@
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|Разведение рук в наклоне|6x2|12-12-12|
**06.03.2024 (Пан Степан-17)**
**06.03.2024 (с77-17)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -551,7 +551,7 @@
|Баттерфляй|57-47|10-15-9-11|
|Трицепс машина|67-77-67|12-10-12|
**13.03.2024 (Пан Степан-18)**
**13.03.2024 (с78-18)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -562,7 +562,7 @@
|Жим от груди лежа (штанга)|30|12-12-12|
|Сведение рук пек дек|37|9-11|
**15.03.2024 (Пан Степан-19)**
**15.03.2024 (с79-19)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -572,7 +572,7 @@
|Махи гантелей в стороны стоя|8x2|12-12-12|
|Жим от плеч вверх (гантели)|14x2|12-12-11|
**18.03.2024 (Пан Степан-20)**
**18.03.2024 (с80-20)**
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------- | -------------- |
@ -582,7 +582,7 @@
| Жим от груди сидя в тренажере (кирпичики) | 40-50 | 12-12-12 |
| Брусья (гравитрон)| 47 | 12-11-10 |
**28.03.2024 (Пан Степан-21)**
**28.03.2024 (с81-21)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -593,7 +593,7 @@
|Жим от груди сидя в тренажере (кирпичики)|67-67-57|12-10-10|
|Брусья (гравитрон)|37-47|6-8|
**01.04.2024 (Пан Степан-22)**
**01.04.2024 (с82-22)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -605,7 +605,7 @@
|Жим от плеч вверх (гантели)|16x2|6-12-9|
|Сведение рук пек дек|47-37|9-12-11|
**05.04.2024 (Пан Степан-23)**
**05.04.2024 (с83-23)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -615,7 +615,7 @@
|Разведение рук пек дек|27|12-12-11|
|Подъем ног в висе||10-10-10|
**08.04.2024 (Пан Степан-24)** 
**08.04.2024 (с84-24)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -625,7 +625,7 @@
|Жим от плеч вверх (штанга)|40-50-55|12-12-10-8|
|Сведение рук пек дек|47-37|8-11-10-10|
**10.04.2024 (Пан Степан-25)**
**10.04.2024 (с85-25)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -635,7 +635,7 @@
|Махи гантелей в стороны стоя|8x2|12-12-12|
|Отведение рук пек дек|27|10-10-9|
**12.04.2024 (Пан Степан-26)**
**12.04.2024 (c86-26)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -646,7 +646,7 @@
|Жим от груди сидя в тренажере (кирпичики)|77|9-10-9|
|Сведение рук пек дек|37-47-37|12-8-11|
**14.04.2024 (соло-27)** 
**14.04.2024 (solo-27)** 
|Упражнение|Вес|Подходы|
|---|---|---|
@ -658,7 +658,7 @@
|Тяга гантели в наклоне|20x2|12-12-12|
|Жим от груди сидя в тренажере (кирпичики)|67-77|15-12-7|
**15.04.2024 (Пан Степан-28)**
**15.04.2024 (с87-28)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -668,7 +668,7 @@
|Жим от плеч вверх (гантели)|16x2-11x2-14x2|12-10-10|
|Разведение рук пек дек|27-27-27-17|12-11-10-12|
**19.04.2024 (Пан Степан-29)**
**19.04.2024 (с88-29)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -678,7 +678,7 @@
|Жим от груди сидя в тренажере (блинчики)|20x2-25x2|12-12-12|
|Сведение рук пек дек|47-37|11-9-12-11|
**22.04.2024 (Пан Степан-30)**
**22.04.2024 (с89-30)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -688,7 +688,7 @@
|Дельт-машина|47|12-12-12|
|Разведение рук в наклоне|8х2|12-12-12|
**26.04.2024 (Пан Степан-31)**
**26.04.2024 (с90-31)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -699,7 +699,7 @@
|Брусья|47|12-12-12|
|Разгибание кик-бек в наклоне|8x2-9x2-8x2|12-10-12|
**01.05.2024 (Пан Степан-32)**
**01.05.2024 (с91-32)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -709,7 +709,7 @@
|Жим от плеч вверх (гантели)|16x2|12-10-10|
|Разведение рук в наклоне|8x2|12-12-11|
**03.05.2024 (Пан Степан-33)**
**03.05.2024 (с92-33)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -719,7 +719,7 @@
|Сведение рук пек дек|37|12-12-11|
|Трицепс машина|67-87-77|12-11-12|
**06.05.2024 (Пан Степан-34)**
**06.05.2024 (с93-34)**
| Упражнение | Вес | Подходы |
| ------------------------------------------- | -------- | -------------- |
@ -729,7 +729,7 @@
| Разведение рук пек дек| 27 | 12-11-12 |
| Жим от плеч вверх в тренажере (кирпичики)| 37 | 12-11-7 |
**21.08.2024 (Пан Степан-69)**
**21.08.2024 (с123-69)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -739,7 +739,7 @@
|Жим от груди сидя в тренажере (блинчики)|20x2-30x2-30x2|12-12-10|
|Баттерфляй|57|12-12-12|
**19.08.2024 (Пан Степан-68)**
**19.08.2024 (с122-68)**
| **Упражнение** | **Вес** | **Подходы** |
| ------------------------------------ | -------------- | ------------- |
@ -750,7 +750,7 @@
| | | |
| | | |
**14.08.2024 (Пан Степан-67)**
**14.08.2024 (с121-67)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -761,7 +761,7 @@
|Баттерфляй|57|12-10-11|
|Брусья|37|9-10|
**12.08.2024 (Пан Степан-66)**
**12.08.2024 (с120-66)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -771,7 +771,7 @@
|Жим от плеч вверх (гантели)|18х2|10-9-7|
|Разведение рук пек дек|37|12-10-10|
**08.08.2024 (Пан Степан-65)**
**08.08.2024 (с119-65)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -782,7 +782,7 @@
|Баттерфляй|57|12-12-12|
|Брусья (гравитрон)|37|8-10-9|
**07.08.2024 (Пан Степан-64)**
**07.08.2024 (с118-64)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -791,7 +791,7 @@
|Разведение рук пек дек|37-37-47-47|12-12-12-12|
|Жим от плеч вверх (гантели)|16х2-18х2|11-8-8-8|
**05.08.2024 (Пан Степан-63)**
**05.08.2024 (с117-63)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -801,7 +801,7 @@
|Жим от груди лежа (гантели)|16х2-22х2|12-12-12-8|
|Сведение рук пек дек|37|12-12-12|
**02.08.2024 (Пан Степан-62)**
**02.08.2024 (с116-62)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -811,7 +811,7 @@
|Дельт-машина|20|12-12-12|
|Разведение рук пек дек|37|10-10|
**31.07.2024 (Пан Степан-61)**
**31.07.2024 (с115-61)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -821,7 +821,7 @@
|Сведение рук пек дек|37-47|12-12-12-9|
|Отжимания|10|9-8-8|
**29.07.2024 (Пан Степан-60)**
**29.07.2024 (с114-60)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -830,7 +830,7 @@
|Жим от плеч вверх в тренажере (кирпичики)|20-30-30-25|12-12-10-12|
|Махи гантелей в стороны стоя|10х2|12-12-12-12|
**26.07.2024 (Пан Степан-59)**
**26.07.2024 (с113-59)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -840,7 +840,7 @@
|Отжимания|+10|12-10-8|
|Баттерфляй|57-57-47|10-12-12-12|
**25.07.2024 (Пан Степан-58)**
**25.07.2024 (с112-58)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -850,7 +850,7 @@
|Разведение рук пек дек|27|12-12-12|
|Махи гантелей в стороны стоя|8х8-9х9|12-12-12-12|
**22.07.2024 (Пан Степан-57)**
**22.07.2024 (с111-57)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -861,7 +861,7 @@
|Сведение рук пек дек|37|12-12-11|
|Трицепс машина|77|12-12-12|
**17.07.2024 (Пан Степан-56)**
**17.07.2024 (с110-56)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -871,7 +871,7 @@
|Разведение рук пек дек|27|12-12-10|
|Жим от плеч вверх (гантели)|16х2|12-12|
**15.07.2024 (Пан Степан-55)**
**15.07.2024 (с109-55)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -881,7 +881,7 @@
|Жим от груди сидя в тренажере (блинчики)|33.5х2-43.5х2|12-12-10-10|
|Брусья|47|9-9-9|
**10.07.2024 (Пан Степан-54)**
**10.07.2024 (с108-54)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -891,7 +891,7 @@
|Дельт-машина |37|12-12-12|
|Разведение рук пек дек|27|12-11-11|
**08.07.2024 (Пан Степан-53)**
**08.07.2024 (с107-53)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -901,7 +901,7 @@
|Сведение рук пек дек|37|12-12-12|
|Брусья (гравитрон)|47|12-12-12-12|
**28.06.2024 (Пан Степан-52)**
**28.06.2024 (с106-52)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -911,7 +911,7 @@
|Жим от плеч вверх (гантели)|14х2|12-12-12|
|Разведение рук пек дек|27|12-12-12|
**26.06.2024 (Пан Степан-51)**
**26.06.2024 (с105-51)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -922,7 +922,7 @@
|Сведение рук пек дек|47-37|10-14-13|
|Брусья|37|12-9-8|
**21.06.2024 (соло-50)**
**21.06.2024 (solo-50)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -932,7 +932,7 @@
|Разведение рук пек дек|17-27|12-11-11-8|
|Жим от плеч вверх в тренажере (кирпичики)|37|12-11-9-9|
**19.06.2024 (Пан Степан-49)**
**19.06.2024 (с104-49)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -943,7 +943,7 @@
|Сведение рук пек дек|47|9-9-8|
|Брусья|37|9-9-9|
**17.06.2024 (Пан Степан-48)**
**17.06.2024 (с103-48)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -953,7 +953,7 @@
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|Разведение рук пек дек|17|12-12-12|
**12.06.2024 (Пан Степан-47)**
**12.06.2024 (с102-47)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -963,7 +963,7 @@
|Жим от груди сидя в тренажере (блинчики)|20x2-22x2|12-12-12-12|
|Жим от груди лежа (гантели)|16x2|10-12|
**10.06.2024 (Пан Степан-46)**
**10.06.2024 (с101-46)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -983,7 +983,7 @@
|Тяга штанги в наклоне|40-45|12-12-12-10|
|Дельт-машина |37-47|15-12-12-12|
**05.06.2024 (Пан Степан-44)**
**05.06.2024 (с100-44)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -1013,7 +1013,7 @@
|Жим от груди сидя в тренажере (кирпичики)|87-87-77|11-9-12-10|
|Трицепс машина|67-77|12-12-12-12|
**20.05.2024 (Пан Степан-40)**
**20.05.2024 (c98-40)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -1024,7 +1024,7 @@
|Баттерфляй|57-47|10-12-12|
**17.05.2024 (Пан Степан-39)**
**17.05.2024 (c97-39)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -1036,7 +1036,7 @@
|Трицепс машина|67-77|12-12-12|
**15.05.2024 (Пан Степан-38)**
**15.05.2024 (c96-38)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -1046,7 +1046,7 @@
|Жим от груди лежа (гантели)|16x2-18x2|12-12-9|
|Брусья (гравитрон)|37|12-11-10|
**13.05.2024 (Пан Степан-37)**
**13.05.2024 (c95-37)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -1055,7 +1055,7 @@
|Жим от плеч вверх (гантели)|16x2-16x2-16x2|12-11-10|
|Махи гантелей в стороны стоя|7x2|12-15-15|
**10.05.2024 (Пан Степан-36)**
**10.05.2024 (c94-36)**
|Упражнение|Вес|Подходы|
|---|---|---|
@ -1065,7 +1065,7 @@
|Жим от груди сидя в тренажере (блинчики)|15х2-25х2|14-12-12-12|
|Сведение рук пек дек|37|12-12-12|
**08.05.2024 (соло-35)**
**08.05.2024 (solo-35)**
| Упражнение | Вес | Подходы |
| -------------------------------------- | ------------------------------------- | ----------- |

File diff suppressed because it is too large Load diff

View file

@ -1,46 +0,0 @@
"""add users
Revision ID: 97535a10551d
Revises: 08ba8bee6328
Create Date: 2026-10-23 17:27:16.608304
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '97535a10551d'
down_revision: Union[str, Sequence[str], None] = '08ba8bee6328'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=254), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('first_name', sa.String(length=100), server_default='', nullable=False),
sa.Column('last_name', sa.String(length=100), server_default='', nullable=False),
sa.Column('role', sa.Enum('trainer', 'trainee', name='user_role', native_enum=False, create_constraint=True, length=20), server_default='trainee', nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
sa.Column('is_superuser', sa.Boolean(), server_default='false', nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_login', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View file

@ -1,51 +0,0 @@
"""add refresh tokens
Also makes the owner a superuser (the first one, who creates the others).
Revision ID: 57f1f0337033
Revises: b1c2d3e4f5a6
Create Date: 2026-10-23 18:00:02.101967
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '57f1f0337033'
down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('refresh_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('family_id', sa.String(length=32), nullable=False),
sa.Column('token_hash', sa.String(length=64), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
# ### end Alembic commands ###
# Somebody has to be able to create users: the owner of the data is the first superuser
op.execute("UPDATE users SET is_superuser = true WHERE email = 'pro100ton@gmail.com'")
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
# ### end Alembic commands ###

View file

@ -1,82 +0,0 @@
"""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

@ -5,4 +5,3 @@ pytest-asyncio==1.4.0
mypy==1.17.1
types-grpcio==1.0.0.20250703
types-protobuf==6.30.2.20250822
httpx==0.28.1

View file

@ -6,4 +6,3 @@ python-dotenv==1.1.1
sqlalchemy[asyncio]==2.1.6
asyncpg==0.32.0
alembic==1.20.1
PyJWT==2.10.1

View file

@ -18,8 +18,7 @@ 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.user_repository import UserRepository
from app.core.dto.user import UserRole
from app.core.database.repository import TrainingRepository
TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
@ -63,43 +62,6 @@ 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:
# 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()
await TrainingRepository(session).delete_all()
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,
)
@pytest.fixture
async def client(session, monkeypatch):
"""HTTP client of the app on the test database (needs the `session` fixture's clean DB)."""
from httpx import ASGITransport, AsyncClient
from app.main import app
monkeypatch.setattr(settings, "JWT_SECRET", "test-secret-" + "x" * 40)
engine = create_engine(TEST_DATABASE_URL)
app.state.session_factory = create_session_factory(engine)
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as http_client:
yield http_client
await engine.dispose()

View file

@ -1,289 +0,0 @@
"""Login, token rotation and who may do what, through the real HTTP API."""
from datetime import date
import pytest
from app.config import settings
from app.core.database.repository import TrainingRepository
from app.core.database.user_repository import UserRepository
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
from app.core.dto.user import UserRole
from app.core.security import hash_password
PASSWORD = "correct horse"
async def make_user(session, email, role=UserRole.trainee, superuser=False, **fields):
repository = UserRepository(session)
user = await repository.create(
email=email, password_hash=hash_password(PASSWORD), role=role, **fields
)
if superuser:
user = await repository.update(user.id, {"is_superuser": True})
return user
async def make_training(session, trainee, trainer=None, exercise="Жим", weight=50):
return await TrainingRepository(session).save(
TrainingDTO(
date=date(2025, 1, 1),
trainee_id=trainee.id,
trainer_id=trainer.id if trainer else None,
exercises=[
ExerciseDTO(name=exercise, approaches=[ApproachDTO(weight=weight, reps=10)])
],
)
)
async def login(http, email, **extra):
return await http.post(
"/api/v1/auth/login/", json={"email": email, "password": PASSWORD, **extra}
)
async def auth_headers(client, email):
token = (await login(client, email)).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
# --- login and tokens ---------------------------------------------------------------------
async def test_login_returns_working_tokens(client, session):
await make_user(session, "a@example.com", first_name="Anna")
response = await login(client, " A@Example.com ")
assert response.status_code == 200
body = response.json()
assert body["token_type"] == "bearer" and body["expires_in"] == 15 * 60
assert body["refresh_token"]
me = await client.get(
"/api/v1/auth/me/", headers={"Authorization": f"Bearer {body['access_token']}"}
)
assert me.json()["email"] == "a@example.com" and "password" not in me.text
assert (await UserRepository(session).list())[0].last_login is not None
async def test_wrong_password_unknown_email_and_inactive_user_are_rejected(client, session):
user = await make_user(session, "a@example.com")
wrong = await client.post(
"/api/v1/auth/login/", json={"email": "a@example.com", "password": "nope"}
)
unknown = await login(client, "nobody@example.com")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json() == unknown.json() # nothing tells which of the two was wrong
await UserRepository(session).update(user.id, {"is_active": False})
assert (await login(client, "a@example.com")).status_code == 401
async def test_web_client_gets_the_refresh_token_only_in_an_httponly_cookie(client, session):
await make_user(session, "a@example.com")
response = await login(client, "a@example.com", client="web")
assert response.json()["refresh_token"] is None
cookie = response.headers["set-cookie"].lower()
assert "refresh_token=" in cookie and "httponly" in cookie and "samesite=strict" in cookie
assert "path=/api/v1/auth" in cookie
refreshed = await client.post("/api/v1/auth/refresh/") # the cookie jar sends it
assert refreshed.status_code == 200
assert refreshed.json()["refresh_token"] is None
assert refreshed.headers["set-cookie"] != response.headers["set-cookie"] # rotated
async def test_refresh_token_works_once_and_reuse_revokes_the_whole_chain(client, session):
await make_user(session, "a@example.com")
first = (await login(client, "a@example.com")).json()["refresh_token"]
second = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first})
assert second.status_code == 200
newest = second.json()["refresh_token"]
assert newest != first
# The old token again: somebody has a copy. Everything issued from it dies.
replay = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first})
assert replay.status_code == 401
after = await client.post("/api/v1/auth/refresh/", json={"refresh_token": newest})
assert after.status_code == 401
async def test_logout_revokes_the_refresh_token(client, session):
await make_user(session, "a@example.com")
refresh_token = (await login(client, "a@example.com")).json()["refresh_token"]
await client.post("/api/v1/auth/logout/", json={"refresh_token": refresh_token})
response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token})
assert response.status_code == 401
async def test_refresh_fails_for_a_deactivated_user(client, session):
user = await make_user(session, "a@example.com")
refresh_token = (await login(client, "a@example.com")).json()["refresh_token"]
await UserRepository(session).update(user.id, {"is_active": False})
response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token})
assert response.status_code == 401
async def test_missing_bad_and_expired_access_tokens_are_401(client, session, monkeypatch):
await make_user(session, "a@example.com")
headers = await auth_headers(client, "a@example.com")
assert (await client.get("/api/v1/trainings/")).status_code == 401
bad = {"Authorization": headers["Authorization"][:-3] + "abc"}
assert (await client.get("/api/v1/trainings/", headers=bad)).status_code == 401
assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 200
monkeypatch.setattr(settings, "ACCESS_TOKEN_TTL_MINUTES", -1)
expired = await auth_headers(client, "a@example.com")
assert (await client.get("/api/v1/trainings/", headers=expired)).status_code == 401
async def test_deactivated_user_loses_access_before_the_token_expires(client, session):
user = await make_user(session, "a@example.com")
headers = await auth_headers(client, "a@example.com")
await UserRepository(session).update(user.id, {"is_active": False})
assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 401
async def test_health_is_open(client):
assert (await client.get("/api/v1/health/")).status_code == 200
# --- who may do what ----------------------------------------------------------------------
async def test_everyone_sees_only_their_trainings(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
coach = await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан")
other_coach = await make_user(session, "coach2@example.com", UserRole.trainer)
admin = await make_user(session, "admin@example.com", superuser=True)
annas = await make_training(session, anna, trainer=coach)
boriss = await make_training(session, boris, trainer=other_coach)
async def visible(email):
response = await client.get(
"/api/v1/trainings/", headers=await auth_headers(client, email)
)
return {item["id"] for item in response.json()}
assert await visible("anna@example.com") == {annas}
assert await visible("boris@example.com") == {boriss}
assert await visible("coach@example.com") == {annas}
assert await visible("admin@example.com") == {annas, boriss}
# a training of someone else doesn't exist for the user
headers = await auth_headers(client, "anna@example.com")
assert (await client.get(f"/api/v1/trainings/{boriss}/", headers=headers)).status_code == 404
assert (await client.get(f"/api/v1/trainings/{annas}/", headers=headers)).status_code == 200
def update_body(trainee_id, trainer_id=None):
return {
"date": "2025-02-02",
"trainee_id": trainee_id,
"trainer_id": trainer_id,
"exercises": [{"name": "Тяга", "approaches": [{"weight": 30, "reps": 8}]}],
}
async def test_only_the_trainee_or_a_superuser_can_change_a_training(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
coach = await make_user(session, "coach@example.com", UserRole.trainer)
await make_user(session, "admin@example.com", superuser=True)
training_id = await make_training(session, anna, trainer=coach)
url = f"/api/v1/trainings/{training_id}/"
coach_headers = await auth_headers(client, "coach@example.com")
boris_headers = await auth_headers(client, "boris@example.com")
anna_headers = await auth_headers(client, "anna@example.com")
assert (await client.put(url, json=update_body(anna.id, coach.id), headers=coach_headers)).status_code == 403
assert (await client.delete(url, headers=coach_headers)).status_code == 403
assert (await client.put(url, json=update_body(anna.id), headers=boris_headers)).status_code == 404
assert (await client.delete(url, headers=boris_headers)).status_code == 404
# the trainee can't hand the training over to somebody else, the superuser can
assert (await client.put(url, json=update_body(boris.id), headers=anna_headers)).status_code == 403
ok = await client.put(url, json=update_body(anna.id), headers=anna_headers)
assert ok.status_code == 200 and ok.json()["trainer"] is None
admin_headers = await auth_headers(client, "admin@example.com")
moved = await client.put(url, json=update_body(boris.id), headers=admin_headers)
assert moved.status_code == 200 and moved.json()["trainee_id"] == boris.id
assert (await client.delete(url, headers=admin_headers)).status_code == 200
async def test_user_management_is_for_superusers_only(client, session):
await make_user(session, "anna@example.com")
admin = await make_user(session, "admin@example.com", superuser=True)
anna_headers = await auth_headers(client, "anna@example.com")
admin_headers = await auth_headers(client, "admin@example.com")
new_user = {"email": "new@example.com", "password": "12345678"}
assert (await client.get("/api/v1/users/", headers=anna_headers)).status_code == 403
assert (await client.post("/api/v1/users/", json=new_user, headers=anna_headers)).status_code == 403
assert (await client.post("/api/v1/users/", json=new_user)).status_code == 401
assert (await client.post("/api/v1/users/", json=new_user, headers=admin_headers)).status_code == 201
# the new user can log in with the password the superuser chose
created = await client.post(
"/api/v1/auth/login/", json={"email": "new@example.com", "password": "12345678"}
)
assert created.status_code == 200
# a superuser can't lock themselves out
assert (await client.delete(f"/api/v1/users/{admin.id}/", headers=admin_headers)).status_code == 400
async def test_any_user_can_list_trainers_but_only_by_name(client, session):
await make_user(session, "anna@example.com")
await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан", last_name="Степан")
response = await client.get(
"/api/v1/users/trainers/", headers=await auth_headers(client, "anna@example.com")
)
assert response.status_code == 200
assert [(t["name"], set(t)) for t in response.json()] == [("Пан Степан", {"id", "name"})]
async def test_exercise_statistics_are_about_the_users_own_trainings(client, session):
anna = await make_user(session, "anna@example.com")
boris = await make_user(session, "boris@example.com")
await make_training(session, anna, exercise="Жим", weight=50)
await make_training(session, boris, exercise="Жим", weight=90)
await make_training(session, boris, exercise="Тяга")
headers = await auth_headers(client, "anna@example.com")
names = await client.get("/api/v1/exercises/", headers=headers)
progress = await client.get("/api/v1/exercises/progress/", params={"name": "Жим"}, headers=headers)
foreign = await client.get("/api/v1/exercises/progress/", params={"name": "Тяга"}, headers=headers)
other_trainee = await client.get(
"/api/v1/exercises/progress/", params={"name": "Жим", "trainee_id": boris.id}, headers=headers
)
assert [n["name"] for n in names.json()] == ["Жим"]
assert progress.json()["max_weight"] == 50 and len(progress.json()["rows"]) == 1
assert foreign.status_code == 404
assert other_trainee.status_code == 404 # Boris' trainings are not visible to Anna
async def test_parser_and_dev_endpoints_need_a_login(client, session):
await make_user(session, "anna@example.com")
headers = await auth_headers(client, "anna@example.com")
body = {"data": "# 07.01.2025 (solo-2)\n", "format": "obsidian"}
assert (await client.post("/api/v1/parser/parse/", json=body)).status_code == 401
assert (await client.post("/api/v1/parser/parse/", json=body, headers=headers)).status_code == 200
assert (await client.get("/api/v1/trainings/obsidian/", headers=headers)).status_code == 403

View file

@ -6,30 +6,23 @@ 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, text
from sqlalchemy import func, select
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(
trainee_id: int, day: int = 1, trainer_id: Optional[int] = None
) -> TrainingDTO:
def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO:
return TrainingDTO(
date=date(2025, 1, day),
trainee_id=trainee_id,
trainer_id=trainer_id,
trainer=trainer,
exercises=[
ExerciseDTO(
name="Жим ногами",
@ -47,17 +40,16 @@ def make_training(
)
async def test_save_and_list_roundtrip(session, trainee):
async def test_save_and_list_roundtrip(session):
repository = TrainingRepository(session)
training_id = await repository.save(make_training(trainee.id))
training_id = await repository.save(make_training())
trainings = await repository.list()
assert len(trainings) == 1
saved = trainings[0]
assert saved.id == training_id
assert (saved.date, saved.trainee_id) == (date(2025, 1, 1), trainee.id)
assert (saved.trainer_id, saved.trainer) == (None, None) # solo
assert (saved.date, saved.trainer) == (date(2025, 1, 1), "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] == [
@ -66,16 +58,16 @@ async def test_save_and_list_roundtrip(session, trainee):
]
async def test_list_is_ordered_by_date_desc(session, trainee):
async def test_list_is_ordered_by_date_desc(session):
repository = TrainingRepository(session)
await repository.save_many([make_training(trainee.id, day=1), make_training(trainee.id, day=3)])
await repository.save_many([make_training(day=1), make_training(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, trainee):
async def test_exercise_names_are_distinct_counted_and_sorted(session):
repository = TrainingRepository(session)
await repository.save_many([make_training(trainee.id, day=1), make_training(trainee.id, day=3)])
await repository.save_many([make_training(day=1), make_training(day=3)])
names = await repository.list_exercise_names()
@ -85,42 +77,38 @@ async def test_exercise_names_are_distinct_counted_and_sorted(session, trainee):
]
async def test_exercise_history_is_newest_first(session, trainee, trainer):
async def test_exercise_history_is_newest_first(session):
repository = TrainingRepository(session)
await repository.save_many(
[
make_training(trainee.id, day=1),
make_training(trainee.id, day=3, trainer_id=trainer.id),
]
[make_training(day=1), make_training(day=3, trainer="c153")]
)
history = await repository.exercise_history("Жим ногами")
assert [item.date.day for item in history] == [3, 1]
assert [item.trainer for item in history] == ["Пан Степан", None]
assert [item.trainer for item in history] == ["c153", "solo"]
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, trainee
session,
):
repository = TrainingRepository(session)
training_id = await repository.save(make_training(trainee.id))
await repository.save(make_training())
history = await repository.exercise_history("Жим гантелей лежа")
assert len(history) == 1
assert history[0].training_id == training_id
assert history[0].splitted_weight is True
assert [a.weight for a in history[0].approaches] == [20]
assert await repository.exercise_history("Несуществующее") == []
async def test_delete_cascades_to_exercises_and_approaches(session, trainee):
async def test_delete_cascades_to_exercises_and_approaches(session):
repository = TrainingRepository(session)
keep_id, delete_id = await repository.save_many(
[make_training(trainee.id, day=1), make_training(trainee.id, day=2)]
[make_training(day=1), make_training(day=2)]
)
assert await repository.delete([delete_id]) == 1
@ -130,67 +118,19 @@ async def test_delete_cascades_to_exercises_and_approaches(session, trainee):
assert await session.scalar(select(func.count(approaches.c.id))) == 3
async def test_get_returns_training_or_none(session, trainee):
async def test_save_many_is_atomic(session):
repository = TrainingRepository(session)
training_id = await repository.save(make_training(trainee.id))
training = await repository.get(training_id)
assert training is not None
assert [e.name for e in training.exercises] == ["Жим ногами", "Жим гантелей лежа"]
assert await repository.get(training_id + 1) is None
async def test_replace_overwrites_training_exercises_and_approaches(session, trainee, trainer):
repository = TrainingRepository(session)
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),
trainee_id=trainee.id,
trainer_id=trainer.id,
exercises=[
ExerciseDTO(
name="Тяга",
splitted_weight=True,
approaches=[ApproachDTO(weight=30, reps=8)],
)
],
)
assert await repository.replace(training_id, updated) is True
training = await repository.get(training_id)
assert (training.date, training.trainer) == (date(2025, 2, 3), "Пан Степан")
assert [e.name for e in training.exercises] == ["Тяга"]
assert training.exercises[0].splitted_weight is True
assert [(a.weight, a.reps) for a in training.exercises[0].approaches] == [(30, 8)]
# the other training is untouched; no orphans left from the replaced one
assert len((await repository.get(other_id)).exercises) == 2
assert await session.scalar(select(func.count(exercises.c.id))) == 3
assert await session.scalar(select(func.count(approaches.c.id))) == 4
async def test_replace_missing_training_returns_false(session, trainee):
repository = TrainingRepository(session)
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
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
with pytest.raises(DBAPIError):
await repository.save_many([make_training(trainee.id, day=1), broken])
await repository.save_many([make_training(day=1), broken])
await session.rollback()
assert await repository.count() == 0
async def test_all_notes_can_be_saved(session, trainee):
async def test_all_notes_can_be_saved(session):
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)
@ -208,52 +148,3 @@ 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

@ -1,16 +0,0 @@
from app.core.security import hash_password, verify_password
def test_hash_verifies_only_the_right_password():
stored = hash_password("correct horse")
assert verify_password("correct horse", stored)
assert not verify_password("wrong", stored)
def test_hashes_are_salted():
assert hash_password("same") != hash_password("same")
def test_garbage_hash_does_not_verify():
assert not verify_password("x", "not-a-hash")
assert not verify_password("x", "bcrypt$1$2$3$4$5")

View file

@ -1,43 +0,0 @@
import pytest
from pydantic import ValidationError
from app.schemas.trainings import TrainingUpdate
def payload(**overrides):
data = {
"date": "2026-10-21",
"trainee_id": 1,
"trainer_id": None,
"exercises": [
{"name": " Жим ", "approaches": [{"weight": 0, "reps": 12}]},
],
}
data.update(overrides)
return data
def test_strips_name_and_solo_training_has_no_trainer():
training = TrainingUpdate.model_validate(payload())
assert training.trainer_id is None
assert training.exercises[0].name == "Жим"
def test_trainee_is_required():
data = payload()
del data["trainee_id"]
with pytest.raises(ValidationError):
TrainingUpdate.model_validate(data)
@pytest.mark.parametrize(
"exercises",
[
[{"name": " ", "approaches": []}],
[{"name": "Жим", "approaches": [{"weight": -1, "reps": 5}]}],
[{"name": "Жим", "approaches": [{"weight": 10, "reps": 0}]}],
],
)
def test_rejects_invalid_exercises(exercises):
with pytest.raises(ValidationError):
TrainingUpdate.model_validate(payload(exercises=exercises))

View file

@ -1,71 +0,0 @@
import pytest
from pydantic import ValidationError
from app.core.database.user_repository import EmailTakenError, UserRepository
from app.core.dto.user import UserRole
from app.schemas.users import UserCreate, UserUpdate
def test_email_is_normalized():
user = UserCreate(email=" Ivan@Example.COM ", password="12345678")
assert user.email == "ivan@example.com"
assert user.role == UserRole.trainee
@pytest.mark.parametrize("email", ["", "no-at.example.com", "a@b", "a b@c.d"])
def test_invalid_email_is_rejected(email):
with pytest.raises(ValidationError):
UserCreate(email=email, password="12345678")
with pytest.raises(ValidationError):
UserUpdate(email=email)
def test_short_password_and_unknown_role_are_rejected():
with pytest.raises(ValidationError):
UserCreate(email="a@b.co", password="short")
with pytest.raises(ValidationError):
UserCreate(email="a@b.co", password="12345678", role="admin")
async def test_create_get_list_and_filter_by_role(session):
repository = UserRepository(session)
trainer = await repository.create(
email="coach@example.com", password_hash="h", role=UserRole.trainer
)
trainee = await repository.create(email="athlete@example.com", password_hash="h")
assert trainer.role == UserRole.trainer and trainee.role == UserRole.trainee
assert trainee.is_active and not trainee.is_superuser and trainee.last_login is None
assert (await repository.get(trainer.id)).email == "coach@example.com"
assert await repository.get(trainee.id + 100) is None
assert [u.id for u in await repository.list()] == [trainer.id, trainee.id]
assert [u.id for u in await repository.list(UserRole.trainer)] == [trainer.id]
async def test_duplicate_email_is_rejected_and_session_stays_usable(session):
repository = UserRepository(session)
await repository.create(email="a@example.com", password_hash="h")
with pytest.raises(EmailTakenError):
await repository.create(email="a@example.com", password_hash="h")
assert len(await repository.list()) == 1
async def test_update_and_delete(session):
repository = UserRepository(session)
user = await repository.create(email="a@example.com", password_hash="h")
other = await repository.create(email="b@example.com", password_hash="h")
updated = await repository.update(
user.id, {"first_name": "Ivan", "role": UserRole.trainer, "is_active": False}
)
assert (updated.first_name, updated.role, updated.is_active) == (
"Ivan", UserRole.trainer, False,
)
assert (await repository.update(user.id, {})).first_name == "Ivan"
assert await repository.update(user.id + 100, {"first_name": "x"}) is None
with pytest.raises(EmailTakenError):
await repository.update(user.id, {"email": other.email})
assert await repository.delete(user.id) is True
assert await repository.delete(user.id) is False

View file

@ -3,7 +3,6 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
target: dev
ports:
- "8000:8000"
volumes:
@ -32,8 +31,6 @@ services:
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
- ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro
- ./frontend/users.html:/usr/share/nginx/html/users.html:ro
- ./frontend/login.html:/usr/share/nginx/html/login.html:ro
- ./frontend/css:/usr/share/nginx/html/css:ro
- ./frontend/js:/usr/share/nginx/html/js:ro
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro

View file

@ -1,7 +1,7 @@
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html exercises.html parse.html users.html login.html /usr/share/nginx/html/
COPY index.html exercises.html parse.html /usr/share/nginx/html/
COPY css /usr/share/nginx/html/css
COPY js /usr/share/nginx/html/js
COPY vendor /usr/share/nginx/html/vendor

View file

@ -18,9 +18,7 @@
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
</ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside>
<main class="flex-grow-1 p-4 content" id="content">
@ -38,7 +36,6 @@
</main>
</div>
<script src="vendor/chart.umd.js"></script>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="vendor/dataTables.min.js"></script>
<script src="vendor/dataTables.bootstrap5.min.js"></script>
<script type="module" src="js/exercises.js"></script>

View file

@ -18,9 +18,7 @@
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
</ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside>
<main class="flex-grow-1 p-4 content" id="content">
@ -29,7 +27,6 @@
</main>
</div>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="vendor/dataTables.min.js"></script>
<script src="vendor/dataTables.bootstrap5.min.js"></script>
<script type="module" src="js/trainings.js"></script>

View file

@ -1,101 +0,0 @@
// Session: the short-lived access token lives in sessionStorage; the refresh token is an
// httpOnly cookie that the browser sends to /auth/* by itself (script code can't read it).
const API = '/api/v1';
const STORAGE_KEY = 'access';
// Refresh a bit before the expiry so that a request doesn't go out with a dying token
const MARGIN_MS = 30_000;
function stored() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY));
} catch {
return null;
}
}
function store(tokens) {
const access = { token: tokens.access_token, expiresAt: Date.now() + tokens.expires_in * 1000 };
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(access));
} catch {
// sessionStorage is unavailable: the token is then only valid for this call chain
}
return access;
}
export function clearSession() {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// nothing to clear
}
}
async function post(path, body) {
return fetch(API + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
/** Signs in; throws an Error with `status` (401: wrong email or password). */
export async function login(email, password) {
const response = await post('/auth/login/', { email, password, client: 'web' });
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
throw error;
}
store(await response.json());
}
// One refresh at a time: the refresh token works once, two parallel requests would
// make the second one look like a stolen token
let refreshing = null;
/** Exchanges the cookie for a new access token. false: the session is over, sign in again. */
export function refresh() {
refreshing ??= (async () => {
try {
const response = await post('/auth/refresh/');
if (!response.ok) {
clearSession();
return false;
}
store(await response.json());
return true;
} finally {
refreshing = null;
}
})();
return refreshing;
}
/** A valid access token, refreshed when needed; null when there is no session. */
export async function accessToken() {
let access = stored();
if (!access || access.expiresAt - Date.now() < MARGIN_MS) {
if (!(await refresh())) {
return null;
}
access = stored();
}
return access?.token ?? null;
}
export async function logout() {
try {
await post('/auth/logout/');
} finally {
clearSession();
}
}
/** Never settles: stops the page code that follows while the browser goes to the login page */
export function redirectToLogin() {
const next = location.pathname + location.search;
location.href = `login.html?next=${encodeURIComponent(next)}`;
return new Promise(() => {});
}

View file

@ -1,31 +1,9 @@
// Shared helpers: API access, DOM building, formatting
import { accessToken, redirectToLogin, refresh } from './auth.js';
const API = '/api/v1';
/** What a training without a trainer is called */
export const SOLO = 'соло';
async function send(path, options, token) {
const headers = { ...options.headers, Authorization: `Bearer ${token}` };
return fetch(API + path, { ...options, headers });
}
/** JSON request to the API with the access token. Without a session the browser is sent to
* the login page; an expired token is refreshed and the request repeated once. */
export async function api(path, options = {}) {
const token = await accessToken();
if (!token) {
return redirectToLogin();
}
let response = await send(path, options, token);
if (response.status === 401) {
if (!(await refresh())) {
return redirectToLogin();
}
response = await send(path, options, await accessToken());
}
export async function api(path, options) {
const response = await fetch(API + path, options);
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
@ -56,17 +34,6 @@ export function formatDate(iso) {
return `${day}.${month}.${year}`;
}
/** "First Last" of a user, the email when the name is empty. Trainers in a list come with
* a ready `name`. */
export function userName(user) {
return user.name || `${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(',', '');
}
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
export function num(value) {
return String(Math.round(value * 10) / 10);

View file

@ -1,35 +0,0 @@
// Modal with the training edit form
import { alert, api, h } from './common.js';
import { editForm } from './edit-form.js';
import { openModal } from './modal.js';
import { user } from './page.js';
/** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */
export function editButton(trainingId) {
const button = h('button', { type: 'button', class: 'btn btn-sm btn-outline-secondary' }, 'Изменить');
button.addEventListener('click', () => openEditDialog(trainingId, () => location.reload()));
return button;
}
/** Opens the form for the training; onChange() is called after it was saved or deleted. */
export async function openEditDialog(trainingId, onChange) {
const { body, hide } = openModal('Редактирование тренировки');
try {
const [training, exerciseNames, trainees, trainers] = await Promise.all([
api(`/trainings/${trainingId}/`),
api('/exercises/'),
// only a superuser may hand a training to another trainee
user.is_superuser ? api('/users/?role=trainee') : null,
api('/users/trainers/'),
]);
body.replaceChildren(editForm(training, exerciseNames, { trainees, trainers }, {
onDone: () => { hide(); onChange(); },
onCancel: hide,
}));
} catch (error) {
body.replaceChildren(alert('danger', error.status === 404
? 'Тренировка не найдена.'
: `Не удалось загрузить тренировку: ${error.message}`));
}
}

View file

@ -1,141 +0,0 @@
// Form for editing one training: date, trainee, trainer, exercises and their approaches
import { alert, api, h, SOLO, userName } from './common.js';
const jsonRequest = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
function field(label, input, className = '') {
return h('div', { class: className }, h('label', { class: 'form-label small text-muted mb-1' }, label), input);
}
function approachRow(approach) {
const row = h('div', { class: 'row g-2 align-items-end approach-row mb-2' },
h('div', { class: 'col-5 col-md-3' },
h('input', { type: 'number', class: 'form-control form-control-sm', name: 'weight', min: 0, step: 'any', required: true, value: approach.weight, 'aria-label': 'Вес, кг' })),
h('div', { class: 'col-auto pb-1' }, '×'),
h('div', { class: 'col-4 col-md-2' },
h('input', { type: 'number', class: 'form-control form-control-sm', name: 'reps', min: 1, step: 1, required: true, value: approach.reps, 'aria-label': 'Повторения' })),
h('div', { class: 'col-auto' },
h('button', { type: 'button', class: 'btn btn-sm btn-outline-danger', title: 'Убрать подход' }, '×')));
row.querySelector('button').addEventListener('click', () => row.remove());
return row;
}
function exerciseCard(exercise) {
const approaches = h('div', {}, ...exercise.approaches.map(approachRow));
const addApproach = h('button', { type: 'button', class: 'btn btn-sm btn-outline-secondary' }, '+ подход');
addApproach.addEventListener('click', () => {
// a new approach starts as a copy of the last one: sets usually repeat
const last = approaches.lastElementChild;
approaches.append(approachRow({
weight: last ? last.querySelector('[name=weight]').value : 0,
reps: last ? last.querySelector('[name=reps]').value : 10,
}));
});
const remove = h('button', { type: 'button', class: 'btn btn-sm btn-outline-danger', title: 'Убрать упражнение' }, 'Убрать упражнение');
const card = h('div', { class: 'card exercise-card' },
h('div', { class: 'card-body' },
h('div', { class: 'row g-2 align-items-end mb-3' },
field('Упражнение', h('input', { type: 'text', class: 'form-control', name: 'name', list: 'edit-exercise-names', maxlength: 255, required: true, value: exercise.name }), 'col-12 col-md-6'),
h('div', { class: 'col-auto form-check ms-2 mb-2' },
h('input', { type: 'checkbox', class: 'form-check-input', name: 'splitted_weight', id: `per-arm-${Math.random().toString(36).slice(2)}`, checked: exercise.splitted_weight }),
h('label', { class: 'form-check-label' }, 'Вес на каждую руку')),
h('div', { class: 'col-auto ms-md-auto' }, remove)),
h('div', { class: 'small text-muted mb-1' }, 'Подходы: вес, кг (0 — без веса) × повторения'),
approaches,
addApproach));
// the checkbox label must point to its checkbox
const checkbox = card.querySelector('[name=splitted_weight]');
card.querySelector('.form-check-label').setAttribute('for', checkbox.id);
remove.addEventListener('click', () => card.remove());
return card;
}
function readForm(form) {
return {
date: form.elements.date.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,
approaches: [...card.querySelectorAll('.approach-row')].map((row) => ({
weight: Number(row.querySelector('[name=weight]').value),
reps: Number(row.querySelector('[name=reps]').value),
})),
})),
};
}
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 (trainees is null when the trainee can't be changed). 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', () => {
const card = exerciseCard({ name: '', splitted_weight: false, approaches: [{ weight: 0, reps: 10 }] });
exercisesBox.append(card);
card.querySelector('[name=name]').focus();
});
const status = h('div', {});
const save = h('button', { type: 'submit', class: 'btn btn-primary' }, 'Сохранить');
const cancel = h('button', { type: 'button', class: 'btn btn-outline-secondary' }, 'Отмена');
cancel.addEventListener('click', onCancel);
const remove = h('button', { type: 'button', class: 'btn btn-outline-danger ms-auto' }, 'Удалить тренировку');
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-12 col-md-3'),
trainees
? field('Тренирующийся', userSelect('trainee_id', trainees, training.trainee_id), 'col-12 col-md-3')
: h('input', { type: 'hidden', name: 'trainee_id', value: training.trainee_id }),
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' },
save,
cancel,
remove),
status);
form.addEventListener('submit', async (event) => {
event.preventDefault();
save.disabled = true;
try {
await api(`/trainings/${training.id}/`, jsonRequest('PUT', readForm(form)));
onDone();
} catch (error) {
status.replaceChildren(alert('danger', `Не удалось сохранить: ${error.message}`));
save.disabled = false;
}
});
remove.addEventListener('click', async () => {
if (!confirm('Удалить тренировку вместе с упражнениями и подходами?')) {
return;
}
try {
await api(`/trainings/${training.id}/`, jsonRequest('DELETE'));
onDone();
} catch (error) {
status.replaceChildren(alert('danger', `Не удалось удалить: ${error.message}`));
}
});
return form;
}

View file

@ -1,7 +1,4 @@
import './page.js';
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js';
import { editButton } from './edit-dialog.js';
import { canEditTraining } from './page.js';
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
import { createTable } from './table.js';
const HINTS = {
@ -108,7 +105,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 || SOLO, cell: (row) => row.trainer || SOLO },
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || '', cell: (row) => row.trainer || '' },
{ 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)} кг` : '—' },
@ -118,7 +115,6 @@ function progressView(progress) {
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
]),
{ title: '', cell: (row) => row.training_id && canEditTraining(row.trainee_id) ? editButton(row.training_id) : null },
];
return [

View file

@ -1,26 +0,0 @@
import { login } from './auth.js';
import { alert, h } from './common.js';
const form = document.getElementById('login-form');
const status = document.getElementById('status');
/** Where to go after signing in: the page that sent us here, but only a path of this site */
function nextPage() {
const next = new URLSearchParams(location.search).get('next');
return next && next.startsWith('/') && !next.startsWith('//') ? next : 'index.html';
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const submit = form.querySelector('button');
submit.disabled = true;
try {
await login(form.elements.email.value, form.elements.password.value);
location.href = nextPage();
} catch (error) {
status.replaceChildren(h('div', { class: 'mb-3' }, alert('danger', error.status === 401
? 'Неверный email или пароль.'
: `Не удалось войти: ${error.message}`)));
submit.disabled = false;
}
});

View file

@ -1,24 +0,0 @@
// Bootstrap modal shell: a title, a close button and an empty body to fill
import { h } from './common.js';
/** Opens the modal at once; returns { body, hide }. The element is removed when it closes. */
export function openModal(title) {
const body = h('div', { class: 'modal-body' }, h('div', { class: 'text-muted' }, 'Загрузка…'));
const element = h('div', { class: 'modal fade', tabindex: '-1', 'aria-labelledby': 'modal-title', 'aria-hidden': 'true' },
h('div', { class: 'modal-dialog modal-lg modal-dialog-scrollable' },
h('div', { class: 'modal-content' },
h('div', { class: 'modal-header' },
h('h2', { class: 'modal-title h5', id: 'modal-title' }, title),
h('button', { type: 'button', class: 'btn-close', 'data-bs-dismiss': 'modal', 'aria-label': 'Закрыть' })),
body)));
const modal = new bootstrap.Modal(element);
element.addEventListener('hidden.bs.modal', () => {
modal.dispose();
element.remove();
});
document.body.append(element);
modal.show();
return { body, hide: () => modal.hide() };
}

View file

@ -1,28 +0,0 @@
// Every page imports this first: it requires a signed-in user and fills the sidebar.
// `user` is the current user (the module waits for it, so importers can use it at once).
import { logout, redirectToLogin } from './auth.js';
import { api, h, userName } from './common.js';
const ROLES = { trainer: 'Тренер', trainee: 'Тренирующийся' };
export const user = await api('/auth/me/');
/** A training can be changed by its trainee (a trainer only sees it) and by a superuser */
export function canEditTraining(traineeId) {
return user.is_superuser || user.id === traineeId;
}
for (const element of document.querySelectorAll('[data-superuser-only]')) {
element.hidden = !user.is_superuser;
}
const signOut = h('button', { type: 'button', class: 'btn btn-sm btn-outline-light mt-2' }, 'Выйти');
signOut.addEventListener('click', async () => {
await logout();
await redirectToLogin();
});
document.getElementById('user-box')?.replaceChildren(
h('div', { class: 'text-white text-truncate' }, userName(user)),
h('div', {}, ROLES[user.role] + (user.is_superuser ? ', администратор' : '')),
signOut);

View file

@ -1,5 +1,4 @@
import { alert, api } from './common.js';
import './page.js';
import { trainingsView } from './trainings-view.js';
const form = document.getElementById('parse-form');

View file

@ -1,8 +1,6 @@
// One table for all trainings: a row per exercise
import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js';
import { editButton } from './edit-dialog.js';
import { canEditTraining } from './page.js';
import { alert, approachChips, formatDate, h, perArmBadge } from './common.js';
import { createTable } from './table.js';
export function trainingsView(trainings) {
@ -16,8 +14,6 @@ export function trainingsView(trainings) {
? training.exercises.map((exercise) => ({ training, exercise }))
: [{ training, exercise: null }]);
// Trainings from the parser preview are not saved yet: nothing to edit
const editable = trainings.every((training) => training.id);
const exerciseCount = rows.filter((row) => row.exercise).length;
return h('div', {},
@ -29,7 +25,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 || SOLO, cell: (row) => row.training.trainer || SOLO },
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.training.trainer || '', cell: (row) => row.training.trainer || '' },
{
title: 'Упражнение',
sort: (row) => row.exercise?.name || '',
@ -38,10 +34,6 @@ export function trainingsView(trainings) {
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
},
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) },
...(editable ? [{
title: '',
cell: (row) => canEditTraining(row.training.trainee_id) ? editButton(row.training.id) : null,
}] : []),
],
}));
}

View file

@ -1,5 +1,4 @@
import { alert, api } from './common.js';
import './page.js';
import { trainingsView } from './trainings-view.js';
const container = document.getElementById('trainings');

View file

@ -1,100 +0,0 @@
// Modal with the form for creating and editing a user
import { alert, api, h } from './common.js';
import { openModal } from './modal.js';
const ROLES = { trainee: 'Тренирующийся', trainer: 'Тренер' };
const jsonRequest = (method, body) => ({
method,
headers: { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
function field(label, input, className = 'col-12 col-md-6') {
return h('div', { class: className }, h('label', { class: 'form-label small text-muted mb-1' }, label), input);
}
function errorText(error) {
if (error.status === 409) return 'Пользователь с таким email уже есть.';
if (error.status === 422) return 'Проверьте поля: email должен быть корректным, пароль — не короче 8 символов.';
if (error.status === 404) return 'Пользователь не найден.';
return error.message;
}
/** user is null for a new user; onChange() is called after it was saved or deleted */
function userForm(user, { onDone, onCancel }) {
const isNew = !user;
const input = (name, attrs) => h('input', { class: 'form-control', name, ...attrs });
const status = h('div', { class: 'mt-3' });
const save = h('button', { type: 'submit', class: 'btn btn-primary' }, isNew ? 'Создать' : 'Сохранить');
const cancel = h('button', { type: 'button', class: 'btn btn-outline-secondary' }, 'Отмена');
cancel.addEventListener('click', onCancel);
const remove = isNew ? null : h('button', { type: 'button', class: 'btn btn-outline-danger ms-auto' }, 'Удалить');
const role = h('select', { class: 'form-select', name: 'role' },
...Object.entries(ROLES).map(([value, title]) =>
h('option', { value, selected: (user?.role || 'trainee') === value }, title)));
const form = h('form', {},
h('div', { class: 'row g-3' },
field('Email', input('email', { type: 'email', required: true, maxlength: 254, value: user?.email || '', autocomplete: 'off' })),
field(isNew ? 'Пароль' : 'Новый пароль', input('password', {
type: 'password', minlength: 8, maxlength: 128, required: isNew, autocomplete: 'new-password',
placeholder: isNew ? 'не короче 8 символов' : 'оставьте пустым, чтобы не менять',
})),
field('Имя', input('first_name', { type: 'text', maxlength: 100, value: user?.first_name || '' })),
field('Фамилия', input('last_name', { type: 'text', maxlength: 100, value: user?.last_name || '' })),
field('Роль', role),
isNew ? null : h('div', { class: 'col-12 col-md-6 d-flex align-items-end' },
h('div', { class: 'form-check mb-2' },
h('input', { type: 'checkbox', class: 'form-check-input', name: 'is_active', id: 'user-is-active', checked: user.is_active }),
h('label', { class: 'form-check-label', for: 'user-is-active' }, 'Активен')))),
h('div', { class: 'd-flex gap-2 align-items-center mt-4' }, save, cancel, remove),
status);
form.addEventListener('submit', async (event) => {
event.preventDefault();
const { elements } = form;
const data = {
email: elements.email.value,
first_name: elements.first_name.value,
last_name: elements.last_name.value,
role: elements.role.value,
};
if (elements.password.value) data.password = elements.password.value;
save.disabled = true;
try {
if (isNew) {
await api('/users/', jsonRequest('POST', data));
} else {
data.is_active = elements.is_active.checked;
await api(`/users/${user.id}/`, jsonRequest('PATCH', data));
}
onDone();
} catch (error) {
status.replaceChildren(alert('danger', `Не удалось сохранить: ${errorText(error)}`));
save.disabled = false;
}
});
remove?.addEventListener('click', async () => {
if (!confirm(`Удалить пользователя ${user.email}?`)) return;
try {
await api(`/users/${user.id}/`, jsonRequest('DELETE'));
onDone();
} catch (error) {
const reason = error.status === 409 ? 'у пользователя есть тренировки' : errorText(error);
status.replaceChildren(alert('danger', `Не удалось удалить: ${reason}`));
}
});
return form;
}
/** Opens the form; user is the existing user to edit or null to create one. */
export function openUserDialog(user, onChange) {
const { body, hide } = openModal(user ? 'Редактирование пользователя' : 'Новый пользователь');
body.replaceChildren(userForm(user, { onDone: () => { hide(); onChange(); }, onCancel: hide }));
}

View file

@ -1,74 +0,0 @@
import { alert, api, formatDateTime, h } from './common.js';
import { user } from './page.js';
import { createTable } from './table.js';
import { openUserDialog } from './user-dialog.js';
const ROLES = {
trainer: { title: 'Тренер', badge: 'text-bg-primary' },
trainee: { title: 'Тренирующийся', badge: 'text-bg-secondary' },
};
if (!user.is_superuser) {
location.href = 'index.html';
}
const container = document.getElementById('users');
const reload = () => location.reload();
const addButton = () => {
const button = h('button', { type: 'button', class: 'btn btn-primary mb-3' }, '+ Добавить пользователя');
button.addEventListener('click', () => openUserDialog(null, reload));
return button;
};
const editButton = (user) => {
const button = h('button', { type: 'button', class: 'btn btn-sm btn-outline-secondary' }, 'Изменить');
button.addEventListener('click', () => openUserDialog(user, reload));
return button;
};
function usersView(users) {
if (!users.length) {
return h('div', {}, addButton(), alert('info', 'Пользователей нет.'));
}
return h('div', {},
addButton(),
h('p', { class: 'text-muted' },
'Пользователей: ', h('strong', {}, `${users.length}`),
', тренеров: ', h('strong', {}, `${users.filter((user) => user.role === 'trainer').length}`)),
createTable({
id: 'users-table',
rows: users,
columns: [
{ title: 'ID', className: 'text-nowrap', sort: (user) => user.id, cell: (user) => `${user.id}` },
{ title: 'Email', className: 'text-nowrap', sort: (user) => user.email, cell: (user) => user.email },
{ title: 'Имя', sort: (user) => user.first_name, cell: (user) => user.first_name },
{ title: 'Фамилия', sort: (user) => user.last_name, cell: (user) => user.last_name },
{
title: 'Роль',
className: 'text-nowrap',
sort: (user) => user.role,
cell: (user) => h('span', { class: `badge ${ROLES[user.role].badge}` }, ROLES[user.role].title),
},
{
title: 'Активен',
sort: (user) => Number(user.is_active),
cell: (user) => user.is_active ? 'Да' : h('span', { class: 'text-muted' }, 'Нет'),
},
{ title: 'Создан', className: 'text-nowrap', sort: (user) => user.created_at, cell: (user) => formatDateTime(user.created_at) },
{
title: 'Последний вход',
className: 'text-nowrap',
sort: (user) => user.last_login || '',
cell: (user) => user.last_login ? formatDateTime(user.last_login) : h('span', { class: 'text-muted' }, '—'),
},
{ title: '', cell: editButton },
],
}));
}
try {
container.replaceChildren(usersView(await api('/users/')));
} catch (error) {
container.replaceChildren(alert('danger', `Не удалось загрузить пользователей: ${error.message}`));
}

View file

@ -1,30 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход - Fitness Parser</title>
<link href="vendor/bootstrap.min.css" rel="stylesheet">
<link href="css/main.css" rel="stylesheet">
</head>
<body class="bg-light">
<main class="container" style="max-width: 24rem; padding-top: 15vh;">
<h1 class="h3 mb-4 text-center">Fitness parser</h1>
<form id="login-form" class="card card-body">
<div class="mb-3">
<label class="form-label small text-muted mb-1" for="email">Email</label>
<input class="form-control" id="email" name="email" type="email" required autofocus autocomplete="username">
</div>
<div class="mb-3">
<label class="form-label small text-muted mb-1" for="password">Пароль</label>
<input class="form-control" id="password" name="password" type="password" required autocomplete="current-password">
</div>
<div id="status"></div>
<button class="btn btn-primary w-100" type="submit">Войти</button>
</form>
</main>
<script type="module" src="js/login.js"></script>
</body>
</html>

View file

@ -18,9 +18,7 @@
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white active" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
</ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside>
<main class="flex-grow-1 p-4 content" id="content">

View file

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Пользователи - Fitness Parser</title>
<link href="vendor/bootstrap.min.css" rel="stylesheet">
<link href="vendor/dataTables.bootstrap5.min.css" rel="stylesheet">
<link href="css/main.css" rel="stylesheet">
</head>
<body>
<div class="d-flex flex-column flex-md-row">
<!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
<aside class="sidebar bg-dark p-3">
<a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="index.html">Fitness parser</a>
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white active" href="users.html">Пользователи</a></li>
</ul>
<div id="user-box" class="mt-4 small text-white-50"></div>
</aside>
<main class="flex-grow-1 p-4 content" id="content">
<h1 class="h3 mb-3">Пользователи</h1>
<div id="users"><div class="text-muted">Загрузка…</div></div>
</main>
</div>
<script src="vendor/bootstrap.bundle.min.js"></script>
<script src="vendor/dataTables.min.js"></script>
<script src="vendor/dataTables.bootstrap5.min.js"></script>
<script type="module" src="js/users.js"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -1,89 +0,0 @@
#!/usr/bin/env python3
"""Builds the production images of the backend and the frontend with a version tag.
python scripts/release.py 0.0.1 # an exact version
python scripts/release.py patch # bump: patch | minor | major (0.0.1 -> 0.0.2)
python scripts/release.py patch --platform linux/arm64 # for a server of another architecture
python scripts/release.py --dry-run patch # only print what would be done
The version is kept in the VERSION file in the repository root (the bump starts from 0.0.0
when there is no such file). Images: fitness_parser_backend:<version> (target `prod`) and
fitness_parser_frontend:<version>.
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERSION_FILE = ROOT / "VERSION"
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
# image name -> (build context, extra docker build arguments)
IMAGES = {
"fitness_parser_backend": ("backend", ["--target", "prod"]),
"fitness_parser_frontend": ("frontend", []),
}
def current_version() -> str:
if not VERSION_FILE.exists():
return "0.0.0"
return VERSION_FILE.read_text().strip()
def next_version(request: str) -> str:
if VERSION_PATTERN.match(request):
return request
major, minor, patch = (int(part) for part in current_version().split("."))
if request == "major":
return f"{major + 1}.0.0"
if request == "minor":
return f"{major}.{minor + 1}.0"
if request == "patch":
return f"{major}.{minor}.{patch + 1}"
sys.exit(f"Unknown version '{request}': use X.Y.Z, patch, minor or major")
def build_command(name: str, version: str, platform: str | None) -> list[str]:
context, extra = IMAGES[name]
if platform:
# buildx can build for another architecture; --load puts the image into local docker
command = ["docker", "buildx", "build", "--platform", platform, "--load"]
else:
command = ["docker", "build"]
return [*command, *extra, "-t", f"{name}:{version}", str(ROOT / context)]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("version", help="X.Y.Z, or patch / minor / major to bump the current one")
parser.add_argument("--platform", help="target platform, e.g. linux/arm64 (default: this machine)")
parser.add_argument("--dry-run", action="store_true", help="print the commands, change nothing")
args = parser.parse_args()
version = next_version(args.version)
print(f"Version: {current_version()} -> {version}")
commands = [build_command(name, version, args.platform) for name in IMAGES]
if args.dry_run:
for command in commands:
print(" ".join(command))
return
for command in commands:
print("+", " ".join(command), flush=True)
if subprocess.run(command).returncode != 0:
sys.exit("Build failed, the VERSION file was not changed")
# Written only after both images were built
VERSION_FILE.write_text(version + "\n")
images = " ".join(f"{name}:{version}" for name in IMAGES)
print(f"\nBuilt {images}\nSend to the server:\n"
f" docker save {images} | gzip | ssh user@host 'gunzip | docker load'")
if __name__ == "__main__":
main()