feat(backend): migrate site-settings to groups (#673)

* feat(frontend):  add user registration page (WIP)

* feat(backend):  add user registration (WIP)

* test(backend):  add validator testing for registration schema

* feat(backend):  continued work on user sign-up

* feat(backend):  add signup flow and user/group settings

* test(backend):  user-creation tests and small refactor of existing tests

* fix(backend):  fix failing group tests

* style: 🎨 fix lint issues

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-09-05 22:05:29 -08:00
committed by GitHub
parent e179dcdb10
commit 3c504e7048
63 changed files with 1665 additions and 841 deletions

View File

@@ -6,6 +6,7 @@ from mealie.db.data_access_layer.group_access_model import GroupDataAccessModel
from mealie.db.models.event import Event, EventNotification
from mealie.db.models.group import Group
from mealie.db.models.group.cookbook import CookBook
from mealie.db.models.group.preferences import GroupPreferencesModel
from mealie.db.models.group.shopping_list import ShoppingList
from mealie.db.models.group.webhooks import GroupWebhooksModel
from mealie.db.models.mealplan import MealPlan
@@ -20,6 +21,7 @@ from mealie.schema.admin import SiteSettings as SiteSettingsSchema
from mealie.schema.cookbook import ReadCookBook
from mealie.schema.events import Event as EventSchema
from mealie.schema.events import EventNotificationIn
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.group.webhook import ReadWebhook
from mealie.schema.meal_plan import MealPlanOut, ShoppingListOut
from mealie.schema.recipe import (
@@ -89,3 +91,4 @@ class DatabaseAccessLayer:
self.webhooks = BaseAccessModel(pk_id, GroupWebhooksModel, ReadWebhook)
self.shopping_lists = BaseAccessModel(pk_id, ShoppingList, ShoppingListOut)
self.cookbooks = BaseAccessModel(pk_id, CookBook, ReadCookBook)
self.group_preferences = BaseAccessModel("group_id", GroupPreferencesModel, ReadGroupPreferences)

View File

@@ -24,13 +24,11 @@ def default_recipe_unit_init(db: DatabaseAccessLayer, session: Session) -> None:
for unit in get_default_units():
try:
db.ingredient_units.create(session, unit)
print("Ingredient Unit Created")
except Exception as e:
print(e)
for food in get_default_foods():
try:
db.ingredient_foods.create(session, food)
print("Ingredient Food Created")
except Exception as e:
print(e)

View File

@@ -8,7 +8,9 @@ from mealie.db.database import db
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_mixins import create_new_group
logger = root_logger.get_logger("init_db")
@@ -38,9 +40,8 @@ def default_settings_init(session: Session):
def default_group_init(session: Session):
default_group = {"name": settings.DEFAULT_GROUP}
logger.info("Generating Default Group")
db.groups.create(session, default_group)
create_new_group(session, GroupBase(name=settings.DEFAULT_GROUP))
def default_user_init(session: Session):

View File

@@ -82,8 +82,6 @@ def auto_init(exclude: Union[set, list] = None): # sourcery no-metrics
except Exception:
get_attr = "id"
print(get_attr)
if relation_dir == ONETOMANY.name and use_list:
instances = handle_one_to_many_list(get_attr, relation_cls, val)
setattr(self, key, instances)

View File

@@ -9,6 +9,7 @@ from .._model_utils import auto_init
from ..group.webhooks import GroupWebhooksModel
from ..recipe.category import Category, group2categories
from .cookbook import CookBook
from .preferences import GroupPreferencesModel
class Group(SqlAlchemyBase, BaseMixins):
@@ -16,7 +17,15 @@ class Group(SqlAlchemyBase, BaseMixins):
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")
categories = orm.relationship(Category, secondary=group2categories, single_parent=True)
categories = orm.relationship(Category, secondary=group2categories, single_parent=True, uselist=True)
preferences = orm.relationship(
GroupPreferencesModel,
back_populates="group",
uselist=False,
single_parent=True,
cascade="all, delete-orphan",
)
# CRUD From Others
mealplans = orm.relationship("MealPlan", back_populates="group", single_parent=True, order_by="MealPlan.start_date")
@@ -24,7 +33,7 @@ class Group(SqlAlchemyBase, BaseMixins):
cookbooks = orm.relationship(CookBook, back_populates="group", single_parent=True)
shopping_lists = orm.relationship("ShoppingList", back_populates="group", single_parent=True)
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks"})
@auto_init({"users", "webhooks", "shopping_lists", "cookbooks", "preferences"})
def __init__(self, **_) -> None:
pass

View File

@@ -0,0 +1,26 @@
import sqlalchemy as sa
import sqlalchemy.orm as orm
from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
class GroupPreferencesModel(SqlAlchemyBase, BaseMixins):
__tablename__ = "group_preferences"
group_id = sa.Column(sa.Integer, sa.ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="preferences")
private_group: bool = sa.Column(sa.Boolean, default=True)
first_day_of_week = sa.Column(sa.Integer, default=0)
# Recipe Defaults
recipe_public: bool = sa.Column(sa.Boolean, default=True)
recipe_show_nutrition: bool = sa.Column(sa.Boolean, default=False)
recipe_show_assets: bool = sa.Column(sa.Boolean, default=False)
recipe_landscape_view: bool = sa.Column(sa.Boolean, default=False)
recipe_disable_comments: bool = sa.Column(sa.Boolean, default=False)
recipe_disable_amount: bool = sa.Column(sa.Boolean, default=False)
@auto_init()
def __init__(self, **_) -> None:
pass

View File

@@ -28,6 +28,7 @@ class User(SqlAlchemyBase, BaseMixins):
email = Column(String, unique=True, index=True)
password = Column(String)
admin = Column(Boolean, default=False)
advanced = Column(Boolean, default=False)
group_id = Column(Integer, ForeignKey("groups.id"))
group = orm.relationship("Group", back_populates="users")
@@ -51,6 +52,7 @@ class User(SqlAlchemyBase, BaseMixins):
favorite_recipes: list[str] = None,
group: str = settings.DEFAULT_GROUP,
admin=False,
advanced=False,
**_
) -> None:
@@ -61,6 +63,7 @@ class User(SqlAlchemyBase, BaseMixins):
self.group = Group.get_ref(session, group)
self.admin = admin
self.password = password
self.advanced = advanced
self.favorite_recipes = [
RecipeModel.get_ref(session=session, match_value=x, match_attr="slug") for x in favorite_recipes
@@ -69,13 +72,26 @@ class User(SqlAlchemyBase, BaseMixins):
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, **_):
def update(
self,
full_name,
email,
group,
admin,
username,
session=None,
favorite_recipes=None,
password=None,
advanced=False,
**_
):
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

View File

@@ -1,7 +1,6 @@
import operator
import shutil
from pathlib import Path
from pprint import pprint
from fastapi import BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm.session import Session
@@ -97,8 +96,6 @@ def import_database(
rebase=import_data.rebase,
)
pprint(db_import)
background_tasks.add_task(create_backup_event, "Database Restore", f"Restore File: {file_name}", session)
return db_import

View File

@@ -1,8 +1,7 @@
from fastapi import APIRouter
from mealie.services.base_http_service import RouterFactory
from mealie.services.cookbook.cookbook_service import CookbookService
from mealie.services.group.webhook_service import WebhookService
from mealie.services._base_http_service import RouterFactory
from mealie.services.group_services import CookbookService, WebhookService
from . import categories, crud, self_service

View File

@@ -2,7 +2,7 @@ from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.services.group.group_service import GroupSelfService
from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/categories", tags=["Groups: Mealplan Categories"])

View File

@@ -1,14 +1,27 @@
from fastapi import Depends
from mealie.routes.routers import UserAPIRouter
from mealie.schema.group.group_preferences import ReadGroupPreferences, UpdateGroupPreferences
from mealie.schema.user.user import GroupInDB
from mealie.services.group.group_service import GroupSelfService
from mealie.services.group_services.group_service import GroupSelfService
user_router = UserAPIRouter(prefix="/groups/self", tags=["Groups: Self Service"])
user_router = UserAPIRouter(prefix="/groups", tags=["Groups: Self Service"])
@user_router.get("", response_model=GroupInDB)
async def get_logged_in_user_group(g_self_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
@user_router.get("/self", response_model=GroupInDB)
async def get_logged_in_user_group(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
""" Returns the Group Data for the Current User """
return g_self_service.item
return g_service.item
@user_router.put("/preferences", response_model=ReadGroupPreferences)
def update_group_preferences(
new_pref: UpdateGroupPreferences, g_service: GroupSelfService = Depends(GroupSelfService.write_existing)
):
return g_service.update_preferences(new_pref).preferences
@user_router.get("/preferences", response_model=ReadGroupPreferences)
def get_group_preferences(g_service: GroupSelfService = Depends(GroupSelfService.write_existing)):
return g_service.item.preferences

View File

@@ -1,12 +1,14 @@
from fastapi import APIRouter
from . import api_tokens, crud, favorites, images, passwords, sign_up
from . import api_tokens, crud, favorites, images, passwords, registration, sign_up
# Must be used because of the way FastAPI works with nested routes
user_prefix = "/users"
router = APIRouter()
router.include_router(registration.router, prefix=user_prefix, tags=["Users: Registration"])
router.include_router(sign_up.admin_router, prefix=user_prefix, tags=["Users: Sign-Up"])
router.include_router(sign_up.public_router, prefix=user_prefix, tags=["Users: Sign-Up"])

View File

@@ -7,7 +7,7 @@ from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter
from mealie.schema.user import ChangePassword
from mealie.services.user.user_service import UserService
from mealie.services.user_services import UserService
user_router = UserAPIRouter(prefix="")

View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends, status
from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import UserOut
from mealie.services.user_services.registration_service import RegistrationService
router = APIRouter(prefix="/register")
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def reset_user_password(
data: CreateUserRegistration, registration_service: RegistrationService = Depends(RegistrationService.public)
):
return registration_service.register_user(data)

View File

@@ -0,0 +1,25 @@
from fastapi_camelcase import CamelModel
class UpdateGroupPreferences(CamelModel):
private_group: bool = False
first_day_of_week: int = 0
# Recipe Defaults
recipe_public: bool = True
recipe_show_nutrition: bool = False
recipe_show_assets: bool = False
recipe_landscape_view: bool = False
recipe_disable_comments: bool = False
recipe_disable_amount: bool = False
class CreateGroupPreferences(UpdateGroupPreferences):
group_id: int
class ReadGroupPreferences(CreateGroupPreferences):
id: int
class Config:
orm_mode = True

View File

@@ -0,0 +1,29 @@
from fastapi_camelcase import CamelModel
from pydantic import validator
from pydantic.types import constr
class CreateUserRegistration(CamelModel):
group: str = None
group_token: str = None
email: constr(to_lower=True, strip_whitespace=True)
username: constr(to_lower=True, strip_whitespace=True)
password: str
password_confirm: str
advanced: bool = False
private: bool = False
@validator("password_confirm")
@classmethod
def passwords_match(cls, value, values):
if "password" in values and value != values["password"]:
raise ValueError("passwords do not match")
return value
@validator("group_token", always=True)
@classmethod
def group_or_token(cls, value, values):
if bool(value) is False and bool(values["group"]) is False:
raise ValueError("group or group_token must be provided")
return value

View File

@@ -6,8 +6,8 @@ from pydantic.types import constr
from pydantic.utils import GetterDict
from mealie.core.config import settings
from mealie.db.models.group import Group
from mealie.db.models.users import User
from mealie.schema.group.group_preferences import ReadGroupPreferences
from mealie.schema.recipe import RecipeSummary
from ..meal_plan import MealPlanOut, ShoppingListOut
@@ -50,8 +50,9 @@ class UserBase(CamelModel):
username: Optional[str]
full_name: Optional[str] = None
email: constr(to_lower=True, strip_whitespace=True)
admin: bool
admin: bool = False
group: Optional[str]
advanced: bool = False
favorite_recipes: Optional[list[str]] = []
class Config:
@@ -128,16 +129,11 @@ class GroupInDB(UpdateGroup):
users: Optional[list[UserOut]]
mealplans: Optional[list[MealPlanOut]]
shopping_lists: Optional[list[ShoppingListOut]]
preferences: Optional[ReadGroupPreferences] = None
class Config:
orm_mode = True
@classmethod
def getter_dict(_cls, orm_model: Group):
return {
**GetterDict(orm_model),
}
class LongLiveTokenInDB(CreateToken):
id: int

View File

@@ -1 +0,0 @@
from .cookbook_service import *

View File

@@ -1,2 +1,3 @@
from .cookbook_service import *
from .group_service import *
from .webhook_service import *

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from mealie.core.root_logger import get_logger
from mealie.db.database import get_database
from mealie.schema.cookbook.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
from mealie.utils.error_messages import ErrorMessages

View File

@@ -0,0 +1,16 @@
from mealie.db.database import get_database
from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.user import GroupBase, GroupInDB
def create_new_group(session, g_base: GroupBase, g_preferences: CreateGroupPreferences = None) -> GroupInDB:
db = get_database()
created_group = db.groups.create(session, g_base)
g_preferences = g_preferences or CreateGroupPreferences(group_id=0)
g_preferences.group_id = created_group.id
db.group_preferences.create(session, g_preferences)
return created_group

View File

@@ -4,9 +4,10 @@ from fastapi import Depends, HTTPException, status
from mealie.core.dependencies.grouped import UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.group.group_preferences import UpdateGroupPreferences
from mealie.schema.recipe.recipe_category import CategoryBase
from mealie.schema.user.user import GroupInDB
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)
@@ -41,8 +42,11 @@ class GroupSelfService(UserHttpService[int, str]):
return self.item
def update_categories(self, new_categories: list[CategoryBase]):
if not self.item:
return
self.item.categories = new_categories
return self.db.groups.update(self.session, self.group_id, self.item)
def update_preferences(self, new_preferences: UpdateGroupPreferences):
self.db.group_preferences.update(self.session, self.group_id, new_preferences)
return self.populate_item()

View File

@@ -5,7 +5,7 @@ from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.schema.group import ReadWebhook
from mealie.schema.group.webhook import CreateWebhook, SaveWebhook
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_group_event
logger = get_logger(module=__name__)

View File

@@ -8,7 +8,7 @@ from sqlalchemy.exc import IntegrityError
from mealie.core.dependencies.grouped import PublicDeps, UserDeps
from mealie.core.root_logger import get_logger
from mealie.schema.recipe.recipe import CreateRecipe, Recipe
from mealie.services.base_http_service.http_services import PublicHttpService
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.services.events import create_recipe_event
logger = get_logger(module=__name__)

View File

@@ -55,7 +55,6 @@ def _exec_crf_test(input_text):
def convert_list_to_crf_model(list_of_ingrdeint_text: list[str]):
print(list_of_ingrdeint_text)
crf_output = _exec_crf_test([pre_process_string(x) for x in list_of_ingrdeint_text])
crf_models = [CRFIngredient(**ingredient) for ingredient in utils.import_data(crf_output.split("\n"))]
@@ -82,6 +81,3 @@ def convert_crf_models_to_ingredients(crf_models: list[CRFIngredient]):
if __name__ == "__main__":
crf_models = convert_list_to_crf_model(INGREDIENT_TEXT)
ingredients = convert_crf_models_to_ingredients(crf_models)
for ingredient in ingredients:
print(ingredient.input)

View File

@@ -0,0 +1 @@
from .user_service import *

View File

@@ -0,0 +1,61 @@
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password
from mealie.schema.group.group_preferences import CreateGroupPreferences
from mealie.schema.user.registration import CreateUserRegistration
from mealie.schema.user.user import GroupBase, GroupInDB, PrivateUser, UserIn
from mealie.services._base_http_service.http_services import PublicHttpService
from mealie.services.events import create_user_event
from mealie.services.group_services.group_mixins import create_new_group
logger = get_logger(module=__name__)
class RegistrationService(PublicHttpService[int, str]):
event_func = create_user_event
def populate_item() -> None:
pass
def register_user(self, registration: CreateUserRegistration) -> PrivateUser:
self.registration = registration
logger.info(f"Registering user {registration.username}")
if registration.group:
group = self._create_new_group()
else:
group = self._existing_group_ref()
return self._create_new_user(group)
def _create_new_user(self, group: GroupInDB) -> PrivateUser:
new_user = UserIn(
email=self.registration.email,
username=self.registration.username,
password=hash_password(self.registration.password),
full_name=self.registration.username,
advanced=self.registration.advanced,
group=group.name,
)
return self.db.users.create(self.session, new_user)
def _create_new_group(self) -> GroupInDB:
group_data = GroupBase(name=self.registration.group)
group_preferences = CreateGroupPreferences(
group_id=0,
private_group=self.registration.private,
first_day_of_week=0,
recipe_public=not self.registration.private,
recipe_show_nutrition=self.registration.advanced,
recipe_show_assets=self.registration.advanced,
recipe_landscape_view=False,
recipe_disable_comments=self.registration.advanced,
recipe_disable_amount=self.registration.advanced,
)
return create_new_group(self.session, group_data, group_preferences)
def _existing_group_ref(self) -> GroupInDB:
pass

View File

@@ -3,7 +3,7 @@ from fastapi import HTTPException, status
from mealie.core.root_logger import get_logger
from mealie.core.security import hash_password, verify_password
from mealie.schema.user.user import ChangePassword, PrivateUser
from mealie.services.base_http_service.http_services import UserHttpService
from mealie.services._base_http_service.http_services import UserHttpService
from mealie.services.events import create_user_event
logger = get_logger(module=__name__)