[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
|
||||
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
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()
|
||||
|
||||
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 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
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.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"]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,5 +17,7 @@ class Settings(BaseSettings):
|
|||
|
||||
PASSWORD_SCHEMES: list[str] = ["bcrypt"]
|
||||
|
||||
TURNSTILE_SECRET_KEY: str
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue