This commit is contained in:
Hayden
2020-12-24 16:37:38 -09:00
commit beed8576c2
137 changed files with 40218 additions and 0 deletions

55
mealie/app.py Normal file
View File

@@ -0,0 +1,55 @@
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from routes import (
backup_routes,
meal_routes,
recipe_routes,
setting_routes,
static_routes,
user_routes,
)
from routes.setting_routes import scheduler
from settings import PORT
from utils.logger import logger
CWD = Path(__file__).parent
WEB_PATH = CWD.joinpath("dist")
app = FastAPI()
# Mount Vue Frontend
app.mount("/static", StaticFiles(directory=WEB_PATH, html=True))
# API Routes
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)
# API 404 Catch all CALL AFTER ROUTERS
@app.get("/api/{full_path:path}", status_code=404, include_in_schema=False)
def invalid_api():
return None
app.include_router(static_routes.router)
if __name__ == "__main__":
logger.info("-----SYSTEM STARTUP-----")
uvicorn.run(
"app:app",
host="0.0.0.0",
port=PORT,
reload=True,
debug=True,
workers=1,
forwarded_allow_ips="*",
)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -0,0 +1,25 @@
![Recipe Image](../images/{{ recipe.image }})
# {{ recipe.name }}
## Ingredients
{% for ingredient in recipe.recipeIngredient %}
- [ ] {{ ingredient }}
{% endfor %}
## Instructions
{% for step in recipe.recipeInstructions %}
- [ ] {{ step.text }}
{% endfor %}
{% for note in recipe.notes %}
**{{ note.title }}:** {{ note.text }}
{% endfor %}
---
Tags: {{ recipe.tags }}
Categories: {{ recipe.categories }}
Original URL: {{ recipe.orgURL }}

24
mealie/db/meal_models.py Normal file
View File

@@ -0,0 +1,24 @@
import uuid
import mongoengine
class MealDocument(mongoengine.EmbeddedDocument):
slug = mongoengine.StringField()
name = mongoengine.StringField()
date = mongoengine.DateField()
dateText = mongoengine.StringField()
image = mongoengine.StringField()
description = mongoengine.StringField()
class MealPlanDocument(mongoengine.Document):
uid = mongoengine.UUIDField(default=uuid.uuid1)
startDate = mongoengine.DateField(required=True)
endDate = mongoengine.DateField(required=True)
meals = mongoengine.ListField(required=True)
meta = {
"db_alias": "core",
"collection": "meals",
}

16
mealie/db/mongo_setup.py Normal file
View File

@@ -0,0 +1,16 @@
import mongoengine
from settings import DB_HOST, DB_PASSWORD, DB_PORT, DB_USERNAME
def global_init():
mongoengine.register_connection(
alias="core",
name="demo_mealie",
host=DB_HOST,
port=int(DB_PORT),
username=DB_USERNAME,
password=DB_PASSWORD,
authentication_source="admin",
)

View File

@@ -0,0 +1,35 @@
import datetime
import uuid
import mongoengine
class RecipeDocument(mongoengine.Document):
# Standard Schema
# id = mongoengine.UUIDField(primary_key=True)
name = mongoengine.StringField(required=True)
description = mongoengine.StringField(required=True)
image = mongoengine.StringField(required=True)
recipeYield = mongoengine.StringField(required=True, default="")
recipeIngredient = mongoengine.ListField(required=True, default=[])
recipeInstructions = mongoengine.ListField(requiredd=True, default=[])
totalTime = mongoengine.StringField(required=False)
# Mealie Specific
slug = mongoengine.StringField(required=True, unique=True)
categories = mongoengine.ListField(default=[])
tags = mongoengine.ListField(default=[])
dateAdded = mongoengine.DateTimeField(binary=True, default=datetime.date.today())
notes = mongoengine.ListField(default=[])
rating = mongoengine.IntField(required=True, default=0)
orgURL = mongoengine.URLField(required=False)
extras = mongoengine.ListField(required=False)
meta = {
"db_alias": "core",
"collection": "recipes",
}
if __name__ == "__main__":
pass

View File

@@ -0,0 +1,37 @@
import mongoengine
class WebhooksDocument(mongoengine.EmbeddedDocument):
webhookURLs = mongoengine.ListField(required=False, default=[])
webhookTime = mongoengine.StringField(required=False, default="00:00")
enabled = mongoengine.BooleanField(required=False, default=False)
class SiteSettingsDocument(mongoengine.Document):
name = mongoengine.StringField(require=True, default="main", unique=True)
webhooks = mongoengine.EmbeddedDocumentField(WebhooksDocument, required=True)
meta = {
"db_alias": "core",
"collection": "settings",
}
class ThemeColorsDocument(mongoengine.EmbeddedDocument):
primary = mongoengine.StringField(require=True)
accent = mongoengine.StringField(require=True)
secondary = mongoengine.StringField(require=True)
success = mongoengine.StringField(require=True)
info = mongoengine.StringField(require=True)
warning = mongoengine.StringField(require=True)
error = mongoengine.StringField(require=True)
class SiteThemeDocument(mongoengine.Document):
name = mongoengine.StringField(require=True, unique=True)
colors = mongoengine.EmbeddedDocumentField(ThemeColorsDocument, required=True)
meta = {
"db_alias": "core",
"collection": "themes",
}

