initial push
This commit is contained in:
commit
d7f27a763d
41 changed files with 1241 additions and 0 deletions
129
.gitignore
vendored
Normal file
129
.gitignore
vendored
Normal 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
11
Dockerfile
Normal 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
32
docker-compose.yml
Normal 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
11
env.template
Normal 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
0
fooder/__init__.py
Normal file
33
fooder/__main__.py
Normal file
33
fooder/__main__.py
Normal 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
6
fooder/app.py
Normal 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
70
fooder/auth.py
Normal 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)
|
0
fooder/controller/__init__.py
Normal file
0
fooder/controller/__init__.py
Normal file
27
fooder/controller/base.py
Normal file
27
fooder/controller/base.py
Normal 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
|
25
fooder/controller/diary.py
Normal file
25
fooder/controller/diary.py
Normal 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])
|
47
fooder/controller/entry.py
Normal file
47
fooder/controller/entry.py
Normal 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
18
fooder/controller/meal.py
Normal 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])
|
32
fooder/controller/product.py
Normal file
32
fooder/controller/product.py
Normal 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)
|
27
fooder/controller/token.py
Normal file
27
fooder/controller/token.py
Normal 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
21
fooder/controller/user.py
Normal 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
29
fooder/db.py
Normal 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)
|
6
fooder/domain/__init__.py
Normal file
6
fooder/domain/__init__.py
Normal 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
23
fooder/domain/base.py
Normal 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
90
fooder/domain/diary.py
Normal 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
122
fooder/domain/entry.py
Normal 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
84
fooder/domain/meal.py
Normal 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
62
fooder/domain/product.py
Normal 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
37
fooder/domain/user.py
Normal 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
0
fooder/model/__init__.py
Normal file
20
fooder/model/diary.py
Normal file
20
fooder/model/diary.py
Normal 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
36
fooder/model/entry.py
Normal 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
28
fooder/model/meal.py
Normal 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
31
fooder/model/product.py
Normal 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
10
fooder/model/token.py
Normal 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
13
fooder/model/user.py
Normal 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
16
fooder/router.py
Normal 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
12
fooder/settings.py
Normal 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
0
fooder/view/__init__.py
Normal file
16
fooder/view/diary.py
Normal file
16
fooder/view/diary.py
Normal 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
34
fooder/view/entry.py
Normal 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
15
fooder/view/meal.py
Normal 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
27
fooder/view/product.py
Normal 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
17
fooder/view/token.py
Normal 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
15
fooder/view/user.py
Normal 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
9
requirements.txt
Normal file
|
@ -0,0 +1,9 @@
|
|||
fastapi
|
||||
pydantic
|
||||
sqlalchemy[postgresql_asyncpg]
|
||||
uvicorn[standard]
|
||||
asyncpg
|
||||
psycopg2-binary
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
fastapi-users
|
Loading…
Reference in a new issue