initial push

This commit is contained in:
Piotr Domański 2023-04-01 16:19:12 +02:00
commit d7f27a763d
41 changed files with 1241 additions and 0 deletions

129
.gitignore vendored Normal file
View file

@ -0,0 +1,129 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

11
Dockerfile Normal file
View file

@ -0,0 +1,11 @@
from python
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
RUN mkdir /opt/fooder
WORKDIR /opt/fooder
COPY fooder /opt/fooder/fooder
CMD ["uvicorn", "fooder.app:app", "--host", "0.0.0.0", "--port", "8000"]

32
docker-compose.yml Normal file
View file

@ -0,0 +1,32 @@
version: '3'
networks:
fooder:
driver: bridge
services:
database:
restart: unless-stopped
image: postgres
networks:
- fooder
env_file:
- .env
platform: linux/amd64
api:
restart: unless-stopped
image: api
build:
dockerfile: Dockerfile
context: .
networks:
- fooder
env_file:
- .env
platform: linux/amd64
volumes:
- ./fooder:/opt/fooder/fooder
ports:
- "8000:8000"
command: "uvicorn fooder.app:app --host 0.0.0.0 --port 8000 --reload"

11
env.template Normal file
View file

@ -0,0 +1,11 @@
POSTGRES_MAX_CONNECTIONS=200
POSTGRES_USER="fooder"
POSTGRES_DATABASE="fooder"
POSTGRES_PASSWORD=123
DB_URI="postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DATABASE}"
ECHO_SQL=0
SECRET_KEY="" # generate with $ openssl rand -hex 32
ALGORITHM="HS256"
ACCESS_TOKEN_EXPIRE_MINUTES=30

0
fooder/__init__.py Normal file
View file

33
fooder/__main__.py Normal file
View file

@ -0,0 +1,33 @@
from argparse import ArgumentParser
if __name__ == "__main__":
parser = ArgumentParser()
group = parser.add_mutually_exclusive_group()
group.add_argument("--create-tables", action="store_true")
group.add_argument("--create-user", action="store_true")
parser.add_argument("--username", type=str, action="store")
parser.add_argument("--password", type=str, action="store")
args = parser.parse_args()
import sqlalchemy
from sqlalchemy.orm import Session
from .domain import Base
from .settings import Settings
settings = Settings()
engine = sqlalchemy.create_engine(settings.DB_URI.replace("+asyncpg", ""))
if args.create_tables:
Base.metadata.create_all(engine)
if args.create_user:
with Session(engine) as session:
from .domain.user import User
user = User(
username=args.username,
)
user.set_password(args.password)
session.add(user)
session.commit()

6
fooder/app.py Normal file
View file

@ -0,0 +1,6 @@
from fastapi import FastAPI
from .router import router
app = FastAPI(title="Fooder")
app.include_router(router)

70
fooder/auth.py Normal file
View file

@ -0,0 +1,70 @@
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import async_sessionmaker
from jose import JWTError, jwt
from fastapi.security import OAuth2PasswordBearer
from fastapi import Depends, FastAPI, HTTPException
from fastapi_users.password import PasswordHelper
from sqlalchemy.ext.asyncio import async_sessionmaker
from typing import AsyncGenerator, Dict, Annotated, Optional
from datetime import datetime, timedelta
from .settings import Settings
from .domain.user import User
from .db import get_session
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token")
settings = Settings()
password_helper = PasswordHelper(pwd_context)
AsyncSessionDependency = Annotated[async_sessionmaker, Depends(get_session)]
TokenDependency = Annotated[str, Depends(oauth2_scheme)]
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
async def authenticate_user(
session: AsyncSession, username: str, password: str
) -> AsyncGenerator[User, None]:
user = await User.get_by_username(session, username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_access_token(user: User):
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {
"sub": user.username,
"exp": expire,
}
encoded_jwt = jwt.encode(
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
)
return encoded_jwt
async def get_current_user(
session: AsyncSessionDependency, token: TokenDependency
) -> User:
async with session() as session:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
username: str = payload.get("sub")
if username is None:
raise HTTPException(status_code=401, detail="Unathorized")
except JWTError:
raise HTTPException(status_code=401, detail="Unathorized")
return await User.get_by_username(session, username)

View file

27
fooder/controller/base.py Normal file
View file

@ -0,0 +1,27 @@
from typing import Annotated, Any
from fastapi import Depends
from sqlalchemy.ext.asyncio import async_sessionmaker
from ..db import get_session
from ..auth import get_current_user, oauth2_scheme
from ..domain.user import User
AsyncSession = Annotated[async_sessionmaker, Depends(get_session)]
UserDependency = Annotated[User, Depends(get_current_user)]
class BaseController:
def __init__(self, session: AsyncSession) -> None:
self.async_session = session
async def call(self, *args, **kwargs) -> Any:
raise NotImplementedError
async def __call__(self, *args, **kwargs) -> Any:
return await self.call(*args, **kwargs)
class AuthorizedController(BaseController):
def __init__(self, session: AsyncSession, user: UserDependency) -> None:
super().__init__(session)
self.user = user

View file

@ -0,0 +1,25 @@
from datetime import date
from fastapi import HTTPException
from ..model.diary import Diary
from ..domain.diary import Diary as DBDiary
from ..domain.meal import Meal as DBMeal
from .base import AuthorizedController
class GetDiary(AuthorizedController):
async def call(self, date: date) -> Diary:
async with self.async_session() as session:
diary = await DBDiary.get_diary(session, self.user.id, date)
if diary is not None:
return Diary.from_orm(diary)
else:
try:
await DBDiary.create(session, self.user.id, date)
await session.commit()
return Diary.from_orm(
await DBDiary.get_diary(session, self.user.id, date)
)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])