7
mealie/db/user_models.py Normal file
View File

@@ -0,0 +1,7 @@
import datetime
import mongoengine
class User(mongoengine.Document):
username: mongoengine.EmailField()
# password: mongoengine.ReferenceField

View File

@@ -0,0 +1,9 @@
# from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class BackupJob(BaseModel):
tag: Optional[str]
template: Optional[str]

View File

@@ -0,0 +1,10 @@
from typing import Optional
from pydantic import BaseModel
class User(BaseModel):
username: str
email: Optional[str] = None
full_name: Optional[str] = None
disabled: Optional[bool] = None

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, HTTPException
from models.backup_models import BackupJob
from services.backup_services import (BACKUP_DIR, TEMPLATE_DIR, export_db,
import_from_archive)
from utils.snackbar import SnackResponse
router = APIRouter()
@router.get("/api/backups/avaiable/", tags=["Import / Export"])
async def avaiable_imports():
""" Returns this weeks meal plan """
imports = []
templates = []
for archive in BACKUP_DIR.glob("*.zip"):
imports.append(archive.name)
for template in TEMPLATE_DIR.glob("*.md"):
templates.append(template.name)
return {"imports": imports, "templates": templates}
@router.post("/api/backups/export/database/", tags=["Import / Export"], status_code=201)
async def export_database(data: BackupJob):
""" Returns this weeks meal plan """
try:
export_db(data.tag, data.template)
except:
HTTPException(
status_code=400,
detail=SnackResponse.error("Error Creating Backup. See Log File"),
)
return SnackResponse.success("Backup Created in /data/backups")
@router.post(
"/api/backups/{file_name}/import/", tags=["Import / Export"], status_code=200
)
async def import_database(file_name: str):
""" Returns this weeks meal plan """
imported = import_from_archive(file_name)
return imported
@router.delete(
"/api/backups/{backup_name}/delete/",
tags=["Import / Export"],
status_code=200,
)
async def delete_backup(backup_name: str):
""" Returns this weeks meal plan """
try:
BACKUP_DIR.joinpath(backup_name).unlink()
except:
HTTPException(
status_code=400,
detail=SnackResponse.error("Unable to Delete Backup. See Log File"),
)
return SnackResponse.success(f"{backup_name} Deleted")

View File

@@ -0,0 +1,69 @@
from pprint import pprint
from fastapi import APIRouter, HTTPException
from services.meal_services import MealPlan
from utils.snackbar import SnackResponse
router = APIRouter()
@router.get("/api/meal-plan/all/", tags=["Meal Plan"])
async def get_all_meals():
""" Returns a list of all avaiable meal plans """
return MealPlan.get_all()
@router.post("/api/meal-plan/create/", tags=["Meal Plan"])
async def set_meal_plan(data: MealPlan):
""" Creates Mealplan from Frontend Data"""
try:
data.process_meals()
data.save_to_db()
except:
raise HTTPException(
status_code=404,
detail=SnackResponse.error("Unable to Create Mealplan See Log"),
)
return SnackResponse.success("Mealplan Created")
@router.post("/api/meal-plan/{plan_id}/update/", tags=["Meal Plan"])
async def update_meal_plan(plan_id: str, meal_plan: MealPlan):
""" Updates a Meal Plan Based off ID """
try:
meal_plan.process_meals()
meal_plan.update(plan_id)
except:
raise HTTPException(
status_code=404,
detail=SnackResponse.error("Unable to Update Mealplan"),
)
return SnackResponse.success("Mealplan Updated")
@router.delete("/api/meal-plan/{plan_id}/delete/", tags=["Meal Plan"])
async def delete_meal_plan(plan_id):
""" Doc Str """
MealPlan.delete(plan_id)
return SnackResponse.success("Mealplan Deleted")
@router.get("/api/meal-plan/today/", tags=["Meal Plan"])
async def get_today():
""" Returns the meal plan data for today """
return MealPlan.today()
@router.get("/api/meal-plan/this-week/", tags=["Meal Plan"])
async def get_this_week():
""" Returns the meal plan data for this week """
return MealPlan.this_week()

View File

