Back: Add user entity

This commit is contained in:
t0xa 2026-10-23 20:30:41 +03:00
parent dd70c7b89c
commit c5350120e6
13 changed files with 444 additions and 4 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"])

View file

@ -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"}

View file

@ -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)),
)

View file

@ -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]

View file

@ -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

View file

@ -0,0 +1,39 @@
"""Password hashing with scrypt from the standard library (no extra dependencies).
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.
"""
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)

View file

@ -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)

View file

@ -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 ###

View file

@ -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()

View file

@ -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")

View file

@ -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