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
|
||||
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`.
|
||||
|
||||
The backend `Dockerfile` has two targets: `dev` (default in compose: pytest, mypy, tests, notes) and `prod` (runtime dependencies and app code only). Production images are built with `docker build --target prod backend`; the prod image has no `data/`, so the dev-only `GET /api/v1/trainings/obsidian/` doesn't work there.
|
||||
|
||||
### Testing
|
||||
Tests that need a database use a separate `<POSTGRES_DATABASE_DB>_test` database (`TEST_DATABASE_URL`, created automatically). Without it they are skipped.
|
||||
```bash
|
||||
|
|
@ -73,23 +71,15 @@ docker compose exec fastapi-app mypy app/
|
|||
- `Approach` - single set with weight and reps
|
||||
|
||||
**API Structure** (backend, JSON only):
|
||||
- `/api/v1/auth/` (login, refresh, logout, me), `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/`
|
||||
- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/`
|
||||
|
||||
**Frontend** (`frontend/`):
|
||||
- `index.html`, `exercises.html`, `parse.html`, `users.html`, `login.html` - pages; `js/auth.js` (tokens, refresh, logout) and `js/page.js` (every page imports it first: requires a login, fills the sidebar, exports `user`); `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal)
|
||||
- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
|
||||
- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (search/sort/paging), one module per page
|
||||
- `vendor/` - Bootstrap CSS and Chart.js (the only third-party code)
|
||||
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
|
||||
|
||||
### Authentication
|
||||
|
||||
Access token: JWT (HS256, 15 min, `JWT_SECRET` in `.env`), sent as `Authorization: Bearer`. Refresh token: random opaque string, 30 days, only its hash is stored (`refresh_tokens`); it works once (rotation), reusing a used one revokes the whole chain. `POST /auth/login/` with `client: "web"` puts the refresh token only into an httpOnly `SameSite=Strict` cookie (path `/api/v1/auth`, `Secure` when `COOKIE_SECURE=true`, set it in production with HTTPS); `client: "native"` (iOS) returns it in the body for the Keychain. The web frontend keeps the access token in `sessionStorage`.
|
||||
|
||||
There is no registration: only a superuser creates users (`/api/v1/users/`). The first login: `docker compose exec fastapi-app python -m app.cli set-password EMAIL [--superuser]`. Access: a superuser sees and changes everything; a trainee sees and changes their trainings; a trainer only sees the trainings where they are the trainer. Exercise statistics are about the user's own trainings (`?trainee_id=` narrows to one trainee).
|
||||
|
||||
### Database
|
||||
|
||||
Trainings belong to a trainee (`trainings.trainee_id`, required) and optionally have a trainer (`trainings.trainer_id`, NULL = solo); both are users (role `trainee` / `trainer`). A user with trainings can't be deleted. `python -m app.cli import` takes the trainer from the note header and matches it with a trainer user by "First Last".
|
||||
|
||||
The backend uses PostgreSQL through async SQLAlchemy (asyncpg). tables are described with SQLAlchemy Core (no ORM) in `backend/app/core/database/tables.py`, `TrainingRepository` (`backend/app/core/database/repository.py`) is the data access layer, and a session is injected into endpoints with `Depends(get_session)`. Schema migrations are handled by Alembic (`alembic.ini`, `migrations/`); the URL is taken from `app/config.py`.
|
||||
|
||||
Configuration is managed through `backend/app/config.py` using Pydantic settings with `.env` file support.
|
||||
|
|
|
|||
|
|
@ -1,37 +1,32 @@
|
|||
# Two images from one file:
|
||||
# docker build --target prod . - runtime only: no pytest/mypy, no tests, no personal notes
|
||||
# docker build --target dev . - prod + dev tools, tests and notes (docker compose uses it)
|
||||
|
||||
FROM python:3.13-slim AS base
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
|
||||
COPY alembic.ini ./
|
||||
COPY ./app ./app
|
||||
COPY ./migrations ./migrations
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
FROM base AS prod
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
|
||||
# The last stage is the default target. The project is developed only in Docker, so the dev
|
||||
# tools (pytest, mypy) live in this image.
|
||||
FROM base AS dev
|
||||
|
||||
COPY requirements-dev.txt pyproject.toml ./
|
||||
# Install Python dependencies (the project is run only in Docker, so dev tools
|
||||
# like pytest and mypy are part of the image)
|
||||
RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
|
||||
# Copy application code
|
||||
COPY pyproject.toml alembic.ini ./
|
||||
COPY ./app ./app
|
||||
COPY ./migrations ./migrations
|
||||
COPY ./data ./data
|
||||
COPY ./tests ./tests
|
||||
|
||||
# Set Python path
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Command will be overridden by docker-compose
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
|
|
|||
|
|
@ -1,61 +1,11 @@
|
|||
import jwt
|
||||
from fastapi import Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.connection import get_session
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.database.token_repository import RefreshTokenRepository
|
||||
from app.core.database.user_repository import UserRepository
|
||||
from app.core.dto.user import UserDTO
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
# auto_error=False: a missing header gets the same 401 as a bad token
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_training_repository(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TrainingRepository:
|
||||
return TrainingRepository(session)
|
||||
|
||||
|
||||
def get_user_repository(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> UserRepository:
|
||||
return UserRepository(session)
|
||||
|
||||
|
||||
def get_token_repository(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RefreshTokenRepository:
|
||||
return RefreshTokenRepository(session)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
repository: UserRepository = Depends(get_user_repository),
|
||||
) -> UserDTO:
|
||||
"""The user of the access token. The user is read from the database on every request, so
|
||||
a deactivated user loses access at once, not when the token expires."""
|
||||
unauthorized = HTTPException(
|
||||
status_code=401,
|
||||
detail="Not authenticated",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if credentials is None:
|
||||
raise unauthorized
|
||||
try:
|
||||
user_id = int(decode_access_token(credentials.credentials)["sub"])
|
||||
except (jwt.PyJWTError, ValueError):
|
||||
raise unauthorized
|
||||
user = await repository.get(user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise unauthorized
|
||||
return user
|
||||
|
||||
|
||||
async def require_superuser(user: UserDTO = Depends(get_current_user)) -> UserDTO:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Superuser only")
|
||||
return user
|
||||
|
|
|
|||
|
|
@ -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 app.api.deps import get_current_user, get_training_repository
|
||||
from app.api.deps import get_training_repository
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.dto.training import ExerciseNameDTO
|
||||
from app.core.dto.user import UserDTO, UserRole
|
||||
from app.core.metrics import Record, summarize
|
||||
from app.schemas.exercises import (
|
||||
ExerciseProgressResponse,
|
||||
|
|
@ -17,57 +16,36 @@ from app.schemas.exercises import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def stats_trainee_id(user: UserDTO, trainee_id: Optional[int]) -> Optional[int]:
|
||||
"""Whose trainings the statistics are about: a trainee's own unless another one is asked
|
||||
for (which, like everything else, is limited to the trainings the user may see)."""
|
||||
if trainee_id is None and user.role == UserRole.trainee:
|
||||
return user.id
|
||||
return trainee_id
|
||||
|
||||
|
||||
def record_response(record: Optional[Record]) -> Optional[RecordResponse]:
|
||||
return RecordResponse(value=record.value, date=record.date) if record else None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ExerciseNameDTO])
|
||||
async def list_exercise_names(
|
||||
trainee_id: Optional[int] = None,
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
"""Distinct exercise names with the number of times each was done."""
|
||||
return await repository.list_exercise_names(
|
||||
viewer=user, trainee_id=stats_trainee_id(user, trainee_id)
|
||||
)
|
||||
return await repository.list_exercise_names()
|
||||
|
||||
|
||||
@router.get("/progress/", response_model=ExerciseProgressResponse)
|
||||
async def exercise_progress(
|
||||
name: str,
|
||||
trainee_id: Optional[int] = None,
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
"""The whole history of the exercise with progress metrics.
|
||||
|
||||
The name is matched exactly, but case-insensitively: what was typed may differ in case.
|
||||
"""
|
||||
trainee_id = stats_trainee_id(user, trainee_id)
|
||||
names_by_casefold = {
|
||||
item.name.casefold(): item.name
|
||||
for item in await repository.list_exercise_names(
|
||||
viewer=user, trainee_id=trainee_id
|
||||
)
|
||||
for item in await repository.list_exercise_names()
|
||||
}
|
||||
exact_name = names_by_casefold.get(name.strip().casefold())
|
||||
if exact_name is None:
|
||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||
|
||||
progress = summarize(
|
||||
await repository.exercise_history(
|
||||
exact_name, viewer=user, trainee_id=trainee_id
|
||||
)
|
||||
)
|
||||
progress = summarize(await repository.exercise_history(exact_name))
|
||||
return ExerciseProgressResponse(
|
||||
name=exact_name,
|
||||
weighted=progress.weighted,
|
||||
|
|
@ -80,8 +58,6 @@ async def exercise_progress(
|
|||
primary_change_percent=progress.primary_change_percent,
|
||||
rows=[
|
||||
SessionRowResponse(
|
||||
training_id=row.session.training_id,
|
||||
trainee_id=row.session.trainee_id,
|
||||
date=row.session.date,
|
||||
trainer=row.session.trainer,
|
||||
splitted_weight=row.session.splitted_weight,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.api.v1 import auth, exercises, parser, health, trainings, users
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import exercises, parser, health, trainings
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(
|
||||
parser.router,
|
||||
prefix="/parser",
|
||||
tags=["parser"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
api_router.include_router(parser.router, prefix="/parser", tags=["parser"])
|
||||
api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"])
|
||||
api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"])
|
||||
api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
|
|
|
|||
|
|
@ -1,125 +1,34 @@
|
|||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.deps import (
|
||||
get_current_user,
|
||||
get_training_repository,
|
||||
get_user_repository,
|
||||
require_superuser,
|
||||
)
|
||||
from app.api.deps import get_training_repository
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.database.user_repository import UserRepository
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.dto.user import UserDTO, UserRole
|
||||
from app.core.parsers.obsidian import parse_training_data
|
||||
from app.schemas.trainings import TrainingUpdate
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _check_participant(
|
||||
repository: UserRepository, user_id: int, role: UserRole
|
||||
) -> None:
|
||||
user = await repository.get(user_id)
|
||||
if user is None or user.role != role:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"User {user_id} is not an existing {role.value}"
|
||||
)
|
||||
|
||||
|
||||
async def _get_editable_training(
|
||||
training_id: int, user: UserDTO, repository: TrainingRepository
|
||||
) -> TrainingDTO:
|
||||
"""A training the user may change: their own (as the trainee), or any for a superuser.
|
||||
|
||||
The trainer can only see the training. A training the user can't see at all is a 404.
|
||||
"""
|
||||
training = await repository.get(training_id, viewer=user)
|
||||
if training is None:
|
||||
raise HTTPException(status_code=404, detail="Training not found")
|
||||
if not (user.is_superuser or training.trainee_id == user.id):
|
||||
raise HTTPException(status_code=403, detail="Only the trainee can change a training")
|
||||
return training
|
||||
|
||||
|
||||
@router.get("/obsidian/", dependencies=[Depends(require_superuser)])
|
||||
@router.get("/obsidian/")
|
||||
async def obsidian_trainings_list():
|
||||
return {"data": parse_training_data()}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TrainingDTO])
|
||||
async def get_trainings_list(
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
"""Trainings the user takes part in (all of them for a superuser)."""
|
||||
return await repository.list(viewer=user)
|
||||
|
||||
|
||||
@router.get("/{training_id}/", response_model=TrainingDTO)
|
||||
async def get_training(
|
||||
training_id: int,
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
training = await repository.get(training_id, viewer=user)
|
||||
if training is None:
|
||||
raise HTTPException(status_code=404, detail="Training not found")
|
||||
return training
|
||||
|
||||
|
||||
@router.post("/", response_model=TrainingDTO, status_code=status.HTTP_201_CREATED)
|
||||
async def create_training(
|
||||
payload: TrainingUpdate,
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
"""Create a training. Only a superuser may create it for another trainee."""
|
||||
if payload.trainee_id != user.id:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only a superuser can create a training for another user"
|
||||
)
|
||||
await _check_participant(user_repository, payload.trainee_id, UserRole.trainee)
|
||||
if payload.trainer_id is not None:
|
||||
await _check_participant(user_repository, payload.trainer_id, UserRole.trainer)
|
||||
training_id = await repository.save(TrainingDTO.model_validate(payload.model_dump()))
|
||||
return await repository.get(training_id)
|
||||
|
||||
|
||||
@router.put("/{training_id}/", response_model=TrainingDTO)
|
||||
async def update_training(
|
||||
training_id: int,
|
||||
payload: TrainingUpdate,
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
"""Replace the training: its date, trainee, trainer, exercises and approaches."""
|
||||
current = await _get_editable_training(training_id, user, repository)
|
||||
if payload.trainee_id != current.trainee_id:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only a superuser can change the trainee"
|
||||
)
|
||||
await _check_participant(user_repository, payload.trainee_id, UserRole.trainee)
|
||||
if payload.trainer_id is not None:
|
||||
await _check_participant(user_repository, payload.trainer_id, UserRole.trainer)
|
||||
training = TrainingDTO.model_validate(payload.model_dump())
|
||||
if not await repository.replace(training_id, training):
|
||||
raise HTTPException(status_code=404, detail="Training not found")
|
||||
return await repository.get(training_id)
|
||||
return await repository.list()
|
||||
|
||||
|
||||
@router.delete("/{training_id}/")
|
||||
async def delete_training(
|
||||
training_id: int,
|
||||
user: UserDTO = Depends(get_current_user),
|
||||
repository: TrainingRepository = Depends(get_training_repository),
|
||||
):
|
||||
await _get_editable_training(training_id, user, repository)
|
||||
await repository.delete([training_id])
|
||||
deleted = await repository.delete([training_id])
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Training not found")
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
python -m app.cli import [--replace] [--trainee-email EMAIL]
|
||||
python -m app.cli set-password EMAIL [--superuser]
|
||||
python -m app.cli import [--replace]
|
||||
|
||||
Imports all trainings from the Obsidian and Apple notes into the database as trainings of one
|
||||
trainee. The trainer from a note header is matched with a user of the trainer role by
|
||||
"First Last" ("соло" means no trainer). The schema must already exist (`alembic upgrade head`).
|
||||
|
||||
`set-password` asks for a new password of the user (and with --superuser makes them a
|
||||
superuser). It is the way to get a working login: users are created only by superusers, and
|
||||
the users made by migrations have random passwords.
|
||||
Imports all trainings from the Obsidian and Apple notes into the database.
|
||||
The schema must already exist (`alembic upgrade head`).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import getpass
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from app.config import settings
|
||||
from app.core.database.connection import create_engine, create_session_factory
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.database.token_repository import RefreshTokenRepository
|
||||
from app.core.database.user_repository import UserRepository
|
||||
from app.core.dto.training import TrainingDTO
|
||||
from app.core.dto.user import UserRole
|
||||
from app.core.security import hash_password
|
||||
from app.core.parsers.apple import AppleNotesParser
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||
|
||||
|
||||
SOLO_NAMES = {"solo", "соло"}
|
||||
|
||||
|
||||
async def assign_participants(
|
||||
session, trainings: List[TrainingDTO], trainee_email: Optional[str]
|
||||
) -> None:
|
||||
"""Set trainee_id and trainer_id on the parsed trainings."""
|
||||
users = await UserRepository(session).list()
|
||||
trainees = [user for user in users if user.role == UserRole.trainee]
|
||||
if trainee_email:
|
||||
matching = [u for u in trainees if u.email == trainee_email.strip().lower()]
|
||||
if not matching:
|
||||
raise SystemExit(f"No trainee with email {trainee_email}")
|
||||
trainee = matching[0]
|
||||
elif len(trainees) == 1:
|
||||
trainee = trainees[0]
|
||||
else:
|
||||
raise SystemExit("There is more than one trainee, pass --trainee-email")
|
||||
|
||||
trainer_ids = {
|
||||
f"{user.first_name} {user.last_name}".strip().casefold(): user.id
|
||||
for user in users
|
||||
if user.role == UserRole.trainer
|
||||
}
|
||||
unknown: Set[str] = set()
|
||||
for training in trainings:
|
||||
training.trainee_id = trainee.id
|
||||
name = (training.trainer or "").strip().casefold()
|
||||
if name in SOLO_NAMES or not name:
|
||||
training.trainer_id = None
|
||||
elif name in trainer_ids:
|
||||
training.trainer_id = trainer_ids[name]
|
||||
else:
|
||||
unknown.add(training.trainer or "")
|
||||
if unknown:
|
||||
raise SystemExit(f"No trainer users for: {', '.join(sorted(unknown))}")
|
||||
|
||||
|
||||
async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
|
||||
async def import_notes(replace: bool) -> None:
|
||||
parsers = [ObsidianNotesParser(), AppleNotesParser()]
|
||||
trainings = []
|
||||
for parser in parsers:
|
||||
|
|
@ -83,7 +32,6 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
|
|||
try:
|
||||
async with create_session_factory(engine)() as session:
|
||||
repository = TrainingRepository(session)
|
||||
await assign_participants(session, trainings, trainee_email)
|
||||
existing = await repository.count()
|
||||
if existing and not replace:
|
||||
raise SystemExit(
|
||||
|
|
@ -98,26 +46,6 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None:
|
|||
print(f"Imported {len(trainings)} trainings")
|
||||
|
||||
|
||||
async def set_password(email: str, password: str, superuser: bool) -> None:
|
||||
engine = create_engine(settings.get_postgres_database_url())
|
||||
try:
|
||||
async with create_session_factory(engine)() as session:
|
||||
users = UserRepository(session)
|
||||
found = await users.get_credentials(email.strip().lower())
|
||||
if found is None:
|
||||
raise SystemExit(f"No user with email {email}")
|
||||
user, _ = found
|
||||
values: Dict[str, Any] = {"password_hash": hash_password(password)}
|
||||
if superuser:
|
||||
values["is_superuser"] = True
|
||||
await users.update(user.id, values)
|
||||
# Whoever was logged in with the old password is logged out
|
||||
await RefreshTokenRepository(session).revoke_all_of_user(user.id)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
print(f"Password of {email} is changed")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
arg_parser = argparse.ArgumentParser(prog="app.cli")
|
||||
|
|
@ -126,26 +54,10 @@ def main() -> None:
|
|||
import_parser.add_argument(
|
||||
"--replace", action="store_true", help="delete existing trainings first"
|
||||
)
|
||||
import_parser.add_argument(
|
||||
"--trainee-email",
|
||||
help="whose trainings these are (not needed when there is a single trainee)",
|
||||
)
|
||||
password_parser = subcommands.add_parser("set-password", help="set a user's password")
|
||||
password_parser.add_argument("email")
|
||||
password_parser.add_argument(
|
||||
"--superuser", action="store_true", help="also make the user a superuser"
|
||||
)
|
||||
args = arg_parser.parse_args()
|
||||
|
||||
if args.command == "import":
|
||||
asyncio.run(import_notes(args.replace, args.trainee_email))
|
||||
elif args.command == "set-password":
|
||||
password = getpass.getpass("New password: ")
|
||||
if len(password) < 8:
|
||||
raise SystemExit("The password must be at least 8 characters long")
|
||||
if password != getpass.getpass("Repeat: "):
|
||||
raise SystemExit("The passwords differ")
|
||||
asyncio.run(set_password(args.email, password, args.superuser))
|
||||
asyncio.run(import_notes(replace=args.replace))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -11,13 +11,6 @@ class Settings(BaseSettings):
|
|||
version: str = "0.0.1"
|
||||
debug: bool = False
|
||||
|
||||
# Auth: JWT_SECRET signs access tokens, it must be long and random (see .env.example)
|
||||
JWT_SECRET: str = ""
|
||||
ACCESS_TOKEN_TTL_MINUTES: int = 15
|
||||
REFRESH_TOKEN_TTL_DAYS: int = 30
|
||||
# The refresh cookie of the web client is sent only over HTTPS when this is on
|
||||
COOKIE_SECURE: bool = False
|
||||
|
||||
# Postgres database settings
|
||||
POSTGRES_DATABASE_URL: Optional[str] = None
|
||||
POSTGRES_DATABASE_HOST: str = "localhost"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from collections import defaultdict
|
||||
from typing import Dict, List, Optional, Sequence
|
||||
|
||||
from sqlalchemy import ColumnElement, delete, func, insert, or_, select, text, true, update
|
||||
from sqlalchemy import delete, func, insert, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database.tables import approaches, exercises, trainings, users
|
||||
from app.core.database.tables import approaches, exercises, trainings
|
||||
from app.core.dto.training import (
|
||||
ApproachDTO,
|
||||
ExerciseDTO,
|
||||
|
|
@ -12,37 +12,6 @@ from app.core.dto.training import (
|
|||
ExerciseSessionDTO,
|
||||
TrainingDTO,
|
||||
)
|
||||
from app.core.dto.user import UserDTO
|
||||
|
||||
_trainer = users.alias("trainer")
|
||||
# "First Last", the email if the name is empty
|
||||
_trainer_name = func.coalesce(
|
||||
func.nullif(func.trim(_trainer.c.first_name + " " + _trainer.c.last_name), ""),
|
||||
_trainer.c.email,
|
||||
).label("trainer")
|
||||
|
||||
|
||||
def _trainings_query():
|
||||
"""Trainings with the trainer's name (a solo training has no trainer)."""
|
||||
return select(trainings, _trainer_name).outerjoin(
|
||||
_trainer, _trainer.c.id == trainings.c.trainer_id
|
||||
)
|
||||
|
||||
|
||||
def _scope(viewer: Optional[UserDTO], trainee_id: Optional[int]) -> List[ColumnElement]:
|
||||
"""Conditions on `trainings`: what the viewer may see, narrowed to one trainee if asked.
|
||||
|
||||
A superuser sees everything, anyone else the trainings where they are the trainee or the
|
||||
trainer. No viewer means no restriction (imports, tests).
|
||||
"""
|
||||
conditions: List[ColumnElement] = []
|
||||
if viewer is not None and not viewer.is_superuser:
|
||||
conditions.append(
|
||||
or_(trainings.c.trainee_id == viewer.id, trainings.c.trainer_id == viewer.id)
|
||||
)
|
||||
if trainee_id is not None:
|
||||
conditions.append(trainings.c.trainee_id == trainee_id)
|
||||
return conditions
|
||||
|
||||
|
||||
class TrainingRepository:
|
||||
|
|
@ -58,108 +27,47 @@ class TrainingRepository:
|
|||
"""Save all trainings in a single transaction, returns their ids."""
|
||||
if not items:
|
||||
return []
|
||||
if any(item.trainee_id is None for item in items):
|
||||
raise ValueError("trainee_id is required to save a training")
|
||||
training_ids = await self._insert_returning_ids(
|
||||
trainings,
|
||||
[
|
||||
{
|
||||
"date": item.date,
|
||||
"trainee_id": item.trainee_id,
|
||||
"trainer_id": item.trainer_id,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
[{"date": item.date, "trainer": item.trainer} for item in items],
|
||||
)
|
||||
|
||||
await self._insert_exercises(training_ids, items)
|
||||
exercise_rows = []
|
||||
exercise_dtos = []
|
||||
for training_id, item in zip(training_ids, items):
|
||||
for exercise in item.exercises:
|
||||
exercise_rows.append(
|
||||
{
|
||||
"training_id": training_id,
|
||||
"name": exercise.name,
|
||||
"splitted_weight": exercise.splitted_weight,
|
||||
}
|
||||
)
|
||||
exercise_dtos.append(exercise)
|
||||
if exercise_rows:
|
||||
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
|
||||
approach_rows = [
|
||||
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
|
||||
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
|
||||
for a in exercise.approaches
|
||||
]
|
||||
if approach_rows:
|
||||
await self._session.execute(insert(approaches), approach_rows)
|
||||
|
||||
await self._session.commit()
|
||||
return training_ids
|
||||
|
||||
async def get(
|
||||
self, training_id: int, *, viewer: Optional[UserDTO] = None
|
||||
) -> Optional[TrainingDTO]:
|
||||
row = (
|
||||
await self._session.execute(
|
||||
_trainings_query().where(
|
||||
trainings.c.id == training_id, *_scope(viewer, None)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
exercise_rows = (
|
||||
await self._session.execute(
|
||||
select(exercises)
|
||||
.where(exercises.c.training_id == training_id)
|
||||
.order_by(exercises.c.id)
|
||||
)
|
||||
).all()
|
||||
approaches_by_exercise = await self._approaches_by_exercise(
|
||||
[exercise.id for exercise in exercise_rows]
|
||||
)
|
||||
return TrainingDTO(
|
||||
id=row.id,
|
||||
date=row.date,
|
||||
trainee_id=row.trainee_id,
|
||||
trainer_id=row.trainer_id,
|
||||
trainer=row.trainer,
|
||||
exercises=[
|
||||
ExerciseDTO(
|
||||
id=exercise.id,
|
||||
training_id=exercise.training_id,
|
||||
name=exercise.name,
|
||||
splitted_weight=exercise.splitted_weight,
|
||||
approaches=approaches_by_exercise[exercise.id],
|
||||
)
|
||||
for exercise in exercise_rows
|
||||
],
|
||||
)
|
||||
|
||||
async def replace(self, training_id: int, training: TrainingDTO) -> bool:
|
||||
"""Overwrite the training with its exercises and approaches, False if it doesn't exist.
|
||||
|
||||
Old exercises are deleted and the new ones inserted in the same transaction.
|
||||
"""
|
||||
result = await self._session.execute(
|
||||
update(trainings)
|
||||
.where(trainings.c.id == training_id)
|
||||
.values(
|
||||
date=training.date,
|
||||
trainee_id=training.trainee_id,
|
||||
trainer_id=training.trainer_id,
|
||||
)
|
||||
)
|
||||
if not result.rowcount: # type: ignore[attr-defined]
|
||||
await self._session.rollback()
|
||||
return False
|
||||
await self._session.execute(
|
||||
delete(exercises).where(exercises.c.training_id == training_id)
|
||||
)
|
||||
await self._insert_exercises([training_id], [training])
|
||||
await self._session.commit()
|
||||
return True
|
||||
|
||||
async def list(self, *, viewer: Optional[UserDTO] = None) -> List[TrainingDTO]:
|
||||
async def list(self) -> List[TrainingDTO]:
|
||||
training_rows = (
|
||||
await self._session.execute(
|
||||
_trainings_query()
|
||||
.where(*_scope(viewer, None))
|
||||
.order_by(trainings.c.date.desc(), trainings.c.id.desc())
|
||||
select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc())
|
||||
)
|
||||
).all()
|
||||
exercise_rows = (
|
||||
await self._session.execute(
|
||||
select(exercises)
|
||||
.where(exercises.c.training_id.in_([row.id for row in training_rows]))
|
||||
.order_by(exercises.c.id)
|
||||
)
|
||||
).all()
|
||||
approaches_by_exercise = await self._approaches_by_exercise(
|
||||
[row.id for row in exercise_rows]
|
||||
)
|
||||
approaches_by_exercise = await self._approaches_by_exercise()
|
||||
exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list)
|
||||
exercise_rows = await self._session.execute(
|
||||
select(exercises).order_by(exercises.c.id)
|
||||
)
|
||||
for row in exercise_rows:
|
||||
exercises_by_training[row.training_id].append(
|
||||
ExerciseDTO(
|
||||
|
|
@ -174,49 +82,33 @@ class TrainingRepository:
|
|||
TrainingDTO(
|
||||
id=row.id,
|
||||
date=row.date,
|
||||
trainee_id=row.trainee_id,
|
||||
trainer_id=row.trainer_id,
|
||||
trainer=row.trainer,
|
||||
exercises=exercises_by_training[row.id],
|
||||
)
|
||||
for row in training_rows
|
||||
]
|
||||
|
||||
async def list_exercise_names(
|
||||
self, *, viewer: Optional[UserDTO] = None, trainee_id: Optional[int] = None
|
||||
) -> List[ExerciseNameDTO]:
|
||||
async def list_exercise_names(self) -> List[ExerciseNameDTO]:
|
||||
"""Distinct exercise names with the number of times each was done."""
|
||||
query = (
|
||||
select(exercises.c.name, func.count(exercises.c.id).label("times"))
|
||||
.join(trainings, trainings.c.id == exercises.c.training_id)
|
||||
.where(*_scope(viewer, trainee_id))
|
||||
.group_by(exercises.c.name)
|
||||
)
|
||||
query = select(
|
||||
exercises.c.name, func.count(exercises.c.id).label("times")
|
||||
).group_by(exercises.c.name)
|
||||
result = await self._session.execute(query)
|
||||
names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result]
|
||||
# Sorted here, not in SQL: the order must not depend on DB collation
|
||||
return sorted(names, key=lambda item: item.name.casefold())
|
||||
|
||||
async def exercise_history(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
viewer: Optional[UserDTO] = None,
|
||||
trainee_id: Optional[int] = None,
|
||||
) -> List[ExerciseSessionDTO]:
|
||||
async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]:
|
||||
"""Every occurrence of the exercise, newest first."""
|
||||
query = (
|
||||
select(
|
||||
exercises.c.id,
|
||||
exercises.c.splitted_weight,
|
||||
trainings.c.id.label("training_id"),
|
||||
trainings.c.trainee_id,
|
||||
trainings.c.date,
|
||||
_trainer_name,
|
||||
trainings.c.trainer,
|
||||
)
|
||||
.join(trainings, trainings.c.id == exercises.c.training_id)
|
||||
.outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id)
|
||||
.where(exercises.c.name == name, *_scope(viewer, trainee_id))
|
||||
.where(exercises.c.name == name)
|
||||
.order_by(trainings.c.date.desc(), exercises.c.id.desc())
|
||||
)
|
||||
rows = (await self._session.execute(query)).all()
|
||||
|
|
@ -225,8 +117,6 @@ class TrainingRepository:
|
|||
)
|
||||
return [
|
||||
ExerciseSessionDTO(
|
||||
training_id=row.training_id,
|
||||
trainee_id=row.trainee_id,
|
||||
date=row.date,
|
||||
trainer=row.trainer,
|
||||
splitted_weight=row.splitted_weight,
|
||||
|
|
@ -252,33 +142,6 @@ class TrainingRepository:
|
|||
)
|
||||
await self._session.commit()
|
||||
|
||||
async def _insert_exercises(
|
||||
self, training_ids: Sequence[int], items: Sequence[TrainingDTO]
|
||||
) -> None:
|
||||
"""Insert exercises and approaches of `items` (training_ids[i] belongs to items[i])."""
|
||||
exercise_rows = []
|
||||
exercise_dtos = []
|
||||
for training_id, item in zip(training_ids, items):
|
||||
for exercise in item.exercises:
|
||||
exercise_rows.append(
|
||||
{
|
||||
"training_id": training_id,
|
||||
"name": exercise.name,
|
||||
"splitted_weight": exercise.splitted_weight,
|
||||
}
|
||||
)
|
||||
exercise_dtos.append(exercise)
|
||||
if not exercise_rows:
|
||||
return
|
||||
exercise_ids = await self._insert_returning_ids(exercises, exercise_rows)
|
||||
approach_rows = [
|
||||
{"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps}
|
||||
for exercise_id, exercise in zip(exercise_ids, exercise_dtos)
|
||||
for a in exercise.approaches
|
||||
]
|
||||
if approach_rows:
|
||||
await self._session.execute(insert(approaches), approach_rows)
|
||||
|
||||
async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]:
|
||||
"""Bulk insert, ids come back in the order of `rows`."""
|
||||
result = await self._session.execute(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from sqlalchemy import (
|
|||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
|
|
@ -13,8 +12,6 @@ from sqlalchemy import (
|
|||
func,
|
||||
)
|
||||
|
||||
from app.core.dto.user import UserRole
|
||||
|
||||
metadata = MetaData()
|
||||
|
||||
trainings = Table(
|
||||
|
|
@ -22,10 +19,7 @@ trainings = Table(
|
|||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("date", Date, nullable=False, index=True),
|
||||
# RESTRICT: a user who has trainings can't be deleted by accident
|
||||
Column("trainee_id", ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True),
|
||||
# No trainer means a solo training
|
||||
Column("trainer_id", ForeignKey("users.id", ondelete="RESTRICT"), index=True),
|
||||
Column("trainer", String(100)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
|
|
@ -58,44 +52,3 @@ approaches = Table(
|
|||
Column("reps", Integer, nullable=False),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
||||
users = Table(
|
||||
"users",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("email", String(254), nullable=False, unique=True, index=True),
|
||||
Column("password_hash", String(255), nullable=False),
|
||||
Column("first_name", String(100), nullable=False, server_default=""),
|
||||
Column("last_name", String(100), nullable=False, server_default=""),
|
||||
# VARCHAR + CHECK instead of a native PostgreSQL enum: adding a role is a plain migration
|
||||
Column(
|
||||
"role",
|
||||
Enum(
|
||||
UserRole,
|
||||
name="user_role",
|
||||
native_enum=False,
|
||||
length=20,
|
||||
create_constraint=True,
|
||||
values_callable=lambda roles: [role.value for role in roles],
|
||||
),
|
||||
nullable=False,
|
||||
server_default=UserRole.trainee.value,
|
||||
),
|
||||
Column("is_active", Boolean, nullable=False, server_default="true"),
|
||||
Column("is_superuser", Boolean, nullable=False, server_default="false"),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("last_login", DateTime(timezone=True)),
|
||||
)
|
||||
|
||||
refresh_tokens = Table(
|
||||
"refresh_tokens",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
# Tokens issued by rotation from one login share a family: a reused token revokes it all
|
||||
Column("family_id", String(32), nullable=False, index=True),
|
||||
Column("token_hash", String(64), nullable=False, unique=True),
|
||||
Column("expires_at", DateTime(timezone=True), nullable=False),
|
||||
Column("revoked_at", DateTime(timezone=True)),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
id: int | None = None
|
||||
date: date
|
||||
trainee_id: int | None = None # None only for trainings that are not saved yet
|
||||
trainer_id: int | None = None # None: a solo training
|
||||
trainer: str | None = None # the trainer's name, filled in when read from the DB
|
||||
trainer: str | None = None
|
||||
exercises: list[ExerciseDTO] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
|
@ -41,8 +39,6 @@ class ExerciseNameDTO(DTOBase):
|
|||
class ExerciseSessionDTO(DTOBase):
|
||||
"""One occurrence of an exercise in the history of that exercise."""
|
||||
|
||||
training_id: int | None = None
|
||||
trainee_id: int | None = None
|
||||
date: date
|
||||
trainer: str | None = None
|
||||
splitted_weight: bool = False
|
||||
|
|
|
|||
|
|
@ -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
|
||||
async def lifespan(app: FastAPI):
|
||||
if len(settings.JWT_SECRET) < 32:
|
||||
raise RuntimeError("JWT_SECRET must be set to a random string of 32+ characters")
|
||||
# Schema is managed by Alembic: run `alembic upgrade head` before starting the app
|
||||
engine = create_engine(settings.get_postgres_database_url())
|
||||
app.state.session_factory = create_session_factory(engine)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
training_id: Optional[int]
|
||||
trainee_id: Optional[int]
|
||||
date: date
|
||||
trainer: Optional[str]
|
||||
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 |
|
||||
| Брусья (гравитрон) | 47 | 12-9-9 |
|
||||
|
||||
**11.09.2023 (Пан Степан)**
|
||||
**11.09.2023 (с29)**
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------ | -------------- | ----------------- |
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
| Трицепс машина | 77 | 12-12-12-10 |
|
||||
| Молоты стоя | 9x2 | 12-12-12-12 |
|
||||
|
||||
**15.09.2023 (Пан Степан)**
|
||||
**15.09.2023 (с30)**
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ---------------------------------- | ----- | ----------- |
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
| Жим от груди лежа (гантели) | 14x2-16x2 | 12-12-12-12 |
|
||||
| Сведение рук пек дек| 37-27 | 10-9-12 |
|
||||
|
||||
**18.09.2023 (Пан Степан)**
|
||||
**18.09.2023 (с31)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
|Дельт-машина|37|15-15-15-15|
|
||||
|Гантели|18|10х2-10х2-10х2|
|
||||
|
||||
**21.09.2023 (Пан Степан)**
|
||||
**21.09.2023 (с32)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -47,7 +47,7 @@
|
|||
|Жим от плеч вверх (гантели)|14x2-16x2|12-12-11-10|
|
||||
|Отжимания||8-8-8|
|
||||
|
||||
**23.09.2023 (Пан Степан)**
|
||||
**23.09.2023 (с33)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
|Дельт-машина|37-37-47-47|12-15-12-12|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-10|
|
||||
|
||||
**23.09.2023 (Пан Степан)**
|
||||
**23.09.2023 (с34)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -68,7 +68,7 @@
|
|||
|Баттерфляй|47|12-12-12|
|
||||
|Брусья (гравитрон)|47|8-8-8|
|
||||
|
||||
**02.10.2023 (соло)**
|
||||
**02.10.2023 (solo)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
|Подъем штанги стоя |20|12-12-11|
|
||||
|Трицепс машина|67-77|15-12-12|
|
||||
|
||||
**04.10.2023 (Пан Степан)**
|
||||
**04.10.2023 (с36)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
|Отжимания|+5|4-5-4|
|
||||
|Подъем ног в висе||10-10-8|
|
||||
|
||||
**06.10.2023 (Пан Степан)**
|
||||
**06.10.2023 (с37)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -100,7 +100,7 @@
|
|||
|Махи рукой в кроссовере|6.5x2|12-15-14|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
||||
|
||||
**09.10.2023 (Пан Степан)**
|
||||
**09.10.2023 (с38)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -110,7 +110,7 @@
|
|||
|Жим от груди сидя в тренажере (кирпичики)|67-77|12-12-12-12|
|
||||
|Брусья (гравитрон)|47|10-8-10-10|
|
||||
|
||||
**11.10.2023 (Пан Степан)**
|
||||
**11.10.2023 (с39)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-10|
|
||||
|Отведение в рук в наклоне||12-12-12|
|
||||
|
||||
**16.10.2023 (Пан Степан)**
|
||||
**16.10.2023 (с40)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -131,7 +131,7 @@
|
|||
|Жим от плеч вверх (гантели)|14х14|12-12-12-12|
|
||||
|Брусья (гравитрон)|47|11-12-9|
|
||||
|
||||
**18.10.2023 (Пан Степан)**
|
||||
**18.10.2023 (с41)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12-10|
|
||||
|Подъем ног в висе||10-10-9|
|
||||
|
||||
**20.10.2023 (соло)**
|
||||
**20.10.2023 (solo)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -152,7 +152,7 @@
|
|||
|Сведение рук пек дек|27-27-37-37|15-15-15-15|
|
||||
|Подъем ног в висе||10-8-7|
|
||||
|
||||
**23.10.2023 (соло)**
|
||||
**23.10.2023 (solo)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -162,7 +162,7 @@
|
|||
|Отведение рук пек дек|17|15-12-12-12|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|27|15-12-12-9|
|
||||
|
||||
**25.10.2023 (Пан Степан)**
|
||||
**25.10.2023 (с42)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -172,7 +172,7 @@
|
|||
|Баттерфляй|47-57|15-12-9-10|
|
||||
|Жим от груди лежа (гантели)|16x2-16x2-14x2|11-10-10|
|
||||
|
||||
**27.10.2023 (Пан Степан)**
|
||||
**27.10.2023 (с43)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -182,7 +182,7 @@
|
|||
|Отведение рук пек дек|17|12-12-12|
|
||||
|Скручивания на мяче||12-12-12|
|
||||
|
||||
**27.10.2023 (Пан Степан)**
|
||||
**27.10.2023 (с44)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -193,7 +193,7 @@
|
|||
|Брусья (гравитрон)|47|12-12-11|
|
||||
|Разведение рук с гантелей из-за головы|6|10-10-10|
|
||||
|
||||
**01.11.2023 (Пан Степан)**
|
||||
**01.11.2023 (с45)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -203,7 +203,7 @@
|
|||
|Дельт-машина |37|15-15-15-15|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|27|10-10-10-8|
|
||||
|
||||
**06.11.2023 (Пан Степан)**
|
||||
**06.11.2023 (с46)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -215,7 +215,7 @@
|
|||
|Разгибание кик-бек в наклоне|5х2|12-12-12|
|
||||
|
||||
|
||||
**10.11.2023 (Пан Степан)**
|
||||
**10.11.2023 (с48)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -225,7 +225,7 @@
|
|||
|Махи гантелей в стороны стоя|7х2-8х2|15-15-15-15|
|
||||
|Разведение рук в наклоне|5х2|15-15-15|
|
||||
|
||||
**13.11.2023 (Пан Степан)**
|
||||
**13.11.2023 (с49)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -236,7 +236,7 @@
|
|||
|Брусья (гравитрон)|37|12-12-8|
|
||||
|Отжимания на трицепс в кроссовере|27|12-12-12|
|
||||
|
||||
**15.11.2023 (Пан Степан)**
|
||||
**15.11.2023 (с50)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -245,7 +245,7 @@
|
|||
|Баттерфляй|47|15-15-14|
|
||||
|Молоты стоя|9x2|12-12-12|
|
||||
|
||||
**17.11.2023 (Пан Степан)**
|
||||
**17.11.2023 (с51)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -255,7 +255,7 @@
|
|||
|Махи рукой в кроссовере|9x2-11x2|12-12-12-12|
|
||||
|Жим от плеч вверх (гантели)|14x2|9-8-10-10|
|
||||
|
||||
**20.11.2023 (Пан Степан)**
|
||||
**20.11.2023 (с52)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -265,7 +265,7 @@
|
|||
|Отжимания на брусьях|0|6-7-6|
|
||||
|Отжимания на трицепс в кроссовере|39|15-12-12-12|
|
||||
|
||||
**22.11.2023 (соло)**
|
||||
**22.11.2023 (solo)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -276,7 +276,7 @@
|
|||
|Молоты стоя|9x2-10x2|12-12-11-11|
|
||||
|Скручивания на мяче||15-12-12-12|
|
||||
|
||||
**24.11.2023 (Пан Степан)**
|
||||
**24.11.2023 (с53)**
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------- | ------- | ----------- |
|
||||
|
|
@ -287,7 +287,7 @@
|
|||
| Жим от плеч вверх (гантели)| 12x2 | 12-12-12-12 |
|
||||
| | | |
|
||||
|
||||
**27.11.2023 (Пан Степан)**
|
||||
**27.11.2023 (с54)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -296,7 +296,7 @@
|
|||
|Тяга горизонтального блока (1 рука)|31.7x2-36.2x2|12-12-12-12|
|
||||
|Брусья|0-0-черная-черная|6-6-7-7|
|
||||
|
||||
**29.11.2023 (Пан Степан)**
|
||||
**29.11.2023 (с55)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -306,7 +306,7 @@
|
|||
|Подъем штанги стоя|20|12-11-9-9|
|
||||
|Молоты стоя|20х2-20х2-18х2|10-9-12|
|
||||
|
||||
**01.12.2023 (Пан Степан)**
|
||||
**01.12.2023 (с56)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -316,7 +316,7 @@
|
|||
|Махи рукой в кроссовере |9x2-13x2|12-10-10|
|
||||
|Разведение рук пек дек|27|10-8-8|
|
||||
|
||||
**11.12.2023 (Пан Степан)**
|
||||
**11.12.2023 (с57)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -346,7 +346,7 @@
|
|||
|Жим от плеч вверх (гантели)|13x2|15-12-9-9|
|
||||
|Отведение рук пек дек|17-27-27-17|15-11-10-12|
|
||||
|
||||
**20.12.2023 (Пан Степан)**
|
||||
**20.12.2023 (с58)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -356,7 +356,7 @@
|
|||
|Сведение рук пек дек|20|12-12-10|
|
||||
|Тяга гантели в наклоне|18x2|12-11-10|
|
||||
|
||||
**23.12.2023 (Пан Степан)**
|
||||
**23.12.2023 (с59)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -365,7 +365,7 @@
|
|||
|Махи рукой в кроссовере|11x2|12-12-12|
|
||||
|Разведение рук пек дек|17|12-12-12-12|
|
||||
|
||||
**27.12.2023 (Пан Степан)**
|
||||
**27.12.2023 (с60)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -375,7 +375,7 @@
|
|||
|Сведение рук пек дек|37-47-47-37|12-10-10-10|
|
||||
|Гравитрон|36-57|10-10-7|
|
||||
|
||||
**27.12.2023 (Пан Степан)**
|
||||
**27.12.2023 (с61)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -396,7 +396,7 @@
|
|||
|Сведение рук пек дек|27-37|12-12-10-7|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-11-9|
|
||||
|
||||
**15.01.2024 (Пан Степан)**
|
||||
**15.01.2024 (с63)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -406,7 +406,7 @@
|
|||
|Подъем штанги стоя|20|13-12-12|
|
||||
|Жим от плеч вверх (гантели)|20х2|7-7-12|
|
||||
|
||||
**17.01.2024 (Пан Степан)**
|
||||
**17.01.2024 (с64)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -416,7 +416,7 @@
|
|||
|Отжимания на трицепс в кроссовере|36.2|13-12-12|
|
||||
|Трицепс машина|67-67-57|12-9-10|
|
||||
|
||||
**19.01.2024 (Пан Степан)**
|
||||
**19.01.2024 (с65)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -426,7 +426,7 @@
|
|||
|Разведение рук пек дек|27|12-12-10|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-10|
|
||||
|
||||
**22.01.2024 (Пан Степан)**
|
||||
**22.01.2024 (с66)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -437,7 +437,7 @@
|
|||
|Тяга горизонтального блока (1 рука)|18x2-22x2|12-12-12-12|
|
||||
|Баттерфляй|47-57|12-12-9|
|
||||
|
||||
**27.01.2024 (Пан Степан-7)**
|
||||
**27.01.2024 (с67-7)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -448,7 +448,7 @@
|
|||
|Махи рукой в кроссовере|9x2-11x2|12-12-12|
|
||||
|Разведение рук пек дек|27|10|
|
||||
|
||||
**29.01.2024 (Пан Степан-8)**
|
||||
**29.01.2024 (с68-8)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -458,7 +458,7 @@
|
|||
|Сведение рук пек дек|37-37-47-37|15-15-8-11|
|
||||
|Тяга вертикального блока обратным хватом|49.8|12-10-8|
|
||||
|
||||
**31.01.2024 (Пан Степан-9)**
|
||||
**31.01.2024 (с69-9)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -468,7 +468,7 @@
|
|||
|Дельт-машина|27|12-12-12-12|
|
||||
|Жим от плеч вверх (гантели)|14x2|12-12-11-10|
|
||||
|
||||
**02.02.2024 (Пан Степан-10)**
|
||||
**02.02.2024 (с70-10)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -477,7 +477,7 @@
|
|||
|Тяга горизонтального блока|49.8-54.5|12-10-12|
|
||||
|Тяга гантели в наклоне|20x2|12-12-12|
|
||||
|
||||
**19.02.2024 (Пан Степан-11)**
|
||||
**19.02.2024 (с71-11)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -488,7 +488,7 @@
|
|||
|Баттерфляй|57-47|10-10-9|
|
||||
|Брусья|47|10-10|
|
||||
|
||||
**21.02.2024 (Пан Степан-12)**
|
||||
**21.02.2024 (с72-12)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -498,7 +498,7 @@
|
|||
|Дельт-машина|47-47-37-37|12-11-12-12|
|
||||
|Отведение рук в стороны с гантелями в наклоне 90⁰|14ш|12-12-12|
|
||||
|
||||
**26.02.2024 (Пан Степан-13)**
|
||||
**26.02.2024 (с73-13)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
|Баттерфляй|47|12-11-10|
|
||||
|Отжимания||9-8|
|
||||
|
||||
**28.02.2024 (Пан Степан-14)**
|
||||
**28.02.2024 (с74-14)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -520,7 +520,7 @@
|
|||
|Дельт-машина|16|12-12-12|
|
||||
|Разведение рук в наклоне|6x2|12-12-12|
|
||||
|
||||
**01.03.2024 (Пан Степан-15)**
|
||||
**01.03.2024 (с75-15)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -530,7 +530,7 @@
|
|||
|Жим от груди сидя в тренажере (блинчики)|20x2|12-12-12|
|
||||
|Сведение рук пек дек|47-37|6-11-10|
|
||||
|
||||
**04.03.2024 (Пан Степан-16)**
|
||||
**04.03.2024 (с76-16)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -540,7 +540,7 @@
|
|||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
||||
|Разведение рук в наклоне|6x2|12-12-12|
|
||||
|
||||
**06.03.2024 (Пан Степан-17)**
|
||||
**06.03.2024 (с77-17)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -551,7 +551,7 @@
|
|||
|Баттерфляй|57-47|10-15-9-11|
|
||||
|Трицепс машина|67-77-67|12-10-12|
|
||||
|
||||
**13.03.2024 (Пан Степан-18)**
|
||||
**13.03.2024 (с78-18)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -562,7 +562,7 @@
|
|||
|Жим от груди лежа (штанга)|30|12-12-12|
|
||||
|Сведение рук пек дек|37|9-11|
|
||||
|
||||
**15.03.2024 (Пан Степан-19)**
|
||||
**15.03.2024 (с79-19)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -572,7 +572,7 @@
|
|||
|Махи гантелей в стороны стоя|8x2|12-12-12|
|
||||
|Жим от плеч вверх (гантели)|14x2|12-12-11|
|
||||
|
||||
**18.03.2024 (Пан Степан-20)**
|
||||
**18.03.2024 (с80-20)**
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------- | -------------- |
|
||||
|
|
@ -582,7 +582,7 @@
|
|||
| Жим от груди сидя в тренажере (кирпичики) | 40-50 | 12-12-12 |
|
||||
| Брусья (гравитрон)| 47 | 12-11-10 |
|
||||
|
||||
**28.03.2024 (Пан Степан-21)**
|
||||
**28.03.2024 (с81-21)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -593,7 +593,7 @@
|
|||
|Жим от груди сидя в тренажере (кирпичики)|67-67-57|12-10-10|
|
||||
|Брусья (гравитрон)|37-47|6-8|
|
||||
|
||||
**01.04.2024 (Пан Степан-22)**
|
||||
**01.04.2024 (с82-22)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -605,7 +605,7 @@
|
|||
|Жим от плеч вверх (гантели)|16x2|6-12-9|
|
||||
|Сведение рук пек дек|47-37|9-12-11|
|
||||
|
||||
**05.04.2024 (Пан Степан-23)**
|
||||
**05.04.2024 (с83-23)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -615,7 +615,7 @@
|
|||
|Разведение рук пек дек|27|12-12-11|
|
||||
|Подъем ног в висе||10-10-10|
|
||||
|
||||
**08.04.2024 (Пан Степан-24)**
|
||||
**08.04.2024 (с84-24)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -625,7 +625,7 @@
|
|||
|Жим от плеч вверх (штанга)|40-50-55|12-12-10-8|
|
||||
|Сведение рук пек дек|47-37|8-11-10-10|
|
||||
|
||||
**10.04.2024 (Пан Степан-25)**
|
||||
**10.04.2024 (с85-25)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -635,7 +635,7 @@
|
|||
|Махи гантелей в стороны стоя|8x2|12-12-12|
|
||||
|Отведение рук пек дек|27|10-10-9|
|
||||
|
||||
**12.04.2024 (Пан Степан-26)**
|
||||
**12.04.2024 (c86-26)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -646,7 +646,7 @@
|
|||
|Жим от груди сидя в тренажере (кирпичики)|77|9-10-9|
|
||||
|Сведение рук пек дек|37-47-37|12-8-11|
|
||||
|
||||
**14.04.2024 (соло-27)**
|
||||
**14.04.2024 (solo-27)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -658,7 +658,7 @@
|
|||
|Тяга гантели в наклоне|20x2|12-12-12|
|
||||
|Жим от груди сидя в тренажере (кирпичики)|67-77|15-12-7|
|
||||
|
||||
**15.04.2024 (Пан Степан-28)**
|
||||
**15.04.2024 (с87-28)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -668,7 +668,7 @@
|
|||
|Жим от плеч вверх (гантели)|16x2-11x2-14x2|12-10-10|
|
||||
|Разведение рук пек дек|27-27-27-17|12-11-10-12|
|
||||
|
||||
**19.04.2024 (Пан Степан-29)**
|
||||
**19.04.2024 (с88-29)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -678,7 +678,7 @@
|
|||
|Жим от груди сидя в тренажере (блинчики)|20x2-25x2|12-12-12|
|
||||
|Сведение рук пек дек|47-37|11-9-12-11|
|
||||
|
||||
**22.04.2024 (Пан Степан-30)**
|
||||
**22.04.2024 (с89-30)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -688,7 +688,7 @@
|
|||
|Дельт-машина|47|12-12-12|
|
||||
|Разведение рук в наклоне|8х2|12-12-12|
|
||||
|
||||
**26.04.2024 (Пан Степан-31)**
|
||||
**26.04.2024 (с90-31)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -699,7 +699,7 @@
|
|||
|Брусья|47|12-12-12|
|
||||
|Разгибание кик-бек в наклоне|8x2-9x2-8x2|12-10-12|
|
||||
|
||||
**01.05.2024 (Пан Степан-32)**
|
||||
**01.05.2024 (с91-32)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -709,7 +709,7 @@
|
|||
|Жим от плеч вверх (гантели)|16x2|12-10-10|
|
||||
|Разведение рук в наклоне|8x2|12-12-11|
|
||||
|
||||
**03.05.2024 (Пан Степан-33)**
|
||||
**03.05.2024 (с92-33)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -719,7 +719,7 @@
|
|||
|Сведение рук пек дек|37|12-12-11|
|
||||
|Трицепс машина|67-87-77|12-11-12|
|
||||
|
||||
**06.05.2024 (Пан Степан-34)**
|
||||
**06.05.2024 (с93-34)**
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| ------------------------------------------- | -------- | -------------- |
|
||||
|
|
@ -729,7 +729,7 @@
|
|||
| Разведение рук пек дек| 27 | 12-11-12 |
|
||||
| Жим от плеч вверх в тренажере (кирпичики)| 37 | 12-11-7 |
|
||||
|
||||
**21.08.2024 (Пан Степан-69)**
|
||||
**21.08.2024 (с123-69)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -739,7 +739,7 @@
|
|||
|Жим от груди сидя в тренажере (блинчики)|20x2-30x2-30x2|12-12-10|
|
||||
|Баттерфляй|57|12-12-12|
|
||||
|
||||
**19.08.2024 (Пан Степан-68)**
|
||||
**19.08.2024 (с122-68)**
|
||||
|
||||
| **Упражнение** | **Вес** | **Подходы** |
|
||||
| ------------------------------------ | -------------- | ------------- |
|
||||
|
|
@ -750,7 +750,7 @@
|
|||
| | | |
|
||||
| | | |
|
||||
|
||||
**14.08.2024 (Пан Степан-67)**
|
||||
**14.08.2024 (с121-67)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -761,7 +761,7 @@
|
|||
|Баттерфляй|57|12-10-11|
|
||||
|Брусья|37|9-10|
|
||||
|
||||
**12.08.2024 (Пан Степан-66)**
|
||||
**12.08.2024 (с120-66)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -771,7 +771,7 @@
|
|||
|Жим от плеч вверх (гантели)|18х2|10-9-7|
|
||||
|Разведение рук пек дек|37|12-10-10|
|
||||
|
||||
**08.08.2024 (Пан Степан-65)**
|
||||
**08.08.2024 (с119-65)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -782,7 +782,7 @@
|
|||
|Баттерфляй|57|12-12-12|
|
||||
|Брусья (гравитрон)|37|8-10-9|
|
||||
|
||||
**07.08.2024 (Пан Степан-64)**
|
||||
**07.08.2024 (с118-64)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -791,7 +791,7 @@
|
|||
|Разведение рук пек дек|37-37-47-47|12-12-12-12|
|
||||
|Жим от плеч вверх (гантели)|16х2-18х2|11-8-8-8|
|
||||
|
||||
**05.08.2024 (Пан Степан-63)**
|
||||
**05.08.2024 (с117-63)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -801,7 +801,7 @@
|
|||
|Жим от груди лежа (гантели)|16х2-22х2|12-12-12-8|
|
||||
|Сведение рук пек дек|37|12-12-12|
|
||||
|
||||
**02.08.2024 (Пан Степан-62)**
|
||||
**02.08.2024 (с116-62)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -811,7 +811,7 @@
|
|||
|Дельт-машина|20|12-12-12|
|
||||
|Разведение рук пек дек|37|10-10|
|
||||
|
||||
**31.07.2024 (Пан Степан-61)**
|
||||
**31.07.2024 (с115-61)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -821,7 +821,7 @@
|
|||
|Сведение рук пек дек|37-47|12-12-12-9|
|
||||
|Отжимания|10|9-8-8|
|
||||
|
||||
**29.07.2024 (Пан Степан-60)**
|
||||
**29.07.2024 (с114-60)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -830,7 +830,7 @@
|
|||
|Жим от плеч вверх в тренажере (кирпичики)|20-30-30-25|12-12-10-12|
|
||||
|Махи гантелей в стороны стоя|10х2|12-12-12-12|
|
||||
|
||||
**26.07.2024 (Пан Степан-59)**
|
||||
**26.07.2024 (с113-59)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -840,7 +840,7 @@
|
|||
|Отжимания|+10|12-10-8|
|
||||
|Баттерфляй|57-57-47|10-12-12-12|
|
||||
|
||||
**25.07.2024 (Пан Степан-58)**
|
||||
**25.07.2024 (с112-58)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -850,7 +850,7 @@
|
|||
|Разведение рук пек дек|27|12-12-12|
|
||||
|Махи гантелей в стороны стоя|8х8-9х9|12-12-12-12|
|
||||
|
||||
**22.07.2024 (Пан Степан-57)**
|
||||
**22.07.2024 (с111-57)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -861,7 +861,7 @@
|
|||
|Сведение рук пек дек|37|12-12-11|
|
||||
|Трицепс машина|77|12-12-12|
|
||||
|
||||
**17.07.2024 (Пан Степан-56)**
|
||||
**17.07.2024 (с110-56)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -871,7 +871,7 @@
|
|||
|Разведение рук пек дек|27|12-12-10|
|
||||
|Жим от плеч вверх (гантели)|16х2|12-12|
|
||||
|
||||
**15.07.2024 (Пан Степан-55)**
|
||||
**15.07.2024 (с109-55)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -881,7 +881,7 @@
|
|||
|Жим от груди сидя в тренажере (блинчики)|33.5х2-43.5х2|12-12-10-10|
|
||||
|Брусья|47|9-9-9|
|
||||
|
||||
**10.07.2024 (Пан Степан-54)**
|
||||
**10.07.2024 (с108-54)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -891,7 +891,7 @@
|
|||
|Дельт-машина |37|12-12-12|
|
||||
|Разведение рук пек дек|27|12-11-11|
|
||||
|
||||
**08.07.2024 (Пан Степан-53)**
|
||||
**08.07.2024 (с107-53)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -901,7 +901,7 @@
|
|||
|Сведение рук пек дек|37|12-12-12|
|
||||
|Брусья (гравитрон)|47|12-12-12-12|
|
||||
|
||||
**28.06.2024 (Пан Степан-52)**
|
||||
**28.06.2024 (с106-52)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -911,7 +911,7 @@
|
|||
|Жим от плеч вверх (гантели)|14х2|12-12-12|
|
||||
|Разведение рук пек дек|27|12-12-12|
|
||||
|
||||
**26.06.2024 (Пан Степан-51)**
|
||||
**26.06.2024 (с105-51)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -922,7 +922,7 @@
|
|||
|Сведение рук пек дек|47-37|10-14-13|
|
||||
|Брусья|37|12-9-8|
|
||||
|
||||
**21.06.2024 (соло-50)**
|
||||
**21.06.2024 (solo-50)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -932,7 +932,7 @@
|
|||
|Разведение рук пек дек|17-27|12-11-11-8|
|
||||
|Жим от плеч вверх в тренажере (кирпичики)|37|12-11-9-9|
|
||||
|
||||
**19.06.2024 (Пан Степан-49)**
|
||||
**19.06.2024 (с104-49)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -943,7 +943,7 @@
|
|||
|Сведение рук пек дек|47|9-9-8|
|
||||
|Брусья|37|9-9-9|
|
||||
|
||||
**17.06.2024 (Пан Степан-48)**
|
||||
**17.06.2024 (с103-48)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -953,7 +953,7 @@
|
|||
|Жим от плеч вверх в тренажере (кирпичики)|27|12-12-12|
|
||||
|Разведение рук пек дек|17|12-12-12|
|
||||
|
||||
**12.06.2024 (Пан Степан-47)**
|
||||
**12.06.2024 (с102-47)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -963,7 +963,7 @@
|
|||
|Жим от груди сидя в тренажере (блинчики)|20x2-22x2|12-12-12-12|
|
||||
|Жим от груди лежа (гантели)|16x2|10-12|
|
||||
|
||||
**10.06.2024 (Пан Степан-46)**
|
||||
**10.06.2024 (с101-46)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -983,7 +983,7 @@
|
|||
|Тяга штанги в наклоне|40-45|12-12-12-10|
|
||||
|Дельт-машина |37-47|15-12-12-12|
|
||||
|
||||
**05.06.2024 (Пан Степан-44)**
|
||||
**05.06.2024 (с100-44)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -1013,7 +1013,7 @@
|
|||
|Жим от груди сидя в тренажере (кирпичики)|87-87-77|11-9-12-10|
|
||||
|Трицепс машина|67-77|12-12-12-12|
|
||||
|
||||
**20.05.2024 (Пан Степан-40)**
|
||||
**20.05.2024 (c98-40)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -1024,7 +1024,7 @@
|
|||
|Баттерфляй|57-47|10-12-12|
|
||||
|
||||
|
||||
**17.05.2024 (Пан Степан-39)**
|
||||
**17.05.2024 (c97-39)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -1036,7 +1036,7 @@
|
|||
|Трицепс машина|67-77|12-12-12|
|
||||
|
||||
|
||||
**15.05.2024 (Пан Степан-38)**
|
||||
**15.05.2024 (c96-38)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -1046,7 +1046,7 @@
|
|||
|Жим от груди лежа (гантели)|16x2-18x2|12-12-9|
|
||||
|Брусья (гравитрон)|37|12-11-10|
|
||||
|
||||
**13.05.2024 (Пан Степан-37)**
|
||||
**13.05.2024 (c95-37)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -1055,7 +1055,7 @@
|
|||
|Жим от плеч вверх (гантели)|16x2-16x2-16x2|12-11-10|
|
||||
|Махи гантелей в стороны стоя|7x2|12-15-15|
|
||||
|
||||
**10.05.2024 (Пан Степан-36)**
|
||||
**10.05.2024 (c94-36)**
|
||||
|
||||
|Упражнение|Вес|Подходы|
|
||||
|---|---|---|
|
||||
|
|
@ -1065,7 +1065,7 @@
|
|||
|Жим от груди сидя в тренажере (блинчики)|15х2-25х2|14-12-12-12|
|
||||
|Сведение рук пек дек|37|12-12-12|
|
||||
|
||||
**08.05.2024 (соло-35)**
|
||||
**08.05.2024 (solo-35)**
|
||||
|
||||
| Упражнение | Вес | Подходы |
|
||||
| -------------------------------------- | ------------------------------------- | ----------- |
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
types-grpcio==1.0.0.20250703
|
||||
types-protobuf==6.30.2.20250822
|
||||
httpx==0.28.1
|
||||
|
|
|
|||
|
|
@ -6,4 +6,3 @@ python-dotenv==1.1.1
|
|||
sqlalchemy[asyncio]==2.1.6
|
||||
asyncpg==0.32.0
|
||||
alembic==1.20.1
|
||||
PyJWT==2.10.1
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|||
|
||||
from app.config import settings
|
||||
from app.core.database.connection import create_engine, create_session_factory
|
||||
from app.core.database.user_repository import UserRepository
|
||||
from app.core.dto.user import UserRole
|
||||
from app.core.database.repository import TrainingRepository
|
||||
|
||||
TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
|
||||
|
||||
|
|
@ -63,43 +62,6 @@ async def session(alembic_config):
|
|||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
engine = create_engine(TEST_DATABASE_URL)
|
||||
async with create_session_factory(engine)() as session:
|
||||
# the migration creates the users of the real data, tests start from nothing
|
||||
await session.execute(text("TRUNCATE users, trainings RESTART IDENTITY CASCADE"))
|
||||
await session.commit()
|
||||
await TrainingRepository(session).delete_all()
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def trainee(session):
|
||||
return await UserRepository(session).create(
|
||||
email="athlete@example.com", password_hash="h", role=UserRole.trainee
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def trainer(session):
|
||||
return await UserRepository(session).create(
|
||||
email="coach@example.com",
|
||||
password_hash="h",
|
||||
first_name="Пан",
|
||||
last_name="Степан",
|
||||
role=UserRole.trainer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(session, monkeypatch):
|
||||
"""HTTP client of the app on the test database (needs the `session` fixture's clean DB)."""
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
monkeypatch.setattr(settings, "JWT_SECRET", "test-secret-" + "x" * 40)
|
||||
engine = create_engine(TEST_DATABASE_URL)
|
||||
app.state.session_factory = create_session_factory(engine)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as http_client:
|
||||
yield http_client
|
||||
await engine.dispose()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
|
||||
from app.core.database.connection import create_engine
|
||||
from app.core.database.tables import approaches, exercises
|
||||
from app.core.database.repository import TrainingRepository
|
||||
from app.core.database.user_repository import UserInUseError, UserRepository
|
||||
from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO
|
||||
from app.core.parsers.apple import AppleNotesParser
|
||||
from app.core.parsers.obsidian import ObsidianNotesParser
|
||||
from tests.conftest import TEST_DATABASE_URL
|
||||
|
||||
|
||||
def make_training(
|
||||
trainee_id: int, day: int = 1, trainer_id: Optional[int] = None
|
||||
) -> TrainingDTO:
|
||||
def make_training(day: int = 1, trainer: str = "solo") -> TrainingDTO:
|
||||
return TrainingDTO(
|
||||
date=date(2025, 1, day),
|
||||
trainee_id=trainee_id,
|
||||
trainer_id=trainer_id,
|
||||
trainer=trainer,
|
||||
exercises=[
|
||||
ExerciseDTO(
|
||||
name="Жим ногами",
|
||||
|
|
@ -47,17 +40,16 @@ def make_training(
|
|||
)
|
||||
|
||||
|
||||
async def test_save_and_list_roundtrip(session, trainee):
|
||||
async def test_save_and_list_roundtrip(session):
|
||||
repository = TrainingRepository(session)
|
||||
|
||||
training_id = await repository.save(make_training(trainee.id))
|
||||
training_id = await repository.save(make_training())
|
||||
trainings = await repository.list()
|
||||
|
||||
assert len(trainings) == 1
|
||||
saved = trainings[0]
|
||||
assert saved.id == training_id
|
||||
assert (saved.date, saved.trainee_id) == (date(2025, 1, 1), trainee.id)
|
||||
assert (saved.trainer_id, saved.trainer) == (None, None) # solo
|
||||
assert (saved.date, saved.trainer) == (date(2025, 1, 1), "solo")
|
||||
assert [e.name for e in saved.exercises] == ["Жим ногами", "Жим гантелей лежа"]
|
||||
assert saved.exercises[1].splitted_weight is True
|
||||
assert [(a.weight, a.reps) for a in saved.exercises[0].approaches] == [
|
||||
|
|
@ -66,16 +58,16 @@ async def test_save_and_list_roundtrip(session, trainee):
|
|||
]
|
||||
|
||||
|
||||
async def test_list_is_ordered_by_date_desc(session, trainee):
|
||||
async def test_list_is_ordered_by_date_desc(session):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save_many([make_training(trainee.id, day=1), make_training(trainee.id, day=3)])
|
||||
await repository.save_many([make_training(day=1), make_training(day=3)])
|
||||
|
||||
assert [t.date.day for t in await repository.list()] == [3, 1]
|
||||
|
||||
|
||||
async def test_exercise_names_are_distinct_counted_and_sorted(session, trainee):
|
||||
async def test_exercise_names_are_distinct_counted_and_sorted(session):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save_many([make_training(trainee.id, day=1), make_training(trainee.id, day=3)])
|
||||
await repository.save_many([make_training(day=1), make_training(day=3)])
|
||||
|
||||
names = await repository.list_exercise_names()
|
||||
|
||||
|
|
@ -85,42 +77,38 @@ async def test_exercise_names_are_distinct_counted_and_sorted(session, trainee):
|
|||
]
|
||||
|
||||
|
||||
async def test_exercise_history_is_newest_first(session, trainee, trainer):
|
||||
async def test_exercise_history_is_newest_first(session):
|
||||
repository = TrainingRepository(session)
|
||||
await repository.save_many(
|
||||
[
|
||||
make_training(trainee.id, day=1),
|
||||
make_training(trainee.id, day=3, trainer_id=trainer.id),
|
||||
]
|
||||
[make_training(day=1), make_training(day=3, trainer="c153")]
|
||||
)
|
||||
|
||||
history = await repository.exercise_history("Жим ногами")
|
||||
|
||||
assert [item.date.day for item in history] == [3, 1]
|
||||
assert [item.trainer for item in history] == ["Пан Степан", None]
|
||||
assert [item.trainer for item in history] == ["c153", "solo"]
|
||||
assert [(a.weight, a.reps) for a in history[0].approaches] == [(100, 12), (110, 10)]
|
||||
assert history[0].splitted_weight is False
|
||||
|
||||
|
||||
async def test_exercise_history_keeps_splitted_weight_and_ignores_other_exercises(
|
||||
session, trainee
|
||||
session,
|
||||
):
|
||||
repository = TrainingRepository(session)
|
||||
training_id = await repository.save(make_training(trainee.id))
|
||||
await repository.save(make_training())
|
||||
|
||||
history = await repository.exercise_history("Жим гантелей лежа")
|
||||
|
||||
assert len(history) == 1
|
||||
assert history[0].training_id == training_id
|
||||
assert history[0].splitted_weight is True
|
||||
assert [a.weight for a in history[0].approaches] == [20]
|
||||
assert await repository.exercise_history("Несуществующее") == []
|
||||
|
||||
|
||||
async def test_delete_cascades_to_exercises_and_approaches(session, trainee):
|
||||
async def test_delete_cascades_to_exercises_and_approaches(session):
|
||||
repository = TrainingRepository(session)
|
||||
keep_id, delete_id = await repository.save_many(
|
||||
[make_training(trainee.id, day=1), make_training(trainee.id, day=2)]
|
||||
[make_training(day=1), make_training(day=2)]
|
||||
)
|
||||
|
||||
assert await repository.delete([delete_id]) == 1
|
||||
|
|
@ -130,67 +118,19 @@ async def test_delete_cascades_to_exercises_and_approaches(session, trainee):
|
|||
assert await session.scalar(select(func.count(approaches.c.id))) == 3
|
||||
|
||||
|
||||
async def test_get_returns_training_or_none(session, trainee):
|
||||
async def test_save_many_is_atomic(session):
|
||||
repository = TrainingRepository(session)
|
||||
training_id = await repository.save(make_training(trainee.id))
|
||||
|
||||
training = await repository.get(training_id)
|
||||
|
||||
assert training is not None
|
||||
assert [e.name for e in training.exercises] == ["Жим ногами", "Жим гантелей лежа"]
|
||||
assert await repository.get(training_id + 1) is None
|
||||
|
||||
|
||||
async def test_replace_overwrites_training_exercises_and_approaches(session, trainee, trainer):
|
||||
repository = TrainingRepository(session)
|
||||
training_id = await repository.save(make_training(trainee.id, day=1))
|
||||
other_id = await repository.save(make_training(trainee.id, day=2))
|
||||
|
||||
updated = TrainingDTO(
|
||||
date=date(2025, 2, 3),
|
||||
trainee_id=trainee.id,
|
||||
trainer_id=trainer.id,
|
||||
exercises=[
|
||||
ExerciseDTO(
|
||||
name="Тяга",
|
||||
splitted_weight=True,
|
||||
approaches=[ApproachDTO(weight=30, reps=8)],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert await repository.replace(training_id, updated) is True
|
||||
|
||||
training = await repository.get(training_id)
|
||||
assert (training.date, training.trainer) == (date(2025, 2, 3), "Пан Степан")
|
||||
assert [e.name for e in training.exercises] == ["Тяга"]
|
||||
assert training.exercises[0].splitted_weight is True
|
||||
assert [(a.weight, a.reps) for a in training.exercises[0].approaches] == [(30, 8)]
|
||||
# the other training is untouched; no orphans left from the replaced one
|
||||
assert len((await repository.get(other_id)).exercises) == 2
|
||||
assert await session.scalar(select(func.count(exercises.c.id))) == 3
|
||||
assert await session.scalar(select(func.count(approaches.c.id))) == 4
|
||||
|
||||
|
||||
async def test_replace_missing_training_returns_false(session, trainee):
|
||||
repository = TrainingRepository(session)
|
||||
assert await repository.replace(999, make_training(trainee.id)) is False
|
||||
|
||||
|
||||
async def test_save_many_is_atomic(session, trainee):
|
||||
repository = TrainingRepository(session)
|
||||
broken = make_training(trainee.id, day=2, trainer_id=999) # no such user
|
||||
broken = make_training(day=2, trainer="x" * 101) # trainer is VARCHAR(100)
|
||||
|
||||
with pytest.raises(DBAPIError):
|
||||
await repository.save_many([make_training(trainee.id, day=1), broken])
|
||||
await repository.save_many([make_training(day=1), broken])
|
||||
await session.rollback()
|
||||
|
||||
assert await repository.count() == 0
|
||||
|
||||
|
||||
async def test_all_notes_can_be_saved(session, trainee):
|
||||
async def test_all_notes_can_be_saved(session):
|
||||
trainings = ObsidianNotesParser().parse_file() + AppleNotesParser().parse_file()
|
||||
for training in trainings:
|
||||
training.trainee_id = trainee.id
|
||||
repository = TrainingRepository(session)
|
||||
|
||||
ids = await repository.save_many(trainings)
|
||||
|
|
@ -208,52 +148,3 @@ async def test_migrations_match_models(alembic_config):
|
|||
async def test_downgrade_and_upgrade(alembic_config):
|
||||
await asyncio.to_thread(command.downgrade, alembic_config, "base")
|
||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
|
||||
|
||||
async def test_user_with_trainings_cannot_be_deleted(session, trainee, trainer):
|
||||
await TrainingRepository(session).save(
|
||||
make_training(trainee.id, trainer_id=trainer.id)
|
||||
)
|
||||
users = UserRepository(session)
|
||||
|
||||
for user in (trainee, trainer):
|
||||
with pytest.raises(UserInUseError):
|
||||
await users.delete(user.id)
|
||||
assert len(await users.list()) == 2
|
||||
|
||||
|
||||
async def test_migration_links_existing_trainings_to_users(alembic_config):
|
||||
"""Trainings written before the users existed: everything is the trainee's, only the
|
||||
non-solo ones get the trainer."""
|
||||
previous = "97535a10551d"
|
||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
await asyncio.to_thread(command.downgrade, alembic_config, previous)
|
||||
engine = create_engine(TEST_DATABASE_URL)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(text("TRUNCATE trainings, users RESTART IDENTITY CASCADE"))
|
||||
await connection.execute(
|
||||
text(
|
||||
"INSERT INTO trainings (date, trainer) VALUES "
|
||||
"('2025-01-01', 'solo'), ('2025-01-02', 'соло'), ('2025-01-03', 'с153'), "
|
||||
"('2025-01-04', NULL)"
|
||||
)
|
||||
)
|
||||
await asyncio.to_thread(command.upgrade, alembic_config, "head")
|
||||
async with engine.connect() as connection:
|
||||
rows = (
|
||||
await connection.execute(
|
||||
text(
|
||||
"SELECT t.date, trainee.email, trainer.email FROM trainings t "
|
||||
"JOIN users trainee ON trainee.id = t.trainee_id "
|
||||
"LEFT JOIN users trainer ON trainer.id = t.trainer_id ORDER BY t.date"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
await engine.dispose()
|
||||
|
||||
assert [(row[1], row[2]) for row in rows] == [
|
||||
("pro100ton@gmail.com", None),
|
||||
("pro100ton@gmail.com", None),
|
||||
("pro100ton@gmail.com", "panstepan@spirit.ru"),
|
||||
("pro100ton@gmail.com", None),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: dev
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
|
|
@ -32,8 +31,6 @@ services:
|
|||
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
||||
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
|
||||
- ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro
|
||||
- ./frontend/users.html:/usr/share/nginx/html/users.html:ro
|
||||
- ./frontend/login.html:/usr/share/nginx/html/login.html:ro
|
||||
- ./frontend/css:/usr/share/nginx/html/css:ro
|
||||
- ./frontend/js:/usr/share/nginx/html/js:ro
|
||||
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html exercises.html parse.html users.html login.html /usr/share/nginx/html/
|
||||
COPY index.html exercises.html parse.html /usr/share/nginx/html/
|
||||
COPY css /usr/share/nginx/html/css
|
||||
COPY js /usr/share/nginx/html/js
|
||||
COPY vendor /usr/share/nginx/html/vendor
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@
|
|||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
@ -38,7 +36,6 @@
|
|||
</main>
|
||||
</div>
|
||||
<script src="vendor/chart.umd.js"></script>
|
||||
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||
<script src="vendor/dataTables.min.js"></script>
|
||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="module" src="js/exercises.js"></script>
|
||||
|
|
|
|||
|
|
@ -18,9 +18,7 @@
|
|||
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
@ -29,7 +27,6 @@
|
|||
</main>
|
||||
</div>
|
||||
|
||||
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||
<script src="vendor/dataTables.min.js"></script>
|
||||
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||
<script type="module" src="js/trainings.js"></script>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import { accessToken, redirectToLogin, refresh } from './auth.js';
|
||||
|
||||
const API = '/api/v1';
|
||||
|
||||
/** What a training without a trainer is called */
|
||||
export const SOLO = 'соло';
|
||||
|
||||
async function send(path, options, token) {
|
||||
const headers = { ...options.headers, Authorization: `Bearer ${token}` };
|
||||
return fetch(API + path, { ...options, headers });
|
||||
}
|
||||
|
||||
/** JSON request to the API with the access token. Without a session the browser is sent to
|
||||
* the login page; an expired token is refreshed and the request repeated once. */
|
||||
export async function api(path, options = {}) {
|
||||
const token = await accessToken();
|
||||
if (!token) {
|
||||
return redirectToLogin();
|
||||
}
|
||||
let response = await send(path, options, token);
|
||||
if (response.status === 401) {
|
||||
if (!(await refresh())) {
|
||||
return redirectToLogin();
|
||||
}
|
||||
response = await send(path, options, await accessToken());
|
||||
}
|
||||
export async function api(path, options) {
|
||||
const response = await fetch(API + path, options);
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP ${response.status}`);
|
||||
error.status = response.status;
|
||||
|
|
@ -56,17 +34,6 @@ export function formatDate(iso) {
|
|||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
/** "First Last" of a user, the email when the name is empty. Trainers in a list come with
|
||||
* a ready `name`. */
|
||||
export function userName(user) {
|
||||
return user.name || `${user.first_name} ${user.last_name}`.trim() || user.email;
|
||||
}
|
||||
|
||||
/** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */
|
||||
export function formatDateTime(iso) {
|
||||
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }).replace(',', '');
|
||||
}
|
||||
|
||||
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
||||
export function num(value) {
|
||||
return String(Math.round(value * 10) / 10);
|
||||
|
|
|
|||
|
|
@ -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, SOLO, tip } from './common.js';
|
||||
import { editButton } from './edit-dialog.js';
|
||||
import { canEditTraining } from './page.js';
|
||||
import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js';
|
||||
import { createTable } from './table.js';
|
||||
|
||||
const HINTS = {
|
||||
|
|
@ -108,7 +105,7 @@ function progressView(progress) {
|
|||
|
||||
const columns = [
|
||||
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) },
|
||||
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || SOLO, cell: (row) => row.trainer || SOLO },
|
||||
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || '', cell: (row) => row.trainer || '' },
|
||||
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) },
|
||||
...(weighted ? [
|
||||
{ title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' },
|
||||
|
|
@ -118,7 +115,6 @@ function progressView(progress) {
|
|||
{ title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` },
|
||||
{ title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` },
|
||||
]),
|
||||
{ title: '', cell: (row) => row.training_id && canEditTraining(row.trainee_id) ? editButton(row.training_id) : null },
|
||||
];
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -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 './page.js';
|
||||
import { trainingsView } from './trainings-view.js';
|
||||
|
||||
const form = document.getElementById('parse-form');
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
// One table for all trainings: a row per exercise
|
||||
|
||||
import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js';
|
||||
import { editButton } from './edit-dialog.js';
|
||||
import { canEditTraining } from './page.js';
|
||||
import { alert, approachChips, formatDate, h, perArmBadge } from './common.js';
|
||||
import { createTable } from './table.js';
|
||||
|
||||
export function trainingsView(trainings) {
|
||||
|
|
@ -16,8 +14,6 @@ export function trainingsView(trainings) {
|
|||
? training.exercises.map((exercise) => ({ training, exercise }))
|
||||
: [{ training, exercise: null }]);
|
||||
|
||||
// Trainings from the parser preview are not saved yet: nothing to edit
|
||||
const editable = trainings.every((training) => training.id);
|
||||
const exerciseCount = rows.filter((row) => row.exercise).length;
|
||||
|
||||
return h('div', {},
|
||||
|
|
@ -29,7 +25,7 @@ export function trainingsView(trainings) {
|
|||
rows,
|
||||
columns: [
|
||||
{ title: 'Дата', className: 'text-nowrap date', sort: (row) => row.training.date, cell: (row) => formatDate(row.training.date) },
|
||||
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.training.trainer || SOLO, cell: (row) => row.training.trainer || SOLO },
|
||||
{ title: 'Тренер', className: 'text-nowrap', sort: (row) => row.training.trainer || '', cell: (row) => row.training.trainer || '' },
|
||||
{
|
||||
title: 'Упражнение',
|
||||
sort: (row) => row.exercise?.name || '',
|
||||
|
|
@ -38,10 +34,6 @@ export function trainingsView(trainings) {
|
|||
: h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'),
|
||||
},
|
||||
{ title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) },
|
||||
...(editable ? [{
|
||||
title: '',
|
||||
cell: (row) => canEditTraining(row.training.trainee_id) ? editButton(row.training.id) : null,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { alert, api } from './common.js';
|
||||
import './page.js';
|
||||
import { trainingsView } from './trainings-view.js';
|
||||
|
||||
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 active" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
|
|||
|
|
@ -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