@@ -0,0 +1,91 @@
from typing import List, Optional
from fastapi import APIRouter, File, Form, HTTPException, Query
from fastapi.responses import FileResponse
from services.image_services import read_image, write_image
from services.recipe_services import Recipe, read_requested_values
from services.scrape_services import create_from_url
from utils.snackbar import SnackResponse
router = APIRouter()
@router.get("/api/all-recipes/", tags=["Recipes"])
async def get_all_recipes(
keys: Optional[List[str]] = Query(...), num: Optional[int] = 100
) -> Optional[List[str]]:
""" Returns key data for all recipes """
all_recipes = read_requested_values(keys, num)
return all_recipes
@router.get("/api/recipe/{recipe_slug}/", tags=["Recipes"])
async def get_recipe(recipe_slug: str):
""" Takes in a recipe slug, returns all data for a recipe """
recipe = Recipe.get_by_slug(recipe_slug)
return recipe
@router.get("/api/recipe/image/{recipe_slug}/", tags=["Recipes"])
async def get_recipe_img(recipe_slug: str):
""" Takes in a recipe slug, returns the static image """
recipe_image = read_image(recipe_slug)
return FileResponse(recipe_image)
# Recipe Creations
@router.post("/api/recipe/create-url/", tags=["Recipes"])
async def get_recipe_url(url: dict):
""" Takes in a URL and Attempts to scrape data and load it into the database """
url = url.get("url")
try:
slug = create_from_url(url)
except:
raise HTTPException(
status_code=400, detail=SnackResponse.error("Unable to Parse URL")
)
return slug
@router.post("/api/recipe/create/", tags=["Recipes"])
async def create_from_json(data: Recipe) -> str:
""" Takes in a JSON string and loads data into the database as a new entry"""
created_recipe = data.save_to_db()
return created_recipe
@router.post("/api/recipe/{recipe_slug}/update/image/", tags=["Recipes"])
def update_image(
recipe_slug: str, image: bytes = File(...), extension: str = Form(...)
):
""" Removes an existing image and replaces it with the incoming file. """
response = write_image(recipe_slug, image, extension)
return response
@router.post("/api/recipe/{recipe_slug}/update/", tags=["Recipes"])
async def update(recipe_slug: str, data: dict):
""" Updates a recipe by existing slug and data. Data should containt """
Recipe.update(recipe_slug, data)
return {"message": "PLACEHOLDER"}
@router.delete("/api/recipe/{recipe_slug}/delete/", tags=["Recipes"])
async def delete(recipe_slug: str):
""" Deletes a recipe by slug """
try:
Recipe.delete(recipe_slug)
except:
raise HTTPException(
status_code=404, detail=SnackResponse.error("Unable to Delete Recipe")
)
return SnackResponse.success("Recipe Deleted")

View File

@@ -0,0 +1,93 @@
from db.mongo_setup import global_init
from fastapi import APIRouter, HTTPException
from services.scheduler_services import Scheduler, post_webhooks
from services.settings_services import SiteSettings, SiteTheme
from utils.snackbar import SnackResponse
router = APIRouter()
global_init()
scheduler = Scheduler()
scheduler.startup_scheduler()
@router.get("/api/site-settings/", tags=["Settings"])
async def get_main_settings():
""" Returns basic site Settings """
return SiteSettings.get_site_settings()
@router.post("/api/site-settings/webhooks/test/", tags=["Settings"])
async def test_webhooks():
""" Test Webhooks """
return post_webhooks()
@router.post("/api/site-settings/update/", tags=["Settings"])
async def update_settings(data: SiteSettings):
""" Returns Site Settings """
try:
data.update()
except:
raise HTTPException(
status_code=400, detail=SnackResponse.error("Unable to Save Settings")
)
scheduler.reschedule_webhooks()
return SnackResponse.success("Settings Updated")
@router.get("/api/site-settings/themes/", tags=["Themes"])
async def get_all_themes():
""" Returns all site themes """
return SiteTheme.get_all()
@router.get("/api/site-settings/themes/{theme_name}/", tags=["Themes"])
async def get_single_theme(theme_name: str):
""" Returns basic site Settings """
return SiteTheme.get_by_name(theme_name)
@router.post("/api/site-settings/themes/create/", tags=["Themes"])
async def create_theme(data: SiteTheme):
""" Creates a Site Color Theme """
try:
data.save_to_db()
except:
raise HTTPException(
status_code=400, detail=SnackResponse.error("Unable to Save Theme")
)
return SnackResponse.success("Theme Saved")
@router.post("/api/site-settings/themes/{theme_name}/update/", tags=["Themes"])
async def update_theme(theme_name: str, data: SiteTheme):
""" Returns basic site Settings """
try:
data.update_document()
except:
raise HTTPException(
status_code=400, detail=SnackResponse.error("Unable to Update Theme")
)
return SnackResponse.success("Theme Updated")
@router.delete("/api/site-settings/themes/{theme_name}/delete/", tags=["Themes"])
async def delete_theme(theme_name: str):
""" Returns basic site Settings """
try:
SiteTheme.delete_theme(theme_name)
except:
raise HTTPException(
status_code=400, detail=SnackResponse.error("Unable to Delete Theme")
)
return SnackResponse.success("Theme Deleted")

