From c5350120e6fda132f2530f0845421888f7e31d29 Mon Sep 17 00:00:00 2001 From: t0xa Date: Fri, 23 Oct 2026 20:30:41 +0300 Subject: [PATCH] Back: Add user entity --- CLAUDE.md | 6 +- backend/app/api/deps.py | 7 ++ backend/app/api/v1/router.py | 3 +- backend/app/api/v1/users.py | 80 ++++++++++++++++++ backend/app/core/database/tables.py | 31 +++++++ backend/app/core/database/user_repository.py | 81 +++++++++++++++++++ backend/app/core/dto/user.py | 25 ++++++ backend/app/core/security.py | 39 +++++++++ backend/app/schemas/users.py | 41 ++++++++++ .../2026_10_23_1727-97535a10551d_add_users.py | 46 +++++++++++ backend/tests/conftest.py | 2 + backend/tests/test_core/test_security.py | 16 ++++ backend/tests/test_core/test_users.py | 71 ++++++++++++++++ 13 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 backend/app/api/v1/users.py create mode 100644 backend/app/core/database/user_repository.py create mode 100644 backend/app/core/dto/user.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/schemas/users.py create mode 100644 backend/migrations/versions/2026_10_23_1727-97535a10551d_add_users.py create mode 100644 backend/tests/test_core/test_security.py create mode 100644 backend/tests/test_core/test_users.py diff --git a/CLAUDE.md b/CLAUDE.md index d04d8af..0fc1a0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,11 +71,11 @@ docker compose exec fastapi-app mypy app/ - `Approach` - single set with weight and reps **API Structure** (backend, JSON only): -- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/` +- `/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/` **Frontend** (`frontend/`): -- `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) +- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/edit-dialog.js` + `js/edit-form.js` (training edit Bootstrap modal) +- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code - `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS) ### Database diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index c0256a0..7eb393c 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -3,9 +3,16 @@ 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.user_repository import UserRepository 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) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 01c7990..7b4afdd 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.v1 import exercises, parser, health, trainings +from app.api.v1 import exercises, parser, health, trainings, users api_router = APIRouter() @@ -7,3 +7,4 @@ api_router.include_router(health.router, prefix="/health", tags=["health"]) 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"]) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..28ed61d --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,80 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.api.deps import get_user_repository +from app.core.database.user_repository import EmailTakenError, UserRepository +from app.core.dto.user import 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) +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]) +async def list_users( + role: Optional[UserRole] = None, + repository: UserRepository = Depends(get_user_repository), +): + return await repository.list(role) + + +@router.get("/{user_id}/", response_model=UserDTO) +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) +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}/") +async def delete_user( + user_id: int, + repository: UserRepository = Depends(get_user_repository), +): + if not await repository.delete(user_id): + raise HTTPException(status_code=404, detail="User not found") + return {"status": "ok"} diff --git a/backend/app/core/database/tables.py b/backend/app/core/database/tables.py index 6b66284..bcebf33 100644 --- a/backend/app/core/database/tables.py +++ b/backend/app/core/database/tables.py @@ -3,6 +3,7 @@ from sqlalchemy import ( Column, Date, DateTime, + Enum, Float, ForeignKey, Integer, @@ -12,6 +13,8 @@ from sqlalchemy import ( func, ) +from app.core.dto.user import UserRole + metadata = MetaData() trainings = Table( @@ -52,3 +55,31 @@ 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)), +) diff --git a/backend/app/core/database/user_repository.py b/backend/app/core/database/user_repository.py new file mode 100644 index 0000000..9706540 --- /dev/null +++ b/backend/app/core/database/user_repository.py @@ -0,0 +1,81 @@ +from typing import Any, Dict, List, Optional + +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 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 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: + result = await self._session.execute(delete(users).where(users.c.id == user_id)) + await self._session.commit() + return bool(result.rowcount) # type: ignore[attr-defined] diff --git a/backend/app/core/dto/user.py b/backend/app/core/dto/user.py new file mode 100644 index 0000000..953a94b --- /dev/null +++ b/backend/app/core/dto/user.py @@ -0,0 +1,25 @@ +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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d1e2203 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,39 @@ +"""Password hashing with scrypt from the standard library (no extra dependencies). + +Stored format: scrypt$$$

$$. The parameters are stored with +the hash, so they can be raised later without invalidating existing passwords. +""" + +import base64 +import hashlib +import hmac +import secrets + +_N, _R, _P = 2**14, 8, 1 +_KEY_LENGTH = 32 + + +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) diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py new file mode 100644 index 0000000..f309be6 --- /dev/null +++ b/backend/app/schemas/users.py @@ -0,0 +1,41 @@ +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) diff --git a/backend/migrations/versions/2026_10_23_1727-97535a10551d_add_users.py b/backend/migrations/versions/2026_10_23_1727-97535a10551d_add_users.py new file mode 100644 index 0000000..f772555 --- /dev/null +++ b/backend/migrations/versions/2026_10_23_1727-97535a10551d_add_users.py @@ -0,0 +1,46 @@ +"""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 ### diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b1151af..daa7bbc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -63,5 +63,7 @@ async def session(alembic_config): engine = create_engine(TEST_DATABASE_URL) async with create_session_factory(engine)() as session: await TrainingRepository(session).delete_all() + await session.execute(text("TRUNCATE users RESTART IDENTITY")) + await session.commit() yield session await engine.dispose() diff --git a/backend/tests/test_core/test_security.py b/backend/tests/test_core/test_security.py new file mode 100644 index 0000000..5f8b2bc --- /dev/null +++ b/backend/tests/test_core/test_security.py @@ -0,0 +1,16 @@ +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") diff --git a/backend/tests/test_core/test_users.py b/backend/tests/test_core/test_users.py new file mode 100644 index 0000000..2781fd9 --- /dev/null +++ b/backend/tests/test_core/test_users.py @@ -0,0 +1,71 @@ +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