Skip to content

Commit 510bd27

Browse files
authored
feat: Recipe sharing (#505)
1 parent 14e07ed commit 510bd27

33 files changed

+841
-271
lines changed

backend/app/controller/recipe/recipe_controller.py

Lines changed: 12 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
1-
import re
2-
31
from app.errors import NotFoundRequest, InvalidUsage
42
from app.models import Household, RecipeItems, RecipeTags
53
from flask import jsonify, Blueprint
64
from flask_jwt_extended import jwt_required
75
from app.helpers import validate_args, authorize_household
86
from app.models import Recipe, Item, Tag
9-
from recipe_scrapers import scrape_html
10-
from recipe_scrapers._exceptions import SchemaOrgException, NoSchemaFoundInWildMode
117
from app.service.file_has_access_or_download import file_has_access_or_download
12-
from app.service.ingredient_parsing import parseIngredients
8+
from app.service.recipe_scraping import scrape
139
from .schemas import (
1410
SearchByNameRequest,
1511
AddRecipe,
@@ -32,12 +28,13 @@ def getAllRecipes(household_id):
3228

3329

3430
@recipe.route("/<int:id>", methods=["GET"])
35-
@jwt_required()
31+
@jwt_required(optional=True)
3632
def getRecipeById(id):
3733
recipe = Recipe.find_by_id(id)
3834
if not recipe:
3935
raise NotFoundRequest()
40-
recipe.checkAuthorized()
36+
if not recipe.public:
37+
recipe.checkAuthorized()
4138
return jsonify(recipe.obj_to_full_dict())
4239

4340

@@ -60,6 +57,8 @@ def addRecipe(args, household_id):
6057
recipe.yields = args["yields"]
6158
if "source" in args:
6259
recipe.source = args["source"]
60+
if "public" in args:
61+
recipe.public = args["public"]
6362
if "photo" in args and args["photo"] != recipe.photo:
6463
recipe.photo = file_has_access_or_download(args["photo"], recipe.photo)
6564
recipe.save()
@@ -109,6 +108,8 @@ def updateRecipe(args, id): # noqa: C901
109108
recipe.yields = args["yields"]
110109
if "source" in args:
111110
recipe.source = args["source"]
111+
if "public" in args:
112+
recipe.public = args["public"]
112113
if "photo" in args and args["photo"] != recipe.photo:
113114
recipe.photo = file_has_access_or_download(args["photo"], recipe.photo)
114115
recipe.save()
@@ -197,56 +198,7 @@ def scrapeRecipe(args, household_id):
197198
if not household:
198199
raise NotFoundRequest()
199200

200-
try:
201-
scraper = scrape_html(args["url"], wild_mode=True)
202-
except:
203-
return "Unsupported website", 400
204-
recipe = Recipe()
205-
recipe.name = scraper.title()
206-
try:
207-
recipe.time = int(scraper.total_time())
208-
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
209-
pass
210-
try:
211-
recipe.cook_time = int(scraper.cook_time())
212-
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
213-
pass
214-
try:
215-
recipe.prep_time = int(scraper.prep_time())
216-
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
217-
pass
218-
try:
219-
yields = re.search(r"\d*", scraper.yields())
220-
if yields:
221-
recipe.yields = int(yields.group())
222-
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
223-
pass
224-
description = ""
225-
try:
226-
description = scraper.description() + "\n\n"
227-
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
228-
pass
229-
try:
230-
description = description + scraper.instructions()
231-
except (NotImplementedError, ValueError, TypeError, AttributeError, SchemaOrgException):
232-
pass
233-
recipe.description = description
234-
recipe.photo = scraper.image()
235-
recipe.source = args["url"]
236-
items = {}
237-
for ingredient in parseIngredients(scraper.ingredients(), household.language):
238-
name = ingredient.name if ingredient.name else ingredient.originalText
239-
item = Item.find_name_starts_with(household_id, name)
240-
if item:
241-
items[ingredient.originalText] = item.obj_to_dict() | {
242-
"description": ingredient.description,
243-
"optional": False,
244-
}
245-
else:
246-
items[ingredient.originalText] = None
247-
return jsonify(
248-
{
249-
"recipe": recipe.obj_to_dict(),
250-
"items": items,
251-
}
252-
)
201+
res = scrape(args["url"], household)
202+
if res:
203+
return jsonify(res)
204+
return "Unsupported website", 400

backend/app/controller/recipe/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class RecipeItem(Schema):
1515
yields = fields.Integer(validate=lambda a: a >= 0)
1616
source = fields.String()
1717
photo = fields.String()
18+
public = fields.Bool()
1819
items = fields.List(fields.Nested(RecipeItem()))
1920
tags = fields.List(fields.String())
2021

@@ -33,6 +34,7 @@ class RecipeItem(Schema):
3334
yields = fields.Integer(validate=lambda a: a >= 0)
3435
source = fields.String()
3536
photo = fields.String()
37+
public = fields.Bool()
3638
items = fields.List(fields.Nested(RecipeItem()))
3739
tags = fields.List(fields.String())
3840

backend/app/controller/shoppinglist/shoppinglist_controller.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ def addRecipeItems(args, id):
389389
try:
390390
for recipeItem in args["items"]:
391391
item = Item.find_by_id(recipeItem["id"])
392+
item.checkAuthorized()
392393
if item:
393394
description = recipeItem["description"]
394395
con = ShoppinglistItems.find_by_ids(shoppinglist.id, item.id)
@@ -423,4 +424,4 @@ def addRecipeItems(args, id):
423424
db.session.rollback()
424425
raise e
425426

426-
return jsonify(item.obj_to_dict())
427+
return jsonify({"msg": "DONE"})

backend/app/controller/upload/upload_controller.py

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,28 +48,14 @@ def upload_file():
4848

4949

5050
@upload.route("<filename>", methods=["GET"])
51-
@jwt_required()
51+
@jwt_required(optional=True)
5252
def download_file(filename):
5353
filename = secure_filename(filename)
5454
f: File = File.query.filter(File.filename == filename).first()
5555

5656
if not f:
5757
raise NotFoundRequest()
5858

59-
if f.household or f.recipe:
60-
household_id = None
61-
if f.household:
62-
household_id = f.household.id
63-
if f.recipe:
64-
household_id = f.recipe.household_id
65-
if f.expense:
66-
household_id = f.expense.household_id
67-
f.checkAuthorized(household_id=household_id)
68-
elif f.created_by and current_user and f.created_by == current_user.id:
69-
pass # created by user can access his pictures
70-
elif f.profile_picture:
71-
pass # profile pictures are public
72-
else:
73-
raise ForbiddenRequest()
59+
f.checkAuthorized()
7460

7561
return send_from_directory(UPLOAD_FOLDER, filename)

backend/app/helpers/db_model_authorize_mixin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55

66
class DbModelAuthorizeMixin(object):
7-
def checkAuthorized(self, requires_admin=False, household_id: int = None):
7+
def checkAuthorized(self, requires_admin=False, household_id: int | None = None):
88
"""
99
Checks if current user ist authorized to access this model. Throws and unauthorized exception if not
1010
IMPORTANT: requires household_id

backend/app/models/file.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22
from typing import Self
3+
4+
from flask_jwt_extended import current_user
35
from app import db
46
from app.config import UPLOAD_FOLDER
7+
from app.errors import ForbiddenRequest
58
from app.helpers import DbModelMixin, TimestampMixin, DbModelAuthorizeMixin
69
from app.models.user import User
710
import os
@@ -37,6 +40,21 @@ def isUnused(self) -> bool:
3740
and not self.profile_picture
3841
)
3942

43+
def checkAuthorized(self, requires_admin=False, household_id: int | None = None):
44+
if self.created_by and current_user and self.created_by == current_user.id:
45+
pass # created by user can access his pictures
46+
elif self.profile_picture:
47+
pass # profile pictures are public
48+
elif self.recipe:
49+
if not self.recipe.public:
50+
super().checkAuthorized(household_id=self.recipe.household_id, requires_admin=requires_admin)
51+
elif self.household:
52+
super().checkAuthorized(household_id=self.household.id, requires_admin=requires_admin)
53+
elif self.expense:
54+
super().checkAuthorized(household_id=self.expense.household_id, requires_admin=requires_admin)
55+
else:
56+
raise ForbiddenRequest()
57+
4058
@classmethod
4159
def find(cls, filename: str) -> Self:
4260
"""

backend/app/models/household.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ def obj_to_dict(self) -> dict:
4949
if self.photo_file:
5050
res["photo_hash"] = self.photo_file.blur_hash
5151
return res
52+
53+
def obj_to_public_dict(self) -> dict:
54+
res = super().obj_to_dict(include_columns=["id", "name", "photo", "language"])
55+
if self.photo_file:
56+
res["photo_hash"] = self.photo_file.blur_hash
57+
return res
5258

5359
def obj_to_export_dict(self) -> dict:
5460
return {

backend/app/models/recipe.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class Recipe(db.Model, DbModelMixin, TimestampMixin, DbModelAuthorizeMixin):
2020
prep_time = db.Column(db.Integer)
2121
yields = db.Column(db.Integer)
2222
source = db.Column(db.String())
23+
public = db.Column(db.Boolean(), nullable=False, default=False)
2324
suggestion_score = db.Column(db.Integer, server_default="0")
2425
suggestion_rank = db.Column(db.Integer, server_default="0")
2526
household_id = db.Column(
@@ -53,6 +54,7 @@ def obj_to_full_dict(self) -> dict:
5354
res = self.obj_to_dict()
5455
res["items"] = [e.obj_to_item_dict() for e in self.items]
5556
res["tags"] = [e.obj_to_item_dict() for e in self.tags]
57+
res["household"] = self.household.obj_to_public_dict()
5658
return res
5759

5860
def obj_to_export_dict(self) -> dict:

backend/app/service/file_has_access_or_download.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import shutil
23
import uuid
34
import requests
45
import blurhash
@@ -10,7 +11,7 @@
1011
from werkzeug.utils import secure_filename
1112

1213

13-
def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str:
14+
def file_has_access_or_download(newPhoto: str, oldPhoto: str | None = None) -> str | None:
1415
"""
1516
Downloads the file if the url is an external URL or checks if the user has access to the file on this server
1617
If the user has no access oldPhoto is returned
@@ -39,6 +40,13 @@ def file_has_access_or_download(newPhoto: str, oldPhoto: str = None) -> str:
3940
if not newPhoto:
4041
return None
4142
f = File.find(newPhoto)
42-
if f and (f.created_by == current_user.id or current_user.admin):
43+
if f and f.isUnused() and (f.created_by == current_user.id or current_user.admin):
4344
return f.filename
45+
elif f:
46+
f.checkAuthorized()
47+
filename = secure_filename(str(uuid.uuid4()) + "." + f.filename.split(".")[-1])
48+
shutil.copyfile(os.path.join(UPLOAD_FOLDER, f.filename), os.path.join(UPLOAD_FOLDER, filename))
49+
File(filename=filename, blur_hash=f.blur_hash, created_by=current_user.id).save()
50+
return filename
51+
4452
return oldPhoto

backend/app/service/ingredient_parsing.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
LLM_MODEL = os.getenv("LLM_MODEL")
99
LLM_API_URL = os.getenv("LLM_API_URL")
1010

11+
1112
class IngredientParsingResult:
12-
originalText: str = None
13-
name: str = None
14-
description: str = None
13+
originalText: str | None = None
14+
name: str | None = None
15+
description: str | None = None
1516

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

3536

3637
def parseLLM(
37-
ingredients: list[str], targetLanguageCode: str = None
38-
) -> list[IngredientParsingResult]:
38+
ingredients: list[str], targetLanguageCode: str | None = None
39+
) -> list[IngredientParsingResult] | None:
3940
systemMessage = """
4041
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.
4142
@@ -50,24 +51,32 @@ def parseLLM(
5051
else ""
5152
)
5253

54+
messages = [
55+
{
56+
"role": "system",
57+
"content": systemMessage,
58+
}
59+
]
60+
if targetLanguageCode in SUPPORTED_LANGUAGES:
61+
messages.append(
62+
{
63+
"role": "user",
64+
"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.",
65+
}
66+
)
67+
68+
messages.append(
69+
{
70+
"role": "user",
71+
"content": json.dumps(ingredients),
72+
}
73+
)
74+
5375
response = completion(
5476
model=LLM_MODEL,
5577
api_base=LLM_API_URL,
5678
# response_format={"type": "json_object"},
57-
messages=[
58-
{
59-
"role": "system",
60-
"content": systemMessage,
61-
},
62-
{
63-
"role": "user",
64-
"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.",
65-
},
66-
{
67-
"role": "user",
68-
"content": json.dumps(ingredients),
69-
},
70-
],
79+
messages=messages,
7180
)
7281

7382
llmResponse = json.loads(response.choices[0].message.content)

0 commit comments

Comments
 (0)