View File

@@ -0,0 +1,25 @@
from pathlib import Path
from fastapi import APIRouter, responses
from fastapi.responses import FileResponse
CWD = Path(__file__).parent
WEB_PATH = CWD.parent.joinpath("dist")
BASE_HTML = WEB_PATH.joinpath("index.html")
router = APIRouter()
@router.get("/favicon.ico", include_in_schema=False)
def facivon():
return responses.RedirectResponse(url="/mealie/favicon.ico")
@router.get("/", include_in_schema=False)
def root():
return FileResponse(BASE_HTML)
@router.get("/{full_path:path}", include_in_schema=False)
def root_plus(full_path):
print(full_path)
return FileResponse(BASE_HTML)

View File

@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
# from fastapi_login import LoginManager
# from fastapi_login.exceptions import InvalidCredentialsException
router = APIRouter()
# SECRET = "876cfb59db03d9c17cefec967b00255d3f7d93a823e5dc2a"
# manager = LoginManager(SECRET, tokenUrl="/api/auth/token")
# fake_db = {"johndoe@e.mail": {"password": "hunter2"}}
# @manager.user_loader
# def load_user(email: str): # could also be an asynchronous function
# user = fake_db.get(email)
# return user
# @router.post("/api/auth/token", tags=["User Gen"])
# def login(data: OAuth2PasswordRequestForm = Depends()):
# email = data.username
# password = data.password
# user = load_user(email) # we are using the same function to retrieve the user
# if not user:
# raise InvalidCredentialsException # you can also use your own HTTPException
# elif password != user["password"]:
# raise InvalidCredentialsException
# access_token = manager.create_access_token(data=dict(sub=email))
# return {"access_token": access_token, "token_type": "bearer"}

26
mealie/scratch.py Normal file
View File

@@ -0,0 +1,26 @@
import collections
import pdfkit
import requests
from db.mongo_setup import global_init
Cron = collections.namedtuple("Cron", "hours minutes")
# def cron_parser(time_str: str) -> Cron:
# time = time_str.split(":")
# cron = Cron(hours=time[0], minutes=time[1])
# print(cron.hours, cron.minutes)
# cron_parser("12:45")
URL = "https://home.homelabhome.com/api/webhook/test_msg"
from services.meal_services import MealPlan
global_init()
todays_meal = MealPlan.today()
requests.post(URL, todays_meal)

View File

@@ -0,0 +1,124 @@
import json
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from db.recipe_models import RecipeDocument
from jinja2 import Template
from utils.logger import logger
from services.recipe_services import IMG_DIR
CWD = Path(__file__).parent
BACKUP_DIR = CWD.parent.joinpath("data", "backups")
TEMPLATE_DIR = CWD.parent.joinpath("data", "templates")
TEMP_DIR = CWD.parent.joinpath("data", "temp")
def auto_backup_job():
for backup in BACKUP_DIR.glob("Auto*.zip"):
backup.unlink()
export_db(tag="Auto", template=None)
logger.info("Auto Backup Called")
def import_from_archive(file_name: str) -> list:
successful_imports = []
file_path = BACKUP_DIR.joinpath(file_name)
with zipfile.ZipFile(file_path, "r") as zip_ref:
zip_ref.extractall(TEMP_DIR)
recipe_dir = TEMP_DIR.joinpath("recipes")
for recipe in recipe_dir.glob("*.json"):
with open(recipe, "r") as f:
recipe_dict = json.loads(f.read())
del recipe_dict["_id"]
del recipe_dict["dateAdded"]
recipeDoc = RecipeDocument(**recipe_dict)
try:
recipeDoc.save()
successful_imports.append(recipe.stem)
except:
print("Failed Import:", recipe.stem)
image_dir = TEMP_DIR.joinpath("images")
for image in image_dir.iterdir():
if image.stem in successful_imports:
shutil.copy(image, IMG_DIR)
shutil.rmtree(TEMP_DIR)
return successful_imports
def export_db(tag=None, template=None):
if tag:
export_tag = tag + "_" + datetime.now().strftime("%Y-%b-%d")
else:
export_tag = datetime.now().strftime("%Y-%b-%d")
backup_folder = TEMP_DIR.joinpath(export_tag)
backup_folder.mkdir(parents=True, exist_ok=True)
img_folder = backup_folder.joinpath("images")
img_folder.mkdir(parents=True, exist_ok=True)
recipe_folder = backup_folder.joinpath("recipes")
recipe_folder.mkdir(parents=True, exist_ok=True)
export_images(img_folder)
export_recipes(recipe_folder, template)
zip_path = BACKUP_DIR.joinpath(f"{export_tag}")
shutil.make_archive(zip_path, "zip", backup_folder)
shutil.rmtree(backup_folder)
shutil.rmtree(TEMP_DIR)
def export_images(dest_dir) -> Path:
for file in IMG_DIR.iterdir():
shutil.copy(file, dest_dir.joinpath(file.name))
def export_recipes(dest_dir: Path, template=None) -> Path:
all_recipes = RecipeDocument.objects()
for recipe in all_recipes:
json_recipe = recipe.to_json(indent=4)
if template:
md_dest = dest_dir.parent.joinpath("markdown")
md_dest.mkdir(parents=True, exist_ok=True)
template = TEMPLATE_DIR.joinpath(template)
export_markdown(md_dest, json_recipe, template)
filename = recipe.slug + ".json"
file_path = dest_dir.joinpath(filename)
with open(file_path, "w") as f:
f.write(json_recipe)
def export_markdown(dest_dir: Path, recipe_data: json, template=Path) -> Path:
recipe_data: dict = json.loads(recipe_data)
recipe_template = TEMPLATE_DIR.joinpath("recipes.md")
with open(recipe_template, "r") as f:
template = Template(f.read())
out_file = dest_dir.joinpath(recipe_data["slug"] + ".md")
content = template.render(recipe=recipe_data)
with open(out_file, "w") as f:
f.write(content)
if __name__ == "__main__":
pass