View file

@ -0,0 +1,47 @@
from typing import AsyncIterator
from fastapi import HTTPException
from ..model.entry import Entry, CreateEntryPayload, UpdateEntryPayload
from ..domain.entry import Entry as DBEntry
from .base import AuthorizedController
class CreateEntry(AuthorizedController):
async def call(self, content: CreateEntryPayload) -> Entry:
async with self.async_session.begin() as session:
try:
entry = await DBEntry.create(
session, content.meal_id, content.product_id, content.grams
)
return Entry.from_orm(entry)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])
class UpdateEntry(AuthorizedController):
async def call(self, entry_id: int, content: UpdateEntryPayload) -> Entry:
async with self.async_session.begin() as session:
entry = await DBEntry.get_by_id(session, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="entry not found")
try:
await entry.update(
session, content.meal_id, content.product_id, content.grams
)
return Entry.from_orm(entry)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])
class DeleteEntry(AuthorizedController):
async def call(self, entry_id: int) -> Entry:
async with self.async_session.begin() as session:
entry = await DBEntry.get_by_id(session, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="entry not found")
try:
await entry.delete(session)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])

18
fooder/controller/meal.py Normal file
View file

@ -0,0 +1,18 @@
from typing import AsyncIterator
from fastapi import HTTPException
from ..model.meal import Meal, CreateMealPayload
from ..domain.meal import Meal as DBMeal
from .base import AuthorizedController
class CreateMeal(AuthorizedController):
async def call(self, content: CreateMealPayload) -> Meal:
async with self.async_session.begin() as session:
try:
meal = await DBMeal.create(
session, content.diary_id, content.order, content.name
)
return Meal.from_orm(meal)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])

View file

@ -0,0 +1,32 @@
from typing import AsyncIterator
from fastapi import HTTPException
from ..model.product import Product, CreateProductPayload
from ..domain.product import Product as DBProduct
from .base import AuthorizedController
class CreateProduct(AuthorizedController):
async def call(self, content: CreateProductPayload) -> Product:
async with self.async_session.begin() as session:
try:
product = await DBProduct.create(
session,
content.name,
content.carb,
content.protein,
content.fat,
)
return Product.from_orm(product)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])
class ListProduct(AuthorizedController):
async def call(self, limit: int, offset: int) -> AsyncIterator[Product]:
async with self.async_session() as session:
async for product in DBProduct.list_all(
session, limit=limit, offset=offset
):
yield Product.from_orm(product)

View file

@ -0,0 +1,27 @@
from typing import AsyncIterator, Annotated
from fastapi import HTTPException, Depends
from fastapi.security import OAuth2PasswordRequestForm
from ..model.token import Token
from ..domain.user import User as DBUser
from .base import BaseController, AsyncSession
from ..auth import authenticate_user, create_access_token
class CreateToken(BaseController):
async def call(self, content: OAuth2PasswordRequestForm) -> Token:
async with self.async_session() as session:
user = await authenticate_user(session, content.username, content.password)
if user is None:
raise HTTPException(
status_code=401, detail="Invalid username or password"
)
access_token = create_access_token(user)
return Token(
access_token=access_token,
token_type="bearer",
)

