Refactor/user database models (#775)

* fix build error

* drop frontend.old

* improve auto_init decorator

* purge depreciated site settings

* formatting

* update init function

* fix(backend): 🐛 Fix password reset bug

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-11-04 14:01:37 -08:00
committed by GitHub
parent 40462a95f1
commit ec3b53cdc3
172 changed files with 430 additions and 12255 deletions

View File

@@ -14,11 +14,9 @@ from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUn
from mealie.db.models.recipe.recipe import RecipeModel
from mealie.db.models.recipe.tag import Tag
from mealie.db.models.server.task import ServerTaskModel
from mealie.db.models.settings import SiteSettings
from mealie.db.models.sign_up import SignUp
from mealie.db.models.users import LongLiveToken, User
from mealie.db.models.users.password_reset import PasswordResetModel
from mealie.schema.admin import SiteSettings as SiteSettingsSchema
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn
@@ -94,10 +92,6 @@ class Database:
# ================================================================
# Site Items
@cached_property
def settings(self) -> AccessModel[SiteSettingsSchema, SiteSettings]:
return AccessModel(self.session, pk_id, SiteSettings, SiteSettingsSchema)
@cached_property
def sign_up(self) -> AccessModel[SignUpOut, SignUp]:
return AccessModel(self.session, pk_id, SignUp, SignUpOut)

View File

@@ -6,7 +6,6 @@ from mealie.db.data_initialization.init_users import default_user_init
from mealie.db.database import get_database
from mealie.db.db_setup import create_session, engine
from mealie.db.models._model_base import SqlAlchemyBase
from mealie.schema.admin import SiteSettings
from mealie.schema.user.user import GroupBase
from mealie.services.events import create_general_event
from mealie.services.group_services.group_utils import create_new_group
@@ -24,16 +23,10 @@ def create_all_models():
def init_db(db: Database) -> None:
default_group_init(db)
default_settings_init(db)
default_user_init(db)
default_recipe_unit_init(db)
def default_settings_init(db: Database):
document = db.settings.create(SiteSettings().dict())
logger.info(f"Created Site Settings: \n {document}")
def default_group_init(db: Database):
logger.info("Generating Default Group")
create_new_group(db, GroupBase(name=settings.DEFAULT_GROUP))

View File

@@ -2,6 +2,5 @@ from .event import *
from .group import *
from .recipe.recipe import *
from .server import *
from .settings import *
from .sign_up import *
from .users import *

View File

@@ -19,9 +19,6 @@ class BaseMixins:
`cls.get_ref` method which will return the object from the database or none. Useful for many-to-many relationships.
"""
class Config:
get_attr = "id"
def update(self, *args, **kwarg):
self.__init__(*args, **kwarg)

View File

@@ -1,119 +0,0 @@
from functools import wraps
from typing import Union
from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY
def handle_one_to_many_list(get_attr, relation_cls, all_elements: list[dict]):
elems_to_create = []
updated_elems = []
for elem in all_elements:
elem_id = elem.get(get_attr, None)
existing_elem = relation_cls.get_ref(match_value=elem_id)
if existing_elem is None:
elems_to_create.append(elem)
else:
for key, value in elem.items():
setattr(existing_elem, key, value)
updated_elems.append(existing_elem)
new_elems = []
for elem in elems_to_create:
new_elems = [relation_cls(**elem) for elem in all_elements]
return new_elems
def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics
"""Wraps the `__init__` method of a class to automatically set the common
attributes.
Args:
exclude (Union[set, list], optional): [description]. Defaults to None.
"""
exclude = exclude or set()
exclude.add("id")
def decorator(init):
@wraps(init)
def wrapper(self, *args, **kwargs): # sourcery no-metrics
"""
Custom initializer that allows nested children initialization.
Only keys that are present as instance's class attributes are allowed.
These could be, for example, any mapped columns or relationships.
Code inspired from GitHub.
Ref: https://github.com/tiangolo/fastapi/issues/2194
"""
cls = self.__class__
model_columns = self.__mapper__.columns
relationships = self.__mapper__.relationships
session = kwargs.get("session", None)
for key, val in kwargs.items():
if key in exclude:
continue
if not hasattr(cls, key):
continue
# raise TypeError(f"Invalid keyword argument: {key}")
if key in model_columns:
setattr(self, key, val)
continue
if key in relationships:
relation_dir = relationships[key].direction.name
relation_cls = relationships[key].mapper.entity
use_list = relationships[key].uselist
try:
get_attr = relation_cls.Config.get_attr
if get_attr is None:
get_attr = "id"
except Exception:
get_attr = "id"
if relation_dir == ONETOMANY.name and use_list:
instances = handle_one_to_many_list(get_attr, relation_cls, val)
setattr(self, key, instances)
if relation_dir == ONETOMANY.name and not use_list:
instance = relation_cls(**val)
setattr(self, key, instance)
elif relation_dir == MANYTOONE.name and not use_list:
if isinstance(val, dict):
val = val.get(get_attr)
if val is None:
raise ValueError(f"Expected 'id' to be provided for {key}")
if isinstance(val, (str, int)):
instance = relation_cls.get_ref(match_value=val, session=session)
setattr(self, key, instance)
elif relation_dir == MANYTOMANY.name:
if not isinstance(val, list):
raise ValueError(f"Expected many to many input to be of type list for {key}")
if len(val) > 0 and isinstance(val[0], dict):
val = [elem.get(get_attr) for elem in val]
instances = [x for x in [relation_cls.get_ref(elem, session=session) for elem in val] if x]
setattr(self, key, instances)
return init(self, *args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1 @@
from .auto_init import auto_init

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
from functools import wraps
from pydantic import BaseModel, Field
from sqlalchemy.orm import MANYTOMANY, MANYTOONE, ONETOMANY, Session
from sqlalchemy.orm.decl_api import DeclarativeMeta
from sqlalchemy.orm.mapper import Mapper
from sqlalchemy.orm.relationships import RelationshipProperty
from sqlalchemy.sql.base import ColumnCollection
from sqlalchemy.util._collections import ImmutableProperties
from .helpers import safe_call
def _default_exclusion() -> set[str]:
return {"id"}
class AutoInitConfig(BaseModel):
"""
Config class for `auto_init` decorator.
"""
get_attr: str = None
exclude: set = Field(default_factory=_default_exclusion)
# auto_create: bool = False
def _get_config(relation_cls: DeclarativeMeta) -> AutoInitConfig:
"""
Returns the config for the given class.
"""
cfg = AutoInitConfig()
cfgKeys = cfg.dict().keys()
# Get the config for the class
try:
class_config: AutoInitConfig = relation_cls.Config
except AttributeError:
return cfg
# Map all matching attributes in Config to all AutoInitConfig attributes
for attr in dir(class_config):
if attr in cfgKeys:
setattr(cfg, attr, getattr(class_config, attr))
return cfg
def get_lookup_attr(relation_cls: DeclarativeMeta) -> str:
"""Returns the primary key attribute of the related class as a string.
Args:
relation_cls (DeclarativeMeta): The SQLAlchemy class to get the primary_key from
Returns:
Any: [description]
"""
cfg = _get_config(relation_cls)
try:
get_attr = cfg.get_attr
if get_attr is None:
get_attr = relation_cls.__table__.primary_key.columns.keys()[0]
except Exception:
get_attr = "id"
return get_attr
def handle_many_to_many(session, get_attr, relation_cls, all_elements: list[dict]):
"""
Proxy call to `handle_one_to_many_list` for many-to-many relationships. Because functionally, they do the same
"""
return handle_one_to_many_list(session, get_attr, relation_cls, all_elements)
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict]):
elems_to_create: list[dict] = []
updated_elems: list[dict] = []
for elem in all_elements:
elem_id = elem.get(get_attr, None)
existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none()
if existing_elem is None:
elems_to_create.append(elem)
else:
for key, value in elem.items():
setattr(existing_elem, key, value)
updated_elems.append(existing_elem)
new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create]
return new_elems + updated_elems
def auto_init(): # sourcery no-metrics
"""Wraps the `__init__` method of a class to automatically set the common
attributes.
Args:
exclude (Union[set, list], optional): [description]. Defaults to None.
"""
def decorator(init):
@wraps(init)
def wrapper(self: DeclarativeMeta, *args, **kwargs): # sourcery no-metrics
"""
Custom initializer that allows nested children initialization.
Only keys that are present as instance's class attributes are allowed.
These could be, for example, any mapped columns or relationships.
Code inspired from GitHub.
Ref: https://github.com/tiangolo/fastapi/issues/2194
"""
cls = self.__class__
exclude = _get_config(cls).exclude
alchemy_mapper: Mapper = self.__mapper__
model_columns: ColumnCollection = alchemy_mapper.columns
relationships: ImmutableProperties = alchemy_mapper.relationships
session = kwargs.get("session", None)
if session is None:
raise ValueError("Session is required to initialize the model with `auto_init`")
for key, val in kwargs.items():
if key in exclude:
continue
if not hasattr(cls, key):
continue
# raise TypeError(f"Invalid keyword argument: {key}")
if key in model_columns:
setattr(self, key, val)
continue
if key in relationships:
prop: RelationshipProperty = relationships[key]
# Identifies the type of relationship (ONETOMANY, MANYTOONE, many-to-one, many-to-many)
relation_dir = prop.direction
# Identifies the parent class of the related object.
relation_cls: DeclarativeMeta = prop.mapper.entity
# Identifies if the relationship was declared with use_list=True
use_list: bool = prop.uselist
get_attr = get_lookup_attr(relation_cls)
if relation_dir == ONETOMANY and use_list:
instances = handle_one_to_many_list(session, get_attr, relation_cls, val)
setattr(self, key, instances)
elif relation_dir == ONETOMANY:
instance = safe_call(relation_cls, val)
setattr(self, key, instance)
elif relation_dir == MANYTOONE and not use_list:
if isinstance(val, dict):
val = val.get(get_attr)
if val is None:
raise ValueError(f"Expected 'id' to be provided for {key}")
if isinstance(val, (str, int)):
instance = session.query(relation_cls).filter_by(**{get_attr: val}).one_or_none()
setattr(self, key, instance)
elif relation_dir == MANYTOMANY:
instances = handle_many_to_many(session, get_attr, relation_cls, val)
setattr(self, key, instances)
return init(self, *args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,39 @@
import inspect
from typing import Any, Callable
def get_valid_call(func: Callable, args_dict) -> dict:
"""
Returns a dictionary of valid arguemnts for the supplied function. if kwargs are accepted,
the original dictionary will be returned.
"""
def get_valid_args(func: Callable) -> tuple:
"""
Returns a tuple of valid arguemnts for the supplied function.
"""
return inspect.getfullargspec(func).args
def accepts_kwargs(func: Callable) -> bool:
"""
Returns True if the function accepts keyword arguments.
"""
return inspect.getfullargspec(func).varkw is not None
if accepts_kwargs(func):
return args_dict
valid_args = get_valid_args(func)
return {k: v for k, v in args_dict.items() if k in valid_args}
def safe_call(func, dict) -> Any:
"""
Safely calls the supplied function with the supplied dictionary of arguments.
by removing any invalid arguments.
"""
try:
return func(**get_valid_call(func, dict))
except TypeError:
return func(**dict)

View File

@@ -46,7 +46,10 @@ class Group(SqlAlchemyBase, BaseMixins):
server_tasks = orm.relationship(ServerTaskModel, back_populates="group", single_parent=True)
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens", "mealplans"})
class Config:
exclude = {"users", "webhooks", "shopping_lists", "cookbooks", "preferences", "invite_tokens", "mealplans"}
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@@ -11,12 +11,7 @@ class RecipeAsset(SqlAlchemyBase):
icon = sa.Column(sa.String)
file_name = sa.Column(sa.String)
def __init__(
self,
name=None,
icon=None,
file_name=None,
) -> None:
def __init__(self, name=None, icon=None, file_name=None) -> None:
self.name = name
self.file_name = file_name
self.icon = icon

View File

@@ -8,12 +8,6 @@ from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
logger = root_logger.get_logger()
site_settings2categories = sa.Table(
"site_settings2categories",
SqlAlchemyBase.metadata,
sa.Column("site_settings.id", sa.Integer, sa.ForeignKey("site_settings.id")),
sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id")),
)
group2categories = sa.Table(
"group2categories",

View File

@@ -86,14 +86,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
class Config:
get_attr = "slug"
@validates("name")
def validate_name(self, key, name):
assert name != ""
return name
@auto_init(
{
exclude = {
"assets",
"extras",
"notes",
@@ -103,7 +96,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
"settings",
"tools",
}
)
@validates("name")
def validate_name(self, key, name):
assert name != ""
return name
@auto_init()
def __init__(
self,
session,
@@ -115,7 +114,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
recipe_instructions: list[dict] = None,
settings: dict = None,
tools: list[str] = None,
**_
**_,
) -> None:
self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition()
self.tools = [Tool(tool=x) for x in tools] if tools else []

View File

@@ -1,35 +0,0 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from sqlalchemy.orm import Session
from mealie.db.models._model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.category import Category, site_settings2categories
class SiteSettings(SqlAlchemyBase, BaseMixins):
__tablename__ = "site_settings"
id = sa.Column(sa.Integer, primary_key=True)
language = sa.Column(sa.String)
first_day_of_week = sa.Column(sa.Integer)
categories = orm.relationship("Category", secondary=site_settings2categories, single_parent=True)
show_recent = sa.Column(sa.Boolean, default=True)
cards_per_section = sa.Column(sa.Integer)
def __init__(
self,
session: Session = None,
language="en",
first_day_of_week: int = 0,
categories: list = [],
show_recent=True,
cards_per_section: int = 9,
) -> None:
session.commit()
self.language = language
self.first_day_of_week = first_day_of_week
self.cards_per_section = cards_per_section
self.show_recent = show_recent
self.categories = [Category.get_ref(session=session, slug=cat.get("slug")) for cat in categories]
def update(self, *args, **kwarg):
self.__init__(*args, **kwarg)

View File

@@ -11,7 +11,6 @@ settings = get_app_settings()
class LongLiveToken(SqlAlchemyBase, BaseMixins):
__tablename__ = "long_live_tokens"
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey("users.id"))
name = Column(String, nullable=False)
token = Column(String, nullable=False)
@@ -25,7 +24,6 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins):
class User(SqlAlchemyBase, BaseMixins):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
full_name = Column(String, index=True)
username = Column(String, index=True, unique=True)
email = Column(String, unique=True, index=True)
@@ -41,7 +39,6 @@ class User(SqlAlchemyBase, BaseMixins):
can_invite = Column(Boolean, default=False)
can_organize = Column(Boolean, default=False)
# Recipes
tokens: list[LongLiveToken] = orm.relationship(
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
)
@@ -67,67 +64,52 @@ class User(SqlAlchemyBase, BaseMixins):
password,
favorite_recipes: list[str] = None,
group: str = settings.DEFAULT_GROUP,
admin=False,
advanced=False,
can_manage=False,
can_invite=False,
can_organize=False,
**_
**kwargs
) -> None:
group = group or settings.DEFAULT_GROUP
favorite_recipes = favorite_recipes or []
self.group = Group.get_ref(session, group)
self.full_name = full_name
self.email = email
self.group = Group.get_ref(session, group)
self.admin = admin
self.password = password
self.advanced = advanced
if self.admin:
self.can_manage = True
self.can_invite = True
self.can_organize = True
else:
self.can_manage = can_manage
self.can_invite = can_invite
self.can_organize = can_organize
self.favorite_recipes = []
if self.username is None:
self.username = full_name
def update(
self,
full_name,
email,
group,
admin,
username,
session=None,
favorite_recipes=None,
password=None,
advanced=False,
can_manage=False,
can_invite=False,
can_organize=False,
**_
):
self._set_permissions(**kwargs)
def update(self, full_name, email, group, username, session=None, favorite_recipes=None, advanced=False, **kwargs):
favorite_recipes = favorite_recipes or []
self.username = username
self.full_name = full_name
self.email = email
self.group = Group.get_ref(session, group)
self.admin = admin
self.advanced = advanced
if self.username is None:
self.username = full_name
if password:
self.password = password
self._set_permissions(**kwargs)
def update_password(self, password):
self.password = password
def _set_permissions(self, admin, can_manage=False, can_invite=False, can_organize=False, **_):
"""Set user permissions based on the admin flag and the passed in kwargs
Args:
admin (bool):
can_manage (bool):
can_invite (bool):
can_organize (bool):
"""
self.admin = admin
if self.admin:
self.can_manage = True
self.can_invite = True
@@ -137,9 +119,6 @@ class User(SqlAlchemyBase, BaseMixins):
self.can_invite = can_invite
self.can_organize = can_organize
def update_password(self, password):
self.password = password
@staticmethod
def get_ref(session, id: str):
return session.query(User).filter(User.id == id).one()

View File

@@ -5,7 +5,6 @@ from mealie.core.dependencies import get_current_user
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import AdminAPIRouter
from mealie.schema.admin import SiteSettings
from mealie.schema.user import GroupInDB, PrivateUser
from mealie.utils.post_webhooks import post_webhooks
@@ -21,16 +20,6 @@ def get_main_settings(session: Session = Depends(generate_session)):
return db.settings.get(1)
@admin_router.put("")
def update_settings(
data: SiteSettings,
session: Session = Depends(generate_session),
):
""" Returns Site Settings """
db = get_database(session)
db.settings.update(1, data.dict())
@admin_router.post("/webhooks/test")
def test_webhooks(
current_user: PrivateUser = Depends(get_current_user),

View File

@@ -24,10 +24,9 @@ async def reset_user_password(id: int, session: Session = Depends(generate_sessi
db.users.update_password(id, new_password)
@user_router.put("/{id}/password")
@user_router.put("/{item_id}/password")
def update_password(password_change: ChangePassword, user_service: UserService = Depends(UserService.write_existing)):
""" Resets the User Password"""
return user_service.change_password(password_change)

View File

@@ -4,31 +4,7 @@ from fastapi_camelcase import CamelModel
from pydantic import validator
from slugify import slugify
from ..recipe.recipe_category import CategoryBase, RecipeCategoryResponse
class SiteSettings(CamelModel):
language: str = "en-US"
first_day_of_week: int = 0
show_recent: bool = True
cards_per_section: int = 9
categories: Optional[list[CategoryBase]] = []
class Config:
orm_mode = True
schema_extra = {
"example": {
"language": "en",
"firstDayOfWeek": 0,
"showRecent": True,
"categories": [
{"id": 1, "name": "thanksgiving", "slug": "thanksgiving"},
{"id": 2, "name": "homechef", "slug": "homechef"},
{"id": 3, "name": "potatoes", "slug": "potatoes"},
],
}
}
from ..recipe.recipe_category import RecipeCategoryResponse
class CustomPageBase(CamelModel):

View File

@@ -11,15 +11,7 @@ from mealie.core.config import get_app_dirs
app_dirs = get_app_dirs()
from mealie.db.database import get_database
from mealie.schema.admin import (
CommentImport,
GroupImport,
NotificationImport,
RecipeImport,
SettingsImport,
SiteSettings,
UserImport,
)
from mealie.schema.admin import CommentImport, GroupImport, NotificationImport, RecipeImport, UserImport
from mealie.schema.events import EventNotificationIn
from mealie.schema.recipe import CommentOut, Recipe
from mealie.schema.user import PrivateUser, UpdateGroup
@@ -181,19 +173,7 @@ class ImportDatabase:
return import_notifications
def import_settings(self):
settings_file = self.import_dir.joinpath("settings", "settings.json")
settings = ImportDatabase.read_models_file(settings_file, SiteSettings)
settings = settings[0]
try:
self.db.settings.update(1, settings.dict())
import_status = SettingsImport(name="Site Settings", status=True)
except Exception as inst:
self.session.rollback()
import_status = SettingsImport(name="Site Settings", status=False, exception=str(inst))
return [import_status]
return []
def import_groups(self):
groups_file = self.import_dir.joinpath("groups", "groups.json")

View File

@@ -181,7 +181,7 @@ class MigrationBase(BaseModel):
except Exception as inst:
exception = inst
logger.error(inst)
logger.exception(inst)
self.session.rollback()
import_status = MigrationImport(slug=recipe.slug, name=recipe.name, status=status, exception=str(exception))

View File

@@ -13,7 +13,12 @@ class UserService(UserHttpService[int, str]):
event_func = create_user_event
acting_user: PrivateUser = None
def populate_item(self, item_id: int) -> None:
self.acting_user = self.db.users.get_one(item_id)
return self.acting_user
def assert_existing(self, id) -> PrivateUser:
self.populate_item(id)
self._populate_target_user(id)
self._assert_user_change_allowed()
return self.target_user
@@ -32,7 +37,6 @@ class UserService(UserHttpService[int, str]):
self.target_user = self.acting_user
def change_password(self, password_change: ChangePassword) -> PrivateUser:
""""""
if not verify_password(password_change.current_password, self.target_user.password):
raise HTTPException(status.HTTP_400_BAD_REQUEST)