View File

@@ -0,0 +1,62 @@
import shutil
from pathlib import Path
import requests
from fastapi.responses import FileResponse
CWD = Path(__file__).parent
IMG_DIR = CWD.parent.joinpath("data", "img")
def read_image(recipe_slug: str) -> FileResponse:
recipe_slug = recipe_slug.split(".")[0]
for file in IMG_DIR.glob(f"{recipe_slug}*"):
return file
def write_image(recipe_slug: str, file_data: bytes, extension: str) -> Path.name:
pass
delete_image(recipe_slug)
image_path = Path(IMG_DIR.joinpath(f"{recipe_slug}.{extension}"))
with open(image_path, "ab") as f:
f.write(file_data)
return image_path
def delete_image(recipe_slug: str) -> str:
recipe_slug = recipe_slug.split(".")[0]
for file in IMG_DIR.glob(f"{recipe_slug}*"):
return file.unlink()
def scrape_image(image_url: str, slug: str) -> Path:
if isinstance(image_url, str): # Handles String Types
image_url = image_url
if isinstance(image_url, list): # Handles List Types
image_url = image_url[0]
if isinstance(image_url, dict): # Handles Dictionary Types
for key in image_url:
if key == "url":
image_url = image_url.get("url")
filename = slug + "." + image_url.split(".")[-1]
filename = IMG_DIR.joinpath(filename)
try:
r = requests.get(image_url, stream=True)
except:
return None
if r.status_code == 200:
r.raw.decode_content = True
with open(filename, "wb") as f:
shutil.copyfileobj(r.raw, f)
return filename
return None

View File

