Skip to content

Commit

Permalink
Merge branch 'main' into weblate-kitchenowl-kitchenowl
Browse files Browse the repository at this point in the history
  • Loading branch information
TomBursch authored Sep 12, 2024
2 parents 9b498c5 + 8d55606 commit 22cc937
Show file tree
Hide file tree
Showing 104 changed files with 977 additions and 360 deletions.
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

STORAGE_PATH = os.getenv("STORAGE_PATH", PROJECT_DIR)
UPLOAD_FOLDER = STORAGE_PATH + "/upload"
ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif"}
ALLOWED_FILE_EXTENSIONS = {"txt", "pdf", "png", "jpg", "jpeg", "gif", "webp", "jxl"}

FRONT_URL = os.getenv("FRONT_URL")

Expand Down
72 changes: 12 additions & 60 deletions backend/app/controller/recipe/recipe_controller.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import re

from app.errors import NotFoundRequest, InvalidUsage
from app.models import Household, RecipeItems, RecipeTags
from flask import jsonify, Blueprint
from flask_jwt_extended import jwt_required
from app.helpers import validate_args, authorize_household
from app.models import Recipe, Item, Tag
from recipe_scrapers import scrape_html
from recipe_scrapers._exceptions import SchemaOrgException, NoSchemaFoundInWildMode
from app.service.file_has_access_or_download import file_has_access_or_download
from app.service.ingredient_parsing import parseIngredients
from app.service.recipe_scraping import scrape
from .schemas import (
SearchByNameRequest,
AddRecipe,
Expand All @@ -32,12 +28,13 @@ def getAllRecipes(household_id):


@recipe.route("/<int:id>", methods=["GET"])
@jwt_required()
@jwt_required(optional=True)
def getRecipeById(id):
recipe = Recipe.find_by_id(id)
if not recipe:
raise NotFoundRequest()
recipe.checkAuthorized()
if not recipe.public:
recipe.checkAuthorized()
return jsonify(recipe.obj_to_full_dict())


Expand All @@ -60,6 +57,8 @@ def addRecipe(args, household_id):
recipe.yields = args["yields"]
if "source" in args:
recipe.source = args["source"]
if "public" in args:
recipe.public = args["public"]
if "photo" in args and args["photo"] != recipe.photo:
recipe.photo = file_has_access_or_download(args["photo"], recipe.photo)
recipe.save()
Expand Down Expand Up @@ -109,6 +108,8 @@ def updateRecipe(args, id): # noqa: C901
recipe.yields = args["yields"]
if "source" in args:
recipe.source = args["source"]
if "public" in args:
recipe.public = args["public"]
if "photo" in args and args["photo"] != recipe.photo:
recipe.photo = file_has_access_or_download(args["photo"], recipe.photo)
recipe.save()
Expand Down Expand Up @@ -197,56 +198,7 @@ def scrapeRecipe(args, household_id):
if not household:
raise NotFoundRequest()

try:
scraper = scrape_html(args["url"], wild_mode=True)
except:
return "Unsupported website", 400
recipe = Recipe()
recipe.name = scraper.title()
try:
recipe.time = int(scraper.total_time())
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
pass
try:
recipe.cook_time = int(scraper.cook_time())
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
pass
try:
recipe.prep_time = int(scraper.prep_time())
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
pass
try:
yields = re.search(r"\d*", scraper.yields())
if yields:
recipe.yields = int(yields.group())
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
pass
description = ""
try:
description = scraper.description() + "\n\n"
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
pass
try:
description = description + scraper.instructions()
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
pass
recipe.description = description
recipe.photo = scraper.image()
recipe.source = args["url"]
items = {}
for ingredient in parseIngredients(scraper.ingredients(), household.language):
name = ingredient.name if ingredient.name else ingredient.originalText
item = Item.find_name_starts_with(household_id, name)
if item:
items[ingredient.originalText] = item.obj_to_dict() | {
"description": ingredient.description,
"optional": False,
}
else:
items[ingredient.originalText] = None
return jsonify(
{
"recipe": recipe.obj_to_dict(),
"items": items,
}
)
res = scrape(args["url"], household)
if res:
return jsonify(res)
return "Unsupported website", 400
2 changes: 2 additions & 0 deletions backend/app/controller/recipe/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class RecipeItem(Schema):
yields = fields.Integer(validate=lambda a: a >= 0)
source = fields.String()
photo = fields.String()
public = fields.Bool()
items = fields.List(fields.Nested(RecipeItem()))
tags = fields.List(fields.String())

Expand All @@ -33,6 +34,7 @@ class RecipeItem(Schema):
yields = fields.Integer(validate=lambda a: a >= 0)
source = fields.String()
photo = fields.String()
public = fields.Bool()
items = fields.List(fields.Nested(RecipeItem()))
tags = fields.List(fields.String())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ def addRecipeItems(args, id):
try:
for recipeItem in args["items"]:
item = Item.find_by_id(recipeItem["id"])
item.checkAuthorized()
if item:
description = recipeItem["description"]
con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id)
Expand Down Expand Up @@ -423,4 +424,4 @@ def addRecipeItems(args, id):
db.session.rollback()
raise e

