diff --git a/mealie/db/database.py b/mealie/db/database.py index c02f472a8..9a3d06f79 100644 --- a/mealie/db/database.py +++ b/mealie/db/database.py @@ -1,3 +1,4 @@ +from sqlalchemy.orm import load_only from sqlalchemy.orm.session import Session from db.db_base import BaseDocument @@ -28,13 +29,13 @@ class _Recipes(BaseDocument): class _Categories(BaseDocument): def __init__(self) -> None: - self.primary_key = "name" + self.primary_key = "slug" self.sql_model = Category class _Tags(BaseDocument): def __init__(self) -> None: - self.primary_key = "name" + self.primary_key = "slug" self.sql_model = Tag diff --git a/mealie/db/db_base.py b/mealie/db/db_base.py index 4739e0a9a..cbc2da4a9 100644 --- a/mealie/db/db_base.py +++ b/mealie/db/db_base.py @@ -16,14 +16,43 @@ class BaseDocument: def get_all( self, session: Session, limit: int = None, order_by: str = None ) -> List[dict]: - list = [x.dict() for x in session.query(self.sql_model).all()] + list = [x.dict() for x in session.query(self.sql_model).limit(limit).all()] if limit == 1: return list[0] return list - def get_all_primary_keys(self, session: Session): + def get_all_limit_columns( + self, session: Session, fields: List[str], limit: int = None + ) -> list[SqlAlchemyBase]: + """Queries the database for the selected model. Restricts return responses to the + keys specified under "fields" + + Args: \n + session (Session): Database Session Object + fields (List[str]): List of column names to query + limit (int): A limit of values to return + + Returns: + list[SqlAlchemyBase]: Returns a list of ORM objects + """ + results = ( + session.query(self.sql_model).options(load_only(*fields)).limit(limit).all() + ) + + return results + + def get_all_primary_keys(self, session: Session) -> List[str]: + """Queries the database of the selected model and returns a list + of all primary_key values + + Args: \n + session (Session): Database Session object + + Returns: + list[str]: + """ results = session.query(self.sql_model).options( load_only(str(self.primary_key)) ) @@ -36,7 +65,7 @@ class BaseDocument: """Query the sql database for one item an return the sql alchemy model object. If no match key is provided the primary_key attribute will be used. - Args: + Args: \n match_value (str): The value to use in the query match_key (str, optional): the key/property to match against. Defaults to None. @@ -80,7 +109,7 @@ class BaseDocument: def save_new(self, session: Session, document: dict) -> dict: """Creates a new database entry for the given SQL Alchemy Model. - Args: + Args: \n session (Session): A Database Session document (dict): A python dictionary representing the data structure @@ -97,7 +126,7 @@ class BaseDocument: def update(self, session: Session, match_value: str, new_data: str) -> dict: """Update a database entry. - Args: + Args: \n session (Session): Database Session match_value (str): Match "key" new_data (str): Match "value" @@ -121,5 +150,4 @@ class BaseDocument: ) session.delete(result) - session.commit() diff --git a/mealie/db/sql/recipe_models.py b/mealie/db/sql/recipe_models.py index f1bb54653..0587947ab 100644 --- a/mealie/db/sql/recipe_models.py +++ b/mealie/db/sql/recipe_models.py @@ -5,6 +5,7 @@ from typing import List import sqlalchemy as sa import sqlalchemy.orm as orm from db.sql.model_base import BaseMixins, SqlAlchemyBase +from slugify import slugify from sqlalchemy.ext.orderinglist import ordering_list from utils.logger import logger @@ -28,14 +29,14 @@ recipes2categories = sa.Table( "recipes2categories", SqlAlchemyBase.metadata, sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), - sa.Column("category_name", sa.String, sa.ForeignKey("categories.name")), + sa.Column("category_slug", sa.String, sa.ForeignKey("categories.slug")), ) recipes2tags = sa.Table( "recipes2tags", SqlAlchemyBase.metadata, sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), - sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id")), + sa.Column("tag_slug", sa.Integer, sa.ForeignKey("tags.slug")), ) @@ -43,36 +44,46 @@ class Category(SqlAlchemyBase): __tablename__ = "categories" id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String, index=True) + slug = sa.Column(sa.String, index=True, unique=True) recipes = orm.relationship( "RecipeModel", secondary=recipes2categories, back_populates="categories" ) def __init__(self, name) -> None: - self.name = name - - @classmethod - def create_if_not_exist(cls, session, name: str): + self.name = name.strip() + self.slug = slugify(name) + @staticmethod + def create_if_not_exist(session, name: str = None): try: - result = session.query(Category).filter_by(**{"name": name}).one() - logger.info("Category Exists, Associating Recipe") - - return result + result = session.query(Category).filter(Category.name == name.strip()).one() + if result: + logger.info("Category exists, associating recipe") + return result + else: + logger.info("Category doesn't exists, creating tag") + return Category(name=name) except: logger.info("Category doesn't exists, creating category") - return cls(name=name) + return Category(name=name) def to_str(self): return self.name def dict(self): - return {"id": self.id, "name": self.name, "recipes": [x.dict() for x in self.recipes]} + return { + "id": self.id, + "slug": self.slug, + "name": self.name, + "recipes": [x.dict() for x in self.recipes], + } class Tag(SqlAlchemyBase): __tablename__ = "tags" id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String, index=True) + slug = sa.Column(sa.String, index=True, unique=True) recipes = orm.relationship( "RecipeModel", secondary=recipes2tags, back_populates="tags" ) @@ -81,22 +92,32 @@ class Tag(SqlAlchemyBase): return self.name def __init__(self, name) -> None: - self.name = name + self.name = name.strip() + self.slug = slugify(self.name) def dict(self): - return {"id": self.id, "name": self.name, "recipes": [x.dict() for x in self.recipes]} - - @classmethod - def create_if_not_exist(cls, session, name: str): + return { + "id": self.id, + "slug": self.slug, + "name": self.name, + "recipes": [x.dict() for x in self.recipes], + } + @staticmethod + def create_if_not_exist(session, name: str = None): try: - result = session.query(Tag).filter_by(**{"name": name}).one() - logger.info("Tag Exists, Associating Recipe") + result = session.query(Tag).filter(Tag.name == name.strip()).first() - return result + if result: + logger.info("Tag exists, associating recipe") + + return result + else: + logger.info("Tag doesn't exists, creating tag") + return Tag(name=name) except: logger.info("Tag doesn't exists, creating tag") - return cls(name=name) + return Tag(name=name) class Note(SqlAlchemyBase): @@ -213,7 +234,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): RecipeIngredient(ingredient=ingr) for ingr in recipeIngredient ] self.recipeInstructions = [ - RecipeInstruction(text=instruc.get("text"), type=instruc.get("text")) + RecipeInstruction(text=instruc.get("text"), type=instruc.get("@type", None)) for instruc in recipeInstructions ] self.totalTime = totalTime @@ -222,11 +243,12 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Mealie Specific self.slug = slug - self.categories = [ - (Category.create_if_not_exist(session, cat)) for cat in categories + Category.create_if_not_exist(session=session, name=cat) + for cat in categories ] - self.tags = [Tag.create_if_not_exist(session, name=tag) for tag in tags] + + self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags] self.dateAdded = dateAdded self.notes = [Note(**note) for note in notes] diff --git a/mealie/models/category_models.py b/mealie/models/category_models.py index c626570c7..e168978ee 100644 --- a/mealie/models/category_models.py +++ b/mealie/models/category_models.py @@ -7,6 +7,7 @@ from services.recipe_services import Recipe class RecipeCategoryResponse(BaseModel): id: int name: str + slug: str recipes: List[Recipe] class Config: diff --git a/mealie/models/recipe_models.py b/mealie/models/recipe_models.py index 572402bfd..084a92f6e 100644 --- a/mealie/models/recipe_models.py +++ b/mealie/models/recipe_models.py @@ -4,8 +4,8 @@ import pydantic from pydantic.main import BaseModel -class RecipeResponse(BaseModel): - List +class AllRecipeResponse(BaseModel): + class Config: schema_extra = { diff --git a/mealie/routes/recipe/all_recipe_routes.py b/mealie/routes/recipe/all_recipe_routes.py index b4bd7f861..e5c48ccd4 100644 --- a/mealie/routes/recipe/all_recipe_routes.py +++ b/mealie/routes/recipe/all_recipe_routes.py @@ -1,19 +1,19 @@ from typing import List, Optional +from db.database import db from db.db_setup import generate_session from fastapi import APIRouter, Depends, Query from models.recipe_models import AllRecipeRequest -from services.recipe_services import read_requested_values from sqlalchemy.orm.session import Session router = APIRouter(tags=["Recipes"]) -@router.get("/api/all-recipes/", response_model=List[dict]) +@router.get("/api/all-recipes/") def get_all_recipes( keys: Optional[List[str]] = Query(...), num: Optional[int] = 100, - db: Session = Depends(generate_session), + session: Session = Depends(generate_session), ): """ Returns key data for all recipes based off the query paramters provided. @@ -21,28 +21,51 @@ def get_all_recipes( recipes containing the slug, image, and name property. By default, responses are limited to 100. + At this time you can only query top level values: + + - slug + - name + - description + - image + - recipeYield + - totalTime + - prepTime + - performTime + - rating + - orgURL + **Note:** You may experience problems with with query parameters. As an alternative you may also use the post method and provide a body. See the *Post* method for more details. """ - all_recipes = read_requested_values(db, keys, num) - return all_recipes + return db.recipes.get_all_limit_columns(session, keys, limit=num) -@router.post("/api/all-recipes/", response_model=List[dict]) +@router.post("/api/all-recipes/") def get_all_recipes_post( - body: AllRecipeRequest, db: Session = Depends(generate_session) + body: AllRecipeRequest, session: Session = Depends(generate_session) ): """ Returns key data for all recipes based off the body data provided. For example, if slug, image, and name are provided you will recieve a list of recipes containing the slug, image, and name property. + At this time you can only query top level values: + + - slug + - name + - description + - image + - recipeYield + - totalTime + - prepTime + - performTime + - rating + - orgURL + Refer to the body example for data formats. """ - all_recipes = read_requested_values(db, body.properties, body.limit) - - return all_recipes + return db.recipes.get_all_limit_columns(session, body.properties, body.limit) diff --git a/mealie/routes/recipe/category_routes.py b/mealie/routes/recipe/category_routes.py index f3f9668f3..c5c7a8e4b 100644 --- a/mealie/routes/recipe/category_routes.py +++ b/mealie/routes/recipe/category_routes.py @@ -13,8 +13,7 @@ router = APIRouter( @router.get("/all/") async def get_all_recipe_categories(session: Session = Depends(generate_session)): """ Returns a list of available categories in the database """ - - return db.categories.get_all_primary_keys(session) + return db.categories.get_all_limit_columns(session, ["slug", "name"]) @router.get("/{category}/", response_model=RecipeCategoryResponse) @@ -23,3 +22,12 @@ def get_all_recipes_by_category( ): """ Returns a list of recipes associated with the provided category. """ return db.categories.get(session, category) + + +@router.delete("/{category}/") +async def delete_recipe_category( + category: str, session: Session = Depends(generate_session) +): + """ Removes a recipe category from the database """ + + db.categories.delete(session, category) diff --git a/mealie/services/backups/imports.py b/mealie/services/backups/imports.py index f47979b22..2f970c294 100644 --- a/mealie/services/backups/imports.py +++ b/mealie/services/backups/imports.py @@ -80,7 +80,8 @@ class ImportDatabase: recipe_obj.save_to_db(self.session) successful_imports.append(recipe.stem) logger.info(f"Imported: {recipe.stem}") - except: + except Exception as inst: + logger.error(inst) logger.info(f"Failed Import: {recipe.stem}") failed_imports.append(recipe.stem) diff --git a/mealie/services/recipe_services.py b/mealie/services/recipe_services.py index 0dbbaca0c..32ed5e784 100644 --- a/mealie/services/recipe_services.py +++ b/mealie/services/recipe_services.py @@ -40,7 +40,6 @@ class Recipe(BaseModel): dateAdded: Optional[datetime.date] notes: Optional[List[RecipeNote]] = [] rating: Optional[int] - rating: Optional[int] orgURL: Optional[str] extras: Optional[dict] = {} @@ -138,33 +137,4 @@ class Recipe(BaseModel): return db.recipes.get_all(session) -def read_requested_values( - session: Session, keys: list, max_results: int = 0 -) -> List[dict]: - """ - Pass in a list of key values to be run against the database. If a match is found - it is then added to a dictionary inside of a list. If a key does not exist the - it will simply not be added to the return data. - Parameters: - keys: list - - Returns: returns a list of dicts containing recipe data - - """ - recipe_list = [] - for recipe in db.recipes.get_all( - session=session, limit=max_results, order_by="dateAdded" - ): - recipe_details = {} - for key in keys: - try: - recipe_key = {key: recipe[key]} - except: - continue - - recipe_details.update(recipe_key) - - recipe_list.append(recipe_details) - - return recipe_list