@@ -0,0 +1,153 @@
import json
from datetime import date, timedelta
from pathlib import Path
from typing import List, Optional
from db.meal_models import MealDocument, MealPlanDocument
from pydantic import BaseModel
from services.recipe_services import Recipe
CWD = Path(__file__).parent
THIS_WEEK = CWD.parent.joinpath("data", "meal_plan", "this_week.json")
NEXT_WEEK = CWD.parent.joinpath("data", "meal_plan", "next_week.json")
WEEKDAYS = [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
class Meal(BaseModel):
slug: str
name: Optional[str]
date: Optional[date]
dateText: str
image: Optional[str]
description: Optional[str]
class MealData(BaseModel):
slug: str
dateText: str
class MealPlan(BaseModel):
uid: Optional[str]
startDate: date
endDate: date
meals: List[Meal]
class Config:
schema_extra = {
"example": {
"startDate": date.today(),
"endDate": date.today(),
"meals": [
{"slug": "Packed Mac and Cheese", "date": date.today()},
{"slug": "Eggs and Toast", "date": date.today()},
],
}
}
def process_meals(self):
meals = []
for x, meal in enumerate(self.meals):
recipe = Recipe.get_by_slug(meal.slug)
meal_data = {
"slug": recipe.slug,
"name": recipe.name,
"date": self.startDate + timedelta(days=x),
"dateText": meal.dateText,
"image": recipe.image,
"description": recipe.description,
}
meals.append(Meal(**meal_data))
self.meals = meals
def save_to_db(self):
meal_docs = []
for meal in self.meals:
meal = meal.dict()
meal_doc = MealDocument(**meal)
meal_docs.append(meal_doc)
self.meals = meal_docs
meal_plan = MealPlanDocument(**self.dict())
meal_plan.save()
@staticmethod
def get_all() -> List:
all_meals = []
for plan in MealPlanDocument.objects.order_by("startDate"):
all_meals.append(MealPlan._unpack_doc(plan))
print(all_meals)
return all_meals
def update(self, uid):
document = MealPlanDocument.objects.get(uid=uid)
meal_docs = []
for meal in self.meals:
meal = meal.dict()
meal_doc = MealDocument(**meal)
meal_docs.append(meal_doc)
self.meals = meal_docs
if document:
document.update(set__meals=self.meals)
document.save()
@staticmethod
def delete(uid):
document = MealPlanDocument.objects.get(uid=uid)
if document:
document.delete()
@staticmethod
def _unpack_doc(document: MealPlanDocument):
meal_plan = json.loads(document.to_json())
del meal_plan["_id"]["$oid"]
print(meal_plan)
meal_plan["uid"] = meal_plan["uid"]["$uuid"]
meal_plan["startDate"] = meal_plan["startDate"]["$date"]
meal_plan["endDate"] = meal_plan["endDate"]["$date"]
meals = []
for meal in meal_plan["meals"]:
meal["date"] = meal["date"]["$date"]
meals.append(Meal(**meal))
meal_plan["meals"] = meals
return MealPlan(**meal_plan)
@staticmethod
def today() -> str:
""" Returns the meal slug for Today """
meal_plan = MealPlanDocument.objects.order_by("startDate").limit(1)
meal_plan = MealPlan._unpack_doc(meal_plan[0])
for meal in meal_plan.meals:
if meal.date == date.today():
return meal.slug
return "No Meal Today"
@staticmethod
def this_week():
meal_plan = MealPlanDocument.objects.order_by("startDate").limit(1)
meal_plan = MealPlan._unpack_doc(meal_plan[0])
return meal_plan

View File

@@ -0,0 +1,178 @@
import datetime
import json
from pathlib import Path
from typing import Any, List, Optional
from db.recipe_models import RecipeDocument
from pydantic import BaseModel, validator
from slugify import slugify
from services.image_services import delete_image
CWD = Path(__file__).parent
ALL_RECIPES = CWD.parent.joinpath("data", "all_recipes.json")
IMG_DIR = CWD.parent.joinpath("data", "img")
class RecipeNote(BaseModel):
title: str
text: str
class RecipeStep(BaseModel):
text: str
class Recipe(BaseModel):
# Standard Schema
name: str
description: Optional[str]
image: Optional[Any]
recipeYield: Optional[str]
recipeIngredient: Optional[list]
recipeInstructions: Optional[list]
totalTime: Optional[Any]
# Mealie Specific
slug: Optional[str] = ""
categories: Optional[List[str]]
tags: Optional[List[str]]
dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]]
rating: Optional[int]
rating: Optional[int]
orgURL: Optional[str]
extras: Optional[List[str]]
class Config:
schema_extra = {
"example": {
"name": "Chicken and Rice With Leeks and Salsa Verde",
"description": "This one-skillet dinner gets deep oniony flavor from lots of leeks cooked down to jammy tenderness.",
"image": "chicken-and-rice-with-leeks-and-salsa-verde.jpg",
"recipeYield": "4 Servings",
"recipeIngredient": [
"1 1/2 lb. skinless, boneless chicken thighs (4-8 depending on size)",
"Kosher salt, freshly ground pepper",
"3 Tbsp. unsalted butter, divided",
],
"recipeInstructions": [
{
"text": "Season chicken with salt and pepper.",
},
],
"slug": "chicken-and-rice-with-leeks-and-salsa-verde",
"tags": ["favorite", "yummy!"],
"categories": ["Dinner", "Pasta"],
"notes": [{"title": "Watch Out!", "text": "Prep the day before!"}],
"orgURL": "https://www.bonappetit.com/recipe/chicken-and-rice-with-leeks-and-salsa-verde",
"rating": 3,
}
}
@validator("slug", always=True, pre=True)
def validate_slug(slug: str, values):
name: str = values["name"]
calc_slug: str = slugify(name)
if slug == calc_slug:
return slug
else:
slug = calc_slug
return slug
@classmethod
def _unpack_doc(cls, document):
document = json.loads(document.to_json())
del document["_id"]
document["dateAdded"] = document["dateAdded"]["$date"]
return cls(**document)
@classmethod
def get_by_slug(_cls, slug: str):
""" Returns a recipe dictionary from the slug """
document = RecipeDocument.objects.get(slug=slug)
return Recipe._unpack_doc(document)
def save_to_db(self) -> str:
recipe_dict = self.dict()
extension = Path(recipe_dict["image"]).suffix
recipe_dict["image"] = recipe_dict.get("slug") + extension
try:
total_time = recipe_dict.get("totalTime")
recipe_dict["totalTime"] = str(total_time)
except:
pass
recipeDoc = RecipeDocument(**recipe_dict)
recipeDoc.save()
return recipeDoc.slug
@staticmethod
def delete(recipe_slug: str) -> str:
""" Removes the recipe from the database by slug """
delete_image(recipe_slug)
document = RecipeDocument.objects.get(slug=recipe_slug)
if document:
document.delete()
return "Document Deleted"
@staticmethod
def update(recipe_slug: str, data: dict) -> dict:
""" Updates the recipe from the database by slug """
document = RecipeDocument.objects.get(slug=recipe_slug)
if document:
document.update(set__name=data.get("name"))
document.update(set__description=data.get("description"))
document.update(set__image=data.get("image"))
document.update(set__recipeYield=data.get("recipeYield"))
document.update(set__recipeIngredient=data.get("recipeIngredient"))
document.update(set__recipeInstructions=data.get("recipeInstructions"))
document.update(set__totalTime=data.get("totalTime"))
document.update(set__categories=data.get("categories"))
document.update(set__tags=data.get("tags"))
document.update(set__notes=data.get("notes"))
document.update(set__orgURL=data.get("orgURL"))
document.update(set__rating=data.get("rating"))
document.update(set__extras=data.get("extras"))
document.save()
def read_requested_values(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 RecipeDocument.objects.order_by("dateAdded").limit(max_results):
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

View File

@@ -0,0 +1,72 @@
import collections
import json
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from utils.logger import logger
from services.backup_services import auto_backup_job
from services.meal_services import MealPlan
from services.recipe_services import Recipe
from services.settings_services import SiteSettings
Cron = collections.namedtuple("Cron", "hours minutes")
def cron_parser(time_str: str) -> Cron:
time = time_str.split(":")
cron = Cron(hours=int(time[0]), minutes=int(time[1]))
return cron
def post_webhooks():
all_settings = SiteSettings.get_site_settings()
if all_settings.webhooks.enabled:
todays_meal = Recipe.get_by_slug(MealPlan.today()).dict()
urls = all_settings.webhooks.webhookURLs
for url in urls:
requests.post(url, json.dumps(todays_meal, default=str))
class Scheduler:
def startup_scheduler(self):
self.scheduler = BackgroundScheduler()
logger.info("----INIT SCHEDULE OBJECT-----")
self.scheduler.start()
self.scheduler.add_job(
auto_backup_job, trigger="cron", hour="3", max_instances=1
)
settings = SiteSettings.get_site_settings()
time = cron_parser(settings.webhooks.webhookTime)
self.webhook = self.scheduler.add_job(
post_webhooks,
trigger="cron",
name="webhooks",
hour=time.hours,
minute=time.minutes,
max_instances=1,
)
logger.info(self.scheduler.print_jobs())
def reschedule_webhooks(self):
"""
Reads the site settings database entry to reschedule the webhooks task
Called after each post to the webhooks endpoint.
"""
settings = SiteSettings.get_site_settings()
time = cron_parser(settings.webhooks.webhookTime)
self.scheduler.reschedule_job(
self.webhook.id,
trigger="cron",
hour=time.hours,
minute=time.minutes,
)
logger.info(self.scheduler.print_jobs())

View File

@@ -0,0 +1,40 @@
from scrape_schema_recipe import scrape_url
from slugify import slugify
from services.image_services import scrape_image
from services.recipe_services import Recipe
def create_from_url(url: str) -> dict:
recipe_data = process_recipe_url(url)
recipe = Recipe(**recipe_data)
return recipe.save_to_db()
def process_recipe_url(url: str) -> dict:
new_recipe: dict = scrape_url(url, python_objects=True)[0]
if not new_recipe:
return "fail" # TODO: Return Better Error Here
slug = slugify(new_recipe["name"])
mealie_tags = {
"slug": slug,
"orgURL": url,
"categories": [],
"tags": [],
"dateAdded": None,
"notes": [],
"extras": [],
}
new_recipe.update(mealie_tags)
try:
img_path = scrape_image(new_recipe.get("image"), slug)
new_recipe["image"] = img_path.name
except:
new_recipe["image"] = None
return new_recipe

View File

@@ -0,0 +1,111 @@
import json
from typing import List, Optional
from db.settings_models import (SiteSettingsDocument, SiteThemeDocument,
ThemeColorsDocument, WebhooksDocument)
from pydantic import BaseModel
class Webhooks(BaseModel):
webhookTime: str
webhookURLs: Optional[List[str]]
enabled: bool
@staticmethod
def run():
pass
class SiteSettings(BaseModel):
name: str = "main"
webhooks: Webhooks
@staticmethod
def _unpack_doc(document: SiteSettingsDocument):
document = json.loads(document.to_json())
del document["_id"]
document["webhhooks"] = Webhooks(**document["webhooks"])
return SiteSettings(**document)
@staticmethod
def get_site_settings():
try:
document = SiteSettingsDocument.objects.get(name="main")
except:
webhooks = WebhooksDocument()
document = SiteSettingsDocument(name="main", webhooks=webhooks)
document.save()
return SiteSettings._unpack_doc(document)
def update(self):
document = SiteSettingsDocument.objects.get(name="main")
new_webhooks = WebhooksDocument(**self.webhooks.dict())
document.update(set__webhooks=new_webhooks)
document.save()
class Colors(BaseModel):
primary: str
accent: str
secondary: str
success: str
info: str
warning: str
error: str
class SiteTheme(BaseModel):
name: str
colors: Colors
@staticmethod
def get_by_name(theme_name):
document = SiteThemeDocument.objects.get(name=theme_name)
return SiteTheme._unpack_doc(document)
@staticmethod
def _unpack_doc(document):
document = json.loads(document.to_json())
del document["_id"]
theme_colors = SiteTheme(**document)
return theme_colors
@staticmethod
def get_all():
all_themes = []
for theme in SiteThemeDocument.objects():
all_themes.append(SiteTheme._unpack_doc(theme))
return all_themes
def save_to_db(self):
theme = self.dict()
theme["colors"] = ThemeColorsDocument(**theme["colors"])
theme_document = SiteThemeDocument(**theme)
theme_document.save()
def update_document(self):
theme = self.dict()
theme["colors"] = ThemeColorsDocument(**theme["colors"])
theme_document = SiteThemeDocument.objects.get(name=self.name)
if theme_document:
theme_document.update(set__colors=theme["colors"])
theme_document.save()
@staticmethod
def delete_theme(theme_name: str) -> str:
""" Removes the theme by name """
document = SiteThemeDocument.objects.get(name=theme_name)
if document:
document.delete()
return "Document Deleted"

20
mealie/settings.py Normal file
View File

@@ -0,0 +1,20 @@
import os
from pathlib import Path
import dotenv
CWD = Path(__file__).parent
ENV = CWD.joinpath(".env")
dotenv.load_dotenv(ENV)
PORT = 9000
# Mongo Database
DB_USERNAME = os.getenv("db_username", "root")
DB_PASSWORD = os.getenv("db_password", "example")
DB_HOST = os.getenv("db_host", "mongo")
DB_PORT = os.getenv("db_port", 27017)
# SFTP Email Stuff
SFTP_USERNAME = os.getenv("sftp_username", None)
SFTP_PASSWORD = os.getenv("sftp_password", None)

View File

@@ -0,0 +1 @@
import datetime

24
mealie/utils/logger.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
from pathlib import Path
LOGGER_LEVEL = "INFO"
CWD = Path(__file__).parent
LOGGER_FILE = CWD.parent.joinpath("data", "mealie.log")
logging.basicConfig(
level=LOGGER_LEVEL,
format="%(asctime)s %(levelname)s: %(message)s",
datefmt="%d-%b-%y %H:%M:%S",
filename=LOGGER_FILE,
)
logger = logging.getLogger(__name__)
""" Logging Cheat Sheet
logger.debug("this is a debugging message")
logger.info("this is an informational message")
logger.warning("this is a warning message")
logger.error("this is an error message")
logger.critical("this is a critical message")
"""

32
mealie/utils/snackbar.py Normal file
View File

@@ -0,0 +1,32 @@
class SnackResponse:
@staticmethod
def _create_response(message: str, type: str) -> dict:
return {"snackbar": {"text": message, "type": type}}
@staticmethod
def primary(message: str) -> dict:
return SnackResponse._create_response(message, "primary")
@staticmethod
def accent(message: str) -> dict:
return SnackResponse._create_response(message, "accent")
@staticmethod
def secondary(message: str) -> dict:
return SnackResponse._create_response(message, "secondary")
@staticmethod
def success(message: str) -> dict:
return SnackResponse._create_response(message, "success")
@staticmethod
def info(message: str) -> dict:
return SnackResponse._create_response(message, "info")
@staticmethod
def warning(message: str) -> dict:
return SnackResponse._create_response(message, "warning")
@staticmethod
def error(message: str) -> dict:
return SnackResponse._create_response(message, "error")

5
mealie/web/config.js Normal file
View File

@@ -0,0 +1,5 @@
const config = (() => {
return {
"VUE_APP_API_BASE_URL": "REPLACE_ME",
};
})();

View File

@@ -0,0 +1 @@
.card-btn{margin-top:-10px}.disabled-card{opacity:1%}.img-input{position:absolute;bottom:0}

File diff suppressed because it is too large Load Diff

BIN
mealie/web/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

1
mealie/web/index.html Normal file
View File

@@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/static/favicon.ico"><title>frontend</title><link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css"><link href="/static/css/app.e50b23f1.css" rel="preload" as="style"><link href="/static/css/chunk-vendors.e0416589.css" rel="preload" as="style"><link href="/static/js/app.b457c0af.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.a435ad20.js" rel="preload" as="script"><link href="/static/css/chunk-vendors.e0416589.css" rel="stylesheet"><link href="/static/css/app.e50b23f1.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.a435ad20.js"></script><script src="/static/js/app.b457c0af.js"></script></body></html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long