21
fooder/controller/user.py Normal file
View file

@ -0,0 +1,21 @@
from typing import AsyncIterator
from fastapi import HTTPException
from ..model.user import User, CreateUserPayload
from ..domain.user import User as DBUser
from .base import BaseController
class CreateUser(BaseController):
async def call(self, content: CreateUserPayload) -> User:
async with self.async_session.begin() as session:
try:
user = await DBUser.create(
session,
content.username,
content.password,
)
return User.from_orm(user)
except AssertionError as e:
raise HTTPException(status_code=400, detail=e.args[0])

29
fooder/db.py Normal file
View file

@ -0,0 +1,29 @@
import logging
from typing import AsyncIterator
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from .settings import Settings
log = logging.getLogger(__name__)
settings = Settings.parse_obj({})
async_engine = create_async_engine(
settings.DB_URI,
pool_pre_ping=True,
echo=settings.ECHO_SQL,
)
AsyncSessionLocal = async_sessionmaker(
bind=async_engine,
autocommit=False,
autoflush=False,
future=True,
)
async def get_session() -> AsyncIterator[async_sessionmaker]:
try:
yield AsyncSessionLocal
except SQLAlchemyError as e:
log.exception(e)

View file

@ -0,0 +1,6 @@
from .base import Base
from .diary import Diary
from .entry import Entry
from .meal import Meal
from .product import Product
from .user import User

23
fooder/domain/base.py Normal file
View file

@ -0,0 +1,23 @@
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, declared_attr
from sqlalchemy import Integer
class Base(DeclarativeBase):
"""Base from DeclarativeBase"""
pass
class CommonMixin:
"""define a series of common elements that may be applied to mapped
classes using this class as a mixin class."""
@declared_attr.directive
def __tablename__(cls) -> str:
"""__tablename__.
:rtype: str
"""
return cls.__name__.lower()
id: Mapped[int] = mapped_column(primary_key=True)

90
fooder/domain/diary.py Normal file
View file

@ -0,0 +1,90 @@
from sqlalchemy.orm import relationship, Mapped, mapped_column, joinedload
from sqlalchemy import ForeignKey, Integer, Date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import date
from typing import Optional
from .base import Base, CommonMixin
from .meal import Meal
class Diary(Base, CommonMixin):
"""Diary represents user diary for given day"""
meals: Mapped[list[Meal]] = relationship(lazy="selectin")
date: Mapped[date] = mapped_column(Date)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"))
@property
def calories(self) -> float:
"""calories.
:rtype: float
"""
return sum(meal.calories for meal in self.meals)
@property
def protein(self) -> float:
"""protein.
:rtype: float
"""
return sum(meal.protein for meal in self.meals)
@property
def carb(self) -> float:
"""carb.
:rtype: float
"""
return sum(meal.carb for meal in self.meals)
@property
def fat(self) -> float:
"""fat.
:rtype: float
"""
return sum(meal.fat for meal in self.meals)
@classmethod
async def get_diary(
cls, session: AsyncSession, user_id: int, date: date
) -> "Optional[Diary]":
"""get_diary."""
query = select(cls).where(cls.user_id == user_id).where(cls.date == date)
return await session.scalar(query)
@classmethod
async def create(cls, session: AsyncSession, user_id: int, date: date) -> "Diary":
diary = Diary(
date=date,
user_id=user_id,
)
session.add(diary)
try:
await session.flush()
except Exception:
raise RuntimeError()
diary = await cls.get_by_id(session, user_id, diary.id)
if not diary:
raise RuntimeError()
await Meal.create(session, diary.id)
return diary
@classmethod
async def get_by_id(
cls, session: AsyncSession, user_id: int, id: int
) -> "Optional[Diary]":
"""get_by_id."""
query = (
select(cls)
.where(cls.user_id == user_id)
.where(cls.id == id)
.options(joinedload(cls.meals))
)
return await session.scalar(query)

122
fooder/domain/entry.py Normal file
View file

