diff --git a/frontend/src/components/Admin/Backup/ImportDialog.vue b/frontend/src/components/Admin/Backup/ImportDialog.vue index 4f2a1be32..497d77404 100644 --- a/frontend/src/components/Admin/Backup/ImportDialog.vue +++ b/frontend/src/components/Admin/Backup/ImportDialog.vue @@ -23,42 +23,15 @@ - - - - - - + + + + + diff --git a/mealie/app.py b/mealie/app.py index b770d0709..f4faeafd1 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -18,6 +18,7 @@ app = FastAPI( redoc_url=redoc_url, ) + def start_scheduler(): import mealie.services.scheduler.scheduled_jobs diff --git a/mealie/db/models/group.py b/mealie/db/models/group.py index 719c02202..07b7763f5 100644 --- a/mealie/db/models/group.py +++ b/mealie/db/models/group.py @@ -57,7 +57,12 @@ class Group(SqlAlchemyBase, BaseMixins): @staticmethod def get_ref(session: Session, name: str): - return session.query(Group).filter(Group.name == name).one() + item = session.query(Group).filter(Group.name == name).one() + if item: + return item + + else: + return session.query(Group).filter(Group.id == 1).one() @staticmethod def create_if_not_exist(session, name: str = DEFAULT_GROUP): diff --git a/mealie/db/models/users.py b/mealie/db/models/users.py index 4df95e9ec..bcd02cfe6 100644 --- a/mealie/db/models/users.py +++ b/mealie/db/models/users.py @@ -34,14 +34,14 @@ class User(SqlAlchemyBase, BaseMixins): group = group if group else DEFAULT_GROUP self.full_name = full_name self.email = email - self.group = Group.create_if_not_exist(session, group) + self.group = Group.get_ref(session, group) self.admin = admin self.password = password def update(self, full_name, email, group, admin, session=None, id=None, password=None): self.full_name = full_name self.email = email - self.group = Group.create_if_not_exist(session, group) + self.group = Group.get_ref(session, group) self.admin = admin if password: diff --git a/mealie/schema/restore.py b/mealie/schema/restore.py index e931d80d0..f57fdd3fa 100644 --- a/mealie/schema/restore.py +++ b/mealie/schema/restore.py @@ -10,7 +10,7 @@ class ImportBase(BaseModel): class RecipeImport(ImportBase): - slug: str + slug: Optional[str] class ThemeImport(ImportBase): diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index 4878d7808..9a8b18c27 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -1,8 +1,9 @@ import json import shutil import zipfile +from logging import exception from pathlib import Path -from typing import List +from typing import Callable, List from fastapi.logger import logger from mealie.core.config import BACKUP_DIR, IMG_DIR, TEMP_DIR @@ -11,6 +12,8 @@ from mealie.schema.recipe import Recipe from mealie.schema.restore import GroupImport, RecipeImport, SettingsImport, ThemeImport, UserImport from mealie.schema.theme import SiteTheme from mealie.schema.user import UpdateGroup, UserInDB +from pydantic.main import BaseModel +from schema.settings import SiteSettings from sqlalchemy.orm.session import Session @@ -50,35 +53,24 @@ class ImportDatabase: imports = [] successful_imports = [] - def read_recipe_file(file_path: Path): - with open(file_path, "r") as f: - try: - recipe_dict = json.loads(f.read()) - recipe_dict = ImportDatabase._recipe_migration(recipe_dict) - return Recipe(**recipe_dict) - except: - import_status = RecipeImport(name=file_path.stem, slug=file_path.stem, status=False) - imports.append(import_status) - - recipes = [read_recipe_file(r) for r in recipe_dir.glob("*.json")] + recipes = ImportDatabase.read_models_file( + file_path=recipe_dir, model=Recipe, single_file=False, migrate=ImportDatabase._recipe_migration + ) for recipe in recipes: - try: - db.recipes.create(self.session, recipe.dict()) - import_status = RecipeImport(name=recipe.name, slug=recipe.slug, status=True) - successful_imports.append(recipe.slug) - logger.info(f"Imported: {recipe.slug}") + recipe: Recipe - except Exception as inst: - self.session.rollback() - logger.error(inst) - logger.info(f"Failed Import: {recipe.slug}") - import_status = RecipeImport( - name=recipe.name, - slug=recipe.slug, - status=False, - exception=str(inst), - ) + import_status = self.import_model( + db_table=db.recipes, + model=recipe, + return_model=RecipeImport, + name_attr="name", + search_key="slug", + slug=recipe.slug, + ) + + if import_status.status: + successful_imports.append(recipe.slug) imports.append(import_status) @@ -122,117 +114,156 @@ class ImportDatabase: def import_themes(self): themes_file = self.import_dir.joinpath("themes", "themes.json") - if not themes_file.exists(): - return [] - + themes = ImportDatabase.read_models_file(themes_file, SiteTheme) theme_imports = [] - with open(themes_file, "r") as f: - themes: list[dict] = json.loads(f.read()) - themes: list[SiteTheme] = [SiteTheme(**theme) for theme in themes] for theme in themes: if theme.name == "default": continue - item = db.themes.get(self.session, theme.name) - if item: - import_status = UserImport(name=theme.name, status=False, exception="Theme Exists") - theme_imports.append(import_status) - continue - try: - db.themes.create(self.session, theme.dict()) - theme_imports.append(ThemeImport(name=theme.name, status=True)) - except Exception as inst: - logger.info(f"Unable Import Theme {theme.name}") - theme_imports.append(ThemeImport(name=theme.name, status=False, exception=str(inst))) + + import_status = self.import_model( + db_table=db.themes, + model=theme, + return_model=ThemeImport, + name_attr="name", + search_key="name", + ) + + theme_imports.append(import_status) return theme_imports - def import_settings(self): + def import_settings(self): #! Broken settings_file = self.import_dir.joinpath("settings", "settings.json") - if not settings_file.exists(): - return [] + settings = ImportDatabase.read_models_file(settings_file, SiteSettings) + settings = settings[0] - settings_imports = [] + try: + db.settings.update(self.session, 1, settings.dict()) + import_status = SettingsImport(name="Site Settings", status=True) - with open(settings_file, "r") as f: - settings: dict = json.loads(f.read()) + except Exception as inst: + self.session.rollback() + import_status = SettingsImport(name="Site Settings", status=False, exception=str(inst)) - name = settings.get("name") - - try: - db.settings.update(self.session, name, settings) - import_status = SettingsImport(name=name, status=True) - - except Exception as inst: - self.session.rollback() - import_status = SettingsImport(name=name, status=False, exception=str(inst)) - - settings_imports.append(import_status) - return settings_imports + return [import_status] def import_groups(self): groups_file = self.import_dir.joinpath("groups", "groups.json") - if not groups_file.exists(): - return [] - + groups = ImportDatabase.read_models_file(groups_file, UpdateGroup) group_imports = [] - with open(groups_file, "r") as f: - groups = [UpdateGroup(**g) for g in json.loads(f.read())] - for group in groups: - item = db.groups.get(self.session, group.name, "name") - if item: - import_status = GroupImport(name=group.name, status=False, exception="Group Exists") - group_imports.append(import_status) - continue - try: - db.groups.create(self.session, group.dict()) - import_status = GroupImport(name=group.name, status=True) - - except Exception as inst: - self.session.rollback() - import_status = GroupImport(name=group.name, status=False, exception=str(inst)) - + import_status = self.import_model(db.groups, group, GroupImport, search_key="name") group_imports.append(import_status) return group_imports def import_users(self): users_file = self.import_dir.joinpath("users", "users.json") - if not users_file.exists(): - return [] - + users = ImportDatabase.read_models_file(users_file, UserInDB) user_imports = [] - - with open(users_file, "r") as f: - users = [UserInDB(**g) for g in json.loads(f.read())] - for user in users: - if user.id == 1: + if user.id == 1: # Update Default User db.users.update(self.session, 1, user.dict()) import_status = UserImport(name=user.full_name, status=True) user_imports.append(import_status) continue - item = db.users.get(self.session, user.email, "email") - if item: - import_status = UserImport(name=user.full_name, status=False, exception="User Email Exists") - user_imports.append(import_status) - continue - - try: - db.users.create(self.session, user.dict()) - import_status = UserImport(name=user.full_name, status=True) - - except Exception as inst: - self.session.rollback() - import_status = UserImport(name=user.full_name, status=False, exception=str(inst)) + import_status = self.import_model( + db_table=db.users, + model=user, + return_model=UserImport, + name_attr="full_name", + search_key="email", + ) user_imports.append(import_status) return user_imports + @staticmethod + def read_models_file(file_path: Path, model: BaseModel, single_file=True, migrate: Callable = None): + """A general purpose function that is used to process a backup `.json` file created by mealie + note that if the file doesn't not exists the function will return any empty list + + Args: + file_path (Path): The path to the .json file or directory + model (BaseModel): The pydantic model that will be created from the .json file entries + single_file (bool, optional): If true, the json data will be treated as list, if false it will use glob style matches and treat each file as its own entry. Defaults to True. + migrate (Callable, optional): A migrate function that will be called on the data prior to creating a model. Defaults to None. + + Returns: + [type]: [description] + """ + if not file_path.exists(): + return [] + + if single_file: + with open(file_path, "r") as f: + file_data = json.loads(f.read()) + + if migrate: + file_data = [migrate(x) for x in file_data] + + return [model(**g) for g in file_data] + + all_models = [] + for file in file_path.glob("*.json"): + with open(file, "r") as f: + file_data = json.loads(f.read()) + + if migrate: + file_data = migrate(file_data) + + all_models.append(model(**file_data)) + + return all_models + + def import_model(self, db_table, model, return_model, name_attr="name", search_key="id", **kwargs): + """A general purpose function used to insert a list of pydantic modelsi into the database. + The assumption at this point is that the models that are inserted. If self.force_imports is true + any existing entries will be removed prior to creation + + Args: + db_table ([type]): A database table like `db.users` + model ([type]): The Pydantic model that matches the database + return_model ([type]): The return model that will be used for the 'report' + name_attr (str, optional): The name property on the return model. Defaults to "name". + search_key (str, optional): The key used to identify if an the entry already exists. Defaults to "id" + **kwargs (): Any kwargs passed will be used to set attributes on the `return_model` + + Returns: + [type]: Returns the `return_model` specified. + """ + model_name = getattr(model, name_attr) + search_value = getattr(model, search_key) + + item = db_table.get(self.session, search_value, search_key) + if item: + if self.force_imports: + primary_key = getattr(item, db_table.primary_key) + db_table.delete(self.session, primary_key) + else: + return return_model( + name=model_name, + status=False, + exception=f"Table entry with matching '{search_key}': '{search_value}' exists", + ) + + try: + db_table.create(self.session, model.dict()) + import_status = return_model(name=model_name, status=True) + + except Exception as inst: + self.session.rollback() + import_status = return_model(name=model_name, status=False, exception=str(inst)) + + for key, value in kwargs.items(): + setattr(return_model, key, value) + + return import_status + def clean_up(self): shutil.rmtree(TEMP_DIR) @@ -248,7 +279,7 @@ def import_database( force_import: bool = False, rebase: bool = False, ): - import_session = ImportDatabase(session, archive) + import_session = ImportDatabase(session, archive, force_import) recipe_report = [] if import_recipes: