Merge pull request #2432 from smilerz/new_automations
add NEVER_UNIT automation
This commit is contained in:
@ -3,8 +3,10 @@ import string
|
|||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
|
||||||
from cookbook.models import Unit, Food, Automation, Ingredient
|
from cookbook.models import Automation, Food, Ingredient, Unit
|
||||||
|
|
||||||
|
|
||||||
class IngredientParser:
|
class IngredientParser:
|
||||||
@ -12,6 +14,8 @@ class IngredientParser:
|
|||||||
ignore_rules = False
|
ignore_rules = False
|
||||||
food_aliases = {}
|
food_aliases = {}
|
||||||
unit_aliases = {}
|
unit_aliases = {}
|
||||||
|
never_unit = {}
|
||||||
|
transpose_words = {}
|
||||||
|
|
||||||
def __init__(self, request, cache_mode, ignore_automations=False):
|
def __init__(self, request, cache_mode, ignore_automations=False):
|
||||||
"""
|
"""
|
||||||
@ -29,7 +33,7 @@ class IngredientParser:
|
|||||||
caches['default'].touch(FOOD_CACHE_KEY, 30)
|
caches['default'].touch(FOOD_CACHE_KEY, 30)
|
||||||
else:
|
else:
|
||||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||||
self.food_aliases[a.param_1] = a.param_2
|
self.food_aliases[a.param_1.lower()] = a.param_2
|
||||||
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
|
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
|
||||||
|
|
||||||
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
|
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
|
||||||
@ -38,11 +42,33 @@ class IngredientParser:
|
|||||||
caches['default'].touch(UNIT_CACHE_KEY, 30)
|
caches['default'].touch(UNIT_CACHE_KEY, 30)
|
||||||
else:
|
else:
|
||||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||||
self.unit_aliases[a.param_1] = a.param_2
|
self.unit_aliases[a.param_1.lower()] = a.param_2
|
||||||
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
|
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
|
||||||
|
|
||||||
|
NEVER_UNIT_CACHE_KEY = f'automation_never_unit_{self.request.space.pk}'
|
||||||
|
if c := caches['default'].get(NEVER_UNIT_CACHE_KEY, None):
|
||||||
|
self.never_unit = c
|
||||||
|
caches['default'].touch(NEVER_UNIT_CACHE_KEY, 30)
|
||||||
|
else:
|
||||||
|
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.NEVER_UNIT).only('param_1', 'param_2').order_by('order').all():
|
||||||
|
self.never_unit[a.param_1.lower()] = a.param_2
|
||||||
|
caches['default'].set(NEVER_UNIT_CACHE_KEY, self.never_unit, 30)
|
||||||
|
|
||||||
|
TRANSPOSE_WORDS_CACHE_KEY = f'automation_transpose_words_{self.request.space.pk}'
|
||||||
|
if c := caches['default'].get(TRANSPOSE_WORDS_CACHE_KEY, None):
|
||||||
|
self.transpose_words = c
|
||||||
|
caches['default'].touch(TRANSPOSE_WORDS_CACHE_KEY, 30)
|
||||||
|
else:
|
||||||
|
i = 0
|
||||||
|
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.TRANSPOSE_WORDS).only('param_1', 'param_2').order_by('order').all():
|
||||||
|
self.transpose_words[i] = [a.param_1.lower(), a.param_2.lower()]
|
||||||
|
i += 1
|
||||||
|
caches['default'].set(TRANSPOSE_WORDS_CACHE_KEY, self.transpose_words, 30)
|
||||||
else:
|
else:
|
||||||
self.food_aliases = {}
|
self.food_aliases = {}
|
||||||
self.unit_aliases = {}
|
self.unit_aliases = {}
|
||||||
|
self.never_unit = {}
|
||||||
|
self.transpose_words = {}
|
||||||
|
|
||||||
def apply_food_automation(self, food):
|
def apply_food_automation(self, food):
|
||||||
"""
|
"""
|
||||||
@ -55,11 +81,11 @@ class IngredientParser:
|
|||||||
else:
|
else:
|
||||||
if self.food_aliases:
|
if self.food_aliases:
|
||||||
try:
|
try:
|
||||||
return self.food_aliases[food]
|
return self.food_aliases[food.lower()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return food
|
return food
|
||||||
else:
|
else:
|
||||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first():
|
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first():
|
||||||
return automation.param_2
|
return automation.param_2
|
||||||
return food
|
return food
|
||||||
|
|
||||||
@ -72,13 +98,13 @@ class IngredientParser:
|
|||||||
if self.ignore_rules:
|
if self.ignore_rules:
|
||||||
return unit
|
return unit
|
||||||
else:
|
else:
|
||||||
if self.unit_aliases:
|
if self.transpose_words:
|
||||||
try:
|
try:
|
||||||
return self.unit_aliases[unit]
|
return self.unit_aliases[unit.lower()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return unit
|
return unit
|
||||||
else:
|
else:
|
||||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first():
|
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1__iexact=unit, disabled=False).order_by('order').first():
|
||||||
return automation.param_2
|
return automation.param_2
|
||||||
return unit
|
return unit
|
||||||
|
|
||||||
@ -160,7 +186,8 @@ class IngredientParser:
|
|||||||
if unit is not None and unit.strip() == '':
|
if unit is not None and unit.strip() == '':
|
||||||
unit = None
|
unit = None
|
||||||
|
|
||||||
if unit is not None and (unit.startswith('(') or unit.startswith('-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
|
if unit is not None and (unit.startswith('(') or unit.startswith(
|
||||||
|
'-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
|
||||||
unit = None
|
unit = None
|
||||||
note = x
|
note = x
|
||||||
return amount, unit, note
|
return amount, unit, note
|
||||||
@ -205,6 +232,67 @@ class IngredientParser:
|
|||||||
food, note = self.parse_food_with_comma(tokens)
|
food, note = self.parse_food_with_comma(tokens)
|
||||||
return food, note
|
return food, note
|
||||||
|
|
||||||
|
def apply_never_unit_automations(self, tokens):
|
||||||
|
"""
|
||||||
|
Moves a string that should never be treated as a unit to next token and optionally replaced with default unit
|
||||||
|
e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white']
|
||||||
|
or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk']
|
||||||
|
:param1 string: string that should never be considered a unit, will be moved to token[2]
|
||||||
|
:param2 (optional) unit as string: will insert unit string into token[1]
|
||||||
|
:return: unit as string (possibly changed by automation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.ignore_rules:
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
new_unit = None
|
||||||
|
alt_unit = self.apply_unit_automation(tokens[1])
|
||||||
|
never_unit = False
|
||||||
|
if self.never_unit:
|
||||||
|
try:
|
||||||
|
new_unit = self.never_unit[tokens[1].lower()]
|
||||||
|
never_unit = True
|
||||||
|
except KeyError:
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
else:
|
||||||
|
if automation := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[
|
||||||
|
tokens[1].lower(), alt_unit.lower()], disabled=False).order_by('order').first():
|
||||||
|
new_unit = automation.param_2
|
||||||
|
never_unit = True
|
||||||
|
|
||||||
|
if never_unit:
|
||||||
|
tokens.insert(1, new_unit)
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
def apply_transpose_words_automations(self, ingredient):
|
||||||
|
"""
|
||||||
|
If two words (param_1 & param_2) are detected in sequence, swap their position in the ingredient string
|
||||||
|
:param 1: first word to detect
|
||||||
|
:param 2: second word to detect
|
||||||
|
return: new ingredient string
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.ignore_rules:
|
||||||
|
return ingredient
|
||||||
|
|
||||||
|
else:
|
||||||
|
tokens = [x.lower() for x in ingredient.replace(',', ' ').split()]
|
||||||
|
if self.transpose_words:
|
||||||
|
filtered_rules = {}
|
||||||
|
for key, value in self.transpose_words.items():
|
||||||
|
if value[0] in tokens and value[1] in tokens:
|
||||||
|
filtered_rules[key] = value
|
||||||
|
for k, v in filtered_rules.items():
|
||||||
|
ingredient = re.sub(rf"\b({v[0]})\W*({v[1]})\b", r"\2 \1", ingredient, flags=re.IGNORECASE)
|
||||||
|
else:
|
||||||
|
for rule in Automation.objects.filter(space=self.request.space, type=Automation.TRANSPOSE_WORDS, disabled=False) \
|
||||||
|
.annotate(param_1_lower=Lower('param_1'), param_2_lower=Lower('param_2')) \
|
||||||
|
.filter(Q(Q(param_1_lower__in=tokens) | Q(param_2_lower__in=tokens))).order_by('order'):
|
||||||
|
ingredient = re.sub(rf"\b({rule.param_1})\W*({rule.param_1})\b", r"\2 \1", ingredient, flags=re.IGNORECASE)
|
||||||
|
return ingredient
|
||||||
|
|
||||||
def parse(self, ingredient):
|
def parse(self, ingredient):
|
||||||
"""
|
"""
|
||||||
Main parsing function, takes an ingredient string (e.g. '1 l Water') and extracts amount, unit, food, ...
|
Main parsing function, takes an ingredient string (e.g. '1 l Water') and extracts amount, unit, food, ...
|
||||||
@ -230,8 +318,8 @@ class IngredientParser:
|
|||||||
|
|
||||||
# if the string contains parenthesis early on remove it and place it at the end
|
# if the string contains parenthesis early on remove it and place it at the end
|
||||||
# because its likely some kind of note
|
# because its likely some kind of note
|
||||||
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', ingredient):
|
if re.match('(.){1,6}\\s\\((.[^\\(\\)])+\\)\\s', ingredient):
|
||||||
match = re.search('\((.[^\(])+\)', ingredient)
|
match = re.search('\\((.[^\\(])+\\)', ingredient)
|
||||||
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
|
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
|
||||||
|
|
||||||
# leading spaces before commas result in extra tokens, clean them out
|
# leading spaces before commas result in extra tokens, clean them out
|
||||||
@ -239,12 +327,14 @@ class IngredientParser:
|
|||||||
|
|
||||||
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
|
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
|
||||||
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
|
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
|
||||||
ingredient = re.sub("^(\d+|\d+[\\.,]\d+) - (\d+|\d+[\\.,]\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
|
ingredient = re.sub("^(\\d+|\\d+[\\.,]\\d+) - (\\d+|\\d+[\\.,]\\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
|
||||||
|
|
||||||
# if amount and unit are connected add space in between
|
# if amount and unit are connected add space in between
|
||||||
if re.match('([0-9])+([A-z])+\s', ingredient):
|
if re.match('([0-9])+([A-z])+\\s', ingredient):
|
||||||
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
|
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
|
||||||
|
|
||||||
|
ingredient = self.apply_transpose_words_automations(ingredient)
|
||||||
|
|
||||||
tokens = ingredient.split() # split at each space into tokens
|
tokens = ingredient.split() # split at each space into tokens
|
||||||
if len(tokens) == 1:
|
if len(tokens) == 1:
|
||||||
# there only is one argument, that must be the food
|
# there only is one argument, that must be the food
|
||||||
@ -257,6 +347,7 @@ class IngredientParser:
|
|||||||
# three arguments if it already has a unit there can't be
|
# three arguments if it already has a unit there can't be
|
||||||
# a fraction for the amount
|
# a fraction for the amount
|
||||||
if len(tokens) > 2:
|
if len(tokens) > 2:
|
||||||
|
tokens = self.apply_never_unit_automations(tokens)
|
||||||
try:
|
try:
|
||||||
if unit is not None:
|
if unit is not None:
|
||||||
# a unit is already found, no need to try the second argument for a fraction
|
# a unit is already found, no need to try the second argument for a fraction
|
||||||
|
@ -15,7 +15,6 @@ from recipe_scrapers._utils import get_host_name, get_minutes
|
|||||||
from cookbook.helper.ingredient_parser import IngredientParser
|
from cookbook.helper.ingredient_parser import IngredientParser
|
||||||
from cookbook.models import Automation, Keyword, PropertyType
|
from cookbook.models import Automation, Keyword, PropertyType
|
||||||
|
|
||||||
|
|
||||||
# from unicodedata import decomposition
|
# from unicodedata import decomposition
|
||||||
|
|
||||||
|
|
||||||
@ -51,7 +50,8 @@ def get_from_scraper(scrape, request):
|
|||||||
recipe_json['internal'] = True
|
recipe_json['internal'] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
servings = scrape.schema.data.get('recipeYield') or 1 # dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
|
# dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
|
||||||
|
servings = scrape.schema.data.get('recipeYield') or 1
|
||||||
except Exception:
|
except Exception:
|
||||||
servings = 1
|
servings = 1
|
||||||
|
|
||||||
@ -156,7 +156,14 @@ def get_from_scraper(scrape, request):
|
|||||||
parsed_description = parse_description(description)
|
parsed_description = parse_description(description)
|
||||||
# TODO notify user about limit if reached
|
# TODO notify user about limit if reached
|
||||||
# limits exist to limit the attack surface for dos style attacks
|
# limits exist to limit the attack surface for dos style attacks
|
||||||
automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').all().order_by('order')[:512]
|
automations = Automation.objects.filter(
|
||||||
|
type=Automation.DESCRIPTION_REPLACE,
|
||||||
|
space=request.space,
|
||||||
|
disabled=False).only(
|
||||||
|
'param_1',
|
||||||
|
'param_2',
|
||||||
|
'param_3').all().order_by('order')[
|
||||||
|
:512]
|
||||||
for a in automations:
|
for a in automations:
|
||||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||||
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
|
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
|
||||||
@ -206,7 +213,14 @@ def get_from_scraper(scrape, request):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if 'source_url' in recipe_json and recipe_json['source_url']:
|
if 'source_url' in recipe_json and recipe_json['source_url']:
|
||||||
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
|
automations = Automation.objects.filter(
|
||||||
|
type=Automation.INSTRUCTION_REPLACE,
|
||||||
|
space=request.space,
|
||||||
|
disabled=False).only(
|
||||||
|
'param_1',
|
||||||
|
'param_2',
|
||||||
|
'param_3').order_by('order').all()[
|
||||||
|
:512]
|
||||||
for a in automations:
|
for a in automations:
|
||||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||||
for s in recipe_json['steps']:
|
for s in recipe_json['steps']:
|
||||||
@ -272,7 +286,7 @@ def get_from_youtube_scraper(url, request):
|
|||||||
|
|
||||||
|
|
||||||
def parse_name(name):
|
def parse_name(name):
|
||||||
if type(name) == list:
|
if isinstance(name, list):
|
||||||
try:
|
try:
|
||||||
name = name[0]
|
name = name[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -316,16 +330,16 @@ def parse_instructions(instructions):
|
|||||||
"""
|
"""
|
||||||
instruction_list = []
|
instruction_list = []
|
||||||
|
|
||||||
if type(instructions) == list:
|
if isinstance(instructions, list):
|
||||||
for i in instructions:
|
for i in instructions:
|
||||||
if type(i) == str:
|
if isinstance(i, str):
|
||||||
instruction_list.append(clean_instruction_string(i))
|
instruction_list.append(clean_instruction_string(i))
|
||||||
else:
|
else:
|
||||||
if 'text' in i:
|
if 'text' in i:
|
||||||
instruction_list.append(clean_instruction_string(i['text']))
|
instruction_list.append(clean_instruction_string(i['text']))
|
||||||
elif 'itemListElement' in i:
|
elif 'itemListElement' in i:
|
||||||
for ile in i['itemListElement']:
|
for ile in i['itemListElement']:
|
||||||
if type(ile) == str:
|
if isinstance(ile, str):
|
||||||
instruction_list.append(clean_instruction_string(ile))
|
instruction_list.append(clean_instruction_string(ile))
|
||||||
elif 'text' in ile:
|
elif 'text' in ile:
|
||||||
instruction_list.append(clean_instruction_string(ile['text']))
|
instruction_list.append(clean_instruction_string(ile['text']))
|
||||||
@ -341,13 +355,13 @@ def parse_image(image):
|
|||||||
# check if list of images is returned, take first if so
|
# check if list of images is returned, take first if so
|
||||||
if not image:
|
if not image:
|
||||||
return None
|
return None
|
||||||
if type(image) == list:
|
if isinstance(image, list):
|
||||||
for pic in image:
|
for pic in image:
|
||||||
if (type(pic) == str) and (pic[:4] == 'http'):
|
if (isinstance(pic, str)) and (pic[:4] == 'http'):
|
||||||
image = pic
|
image = pic
|
||||||
elif 'url' in pic:
|
elif 'url' in pic:
|
||||||
image = pic['url']
|
image = pic['url']
|
||||||
elif type(image) == dict:
|
elif isinstance(image, dict):
|
||||||
if 'url' in image:
|
if 'url' in image:
|
||||||
image = image['url']
|
image = image['url']
|
||||||
|
|
||||||
@ -358,12 +372,12 @@ def parse_image(image):
|
|||||||
|
|
||||||
|
|
||||||
def parse_servings(servings):
|
def parse_servings(servings):
|
||||||
if type(servings) == str:
|
if isinstance(servings, str):
|
||||||
try:
|
try:
|
||||||
servings = int(re.search(r'\d+', servings).group())
|
servings = int(re.search(r'\d+', servings).group())
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
servings = 1
|
servings = 1
|
||||||
elif type(servings) == list:
|
elif isinstance(servings, list):
|
||||||
try:
|
try:
|
||||||
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
||||||
except KeyError:
|
except KeyError:
|
||||||
@ -372,12 +386,12 @@ def parse_servings(servings):
|
|||||||
|
|
||||||
|
|
||||||
def parse_servings_text(servings):
|
def parse_servings_text(servings):
|
||||||
if type(servings) == str:
|
if isinstance(servings, str):
|
||||||
try:
|
try:
|
||||||
servings = re.sub("\d+", '', servings).strip()
|
servings = re.sub("\\d+", '', servings).strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
servings = ''
|
servings = ''
|
||||||
if type(servings) == list:
|
if isinstance(servings, list):
|
||||||
try:
|
try:
|
||||||
servings = parse_servings_text(servings[1])
|
servings = parse_servings_text(servings[1])
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -394,7 +408,7 @@ def parse_time(recipe_time):
|
|||||||
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
|
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
|
||||||
except ISO8601Error:
|
except ISO8601Error:
|
||||||
try:
|
try:
|
||||||
if (type(recipe_time) == list and len(recipe_time) > 0):
|
if (isinstance(recipe_time, list) and len(recipe_time) > 0):
|
||||||
recipe_time = recipe_time[0]
|
recipe_time = recipe_time[0]
|
||||||
recipe_time = round(parse_duration(recipe_time).seconds / 60)
|
recipe_time = round(parse_duration(recipe_time).seconds / 60)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
@ -413,7 +427,7 @@ def parse_keywords(keyword_json, space):
|
|||||||
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
|
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
|
||||||
else:
|
else:
|
||||||
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||||
keyword_aliases[a.param_1] = a.param_2
|
keyword_aliases[a.param_1.lower()] = a.param_2
|
||||||
caches['default'].set(KEYWORD_CACHE_KEY, keyword_aliases, 30)
|
caches['default'].set(KEYWORD_CACHE_KEY, keyword_aliases, 30)
|
||||||
|
|
||||||
# keywords as list
|
# keywords as list
|
||||||
@ -424,7 +438,7 @@ def parse_keywords(keyword_json, space):
|
|||||||
if len(kw) != 0:
|
if len(kw) != 0:
|
||||||
if keyword_aliases:
|
if keyword_aliases:
|
||||||
try:
|
try:
|
||||||
kw = keyword_aliases[kw]
|
kw = keyword_aliases[kw.lower()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||||
@ -438,15 +452,15 @@ def parse_keywords(keyword_json, space):
|
|||||||
def listify_keywords(keyword_list):
|
def listify_keywords(keyword_list):
|
||||||
# keywords as string
|
# keywords as string
|
||||||
try:
|
try:
|
||||||
if type(keyword_list[0]) == dict:
|
if isinstance(keyword_list[0], dict):
|
||||||
return keyword_list
|
return keyword_list
|
||||||
except (KeyError, IndexError):
|
except (KeyError, IndexError):
|
||||||
pass
|
pass
|
||||||
if type(keyword_list) == str:
|
if isinstance(keyword_list, str):
|
||||||
keyword_list = keyword_list.split(',')
|
keyword_list = keyword_list.split(',')
|
||||||
|
|
||||||
# keywords as string in list
|
# keywords as string in list
|
||||||
if (type(keyword_list) == list and len(keyword_list) == 1 and ',' in keyword_list[0]):
|
if (isinstance(keyword_list, list) and len(keyword_list) == 1 and ',' in keyword_list[0]):
|
||||||
keyword_list = keyword_list[0].split(',')
|
keyword_list = keyword_list[0].split(',')
|
||||||
return [x.strip() for x in keyword_list]
|
return [x.strip() for x in keyword_list]
|
||||||
|
|
||||||
@ -500,13 +514,13 @@ def get_images_from_soup(soup, url):
|
|||||||
|
|
||||||
|
|
||||||
def clean_dict(input_dict, key):
|
def clean_dict(input_dict, key):
|
||||||
if type(input_dict) == dict:
|
if isinstance(input_dict, dict):
|
||||||
for x in list(input_dict):
|
for x in list(input_dict):
|
||||||
if x == key:
|
if x == key:
|
||||||
del input_dict[x]
|
del input_dict[x]
|
||||||
elif type(input_dict[x]) == dict:
|
elif isinstance(input_dict[x], dict):
|
||||||
input_dict[x] = clean_dict(input_dict[x], key)
|
input_dict[x] = clean_dict(input_dict[x], key)
|
||||||
elif type(input_dict[x]) == list:
|
elif isinstance(input_dict[x], list):
|
||||||
temp_list = []
|
temp_list = []
|
||||||
for e in input_dict[x]:
|
for e in input_dict[x]:
|
||||||
temp_list.append(clean_dict(e, key))
|
temp_list.append(clean_dict(e, key))
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 4.1.10 on 2023-08-25 13:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('cookbook', '0198_propertytype_order'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='automation',
|
||||||
|
name='type',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('FOOD_ALIAS',
|
||||||
|
'Food Alias'),
|
||||||
|
('UNIT_ALIAS',
|
||||||
|
'Unit Alias'),
|
||||||
|
('KEYWORD_ALIAS',
|
||||||
|
'Keyword Alias'),
|
||||||
|
('DESCRIPTION_REPLACE',
|
||||||
|
'Description Replace'),
|
||||||
|
('INSTRUCTION_REPLACE',
|
||||||
|
'Instruction Replace'),
|
||||||
|
('NEVER_UNIT',
|
||||||
|
'Never Unit'),
|
||||||
|
('TRANSPOSE_WORDS',
|
||||||
|
'Transpose Words')],
|
||||||
|
max_length=128),
|
||||||
|
),
|
||||||
|
]
|
@ -5,7 +5,6 @@ import uuid
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import oauth2_provider.models
|
import oauth2_provider.models
|
||||||
from PIL import Image
|
|
||||||
from annoying.fields import AutoOneToOneField
|
from annoying.fields import AutoOneToOneField
|
||||||
from django.contrib import auth
|
from django.contrib import auth
|
||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
@ -14,13 +13,14 @@ from django.contrib.postgres.search import SearchVectorField
|
|||||||
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
|
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
|
||||||
from django.core.validators import MinLengthValidator
|
from django.core.validators import MinLengthValidator
|
||||||
from django.db import IntegrityError, models
|
from django.db import IntegrityError, models
|
||||||
from django.db.models import Index, ProtectedError, Q, Avg, Max
|
from django.db.models import Avg, Index, Max, ProtectedError, Q
|
||||||
from django.db.models.fields.related import ManyToManyField
|
from django.db.models.fields.related import ManyToManyField
|
||||||
from django.db.models.functions import Substr
|
from django.db.models.functions import Substr
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django_prometheus.models import ExportModelOperationsMixin
|
from django_prometheus.models import ExportModelOperationsMixin
|
||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
|
from PIL import Image
|
||||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||||
|
|
||||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
||||||
@ -770,7 +770,8 @@ class PropertyType(models.Model, PermissionModelMixin):
|
|||||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||||
order = models.IntegerField(default=0)
|
order = models.IntegerField(default=0)
|
||||||
description = models.CharField(max_length=512, blank=True, null=True)
|
description = models.CharField(max_length=512, blank=True, null=True)
|
||||||
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')), (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
|
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')),
|
||||||
|
(PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
|
||||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||||
|
|
||||||
# TODO show if empty property?
|
# TODO show if empty property?
|
||||||
@ -1319,10 +1320,13 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
|||||||
KEYWORD_ALIAS = 'KEYWORD_ALIAS'
|
KEYWORD_ALIAS = 'KEYWORD_ALIAS'
|
||||||
DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE'
|
DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE'
|
||||||
INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE'
|
INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE'
|
||||||
|
NEVER_UNIT = 'NEVER_UNIT'
|
||||||
|
TRANSPOSE_WORDS = 'TRANSPOSE_WORDS'
|
||||||
|
|
||||||
type = models.CharField(max_length=128,
|
type = models.CharField(max_length=128,
|
||||||
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')),
|
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')),
|
||||||
(DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')),))
|
(DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')),
|
||||||
|
(NEVER_UNIT, _('Never Unit')), (TRANSPOSE_WORDS, _('Transpose Words')),))
|
||||||
name = models.CharField(max_length=128, default='')
|
name = models.CharField(max_length=128, default='')
|
||||||
description = models.TextField(blank=True, null=True)
|
description = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
|
@ -3,11 +3,11 @@
|
|||||||
issues while working on them, I might change how they work breaking existing automations.
|
issues while working on them, I might change how they work breaking existing automations.
|
||||||
I will try to avoid this and am pretty confident it won't happen.
|
I will try to avoid this and am pretty confident it won't happen.
|
||||||
|
|
||||||
|
|
||||||
Automations allow Tandoor to automatically perform certain tasks, especially when importing recipes, that
|
Automations allow Tandoor to automatically perform certain tasks, especially when importing recipes, that
|
||||||
would otherwise have to be done manually. Currently, the following automations are supported.
|
would otherwise have to be done manually. Currently, the following automations are supported.
|
||||||
|
|
||||||
## Unit, Food, Keyword Alias
|
## Unit, Food, Keyword Alias
|
||||||
|
|
||||||
Foods, Units and Keywords can have automations that automatically replace them with another object
|
Foods, Units and Keywords can have automations that automatically replace them with another object
|
||||||
to allow aliasing them.
|
to allow aliasing them.
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ These automations are best created by dragging and dropping Foods, Units or Keyw
|
|||||||
views and creating the automation there.
|
views and creating the automation there.
|
||||||
|
|
||||||
You can also create them manually by setting the following
|
You can also create them manually by setting the following
|
||||||
|
|
||||||
- **Parameter 1**: name of food/unit/keyword to match
|
- **Parameter 1**: name of food/unit/keyword to match
|
||||||
- **Parameter 2**: name of food/unit/keyword to replace matched food with
|
- **Parameter 2**: name of food/unit/keyword to replace matched food with
|
||||||
|
|
||||||
@ -25,7 +26,8 @@ These rules are processed whenever you are importing recipes from websites or ot
|
|||||||
and when using the simple ingredient input (shopping, recipe editor, ...).
|
and when using the simple ingredient input (shopping, recipe editor, ...).
|
||||||
|
|
||||||
## Description Replace
|
## Description Replace
|
||||||
This automation is a bit more complicated than the alis rules. It is run when importing a recipe
|
|
||||||
|
This automation is a bit more complicated than the alias rules. It is run when importing a recipe
|
||||||
from a website.
|
from a website.
|
||||||
|
|
||||||
It uses Regular Expressions (RegEx) to determine if a description should be altered, what exactly to remove
|
It uses Regular Expressions (RegEx) to determine if a description should be altered, what exactly to remove
|
||||||
@ -46,17 +48,45 @@ To test out your patterns and learn about RegEx you can use [regexr.com](https:/
|
|||||||
during normal usage.
|
during normal usage.
|
||||||
|
|
||||||
## Instruction Replace
|
## Instruction Replace
|
||||||
|
|
||||||
This works just like the Description Replace automation but runs against all instruction texts
|
This works just like the Description Replace automation but runs against all instruction texts
|
||||||
in all steps of a recipe during import.
|
in all steps of a recipe during import.
|
||||||
|
|
||||||
Also instead of just replacing a single occurrence of the matched pattern it will replace all.
|
Also instead of just replacing a single occurrence of the matched pattern it will replace all.
|
||||||
|
|
||||||
|
## Never Unit
|
||||||
|
|
||||||
|
Some ingredients have a pattern of AMOUNT and FOOD, if the food has multiple words (e.g. egg yolk) this can cause Tandoor
|
||||||
|
to detect the word "egg" as a unit. This automation will detect the word 'egg' as something that should never be considered
|
||||||
|
a unit.
|
||||||
|
|
||||||
|
You can also create them manually by setting the following
|
||||||
|
|
||||||
|
- **Parameter 1**: string to detect
|
||||||
|
- **Parameter 2**: Optional: unit to insert into ingredient (e.g. 1 whole 'egg yolk' instead of 1 <empty> 'egg yolk')
|
||||||
|
|
||||||
|
These rules are processed whenever you are importing recipes from websites or other apps
|
||||||
|
and when using the simple ingredient input (shopping, recipe editor, ...).
|
||||||
|
|
||||||
|
## Transpose Words
|
||||||
|
|
||||||
|
Some recipes list the food before the units for some foods (garlic cloves). This automation will transpose 2 words in an
|
||||||
|
ingredient so "garlic cloves" will automatically become "cloves garlic"
|
||||||
|
|
||||||
|
- **Parameter 1**: first word to detect
|
||||||
|
- **Parameter 2**: second word to detect
|
||||||
|
|
||||||
|
These rules are processed whenever you are importing recipes from websites or other apps
|
||||||
|
and when using the simple ingredient input (shopping, recipe editor, ...).
|
||||||
|
|
||||||
# Order
|
# Order
|
||||||
|
|
||||||
If the Automation type allows for more than one rule to be executed (for example description replace)
|
If the Automation type allows for more than one rule to be executed (for example description replace)
|
||||||
the rules are processed in ascending order (ordered by the *order* property of the automation).
|
the rules are processed in ascending order (ordered by the _order_ property of the automation).
|
||||||
The default order is always 1000 to make it easier to add automations before and after other automations.
|
The default order is always 1000 to make it easier to add automations before and after other automations.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
1. Rule ABC (order 1000) replaces `everything` with `abc`
|
1. Rule ABC (order 1000) replaces `everything` with `abc`
|
||||||
2. Rule DEF (order 2000) replaces `everything` with `def`
|
2. Rule DEF (order 2000) replaces `everything` with `def`
|
||||||
3. Rule XYZ (order 500) replaces `everything` with `xyz`
|
3. Rule XYZ (order 500) replaces `everything` with `xyz`
|
||||||
|
@ -526,5 +526,7 @@
|
|||||||
"Use_Plural_Food_Simple": "Use plural form for food dynamically",
|
"Use_Plural_Food_Simple": "Use plural form for food dynamically",
|
||||||
"plural_usage_info": "Use the plural form for units and food inside this space.",
|
"plural_usage_info": "Use the plural form for units and food inside this space.",
|
||||||
"Create Recipe": "Create Recipe",
|
"Create Recipe": "Create Recipe",
|
||||||
"Import Recipe": "Import Recipe"
|
"Import Recipe": "Import Recipe",
|
||||||
|
"Never_Unit": "Never Unit",
|
||||||
|
"Transpose_Words": "Transpose Words"
|
||||||
}
|
}
|
||||||
|
@ -281,7 +281,7 @@ export class Models {
|
|||||||
apiName: "Unit",
|
apiName: "Unit",
|
||||||
paginated: true,
|
paginated: true,
|
||||||
create: {
|
create: {
|
||||||
params: [["name", "plural_name", "description", "base_unit", "open_data_slug",]],
|
params: [["name", "plural_name", "description", "base_unit", "open_data_slug"]],
|
||||||
form: {
|
form: {
|
||||||
show_help: true,
|
show_help: true,
|
||||||
name: {
|
name: {
|
||||||
@ -558,6 +558,8 @@ export class Models {
|
|||||||
{ value: "KEYWORD_ALIAS", text: "Keyword_Alias" },
|
{ value: "KEYWORD_ALIAS", text: "Keyword_Alias" },
|
||||||
{ value: "DESCRIPTION_REPLACE", text: "Description_Replace" },
|
{ value: "DESCRIPTION_REPLACE", text: "Description_Replace" },
|
||||||
{ value: "INSTRUCTION_REPLACE", text: "Instruction_Replace" },
|
{ value: "INSTRUCTION_REPLACE", text: "Instruction_Replace" },
|
||||||
|
{ value: "NEVER_UNIT", text: "Never_Unit" },
|
||||||
|
{ value: "TRANSPOSE_WORDS", text: "Transpose_Words" },
|
||||||
],
|
],
|
||||||
field: "type",
|
field: "type",
|
||||||
label: "Type",
|
label: "Type",
|
||||||
@ -625,9 +627,8 @@ export class Models {
|
|||||||
label: "Disabled",
|
label: "Disabled",
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
},
|
},
|
||||||
form_function: "AutomationOrderDefault"
|
form_function: "AutomationOrderDefault",
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -641,7 +642,7 @@ export class Models {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
params: [['food', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'open_data_slug']],
|
params: [["food", "base_amount", "base_unit", "converted_amount", "converted_unit", "open_data_slug"]],
|
||||||
form: {
|
form: {
|
||||||
show_help: true,
|
show_help: true,
|
||||||
// TODO add proper help texts for everything
|
// TODO add proper help texts for everything
|
||||||
@ -695,9 +696,7 @@ export class Models {
|
|||||||
help_text: "open_data_help_text",
|
help_text: "open_data_help_text",
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -711,7 +710,7 @@ export class Models {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
params: [['name', 'icon', 'unit', 'description','order']],
|
params: [["name", "icon", "unit", "description", "order"]],
|
||||||
form: {
|
form: {
|
||||||
show_help: true,
|
show_help: true,
|
||||||
name: {
|
name: {
|
||||||
@ -764,7 +763,6 @@ export class Models {
|
|||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -852,7 +850,7 @@ export class Models {
|
|||||||
params: ["filter_list"],
|
params: ["filter_list"],
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
params: [["name",]],
|
params: [["name"]],
|
||||||
form: {
|
form: {
|
||||||
name: {
|
name: {
|
||||||
form_field: true,
|
form_field: true,
|
||||||
|
Reference in New Issue
Block a user