[user] views [docs] openapi
This commit is contained in:
parent
65d0a19e41
commit
9731472fcf
15 changed files with 256 additions and 8 deletions
10
Dockerfile
10
Dockerfile
|
|
@ -1,5 +1,5 @@
|
||||||
# builder
|
# builder
|
||||||
FROM python:3.11.5-bullseye as builder
|
FROM python:3.14.3-alpine as builder
|
||||||
|
|
||||||
RUN mkdir /opt/fooder
|
RUN mkdir /opt/fooder
|
||||||
WORKDIR /opt/fooder
|
WORKDIR /opt/fooder
|
||||||
|
|
@ -10,17 +10,17 @@ COPY fooder /opt/fooder/fooder
|
||||||
COPY setup.py /opt/fooder/setup.py
|
COPY setup.py /opt/fooder/setup.py
|
||||||
|
|
||||||
RUN python /opt/fooder/setup.py sdist
|
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
|
# 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
|
COPY requirements/docker.txt requirements.txt
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
RUN useradd fooder
|
RUN adduser -D fooder
|
||||||
|
|
||||||
RUN mkdir /opt/fooder && chown fooder:fooder /opt/fooder
|
RUN mkdir /opt/fooder && chown fooder:fooder /opt/fooder
|
||||||
WORKDIR /opt/fooder
|
WORKDIR /opt/fooder
|
||||||
|
|
|
||||||
1
doc/openapi.json
Normal file
1
doc/openapi.json
Normal file
File diff suppressed because one or more lines are too long
24
fooder/alembic/versions/a1b2c3d4e5f6_.py
Normal file
24
fooder/alembic/versions/a1b2c3d4e5f6_.py
Normal 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
19
fooder/captcha.py
Normal 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()
|
||||||
21
fooder/command/create_user.py
Normal file
21
fooder/command/create_user.py
Normal 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
|
||||||
|
|
@ -21,3 +21,9 @@ class UserController(ModelController[User]):
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
return cls(ctx, obj)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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
|
from fooder.domain.base import Base, CommonMixin, PasswordMixin, SoftDeleteMixin
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||||
class User(Base, CommonMixin, PasswordMixin, SoftDeleteMixin):
|
class User(Base, CommonMixin, PasswordMixin, SoftDeleteMixin):
|
||||||
"""User."""
|
"""User."""
|
||||||
|
|
||||||
username: Mapped[str]
|
username: Mapped[str] = mapped_column(unique=True)
|
||||||
settings: Mapped[UserSettings] = relationship(
|
settings: Mapped[UserSettings] = relationship(
|
||||||
back_populates="user", lazy="selectin", uselist=False
|
back_populates="user", lazy="selectin", uselist=False
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,8 @@ class InvalidValue(ApiException):
|
||||||
class Conflict(ApiException):
|
class Conflict(ApiException):
|
||||||
HTTP_CODE = 409
|
HTTP_CODE = 409
|
||||||
MESSAGE = "Conflict"
|
MESSAGE = "Conflict"
|
||||||
|
|
||||||
|
|
||||||
|
class CaptchaFailed(ApiException):
|
||||||
|
HTTP_CODE = 403
|
||||||
|
MESSAGE = "Captcha verification failed"
|
||||||
|
|
|
||||||
16
fooder/model/user.py
Normal file
16
fooder/model/user.py
Normal 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
|
||||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter
|
||||||
|
|
||||||
from fooder.view.token import router as token_router
|
from fooder.view.token import router as token_router
|
||||||
from fooder.view.product import router as product_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.user_settings import router as user_settings_router
|
||||||
from fooder.view.diary import router as diary_router
|
from fooder.view.diary import router as diary_router
|
||||||
from fooder.view.meal import router as meal_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 = APIRouter(prefix="/api")
|
||||||
router.include_router(token_router, prefix="/token", tags=["token"])
|
router.include_router(token_router, prefix="/token", tags=["token"])
|
||||||
router.include_router(product_router, prefix="/product", tags=["product"])
|
router.include_router(product_router, prefix="/product", tags=["product"])
|
||||||
|
router.include_router(user_router, prefix="/user", tags=["user"])
|
||||||
router.include_router(
|
router.include_router(
|
||||||
user_settings_router, prefix="/user/settings", tags=["user_settings"]
|
user_settings_router, prefix="/user/settings", tags=["user_settings"]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,5 +17,7 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
PASSWORD_SCHEMES: list[str] = ["bcrypt"]
|
PASSWORD_SCHEMES: list[str] = ["bcrypt"]
|
||||||
|
|
||||||
|
TURNSTILE_SECRET_KEY: str
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ os.environ.update(
|
||||||
"SECRET_KEY": "test-secret",
|
"SECRET_KEY": "test-secret",
|
||||||
"REFRESH_SECRET_KEY": "test-refresh",
|
"REFRESH_SECRET_KEY": "test-refresh",
|
||||||
"API_KEY": "test-key",
|
"API_KEY": "test-key",
|
||||||
|
"TURNSTILE_SECRET_KEY": "test-turnstile",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
112
fooder/test/view/test_user.py
Normal file
112
fooder/test/view/test_user.py
Normal 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
38
fooder/view/user.py
Normal 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
|
||||||
|
)
|
||||||
|
|
@ -5,10 +5,11 @@ sqlalchemy[postgresql_asyncpg]
|
||||||
alembic
|
alembic
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
asyncpg
|
asyncpg
|
||||||
psycopg2-binary==2.9.3
|
psycopg2-binary
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
bcrypt<5.0.0
|
bcrypt<5.0.0
|
||||||
passlib[bcrypt]
|
passlib[bcrypt]
|
||||||
fastapi-users
|
fastapi-users
|
||||||
requests
|
requests
|
||||||
black # for alembic
|
black # for alembic
|
||||||
|
httpx
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue