From 6176eeb024df6b6db78c910114bdf489c8571720 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Tue, 3 Jan 2023 23:12:00 +0100 Subject: [PATCH] basics --- cookbook/helper/ingredient_parser.py | 8 +- cookbook/helper/recipe_url_import.py | 25 +- ..._automation_order_alter_automation_type.py | 23 + cookbook/models.py | 9 +- cookbook/serializer.py | 12 +- cookbook/tests/other/test_url_import.py | 10 + docs/features/automation.md | 39 + openapitools.json | 7 + .../components/Modals/GenericModalForm.vue | 4 +- vue/src/components/Modals/NumberInput.vue | 37 + vue/src/utils/models.js | 1862 +++++++++-------- vue/src/utils/openapi/api.ts | 152 +- vue/src/utils/utils.js | 6 + 13 files changed, 1209 insertions(+), 985 deletions(-) create mode 100644 cookbook/migrations/0186_automation_order_alter_automation_type.py create mode 100644 docs/features/automation.md create mode 100644 vue/src/components/Modals/NumberInput.vue diff --git a/cookbook/helper/ingredient_parser.py b/cookbook/helper/ingredient_parser.py index 45f4e553..47e4058f 100644 --- a/cookbook/helper/ingredient_parser.py +++ b/cookbook/helper/ingredient_parser.py @@ -28,7 +28,7 @@ class IngredientParser: self.food_aliases = c caches['default'].touch(FOOD_CACHE_KEY, 30) else: - for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').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 caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30) @@ -37,7 +37,7 @@ class IngredientParser: self.unit_aliases = c caches['default'].touch(UNIT_CACHE_KEY, 30) else: - for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').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 caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30) else: @@ -59,7 +59,7 @@ class IngredientParser: except KeyError: return food else: - if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).first(): + if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first(): return automation.param_2 return food @@ -78,7 +78,7 @@ class IngredientParser: except KeyError: return unit else: - if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).first(): + if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first(): return automation.param_2 return unit diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index 2ed85b08..8441ccaa 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -12,7 +12,8 @@ from recipe_scrapers._utils import get_host_name, get_minutes from cookbook.helper import recipe_url_import as helper from cookbook.helper.ingredient_parser import IngredientParser -from cookbook.models import Keyword +from cookbook.models import Keyword, Automation + # from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR @@ -121,7 +122,7 @@ def get_from_scraper(scrape, request): try: keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0]) except Exception: - pass + recipe_json['source_url'] = '' try: recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space) @@ -139,10 +140,18 @@ def get_from_scraper(scrape, request): if len(recipe_json['steps']) == 0: recipe_json['steps'].append({'instruction': '', 'ingredients': [], }) - if len(parse_description(description)) > 256: # split at 256 as long descriptions dont look good on recipe cards - recipe_json['steps'][0]['instruction'] = f'*{parse_description(description)}* \n\n' + recipe_json['steps'][0]['instruction'] + parsed_description = parse_description(description) + # TODO notify user about limit if reached + # 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] + for a in automations: + 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) + + if len(parsed_description) > 256: # split at 256 as long descriptions don't look good on recipe cards + recipe_json['steps'][0]['instruction'] = f'*{parsed_description}* \n\n' + recipe_json['steps'][0]['instruction'] else: - recipe_json['description'] = parse_description(description)[:512] + recipe_json['description'] = parsed_description[:512] try: for x in scrape.ingredients(): @@ -175,6 +184,12 @@ def get_from_scraper(scrape, request): except Exception: pass + if recipe_json['source_url']: + automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512] + for a in automations: + if re.match(a.param_1, (recipe_json['source_url'])[:512]): + recipe_json['description'] = re.sub(a.param_2, a.param_3, recipe_json['description'], count=1) + return recipe_json diff --git a/cookbook/migrations/0186_automation_order_alter_automation_type.py b/cookbook/migrations/0186_automation_order_alter_automation_type.py new file mode 100644 index 00000000..42615ce4 --- /dev/null +++ b/cookbook/migrations/0186_automation_order_alter_automation_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.1.4 on 2023-01-03 21:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0185_food_plural_name_ingredient_always_use_plural_food_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='automation', + name='order', + field=models.IntegerField(default=1000), + ), + 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')], max_length=128), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 2295266c..e488bab9 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -367,7 +367,7 @@ class UserPreference(models.Model, PermissionModelMixin): ) user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True) - image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True,blank=True, related_name='user_image') + image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image') theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR) nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY) default_unit = models.CharField(max_length=32, default='g') @@ -1223,9 +1223,12 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis FOOD_ALIAS = 'FOOD_ALIAS' UNIT_ALIAS = 'UNIT_ALIAS' KEYWORD_ALIAS = 'KEYWORD_ALIAS' + DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE' + INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE' 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')),)) name = models.CharField(max_length=128, default='') description = models.TextField(blank=True, null=True) @@ -1233,6 +1236,8 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis param_2 = models.CharField(max_length=128, blank=True, null=True) param_3 = models.CharField(max_length=128, blank=True, null=True) + order = models.IntegerField(default=1000) + disabled = models.BooleanField(default=False) updated_at = models.DateTimeField(auto_now=True) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 0a799528..e3a2ec0b 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -876,11 +876,11 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): value = value.quantize( Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero return ( - obj.name - or getattr(obj.mealplan, 'title', None) - or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)]) - or obj.recipe.name - ) + f' ({value:.2g})' + obj.name + or getattr(obj.mealplan, 'title', None) + or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)]) + or obj.recipe.name + ) + f' ({value:.2g})' def update(self, instance, validated_data): # TODO remove once old shopping list @@ -1067,7 +1067,7 @@ class AutomationSerializer(serializers.ModelSerializer): class Meta: model = Automation fields = ( - 'id', 'type', 'name', 'description', 'param_1', 'param_2', 'param_3', 'disabled', 'created_by',) + 'id', 'type', 'name', 'description', 'param_1', 'param_2', 'param_3', 'order', 'disabled', 'created_by',) read_only_fields = ('created_by',) diff --git a/cookbook/tests/other/test_url_import.py b/cookbook/tests/other/test_url_import.py index ae4677c0..394838dc 100644 --- a/cookbook/tests/other/test_url_import.py +++ b/cookbook/tests/other/test_url_import.py @@ -72,3 +72,13 @@ def test_recipe_import(arg, u1_s1): content_type='application/json') recipe = json.loads(response.content)['recipe_json'] validate_recipe(arg, recipe) + + +# def test_description_replace_automation(): +# if 'cookbook' in os.getcwd(): +# test_file = os.path.join(os.getcwd(), 'other', 'test_data', 'chefkoch2.html') +# else: +# test_file = os.path.join(os.getcwd(), 'cookbook', 'tests', 'other', 'test_data', 'chefkoch2.html') +# +# with open(test_file, 'r', encoding='UTF-8') as d: +# pass \ No newline at end of file diff --git a/docs/features/automation.md b/docs/features/automation.md new file mode 100644 index 00000000..a3464c6d --- /dev/null +++ b/docs/features/automation.md @@ -0,0 +1,39 @@ +!!! warning + Automations are currently in a beta stage. They work pretty stable but if I encounter any + 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. + + +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. + + +## Unit, Food, Keyword Alias +asd + +## Description Replace +This automation is a bit more complicated than the alis rules. + +It uses Regular Expressions (RegEx) to determine if a description should be altered, what exactly to remove +and what to replace it with. + +- **Parameter 1**: pattern of which sites to match (e.g. `.*.chefkoch.de.*`, `.*`) +- **Parameter 2**: pattern of what to replace (e.g. `.*`) +- **Parameter 3**: value to replace matched occurrence of parameter 2 with. Only one occurrence of the pattern is replaced. + +To replace the description the python [re.sub](https://docs.python.org/2/library/re.html#re.sub) function is used +like this `re.sub(, , , count=1)` + +To test out your patterns and learn about RegEx you can use [regexr.com](https://regexr.com/) + +# Order +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 default order is always 1000 to make it easier to add automations before and after other automations. + +Example: +1. Rule ABC (order 1000) replaces `everything` with `abc` +2. Rule DEF (order 2000) replaces `everything` with `def` +3. Rule XYZ (order 500) replaces `everything` with `xyz` + +After processing rules XYZ, then ABC and then DEF the description will have the value `def` \ No newline at end of file diff --git a/openapitools.json b/openapitools.json index e69de29b..c871d87b 100644 --- a/openapitools.json +++ b/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "6.2.1" + } +} diff --git a/vue/src/components/Modals/GenericModalForm.vue b/vue/src/components/Modals/GenericModalForm.vue index 00689746..34ff448d 100644 --- a/vue/src/components/Modals/GenericModalForm.vue +++ b/vue/src/components/Modals/GenericModalForm.vue @@ -15,6 +15,7 @@ +