@ -0,0 +1,122 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship, joinedload
from sqlalchemy import ForeignKey, Integer, DateTime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
from sqlalchemy import select
from datetime import datetime
from typing import Optional
from .base import Base, CommonMixin
from .product import Product
class Entry(Base, CommonMixin):
"""Entry."""
grams: Mapped[float]
product_id: Mapped[int] = mapped_column(Integer, ForeignKey("product.id"))
product: Mapped[Product] = relationship(lazy="selectin")
meal_id: Mapped[int] = mapped_column(Integer, ForeignKey("meal.id"))
last_changed: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
@property
def amount(self) -> float:
"""amount.
:rtype: float
"""
return self.grams / 100
@property
def calories(self) -> float:
"""calories.
:rtype: float
"""
return self.amount * self.product.calories
@property
def protein(self) -> float:
"""protein.
:rtype: float
"""
return self.amount * self.product.protein
@property
def carb(self) -> float:
"""carb.
:rtype: float
"""
return self.amount * self.product.carb
@property
def fat(self) -> float:
"""fat.
:rtype: float
"""
return self.amount * self.product.fat
@classmethod
async def create(
cls, session: AsyncSession, meal_id: int, product_id: int, grams: float
) -> "Entry":
"""create."""
assert grams > 0, "grams must be greater than 0"
entry = Entry(
meal_id=meal_id,
product_id=product_id,
grams=grams,
)
session.add(entry)
try:
await session.flush()
except IntegrityError:
raise AssertionError("meal or product does not exist")
entry = await cls.get_by_id(session, entry.id)
if not entry:
raise RuntimeError()
return entry
async def update(
self,
session: AsyncSession,
meal_id: Optional[int],
product_id: Optional[int],
grams: Optional[float],
) -> None:
"""update."""
if grams is not None:
assert grams > 0, "grams must be greater than 0"
self.grams = grams
if meal_id is not None:
self.meal_id = meal_id
try:
session.flush()
except IntegrityError:
raise AssertionError("meal does not exist")
if product_id is not None:
self.product_id = product_id
try:
session.flush()
except IntegrityError:
raise AssertionError("product does not exist")
@classmethod
async def get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Entry]":
"""get_by_id."""
query = select(cls).where(cls.id == id).options(joinedload(cls.product))
return await session.scalar(query.order_by(cls.id))
async def delete(self, session) -> None:
"""delete."""
await session.delete(self)
await session.flush()

84
fooder/domain/meal.py Normal file
View file

@ -0,0 +1,84 @@
from sqlalchemy.orm import relationship, Mapped, mapped_column, joinedload
from sqlalchemy import ForeignKey, Integer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
from typing import Optional
from .base import Base, CommonMixin
from .entry import Entry
class Meal(Base, CommonMixin):
"""Meal."""
name: Mapped[str]
order: Mapped[int]
diary_id: Mapped[int] = mapped_column(Integer, ForeignKey("diary.id"))
entries: Mapped[list[Entry]] = relationship(lazy="selectin")
@property
def calories(self) -> float:
"""calories.
:rtype: float
"""
return sum(entry.calories for entry in self.entries)
@property
def protein(self) -> float:
"""protein.
:rtype: float
"""
return sum(entry.protein for entry in self.entries)
@property
def carb(self) -> float:
"""carb.
:rtype: float
"""
return sum(entry.carb for entry in self.entries)
@property
def fat(self) -> float:
"""fat.
:rtype: float
"""
return sum(entry.fat for entry in self.entries)
@classmethod
async def create(
cls,
session: AsyncSession,
diary_id: int,
order: int = 0,
name: Optional[str] = None,
) -> "Meal":
# check if order already exists in diary
query = select(cls).where(cls.diary_id == diary_id).where(cls.order == order)
existing_meal = await session.scalar(query)
assert existing_meal is None, "order already exists in diary"
if name is None:
name = f"Meal {order}"
meal = Meal(diary_id=diary_id, name=name, order=order)
session.add(meal)
try:
await session.flush()
except IntegrityError:
raise AssertionError("diary does not exist")
meal = await cls.get_by_id(session, meal.id)
if not meal:
raise RuntimeError()
return meal
@classmethod
async def get_by_id(cls, session: AsyncSession, id: int) -> "Optional[Meal]":
"""get_by_id."""
query = select(cls).where(cls.id == id).options(joinedload(cls.entries))
return await session.scalar(query.order_by(cls.id))

62
fooder/domain/product.py Normal file
View file

