database refactoring

This commit is contained in:
hay-kot 2021-03-06 18:08:56 -09:00
commit db61ac8a31
26 changed files with 234 additions and 113 deletions

View file

@ -1,10 +1,15 @@
# Release Notes
## v0.4.0 Whoa, What a Release! [DRAFT]
- Authentication! Tons of stuff went into creating a flexible authentication platform for a lot of different use cases. Review the documentation for more information on how to use the authentication, and how everything works together.
### Bug Fixes
### Features and Improvements
- Authentication! Tons of stuff went into creating a flexible authentication platform for a lot of different use cases. Review the documentation for more information on how to use the authentication, and how everything works together. Some key features include
- Sign Up Links
- Admin and User Roles
- Group Management
- Create/Edit/Delete Restrictions
- Recipe Database Refactoring. Tons of new information is now stored for recipes in the database. Not all is accessible via the UI, but it's coming.
- Nutrition Information
- calories
@ -13,9 +18,18 @@
- proteinContent
- sodiumContent
- sugarContent
- recipeCuisine
- recipeCuisine has been added
- "categories" has been migrated to "recipeCategory" to adhear closer to the standard schema
- "tool" - a list of tools used for the recipe
- Removed CDN dependencies
- Completed Redesign of the Admin Panel
- Profile Pages
- Side Panel Menu
- Language selector is now displayed on all pages and does not require an account
### Development / Misc
- Database Model Refactoring
- File/Folder Name Refactoring
## v0.3.0

View file

@ -63,8 +63,8 @@
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
v-model="editedItem.group"
label="Group Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
@ -143,7 +143,7 @@ export default {
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Group", value: "group" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
@ -154,7 +154,7 @@ export default {
fullName: "",
password: "",
email: "",
family: "",
group: "",
admin: false,
},
defaultItem: {
@ -162,7 +162,7 @@ export default {
fullName: "",
password: "",
email: "",
family: "",
group: "",
admin: false,
},
}),

View file

@ -63,8 +63,8 @@
</v-col>
<v-col cols="12" sm="12" md="6">
<v-text-field
v-model="editedItem.family"
label="Family Group"
v-model="editedItem.group"
label="Group Group"
></v-text-field>
</v-col>
<v-col cols="12" sm="12" md="6" v-if="showPassword">
@ -143,7 +143,7 @@ export default {
},
{ text: "Full Name", value: "fullName" },
{ text: "Email", value: "email" },
{ text: "Family", value: "family" },
{ text: "Group", value: "group" },
{ text: "Admin", value: "admin" },
{ text: "", value: "actions", sortable: false, align: "center" },
],
@ -154,7 +154,7 @@ export default {
fullName: "",
password: "",
email: "",
family: "",
group: "",
admin: false,
},
defaultItem: {
@ -162,7 +162,7 @@ export default {
fullName: "",
password: "",
email: "",
family: "",
group: "",
admin: false,
},
}),

View file