return jsonify(item.obj_to_dict())
return jsonify({"msg": "DONE"})
18 changes: 2 additions & 16 deletions backend/app/controller/upload/upload_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,14 @@ def upload_file():


@upload.route("<filename>", methods=["GET"])
@jwt_required()
@jwt_required(optional=True)
def download_file(filename):
filename = secure_filename(filename)
f: File = File.query.filter(File.filename == filename).first()

if not f:
raise NotFoundRequest()

if f.household or f.recipe:
household_id = None
if f.household:
household_id = f.household.id
if f.recipe:
household_id = f.recipe.household_id
if f.expense:
household_id = f.expense.household_id
f.checkAuthorized(household_id=household_id)
elif f.created_by and current_user and f.created_by == current_user.id:
pass # created by user can access his pictures
elif f.profile_picture:
pass # profile pictures are public
else:
raise ForbiddenRequest()
f.checkAuthorized()

return send_from_directory(UPLOAD_FOLDER, filename)
2 changes: 1 addition & 1 deletion backend/app/helpers/db_model_authorize_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


class DbModelAuthorizeMixin(object):
def checkAuthorized(self, requires_admin=False, household_id: int = None):
def checkAuthorized(self, requires_admin=False, household_id: int | None = None):
"""
Checks if current user ist authorized to access this model. Throws and unauthorized exception if not
IMPORTANT: requires household_id
Expand Down
18 changes: 18 additions & 0 deletions backend/app/models/file.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from __future__ import annotations
from typing import Self

from flask_jwt_extended import current_user
from app import db
from app.config import UPLOAD_FOLDER
from app.errors import ForbiddenRequest
from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin
from app.models.user import User
import os
Expand Down Expand Up @@ -37,6 +40,21 @@ def isUnused(self) -> bool:
and not self.profile_picture
)

def checkAuthorized(self, requires_admin=False, household_id: int | None = None):
if self.created_by and current_user and self.created_by == current_user.id:
pass # created by user can access his pictures
elif self.profile_picture:
pass # profile pictures are public
elif self.recipe:
if not self.recipe.public:
super().checkAuthorized(household_id=self.recipe.household_id, requires_admin=requires_admin)
elif self.household:
super().checkAuthorized(household_id=self.household.id, requires_admin=requires_admin)
elif self.expense:
super().checkAuthorized(household_id=self.expense.household_id, requires_admin=requires_admin)
else:
raise ForbiddenRequest()

@classmethod
def find(cls, filename: str) -> Self:
"""
Expand Down
23 changes: 11 additions & 12 deletions backend/app/models/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,17 +83,16 @@ def find_all(cls) -> list[Self]:
def get_recent(cls, shoppinglist_id: int, limit: int = 9) -> list[Self]:
sq = db.session.query(ShoppinglistItems.item_id).subquery().select()
sq2 = (
db.session.query(func.max(cls.id))
.filter(cls.status == Status.DROPPED)
.filter(cls.item_id.notin_(sq))
.group_by(cls.item_id)
.join(cls.item)
.subquery()
.select()
)
return (
cls.query.filter(cls.shoppinglist_id == shoppinglist_id)
.filter(cls.id.in_(sq2))
.order_by(cls.created_at.desc(), cls.item_id)
cls.query.filter(
cls.shoppinglist_id == shoppinglist_id,
cls.status == Status.DROPPED,
cls.item_id.notin_(sq),
)
.distinct(cls.item_id)
.order_by(cls.item_id, cls.created_at.desc())
.limit(limit)
.subquery()
)
alias = db.aliased(cls, sq2)
q = db.session.query(alias).order_by(alias.created_at.desc())
return q.all()
6 changes: 6 additions & 0 deletions backend/app/models/household.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ def obj_to_dict(self) -> dict:
if self.photo_file:
res["photo_hash"] = self.photo_file.blur_hash
return res

def obj_to_public_dict(self) -> dict:
res = super().obj_to_dict(include_columns=["id", "name", "photo", "language"])
if self.photo_file:
res["photo_hash"] = self.photo_file.blur_hash
return res

