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