Compare commits
No commits in common. "main" and "7-Add-database-to-project" have entirely different histories.
main
...
7-Add-data
59 changed files with 513 additions and 3128 deletions
|
|
@ -10,9 +10,3 @@ PYTHONPATH=/app
|
||||||
|
|
||||||
# Development settings
|
# Development settings
|
||||||
DEBUG=true
|
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
|
|
||||||
|
|
|
||||||
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -38,8 +38,6 @@ docker compose down -v
|
||||||
```
|
```
|
||||||
Rebuild (`--build`) is needed only after changing `requirements*.txt`, `Dockerfile` or `pyproject.toml`.
|
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
|
### Testing
|
||||||
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
|
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -73,23 +71,15 @@ docker compose exec fastapi-app mypy app/
|
||||||
- `Approach` - single set with weight and reps
|
- `Approach` - single set with weight and reps
|
||||||
|
|
||||||
**API Structure** (backend, JSON only):
|
**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/`):
|
**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)
|
- `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 + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
|
- `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)
|
- `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
|
### 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`.
|
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.
|
Configuration is managed through `backend/app/config.py` using Pydantic settings with `.env` file support.
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,32 @@
|
||||||
# Two images from one file:
|
FROM python:3.13-slim
|
||||||
# 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
|
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
WORKDIR /app
|
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 first for better caching
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt requirements-dev.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
COPY alembic.ini ./
|
# Install Python dependencies (the project is run only in Docker, so dev tools
|
||||||
COPY ./app ./app
|
# like pytest and mypy are part of the image)
|
||||||
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 ./
|
|
||||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
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 ./data ./data
|
||||||
COPY ./tests ./tests
|
COPY ./tests ./tests
|
||||||
|
|
||||||
|
# Set Python path
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
# Command will be overridden by docker-compose
|
# Command will be overridden by docker-compose
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,11 @@
|
||||||
import jwt
|
from fastapi import Depends
|
||||||
from fastapi import Depends, HTTPException
|
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database.connection import get_session
|
from app.core.database.connection import get_session
|
||||||
from app.core.database.repository import TrainingRepository
|
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(
|
def get_training_repository(
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> TrainingRepository:
|
) -> TrainingRepository:
|
||||||
return TrainingRepository(session)
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -2,10 +2,9 @@ from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
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.database.repository import TrainingRepository
|
||||||
from app.core.dto.training import ExerciseNameDTO
|
from app.core.dto.training import ExerciseNameDTO
|
||||||
from app.core.dto.user import UserDTO, UserRole
|
|
||||||
from app.core.metrics import Record, summarize
|
from app.core.metrics import Record, summarize
|
||||||
from app.schemas.exercises import (
|
from app.schemas.exercises import (
|
||||||
ExerciseProgressResponse,
|
ExerciseProgressResponse,
|
||||||
|
|
@ -17,57 +16,36 @@ from app.schemas.exercises import (
|
||||||
router = APIRouter()
|
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]:
|
def record_response(record: Optional[Record]) -> Optional[RecordResponse]:
|
||||||
return RecordResponse(value=record.value, date=record.date) if record else None
|
return RecordResponse(value=record.value, date=record.date) if record else None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[ExerciseNameDTO])
|
@router.get("/", response_model=List[ExerciseNameDTO])
|
||||||
async def list_exercise_names(
|
async def list_exercise_names(
|
||||||
trainee_id: Optional[int] = None,
|
|
||||||
user: UserDTO = Depends(get_current_user),
|
|
||||||
repository: TrainingRepository = Depends(get_training_repository),
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
):
|
):
|
||||||
"""Distinct exercise names with the number of times each was done."""
|
"""Distinct exercise names with the number of times each was done."""
|
||||||
return await repository.list_exercise_names(
|
return await repository.list_exercise_names()
|
||||||
viewer=user, trainee_id=stats_trainee_id(user, trainee_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/progress/", response_model=ExerciseProgressResponse)
|
@router.get("/progress/", response_model=ExerciseProgressResponse)
|
||||||
async def exercise_progress(
|
async def exercise_progress(
|
||||||
name: str,
|
name: str,
|
||||||
trainee_id: Optional[int] = None,
|
|
||||||
user: UserDTO = Depends(get_current_user),
|
|
||||||
repository: TrainingRepository = Depends(get_training_repository),
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
):
|
):
|
||||||
"""The whole history of the exercise with progress metrics.
|
"""The whole history of the exercise with progress metrics.
|
||||||
|
|
||||||
The name is matched exactly, but case-insensitively: what was typed may differ in case.
|
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 = {
|
names_by_casefold = {
|
||||||
item.name.casefold(): item.name
|
item.name.casefold(): item.name
|
||||||
for item in await repository.list_exercise_names(
|
for item in await repository.list_exercise_names()
|
||||||
viewer=user, trainee_id=trainee_id
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
exact_name = names_by_casefold.get(name.strip().casefold())
|
exact_name = names_by_casefold.get(name.strip().casefold())
|
||||||
if exact_name is None:
|
if exact_name is None:
|
||||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||||
|
|
||||||
progress = summarize(
|
progress = summarize(await repository.exercise_history(exact_name))
|
||||||
await repository.exercise_history(
|
|
||||||
exact_name, viewer=user, trainee_id=trainee_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ExerciseProgressResponse(
|
return ExerciseProgressResponse(
|
||||||
name=exact_name,
|
name=exact_name,
|
||||||
weighted=progress.weighted,
|
weighted=progress.weighted,
|
||||||
|
|
@ -80,8 +58,6 @@ async def exercise_progress(
|
||||||
primary_change_percent=progress.primary_change_percent,
|
primary_change_percent=progress.primary_change_percent,
|
||||||
rows=[
|
rows=[
|
||||||
SessionRowResponse(
|
SessionRowResponse(
|
||||||
training_id=row.session.training_id,
|
|
||||||
trainee_id=row.session.trainee_id,
|
|
||||||
date=row.session.date,
|
date=row.session.date,
|
||||||
trainer=row.session.trainer,
|
trainer=row.session.trainer,
|
||||||
splitted_weight=row.session.splitted_weight,
|
splitted_weight=row.session.splitted_weight,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter
|
||||||
|
from app.api.v1 import exercises, parser, health, trainings
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.api.v1 import auth, exercises, parser, health, trainings, users
|
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
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"])
|
||||||
api_router.include_router(
|
|
||||||
parser.router,
|
|
||||||
prefix="/parser",
|
|
||||||
tags=["parser"],
|
|
||||||
dependencies=[Depends(get_current_user)],
|
|
||||||
)
|
|
||||||
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
||||||
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
||||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +1,34 @@
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import get_training_repository
|
||||||
get_current_user,
|
|
||||||
get_training_repository,
|
|
||||||
get_user_repository,
|
|
||||||
require_superuser,
|
|
||||||
)
|
|
||||||
from app.core.database.repository import TrainingRepository
|
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.training import TrainingDTO
|
||||||
from app.core.dto.user import UserDTO, UserRole
|
|
||||||
from app.core.parsers.obsidian import parse_training_data
|
from app.core.parsers.obsidian import parse_training_data
|
||||||
from app.schemas.trainings import TrainingUpdate
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def _check_participant(
|
@router.get("/obsidian/")
|
||||||
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)])
|
|
||||||
async def obsidian_trainings_list():
|
async def obsidian_trainings_list():
|
||||||
return {"data": parse_training_data()}
|
return {"data": parse_training_data()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[TrainingDTO])
|
@router.get("/", response_model=List[TrainingDTO])
|
||||||
async def get_trainings_list(
|
async def get_trainings_list(
|
||||||
user: UserDTO = Depends(get_current_user),
|
|
||||||
repository: TrainingRepository = Depends(get_training_repository),
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
):
|
):
|
||||||
"""Trainings the user takes part in (all of them for a superuser)."""
|
return await repository.list()
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{training_id}/")
|
@router.delete("/{training_id}/")
|
||||||
async def delete_training(
|
async def delete_training(
|
||||||
training_id: int,
|
training_id: int,
|
||||||
user: UserDTO = Depends(get_current_user),
|
|
||||||
repository: TrainingRepository = Depends(get_training_repository),
|
repository: TrainingRepository = Depends(get_training_repository),
|
||||||
):
|
):
|
||||||
await _get_editable_training(training_id, user, repository)
|
deleted = await repository.delete([training_id])
|
||||||
await repository.delete([training_id])
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Training not found")
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
|
||||||
|
|
@ -1,74 +1,23 @@
|
||||||
"""Command line helpers.
|
"""Command line helpers.
|
||||||
|
|
||||||
python -m app.cli import [--replace] [--trainee-email EMAIL]
|
python -m app.cli import [--replace]
|
||||||
python -m app.cli set-password EMAIL [--superuser]
|
|
||||||
|
|
||||||
Imports all trainings from the Obsidian and Apple notes into the database as trainings of one
|
Imports all trainings from the Obsidian and Apple notes into the database.
|
||||||
trainee. The trainer from a note header is matched with a user of the trainer role by
|
The schema must already exist (`alembic upgrade head`).
|
||||||
"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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import getpass
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional, Set
|
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.database.connection import create_engine, create_session_factory
|
from app.core.database.connection import create_engine, create_session_factory
|
||||||
from app.core.database.repository import TrainingRepository
|
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.apple import AppleNotesParser
|
||||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||||
|
|
||||||
|
|
||||||
SOLO_NAMES = {"solo", "соло"}
|
async def import_notes(replace: bool) -> None:
|
||||||
|
|
||||||
|
|
||||||
async def assign_participants(
|
|
||||||
session, trainings: List[TrainingDTO], trainee_email: Optional[str]
|
|
||||||
) -> None:
|
|
||||||
"""Set trainee_id and trainer_id on the parsed trainings."""
|
|
||||||
users = await UserRepository(session).list()
|
|
||||||
trainees = [user for user in users if user.role == UserRole.trainee]
|
|
||||||
if trainee_email:
|
|
||||||
matching = [u for u in trainees if u.email == trainee_email.strip().lower()]
|
|
||||||
if not matching:
|
|
||||||
raise SystemExit(f"No trainee with email {trainee_email}")
|
|
||||||
trainee = matching[0]
|
|
||||||
elif len(trainees) == 1:
|
|
||||||
trainee = trainees[0]
|
|
||||||
else:
|
|
||||||
raise SystemExit("There is more than one trainee, pass --trainee-email")
|
|
||||||
|
|
||||||
trainer_ids = {
|
|
||||||
f"{user.first_name} {user.last_name}".strip().casefold(): user.id
|
|
||||||
for user in users
|
|
||||||
if user.role == UserRole.trainer
|
|
||||||
}
|
|
||||||
unknown: Set[str] = set()
|
|
||||||
for training in trainings:
|
|
||||||
training.trainee_id = trainee.id
|
|
||||||
name = (training.trainer or "").strip().casefold()
|
|
||||||
if name in SOLO_NAMES or not name:
|
|
||||||
training.trainer_id = None
|
|
||||||
elif name in trainer_ids:
|
|
||||||
training.trainer_id = trainer_ids[name]
|
|
||||||
else:
|
|
||||||
unknown.add(training.trainer or "")
|
|
||||||
if unknown:
|
|
||||||
raise SystemExit(f"No trainer users for: {', '.join(sorted(unknown))}")
|
|
||||||
|
|
||||||
|
|
||||||
async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
|
|
||||||
parsers = [ObsidianNotesParser(), AppleNotesParser()]
|
parsers = [ObsidianNotesParser(), AppleNotesParser()]
|
||||||
trainings = []
|
trainings = []
|
||||||
for parser in parsers:
|
for parser in parsers:
|
||||||
|
|
@ -83,7 +32,6 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
|
||||||
try:
|
try:
|
||||||
async with create_session_factory(engine)() as session:
|
async with create_session_factory(engine)() as session:
|
||||||
repository = TrainingRepository(session)
|
repository = TrainingRepository(session)
|
||||||
await assign_participants(session, trainings, trainee_email)
|
|
||||||
existing = await repository.count()
|
existing = await repository.count()
|
||||||
if existing and not replace:
|
if existing and not replace:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
|
|
@ -98,26 +46,6 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
|
||||||
print(f"Imported {len(trainings)} trainings")
|
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:
|
def main() -> None:
|
||||||
logging.basicConfig(level=logging.WARNING)
|
logging.basicConfig(level=logging.WARNING)
|
||||||
arg_parser = argparse.ArgumentParser(prog="app.cli")
|
arg_parser = argparse.ArgumentParser(prog="app.cli")
|
||||||
|
|
@ -126,26 +54,10 @@ def main() -> None:
|
||||||
import_parser.add_argument(
|
import_parser.add_argument(
|
||||||
"--replace", action="store_true", help="delete existing trainings first"
|
"--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()
|
args = arg_parser.parse_args()
|
||||||
|
|
||||||
if args.command == "import":
|
if args.command == "import":
|
||||||
asyncio.run(import_notes(args.replace, args.trainee_email))
|
asyncio.run(import_notes(replace=args.replace))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,6 @@ class Settings(BaseSettings):
|
||||||
version: str = "0.0.1"
|
version: str = "0.0.1"
|
||||||
debug: bool = False
|
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 settings
|
||||||
POSTGRES_DATABASE_URL: Optional[str] = None
|
POSTGRES_DATABASE_URL: Optional[str] = None
|
||||||
POSTGRES_DATABASE_HOST: str = "localhost"
|
POSTGRES_DATABASE_HOST: str = "localhost"
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict, List, Optional, Sequence
|
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 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 (
|
from app.core.dto.training import (
|
||||||
ApproachDTO,
|
ApproachDTO,
|
||||||
ExerciseDTO,
|
ExerciseDTO,
|
||||||
|
|
@ -12,37 +12,6 @@ from app.core.dto.training import (
|
||||||
ExerciseSessionDTO,
|
ExerciseSessionDTO,
|
||||||
TrainingDTO,
|
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:
|
class TrainingRepository:
|
||||||
|
|
@ -58,108 +27,47 @@ class TrainingRepository:
|
||||||
"""Save all trainings in a single transaction, returns their ids."""
|
"""Save all trainings in a single transaction, returns their ids."""
|
||||||
if not items:
|
if not items:
|
||||||
return []
|
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(
|
training_ids = await self._insert_returning_ids(
|
||||||
trainings,
|
trainings,
|
||||||
[
|
[{"date": item.date, "trainer": item.trainer} for item in items],
|
||||||
{
|
|
||||||
"date": item.date,
|
|
||||||
"trainee_id": item.trainee_id,
|
|
||||||
"trainer_id": item.trainer_id,
|
|
||||||
}
|
|
||||||
for item in items
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self._insert_exercises(training_ids, items)
|
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()
|
await self._session.commit()
|
||||||
return training_ids
|
return training_ids
|
||||||
|
|
||||||
async def get(
|
async def list(self) -> List[TrainingDTO]:
|
||||||
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]:
|
|
||||||
training_rows = (
|
training_rows = (
|
||||||
await self._session.execute(
|
await self._session.execute(
|
||||||
_trainings_query()
|
select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc())
|
||||||
.where(*_scope(viewer, None))
|
|
||||||
.order_by(trainings.c.date.desc(), trainings.c.id.desc())
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
exercise_rows = (
|
approaches_by_exercise = await self._approaches_by_exercise()
|
||||||
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]
|
|
||||||
)
|
|
||||||
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list)
|
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:
|
for row in exercise_rows:
|
||||||
exercises_by_training[row.training_id].append(
|
exercises_by_training[row.training_id].append(
|
||||||
ExerciseDTO(
|
ExerciseDTO(
|
||||||
|
|
@ -174,49 +82,33 @@ class TrainingRepository:
|
||||||
TrainingDTO(
|
TrainingDTO(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
date=row.date,
|
date=row.date,
|
||||||
trainee_id=row.trainee_id,
|
|
||||||
trainer_id=row.trainer_id,
|
|
||||||
trainer=row.trainer,
|
trainer=row.trainer,
|
||||||
exercises=exercises_by_training[row.id],
|
exercises=exercises_by_training[row.id],
|
||||||
)
|
)
|
||||||
for row in training_rows
|
for row in training_rows
|
||||||
]
|
]
|
||||||
|
|
||||||
async def list_exercise_names(
|
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
|
||||||
self, *, viewer: Optional[UserDTO] = None, trainee_id: Optional[int] = None
|
|
||||||
) -> List[ExerciseNameDTO]:
|
|
||||||
"""Distinct exercise names with the number of times each was done."""
|
"""Distinct exercise names with the number of times each was done."""
|
||||||
query = (
|
query = select(
|
||||||
select(exercises.c.name, func.count(exercises.c.id).label("times"))
|
exercises.c.name, func.count(exercises.c.id).label("times")
|
||||||
.join(trainings, trainings.c.id == exercises.c.training_id)
|
).group_by(exercises.c.name)
|
||||||
.where(*_scope(viewer, trainee_id))
|
|
||||||
.group_by(exercises.c.name)
|
|
||||||
)
|
|
||||||
result = await self._session.execute(query)
|
result = await self._session.execute(query)
|
||||||
names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result]
|
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
|
# Sorted here, not in SQL: the order must not depend on DB collation
|
||||||
return sorted(names, key=lambda item: item.name.casefold())
|
return sorted(names, key=lambda item: item.name.casefold())
|
||||||
|
|
||||||
async def exercise_history(
|
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]:
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
viewer: Optional[UserDTO] = None,
|
|
||||||
trainee_id: Optional[int] = None,
|
|
||||||
) -> List[ExerciseSessionDTO]:
|
|
||||||
"""Every occurrence of the exercise, newest first."""
|
"""Every occurrence of the exercise, newest first."""
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
exercises.c.id,
|
exercises.c.id,
|
||||||
exercises.c.splitted_weight,
|
exercises.c.splitted_weight,
|
||||||
trainings.c.id.label("training_id"),
|
|
||||||
trainings.c.trainee_id,
|
|
||||||
trainings.c.date,
|
trainings.c.date,
|
||||||
_trainer_name,
|
trainings.c.trainer,
|
||||||
)
|
)
|
||||||
.join(trainings, trainings.c.id == exercises.c.training_id)
|
.join(trainings, trainings.c.id == exercises.c.training_id)
|
||||||
.outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id)
|
.where(exercises.c.name == name)
|
||||||
.where(exercises.c.name == name, *_scope(viewer, trainee_id))
|
|
||||||
.order_by(trainings.c.date.desc(), exercises.c.id.desc())
|
.order_by(trainings.c.date.desc(), exercises.c.id.desc())
|
||||||
)
|
)
|
||||||
rows = (await self._session.execute(query)).all()
|
rows = (await self._session.execute(query)).all()
|
||||||
|
|
@ -225,8 +117,6 @@ class TrainingRepository:
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
ExerciseSessionDTO(
|
ExerciseSessionDTO(
|
||||||
training_id=row.training_id,
|
|
||||||
trainee_id=row.trainee_id,
|
|
||||||
date=row.date,
|
date=row.date,
|
||||||
trainer=row.trainer,
|
trainer=row.trainer,
|
||||||
splitted_weight=row.splitted_weight,
|
splitted_weight=row.splitted_weight,
|
||||||
|
|
@ -252,33 +142,6 @@ class TrainingRepository:
|
||||||
)
|
)
|
||||||
await self._session.commit()
|
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]:
|
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
|
||||||
"""Bulk insert, ids come back in the order of `rows`."""
|
"""Bulk insert, ids come back in the order of `rows`."""
|
||||||
result = await self._session.execute(
|
result = await self._session.execute(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
Enum,
|
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
|
@ -13,8 +12,6 @@ from sqlalchemy import (
|
||||||
func,
|
func,
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.core.dto.user import UserRole
|
|
||||||
|
|
||||||
metadata = MetaData()
|
metadata = MetaData()
|
||||||
|
|
||||||
trainings = Table(
|
trainings = Table(
|
||||||
|
|
@ -22,10 +19,7 @@ trainings = Table(
|
||||||
metadata,
|
metadata,
|
||||||
Column("id", Integer, primary_key=True),
|
Column("id", Integer, primary_key=True),
|
||||||
Column("date", Date, nullable=False, index=True),
|
Column("date", Date, nullable=False, index=True),
|
||||||
# RESTRICT: a user who has trainings can't be deleted by accident
|
Column("trainer", String(100)),
|
||||||
Column("trainee_id", ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True),
|
|
||||||
# No trainer means a solo training
|
|
||||||
Column("trainer_id", ForeignKey("users.id", ondelete="RESTRICT"), index=True),
|
|
||||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -58,44 +52,3 @@ approaches = Table(
|
||||||
Column("reps", Integer, nullable=False),
|
Column("reps", Integer, nullable=False),
|
||||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
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()),
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
)
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -25,9 +25,7 @@ class ExerciseDTO(DTOBase):
|
||||||
class TrainingDTO(DTOBase):
|
class TrainingDTO(DTOBase):
|
||||||
id: int | None = None
|
id: int | None = None
|
||||||
date: date
|
date: date
|
||||||
trainee_id: int | None = None # None only for trainings that are not saved yet
|
trainer: str | None = None
|
||||||
trainer_id: int | None = None # None: a solo training
|
|
||||||
trainer: str | None = None # the trainer's name, filled in when read from the DB
|
|
||||||
exercises: list[ExerciseDTO] = Field(default_factory=list)
|
exercises: list[ExerciseDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,8 +39,6 @@ class ExerciseNameDTO(DTOBase):
|
||||||
class ExerciseSessionDTO(DTOBase):
|
class ExerciseSessionDTO(DTOBase):
|
||||||
"""One occurrence of an exercise in the history of that exercise."""
|
"""One occurrence of an exercise in the history of that exercise."""
|
||||||
|
|
||||||
training_id: int | None = None
|
|
||||||
trainee_id: int | None = None
|
|
||||||
date: date
|
date: date
|
||||||
trainer: str | None = None
|
trainer: str | None = None
|
||||||
splitted_weight: bool = False
|
splitted_weight: bool = False
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -15,8 +15,6 @@ logging.basicConfig(
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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
|
# Schema is managed by Alembic: run `alembic upgrade head` before starting the app
|
||||||
engine = create_engine(settings.get_postgres_database_url())
|
engine = create_engine(settings.get_postgres_database_url())
|
||||||
app.state.session_factory = create_session_factory(engine)
|
app.state.session_factory = create_session_factory(engine)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -15,8 +15,6 @@ class SessionMetricsResponse(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class SessionRowResponse(BaseModel):
|
class SessionRowResponse(BaseModel):
|
||||||
training_id: Optional[int]
|
|
||||||
trainee_id: Optional[int]
|
|
||||||
date: date
|
date: date
|
||||||
trainer: Optional[str]
|
trainer: Optional[str]
|
||||||
splitted_weight: bool
|
splitted_weight: bool
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
**08.09.2023 (Пан Степан)**
|
**08.09.2023 (с28)**
|
||||||
|
|
||||||
| Упражнение | Вес | Подходы |
|
| Упражнение | Вес | Подходы |
|
||||||
| ---------------------------------- | ------------------ | ----------- |
|
| ---------------------------------- | ------------------ | ----------- |
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
| Жим от груди лежа (гантели) | 16х2 | 12-12-12-12 |
|
| Жим от груди лежа (гантели) | 16х2 | 12-12-12-12 |
|
||||||
| Брусья (гравитрон) | 47 | 12-9-9 |
|
| Брусья (гравитрон) | 47 | 12-9-9 |
|
||||||
|
|
||||||
**11.09.2023 (Пан Степан)**
|
**11.09.2023 (с29)**
|
||||||
|
|
||||||
| Упражнение | Вес | Подходы |
|
| Упражнение | Вес | Подходы |
|
||||||
| ------------------------ | -------------- | ----------------- |
|
| ------------------------ | -------------- | ----------------- |
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
| Трицепс машина | 77 | 12-12-12-10 |
|
| Трицепс машина | 77 | 12-12-12-10 |
|
||||||
| Молоты стоя | 9x2 | 12-12-12-12 |
|
| Молоты стоя | 9x2 | 12-12-12-12 |
|
||||||
|
|
||||||
**15.09.2023 (Пан Степан)**
|
**15.09.2023 (с30)**
|
||||||
|
|
||||||
| Упражнение | Вес | Подходы |
|
| Упражнение | Вес | Подходы |
|
||||||
| ---------------------------------- | ----- | ----------- |
|
| ---------------------------------- | ----- | ----------- |
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
| Жим от груди лежа (гантели) | 14x2-16x2 | 12-12-12-12 |
|
| Жим от груди лежа (гантели) | 14x2-16x2 | 12-12-12-12 |
|
||||||
| Сведение рук пек дек| 37-27 | 10-9-12 |
|
| Сведение рук пек дек| 37-27 | 10-9-12 |
|
||||||
|
|
||||||
**18.09.2023 (Пан Степан)**
|
**18.09.2023 (с31)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
|Дельт-машина|37|15-15-15-15|
|
|Дельт-машина|37|15-15-15-15|
|
||||||
|Гантели|18|10х2-10х2-10х2|
|
|Гантели|18|10х2-10х2-10х2|
|
||||||
|
|
||||||
**21.09.2023 (Пан Степан)**
|
**21.09.2023 (с32)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -47,7 +47,7 @@
|
||||||
|Жим от плеч вверх (гантели)|14x2-16x2|12-12-11-10|
|
|Жим от плеч вверх (гантели)|14x2-16x2|12-12-11-10|
|
||||||
|Отжимания||8-8-8|
|
|Отжимания||8-8-8|
|
||||||
|
|
||||||
**23.09.2023 (Пан Степан)**
|
**23.09.2023 (с33)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
|Дельт-машина|37-37-47-47|12-15-12-12|
|
|Дельт-машина|37-37-47-47|12-15-12-12|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-10|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-10|
|
||||||
|
|
||||||
**23.09.2023 (Пан Степан)**
|
**23.09.2023 (с34)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -68,7 +68,7 @@
|
||||||
|Баттерфляй|47|12-12-12|
|
|Баттерфляй|47|12-12-12|
|
||||||
|Брусья (гравитрон)|47|8-8-8|
|
|Брусья (гравитрон)|47|8-8-8|
|
||||||
|
|
||||||
**02.10.2023 (соло)**
|
**02.10.2023 (solo)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
|Подъем штанги стоя |20|12-12-11|
|
|Подъем штанги стоя |20|12-12-11|
|
||||||
|Трицепс машина|67-77|15-12-12|
|
|Трицепс машина|67-77|15-12-12|
|
||||||
|
|
||||||
**04.10.2023 (Пан Степан)**
|
**04.10.2023 (с36)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
|Отжимания|+5|4-5-4|
|
|Отжимания|+5|4-5-4|
|
||||||
|Подъем ног в висе||10-10-8|
|
|Подъем ног в висе||10-10-8|
|
||||||
|
|
||||||
**06.10.2023 (Пан Степан)**
|
**06.10.2023 (с37)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -100,7 +100,7 @@
|
||||||
|Махи рукой в кроссовере|6.5x2|12-15-14|
|
|Махи рукой в кроссовере|6.5x2|12-15-14|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
||||||
|
|
||||||
**09.10.2023 (Пан Степан)**
|
**09.10.2023 (с38)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
|Жим от груди сидя в тренажере (кирпичики)|67-77|12-12-12-12|
|
|Жим от груди сидя в тренажере (кирпичики)|67-77|12-12-12-12|
|
||||||
|Брусья (гравитрон)|47|10-8-10-10|
|
|Брусья (гравитрон)|47|10-8-10-10|
|
||||||
|
|
||||||
**11.10.2023 (Пан Степан)**
|
**11.10.2023 (с39)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-10|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-10|
|
||||||
|Отведение в рук в наклоне||12-12-12|
|
|Отведение в рук в наклоне||12-12-12|
|
||||||
|
|
||||||
**16.10.2023 (Пан Степан)**
|
**16.10.2023 (с40)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -131,7 +131,7 @@
|
||||||
|Жим от плеч вверх (гантели)|14х14|12-12-12-12|
|
|Жим от плеч вверх (гантели)|14х14|12-12-12-12|
|
||||||
|Брусья (гравитрон)|47|11-12-9|
|
|Брусья (гравитрон)|47|11-12-9|
|
||||||
|
|
||||||
**18.10.2023 (Пан Степан)**
|
**18.10.2023 (с41)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12-10|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12-10|
|
||||||
|Подъем ног в висе||10-10-9|
|
|Подъем ног в висе||10-10-9|
|
||||||
|
|
||||||
**20.10.2023 (соло)**
|
**20.10.2023 (solo)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -152,7 +152,7 @@
|
||||||
|Сведение рук пек дек|27-27-37-37|15-15-15-15|
|
|Сведение рук пек дек|27-27-37-37|15-15-15-15|
|
||||||
|Подъем ног в висе||10-8-7|
|
|Подъем ног в висе||10-8-7|
|
||||||
|
|
||||||
**23.10.2023 (соло)**
|
**23.10.2023 (solo)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -162,7 +162,7 @@
|
||||||
|Отведение рук пек дек|17|15-12-12-12|
|
|Отведение рук пек дек|17|15-12-12-12|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|15-12-12-9|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|15-12-12-9|
|
||||||
|
|
||||||
**25.10.2023 (Пан Степан)**
|
**25.10.2023 (с42)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -172,7 +172,7 @@
|
||||||
|Баттерфляй|47-57|15-12-9-10|
|
|Баттерфляй|47-57|15-12-9-10|
|
||||||
|Жим от груди лежа (гантели)|16x2-16x2-14x2|11-10-10|
|
|Жим от груди лежа (гантели)|16x2-16x2-14x2|11-10-10|
|
||||||
|
|
||||||
**27.10.2023 (Пан Степан)**
|
**27.10.2023 (с43)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -182,7 +182,7 @@
|
||||||
|Отведение рук пек дек|17|12-12-12|
|
|Отведение рук пек дек|17|12-12-12|
|
||||||
|Скручивания на мяче||12-12-12|
|
|Скручивания на мяче||12-12-12|
|
||||||
|
|
||||||
**27.10.2023 (Пан Степан)**
|
**27.10.2023 (с44)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -193,7 +193,7 @@
|
||||||
|Брусья (гравитрон)|47|12-12-11|
|
|Брусья (гравитрон)|47|12-12-11|
|
||||||
|Разведение рук с гантелей из-за головы|6|10-10-10|
|
|Разведение рук с гантелей из-за головы|6|10-10-10|
|
||||||
|
|
||||||
**01.11.2023 (Пан Степан)**
|
**01.11.2023 (с45)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -203,7 +203,7 @@
|
||||||
|Дельт-машина |37|15-15-15-15|
|
|Дельт-машина |37|15-15-15-15|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|10-10-10-8|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|10-10-10-8|
|
||||||
|
|
||||||
**06.11.2023 (Пан Степан)**
|
**06.11.2023 (с46)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -215,7 +215,7 @@
|
||||||
|Разгибание кик-бек в наклоне|5х2|12-12-12|
|
|Разгибание кик-бек в наклоне|5х2|12-12-12|
|
||||||
|
|
||||||
|
|
||||||
**10.11.2023 (Пан Степан)**
|
**10.11.2023 (с48)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -225,7 +225,7 @@
|
||||||
|Махи гантелей в стороны стоя|7х2-8х2|15-15-15-15|
|
|Махи гантелей в стороны стоя|7х2-8х2|15-15-15-15|
|
||||||
|Разведение рук в наклоне|5х2|15-15-15|
|
|Разведение рук в наклоне|5х2|15-15-15|
|
||||||
|
|
||||||
**13.11.2023 (Пан Степан)**
|
**13.11.2023 (с49)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -236,7 +236,7 @@
|
||||||
|Брусья (гравитрон)|37|12-12-8|
|
|Брусья (гравитрон)|37|12-12-8|
|
||||||
|Отжимания на трицепс в кроссовере|27|12-12-12|
|
|Отжимания на трицепс в кроссовере|27|12-12-12|
|
||||||
|
|
||||||
**15.11.2023 (Пан Степан)**
|
**15.11.2023 (с50)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -245,7 +245,7 @@
|
||||||
|Баттерфляй|47|15-15-14|
|
|Баттерфляй|47|15-15-14|
|
||||||
|Молоты стоя|9x2|12-12-12|
|
|Молоты стоя|9x2|12-12-12|
|
||||||
|
|
||||||
**17.11.2023 (Пан Степан)**
|
**17.11.2023 (с51)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -255,7 +255,7 @@
|
||||||
|Махи рукой в кроссовере|9x2-11x2|12-12-12-12|
|
|Махи рукой в кроссовере|9x2-11x2|12-12-12-12|
|
||||||
|Жим от плеч вверх (гантели)|14x2|9-8-10-10|
|
|Жим от плеч вверх (гантели)|14x2|9-8-10-10|
|
||||||
|
|
||||||
**20.11.2023 (Пан Степан)**
|
**20.11.2023 (с52)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -265,7 +265,7 @@
|
||||||
|Отжимания на брусьях|0|6-7-6|
|
|Отжимания на брусьях|0|6-7-6|
|
||||||
|Отжимания на трицепс в кроссовере|39|15-12-12-12|
|
|Отжимания на трицепс в кроссовере|39|15-12-12-12|
|
||||||
|
|
||||||
**22.11.2023 (соло)**
|
**22.11.2023 (solo)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -276,7 +276,7 @@
|
||||||
|Молоты стоя|9x2-10x2|12-12-11-11|
|
|Молоты стоя|9x2-10x2|12-12-11-11|
|
||||||
|Скручивания на мяче||15-12-12-12|
|
|Скручивания на мяче||15-12-12-12|
|
||||||
|
|
||||||
**24.11.2023 (Пан Степан)**
|
**24.11.2023 (с53)**
|
||||||
|
|
||||||
| Упражнение | Вес | Подходы |
|
| Упражнение | Вес | Подходы |
|
||||||
| -------------------------------- | ------- | ----------- |
|
| -------------------------------- | ------- | ----------- |
|
||||||
|
|
@ -287,7 +287,7 @@
|
||||||
| Жим от плеч вверх (гантели)| 12x2 | 12-12-12-12 |
|
| Жим от плеч вверх (гантели)| 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|
|
|Тяга горизонтального блока (1 рука)|31.7x2-36.2x2|12-12-12-12|
|
||||||
|Брусья|0-0-черная-черная|6-6-7-7|
|
|Брусья|0-0-черная-черная|6-6-7-7|
|
||||||
|
|
||||||
**29.11.2023 (Пан Степан)**
|
**29.11.2023 (с55)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -306,7 +306,7 @@
|
||||||
|Подъем штанги стоя|20|12-11-9-9|
|
|Подъем штанги стоя|20|12-11-9-9|
|
||||||
|Молоты стоя|20х2-20х2-18х2|10-9-12|
|
|Молоты стоя|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|
|
|Махи рукой в кроссовере |9x2-13x2|12-10-10|
|
||||||
|Разведение рук пек дек|27|10-8-8|
|
|Разведение рук пек дек|27|10-8-8|
|
||||||
|
|
||||||
**11.12.2023 (Пан Степан)**
|
**11.12.2023 (с57)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -346,7 +346,7 @@
|
||||||
|Жим от плеч вверх (гантели)|13x2|15-12-9-9|
|
|Жим от плеч вверх (гантели)|13x2|15-12-9-9|
|
||||||
|Отведение рук пек дек|17-27-27-17|15-11-10-12|
|
|Отведение рук пек дек|17-27-27-17|15-11-10-12|
|
||||||
|
|
||||||
**20.12.2023 (Пан Степан)**
|
**20.12.2023 (с58)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -356,7 +356,7 @@
|
||||||
|Сведение рук пек дек|20|12-12-10|
|
|Сведение рук пек дек|20|12-12-10|
|
||||||
|Тяга гантели в наклоне|18x2|12-11-10|
|
|Тяга гантели в наклоне|18x2|12-11-10|
|
||||||
|
|
||||||
**23.12.2023 (Пан Степан)**
|
**23.12.2023 (с59)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -365,7 +365,7 @@
|
||||||
|Махи рукой в кроссовере|11x2|12-12-12|
|
|Махи рукой в кроссовере|11x2|12-12-12|
|
||||||
|Разведение рук пек дек|17|12-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|
|
|Сведение рук пек дек|37-47-47-37|12-10-10-10|
|
||||||
|Гравитрон|36-57|10-10-7|
|
|Гравитрон|36-57|10-10-7|
|
||||||
|
|
||||||
**27.12.2023 (Пан Степан)**
|
**27.12.2023 (с61)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -396,7 +396,7 @@
|
||||||
|Сведение рук пек дек|27-37|12-12-10-7|
|
|Сведение рук пек дек|27-37|12-12-10-7|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-9|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-9|
|
||||||
|
|
||||||
**15.01.2024 (Пан Степан)**
|
**15.01.2024 (с63)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -406,7 +406,7 @@
|
||||||
|Подъем штанги стоя|20|13-12-12|
|
|Подъем штанги стоя|20|13-12-12|
|
||||||
|Жим от плеч вверх (гантели)|20х2|7-7-12|
|
|Жим от плеч вверх (гантели)|20х2|7-7-12|
|
||||||
|
|
||||||
**17.01.2024 (Пан Степан)**
|
**17.01.2024 (с64)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -416,7 +416,7 @@
|
||||||
|Отжимания на трицепс в кроссовере|36.2|13-12-12|
|
|Отжимания на трицепс в кроссовере|36.2|13-12-12|
|
||||||
|Трицепс машина|67-67-57|12-9-10|
|
|Трицепс машина|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|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|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|
|
|Тяга горизонтального блока (1 рука)|18x2-22x2|12-12-12-12|
|
||||||
|Баттерфляй|47-57|12-12-9|
|
|Баттерфляй|47-57|12-12-9|
|
||||||
|
|
||||||
**27.01.2024 (Пан Степан-7)**
|
**27.01.2024 (с67-7)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -448,7 +448,7 @@
|
||||||
|Махи рукой в кроссовере|9x2-11x2|12-12-12|
|
|Махи рукой в кроссовере|9x2-11x2|12-12-12|
|
||||||
|Разведение рук пек дек|27|10|
|
|Разведение рук пек дек|27|10|
|
||||||
|
|
||||||
**29.01.2024 (Пан Степан-8)**
|
**29.01.2024 (с68-8)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -458,7 +458,7 @@
|
||||||
|Сведение рук пек дек|37-37-47-37|15-15-8-11|
|
|Сведение рук пек дек|37-37-47-37|15-15-8-11|
|
||||||
|Тяга вертикального блока обратным хватом|49.8|12-10-8|
|
|Тяга вертикального блока обратным хватом|49.8|12-10-8|
|
||||||
|
|
||||||
**31.01.2024 (Пан Степан-9)**
|
**31.01.2024 (с69-9)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -468,7 +468,7 @@
|
||||||
|Дельт-машина|27|12-12-12-12|
|
|Дельт-машина|27|12-12-12-12|
|
||||||
|Жим от плеч вверх (гантели)|14x2|12-12-11-10|
|
|Жим от плеч вверх (гантели)|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|
|
|Тяга горизонтального блока|49.8-54.5|12-10-12|
|
||||||
|Тяга гантели в наклоне|20x2|12-12-12|
|
|Тяга гантели в наклоне|20x2|12-12-12|
|
||||||
|
|
||||||
**19.02.2024 (Пан Степан-11)**
|
**19.02.2024 (с71-11)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -488,7 +488,7 @@
|
||||||
|Баттерфляй|57-47|10-10-9|
|
|Баттерфляй|57-47|10-10-9|
|
||||||
|Брусья|47|10-10|
|
|Брусья|47|10-10|
|
||||||
|
|
||||||
**21.02.2024 (Пан Степан-12)**
|
**21.02.2024 (с72-12)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -498,7 +498,7 @@
|
||||||
|Дельт-машина|47-47-37-37|12-11-12-12|
|
|Дельт-машина|47-47-37-37|12-11-12-12|
|
||||||
|Отведение рук в стороны с гантелями в наклоне 90⁰|14ш|12-12-12|
|
|Отведение рук в стороны с гантелями в наклоне 90⁰|14ш|12-12-12|
|
||||||
|
|
||||||
**26.02.2024 (Пан Степан-13)**
|
**26.02.2024 (с73-13)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -509,7 +509,7 @@
|
||||||
|Баттерфляй|47|12-11-10|
|
|Баттерфляй|47|12-11-10|
|
||||||
|Отжимания||9-8|
|
|Отжимания||9-8|
|
||||||
|
|
||||||
**28.02.2024 (Пан Степан-14)**
|
**28.02.2024 (с74-14)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -520,7 +520,7 @@
|
||||||
|Дельт-машина|16|12-12-12|
|
|Дельт-машина|16|12-12-12|
|
||||||
|Разведение рук в наклоне|6x2|12-12-12|
|
|Разведение рук в наклоне|6x2|12-12-12|
|
||||||
|
|
||||||
**01.03.2024 (Пан Степан-15)**
|
**01.03.2024 (с75-15)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -530,7 +530,7 @@
|
||||||
|Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12|
|
|Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12|
|
||||||
|Сведение рук пек дек|47-37|6-11-10|
|
|Сведение рук пек дек|47-37|6-11-10|
|
||||||
|
|
||||||
**04.03.2024 (Пан Степан-16)**
|
**04.03.2024 (с76-16)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -540,7 +540,7 @@
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
||||||
|Разведение рук в наклоне|6x2|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|
|
|Баттерфляй|57-47|10-15-9-11|
|
||||||
|Трицепс машина|67-77-67|12-10-12|
|
|Трицепс машина|67-77-67|12-10-12|
|
||||||
|
|
||||||
**13.03.2024 (Пан Степан-18)**
|
**13.03.2024 (с78-18)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -562,7 +562,7 @@
|
||||||
|Жим от груди лежа (штанга)|30|12-12-12|
|
|Жим от груди лежа (штанга)|30|12-12-12|
|
||||||
|Сведение рук пек дек|37|9-11|
|
|Сведение рук пек дек|37|9-11|
|
||||||
|
|
||||||
**15.03.2024 (Пан Степан-19)**
|
**15.03.2024 (с79-19)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -572,7 +572,7 @@
|
||||||
|Махи гантелей в стороны стоя|8x2|12-12-12|
|
|Махи гантелей в стороны стоя|8x2|12-12-12|
|
||||||
|Жим от плеч вверх (гантели)|14x2|12-12-11|
|
|Жим от плеч вверх (гантели)|14x2|12-12-11|
|
||||||
|
|
||||||
**18.03.2024 (Пан Степан-20)**
|
**18.03.2024 (с80-20)**
|
||||||
|
|
||||||
| Упражнение | Вес | Подходы |
|
| Упражнение | Вес | Подходы |
|
||||||
| -------------------------------------- | ------- | -------------- |
|
| -------------------------------------- | ------- | -------------- |
|
||||||
|
|
@ -582,7 +582,7 @@
|
||||||
| Жим от груди сидя в тренажере (кирпичики) | 40-50 | 12-12-12 |
|
| Жим от груди сидя в тренажере (кирпичики) | 40-50 | 12-12-12 |
|
||||||
| Брусья (гравитрон)| 47 | 12-11-10 |
|
| Брусья (гравитрон)| 47 | 12-11-10 |
|
||||||
|
|
||||||
**28.03.2024 (Пан Степан-21)**
|
**28.03.2024 (с81-21)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -593,7 +593,7 @@
|
||||||
|Жим от груди сидя в тренажере (кирпичики)|67-67-57|12-10-10|
|
|Жим от груди сидя в тренажере (кирпичики)|67-67-57|12-10-10|
|
||||||
|Брусья (гравитрон)|37-47|6-8|
|
|Брусья (гравитрон)|37-47|6-8|
|
||||||
|
|
||||||
**01.04.2024 (Пан Степан-22)**
|
**01.04.2024 (с82-22)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -605,7 +605,7 @@
|
||||||
|Жим от плеч вверх (гантели)|16x2|6-12-9|
|
|Жим от плеч вверх (гантели)|16x2|6-12-9|
|
||||||
|Сведение рук пек дек|47-37|9-12-11|
|
|Сведение рук пек дек|47-37|9-12-11|
|
||||||
|
|
||||||
**05.04.2024 (Пан Степан-23)**
|
**05.04.2024 (с83-23)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -615,7 +615,7 @@
|
||||||
|Разведение рук пек дек|27|12-12-11|
|
|Разведение рук пек дек|27|12-12-11|
|
||||||
|Подъем ног в висе||10-10-10|
|
|Подъем ног в висе||10-10-10|
|
||||||
|
|
||||||
**08.04.2024 (Пан Степан-24)**
|
**08.04.2024 (с84-24)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -625,7 +625,7 @@
|
||||||
|Жим от плеч вверх (штанга)|40-50-55|12-12-10-8|
|
|Жим от плеч вверх (штанга)|40-50-55|12-12-10-8|
|
||||||
|Сведение рук пек дек|47-37|8-11-10-10|
|
|Сведение рук пек дек|47-37|8-11-10-10|
|
||||||
|
|
||||||
**10.04.2024 (Пан Степан-25)**
|
**10.04.2024 (с85-25)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -635,7 +635,7 @@
|
||||||
|Махи гантелей в стороны стоя|8x2|12-12-12|
|
|Махи гантелей в стороны стоя|8x2|12-12-12|
|
||||||
|Отведение рук пек дек|27|10-10-9|
|
|Отведение рук пек дек|27|10-10-9|
|
||||||
|
|
||||||
**12.04.2024 (Пан Степан-26)**
|
**12.04.2024 (c86-26)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -646,7 +646,7 @@
|
||||||
|Жим от груди сидя в тренажере (кирпичики)|77|9-10-9|
|
|Жим от груди сидя в тренажере (кирпичики)|77|9-10-9|
|
||||||
|Сведение рук пек дек|37-47-37|12-8-11|
|
|Сведение рук пек дек|37-47-37|12-8-11|
|
||||||
|
|
||||||
**14.04.2024 (соло-27)**
|
**14.04.2024 (solo-27)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -658,7 +658,7 @@
|
||||||
|Тяга гантели в наклоне|20x2|12-12-12|
|
|Тяга гантели в наклоне|20x2|12-12-12|
|
||||||
|Жим от груди сидя в тренажере (кирпичики)|67-77|15-12-7|
|
|Жим от груди сидя в тренажере (кирпичики)|67-77|15-12-7|
|
||||||
|
|
||||||
**15.04.2024 (Пан Степан-28)**
|
**15.04.2024 (с87-28)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -668,7 +668,7 @@
|
||||||
|Жим от плеч вверх (гантели)|16x2-11x2-14x2|12-10-10|
|
|Жим от плеч вверх (гантели)|16x2-11x2-14x2|12-10-10|
|
||||||
|Разведение рук пек дек|27-27-27-17|12-11-10-12|
|
|Разведение рук пек дек|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|
|
|Жим от груди сидя в тренажере (блинчики)|20x2-25x2|12-12-12|
|
||||||
|Сведение рук пек дек|47-37|11-9-12-11|
|
|Сведение рук пек дек|47-37|11-9-12-11|
|
||||||
|
|
||||||
**22.04.2024 (Пан Степан-30)**
|
**22.04.2024 (с89-30)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -688,7 +688,7 @@
|
||||||
|Дельт-машина|47|12-12-12|
|
|Дельт-машина|47|12-12-12|
|
||||||
|Разведение рук в наклоне|8х2|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|
|
|Брусья|47|12-12-12|
|
||||||
|Разгибание кик-бек в наклоне|8x2-9x2-8x2|12-10-12|
|
|Разгибание кик-бек в наклоне|8x2-9x2-8x2|12-10-12|
|
||||||
|
|
||||||
**01.05.2024 (Пан Степан-32)**
|
**01.05.2024 (с91-32)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -709,7 +709,7 @@
|
||||||
|Жим от плеч вверх (гантели)|16x2|12-10-10|
|
|Жим от плеч вверх (гантели)|16x2|12-10-10|
|
||||||
|Разведение рук в наклоне|8x2|12-12-11|
|
|Разведение рук в наклоне|8x2|12-12-11|
|
||||||
|
|
||||||
**03.05.2024 (Пан Степан-33)**
|
**03.05.2024 (с92-33)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -719,7 +719,7 @@
|
||||||
|Сведение рук пек дек|37|12-12-11|
|
|Сведение рук пек дек|37|12-12-11|
|
||||||
|Трицепс машина|67-87-77|12-11-12|
|
|Трицепс машина|67-87-77|12-11-12|
|
||||||
|
|
||||||
**06.05.2024 (Пан Степан-34)**
|
**06.05.2024 (с93-34)**
|
||||||
|
|
||||||
| Упражнение | Вес | Подходы |
|
| Упражнение | Вес | Подходы |
|
||||||
| ------------------------------------------- | -------- | -------------- |
|
| ------------------------------------------- | -------- | -------------- |
|
||||||
|
|
@ -729,7 +729,7 @@
|
||||||
| Разведение рук пек дек| 27 | 12-11-12 |
|
| Разведение рук пек дек| 27 | 12-11-12 |
|
||||||
| Жим от плеч вверх в тренажере (кирпичики)| 37 | 12-11-7 |
|
| Жим от плеч вверх в тренажере (кирпичики)| 37 | 12-11-7 |
|
||||||
|
|
||||||
**21.08.2024 (Пан Степан-69)**
|
**21.08.2024 (с123-69)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -739,7 +739,7 @@
|
||||||
|Жим от груди сидя в тренажере (блинчики)|20x2-30x2-30x2|12-12-10|
|
|Жим от груди сидя в тренажере (блинчики)|20x2-30x2-30x2|12-12-10|
|
||||||
|Баттерфляй|57|12-12-12|
|
|Баттерфляй|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|
|
|Баттерфляй|57|12-10-11|
|
||||||
|Брусья|37|9-10|
|
|Брусья|37|9-10|
|
||||||
|
|
||||||
**12.08.2024 (Пан Степан-66)**
|
**12.08.2024 (с120-66)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -771,7 +771,7 @@
|
||||||
|Жим от плеч вверх (гантели)|18х2|10-9-7|
|
|Жим от плеч вверх (гантели)|18х2|10-9-7|
|
||||||
|Разведение рук пек дек|37|12-10-10|
|
|Разведение рук пек дек|37|12-10-10|
|
||||||
|
|
||||||
**08.08.2024 (Пан Степан-65)**
|
**08.08.2024 (с119-65)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -782,7 +782,7 @@
|
||||||
|Баттерфляй|57|12-12-12|
|
|Баттерфляй|57|12-12-12|
|
||||||
|Брусья (гравитрон)|37|8-10-9|
|
|Брусья (гравитрон)|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|
|
|Разведение рук пек дек|37-37-47-47|12-12-12-12|
|
||||||
|Жим от плеч вверх (гантели)|16х2-18х2|11-8-8-8|
|
|Жим от плеч вверх (гантели)|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|
|
|Жим от груди лежа (гантели)|16х2-22х2|12-12-12-8|
|
||||||
|Сведение рук пек дек|37|12-12-12|
|
|Сведение рук пек дек|37|12-12-12|
|
||||||
|
|
||||||
**02.08.2024 (Пан Степан-62)**
|
**02.08.2024 (с116-62)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -811,7 +811,7 @@
|
||||||
|Дельт-машина|20|12-12-12|
|
|Дельт-машина|20|12-12-12|
|
||||||
|Разведение рук пек дек|37|10-10|
|
|Разведение рук пек дек|37|10-10|
|
||||||
|
|
||||||
**31.07.2024 (Пан Степан-61)**
|
**31.07.2024 (с115-61)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -821,7 +821,7 @@
|
||||||
|Сведение рук пек дек|37-47|12-12-12-9|
|
|Сведение рук пек дек|37-47|12-12-12-9|
|
||||||
|Отжимания|10|9-8-8|
|
|Отжимания|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|
|
|Жим от плеч вверх в тренажере (кирпичики)|20-30-30-25|12-12-10-12|
|
||||||
|Махи гантелей в стороны стоя|10х2|12-12-12-12|
|
|Махи гантелей в стороны стоя|10х2|12-12-12-12|
|
||||||
|
|
||||||
**26.07.2024 (Пан Степан-59)**
|
**26.07.2024 (с113-59)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -840,7 +840,7 @@
|
||||||
|Отжимания|+10|12-10-8|
|
|Отжимания|+10|12-10-8|
|
||||||
|Баттерфляй|57-57-47|10-12-12-12|
|
|Баттерфляй|57-57-47|10-12-12-12|
|
||||||
|
|
||||||
**25.07.2024 (Пан Степан-58)**
|
**25.07.2024 (с112-58)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -850,7 +850,7 @@
|
||||||
|Разведение рук пек дек|27|12-12-12|
|
|Разведение рук пек дек|27|12-12-12|
|
||||||
|Махи гантелей в стороны стоя|8х8-9х9|12-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|
|
|Сведение рук пек дек|37|12-12-11|
|
||||||
|Трицепс машина|77|12-12-12|
|
|Трицепс машина|77|12-12-12|
|
||||||
|
|
||||||
**17.07.2024 (Пан Степан-56)**
|
**17.07.2024 (с110-56)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -871,7 +871,7 @@
|
||||||
|Разведение рук пек дек|27|12-12-10|
|
|Разведение рук пек дек|27|12-12-10|
|
||||||
|Жим от плеч вверх (гантели)|16х2|12-12|
|
|Жим от плеч вверх (гантели)|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|
|
|Жим от груди сидя в тренажере (блинчики)|33.5х2-43.5х2|12-12-10-10|
|
||||||
|Брусья|47|9-9-9|
|
|Брусья|47|9-9-9|
|
||||||
|
|
||||||
**10.07.2024 (Пан Степан-54)**
|
**10.07.2024 (с108-54)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -891,7 +891,7 @@
|
||||||
|Дельт-машина |37|12-12-12|
|
|Дельт-машина |37|12-12-12|
|
||||||
|Разведение рук пек дек|27|12-11-11|
|
|Разведение рук пек дек|27|12-11-11|
|
||||||
|
|
||||||
**08.07.2024 (Пан Степан-53)**
|
**08.07.2024 (с107-53)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -901,7 +901,7 @@
|
||||||
|Сведение рук пек дек|37|12-12-12|
|
|Сведение рук пек дек|37|12-12-12|
|
||||||
|Брусья (гравитрон)|47|12-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|
|
|Жим от плеч вверх (гантели)|14х2|12-12-12|
|
||||||
|Разведение рук пек дек|27|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|
|
|Сведение рук пек дек|47-37|10-14-13|
|
||||||
|Брусья|37|12-9-8|
|
|Брусья|37|12-9-8|
|
||||||
|
|
||||||
**21.06.2024 (соло-50)**
|
**21.06.2024 (solo-50)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -932,7 +932,7 @@
|
||||||
|Разведение рук пек дек|17-27|12-11-11-8|
|
|Разведение рук пек дек|17-27|12-11-11-8|
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|37|12-11-9-9|
|
|Жим от плеч вверх в тренажере (кирпичики)|37|12-11-9-9|
|
||||||
|
|
||||||
**19.06.2024 (Пан Степан-49)**
|
**19.06.2024 (с104-49)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -943,7 +943,7 @@
|
||||||
|Сведение рук пек дек|47|9-9-8|
|
|Сведение рук пек дек|47|9-9-8|
|
||||||
|Брусья|37|9-9-9|
|
|Брусья|37|9-9-9|
|
||||||
|
|
||||||
**17.06.2024 (Пан Степан-48)**
|
**17.06.2024 (с103-48)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -953,7 +953,7 @@
|
||||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
||||||
|Разведение рук пек дек|17|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|
|
|Жим от груди сидя в тренажере (блинчики)|20x2-22x2|12-12-12-12|
|
||||||
|Жим от груди лежа (гантели)|16x2|10-12|
|
|Жим от груди лежа (гантели)|16x2|10-12|
|
||||||
|
|
||||||
**10.06.2024 (Пан Степан-46)**
|
**10.06.2024 (с101-46)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -983,7 +983,7 @@
|
||||||
|Тяга штанги в наклоне|40-45|12-12-12-10|
|
|Тяга штанги в наклоне|40-45|12-12-12-10|
|
||||||
|Дельт-машина |37-47|15-12-12-12|
|
|Дельт-машина |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|
|
|Жим от груди сидя в тренажере (кирпичики)|87-87-77|11-9-12-10|
|
||||||
|Трицепс машина|67-77|12-12-12-12|
|
|Трицепс машина|67-77|12-12-12-12|
|
||||||
|
|
||||||
**20.05.2024 (Пан Степан-40)**
|
**20.05.2024 (c98-40)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -1024,7 +1024,7 @@
|
||||||
|Баттерфляй|57-47|10-12-12|
|
|Баттерфляй|57-47|10-12-12|
|
||||||
|
|
||||||
|
|
||||||
**17.05.2024 (Пан Степан-39)**
|
**17.05.2024 (c97-39)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -1036,7 +1036,7 @@
|
||||||
|Трицепс машина|67-77|12-12-12|
|
|Трицепс машина|67-77|12-12-12|
|
||||||
|
|
||||||
|
|
||||||
**15.05.2024 (Пан Степан-38)**
|
**15.05.2024 (c96-38)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -1046,7 +1046,7 @@
|
||||||
|Жим от груди лежа (гантели)|16x2-18x2|12-12-9|
|
|Жим от груди лежа (гантели)|16x2-18x2|12-12-9|
|
||||||
|Брусья (гравитрон)|37|12-11-10|
|
|Брусья (гравитрон)|37|12-11-10|
|
||||||
|
|
||||||
**13.05.2024 (Пан Степан-37)**
|
**13.05.2024 (c95-37)**
|
||||||
|
|
||||||
|Упражнение|Вес|Подходы|
|
|Упражнение|Вес|Подходы|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|
@ -1055,7 +1055,7 @@
|
||||||
|Жим от плеч вверх (гантели)|16x2-16x2-16x2|12-11-10|
|
|Жим от плеч вверх (гантели)|16x2-16x2-16x2|12-11-10|
|
||||||
|Махи гантелей в стороны стоя|7x2|12-15-15|
|
|Махи гантелей в стороны стоя|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|
|
|Жим от груди сидя в тренажере (блинчики)|15х2-25х2|14-12-12-12|
|
||||||
|Сведение рук пек дек|37|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
|
|
@ -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 ###
|
|
||||||
|
|
@ -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 ###
|
|
||||||
|
|
@ -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')
|
|
||||||
|
|
@ -5,4 +5,3 @@ pytest-asyncio==1.4.0
|
||||||
mypy==1.17.1
|
mypy==1.17.1
|
||||||
types-grpcio==1.0.0.20250703
|
types-grpcio==1.0.0.20250703
|
||||||
types-protobuf==6.30.2.20250822
|
types-protobuf==6.30.2.20250822
|
||||||
httpx==0.28.1
|
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,3 @@ python-dotenv==1.1.1
|
||||||
sqlalchemy[asyncio]==2.1.6
|
sqlalchemy[asyncio]==2.1.6
|
||||||
asyncpg==0.32.0
|
asyncpg==0.32.0
|
||||||
alembic==1.20.1
|
alembic==1.20.1
|
||||||
PyJWT==2.10.1
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.database.connection import create_engine, create_session_factory
|
from app.core.database.connection import create_engine, create_session_factory
|
||||||
from app.core.database.user_repository import UserRepository
|
from app.core.database.repository import TrainingRepository
|
||||||
from app.core.dto.user import UserRole
|
|
||||||
|
|
||||||
TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
|
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")
|
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||||
engine = create_engine(TEST_DATABASE_URL)
|
engine = create_engine(TEST_DATABASE_URL)
|
||||||
async with create_session_factory(engine)() as session:
|
async with create_session_factory(engine)() as session:
|
||||||
# the migration creates the users of the real data, tests start from nothing
|
await TrainingRepository(session).delete_all()
|
||||||
await session.execute(text("TRUNCATE users, trainings RESTART IDENTITY CASCADE"))
|
|
||||||
await session.commit()
|
|
||||||
yield session
|
yield session
|
||||||
await engine.dispose()
|
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()
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -6,30 +6,23 @@ when TEST_DATABASE_URL is not set.
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.exc import DBAPIError
|
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.tables import approaches, exercises
|
||||||
from app.core.database.repository import TrainingRepository
|
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.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
||||||
from app.core.parsers.apple import AppleNotesParser
|
from app.core.parsers.apple import AppleNotesParser
|
||||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||||
from tests.conftest import TEST_DATABASE_URL
|
|
||||||
|
|
||||||
|
|
||||||
def make_training(
|
def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO:
|
||||||
trainee_id: int, day: int = 1, trainer_id: Optional[int] = None
|
|
||||||
) -> TrainingDTO:
|
|
||||||
return TrainingDTO(
|
return TrainingDTO(
|
||||||
date=date(2025, 1, day),
|
date=date(2025, 1, day),
|
||||||
trainee_id=trainee_id,
|
trainer=trainer,
|
||||||
trainer_id=trainer_id,
|
|
||||||
exercises=[
|
exercises=[
|
||||||
ExerciseDTO(
|
ExerciseDTO(
|
||||||
name="Жим ногами",
|
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)
|
repository = TrainingRepository(session)
|
||||||
|
|
||||||
training_id = await repository.save(make_training(trainee.id))
|
training_id = await repository.save(make_training())
|
||||||
trainings = await repository.list()
|
trainings = await repository.list()
|
||||||
|
|
||||||
assert len(trainings) == 1
|
assert len(trainings) == 1
|
||||||
saved = trainings[0]
|
saved = trainings[0]
|
||||||
assert saved.id == training_id
|
assert saved.id == training_id
|
||||||
assert (saved.date, saved.trainee_id) == (date(2025, 1, 1), trainee.id)
|
assert (saved.date, saved.trainer) == (date(2025, 1, 1), "solo")
|
||||||
assert (saved.trainer_id, saved.trainer) == (None, None) # solo
|
|
||||||
assert [e.name for e in saved.exercises] == ["Жим ногами", "Жим гантелей лежа"]
|
assert [e.name for e in saved.exercises] == ["Жим ногами", "Жим гантелей лежа"]
|
||||||
assert saved.exercises[1].splitted_weight is True
|
assert saved.exercises[1].splitted_weight is True
|
||||||
assert [(a.weight, a.reps) for a in saved.exercises[0].approaches] == [
|
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)
|
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]
|
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)
|
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()
|
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)
|
repository = TrainingRepository(session)
|
||||||
await repository.save_many(
|
await repository.save_many(
|
||||||
[
|
[make_training(day=1), make_training(day=3, trainer="c153")]
|
||||||
make_training(trainee.id, day=1),
|
|
||||||
make_training(trainee.id, day=3, trainer_id=trainer.id),
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
history = await repository.exercise_history("Жим ногами")
|
history = await repository.exercise_history("Жим ногами")
|
||||||
|
|
||||||
assert [item.date.day for item in history] == [3, 1]
|
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 [(a.weight, a.reps) for a in history[0].approaches] == [(100, 12), (110, 10)]
|
||||||
assert history[0].splitted_weight is False
|
assert history[0].splitted_weight is False
|
||||||
|
|
||||||
|
|
||||||
async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercises(
|
async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercises(
|
||||||
session, trainee
|
session,
|
||||||
):
|
):
|
||||||
repository = TrainingRepository(session)
|
repository = TrainingRepository(session)
|
||||||
training_id = await repository.save(make_training(trainee.id))
|
await repository.save(make_training())
|
||||||
|
|
||||||
history = await repository.exercise_history("Жим гантелей лежа")
|
history = await repository.exercise_history("Жим гантелей лежа")
|
||||||
|
|
||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
assert history[0].training_id == training_id
|
|
||||||
assert history[0].splitted_weight is True
|
assert history[0].splitted_weight is True
|
||||||
assert [a.weight for a in history[0].approaches] == [20]
|
assert [a.weight for a in history[0].approaches] == [20]
|
||||||
assert await repository.exercise_history("Несуществующее") == []
|
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)
|
repository = TrainingRepository(session)
|
||||||
keep_id, delete_id = await repository.save_many(
|
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
|
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
|
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)
|
repository = TrainingRepository(session)
|
||||||
training_id = await repository.save(make_training(trainee.id))
|
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
with pytest.raises(DBAPIError):
|
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()
|
await session.rollback()
|
||||||
|
|
||||||
assert await repository.count() == 0
|
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()
|
trainings = ObsidianNotesParser().parse_file() + AppleNotesParser().parse_file()
|
||||||
for training in trainings:
|
|
||||||
training.trainee_id = trainee.id
|
|
||||||
repository = TrainingRepository(session)
|
repository = TrainingRepository(session)
|
||||||
|
|
||||||
ids = await repository.save_many(trainings)
|
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):
|
async def test_downgrade_and_upgrade(alembic_config):
|
||||||
await asyncio.to_thread(command.downgrade, alembic_config, "base")
|
await asyncio.to_thread(command.downgrade, alembic_config, "base")
|
||||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
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),
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -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))
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -3,7 +3,6 @@ services:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: dev
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -32,8 +31,6 @@ services:
|
||||||
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
||||||
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
|
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
|
||||||
- ./frontend/parse.html:/usr/share/nginx/html/parse.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/css:/usr/share/nginx/html/css:ro
|
||||||
- ./frontend/js:/usr/share/nginx/html/js:ro
|
- ./frontend/js:/usr/share/nginx/html/js:ro
|
||||||
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro
|
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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 css /usr/share/nginx/html/css
|
||||||
COPY js /usr/share/nginx/html/js
|
COPY js /usr/share/nginx/html/js
|
||||||
COPY vendor /usr/share/nginx/html/vendor
|
COPY vendor /usr/share/nginx/html/vendor
|
||||||
|
|
|
||||||
|
|
@ -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="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="parse.html">Разобрать заметку</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white active" href="exercises.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>
|
</ul>
|
||||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-grow-1 p-4 content" id="content">
|
<main class="flex-grow-1 p-4 content" id="content">
|
||||||
|
|
@ -38,7 +36,6 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script src="vendor/chart.umd.js"></script>
|
<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.min.js"></script>
|
||||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
<script type="module" src="js/exercises.js"></script>
|
<script type="module" src="js/exercises.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -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 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="parse.html">Разобрать заметку</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.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>
|
</ul>
|
||||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-grow-1 p-4 content" id="content">
|
<main class="flex-grow-1 p-4 content" id="content">
|
||||||
|
|
@ -29,7 +27,6 @@
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="vendor/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="vendor/dataTables.min.js"></script>
|
<script src="vendor/dataTables.min.js"></script>
|
||||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
<script type="module" src="js/trainings.js"></script>
|
<script type="module" src="js/trainings.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +1,9 @@
|
||||||
// Shared helpers: API access, DOM building, formatting
|
// Shared helpers: API access, DOM building, formatting
|
||||||
|
|
||||||
import { accessToken, redirectToLogin, refresh } from './auth.js';
|
|
||||||
|
|
||||||
const API = '/api/v1';
|
const API = '/api/v1';
|
||||||
|
|
||||||
/** What a training without a trainer is called */
|
export async function api(path, options) {
|
||||||
export const SOLO = 'соло';
|
const response = await fetch(API + path, options);
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(`HTTP ${response.status}`);
|
const error = new Error(`HTTP ${response.status}`);
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
|
|
@ -56,17 +34,6 @@ export function formatDate(iso) {
|
||||||
return `${day}.${month}.${year}`;
|
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" */
|
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
||||||
export function num(value) {
|
export function num(value) {
|
||||||
return String(Math.round(value * 10) / 10);
|
return String(Math.round(value * 10) / 10);
|
||||||
|
|
|
||||||
|
|
@ -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}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
import './page.js';
|
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
|
||||||
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js';
|
|
||||||
import { editButton } from './edit-dialog.js';
|
|
||||||
import { canEditTraining } from './page.js';
|
|
||||||
import { createTable } from './table.js';
|
import { createTable } from './table.js';
|
||||||
|
|
||||||
const HINTS = {
|
const HINTS = {
|
||||||
|
|
@ -108,7 +105,7 @@ function progressView(progress) {
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) },
|
{ 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) },
|
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) },
|
||||||
...(weighted ? [
|
...(weighted ? [
|
||||||
{ title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' },
|
{ 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.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: 'Всего повторов', 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 [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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() };
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { alert, api } from './common.js';
|
import { alert, api } from './common.js';
|
||||||
import './page.js';
|
|
||||||
import { trainingsView } from './trainings-view.js';
|
import { trainingsView } from './trainings-view.js';
|
||||||
|
|
||||||
const form = document.getElementById('parse-form');
|
const form = document.getElementById('parse-form');
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
// One table for all trainings: a row per exercise
|
// One table for all trainings: a row per exercise
|
||||||
|
|
||||||
import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js';
|
import { alert, approachChips, formatDate, h, perArmBadge } from './common.js';
|
||||||
import { editButton } from './edit-dialog.js';
|
|
||||||
import { canEditTraining } from './page.js';
|
|
||||||
import { createTable } from './table.js';
|
import { createTable } from './table.js';
|
||||||
|
|
||||||
export function trainingsView(trainings) {
|
export function trainingsView(trainings) {
|
||||||
|
|
@ -16,8 +14,6 @@ export function trainingsView(trainings) {
|
||||||
? training.exercises.map((exercise) => ({ training, exercise }))
|
? training.exercises.map((exercise) => ({ training, exercise }))
|
||||||
: [{ training, exercise: null }]);
|
: [{ 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;
|
const exerciseCount = rows.filter((row) => row.exercise).length;
|
||||||
|
|
||||||
return h('div', {},
|
return h('div', {},
|
||||||
|
|
@ -29,7 +25,7 @@ export function trainingsView(trainings) {
|
||||||
rows,
|
rows,
|
||||||
columns: [
|
columns: [
|
||||||
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.training.date, cell: (row) => formatDate(row.training.date) },
|
{ 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: 'Упражнение',
|
title: 'Упражнение',
|
||||||
sort: (row) => row.exercise?.name || '',
|
sort: (row) => row.exercise?.name || '',
|
||||||
|
|
@ -38,10 +34,6 @@ export function trainingsView(trainings) {
|
||||||
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
|
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
|
||||||
},
|
},
|
||||||
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) },
|
{ 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,
|
|
||||||
}] : []),
|
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { alert, api } from './common.js';
|
import { alert, api } from './common.js';
|
||||||
import './page.js';
|
|
||||||
import { trainingsView } from './trainings-view.js';
|
import { trainingsView } from './trainings-view.js';
|
||||||
|
|
||||||
const container = document.getElementById('trainings');
|
const container = document.getElementById('trainings');
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
|
||||||
}
|
|
||||||
|
|
@ -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}`));
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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="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 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"><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>
|
</ul>
|
||||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-grow-1 p-4 content" id="content">
|
<main class="flex-grow-1 p-4 content" id="content">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
7
frontend/vendor/bootstrap.bundle.min.js
vendored
7
frontend/vendor/bootstrap.bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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()
|
|
||||||
Loading…
Reference in a new issue