@ -0,0 +1,62 @@
from sqlalchemy.orm import Mapped
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import AsyncIterator
from .base import Base, CommonMixin
class Product(Base, CommonMixin):
"""Product."""
name: Mapped[str]
protein: Mapped[float]
carb: Mapped[float]
fat: Mapped[float]
@property
def calories(self) -> float:
"""calories.
:rtype: float
"""
return self.protein * 4 + self.carb * 4 + self.fat * 9
@classmethod
async def list_all(cls, session: AsyncSession, offset: int, limit: int):
query = select(cls).offset(offset).limit(limit)
stream = await session.stream_scalars(query.order_by(cls.id))
async for row in stream:
yield row
@classmethod
async def create(
cls, session: AsyncSession, name: str, carb: float, protein: float, fat: float
) -> "Product":
# validation here
assert carb <= 100, "carb must be less than 100"
assert protein <= 100, "protein must be less than 100"
assert fat <= 100, "fat must be less than 100"
assert carb >= 0, "carb must be greater than 0"
assert protein >= 0, "protein must be greater than 0"
assert fat >= 0, "fat must be greater than 0"
assert carb + protein + fat <= 100, "total must be less than 100"
# to avoid duplicates in the database keep name as lower
name = name.lower()
# check if product already exists
query = select(cls).where(cls.name == name)
existing_product = await session.scalar(query)
assert existing_product is None, "product already exists"
product = Product(
name=name,
protein=protein,
carb=carb,
fat=fat,
)
session.add(product)
await session.flush()
return product

37
fooder/domain/user.py Normal file
View file

@ -0,0 +1,37 @@
from sqlalchemy.orm import Mapped
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from .base import Base, CommonMixin
class User(Base, CommonMixin):
"""Product."""
username: Mapped[str]
hashed_password: Mapped[str]
@classmethod
async def get_by_username(
cls, session: AsyncSession, username: str
) -> Optional["User"]:
query = select(cls).filter(cls.username == username)
return await session.scalar(query.order_by(cls.id))
def set_password(self, password) -> None:
from ..auth import password_helper
self.hashed_password = password_helper.hash(password)
@classmethod
async def create(
cls, session: AsyncSession, username: str, password: str
) -> "User":
exsisting_user = await User.get_by_username(session, username)
assert exsisting_user is None, "user already exists"
user = cls(username=username)
user.set_password(password)
session.add(user)
await session.flush()
return user

0
fooder/model/__init__.py Normal file
View file

20
fooder/model/diary.py Normal file
View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
from typing import List
from datetime import date
from .meal import Meal
class Diary(BaseModel):
"""Diary represents user diary for given day"""
id: int
date: date
meals: List[Meal]
calories: float
protein: float
carb: float
fat: float
class Config:
orm_mode = True

36
fooder/model/entry.py Normal file
View file

@ -0,0 +1,36 @@
from pydantic import BaseModel
from typing import Optional
from .product import Product
class Entry(BaseModel):
"""Entry."""
id: int
grams: float
product: Product
meal_id: int
calories: float
protein: float
carb: float
fat: float
class Config:
orm_mode = True
class CreateEntryPayload(BaseModel):
"""CreateEntryPayload."""
grams: float
product_id: int
meal_id: int
class UpdateEntryPayload(BaseModel):
"""CreateEntryPayload."""
grams: Optional[float]
product_id: Optional[int]
meal_id: Optional[int]

28
fooder/model/meal.py Normal file
View file

@ -0,0 +1,28 @@
from pydantic import BaseModel
from typing import List, Optional
from .entry import Entry
class Meal(BaseModel):
"""Meal."""
id: int
name: str
order: int
calories: float
protein: float
carb: float
fat: float
entries: List[Entry]
diary_id: int
class Config:
orm_mode = True
class CreateMealPayload(BaseModel):
"""CreateMealPayload."""
name: Optional[str]
order: int
diary_id: int

31
fooder/model/product.py Normal file
View file

@ -0,0 +1,31 @@
from pydantic import BaseModel
from typing import List
class Product(BaseModel):
"""Product."""
id: int
name: str
calories: float
protein: float
carb: float
fat: float
class Config:
orm_mode = True
class CreateProductPayload(BaseModel):
"""ProductCreatePayload."""
name: str
protein: float
carb: float
fat: float
class ListProductPayload(BaseModel):
"""ProductListPayload."""
products: List[Product]

10
fooder/model/token.py Normal file
View file

@ -0,0 +1,10 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None

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

@ -0,0 +1,13 @@
from pydantic import BaseModel
class User(BaseModel):
username: str
class Config:
orm_mode = True
class CreateUserPayload(BaseModel):
username: str
password: str

16
fooder/router.py Normal file
View file