@ -121,7 +121,7 @@ export default {
const userData = {
fullName: this.user.name,
email: this.user.email,
family: "public",
group: "default",
password: this.user.password,
admin: false,
};

View file

@ -55,11 +55,11 @@
>
</v-text-field>
<v-text-field
label="Family"
label="Group"
readonly
v-model="user.family"
v-model="user.group"
persistent-hint
hint="Family groups can only be set by administrators"
hint="Group groups can only be set by administrators"
>
</v-text-field>
</v-form>
@ -167,7 +167,7 @@ export default {
user: {
fullName: "Change Me",
email: "changeme@email.com",
family: "public",
group: "public",
admin: true,
id: 1,
},

View file

@ -1,5 +1,6 @@
import uvicorn
from fastapi import FastAPI
from fastapi.logger import logger
# import utils.startup as startup
from core.config import APP_VERSION, PORT, SECRET, docs_url, redoc_url
@ -13,6 +14,7 @@ from routes import (
setting_routes,
theme_routes,
)
from routes.groups import groups
from routes.recipe import (
all_recipe_routes,
category_routes,
@ -20,7 +22,6 @@ from routes.recipe import (
tag_routes,
)
from routes.users import users
from fastapi.logger import logger
app = FastAPI(
title="Mealie",
@ -42,11 +43,13 @@ def start_scheduler():
def api_routers():
# Authentication
app.include_router(users.router)
app.include_router(groups.router)
# Recipes
app.include_router(all_recipe_routes.router)
app.include_router(category_routes.router)
app.include_router(tag_routes.router)
app.include_router(recipe_crud_routes.router)
# Meal Routes
app.include_router(meal_routes.router)
# Settings Routes

View file

@ -83,6 +83,7 @@ else:
# Mongo Database
MEALIE_DB_NAME = os.getenv("mealie_db_name", "mealie")
DEFAULT_GROUP = os.getenv("default_group", "home")
DB_USERNAME = os.getenv("db_username", "root")
DB_PASSWORD = os.getenv("db_password", "example")
DB_HOST = os.getenv("db_host", "mongo")

View file

@ -1,6 +1,8 @@
from schema.user import GroupInDB, UserInDB
from sqlalchemy.orm.session import Session
from db.db_base import BaseDocument
from db.models.group import Group
from db.models.mealplan import MealPlanModel
from db.models.recipe.recipe import Category, RecipeModel, Tag
from db.models.settings import SiteSettingsModel
@ -8,16 +10,12 @@ from db.models.sign_up import SignUp
from db.models.theme import SiteThemeModel
from db.models.users import User
"""
# TODO
- [ ] Abstract Classes to use save_new, and update from base models
"""
class _Recipes(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
self.sql_model: RecipeModel = RecipeModel
self.orm_mode = False
def update_image(self, session: Session, slug: str, extension: str = None) -> str:
entry: RecipeModel = self._query_one(session, match_value=slug)
@ -31,36 +29,43 @@ class _Categories(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
self.sql_model = Category
self.orm_mode = False
class _Tags(BaseDocument):
def __init__(self) -> None:
self.primary_key = "slug"
self.sql_model = Tag
self.orm_mode = False
class _Meals(BaseDocument):
def __init__(self) -> None:
self.primary_key = "uid"
self.sql_model = MealPlanModel
self.orm_mode = False
class _Settings(BaseDocument):
def __init__(self) -> None:
self.primary_key = "name"
self.sql_model = SiteSettingsModel
self.orm_mode = False
class _Themes(BaseDocument):
def __init__(self) -> None:
self.primary_key = "name"
self.sql_model = SiteThemeModel
self.orm_mode = False
class _Users(BaseDocument):
def __init__(self) -> None:
self.primary_key = "id"
self.sql_model = User
self.orm_mode = True
self.schema = UserInDB
def update_password(self, session, id, password: str):
entry = self._query_one(session=session, match_value=id)
@ -71,16 +76,19 @@ class _Users(BaseDocument):
return return_data
class _SignUps(BaseDocument):
class _Groups(BaseDocument):
def __init__(self) -> None:
self.primary_key = "token"
self.sql_model = SignUp
self.primary_key = "id"
self.sql_model = Group
self.orm_mode = True
self.schema = GroupInDB
class _SignUps(BaseDocument):
def __init__(self) -> None:
self.primary_key = "token"
self.sql_model = SignUp
self.orm_mode = False
class Database:
@ -93,6 +101,7 @@ class Database:
self.tags = _Tags()
self.users = _Users()
self.sign_ups = _SignUps()
self.groups = _Groups()
db = Database()

View file

@ -1,5 +1,6 @@
from typing import List
from pydantic import BaseModel
from sqlalchemy.orm import load_only
from sqlalchemy.orm.session import Session
@ -11,11 +12,20 @@ class BaseDocument:
self.primary_key: str
self.store: str
self.sql_model: SqlAlchemyBase
self.orm_mode = False
self.schema: BaseModel
# TODO: Improve Get All Query Functionality
def get_all(
self, session: Session, limit: int = None, order_by: str = None
) -> List[dict]:
if self.orm_mode:
return [
self.schema.from_orm(x)
for x in session.query(self.sql_model).limit(limit).all()
]
list = [x.dict() for x in session.query(self.sql_model).limit(limit).all()]
if limit == 1:
@ -105,6 +115,15 @@ class BaseDocument:
.limit(limit)
.all()
)
if self.orm_mode:
if limit == 1:
try:
return self.schema.from_orm(result[0])
except IndexError:
return None
return [self.schema(x) for x in result]
db_entries = [x.dict() for x in result]
if limit == 1:
@ -128,6 +147,10 @@ class BaseDocument:
new_document = self.sql_model(session=session, **document)
session.add(new_document)
session.commit()
if self.orm_mode:
return self.schema.from_orm(new_document)
return_data = new_document.dict()
return return_data
@ -145,9 +168,13 @@ class BaseDocument:
entry = self._query_one(session=session, match_value=match_value)
entry.update(session=session, **new_data)
if self.orm_mode:
session.commit()
return self.schema.from_orm(entry)
return_data = entry.dict()
session.commit()
return return_data
def delete(self, session: Session, primary_key_value) -> dict:

View file

@ -1,3 +1,4 @@
from core.config import DEFAULT_GROUP
from core.security import get_password_hash
from fastapi.logger import logger
from schema.settings import SiteSettings, Webhooks
@ -12,6 +13,7 @@ def init_db(db: Session = None) -> None:
if not db:
db = create_session()
default_group_init(db)
default_settings_init(db)
default_theme_init(db)
default_user_init(db)
@ -50,12 +52,19 @@ def default_settings_init(session: Session):
pass
def default_group_init(session: Session):
default_group = {"name": DEFAULT_GROUP}
logger.info("Generating Default Group")
db.groups.create(session, default_group)
pass
def default_user_init(session: Session):
default_user = {
"full_name": "Change Me",
"email": "changeme@email.com",
"password": get_password_hash("MyPassword"),
"family": "public",
"group": DEFAULT_GROUP,
"admin": True,
}

View file

@ -4,3 +4,4 @@ from db.models.settings import *
from db.models.theme import *
from db.models.users import *
from db.models.sign_up import *
from db.models.group import *

View file

@ -1,41 +1,31 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from core.config import DEFAULT_GROUP
from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, Integer, String
from fastapi.logger import logger
from slugify import slugify
class Group(SqlAlchemyBase, BaseMixins):
__tablename__ = "groups"
id = Column(Integer, primary_key=True)
name = Column(String, index=True)
slug = Column(String, unique=True, index=True)
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String, index=True, nullable=False, unique=True)
users = orm.relationship("User", back_populates="group")
mealplans = orm.relationship("MealPlanModel", back_populates="group")
def __init__(
self,
session,
full_name,
email,
password,
family="public",
admin=False,
) -> None:
self.full_name = full_name
self.email = email
self.family = family
self.admin = admin
self.password = password
def dict(self):
return {
"id": self.id,
"full_name": self.full_name,
"email": self.email,
"admin": self.admin,
"family": self.family,
"password": self.password,
}
def update(self, full_name, email, family, admin, session=None):
self.full_name = full_name
self.email = email
self.family = family
self.admin = admin
def __init__(self, name, session=None) -> None:
self.name = name
@staticmethod
def create_if_not_exist(session, name: str = DEFAULT_GROUP):
try:
result = session.query(Group).filter(Group.name == name).one()
if result:
logger.info("Category exists, associating recipe")
return result
else:
logger.info("Category doesn't exists, creating tag")
return Group(name=name)
except:
logger.info("Category doesn't exists, creating category")
return Group(name=name)

View file

@ -46,6 +46,8 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins):
startDate = sa.Column(sa.Date)
endDate = sa.Column(sa.Date)
meals: List[Meal] = orm.relation(Meal)
group_id = sa.Column(sa.String, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="mealplans")
def __init__(self, startDate, endDate, meals, uid=None, session=None) -> None:
self.startDate = startDate

View file

@ -6,12 +6,12 @@ class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
calories = sa.Column(sa.Integer)
fatContent = sa.Column(sa.Integer)
fiberContent = sa.Column(sa.Integer)
proteinContent = sa.Column(sa.Integer)
sodiumContent = sa.Column(sa.Integer)
sugarContent = sa.Column(sa.Integer)
calories = sa.Column(sa.String)
fatContent = sa.Column(sa.String)
fiberContent = sa.Column(sa.String)
proteinContent = sa.Column(sa.String)
sodiumContent = sa.Column(sa.String)
sugarContent = sa.Column(sa.String)
def __init__(
self,

View file

@ -1,5 +1,7 @@
from core.config import DEFAULT_GROUP
from db.models.group import Group
from db.models.model_base import BaseMixins, SqlAlchemyBase
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
class User(SqlAlchemyBase, BaseMixins):
@ -8,9 +10,9 @@ class User(SqlAlchemyBase, BaseMixins):
full_name = Column(String, index=True)
email = Column(String, unique=True, index=True)
password = Column(String)
is_active = Column(Boolean(), default=True)
family = Column(String)
admin = Column(Boolean(), default=False)
group_id = Column(String, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
admin = Column(Boolean, default=False)
def __init__(
self,
@ -18,29 +20,21 @@ class User(SqlAlchemyBase, BaseMixins):
full_name,
email,
password,
family="public",
group: str = DEFAULT_GROUP,
admin=False,
) -> None:
group = group if group else DEFAULT_GROUP
self.full_name = full_name
self.email = email
self.family = family
self.group = Group.create_if_not_exist(session, group)
self.admin = admin
self.password = password
def dict(self):
return {
"id": self.id,
"full_name": self.full_name,
"email": self.email,
"admin": self.admin,
"family": self.family,
"password": self.password,
}
def update(self, full_name, email, family, admin, session=None):
def update(self, full_name, email, group, admin, session=None):
self.full_name = full_name
self.email = email
self.family = family
self.group = Group.create_if_not_exist(session, group)
self.admin = admin
def update_password(self, password):

View file

@ -20,5 +20,5 @@ def query_user(user_email: str, session: Session = None) -> UserInDB:
session = session if session else create_session()
user = db.users.get(session, user_email, "email")
session.close()
return UserInDB(**user)
return user

View file

@ -0,0 +1,24 @@
import shutil
from datetime import timedelta
from core.config import USER_DIR
from core.security import get_password_hash, verify_password
from db.database import db
from db.db_setup import generate_session
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi.responses import FileResponse
from routes.deps import manager
from schema.snackbar import SnackResponse
from schema.user import GroupBase, GroupInDB
from sqlalchemy.orm.session import Session
router = APIRouter(prefix="/api/groups", tags=["Groups"])
@router.get("", response_model=list[GroupInDB])
async def get_all_groups(
current_user=Depends(manager),
session: Session = Depends(generate_session),
):
""" Returns a list of all groups in the database """
return db.groups.get_all(session)

View file

@ -0,0 +1,7 @@
from fastapi import APIRouter
from routes.groups import crud
router = APIRouter()
router.include_router(crud.router)

View file

@ -66,8 +66,8 @@ async def update_user(
):
if current_user.id == id or current_user.admin:
updated_user = db.users.update(session, id, new_data.dict())
email = updated_user.get("email")
updated_user: UserInDB = db.users.update(session, id, new_data.dict())
email = updated_user.email
if current_user.id == id:
access_token = manager.create_access_token(
data=dict(sub=email), expires=timedelta(hours=2)

View file

@ -15,12 +15,12 @@ class RecipeStep(BaseModel):
class Nutrition(BaseModel):
calories: Optional[int]
fatContent: Optional[int]
fiberContent: Optional[int]
proteinContent: Optional[int]
sodiumContent: Optional[int]
sugarContent: Optional[int]
calories: Optional[str]
fatContent: Optional[str]
fiberContent: Optional[str]
proteinContent: Optional[str]
sodiumContent: Optional[str]
sugarContent: Optional[str]
class Recipe(BaseModel):

View file

@ -1,26 +1,35 @@
from typing import Optional
from core.config import DEFAULT_GROUP
from fastapi_camelcase import CamelModel
# from pydantic import EmailStr
class ChangePassword(CamelModel):
current_password: str
new_password: str
class GroupBase(CamelModel):
name: str
class Config:
orm_mode = True
class UserBase(CamelModel):
full_name: Optional[str] = None
email: str
family: str
admin: bool
group: Optional[str]
class Config:
orm_mode = True
class Config:
schema_extra = {
"fullName": "Change Me",
"email": "changeme@email.com",
"family": "public",
"group": DEFAULT_GROUP,
"admin": "false",
}
@ -31,7 +40,24 @@ class UserIn(UserBase):
class UserOut(UserBase):
id: int
group: GroupBase
class Config:
orm_mode = True
class UserInDB(UserIn, UserOut):
class UserInDB(UserOut):
password: str
pass
class Config:
orm_mode = True
class GroupInDB(GroupBase):
id: int
name: str
users: Optional[list[UserOut]]
class Config:
orm_mode = True

View file

@ -20,12 +20,12 @@ class RecipeStep(BaseModel):
class Nutrition(BaseModel):
calories: Optional[int]
fatContent: Optional[int]
fiberContent: Optional[int]
proteinContent: Optional[int]
sodiumContent: Optional[int]
sugarContent: Optional[int]
calories: Optional[str]
fatContent: Optional[str]
fiberContent: Optional[str]
proteinContent: Optional[str]
sodiumContent: Optional[str]
sugarContent: Optional[str]
class Recipe(BaseModel):

View file

@ -31,6 +31,9 @@ class Cleaner:
recipe_data["prepTime"] = Cleaner.time(recipe_data.get("prepTime", None))
recipe_data["performTime"] = Cleaner.time(recipe_data.get("performTime", None))
recipe_data["totalTime"] = Cleaner.time(recipe_data.get("totalTime", None))
recipe_data["recipeCategory"] = Cleaner.category(
recipe_data.get("recipeCategory", [])
)
recipe_data["recipeYield"] = Cleaner.yield_amount(
recipe_data.get("recipeYield")
@ -47,6 +50,13 @@ class Cleaner:
return recipe_data
@staticmethod
def category(category: str):
if type(category) == type(str):
return [category]
else:
return []
@staticmethod
def html(raw_html):
cleanr = re.compile("<.*?>")

View file

@ -34,7 +34,7 @@ def api_client():
yield TestClient(app)
SQLITE_FILE.unlink()
# SQLITE_FILE.unlink()
@fixture(scope="session")

View file

@ -63,7 +63,7 @@ def test_read_update(api_client, recipe_data):
recipe["notes"] = test_notes
test_categories = ["one", "two", "three"]
recipe["categories"] = test_categories
recipe["recipeCategory"] = test_categories
response = api_client.put(
f"{RECIPES_PREFIX}/{recipe_data.expected_slug}", json=recipe
@ -77,7 +77,7 @@ def test_read_update(api_client, recipe_data):
recipe = json.loads(response.content)
assert recipe["notes"] == test_notes
assert recipe["categories"].sort() == test_categories.sort()
assert recipe["recipeCategory"].sort() == test_categories.sort()
@pytest.mark.parametrize("recipe_data", recipe_test_data)

View file

@ -16,7 +16,9 @@ def default_user():
"id": 1,
"fullName": "Change Me",
"email": "changeme@email.com",
"family": "public",
"group": {
"name": "home"
},
"admin": True
}
@ -27,7 +29,9 @@ def new_user():
"id": 2,
"fullName": "My New User",
"email": "newuser@email.com",
"family": "public",
"group": {
"name": "home"
},
"admin": False
}
@ -54,7 +58,7 @@ def test_create_user(api_client: requests, token, new_user):
"fullName": "My New User",
"email": "newuser@email.com",
"password": "MyStrongPassword",
"family": "public",
"group": "home",
"admin": False
}
@ -78,7 +82,7 @@ def test_update_user(api_client: requests, token):
"id": 1,
"fullName": "Updated Name",
"email": "updated@email.com",
"family": "public",
"group": "home",
"admin": True
}
response = api_client.put(f"{BASE}/1", headers=token, json=update_data)