category/tag database relationship and endpoints

This commit is contained in:
hayden
2021-01-30 17:25:05 -09:00
parent 016108d35f
commit d6794cba7d
14 changed files with 178 additions and 102 deletions

View File

@@ -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:

View File

@@ -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()

View File

@@ -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()

View File

@@ -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]

View File

@@ -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,

View File

@@ -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]

View File

@@ -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)

View File

@@ -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"
}
}
schema_extra = {"example": {"id": 1, "name": "dinner", "recipes": [{}]}}

View File

@@ -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")

View File

@@ -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()

View File

@@ -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 """

View File

@@ -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)

View File

@@ -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)

5
mealie/run.sh Normal file
View File

@@ -0,0 +1,5 @@
## Run Migration
## Start Application
uvicorn app:app --host 0.0.0.0 --port 80