@ -0,0 +1,16 @@
from fastapi import APIRouter
from .view.product import router as product_router
from .view.diary import router as diary_router
from .view.meal import router as meal_router
from .view.entry import router as entry_router
from .view.token import router as token_router
from .view.user import router as user_router
router = APIRouter(prefix="/api")
router.include_router(product_router, prefix="/product", tags=["product"])
router.include_router(diary_router, prefix="/diary", tags=["diary"])
router.include_router(meal_router, prefix="/meal", tags=["meal"])
router.include_router(entry_router, prefix="/entry", tags=["entry"])
router.include_router(token_router, prefix="/token", tags=["token"])
router.include_router(user_router, prefix="/user", tags=["user"])

12
fooder/settings.py Normal file
View file

@ -0,0 +1,12 @@
from pydantic import BaseSettings
class Settings(BaseSettings):
"""Settings."""
DB_URI: str
ECHO_SQL: bool
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30

0
fooder/view/__init__.py Normal file
View file

16
fooder/view/diary.py Normal file
View file

@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends, Request
from ..model.diary import Diary
from ..controller.diary import GetDiary
from datetime import date
router = APIRouter(tags=["diary"])
@router.get("/", response_model=Diary)
async def get_diary(
request: Request,
date: date,
controller: GetDiary = Depends(GetDiary),
):
return await controller.call(date)

34
fooder/view/entry.py Normal file
View file

@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, Request
from ..model.entry import Entry, CreateEntryPayload, UpdateEntryPayload
from ..controller.entry import CreateEntry, UpdateEntry, DeleteEntry
router = APIRouter(tags=["entry"])
@router.post("/", response_model=Entry)
async def create_entry(
request: Request,
data: CreateEntryPayload,
contoller: CreateEntry = Depends(CreateEntry),
):
return await contoller.call(data)
@router.put("/{entry_id}", response_model=Entry)
async def update_entry(
request: Request,
entry_id: int,
data: UpdateEntryPayload,
contoller: UpdateEntry = Depends(UpdateEntry),
):
return await contoller.call(entry_id, data)
@router.delete("/{entry_id}")
async def delete_entry(
request: Request,
entry_id: int,
contoller: DeleteEntry = Depends(DeleteEntry),
):
return await contoller.call(entry_id)

15
fooder/view/meal.py Normal file
View file

@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends, Request
from ..model.meal import Meal, CreateMealPayload
from ..controller.meal import CreateMeal
router = APIRouter(tags=["meal"])
@router.post("/", response_model=Meal)
async def create_meal(
request: Request,
data: CreateMealPayload,
contoller: CreateMeal = Depends(CreateMeal),
):
return await contoller.call(data)

27
fooder/view/product.py Normal file
View file

@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, Request
from ..model.product import Product, CreateProductPayload, ListProductPayload
from ..controller.product import ListProduct, CreateProduct
router = APIRouter(tags=["product"])
@router.get("/", response_model=ListProductPayload)
async def list_product(
request: Request,
controller: ListProduct = Depends(ListProduct),
limit: int = 10,
offset: int = 0,
):
return ListProductPayload(
products=[p async for p in controller.call(limit=limit, offset=offset)]
)
@router.post("/", response_model=Product)
async def create_product(
request: Request,
data: CreateProductPayload,
contoller: CreateProduct = Depends(CreateProduct),
):
return await contoller.call(data)

17
fooder/view/token.py Normal file
View file

@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends, Request
from ..model.token import Token
from ..controller.token import CreateToken
from fastapi.security import OAuth2PasswordRequestForm
from typing import Annotated
router = APIRouter(tags=["token"])
@router.post("/", response_model=Token)
async def create_token(
request: Request,
data: Annotated[OAuth2PasswordRequestForm, Depends()],
controller: CreateToken = Depends(CreateToken),
):
return await controller.call(data)

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

@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends, Request
from ..model.user import User, CreateUserPayload
from ..controller.user import CreateUser
router = APIRouter(tags=["user"])
@router.post("/", response_model=User)
async def create_user(
request: Request,
data: CreateUserPayload,
contoller: CreateUser = Depends(CreateUser),
):
return await contoller.call(data)

9
requirements.txt Normal file
View file

@ -0,0 +1,9 @@
fastapi
pydantic
sqlalchemy[postgresql_asyncpg]
uvicorn[standard]
asyncpg
psycopg2-binary
python-jose[cryptography]
passlib[bcrypt]
fastapi-users