[user] views [docs] openapi

This commit is contained in:
Piotr Domański 2026-04-07 22:05:03 +02:00
parent 65d0a19e41
commit 9731472fcf
15 changed files with 256 additions and 8 deletions

View file

@ -1,5 +1,5 @@
# builder
FROM python:3.11.5-bullseye as builder
FROM python:3.14.3-alpine as builder
RUN mkdir /opt/fooder
WORKDIR /opt/fooder
@ -10,17 +10,17 @@ COPY fooder /opt/fooder/fooder
COPY setup.py /opt/fooder/setup.py
RUN python /opt/fooder/setup.py sdist
RUN mv /opt/fooder/dist/FooderApi*.tar.gz /opt/fooder/dist/fooder.tar.gz
RUN mv /opt/fooder/dist/*.tar.gz /opt/fooder/dist/fooder.tar.gz
# final image
FROM python:3.11.5-bullseye
FROM python:3.14.3-alpine
RUN apt-get -y install libpq-dev
RUN apk add --no-cache postgresql-dev
COPY requirements/docker.txt requirements.txt
RUN pip install -r requirements.txt
RUN useradd fooder
RUN adduser -D fooder
RUN mkdir /opt/fooder && chown fooder:fooder /opt/fooder
WORKDIR /opt/fooder

1
doc/openapi.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,24 @@
"""add unique constraint to user.username
Revision ID: a1b2c3d4e5f6
Revises: 4e8d78ff6e9e
Create Date: 2026-04-07 15:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, Sequence[str], None] = "4e8d78ff6e9e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_unique_constraint("uq_user_username", "user", ["username"])
def downgrade() -> None:
op.drop_constraint("uq_user_username", "user", type_="unique")

19
fooder/captcha.py Normal file
View file

@ -0,0 +1,19 @@
import httpx
from fooder.exc import CaptchaFailed
from fooder.settings import settings
async def verify_turnstile(token: str, ip: str | None = None) -> None:
data = {"secret": settings.TURNSTILE_SECRET_KEY, "response": token}
if ip:
data["remoteip"] = ip
async with httpx.AsyncClient() as client:
r = await client.post(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
data=data,
)
if not r.json().get("success"):
raise CaptchaFailed()

View file

@ -0,0 +1,21 @@
from fooder.context import Context
from fooder.domain.user import User
from fooder.domain.user_settings import UserSettings
async def create_user(ctx: Context, username: str, password: str) -> User:
user = User(username=username)
user.set_password(password)
await ctx.repo.user.create(user)
user_settings = UserSettings(
user_id=user.id,
protein_goal=0.0,
carb_goal=0.0,
fat_goal=0.0,
fiber_goal=0.0,
calories_goal=0.0,
)
await ctx.repo.user_settings.create(user_settings)
return user

View file

@ -21,3 +21,9 @@ class UserController(ModelController[User]):
raise Unauthorized()
return cls(ctx, obj)
async def change_password(self, current_password: str, new_password: str) -> None:
if not self.obj.verify_password(current_password):
raise Unauthorized()
self.obj.set_password(new_password)
await self.ctx.repo.user.update(self.obj)

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fooder.domain.base import Base, CommonMixin, PasswordMixin, SoftDeleteMixin
@ -13,7 +13,7 @@ if TYPE_CHECKING:
class User(Base, CommonMixin, PasswordMixin, SoftDeleteMixin):
"""User."""
username: Mapped[str]
username: Mapped[str] = mapped_column(unique=True)
settings: Mapped[UserSettings] = relationship(
back_populates="user", lazy="selectin", uselist=False
)

View file

@ -28,3 +28,8 @@ class InvalidValue(ApiException):
class Conflict(ApiException):
HTTP_CODE = 409
MESSAGE = "Conflict"
class CaptchaFailed(ApiException):
HTTP_CODE = 403
MESSAGE = "Captcha verification failed"

16
fooder/model/user.py Normal file
View file

@ -0,0 +1,16 @@
from typing import Annotated
from pydantic import BaseModel, Field
Password = Annotated[str, Field(min_length=8, max_length=128)]
class UserCreateModel(BaseModel):
username: Annotated[str, Field(min_length=1, max_length=64)]
password: Password
captcha_token: str
class UserChangePasswordModel(BaseModel):
current_password: str
new_password: Password

View file

@ -2,6 +2,7 @@ from fastapi import APIRouter
from fooder.view.token import router as token_router
from fooder.view.product import router as product_router
from fooder.view.user import router as user_router
from fooder.view.user_settings import router as user_settings_router
from fooder.view.diary import router as diary_router
from fooder.view.meal import router as meal_router
@ -11,6 +12,7 @@ from fooder.view.preset import router as preset_router
router = APIRouter(prefix="/api")
router.include_router(token_router, prefix="/token", tags=["token"])
router.include_router(product_router, prefix="/product", tags=["product"])
router.include_router(user_router, prefix="/user", tags=["user"])
router.include_router(
user_settings_router, prefix="/user/settings", tags=["user_settings"]
)

View file

@ -17,5 +17,7 @@ class Settings(BaseSettings):
PASSWORD_SCHEMES: list[str] = ["bcrypt"]
TURNSTILE_SECRET_KEY: str
settings = Settings()

View file

@ -11,6 +11,7 @@ os.environ.update(
"SECRET_KEY": "test-secret",
"REFRESH_SECRET_KEY": "test-refresh",
"API_KEY": "test-key",
"TURNSTILE_SECRET_KEY": "test-turnstile",
}
)

View file

@ -0,0 +1,112 @@
import pytest
import fooder.view.user as user_view
from fooder.exc import CaptchaFailed
@pytest.fixture(autouse=True)
def bypass_captcha(monkeypatch):
async def _noop(token: str, ip=None) -> None:
pass
monkeypatch.setattr(user_view, "verify_turnstile", _noop)
@pytest.fixture
def new_user_payload():
return {
"username": "newuser",
"password": "securepassword1",
"captcha_token": "test-token",
}
async def test_create_user_returns_201(client, new_user_payload):
response = await client.post("/api/user", json=new_user_payload)
assert response.status_code == 201
async def test_create_user_returns_tokens(client, new_user_payload):
response = await client.post("/api/user", json=new_user_payload)
body = response.json()
assert "access_token" in body
assert "refresh_token" in body
assert body["token_type"] == "bearer"
async def test_create_user_can_login(client, new_user_payload):
await client.post("/api/user", json=new_user_payload)
response = await client.post(
"/api/token",
data={
"username": new_user_payload["username"],
"password": new_user_payload["password"],
},
)
assert response.status_code == 200
async def test_create_user_duplicate_username_returns_409(client, new_user_payload):
await client.post("/api/user", json=new_user_payload)
response = await client.post("/api/user", json=new_user_payload)
assert response.status_code == 409
async def test_create_user_password_too_short_returns_422(client, new_user_payload):
new_user_payload["password"] = "short"
response = await client.post("/api/user", json=new_user_payload)
assert response.status_code == 422
async def test_create_user_captcha_failure_returns_403(client, monkeypatch, new_user_payload):
async def _fail(token: str, ip=None) -> None:
raise CaptchaFailed()
monkeypatch.setattr(user_view, "verify_turnstile", _fail)
response = await client.post("/api/user", json=new_user_payload)
assert response.status_code == 403
async def test_change_password_returns_204(auth_client, user_password):
response = await auth_client.patch(
"/api/user/password",
json={"current_password": user_password, "new_password": "newpassword1"},
)
assert response.status_code == 204
async def test_change_password_can_login_with_new_password(auth_client, user, user_password):
new_password = "newpassword1"
await auth_client.patch(
"/api/user/password",
json={"current_password": user_password, "new_password": new_password},
)
response = await auth_client.post(
"/api/token",
data={"username": user.username, "password": new_password},
)
assert response.status_code == 200
async def test_change_password_wrong_current_returns_401(auth_client):
response = await auth_client.patch(
"/api/user/password",
json={"current_password": "wrongpassword", "new_password": "newpassword1"},
)
assert response.status_code == 401
async def test_change_password_new_too_short_returns_422(auth_client, user_password):
response = await auth_client.patch(
"/api/user/password",
json={"current_password": user_password, "new_password": "short"},
)
assert response.status_code == 422
async def test_change_password_unauthenticated_returns_401(client):
response = await client.patch(
"/api/user/password",
json={"current_password": "any", "new_password": "newpassword1"},
)
assert response.status_code == 401

38
fooder/view/user.py Normal file
View file

@ -0,0 +1,38 @@
from fastapi import APIRouter, Depends, Request
from fooder.captcha import verify_turnstile
from fooder.command.create_user import create_user
from fooder.context import Context, ContextDependency, AuthContextDependency
from fooder.controller.user import UserController
from fooder.model.token import TokenResponse
from fooder.model.user import UserCreateModel, UserChangePasswordModel
from fooder.view.token import gen_token_response
router = APIRouter(tags=["user"])
_ctx = ContextDependency()
_auth_ctx = AuthContextDependency()
@router.post("", response_model=TokenResponse, status_code=201)
async def user_create(
data: UserCreateModel,
request: Request,
ctx: Context = Depends(_ctx),
) -> TokenResponse:
ip = request.client.host if request.client else None
await verify_turnstile(data.captcha_token, ip)
async with ctx.repo.transaction():
user = await create_user(ctx, data.username, data.password)
return gen_token_response(user.id, ctx.clock())
@router.patch("/password", status_code=204)
async def user_change_password(
data: UserChangePasswordModel,
ctx: Context = Depends(_auth_ctx),
) -> None:
async with ctx.repo.transaction():
await UserController(ctx, ctx.user).change_password(
data.current_password, data.new_password
)

View file

@ -5,10 +5,11 @@ sqlalchemy[postgresql_asyncpg]
alembic
uvicorn[standard]
asyncpg
psycopg2-binary==2.9.3
psycopg2-binary
python-jose[cryptography]
bcrypt<5.0.0
passlib[bcrypt]
fastapi-users
requests
black # for alembic
httpx