def obj_to_export_dict(self) -> dict:
return {
Expand Down
2 changes: 2 additions & 0 deletions backend/app/models/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin):
prep_time = db.Column(db.Integer)
yields = db.Column(db.Integer)
source = db.Column(db.String())
public = db.Column(db.Boolean(), nullable=False, default=False)
suggestion_score = db.Column(db.Integer, server_default="0")
suggestion_rank = db.Column(db.Integer, server_default="0")
household_id = db.Column(
Expand Down Expand Up @@ -53,6 +54,7 @@ def obj_to_full_dict(self) -> dict:
res = self.obj_to_dict()
res["items"] = [e.obj_to_item_dict() for e in self.items]
res["tags"] = [e.obj_to_item_dict() for e in self.tags]
res["household"] = self.household.obj_to_public_dict()
return res

def obj_to_export_dict(self) -> dict:
Expand Down
12 changes: 10 additions & 2 deletions backend/app/service/file_has_access_or_download.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import shutil
import uuid
import requests
import blurhash
Expand All @@ -10,7 +11,7 @@
from werkzeug.utils import secure_filename


def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str:
def file_has_access_or_download(newPhoto: str, oldPhoto: str | None = None) -> str | None:
"""
Downloads the file if the url is an external URL or checks if the user has access to the file on this server
If the user has no access oldPhoto is returned
Expand Down Expand Up @@ -39,6 +40,13 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str:
if not newPhoto:
return None
f = File.find(newPhoto)
if f and (f.created_by == current_user.id or current_user.admin):
if f and f.isUnused() and (f.created_by == current_user.id or current_user.admin):
return f.filename
elif f:
f.checkAuthorized()
filename = secure_filename(str(uuid.uuid4()) + "." + f.filename.split(".")[-1])
shutil.copyfile(os.path.join(UPLOAD_FOLDER, f.filename), os.path.join(UPLOAD_FOLDER, filename))
File(filename=filename, blur_hash=f.blur_hash, created_by=current_user.id).save()
return filename

return oldPhoto
47 changes: 28 additions & 19 deletions backend/app/service/ingredient_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@
LLM_MODEL = os.getenv("LLM_MODEL")
LLM_API_URL = os.getenv("LLM_API_URL")


class IngredientParsingResult:
originalText: str = None
name: str = None
description: str = None
originalText: str | None = None
name: str | None = None
description: str | None = None

def __init__(self, original_text, name, description):
self.originalText = original_text
Expand All @@ -34,8 +35,8 @@ def parseNLPSingle(ingredient):


def parseLLM(
ingredients: list[str], targetLanguageCode: str = None
) -> list[IngredientParsingResult]:
ingredients: list[str], targetLanguageCode: str | None = None
) -> list[IngredientParsingResult] | None:
systemMessage = """
You are a tool that returns only JSON in the form of [{"name": name, "description": description}, ...]. Split every string from the list into these two properties. You receive recipe ingredients and fill the name field with the singular name of the ingredient and everything else is the description. Translate the response into the specified language.
Expand All @@ -50,24 +51,32 @@ def parseLLM(
else ""
)

messages = [
{
"role": "system",
"content": systemMessage,
}
]
if targetLanguageCode in SUPPORTED_LANGUAGES:
messages.append(
{
"role": "user",
"content": f"Translate the response to {SUPPORTED_LANGUAGES[targetLanguageCode]}. Translate the JSON content to {SUPPORTED_LANGUAGES[targetLanguageCode]}. Your target language is {SUPPORTED_LANGUAGES[targetLanguageCode]}. Respond in {SUPPORTED_LANGUAGES[targetLanguageCode]} from the start.",
}
)

messages.append(
{
"role": "user",
"content": json.dumps(ingredients),
}
)

response = completion(
model=LLM_MODEL,
api_base=LLM_API_URL,
# response_format={"type": "json_object"},
messages=[
{
"role": "system",
"content": systemMessage,
},
{
"role": "user",
"content": f"Translate the response to {SUPPORTED_LANGUAGES[targetLanguageCode]}. Translate the JSON content to {SUPPORTED_LANGUAGES[targetLanguageCode]}. Your target language is {SUPPORTED_LANGUAGES[targetLanguageCode]}. Respond in {SUPPORTED_LANGUAGES[targetLanguageCode]} from the start.",
},
{
"role": "user",
"content": json.dumps(ingredients),
},
],
messages=messages,
)

llmResponse = json.loads(response.choices[0].message.content)
Expand Down
Loading

0 comments on commit 22cc937

Please sign in to comment.