From d6794cba7d65f6a0f829e717889020a9a54d33b4 Mon Sep 17 00:00:00 2001 From: hayden Date: Sat, 30 Jan 2021 17:25:05 -0900 Subject: [PATCH] category/tag database relationship and endpoints --- mealie/app.py | 44 ++++++++++++---- mealie/db/database.py | 20 ++++++-- mealie/db/db_base.py | 12 ++++- mealie/db/sql/meal_models.py | 6 ++- mealie/db/sql/recipe_models.py | 83 ++++++++++++++++++++++++------- mealie/db/sql/settings_models.py | 4 +- mealie/db/sql/theme_models.py | 2 +- mealie/models/category_models.py | 16 +++--- mealie/routes/backup_routes.py | 24 ++++----- mealie/routes/category_routes.py | 14 ------ mealie/routes/meal_routes.py | 17 +++---- mealie/routes/migration_routes.py | 14 +++--- mealie/routes/setting_routes.py | 19 ++++--- mealie/run.sh | 5 ++ 14 files changed, 178 insertions(+), 102 deletions(-) delete mode 100644 mealie/routes/category_routes.py create mode 100644 mealie/run.sh diff --git a/mealie/app.py b/mealie/app.py index cef6f4850..c8be40215 100644 --- a/mealie/app.py +++ b/mealie/app.py @@ -8,16 +8,32 @@ from routes import ( backup_routes, meal_routes, migration_routes, - recipe_routes, setting_routes, static_routes, user_routes, - category_routes, ) - +from routes.recipe import ( + all_recipe_routes, + category_routes, + recipe_crud_routes, + tag_routes, +) from utils.api_docs import generate_api_docs from utils.logger import logger +""" +TODO: +- [ ] Fix Duplicate Category +- [ ] Fix Duplicate Tags +- [ ] Add Endpoint + - [ ] Endpoint Tests +- [ ] Setup Database Migrations +- [ ] Finish Frontend Category Management +- [ ] Ingredient Drag-Drop / Reorder +- [ ] Refactor Endpoints + + +""" app = FastAPI( title="Mealie", description="A place for all your recipes", @@ -32,15 +48,21 @@ def mount_static_files(): def api_routers(): - # First - print() - app.include_router(recipe_routes.router) - app.include_router(meal_routes.router) - app.include_router(setting_routes.router) - app.include_router(backup_routes.router) - app.include_router(user_routes.router) - app.include_router(migration_routes.router) + # Recipes + app.include_router(all_recipe_routes.router) + app.include_router(recipe_crud_routes.router) app.include_router(category_routes.router) + app.include_router(tag_routes.router) + # Meal Routes + app.include_router(meal_routes.router) + # Settings Routes + app.include_router(setting_routes.router) + # Backups/Imports Routes + app.include_router(backup_routes.router) + # User Routes + app.include_router(user_routes.router) + # Migration Routes + app.include_router(migration_routes.router) if PRODUCTION: diff --git a/mealie/db/database.py b/mealie/db/database.py index 1085100e9..c02f472a8 100644 --- a/mealie/db/database.py +++ b/mealie/db/database.py @@ -2,14 +2,14 @@ from sqlalchemy.orm.session import Session from db.db_base import BaseDocument from db.sql.meal_models import MealPlanModel -from db.sql.recipe_models import RecipeModel +from db.sql.recipe_models import Category, RecipeModel, Tag from db.sql.settings_models import SiteSettingsModel from db.sql.theme_models import SiteThemeModel """ # TODO - [ ] Abstract Classes to use save_new, and update from base models - - [ ] Create Category and Tags Table with Many to Many relationship + - [x] Create Category and Tags Table with Many to Many relationship """ @@ -19,13 +19,25 @@ class _Recipes(BaseDocument): self.sql_model = RecipeModel def update_image(self, session: Session, slug: str, extension: str) -> str: - entry = self._query_one(session, match_value=slug) + entry: RecipeModel = self._query_one(session, match_value=slug) entry.image = f"{slug}.{extension}" session.commit() return f"{slug}.{extension}" +class _Categories(BaseDocument): + def __init__(self) -> None: + self.primary_key = "name" + self.sql_model = Category + + +class _Tags(BaseDocument): + def __init__(self) -> None: + self.primary_key = "name" + self.sql_model = Tag + + class _Meals(BaseDocument): def __init__(self) -> None: self.primary_key = "uid" @@ -58,6 +70,8 @@ class Database: self.meals = _Meals() self.settings = _Settings() self.themes = _Themes() + self.categories = _Categories() + self.tags = _Tags() db = Database() diff --git a/mealie/db/db_base.py b/mealie/db/db_base.py index a7354bbbc..4739e0a9a 100644 --- a/mealie/db/db_base.py +++ b/mealie/db/db_base.py @@ -1,5 +1,6 @@ -from typing import List, Union +from typing import List +from sqlalchemy.orm import load_only from sqlalchemy.orm.session import Session from db.sql.model_base import SqlAlchemyBase @@ -22,6 +23,13 @@ class BaseDocument: return list + def get_all_primary_keys(self, session: Session): + results = session.query(self.sql_model).options( + load_only(str(self.primary_key)) + ) + results_as_dict = [x.dict() for x in results] + return [x.get(self.primary_key) for x in results_as_dict] + def _query_one( self, session: Session, match_value: str, match_key: str = None ) -> SqlAlchemyBase: @@ -79,7 +87,7 @@ class BaseDocument: Returns: dict: A dictionary representation of the database entry """ - new_document = self.sql_model(**document) + new_document = self.sql_model(session=session, **document) session.add(new_document) return_data = new_document.dict() session.commit() diff --git a/mealie/db/sql/meal_models.py b/mealie/db/sql/meal_models.py index ad880267c..9001ded78 100644 --- a/mealie/db/sql/meal_models.py +++ b/mealie/db/sql/meal_models.py @@ -17,7 +17,9 @@ class Meal(SqlAlchemyBase): image = sa.Column(sa.String) description = sa.Column(sa.String) - def __init__(self, slug, name, date, dateText, image, description) -> None: + def __init__( + self, slug, name, date, dateText, image, description, session=None + ) -> None: self.slug = slug self.name = name self.date = date @@ -45,7 +47,7 @@ class MealPlanModel(SqlAlchemyBase, BaseMixins): endDate = sa.Column(sa.Date) meals: List[Meal] = orm.relation(Meal) - def __init__(self, startDate, endDate, meals, uid=None) -> None: + def __init__(self, startDate, endDate, meals, uid=None, session=None) -> None: self.startDate = startDate self.endDate = endDate self.meals = [Meal(**meal) for meal in meals] diff --git a/mealie/db/sql/recipe_models.py b/mealie/db/sql/recipe_models.py index cde7699d2..f1bb54653 100644 --- a/mealie/db/sql/recipe_models.py +++ b/mealie/db/sql/recipe_models.py @@ -6,6 +6,7 @@ import sqlalchemy as sa import sqlalchemy.orm as orm from db.sql.model_base import BaseMixins, SqlAlchemyBase from sqlalchemy.ext.orderinglist import ordering_list +from utils.logger import logger class ApiExtras(SqlAlchemyBase): @@ -23,36 +24,80 @@ class ApiExtras(SqlAlchemyBase): return {self.key_name: self.value} -recipes2categories = sa.Table("recipes2categories", SqlAlchemyBase.metadata, +recipes2categories = sa.Table( + "recipes2categories", + SqlAlchemyBase.metadata, sa.Column("recipe_id", sa.Integer, sa.ForeignKey("recipes.id")), - sa.Column("category_id", sa.Integer, sa.ForeignKey("categories.id"))) - + sa.Column("category_name", sa.String, sa.ForeignKey("categories.name")), +) + +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")), +) + + class Category(SqlAlchemyBase): __tablename__ = "categories" id = sa.Column(sa.Integer, primary_key=True) name = sa.Column(sa.String, index=True) recipes = orm.relationship( - "RecipeModel", - secondary=recipes2categories, - back_populates="categories" + "RecipeModel", secondary=recipes2categories, back_populates="categories" ) def __init__(self, name) -> None: self.name = name + @classmethod + def create_if_not_exist(cls, session, name: str): + + try: + result = session.query(Category).filter_by(**{"name": name}).one() + logger.info("Category Exists, Associating Recipe") + + return result + except: + logger.info("Category doesn't exists, creating category") + return cls(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]} + class Tag(SqlAlchemyBase): __tablename__ = "tags" id = sa.Column(sa.Integer, primary_key=True) - parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id")) name = sa.Column(sa.String, index=True) + recipes = orm.relationship( + "RecipeModel", secondary=recipes2tags, back_populates="tags" + ) def to_str(self): return self.name + def __init__(self, name) -> None: + self.name = 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): + + try: + result = session.query(Tag).filter_by(**{"name": name}).one() + logger.info("Tag Exists, Associating Recipe") + + return result + except: + logger.info("Tag doesn't exists, creating tag") + return cls(name=name) + class Note(SqlAlchemyBase): __tablename__ = "notes" @@ -128,25 +173,20 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Mealie Specific slug = sa.Column(sa.String, index=True, unique=True) categories: List = orm.relationship( - "Category", - secondary=recipes2categories, - back_populates="recipes", + "Category", secondary=recipes2categories, back_populates="recipes" ) tags: List[Tag] = orm.relationship( - "Tag", - cascade="all, delete", + "Tag", secondary=recipes2tags, back_populates="recipes" ) dateAdded = sa.Column(sa.Date, default=date.today) - notes: List[Note] = orm.relationship( - "Note", - cascade="all, delete", - ) + notes: List[Note] = orm.relationship("Note", cascade="all, delete") rating = sa.Column(sa.Integer) orgURL = sa.Column(sa.String) extras: List[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete") def __init__( self, + session, name: str = None, description: str = None, image: str = None, @@ -182,8 +222,12 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): # Mealie Specific self.slug = slug - self.categories = [Category(cat) for cat in categories] - self.tags = [Tag(name=tag) for tag in tags] + + self.categories = [ + (Category.create_if_not_exist(session, cat)) for cat in categories + ] + self.tags = [Tag.create_if_not_exist(session, name=tag) for tag in tags] + self.dateAdded = dateAdded self.notes = [Note(**note) for note in notes] self.rating = rating @@ -212,10 +256,11 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): extras: dict = None, ): """Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions""" - list_of_tables = [RecipeIngredient, RecipeInstruction, Tag, ApiExtras] + list_of_tables = [RecipeIngredient, RecipeInstruction, ApiExtras] RecipeModel._sql_remove_list(session, list_of_tables, self.id) self.__init__( + session=session, name=name, description=description, image=image, diff --git a/mealie/db/sql/settings_models.py b/mealie/db/sql/settings_models.py index 64307468d..39772ec10 100644 --- a/mealie/db/sql/settings_models.py +++ b/mealie/db/sql/settings_models.py @@ -8,7 +8,7 @@ class SiteSettingsModel(SqlAlchemyBase): name = sa.Column(sa.String, primary_key=True) webhooks = orm.relationship("WebHookModel", uselist=False, cascade="all, delete") - def __init__(self, name: str = None, webhooks: dict = None) -> None: + def __init__(self, name: str = None, webhooks: dict = None, session=None) -> None: self.name = name self.webhooks = WebHookModel(**webhooks) @@ -33,7 +33,7 @@ class WebHookModel(SqlAlchemyBase, BaseMixins): enabled = sa.Column(sa.Boolean, default=False) def __init__( - self, webhookURLs: list, webhookTime: str, enabled: bool = False + self, webhookURLs: list, webhookTime: str, enabled: bool = False, session=None ) -> None: self.webhookURLs = [WebhookURLModel(url=x) for x in webhookURLs] diff --git a/mealie/db/sql/theme_models.py b/mealie/db/sql/theme_models.py index 7b12e34f0..c6dca8019 100644 --- a/mealie/db/sql/theme_models.py +++ b/mealie/db/sql/theme_models.py @@ -8,7 +8,7 @@ class SiteThemeModel(SqlAlchemyBase): name = sa.Column(sa.String, primary_key=True) colors = orm.relationship("ThemeColorsModel", uselist=False, cascade="all, delete") - def __init__(self, name: str, colors: dict) -> None: + def __init__(self, name: str, colors: dict, session=None) -> None: self.name = name self.colors = ThemeColorsModel(**colors) diff --git a/mealie/models/category_models.py b/mealie/models/category_models.py index d0d905230..c626570c7 100644 --- a/mealie/models/category_models.py +++ b/mealie/models/category_models.py @@ -1,11 +1,13 @@ -from pydantic.main import BaseModel +from typing import List -class Category(BaseModel): +from pydantic.main import BaseModel +from services.recipe_services import Recipe + + +class RecipeCategoryResponse(BaseModel): + id: int name: str + recipes: List[Recipe] class Config: - schema_extra = { - "example": { - "name": "Breakfast" - } - } \ No newline at end of file + schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}} diff --git a/mealie/routes/backup_routes.py b/mealie/routes/backup_routes.py index aa3ee33ee..184583597 100644 --- a/mealie/routes/backup_routes.py +++ b/mealie/routes/backup_routes.py @@ -11,10 +11,10 @@ from sqlalchemy.orm.session import Session from starlette.responses import FileResponse from utils.snackbar import SnackResponse -router = APIRouter(tags=["Import / Export"]) +router = APIRouter(prefix="/api/backups", tags=["Import / Export"]) -@router.get("/api/backups/available/", response_model=Imports) +@router.get("/available/", response_model=Imports) def available_imports(): """Returns a list of avaiable .zip files for import into Mealie.""" imports = [] @@ -31,7 +31,7 @@ def available_imports(): return Imports(imports=imports, templates=templates) -@router.post("/api/backups/export/database/", status_code=201) +@router.post("/export/database/", status_code=201) def export_database(data: BackupJob, db: Session = Depends(generate_session)): """Generates a backup of the recipe database in json format.""" export_path = backup_all( @@ -51,7 +51,7 @@ def export_database(data: BackupJob, db: Session = Depends(generate_session)): ) -@router.post("/api/backups/upload/") +@router.post("/upload/") def upload_backup_zipfile(archive: UploadFile = File(...)): """ Upload a .zip File to later be imported into Mealie """ dest = BACKUP_DIR.joinpath(archive.filename) @@ -65,7 +65,7 @@ def upload_backup_zipfile(archive: UploadFile = File(...)): return SnackResponse.error("Failure uploading file") -@router.get("/api/backups/{file_name}/download/") +@router.get("/{file_name}/download/") def upload_nextcloud_zipfile(file_name: str): """ Upload a .zip File to later be imported into Mealie """ file = BACKUP_DIR.joinpath(file_name) @@ -78,7 +78,7 @@ def upload_nextcloud_zipfile(file_name: str): return SnackResponse.error("No File Found") -@router.post("/api/backups/{file_name}/import/", status_code=200) +@router.post("/{file_name}/import/", status_code=200) def import_database( file_name: str, import_data: ImportJob, db: Session = Depends(generate_session) ): @@ -98,20 +98,16 @@ def import_database( return imported -@router.delete( - "/api/backups/{backup_name}/delete/", - tags=["Import / Export"], - status_code=200, -) -def delete_backup(backup_name: str): +@router.delete("/{file_name}/delete/", tags=["Import / Export"], status_code=200) +def delete_backup(file_name: str): """ Removes a database backup from the file system """ try: - BACKUP_DIR.joinpath(backup_name).unlink() + BACKUP_DIR.joinpath(file_name).unlink() except: HTTPException( status_code=400, detail=SnackResponse.error("Unable to Delete Backup. See Log File"), ) - return SnackResponse.success(f"{backup_name} Deleted") + return SnackResponse.success(f"{file_name} Deleted") diff --git a/mealie/routes/category_routes.py b/mealie/routes/category_routes.py deleted file mode 100644 index 128e0dfba..000000000 --- a/mealie/routes/category_routes.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import List - -from models.category_models import Category -from services.category_services import get_all -from fastapi import APIRouter, HTTPException -from utils.snackbar import SnackResponse - -router = APIRouter(tags=["Category"]) - -@router.get("/api/category/all", response_model=List[Category]) -def get_all_categories(): - """ Returns a list of all categories """ - - return get_all() \ No newline at end of file diff --git a/mealie/routes/meal_routes.py b/mealie/routes/meal_routes.py index 08b9771e1..b183356e6 100644 --- a/mealie/routes/meal_routes.py +++ b/mealie/routes/meal_routes.py @@ -6,17 +6,17 @@ from services.meal_services import MealPlan from sqlalchemy.orm.session import Session from utils.snackbar import SnackResponse -router = APIRouter(tags=["Meal Plan"]) +router = APIRouter(prefix="/api/meal-plan", tags=["Meal Plan"]) -@router.get("/api/meal-plan/all/", response_model=List[MealPlan]) +@router.get("/all/", response_model=List[MealPlan]) def get_all_meals(db: Session = Depends(generate_session)): """ Returns a list of all available Meal Plan """ return MealPlan.get_all(db) -@router.post("/api/meal-plan/create/") +@router.post("/create/") def set_meal_plan(data: MealPlan, db: Session = Depends(generate_session)): """ Creates a meal plan database entry """ data.process_meals(db) @@ -30,7 +30,7 @@ def set_meal_plan(data: MealPlan, db: Session = Depends(generate_session)): return SnackResponse.success("Mealplan Created") -@router.post("/api/meal-plan/{plan_id}/update/") +@router.post("/{plan_id}/update/") def update_meal_plan( plan_id: str, meal_plan: MealPlan, db: Session = Depends(generate_session) ): @@ -49,7 +49,7 @@ def update_meal_plan( return SnackResponse.success("Mealplan Updated") -@router.delete("/api/meal-plan/{plan_id}/delete/") +@router.delete("/{plan_id}/delete/") def delete_meal_plan(plan_id, db: Session = Depends(generate_session)): """ Removes a meal plan from the database """ @@ -58,10 +58,7 @@ def delete_meal_plan(plan_id, db: Session = Depends(generate_session)): return SnackResponse.success("Mealplan Deleted") -@router.get( - "/api/meal-plan/today/", - tags=["Meal Plan"], -) +@router.get("/today/", tags=["Meal Plan"]) def get_today(db: Session = Depends(generate_session)): """ Returns the recipe slug for the meal scheduled for today. @@ -71,7 +68,7 @@ def get_today(db: Session = Depends(generate_session)): return MealPlan.today(db) -@router.get("/api/meal-plan/this-week/", response_model=MealPlan) +@router.get("/this-week/", response_model=MealPlan) def get_this_week(db: Session = Depends(generate_session)): """ Returns the meal plan data for this week """ diff --git a/mealie/routes/migration_routes.py b/mealie/routes/migration_routes.py index d06203c73..48db9e2c9 100644 --- a/mealie/routes/migration_routes.py +++ b/mealie/routes/migration_routes.py @@ -11,10 +11,10 @@ from services.migrations.nextcloud import migrate as nextcloud_migrate from sqlalchemy.orm.session import Session from utils.snackbar import SnackResponse -router = APIRouter(tags=["Migration"]) +router = APIRouter(prefix="/api/migrations", tags=["Migration"]) -@router.get("/api/migrations/", response_model=List[Migrations]) +@router.get("/", response_model=List[Migrations]) def get_avaiable_nextcloud_imports(): """ Returns a list of avaiable directories that can be imported into Mealie """ response_data = [] @@ -35,7 +35,7 @@ def get_avaiable_nextcloud_imports(): return response_data -@router.post("/api/migrations/{type}/{file_name}/import/") +@router.post("/{type}/{file_name}/import/") def import_nextcloud_directory( type: str, file_name: str, db: Session = Depends(generate_session) ): @@ -49,11 +49,11 @@ def import_nextcloud_directory( return SnackResponse.error("Incorrect Migration Type Selected") -@router.delete("/api/migrations/{folder}/{file}/delete/") -def delete_migration_data(folder: str, file: str): +@router.delete("/{type}/{file_name}/delete/") +def delete_migration_data(type: str, file_name: str): """ Removes migration data from the file system """ - remove_path = MIGRATION_DIR.joinpath(folder, file) + remove_path = MIGRATION_DIR.joinpath(type, file_name) if remove_path.is_file(): remove_path.unlink() @@ -65,7 +65,7 @@ def delete_migration_data(folder: str, file: str): return SnackResponse.info(f"Migration Data Remove: {remove_path.absolute()}") -@router.post("/api/migrations/{type}/upload/") +@router.post("/{type}/upload/") def upload_nextcloud_zipfile(type: str, archive: UploadFile = File(...)): """ Upload a .zip File to later be imported into Mealie """ dir = MIGRATION_DIR.joinpath(type) diff --git a/mealie/routes/setting_routes.py b/mealie/routes/setting_routes.py index 0c3249787..37e7a786f 100644 --- a/mealie/routes/setting_routes.py +++ b/mealie/routes/setting_routes.py @@ -5,24 +5,23 @@ from sqlalchemy.orm.session import Session from utils.post_webhooks import post_webhooks from utils.snackbar import SnackResponse -router = APIRouter(tags=["Settings"]) +router = APIRouter(prefix="/api/site-settings", tags=["Settings"]) - -@router.get("/api/site-settings/") +@router.get("/") def get_main_settings(db: Session = Depends(generate_session)): """ Returns basic site settings """ return SiteSettings.get_site_settings(db) -@router.post("/api/site-settings/webhooks/test/") +@router.post("/webhooks/test/") def test_webhooks(): """ Run the function to test your webhooks """ return post_webhooks() -@router.post("/api/site-settings/update/") +@router.post("/update/") def update_settings(data: SiteSettings, db: Session = Depends(generate_session)): """ Returns Site Settings """ data.update(db) @@ -36,20 +35,20 @@ def update_settings(data: SiteSettings, db: Session = Depends(generate_session)) return SnackResponse.success("Settings Updated") -@router.get("/api/site-settings/themes/", tags=["Themes"]) +@router.get("/themes/", tags=["Themes"]) def get_all_themes(db: Session = Depends(generate_session)): """ Returns all site themes """ return SiteTheme.get_all(db) -@router.get("/api/site-settings/themes/{theme_name}/", tags=["Themes"]) +@router.get("/themes/{theme_name}/", tags=["Themes"]) def get_single_theme(theme_name: str, db: Session = Depends(generate_session)): """ Returns a named theme """ return SiteTheme.get_by_name(db, theme_name) -@router.post("/api/site-settings/themes/create/", tags=["Themes"]) +@router.post("/themes/create/", tags=["Themes"]) def create_theme(data: SiteTheme, db: Session = Depends(generate_session)): """ Creates a site color theme database entry """ data.save_to_db(db) @@ -63,7 +62,7 @@ def create_theme(data: SiteTheme, db: Session = Depends(generate_session)): return SnackResponse.success("Theme Saved") -@router.post("/api/site-settings/themes/{theme_name}/update/", tags=["Themes"]) +@router.post("/themes/{theme_name}/update/", tags=["Themes"]) def update_theme( theme_name: str, data: SiteTheme, db: Session = Depends(generate_session) ): @@ -79,7 +78,7 @@ def update_theme( return SnackResponse.success("Theme Updated") -@router.delete("/api/site-settings/themes/{theme_name}/delete/", tags=["Themes"]) +@router.delete("/themes/{theme_name}/delete/", tags=["Themes"]) def delete_theme(theme_name: str, db: Session = Depends(generate_session)): """ Deletes theme from the database """ SiteTheme.delete_theme(db, theme_name) diff --git a/mealie/run.sh b/mealie/run.sh new file mode 100644 index 000000000..b90769efc --- /dev/null +++ b/mealie/run.sh @@ -0,0 +1,5 @@ +## Run Migration + + +## Start Application +uvicorn app:app --host 0.0.0.0 --port 80 \ No newline at end of file