commit d7f27a763dab46283ac17d859a645777b8e67d18 Author: Piotr DomaƄski Date: Sat Apr 1 16:19:12 2023 +0200 initial push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e4761 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0bf588 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e100648 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/env.template b/env.template new file mode 100644 index 0000000..9dc6843 --- /dev/null +++ b/env.template @@ -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 diff --git a/fooder/__init__.py b/fooder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fooder/__main__.py b/fooder/__main__.py new file mode 100644 index 0000000..aad905c --- /dev/null +++ b/fooder/__main__.py @@ -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() diff --git a/fooder/app.py b/fooder/app.py new file mode 100644 index 0000000..aedfc5a --- /dev/null +++ b/fooder/app.py @@ -0,0 +1,6 @@ +from fastapi import FastAPI +from .router import router + + +app = FastAPI(title="Fooder") +app.include_router(router) diff --git a/fooder/auth.py b/fooder/auth.py new file mode 100644 index 0000000..1e12b0e --- /dev/null +++ b/fooder/auth.py @@ -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) diff --git a/fooder/controller/__init__.py b/fooder/controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fooder/controller/base.py b/fooder/controller/base.py new file mode 100644 index 0000000..9332377 --- /dev/null +++ b/fooder/controller/base.py @@ -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 diff --git a/fooder/controller/diary.py b/fooder/controller/diary.py new file mode 100644 index 0000000..54362f2 --- /dev/null +++ b/fooder/controller/diary.py @@ -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]) diff --git a/fooder/controller/entry.py b/fooder/controller/entry.py new file mode 100644 index 0000000..68e25df --- /dev/null +++ b/fooder/controller/entry.py @@ -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]) diff --git a/fooder/controller/meal.py b/fooder/controller/meal.py new file mode 100644 index 0000000..21dc005 --- /dev/null +++ b/fooder/controller/meal.py @@ -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]) diff --git a/fooder/controller/product.py b/fooder/controller/product.py new file mode 100644 index 0000000..da2550e --- /dev/null +++ b/fooder/controller/product.py @@ -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) diff --git a/fooder/controller/token.py b/fooder/controller/token.py new file mode 100644 index 0000000..d08a28f --- /dev/null +++ b/fooder/controller/token.py @@ -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", + ) diff --git a/fooder/controller/user.py b/fooder/controller/user.py new file mode 100644 index 0000000..634b8f6 --- /dev/null +++ b/fooder/controller/user.py @@ -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]) diff --git a/fooder/db.py b/fooder/db.py new file mode 100644 index 0000000..5e05ef5 --- /dev/null +++ b/fooder/db.py @@ -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) diff --git a/fooder/domain/__init__.py b/fooder/domain/__init__.py new file mode 100644 index 0000000..08b4fe3 --- /dev/null +++ b/fooder/domain/__init__.py @@ -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 diff --git a/fooder/domain/base.py b/fooder/domain/base.py new file mode 100644 index 0000000..0fe1919 --- /dev/null +++ b/fooder/domain/base.py @@ -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) diff --git a/fooder/domain/diary.py b/fooder/domain/diary.py new file mode 100644 index 0000000..4d76864 --- /dev/null +++ b/fooder/domain/diary.py @@ -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) diff --git a/fooder/domain/entry.py b/fooder/domain/entry.py new file mode 100644 index 0000000..1a92d92 --- /dev/null +++ b/fooder/domain/entry.py @@ -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() diff --git a/fooder/domain/meal.py b/fooder/domain/meal.py new file mode 100644 index 0000000..0bd37c7 --- /dev/null +++ b/fooder/domain/meal.py @@ -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)) diff --git a/fooder/domain/product.py b/fooder/domain/product.py new file mode 100644 index 0000000..0cb082b --- /dev/null +++ b/fooder/domain/product.py @@ -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 diff --git a/fooder/domain/user.py b/fooder/domain/user.py new file mode 100644 index 0000000..abf6ccc --- /dev/null +++ b/fooder/domain/user.py @@ -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 diff --git a/fooder/model/__init__.py b/fooder/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fooder/model/diary.py b/fooder/model/diary.py new file mode 100644 index 0000000..0d67816 --- /dev/null +++ b/fooder/model/diary.py @@ -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 diff --git a/fooder/model/entry.py b/fooder/model/entry.py new file mode 100644 index 0000000..cf2ac85 --- /dev/null +++ b/fooder/model/entry.py @@ -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] diff --git a/fooder/model/meal.py b/fooder/model/meal.py new file mode 100644 index 0000000..93419bb --- /dev/null +++ b/fooder/model/meal.py @@ -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 diff --git a/fooder/model/product.py b/fooder/model/product.py new file mode 100644 index 0000000..0cdf1cd --- /dev/null +++ b/fooder/model/product.py @@ -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] diff --git a/fooder/model/token.py b/fooder/model/token.py new file mode 100644 index 0000000..0802867 --- /dev/null +++ b/fooder/model/token.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username: str | None = None diff --git a/fooder/model/user.py b/fooder/model/user.py new file mode 100644 index 0000000..1f3017c --- /dev/null +++ b/fooder/model/user.py @@ -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 diff --git a/fooder/router.py b/fooder/router.py new file mode 100644 index 0000000..744aed5 --- /dev/null +++ b/fooder/router.py @@ -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"]) diff --git a/fooder/settings.py b/fooder/settings.py new file mode 100644 index 0000000..0d05e30 --- /dev/null +++ b/fooder/settings.py @@ -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 diff --git a/fooder/view/__init__.py b/fooder/view/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fooder/view/diary.py b/fooder/view/diary.py new file mode 100644 index 0000000..618d170 --- /dev/null +++ b/fooder/view/diary.py @@ -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) diff --git a/fooder/view/entry.py b/fooder/view/entry.py new file mode 100644 index 0000000..d452856 --- /dev/null +++ b/fooder/view/entry.py @@ -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) diff --git a/fooder/view/meal.py b/fooder/view/meal.py new file mode 100644 index 0000000..d99e0f3 --- /dev/null +++ b/fooder/view/meal.py @@ -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) diff --git a/fooder/view/product.py b/fooder/view/product.py new file mode 100644 index 0000000..9b608df --- /dev/null +++ b/fooder/view/product.py @@ -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) diff --git a/fooder/view/token.py b/fooder/view/token.py new file mode 100644 index 0000000..2b8c17a --- /dev/null +++ b/fooder/view/token.py @@ -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) diff --git a/fooder/view/user.py b/fooder/view/user.py new file mode 100644 index 0000000..c93ee79 --- /dev/null +++ b/fooder/view/user.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..39704cb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi +pydantic +sqlalchemy[postgresql_asyncpg] +uvicorn[standard] +asyncpg +psycopg2-binary +python-jose[cryptography] +passlib[bcrypt] +fastapi-users