From 79c8edd354d3e44a20016df4b17a86a3419f834f Mon Sep 17 00:00:00 2001 From: MaxJa4 <74194322+MaxJa4@users.noreply.github.com> Date: Fri, 7 Jan 2022 20:14:49 +0100 Subject: [PATCH 001/150] Some additional info for reverse proxy setups. Since there have been quite some people with basic docker setup issues when using a reverse proxy and very basic reverse proxies like a nginx running locally as a proxy or Caddy, I figured these added sentences might clear things up for some people. Feel free to suggest additional topics which should be added or refined. --- docs/install/docker.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/install/docker.md b/docs/install/docker.md index fdf2dab9..3a29ea42 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -73,6 +73,8 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d Most deployments will likely use a reverse proxy. +If your reverse proxy is not listed here, please refer to [Others](https://docs.tandoor.dev/install/docker/#others). + #### Traefik If you use traefik, this configuration is the one for you. @@ -157,6 +159,12 @@ In both cases, also make sure to mount `/media/` in your swag container to point Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. +### Others + +If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking. + +An example port config (inside the respective docker-compose.yml) would be: `8123:80` instead of the `80:80` or if you want to be sure, that Tandoor is **just** accessible via your proxy and don't wanna bother with your firewall, then `127.0.0.1:8123:80` is a viable option too. + ## Additional Information ### Nginx vs Gunicorn From acfb02cc0ec9b072e9c97d4ec4932442e44eb74e Mon Sep 17 00:00:00 2001 From: MaxJa4 <74194322+MaxJa4@users.noreply.github.com> Date: Sun, 9 Jan 2022 14:21:45 +0100 Subject: [PATCH 002/150] Extension and hopefully simplification of bug template To get more information about bugs and prohibit having to ask one by one for specific information, I extended and redesigned the bug template. Fell free to change parts or suggest changes. Please note, that all explanatory parts are hidden as comments in the markdown (which the user will see when creating a bug ticket) so they don't unnecessarily clutter the finished bug report. --- .github/ISSUE_TEMPLATE/bug_report.md | 74 ++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index f6e365af..5cb51e29 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,9 +7,75 @@ assignees: '' --- -### Version -Please provide your current version (can be found on the system page since v0.8.4) -Version: +## Version + +**Tandoor-Version:** -### Bug description +## Setup configuration + + +### Setup +- [ ] Docker / Docker-Compose +- [ ] Unraid +- [ ] Synology +- [ ] Kubernetes +- [ ] Manual setup +- [ ] Others (please state below) + +### Reverse Proxy +- [ ] No reverse proxy +- [ ] jwilder's nginx proxy +- [ ] Nginx proxy manager (NPM) +- [ ] SWAG +- [ ] Caddy +- [ ] Traefik +- [ ] Others (please state below) + + +**Additional information:** + +## Bug description A clear and concise description of what the bug is. + + + +## Logs + + +
+ Web-Container-Logs + + + + ``` + Replace me with logs + ``` +
+ +
+ DB-Container-Logs + + + + ``` + Replace me with logs + ``` +
+ +
+ Nginx-Container-Logs + + + + ``` + Replace me with logs + ``` +
From 25ccea90e06220215555fb2e35f4b5e369bcb6bf Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 10 Jan 2022 15:05:56 -0600 Subject: [PATCH 003/150] WIP --- cookbook/helper/recipe_search.py | 40 +++++++++++++ cookbook/models.py | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 9ad2b81f..748bc338 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -389,3 +389,43 @@ def old_search(request): queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by('name'), space=request.space) return f.qs + + +# from django.db.models import fields +# from cookbook.models import Food, Recipe, Keyword, RecipeBook, Unit +# many_to_many = {} +# one_to_many = {} +# many_to_one = {} +# char = {} +# boolean = {} +# number = {} +# other = {} +# image = {} +# date = {} +# for model in [Food, Recipe, Keyword, RecipeBook, Unit]: +# print(name:=model.__name__, ":") +# for x in model._meta.get_fields(): + +# if x.name in ['space', 'id']: +# continue +# elif x.many_to_many: +# many_to_many[name]=[*many_to_many.get(name, []), x.name] +# elif x.one_to_many: +# one_to_many[name] = [*one_to_many.get(name, []), x.name] +# elif x.many_to_one: +# many_to_one[name] = [*many_to_one.get(name, []), x.name] +# elif isinstance(x, fields.CharField): +# char[name] = [*char.get(name, []), x.name] +# elif isinstance(x, fields.BooleanField): +# boolean[name] = [*boolean.get(name, []), x.name] +# elif isinstance(x, fields.IntegerField) or isinstance(x, fields.DecimalField): +# number[name] = [*number.get(name, []), x.name] +# elif isinstance(x, fields.DateField): +# date[name] = [*date.get(name, []), x.name] +# elif isinstance(x, fields.files.ImageField): +# image[name] = [*image.get(name, []), x.name] +# else: +# other[name] = [*other.get(name, []), x.name] +# if x.hidden: +# hidden[name] = [*hidden.get(name, []), x.name] +# print('---', x.name, ' - ', x.db_type, x.remote_name) diff --git a/cookbook/models.py b/cookbook/models.py index cfe4ab81..7187db33 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -1090,3 +1090,99 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis objects = ScopedManager(space='space') space = models.ForeignKey(Space, on_delete=models.CASCADE) + + +class ModelFilter(models.Model): + EQUAL = 'EQUAL' + NOT_EQUAL = 'NOT_EQUAL' + LESS_THAN = 'LESS_THAN' + GREATER_THAN = 'GREATER_THAN' + LESS_THAN_EQ = 'LESS_THAN_EQ' + GREATER_THAN_EQ = 'GREATER_THAN_EQ' + CONTAINS = 'CONTAINS' + NOT_CONTAINS = 'NOT_CONTAINS' + STARTS_WITH = 'STARTS_WITH' + NOT_STARTS_WITH = 'NOT_STARTS_WITH' + ENDS_WITH = 'ENDS_WITH' + NOT_ENDS_WITH = 'NOT_ENDS_WITH' + INCLUDES = 'INCLUDES' + NOT_INCLUDES = 'NOT_INCLUDES' + COUNT_EQ = 'COUNT_EQ' + COUNT_NEQ = 'COUNT_NEQ' + COUNT_LT = 'COUNT_LT' + COUNT_GT = 'COUNT_GT' + + OPERATION = ( + (EQUAL, _('is')), + (NOT_EQUAL, _('is not')), + (LESS_THAN, _('less than')), + (GREATER_THAN, _('greater than')), + (LESS_THAN_EQ, _('less or equal')), + (GREATER_THAN_EQ, _('greater or equal')), + (CONTAINS, _('contains')), + (NOT_CONTAINS, _('does not contain')), + (STARTS_WITH, _('starts with')), + (NOT_STARTS_WITH, _('does not start with')), + (INCLUDES, _('includes')), + (NOT_INCLUDES, _('does not include')), + (COUNT_EQ, _('count equals')), + (COUNT_NEQ, _('count does not equal')), + (COUNT_LT, _('count less than')), + (COUNT_GT, _('count greater than')), + ) + + STRING = 'STRING' + NUMBER = 'NUMBER' + BOOLEAN = 'BOOLEAN' + DATE = 'DATE' + + FIELD_TYPE = ( + (STRING, _('string')), + (NUMBER, _('number')), + (BOOLEAN, _('boolean')), + (DATE, _('date')), + ) + + field = models.CharField(max_length=32) + field_type = models.CharField(max_length=32, choices=(FIELD_TYPE)) + operation = models.CharField(max_length=32, choices=(OPERATION)) + target_value = models.CharField(max_length=128) + sort = models.BooleanField(default=False,) + ascending = models.BooleanField(default=True,) + + def __str__(self): + return f"{self.field} - {self.operation} - {self.target_value}" + + +class SavedFilter(models.Model, PermissionModelMixin): + FOOD = 'FOOD' + UNIT = 'UNIT' + KEYWORD = "KEYWORD" + RECIPE = 'RECIPE' + BOOK = 'BOOK' + + MODELS = ( + (FOOD, _('Food')), + (UNIT, _('Unit')), + (KEYWORD, _('Keyword')), + (RECIPE, _('Recipe')), + (BOOK, _('Book')) + ) + + name = models.CharField(max_length=128, ) + type = models.CharField(max_length=24, choices=(MODELS)), + description = models.CharField(max_length=256, blank=True) + shared = models.ManyToManyField(User, blank=True, related_name='filter_share') + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True) + + objects = ScopedManager(space='space') + space = models.ForeignKey(Space, on_delete=models.CASCADE) + + def __str__(self): + return f"{self.type}: {self.name}" + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space') + ] From f7cb067b527ddab656e1b55ce101beda64dab5c0 Mon Sep 17 00:00:00 2001 From: smilerz Date: Tue, 11 Jan 2022 07:24:59 -0600 Subject: [PATCH 004/150] construct values in queryset instead of serializer methods --- cookbook/helper/HelperFunctions.py | 2 +- cookbook/serializer.py | 44 ++++++++++---------- cookbook/views/api.py | 65 +++++++++++++++++++++++++----- recipes/middleware.py | 7 ++-- recipes/settings.py | 4 +- 5 files changed, 82 insertions(+), 40 deletions(-) diff --git a/cookbook/helper/HelperFunctions.py b/cookbook/helper/HelperFunctions.py index cf04c3e2..e2971c2e 100644 --- a/cookbook/helper/HelperFunctions.py +++ b/cookbook/helper/HelperFunctions.py @@ -7,7 +7,7 @@ class Round(Func): def str2bool(v): - if type(v) == bool: + if type(v) == bool or v is None: return v else: return v.lower() in ("yes", "true", "1") diff --git a/cookbook/serializer.py b/cookbook/serializer.py index ac623868..652e94f8 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -12,6 +12,7 @@ from rest_framework import serializers from rest_framework.exceptions import NotFound, ValidationError from rest_framework.fields import empty +from cookbook.helper.HelperFunctions import str2bool from cookbook.helper.shopping_helper import list_from_recipe from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType, @@ -21,13 +22,18 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Fo SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog) from cookbook.templatetags.custom_tags import markdown +from recipes.settings import MEDIA_URL, SCRIPT_NAME class ExtendedRecipeMixin(serializers.ModelSerializer): # adds image and recipe count to serializer when query param extended=1 - image = serializers.SerializerMethodField('get_image') - numrecipe = serializers.SerializerMethodField('count_recipes') + # ORM path to this object from Recipe recipe_filter = None + # list of ORM paths to any image + images = None + + image = serializers.SerializerMethodField('get_image') + numrecipe = serializers.ReadOnlyField(source='count_recipes_test') def get_fields(self, *args, **kwargs): fields = super().get_fields(*args, **kwargs) @@ -37,8 +43,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): api_serializer = None # extended values are computationally expensive and not needed in normal circumstances try: - if bool(int( - self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer: + if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: return fields except (AttributeError, KeyError) as e: pass @@ -50,21 +55,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): return fields def get_image(self, obj): - # TODO add caching - recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude( - image__isnull=True).exclude(image__exact='') - try: - if recipes.count() == 0 and obj.has_children(): - obj__in = self.recipe_filter + '__in' - recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude( - image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree - except AttributeError: - # probably not a tree - pass - if recipes.count() != 0: - return random.choice(recipes).image.url - else: - return None + if obj.recipe_image: + return SCRIPT_NAME + MEDIA_URL + obj.recipe_image def count_recipes(self, obj): return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count() @@ -98,7 +90,11 @@ class CustomOnHandField(serializers.Field): return instance def to_representation(self, obj): - shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] + shared_users = [] + if request := self.context.get('request', None): + shared_users = request._shared_users + else: + shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] return obj.onhand_users.filter(id__in=shared_users).exists() def to_internal_value(self, data): @@ -379,14 +375,16 @@ class RecipeSimpleSerializer(serializers.ModelSerializer): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin): supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) recipe = RecipeSimpleSerializer(allow_null=True, required=False) - shopping = serializers.SerializerMethodField('get_shopping_status') + # shopping = serializers.SerializerMethodField('get_shopping_status') + shopping = serializers.ReadOnlyField(source='shopping_status') inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False) food_onhand = CustomOnHandField(required=False, allow_null=True) recipe_filter = 'steps__ingredients__food' + images = ['recipe__image'] - def get_shopping_status(self, obj): - return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 + # def get_shopping_status(self, obj): + # return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0 def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 86f97660..b0023d9c 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,8 +12,9 @@ from django.contrib.auth.models import User from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import FieldError, ValidationError from django.core.files import File -from django.db.models import Case, ProtectedError, Q, Value, When +from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When from django.db.models.fields.related import ForeignObjectRel +from django.db.models.functions import Coalesce from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse @@ -30,6 +31,7 @@ from rest_framework.response import Response from rest_framework.viewsets import ViewSetMixin from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow +from cookbook.helper.HelperFunctions import str2bool from cookbook.helper.image_processing import handle_image from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner, @@ -100,7 +102,38 @@ class DefaultPagination(PageNumberPagination): max_page_size = 200 -class FuzzyFilterMixin(ViewSetMixin): +class ExtendedRecipeMixin(): + ''' + ExtendedRecipe annotates a queryset with recipe_image and recipe_count values + ''' + @classmethod + def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False): + extended = str2bool(request.query_params.get('extended', None)) + if extended: + recipe_filter = serializer.recipe_filter + images = serializer.images + space = request.space + + # add a recipe count annotation to the query + # explanation on construction https://stackoverflow.com/a/43771738/15762829 + recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count') + queryset = queryset.annotate(recipe_count_test=Coalesce(Subquery(recipe_count), 0)) + + # add a recipe image annotation to the query + image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] + if tree: + image_children_subquery = Recipe.objects.filter(**{f"{recipe_filter}__path__startswith": OuterRef('path')}, + space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1] + else: + image_children_subquery = None + if images: + queryset = queryset.annotate(recipe_image=Coalesce(*images, image_subquery, image_children_subquery)) + else: + queryset = queryset.annotate(recipe_image=Coalesce(image_subquery, image_children_subquery)) + return queryset + + +class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): schema = FilterSchema() def get_queryset(self): @@ -141,12 +174,12 @@ class FuzzyFilterMixin(ViewSetMixin): if random: self.queryset = self.queryset.order_by("?") self.queryset = self.queryset[:int(limit)] - return self.queryset + return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class) class MergeMixin(ViewSetMixin): - @decorators.action(detail=True, url_path='merge/(?P[^/.]+)', methods=['PUT'], ) - @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) + @ decorators.action(detail=True, url_path='merge/(?P[^/.]+)', methods=['PUT'], ) + @ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) def merge(self, request, pk, target): self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]." @@ -211,7 +244,7 @@ class MergeMixin(ViewSetMixin): return Response(content, status=status.HTTP_400_BAD_REQUEST) -class TreeMixin(MergeMixin, FuzzyFilterMixin): +class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin): schema = TreeSchema() model = None @@ -237,11 +270,13 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin): except self.model.DoesNotExist: self.queryset = self.model.objects.none() else: - return super().get_queryset() - return self.queryset.filter(space=self.request.space).order_by('name') + self.queryset = super().get_queryset() + self.queryset = self.queryset.filter(space=self.request.space).order_by('name') - @decorators.action(detail=True, url_path='move/(?P[^/.]+)', methods=['PUT'], ) - @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) + return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True) + + @ decorators.action(detail=True, url_path='move/(?P[^/.]+)', methods=['PUT'], ) + @ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) def move(self, request, pk, parent): self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root." if self.model.node_order_by: @@ -413,7 +448,15 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin): permission_classes = [CustomIsUser] pagination_class = DefaultPagination - @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,) + def get_queryset(self): + self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id] + + self.queryset = super().get_queryset() + shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id') + # onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users])) + return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users', 'inherit_fields').select_related('recipe', 'supermarket_category') + + @ decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,) # TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably def shopping(self, request, pk): if self.request.space.demo: diff --git a/recipes/middleware.py b/recipes/middleware.py index ebe9c51f..09346e6a 100644 --- a/recipes/middleware.py +++ b/recipes/middleware.py @@ -13,19 +13,20 @@ class CustomRemoteUser(RemoteUserMiddleware): Gist code by vstoykov, you can check his original gist at: https://gist.github.com/vstoykov/1390853/5d2e8fac3ca2b2ada8c7de2fb70c021e50927375 Changes: -Ignoring static file requests and a certain useless admin request from triggering the logger. +Ignoring static file requests and a certain useless admin request from triggering the logger. Updated statements to make it Python 3 friendly. """ - def terminal_width(): """ Function to compute the terminal width. """ width = 0 try: - import struct, fcntl, termios + import fcntl + import struct + import termios s = struct.pack('HHHH', 0, 0, 0, 0) x = fcntl.ioctl(1, termios.TIOCGWINSZ, s) width = struct.unpack('HHHH', x)[1] diff --git a/recipes/settings.py b/recipes/settings.py index 9b9dacbb..dcbbfc07 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -371,10 +371,10 @@ LANGUAGES = [ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/2.0/howto/static-files/ +SCRIPT_NAME = os.getenv('SCRIPT_NAME', '') # path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest JS_REVERSE_OUTPUT_PATH = os.path.join(BASE_DIR, "cookbook/static/django_js_reverse") - -JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', os.getenv('SCRIPT_NAME', '')) +JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', SCRIPT_NAME) STATIC_URL = os.getenv('STATIC_URL', '/static/') STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") From f9b04a3f1ea41a4b32211ff1b9106b08ca010d59 Mon Sep 17 00:00:00 2001 From: smilerz Date: Tue, 11 Jan 2022 08:33:42 -0600 Subject: [PATCH 005/150] bug fix --- cookbook/serializer.py | 6 +++--- cookbook/views/api.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 652e94f8..09520545 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -90,10 +90,10 @@ class CustomOnHandField(serializers.Field): return instance def to_representation(self, obj): - shared_users = [] + shared_users = None if request := self.context.get('request', None): - shared_users = request._shared_users - else: + shared_users = getattr(request, '_shared_users', None) + if shared_users is None: shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id] return obj.onhand_users.filter(id__in=shared_users).exists() diff --git a/cookbook/views/api.py b/cookbook/views/api.py index b0023d9c..1135cfec 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -270,7 +270,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin): except self.model.DoesNotExist: self.queryset = self.model.objects.none() else: - self.queryset = super().get_queryset() + return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True) self.queryset = self.queryset.filter(space=self.request.space).order_by('name') return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True) From 8a4f35e5928d620294b286b83f2529126429f810 Mon Sep 17 00:00:00 2001 From: Marcus Wolschon Date: Wed, 12 Jan 2022 11:37:08 +0100 Subject: [PATCH 006/150] #1093 Recipe link in plan #1093 add a recipe link into meal plan --- vue/src/apps/MealPlanView/MealPlanView.vue | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index a6fd2b12..f9be0258 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -129,6 +129,16 @@ > {{ $t("Edit") }} + + {{ $t("Recipe") }} + Date: Wed, 12 Jan 2022 16:15:55 +0100 Subject: [PATCH 007/150] #1093 conditional receipt link in plan --- vue/src/apps/MealPlanView/MealPlanView.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index f9be0258..7976a0d0 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -130,6 +130,7 @@ {{ $t("Edit") }} level: - parent.extend([i]) - level = r[1]['level'] - else: - parent[-1] = i - j = 0 +# cached_search = { +# 'recipe_list': list(qs.values_list('id', flat=True)), +# 'keyword_list': request.query_params.getlist('keywords', []), +# 'food_list': request.query_params.getlist('foods', []), +# 'book_list': request.query_params.getlist('book', []), +# 'search_keywords_or': str2bool(request.query_params.get('keywords_or', True)), +# 'search_foods_or': str2bool(request.query_params.get('foods_or', True)), +# 'search_books_or': str2bool(request.query_params.get('books_or', True)), +# 'space': request.space, +# 'Ratings': facets['Ratings'], +# 'Recent': facets['Recent'], +# 'Keywords': facets['Keywords'], +# 'Foods': facets['Foods'], +# 'Books': facets['Books'] +# } +# caches['default'].set(SEARCH_CACHE_KEY, cached_search, cache_timeout) +# return facets - while j < level: - # this causes some double counting when a recipe has both a child and an ancestor - annotation[parent[j]][1]['count'] += getattr(r[0], 'recipe_count', 0) - if expand: - annotation[parent[j]][1]['isDefaultExpanded'] = True - j += 1 - if level == 0: - tree_list.append(annotation[i][1]) - elif level > 0: - annotation[parent[level - 1]][1].setdefault('children', []).append(annotation[i][1]) - i += 1 - return tree_list +# # construct and cache new values by retrieving search parameters from the cache +# SEARCH_CACHE_KEY = f'recipes_filter_{hash_key}' +# if c := caches['default'].get(SEARCH_CACHE_KEY, None): +# recipe_list = c['recipe_list'] +# keyword_list = c['keyword_list'] +# food_list = c['food_list'] +# book_list = c['book_list'] +# search_keywords_or = c['search_keywords_or'] +# search_foods_or = c['search_foods_or'] +# search_books_or = c['search_books_or'] +# else: +# return {} + +# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results +# if search_keywords_or: +# keywords = Keyword.objects.filter(space=request.space).distinct() +# else: +# keywords = Keyword.objects.filter(Q(recipe__in=recipe_list) | Q(depth=1)).filter(space=request.space).distinct() + +# # Subquery that counts recipes for keyword including children +# kw_recipe_count = Recipe.objects.filter(**{'keywords__path__startswith': OuterRef('path')}, id__in=recipe_list, space=request.space +# ).values(kw=Substr('keywords__path', 1, Keyword.steplen) +# ).annotate(count=Count('pk', distinct=True)).values('count') + +# # set keywords to root objects only +# keywords = keywords.annotate(count=Coalesce(Subquery(kw_recipe_count), 0) +# ).filter(depth=1, count__gt=0 +# ).values('id', 'name', 'count', 'numchild' +# ).order_by('name') +# if keyword: +# facets['Keywords'] = list(keywords) +# return facets + +# # custom django-tree function annotates a queryset to make building a tree easier. +# # see https://django-treebeard.readthedocs.io/en/latest/api.html#treebeard.models.Node.get_annotated_list_qs for details +# # kw_a = annotated_qs(keywords, root=True, fill=True) + +# # # if using an OR search, will annotate all keywords, otherwise, just those that appear in results +# if search_foods_or: +# foods = Food.objects.filter(space=request.space).distinct() +# else: +# foods = Food.objects.filter(Q(ingredient__step__recipe__in=recipe_list) | Q(depth=1)).filter(space=request.space).distinct() + +# food_recipe_count = Recipe.objects.filter(**{'steps__ingredients__food__path__startswith': OuterRef('path')}, id__in=recipe_list, space=request.space +# ).values(kw=Substr('steps__ingredients__food__path', 1, Food.steplen * (1+getattr(food, 'depth', 0))) +# ).annotate(count=Count('pk', distinct=True)).values('count') + +# # set keywords to root objects only +# foods = foods.annotate(count=Coalesce(Subquery(food_recipe_count), 0) +# ).filter(depth=(1+getattr(food, 'depth', 0)), count__gt=0 +# ).values('id', 'name', 'count', 'numchild' +# ).order_by('name') +# if food: +# facets['Foods'] = list(foods) +# return facets + +# # food_a = annotated_qs(foods, root=True, fill=True) + +# # c['Keywords'] = facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list) +# c['Keywords'] = facets['Keywords'] = list(keywords) +# # c['Foods'] = facets['Foods'] = fill_annotated_parents(food_a, food_list) +# c['Foods'] = facets['Foods'] = list(foods) +# # TODO add book facet +# c['Books'] = facets['Books'] = [] +# caches['default'].set(SEARCH_CACHE_KEY, c, cache_timeout) +# return facets -def annotated_qs(qs, root=False, fill=False): - """ - Gets an annotated list from a queryset. - :param root: +# def fill_annotated_parents(annotation, filters): +# tree_list = [] +# parent = [] +# i = 0 +# level = -1 +# for r in annotation: +# expand = False - Will backfill in annotation to include all parents to root node. +# annotation[i][1]['id'] = r[0].id +# annotation[i][1]['name'] = r[0].name +# annotation[i][1]['count'] = getattr(r[0], 'recipe_count', 0) +# annotation[i][1]['isDefaultExpanded'] = False - :param fill: - Will fill in gaps in annotation where nodes between children - and ancestors are not included in the queryset. - """ +# if str(r[0].id) in filters: +# expand = True +# if r[1]['level'] < level: +# parent = parent[:r[1]['level'] - level] +# parent[-1] = i +# level = r[1]['level'] +# elif r[1]['level'] > level: +# parent.extend([i]) +# level = r[1]['level'] +# else: +# parent[-1] = i +# j = 0 - result, info = [], {} - start_depth, prev_depth = (None, None) - nodes_list = list(qs.values_list('pk', flat=True)) - for node in qs.order_by('path'): - node_queue = [node] - while len(node_queue) > 0: - dirty = False - current_node = node_queue[-1] - depth = current_node.get_depth() - parent_id = current_node.parent - if root and depth > 1 and parent_id not in nodes_list: - parent_id = current_node.parent - nodes_list.append(parent_id) - node_queue.append(current_node.__class__.objects.get(pk=parent_id)) - dirty = True +# while j < level: +# # this causes some double counting when a recipe has both a child and an ancestor +# annotation[parent[j]][1]['count'] += getattr(r[0], 'recipe_count', 0) +# if expand: +# annotation[parent[j]][1]['isDefaultExpanded'] = True +# j += 1 +# if level == 0: +# tree_list.append(annotation[i][1]) +# elif level > 0: +# annotation[parent[level - 1]][1].setdefault('children', []).append(annotation[i][1]) +# i += 1 +# return tree_list - if fill and depth > 1 and prev_depth and depth > prev_depth and parent_id not in nodes_list: - nodes_list.append(parent_id) - node_queue.append(current_node.__class__.objects.get(pk=parent_id)) - dirty = True - if not dirty: - working_node = node_queue.pop() - if start_depth is None: - start_depth = depth - open = (depth and (prev_depth is None or depth > prev_depth)) - if prev_depth is not None and depth < prev_depth: - info['close'] = list(range(0, prev_depth - depth)) - info = {'open': open, 'close': [], 'level': depth - start_depth} - result.append((working_node, info,)) - prev_depth = depth - if start_depth and start_depth > 0: - info['close'] = list(range(0, prev_depth - start_depth + 1)) - return result +# def annotated_qs(qs, root=False, fill=False): +# """ +# Gets an annotated list from a queryset. +# :param root: + +# Will backfill in annotation to include all parents to root node. + +# :param fill: +# Will fill in gaps in annotation where nodes between children +# and ancestors are not included in the queryset. +# """ + +# result, info = [], {} +# start_depth, prev_depth = (None, None) +# nodes_list = list(qs.values_list('pk', flat=True)) +# for node in qs.order_by('path'): +# node_queue = [node] +# while len(node_queue) > 0: +# dirty = False +# current_node = node_queue[-1] +# depth = current_node.get_depth() +# parent_id = current_node.parent +# if root and depth > 1 and parent_id not in nodes_list: +# parent_id = current_node.parent +# nodes_list.append(parent_id) +# node_queue.append(current_node.__class__.objects.get(pk=parent_id)) +# dirty = True + +# if fill and depth > 1 and prev_depth and depth > prev_depth and parent_id not in nodes_list: +# nodes_list.append(parent_id) +# node_queue.append(current_node.__class__.objects.get(pk=parent_id)) +# dirty = True + +# if not dirty: +# working_node = node_queue.pop() +# if start_depth is None: +# start_depth = depth +# open = (depth and (prev_depth is None or depth > prev_depth)) +# if prev_depth is not None and depth < prev_depth: +# info['close'] = list(range(0, prev_depth - depth)) +# info = {'open': open, 'close': [], 'level': depth - start_depth} +# result.append((working_node, info,)) +# prev_depth = depth +# if start_depth and start_depth > 0: +# info['close'] = list(range(0, prev_depth - start_depth + 1)) +# return result def old_search(request): diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 1135cfec..e9ba46ee 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -38,7 +38,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus CustomIsShare, CustomIsShared, CustomIsUser, group_required) from cookbook.helper.recipe_html_import import get_recipe_from_source -from cookbook.helper.recipe_search import get_facet, old_search, search_recipes +from cookbook.helper.recipe_search import RecipeFacet, old_search, search_recipes from cookbook.helper.recipe_url_import import get_from_scraper from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, @@ -604,7 +604,7 @@ class RecipePagination(PageNumberPagination): max_page_size = 100 def paginate_queryset(self, queryset, request, view=None): - self.facets = get_facet(qs=queryset, request=request) + self.facets = RecipeFacet(request, queryset=queryset) return super().paginate_queryset(queryset, request, view) def get_paginated_response(self, data): @@ -613,7 +613,7 @@ class RecipePagination(PageNumberPagination): ('next', self.get_next_link()), ('previous', self.get_previous_link()), ('results', data), - ('facets', self.facets) + ('facets', self.facets.get_facets()) ])) @@ -651,8 +651,7 @@ class RecipeViewSet(viewsets.ModelViewSet): self.queryset = self.queryset.filter(space=self.request.space) self.queryset = search_recipes(self.request, self.queryset, self.request.GET) - - return super().get_queryset() + return super().get_queryset().prefetch_related('cooklog_set') def list(self, request, *args, **kwargs): if self.request.GET.get('debug', False): @@ -1132,10 +1131,13 @@ def ingredient_from_string(request): @group_required('user') def get_facets(request): key = request.GET.get('hash', None) + food = request.GET.get('food', None) + keyword = request.GET.get('keyword', None) + facets = RecipeFacet(request, hash_key=key) return JsonResponse( { - 'facets': get_facet(request=request, use_cache=False, hash_key=key), + 'facets': facets.get_facets(), }, status=200 ) diff --git a/recipes/middleware.py b/recipes/middleware.py index 09346e6a..8f608794 100644 --- a/recipes/middleware.py +++ b/recipes/middleware.py @@ -1,3 +1,4 @@ +import time from os import getenv from django.conf import settings diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index a76c7f05..b6c71789 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -99,7 +99,7 @@ :options="facets.Keywords" :flat="true" searchNested - multiple + :multiple="true" :placeholder="$t('Keywords')" :normalizer="normalizer" @input="refreshData(false)" @@ -123,10 +123,11 @@ { + if (x?.numchild > 0) { + return { ...x, children: null } + } else { + return x + } + }) + }, ratingOptions: function () { return [ { id: 5, label: "⭐⭐⭐⭐⭐" + " (" + (this.facets.Ratings?.["5.0"] ?? 0) + ")" }, @@ -403,6 +414,7 @@ export default { this.pagination_count = result.data.count this.facets = result.data.facets + console.log(this.facets) if (this.facets?.cache_key) { this.getFacets(this.facets.cache_key) } @@ -480,7 +492,7 @@ export default { } }, getFacets: function (hash) { - this.genericGetAPI("api_get_facets", { hash: hash }).then((response) => { + return this.genericGetAPI("api_get_facets", { hash: hash }).then((response) => { this.facets = { ...this.facets, ...response.data.facets } }) }, @@ -512,6 +524,35 @@ export default { console.log(result.data) }) }, + loadFoodChildren({ action, parentNode, callback }) { + // Typically, do the AJAX stuff here. + // Once the server has responded, + // assign children options to the parent node & call the callback. + + if (action === LOAD_CHILDREN_OPTIONS) { + switch (parentNode.id) { + case "success": { + console.log(parentNode) + break + } + // case "no-children": { + // simulateAsyncOperation(() => { + // parentNode.children = [] + // callback() + // }) + // break + // } + // case "failure": { + // simulateAsyncOperation(() => { + // callback(new Error("Failed to load options: network error.")) + // }) + // break + // } + default: /* empty */ + } + } + callback() + }, }, } From dc10bf2c494a5c8afe20eb5f80a52d0546fdaab1 Mon Sep 17 00:00:00 2001 From: MaxJa4 <74194322+MaxJa4@users.noreply.github.com> Date: Wed, 12 Jan 2022 21:55:31 +0100 Subject: [PATCH 009/150] Add general note and remove duplicate subchapter from docker installation docs Add general note and remove duplicate subchapter from docker installation docs --- docs/install/docker.md | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/docs/install/docker.md b/docs/install/docker.md index 3a29ea42..8ffb5a22 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -60,6 +60,7 @@ The main, and also recommended, installation option is to install this applicati ### Plain This configuration exposes the application through an nginx web server on port 80 of your machine. +Be aware that having some other web server or container running on your host machine on port 80 will block this from working. ```shell wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml @@ -137,28 +138,6 @@ In both cases, also make sure to mount `/media/` in your swag container to point Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. -#### Nginx Swag by LinuxServer - -[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io - -It also contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance - -If you're running Swag on the default port, you'll just need to change the container name to yours. - -If your running Swag on a custom port, some headers must be changed. To do this, - -- Create a copy of `proxy.conf` -- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to - - `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;` -- Update `recipes.subdomain.conf` to use the new file -- Restart the linuxserver/swag container and Recipes will work - -More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627). - -In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory. - -Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. - ### Others If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking. From 22953b0591f8652938f993c7ee766738aadae809 Mon Sep 17 00:00:00 2001 From: smilerz Date: Wed, 12 Jan 2022 16:21:36 -0600 Subject: [PATCH 010/150] trees in recipe search loaded asynchronously --- cookbook/helper/recipe_search.py | 99 +++++++++++++------ cookbook/views/api.py | 9 +- .../RecipeSearchView/RecipeSearchView.vue | 71 ++++++------- vue/src/locales/en.json | 2 +- 4 files changed, 108 insertions(+), 73 deletions(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 608fd8a2..13c06fe5 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -201,12 +201,11 @@ def search_recipes(request, queryset, params): return queryset -class CacheEmpty(Exception): - pass - - class RecipeFacet(): - def __init__(self, request, queryset=None, hash_key=None, cache_timeout=600): + class CacheEmpty(Exception): + pass + + def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600): if hash_key is None and queryset is None: raise ValueError(_("One of queryset or hash_key must be provided")) @@ -215,15 +214,15 @@ class RecipeFacet(): self.hash_key = hash_key or str(hash(frozenset(self._queryset.values_list('pk')))) self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}" self._cache_timeout = cache_timeout - self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, None) + self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {}) if self._cache is None and self._queryset is None: - raise CacheEmpty("No queryset provided and cache empty") + raise self.CacheEmpty("No queryset provided and cache empty") - self.Keywords = getattr(self._cache, 'Keywords', None) - self.Foods = getattr(self._cache, 'Foods', None) - self.Books = getattr(self._cache, 'Books', None) - self.Ratings = getattr(self._cache, 'Ratings', None) - self.Recent = getattr(self._cache, 'Recent', None) + self.Keywords = self._cache.get('Keywords', None) + self.Foods = self._cache.get('Foods', None) + self.Books = self._cache.get('Books', None) + self.Ratings = self._cache.get('Ratings', None) + self.Recent = self._cache.get('Recent', None) if self._queryset: self._recipe_list = list(self._queryset.values_list('id', flat=True)) @@ -292,16 +291,9 @@ class RecipeFacet(): else: keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(depth=1)).filter(space=self._request.space).distinct() - # Subquery that counts recipes for keyword including children - kw_recipe_count = Recipe.objects.filter(**{'keywords__path__startswith': OuterRef('path')}, id__in=self._recipe_list, space=self._request.space - ).values(kw=Substr('keywords__path', 1, Keyword.steplen) - ).annotate(count=Count('pk', distinct=True)).values('count') - # set keywords to root objects only - keywords = keywords.annotate(count=Coalesce(Subquery(kw_recipe_count), 0) - ).filter(depth=1, count__gt=0 - ).values('id', 'name', 'count', 'numchild').order_by('name') - self.Keywords = list(keywords) + keywords = self._keyword_queryset(keywords) + self.Keywords = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)] self.set_cache('Keywords', self.Keywords) return self.Keywords @@ -313,16 +305,10 @@ class RecipeFacet(): else: foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(depth=1)).filter(space=self._request.space).distinct() - food_recipe_count = Recipe.objects.filter(**{'steps__ingredients__food__path__startswith': OuterRef('path')}, id__in=self._recipe_list, space=self._request.space - ).values(kw=Substr('steps__ingredients__food__path', 1, Food.steplen) - ).annotate(count=Count('pk', distinct=True)).values('count') - # set keywords to root objects only - foods = foods.annotate(count=Coalesce(Subquery(food_recipe_count), 0) - ).filter(depth=1, count__gt=0 - ).values('id', 'name', 'count', 'numchild' - ).order_by('name') - self.Foods = list(foods) + foods = self._food_queryset(foods) + + self.Foods = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)] self.set_cache('Foods', self.Foods) return self.Foods @@ -349,6 +335,59 @@ class RecipeFacet(): self.set_cache('Recent', self.Recent) return self.Recent + def add_food_children(self, id): + try: + food = Food.objects.get(id=id) + nodes = food.get_ancestors() + except Food.DoesNotExist: + return self.get_facets() + foods = self._food_queryset(Food.objects.filter(path__startswith=food.path, depth=food.depth+1), food) + deep_search = self.Foods + for node in nodes: + index = next((i for i, x in enumerate(deep_search) if x["id"] == node.id), None) + deep_search = deep_search[index]['children'] + index = next((i for i, x in enumerate(deep_search) if x["id"] == food.id), None) + deep_search[index]['children'] = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)] + self.set_cache('Foods', self.Foods) + return self.get_facets() + + def add_keyword_children(self, id): + try: + keyword = Keyword.objects.get(id=id) + nodes = keyword.get_ancestors() + except Keyword.DoesNotExist: + return self.get_facets() + keywords = self._keyword_queryset(Keyword.objects.filter(path__startswith=keyword.path, depth=keyword.depth+1), keyword) + deep_search = self.Keywords + for node in nodes: + index = next((i for i, x in enumerate(deep_search) if x["id"] == node.id), None) + deep_search = deep_search[index]['children'] + index = next((i for i, x in enumerate(deep_search) if x["id"] == keyword.id), None) + deep_search[index]['children'] = [{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)] + self.set_cache('Keywords', self.Keywords) + return self.get_facets() + + def _recipe_count_queryset(self, field, depth=1, steplen=4): + return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path')}, id__in=self._recipe_list, space=self._request.space + ).values(child=Substr(f'{field}__path', 1, steplen) + ).annotate(count=Count('pk', distinct=True)).values('count') + + def _keyword_queryset(self, queryset, keyword=None): + depth = getattr(keyword, 'depth', 0) + 1 + steplen = depth * Keyword.steplen + + return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0) + ).filter(depth=depth, count__gt=0 + ).values('id', 'name', 'count', 'numchild').order_by('name') + + def _food_queryset(self, queryset, food=None): + depth = getattr(food, 'depth', 0) + 1 + steplen = depth * Food.steplen + + return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0) + ).filter(depth__lte=depth, count__gt=0 + ).values('id', 'name', 'count', 'numchild').order_by('name') + # # TODO: This might be faster https://github.com/django-treebeard/django-treebeard/issues/115 # def get_facet(qs=None, request=None, use_cache=True, hash_key=None, food=None, keyword=None): diff --git a/cookbook/views/api.py b/cookbook/views/api.py index e9ba46ee..333e0c8b 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -1135,9 +1135,16 @@ def get_facets(request): keyword = request.GET.get('keyword', None) facets = RecipeFacet(request, hash_key=key) + if food: + results = facets.add_food_children(food) + elif keyword: + results = facets.add_keyword_children(keyword) + else: + results = facets.get_facets() + return JsonResponse( { - 'facets': facets.get_facets(), + 'facets': results, }, status=200 ) diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index b6c71789..0e8d25a0 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -80,7 +80,7 @@
@@ -97,6 +97,7 @@ { - if (x?.numchild > 0) { - return { ...x, children: null } - } else { - return x - } - }) - }, ratingOptions: function () { return [ { id: 5, label: "⭐⭐⭐⭐⭐" + " (" + (this.facets.Ratings?.["5.0"] ?? 0) + ")" }, @@ -414,10 +405,9 @@ export default { this.pagination_count = result.data.count this.facets = result.data.facets - console.log(this.facets) - if (this.facets?.cache_key) { - this.getFacets(this.facets.cache_key) - } + // if (this.facets?.cache_key) { + // this.getFacets(this.facets.cache_key) + // } this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id) if (!this.searchFiltered) { // if meal plans are being shown - filter out any meal plan recipes from the recipe list @@ -491,8 +481,12 @@ export default { return [undefined, undefined] } }, - getFacets: function (hash) { - return this.genericGetAPI("api_get_facets", { hash: hash }).then((response) => { + getFacets: function (hash, facet, id) { + let params = { hash: hash } + if (facet) { + params[facet] = id + } + return this.genericGetAPI("api_get_facets", params).then((response) => { this.facets = { ...this.facets, ...response.data.facets } }) }, @@ -520,9 +514,7 @@ export default { } else { params.options = { query: { debug: true } } } - this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => { - console.log(result.data) - }) + this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {}) }, loadFoodChildren({ action, parentNode, callback }) { // Typically, do the AJAX stuff here. @@ -530,28 +522,25 @@ export default { // assign children options to the parent node & call the callback. if (action === LOAD_CHILDREN_OPTIONS) { - switch (parentNode.id) { - case "success": { - console.log(parentNode) - break - } - // case "no-children": { - // simulateAsyncOperation(() => { - // parentNode.children = [] - // callback() - // }) - // break - // } - // case "failure": { - // simulateAsyncOperation(() => { - // callback(new Error("Failed to load options: network error.")) - // }) - // break - // } - default: /* empty */ + if (this.facets?.cache_key) { + this.getFacets(this.facets.cache_key, "food", parentNode.id).then(callback()) } + } else { + callback() + } + }, + loadKeywordChildren({ action, parentNode, callback }) { + // Typically, do the AJAX stuff here. + // Once the server has responded, + // assign children options to the parent node & call the callback. + + if (action === LOAD_CHILDREN_OPTIONS) { + if (this.facets?.cache_key) { + this.getFacets(this.facets.cache_key, "keyword", parentNode.id).then(callback()) + } + } else { + callback() } - callback() }, }, } diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index fcf49e54..c404ed99 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -116,7 +116,7 @@ "Information": "Information", "Download": "Download", "Create": "Create", - "Advanced Search Settings": "Advanced Search Settings", + "Search Settings": "Search Settings", "View": "View", "Recipes": "Recipes", "Move": "Move", From 798aa7f179370a4dbb6af37b22d41370ae477b0e Mon Sep 17 00:00:00 2001 From: smilerz Date: Wed, 12 Jan 2022 16:55:39 -0600 Subject: [PATCH 011/150] detect empty queryset --- cookbook/helper/recipe_search.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 13c06fe5..e026a0b1 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -224,7 +224,7 @@ class RecipeFacet(): self.Ratings = self._cache.get('Ratings', None) self.Recent = self._cache.get('Recent', None) - if self._queryset: + if self._queryset is not None: self._recipe_list = list(self._queryset.values_list('id', flat=True)) self._search_params = { 'keyword_list': self._request.query_params.getlist('keywords', []), @@ -235,7 +235,7 @@ class RecipeFacet(): 'search_books_or': str2bool(self._request.query_params.get('books_or', True)), 'space': self._request.space, } - elif self.hash_key: + elif self.hash_key is not None: self._recipe_list = self._cache.get('recipe_list', None) self._search_params = { 'keyword_list': self._cache.get('keyword_list', None), From 8b1233be62abbfe9d4ddee3fa674b7da3674a8a6 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 13 Jan 2022 12:02:28 -0600 Subject: [PATCH 012/150] facets cache-only on initial load --- cookbook/helper/recipe_search.py | 14 ++++-- cookbook/views/api.py | 4 +- .../RecipeSearchView/RecipeSearchView.vue | 50 ++++++++++--------- vue/src/locales/en.json | 6 ++- 4 files changed, 46 insertions(+), 28 deletions(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index e026a0b1..8126baf1 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -6,6 +6,7 @@ from django.core.cache import caches from django.db.models import Avg, Case, Count, Func, Max, OuterRef, Q, Subquery, Value, When from django.db.models.functions import Coalesce, Substr from django.utils import timezone, translation +from django.utils.translation import gettext as _ from cookbook.filters import RecipeFilter from cookbook.helper.HelperFunctions import Round, str2bool @@ -259,9 +260,16 @@ class RecipeFacet(): } caches['default'].set(self._SEARCH_CACHE_KEY, self._cache, self._cache_timeout) - def get_facets(self): - if self._cache is None: - pass + def get_facets(self, from_cache=False): + if from_cache: + return { + 'cache_key': self.hash_key or '', + 'Ratings': self.Ratings or {}, + 'Recent': self.Recent or [], + 'Keywords': self.Keywords or [], + 'Foods': self.Foods or [], + 'Books': self.Books or [] + } return { 'cache_key': self.hash_key, 'Ratings': self.get_ratings(), diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 333e0c8b..a65c22fa 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -604,6 +604,8 @@ class RecipePagination(PageNumberPagination): max_page_size = 100 def paginate_queryset(self, queryset, request, view=None): + if queryset is None: + raise Exception self.facets = RecipeFacet(request, queryset=queryset) return super().paginate_queryset(queryset, request, view) @@ -613,7 +615,7 @@ class RecipePagination(PageNumberPagination): ('next', self.get_next_link()), ('previous', self.get_previous_link()), ('results', data), - ('facets', self.facets.get_facets()) + ('facets', self.facets.get_facets(from_cache=True)) ])) diff --git a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue index 0e8d25a0..2c0c80f8 100644 --- a/vue/src/apps/RecipeSearchView/RecipeSearchView.vue +++ b/vue/src/apps/RecipeSearchView/RecipeSearchView.vue @@ -98,9 +98,10 @@ v-model="settings.search_keywords" :options="facets.Keywords" :load-options="loadKeywordChildren" - :flat="true" - searchNested :multiple="true" + :flat="true" + :auto-load-root-options="false" + searchNested :placeholder="$t('Keywords')" :normalizer="normalizer" @input="refreshData(false)" @@ -126,9 +127,10 @@ v-model="settings.search_foods" :options="facets.Foods" :load-options="loadFoodChildren" - :flat="true" - searchNested :multiple="true" + :flat="true" + :auto-load-root-options="false" + searchNested :placeholder="$t('Ingredients')" :normalizer="normalizer" @input="refreshData(false)" @@ -400,22 +402,28 @@ export default { if (!this.searchFiltered) { params.options = { query: { last_viewed: this.settings.recently_viewed } } } - this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => { - window.scrollTo(0, 0) - this.pagination_count = result.data.count + this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params) + .then((result) => { + window.scrollTo(0, 0) + this.pagination_count = result.data.count - this.facets = result.data.facets - // if (this.facets?.cache_key) { - // this.getFacets(this.facets.cache_key) - // } - this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id) - if (!this.searchFiltered) { - // if meal plans are being shown - filter out any meal plan recipes from the recipe list - let mealPlans = [] - this.meal_plans.forEach((x) => mealPlans.push(x.recipe.id)) - this.recipes = this.recipes.filter((recipe) => !mealPlans.includes(recipe.id)) - } - }) + this.facets = result.data.facets + // if (this.facets?.cache_key) { + // this.getFacets(this.facets.cache_key) + // } + this.recipes = this.removeDuplicates(result.data.results, (recipe) => recipe.id) + if (!this.searchFiltered) { + // if meal plans are being shown - filter out any meal plan recipes from the recipe list + let mealPlans = [] + this.meal_plans.forEach((x) => mealPlans.push(x.recipe.id)) + this.recipes = this.recipes.filter((recipe) => !mealPlans.includes(recipe.id)) + } + }) + .then(() => { + this.$nextTick(function () { + this.getFacets(this.facets?.cache_key) + }) + }) }, openRandom: function () { this.refreshData(true) @@ -525,8 +533,6 @@ export default { if (this.facets?.cache_key) { this.getFacets(this.facets.cache_key, "food", parentNode.id).then(callback()) } - } else { - callback() } }, loadKeywordChildren({ action, parentNode, callback }) { @@ -538,8 +544,6 @@ export default { if (this.facets?.cache_key) { this.getFacets(this.facets.cache_key, "keyword", parentNode.id).then(callback()) } - } else { - callback() } }, }, diff --git a/vue/src/locales/en.json b/vue/src/locales/en.json index c404ed99..5d3c70a0 100644 --- a/vue/src/locales/en.json +++ b/vue/src/locales/en.json @@ -282,5 +282,9 @@ "shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.", "shopping_add_onhand": "Auto On Hand", "related_recipes": "Related Recipes", - "today_recipes": "Today's Recipes" + "today_recipes": "Today's Recipes", + "mark_complete": "Mark Complete", + "QuickEntry": "Quick Entry", + "shopping_add_onhand_desc": "Mark food 'On Hand' when checked off shopping list.", + "shopping_add_onhand": "Auto On Hand" } From cf2d33daadc3b93609a57c5898137791ae1d9f0b Mon Sep 17 00:00:00 2001 From: Maximilian Jannack Date: Thu, 13 Jan 2022 21:17:08 +0100 Subject: [PATCH 013/150] Disabled old issue templates and added new ones with new GitHub issues format --- .../{bug_report.md => bug_report.md.bak} | 0 .github/ISSUE_TEMPLATE/bug_report.yml | 63 ++++++++++++++ ...ture_request.md => feature_request.md.bak} | 0 .github/ISSUE_TEMPLATE/feature_request.yml | 39 +++++++++ .../{help-request.md => help-request.md.bak} | 0 .github/ISSUE_TEMPLATE/help_request.yml | 82 +++++++++++++++++++ .../{url_import.md => url_import.md.bak} | 0 .github/ISSUE_TEMPLATE/website_import.yml | 36 ++++++++ 8 files changed, 220 insertions(+) rename .github/ISSUE_TEMPLATE/{bug_report.md => bug_report.md.bak} (100%) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml rename .github/ISSUE_TEMPLATE/{feature_request.md => feature_request.md.bak} (100%) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml rename .github/ISSUE_TEMPLATE/{help-request.md => help-request.md.bak} (100%) create mode 100644 .github/ISSUE_TEMPLATE/help_request.yml rename .github/ISSUE_TEMPLATE/{url_import.md => url_import.md.bak} (100%) create mode 100644 .github/ISSUE_TEMPLATE/website_import.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md.bak similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/bug_report.md.bak diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2fdbcc3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,63 @@ +name: Bug Report +description: "Create a report to help us improve" +#title: "" +#labels: ["Bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: input + id: version + attributes: + label: Tandoor Version + description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)" + validations: + required: true + - type: dropdown + id: setup + attributes: + label: Setup + description: "How is your Tandoor instance set up?" + options: + - Docker / Docker-Compose + - Unraid + - Synology + - Kubernetes + - Manual Setup + - Others (please state below) + validations: + required: true + - type: dropdown + id: reverse-proxy + attributes: + label: "Reverse Proxy" + description: "What reverse proxy do you use with Tandoor?" + options: + - No reverse proxy + - jwilder's nginx proxy + - Nginx Proxy Manager (NPM) + - SWAG + - Caddy + - Traefik + - Others (please state below) + validations: + required: true + - type: input + id: other + attributes: + label: Other + description: "In case you chose 'Others' above, please provide more info here." + - type: textarea + id: bug-descr + attributes: + label: Bug description + description: "Please accurately describe the bug you encountered." + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Please copy and paste any relevant logs. This will be automatically formatted into code, so no need for backticks. + render: shell diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md.bak similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to .github/ISSUE_TEMPLATE/feature_request.md.bak diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..ad823cfa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature Request +description: "Suggest an idea for this project" +#title: "" +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this feature request! + - type: textarea + id: problem + attributes: + label: "Is your feature request related to a problem? Please describe." + description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when..." + - type: textarea + id: solution + attributes: + label: "Describe the solution you'd like" + description: "A clear and concise description of what you want to happen." + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: "Describe alternatives you've considered" + description: "A clear and concise description of any alternative solutions or features you've considered." + - type: textarea + id: additional + attributes: + label: "Additional context" + description: "Add any other context or screenshots about the feature request here." + - type: checkboxes + attributes: + label: "Contribute" + description: "Are you willing and able to help develop this feature?" + options: + - label: "Yes" + - label: "Partly" + - label: "No" diff --git a/.github/ISSUE_TEMPLATE/help-request.md b/.github/ISSUE_TEMPLATE/help-request.md.bak similarity index 100% rename from .github/ISSUE_TEMPLATE/help-request.md rename to .github/ISSUE_TEMPLATE/help-request.md.bak diff --git a/.github/ISSUE_TEMPLATE/help_request.yml b/.github/ISSUE_TEMPLATE/help_request.yml new file mode 100644 index 00000000..55c904ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/help_request.yml @@ -0,0 +1,82 @@ +name: Help request +description: "If there is anything wrong with your setup" +#title: "" +labels: ["setup issue"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this help request! + - type: textarea + id: issue + attributes: + label: Issue + description: "Please describe your problem here." + validations: + required: true + - type: input + id: version + attributes: + label: Tandoor Version + description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)" + validations: + required: true + - type: input + id: os + attributes: + label: OS Version + description: "E.g. Ubuntu 20.02" + validations: + required: true + - type: dropdown + id: setup + attributes: + label: Setup + description: "How is your Tandoor instance set up?" + options: + - Docker / Docker-Compose + - Unraid + - Synology + - Kubernetes + - Manual Setup + - Others (please state below) + validations: + required: true + - type: dropdown + id: reverse-proxy + attributes: + label: "Reverse Proxy" + description: "What reverse proxy do you use with Tandoor?" + options: + - No reverse proxy + - jwilder's nginx proxy + - Nginx Proxy Manager (NPM) + - SWAG + - Caddy + - Traefik + - Others (please state below) + validations: + required: true + - type: input + id: other + attributes: + label: Other + description: "In case you chose 'Others' above or have more info, please provide additional details here." + - type: textarea + id: env + attributes: + label: Environment file + description: "Please include your `.env` config file (**make sure to remove/replace all secrets**)" + render: shell + - type: textarea + id: docker-compose + attributes: + label: Docker-Compose file + description: "When running with docker compose please provide your `docker-compose.yml`" + render: shell + - type: textarea + id: logs + attributes: + label: Relevant logs + description: "If you feel like there is anything interesting please post the output of `docker-compose logs` at container startup and when the issue happens." + render: shell diff --git a/.github/ISSUE_TEMPLATE/url_import.md b/.github/ISSUE_TEMPLATE/url_import.md.bak similarity index 100% rename from .github/ISSUE_TEMPLATE/url_import.md rename to .github/ISSUE_TEMPLATE/url_import.md.bak diff --git a/.github/ISSUE_TEMPLATE/website_import.yml b/.github/ISSUE_TEMPLATE/website_import.yml new file mode 100644 index 00000000..aeba4fbd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/website_import.yml @@ -0,0 +1,36 @@ +name: Website Import +description: "Anything related to website imports" +#title: "" +#labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this website import form! + - type: input + id: version + attributes: + label: Tandoor Version + description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)" + validations: + required: true + - type: input + id: url + attributes: + label: Import URL + description: "Exact URL you are trying to import from." + validations: + required: true + - type: textarea + id: bug-descr + attributes: + label: "When did the issue happen?" + description: "When pressing the search button with the url / when importing after the page has loaded / ..." + validations: + required: true + - type: textarea + id: logs + attributes: + label: Response / message shown + description: Please copy and paste any relevant logs or responses / messages which are shown in Tandoor. This will be automatically formatted into code, so no need for backticks. + render: shell From ffa91863dd131e25822b212821ed136162700812 Mon Sep 17 00:00:00 2001 From: Maximilian Jannack Date: Thu, 13 Jan 2022 21:26:15 +0100 Subject: [PATCH 014/150] Added config.yml for FAQ link --- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..f867d5c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: FAQs + url: https://docs.tandoor.dev/faq/ + about: Please take a look at the FAQs before creating a bug ticket. From d2d27657651b035dbadd46d1da920be4c1c9efbf Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 13 Jan 2022 15:53:24 -0600 Subject: [PATCH 015/150] Update serializer.py image location should use MEDIA_URL alone and not combine with SCRIPT_NAME --- cookbook/serializer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 09520545..48236f39 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -22,7 +22,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Fo SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog) from cookbook.templatetags.custom_tags import markdown -from recipes.settings import MEDIA_URL, SCRIPT_NAME +from recipes.settings import MEDIA_URL class ExtendedRecipeMixin(serializers.ModelSerializer): @@ -56,7 +56,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): def get_image(self, obj): if obj.recipe_image: - return SCRIPT_NAME + MEDIA_URL + obj.recipe_image + return MEDIA_URL + obj.recipe_image def count_recipes(self, obj): return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count() From e313481fc8b65acf5a6b9368242ebe349e0a702d Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 13 Jan 2022 16:00:59 -0600 Subject: [PATCH 016/150] WIP --- cookbook/helper/shopping_helper.py | 1 + cookbook/models.py | 1 + cookbook/serializer.py | 4 ++-- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index 658f63ca..c1a3dc1a 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -38,6 +38,7 @@ def shopping_helper(qs, request): return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') +# TODO refactor as class def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False): """ Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe diff --git a/cookbook/models.py b/cookbook/models.py index 7187db33..7e4bd1f6 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -1146,6 +1146,7 @@ class ModelFilter(models.Model): field = models.CharField(max_length=32) field_type = models.CharField(max_length=32, choices=(FIELD_TYPE)) operation = models.CharField(max_length=32, choices=(OPERATION)) + negate = models.BooleanField(default=False,) target_value = models.CharField(max_length=128) sort = models.BooleanField(default=False,) ascending = models.BooleanField(default=True,) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 09520545..48236f39 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -22,7 +22,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Fo SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog) from cookbook.templatetags.custom_tags import markdown -from recipes.settings import MEDIA_URL, SCRIPT_NAME +from recipes.settings import MEDIA_URL class ExtendedRecipeMixin(serializers.ModelSerializer): @@ -56,7 +56,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): def get_image(self, obj): if obj.recipe_image: - return SCRIPT_NAME + MEDIA_URL + obj.recipe_image + return MEDIA_URL + obj.recipe_image def count_recipes(self, obj): return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count() From 6d9a90c6bac8785f43c8377384651b3c8defaabb Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 13 Jan 2022 16:49:10 -0600 Subject: [PATCH 017/150] fix onhand_users --- cookbook/models.py | 5 ++++- cookbook/serializer.py | 13 +++++++++---- vue/src/components/GenericHorizontalCard.vue | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/cookbook/models.py b/cookbook/models.py index 7e4bd1f6..07e23097 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -77,7 +77,10 @@ class TreeManager(MP_NodeManager): for field in many_to_many: field_model = getattr(obj, field).model for related_obj in many_to_many[field]: - getattr(obj, field).add(field_model.objects.get(**dict(related_obj))) + if isinstance(related_obj, User): + getattr(obj, field).add(field_model.objects.get(id=related_obj.id)) + else: + getattr(obj, field).add(field_model.objects.get(**dict(related_obj))) return obj, True except IntegrityError as e: if 'Key (path)' in e.args[0]: diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 48236f39..0fa2ee21 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -394,15 +394,20 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create( name=validated_data.pop('supermarket_category')['name'], space=self.context['request'].space) - onhand = validated_data.get('food_onhand', None) + onhand = validated_data.pop('food_onhand', None) # assuming if on hand for user also onhand for shopping_share users if not onhand is None: shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) - if onhand: - validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users + if self.instance: + onhand_users = self.instance.onhand_users.all() else: - validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users)) + onhand_users = [] + if onhand: + validated_data['onhand_users'] = list(onhand_users) + shared_users + else: + validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users)) + obj, created = Food.objects.get_or_create(**validated_data) return obj diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index 39a31d90..9d7554b0 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -238,7 +238,7 @@ export default { }) popper.update() this.over = false - this.$emit({ action: "drop", target: this.item, source: this.source }) + // this.$emit({ action: "drop", target: this.item, source: this.source }) } else { this.isError = true } From b3f05b0bfdf5bc3e77a154cdaea6d520bb5d50e8 Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 13 Jan 2022 16:50:15 -0600 Subject: [PATCH 018/150] fix bug creating food with create form --- cookbook/models.py | 5 ++++- cookbook/serializer.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/cookbook/models.py b/cookbook/models.py index cfe4ab81..1f4562c0 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -77,7 +77,10 @@ class TreeManager(MP_NodeManager): for field in many_to_many: field_model = getattr(obj, field).model for related_obj in many_to_many[field]: - getattr(obj, field).add(field_model.objects.get(**dict(related_obj))) + if isinstance(related_obj, User): + getattr(obj, field).add(field_model.objects.get(id=related_obj.id)) + else: + getattr(obj, field).add(field_model.objects.get(**dict(related_obj))) return obj, True except IntegrityError as e: if 'Key (path)' in e.args[0]: diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 48236f39..0fa2ee21 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -394,15 +394,20 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create( name=validated_data.pop('supermarket_category')['name'], space=self.context['request'].space) - onhand = validated_data.get('food_onhand', None) + onhand = validated_data.pop('food_onhand', None) # assuming if on hand for user also onhand for shopping_share users if not onhand is None: shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all()) - if onhand: - validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users + if self.instance: + onhand_users = self.instance.onhand_users.all() else: - validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users)) + onhand_users = [] + if onhand: + validated_data['onhand_users'] = list(onhand_users) + shared_users + else: + validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users)) + obj, created = Food.objects.get_or_create(**validated_data) return obj From 0e1153ce3a818f2367f868587846b23f2bdd9c2f Mon Sep 17 00:00:00 2001 From: smilerz Date: Thu, 13 Jan 2022 17:40:26 -0600 Subject: [PATCH 019/150] deleted extraneous emit --- vue/src/components/GenericHorizontalCard.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index 39a31d90..8f130b47 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -238,7 +238,6 @@ export default { }) popper.update() this.over = false - this.$emit({ action: "drop", target: this.item, source: this.source }) } else { this.isError = true } From 2927333bf14f2f75fca4f1363cc8b59a249f7a26 Mon Sep 17 00:00:00 2001 From: Marcus Wolschon Date: Fri, 14 Jan 2022 13:52:42 +0100 Subject: [PATCH 020/150] #1093 code cleanup --- vue/src/apps/MealPlanView/MealPlanView.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index 7976a0d0..66c74ae8 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -132,11 +132,11 @@ {{ $t("Recipe") }} @@ -275,7 +275,7 @@ import moment from "moment" import draggable from "vuedraggable" import VueCookies from "vue-cookies" -import { ApiMixin, StandardToasts, ResolveUrlMixin } from "@/utils/utils" +import { ApiMixin, StandardToasts, ResolveUrlMixin, resolveDjangoUrl } from "@/utils/utils" import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle" import { ApiApiFactory } from "@/utils/openapi/api" @@ -422,8 +422,8 @@ export default { }, }, methods: { - navigateToURL: function(url) { - window.open(url) + openReceipt: function(recipe) { + window.open(resolveDjangoUrl('view_recipe', recipe.id)) }, addToShopping(entry) { if (entry.originalItem.entry.recipe !== null) { From 934eeee5c4779fec09cb608f5274f2473bc0d686 Mon Sep 17 00:00:00 2001 From: Marcus Wolschon Date: Fri, 14 Jan 2022 13:56:46 +0100 Subject: [PATCH 021/150] #1093 code cleanup --- vue/src/apps/MealPlanView/MealPlanView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index 66c74ae8..07b61685 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -275,7 +275,7 @@ import moment from "moment" import draggable from "vuedraggable" import VueCookies from "vue-cookies" -import { ApiMixin, StandardToasts, ResolveUrlMixin, resolveDjangoUrl } from "@/utils/utils" +import { ApiMixin, StandardToasts, ResolveUrlMixin } from "@/utils/utils" import { CalendarView, CalendarMathMixin } from "vue-simple-calendar/src/components/bundle" import { ApiApiFactory } from "@/utils/openapi/api" @@ -423,7 +423,7 @@ export default { }, methods: { openReceipt: function(recipe) { - window.open(resolveDjangoUrl('view_recipe', recipe.id)) + window.open(this.resolveDjangoUrl('view_recipe', recipe.id)) }, addToShopping(entry) { if (entry.originalItem.entry.recipe !== null) { From 01d5ab92c5883c030873312ced62047abdbb0761 Mon Sep 17 00:00:00 2001 From: Tiago Rascazzi Date: Thu, 6 Jan 2022 16:30:49 +0000 Subject: [PATCH 022/150] Translated using Weblate (French) Currently translated at 72.6% (202 of 278 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/ --- vue/src/locales/fr.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/vue/src/locales/fr.json b/vue/src/locales/fr.json index c686f08e..6eb6dee0 100644 --- a/vue/src/locales/fr.json +++ b/vue/src/locales/fr.json @@ -192,5 +192,13 @@ "Edit_Recipe": "Modifier une Recette", "Move_Up": "Monter", "Time": "Temps", - "Coming_Soon": "Bientôt disponible" + "Coming_Soon": "Bientôt disponible", + "Create_New_Shopping Category": "Ajouter une catégorie de courses", + "success_moving_resource": "Ressource correctement déplacée !", + "err_moving_resource": "Il y a eu une erreur pour déplacer une ressource !", + "err_merging_resource": "Il y a eu une erreur pour fusionner une ressource !", + "success_merging_resource": "Ressource correctement fusionnée !", + "Added_by": "Ajouter par", + "Added_on": "Ajouter le", + "Shopping_Categories": "Catégories de courses" } From 91fcb1b822b5bacc7d90245232ff14d21db7fe08 Mon Sep 17 00:00:00 2001 From: Tomasz Klimczak Date: Thu, 6 Jan 2022 12:50:29 +0000 Subject: [PATCH 023/150] Translated using Weblate (Polish) Currently translated at 80.9% (225 of 278 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/ --- vue/src/locales/pl.json | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/vue/src/locales/pl.json b/vue/src/locales/pl.json index 759237a9..959ed23b 100644 --- a/vue/src/locales/pl.json +++ b/vue/src/locales/pl.json @@ -1,5 +1,5 @@ { - "warning_feature_beta": "Ta funkcja jest obecnie w wersji BETA (testowej). Podczas korzystania z tej funkcji należy spodziewać się błędów i ewentualnych zmian w przyszłości (prawdopodobna utrata danych powiązanych z tą funkcją).", + "warning_feature_beta": "Ta funkcja jest obecnie w wersji BETA (testowej). Podczas korzystania z tej funkcji mogą wystąpić błędy, a w przyszłości zmiany funkcjonalności (możliwa utrata danych powiązanych z tą funkcją).", "err_fetching_resource": "Wystąpił błąd podczas pobierania zasobu!", "err_creating_resource": "Wystąpił błąd podczas tworzenia zasobu!", "err_updating_resource": "Wystąpił błąd podczas aktualizowania zasobu!", @@ -207,5 +207,22 @@ "Auto_Planner": "Plan automatyczny", "New_Cookbook": "Nowa książka kucharska", "Hide_Keyword": "Ukryj słowa kluczowe", - "Clear": "Wyczyść" + "Clear": "Wyczyść", + "err_moving_resource": "Wystąpił błąd podczas przenoszenia zasobu!", + "err_merging_resource": "Wystąpił błąd podczas scalania zasobu!", + "success_moving_resource": "Pomyślnie przeniesiono zasób!", + "success_merging_resource": "Pomyślnie scalono zasób!", + "Added_by": "Dodane przez", + "Added_on": "Dodano dnia", + "IngredientInShopping": "Ten składnik znajduje się na Twojej liście zakupów.", + "NotInShopping": "{food} nie ma na Twojej liście zakupów.", + "OnHand": "Obecnie pod ręką", + "FoodNotOnHand": "Nie masz pod ręką {food}.", + "Undefined": "Nieokreślony", + "AddFoodToShopping": "Dodaj {food} do swojej listy zakupów", + "RemoveFoodFromShopping": "Usuń {food} z listy zakupów", + "Shopping_Categories": "Kategorie zakupów", + "AddToShopping": "Dodaj do listy zakupów", + "FoodOnHand": "Masz pod ręką {food}.", + "DeleteShoppingConfirm": "Czy na pewno chcesz usunąć wszystkie {food} z listy zakupów?" } From 51620a34d93a9745d6e356f77eb99fd43c91d283 Mon Sep 17 00:00:00 2001 From: tomtjes Date: Fri, 14 Jan 2022 15:10:22 -0500 Subject: [PATCH 024/150] add swag config example --- docs/install/docker.md | 2 + docs/install/swag.md | 118 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/install/swag.md diff --git a/docs/install/docker.md b/docs/install/docker.md index 8ffb5a22..48408564 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -138,6 +138,8 @@ In both cases, also make sure to mount `/media/` in your swag container to point Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. +For step-by-step instructions to set this up from scratch, see [this example](swag.md). + ### Others If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [PLAIN](https://docs.tandoor.dev/install/docker/#plain) setup above and change the outbound port to one of your liking. diff --git a/docs/install/swag.md b/docs/install/swag.md new file mode 100644 index 00000000..3beeb112 --- /dev/null +++ b/docs/install/swag.md @@ -0,0 +1,118 @@ +!!! danger + Please refer to the [official documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. This example shows just one setup that may or may not differ from yours in significant ways. This tutorial does not cover security measures, backups, and many other things that you might want to consider. + +## Prerequisites + +- You have a newly spun-up Ubuntu server with docker (pre-)installed. +- At least one `mydomain.com` and one `mysubdomain.mydomain.com` are pointing to the server's IP. (This tutorial does not cover subfolder installation.) +- You have an ssh terminal session open. + +## Installation + +### Download and edit Tandoor configuration + +``` +cd /opt +mkdir recipes +cd recipes +wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env +base64 /dev/urandom | head -c50 +``` +Copy the response from that last command and paste the key into the `.env` file: +``` +nano .env +``` +You'll also need to enter a Postgres password into the `.env` file. Then, save the file and exit the editor. + +### Install and configure Docker Compose + +In keeping with [these instructions](https://docs.linuxserver.io/general/docker-compose): +``` +cd /opt +curl -L --fail https://raw.githubusercontent.com/linuxserver/docker-docker-compose/master/run.sh -o /usr/local/bin/docker-compose +chmod +x /usr/local/bin/docker-compose +``` + +Next, create and edit the docker compose file. + +``` +nano docker-compose.yml +``` + +Paste the following and adjust your domains, subdomains and time zone. + +``` +--- +version: "2.1" +services: + swag: + image: ghcr.io/linuxserver/swag + container_name: swag + cap_add: + - NET_ADMIN + environment: + - PUID=1000 + - PGID=1000 + - TZ=Europe/Berlin # <---- EDIT THIS <---- <---- + - URL=mydomain.com # <---- EDIT THIS <---- <---- + - SUBDOMAINS=mysubdomain,myothersubdomain # <---- EDIT THIS <---- <---- + - EXTRA_DOMAINS=myotherdomain.com # <---- EDIT THIS <---- <---- + - VALIDATION=http + volumes: + - ./swag:/config + - ./recipes/media:/media + ports: + - 443:443 + - 80:80 + restart: unless-stopped + + db_recipes: + restart: always + container_name: db_recipes + image: postgres:11-alpine + volumes: + - ./recipes/db:/var/lib/postgresql/data + env_file: + - ./recipes/.env + + recipes: + image: vabene1111/recipes + container_name: recipes + restart: unless-stopped + env_file: + - ./recipes/.env + environment: + - UID=1000 + - GID=1000 + - TZ=Europe/Berlin # <---- EDIT THIS <---- <---- + volumes: + - ./recipes/static:/opt/recipes/staticfiles + - ./recipes/media:/opt/recipes/mediafiles + depends_on: + - db_recipes +``` + +Save and exit. + +### Create containers and configure swag reverse proxy + +``` +docker-compose up -d +``` + +``` +cd /opt/swag/nginx/proxy-confs +cp recipes.subdomain.conf.sample recipes.subdomain.conf +nano recipes.subdomain.conf +``` + +Change the line `server_name recipes.*;` to `server_name mysubdomain.*;`, save and exit. + +### Finalize + +``` +cd /opt +docker restart swag recipes +``` + +Go to `https://mysubdomain.mydomain.com`. (If you get a "502 Bad Gateway" error, be patient. It might take a short while until it's functional.) \ No newline at end of file From 17ad01ae8c0ba17923dffafbac22a6d39b79ca76 Mon Sep 17 00:00:00 2001 From: Josselin du PLESSIS Date: Fri, 14 Jan 2022 22:42:42 +0000 Subject: [PATCH 025/150] Translated using Weblate (French) Currently translated at 100.0% (284 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/ --- vue/src/locales/fr.json | 85 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/vue/src/locales/fr.json b/vue/src/locales/fr.json index 6eb6dee0..a250b9e4 100644 --- a/vue/src/locales/fr.json +++ b/vue/src/locales/fr.json @@ -200,5 +200,88 @@ "success_merging_resource": "Ressource correctement fusionnée !", "Added_by": "Ajouter par", "Added_on": "Ajouter le", - "Shopping_Categories": "Catégories de courses" + "Shopping_Categories": "Catégories de courses", + "Add_Servings_to_Shopping": "Ajouter {servings} partions aux courses", + "CountMore": "...+ {count} en plus", + "NoCategory": "Pas de catégorie sélectionnée.", + "OfflineAlert": "Vous êtes déconnecté, votre liste de courses peut ne pas être synchronisée.", + "shopping_share_desc": "Les utilisateurs verront tous les articles que vous ajoutez à votre liste de courses. Ils doivent vous ajouter pour que vous voyez les articles de leur liste.", + "shopping_auto_sync_desc": "Le réglage sur 0 désactive la synchronisation automatique. Lorsque vous consultez une liste de courses, celle-ci est mise à jour toutes les secondes pour synchroniser les modifications apportées par une autre personne. Cette fonction est utile lorsque vous faites des achats avec plusieurs personnes, mais elle consomme des données mobiles.", + "mealplan_autoinclude_related_desc": "Lorsque vous ajoutez un plan de repas à la liste de courses (manuellement ou automatiquement), incluez toutes les recettes associées.", + "err_move_self": "Impossible de déplacer un élément vers lui-même", + "show_sql": "Montrer le SQL", + "filter_to_supermarket_desc": "Par défaut, la liste de courses est filtrée pour n'inclure que les catégories du supermarché sélectionné.", + "CategoryInstruction": "Faites glisser les catégories pour modifier l'ordre dans lequel elles apparaissent dans la liste des courses.", + "in_shopping": "Dans la liste de courses", + "and_up": "&Au-dessus", + "Plan_Show_How_Many_Periods": "Combien de périodes montrer", + "Edit_Meal_Plan_Entry": "Modifier le plan de repas", + "Periods": "Périodes", + "Period": "Période", + "Plan_Period_To_Show": "Montrer les semaines, mois ou années", + "Auto_Planner": "Planning automatique", + "New_Cookbook": "Nouveau livres de recettes", + "Hide_Keyword": "Cacher les mots clés", + "Clear": "Supprimer", + "AddToShopping": "Ajouter à la liste de courses", + "IngredientInShopping": "Cet ingrédient est dans votre liste de courses.", + "NotInShopping": "{food} n'est pas dans votre liste de courses.", + "OnHand": "Disponible actuellement", + "FoodNotOnHand": "L'ingrédient {food} n'est pas disponible.", + "Planner": "Planificateur", + "Planner_Settings": "Paramètres du planificateur", + "AddFoodToShopping": "Ajouter l'ingrédient {food} à votre liste de courses", + "DeleteShoppingConfirm": "Etes-vous sûr que vous souhaitez retirer tous les ingrédients {food} de votre liste de courses ?", + "IgnoredFood": "L'ingrédient {food} est paramétré pour ignorer les courses.", + "Inherit": "Hériter", + "InheritFields": "Hériter les valeurs des champs", + "FoodInherit": "Ingrédient hérité", + "ShowUncategorizedFood": "Montrer ce qui est indéfini", + "GroupBy": "Grouper par", + "SupermarketCategoriesOnly": "Catégories de supermarché uniquement", + "MoveCategory": "Déplacer vers : ", + "IgnoreThis": "Ne jamais ajouter l'ingrédient {food} aux courses", + "DelayFor": "Retard de {hours} heures", + "Warning": "Avertissement", + "InheritWarning": "L'ingrédient {food} est un héritage, les changements pourraient ne pas être conservés.", + "ShowDelayed": "Afficher les éléments retardés", + "Completed": "Achevé", + "shopping_share": "Partager la liste de courses", + "shopping_auto_sync": "Autosynchronisation", + "mealplan_autoadd_shopping": "Ajout automatique d'un plan de repas", + "mealplan_autoexclude_onhand": "Exclure les aliments disponibles", + "mealplan_autoinclude_related": "Ajouter les recettes connexes", + "default_delay": "Heures de retard par défaut", + "mealplan_autoadd_shopping_desc": "Ajouter automatiquement les ingrédients du plan de repas à la liste de courses.", + "mealplan_autoexclude_onhand_desc": "Lorsque vous ajoutez un plan de repas à la liste de courses (manuellement ou automatiquement), excluez les ingrédients que vous avez déjà.", + "default_delay_desc": "Nombre d'heures par défaut pour retarder l'ajoût d'un article à la liste de courses.", + "filter_to_supermarket": "Limiter au supermarché", + "nothing": "Rien à effectuer", + "err_merge_self": "Impossible de fusionner un élément avec lui-même", + "CategoryName": "Intitulé de la catégorie", + "SupermarketName": "Nom du supermarché", + "shopping_recent_days_desc": "Jours des entrées récentes de la liste de courses à afficher.", + "shopping_recent_days": "Jours récents", + "create_shopping_new": "Ajouter à la NOUVELLE liste de courses", + "download_pdf": "Télécharger le PDF", + "download_csv": "Télécharger le CSV", + "csv_delim_help": "Délimiteur à utiliser pour les exports CSV.", + "csv_delim_label": "Délimiteur CSV", + "SuccessClipboard": "Liste de courses copiée dans le presse-papiers", + "copy_to_clipboard": "Copier dans le presse-papiers", + "csv_prefix_help": "Préfixe à ajouter lors de la copie de la liste dans le presse-papiers.", + "csv_prefix_label": "Lister les préfixes", + "copy_markdown_table": "Copier en tant que tableau Markdown", + "DelayUntil": "Retard jusqu'à", + "mark_complete": "Marque comme terminé", + "QuickEntry": "Entrée rapide", + "shopping_add_onhand_desc": "Marquer les aliments comme \"disponibles\" lorsqu'ils sont cochés sur la liste des courses.", + "shopping_add_onhand": "Disponible par défaut", + "related_recipes": "Recettes connexes", + "today_recipes": "Recettes du jour", + "Search Settings": "Paramètres de recherche", + "FoodOnHand": "L'ingrédient {food} est disponible.", + "Undefined": "Indéfini", + "Create_Meal_Plan_Entry": "Création d'un plan de repas", + "RemoveFoodFromShopping": "Retirer l'ingrédient {food} de votre liste de courses" } From 965d2c05e74127e134bd76968382d39d0d2aa46b Mon Sep 17 00:00:00 2001 From: FrenchAnon Date: Fri, 14 Jan 2022 23:17:50 +0000 Subject: [PATCH 026/150] Translated using Weblate (French) Currently translated at 100.0% (284 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/ --- vue/src/locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/locales/fr.json b/vue/src/locales/fr.json index a250b9e4..efd71591 100644 --- a/vue/src/locales/fr.json +++ b/vue/src/locales/fr.json @@ -37,7 +37,7 @@ "Carbohydrates": "Glucides", "Calories": "Calories", "Energy": "Energie", - "Nutrition": "Informations nutritionnelles", + "Nutrition": "Valeurs nutritionnelles", "Date": "Date", "Share": "Partager", "Export": "Exporter", From 8cebc98d3b381f4033e93ee1403297ce5b8f4af6 Mon Sep 17 00:00:00 2001 From: SMunos Date: Fri, 14 Jan 2022 23:18:38 +0000 Subject: [PATCH 027/150] Translated using Weblate (French) Currently translated at 100.0% (284 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/ --- vue/src/locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/locales/fr.json b/vue/src/locales/fr.json index efd71591..79a44627 100644 --- a/vue/src/locales/fr.json +++ b/vue/src/locales/fr.json @@ -219,7 +219,7 @@ "Periods": "Périodes", "Period": "Période", "Plan_Period_To_Show": "Montrer les semaines, mois ou années", - "Auto_Planner": "Planning automatique", + "Auto_Planner": "Planning Auto", "New_Cookbook": "Nouveau livres de recettes", "Hide_Keyword": "Cacher les mots clés", "Clear": "Supprimer", From f07690d7e3d033fed69c024d6f52c9eaedcf81a0 Mon Sep 17 00:00:00 2001 From: mheiland <15824364+mheiland@users.noreply.github.com> Date: Sat, 15 Jan 2022 00:24:56 +0100 Subject: [PATCH 028/150] Example for third-party authentication Providing an example to integrate Keycloak as IAM for Tandoor. Hinting that both SOCIAL* variables are required. --- docs/features/authentication.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/features/authentication.md b/docs/features/authentication.md index 6390e91e..0f611488 100644 --- a/docs/features/authentication.md +++ b/docs/features/authentication.md @@ -32,7 +32,7 @@ as environment files loaded by docker compose don't support multiple lines for a Take the example configuration from the allauth docs, fill in your settings and then inline the whole object (you can use a service like [www.freeformatter.com](https://www.freeformatter.com/json-formatter.html) for formatting). -Assign it to the `SOCIALACCOUNT_PROVIDERS` variable. +Assign it to the additional `SOCIALACCOUNT_PROVIDERS` variable. ```ini SOCIALACCOUNT_PROVIDERS={"nextcloud":{"SERVER":"https://nextcloud.example.org"}} @@ -56,6 +56,25 @@ Use the superuser account to grant permissions to the newly created users. I do not have a ton of experience with using various single signon providers and also cannot test all of them. If you have any Feedback or issues let me know. +### Third-party authentication example +Keycloak is a popular IAM solution and integration is straight forward thanks to Django Allauth. This example can also be used as reference for other third-party authentication solutions, as documented by Allauth. + +At Keycloak, create a new client and assign a `Client-ID`, this client comes with a `Secret-Key`. Both values are required later on. Make sure to define the correct Redirection-URL for the service, for example `https://tandoor.example.com/*`. Depending on your Keycloak setup, you need to assign roles and groups to grant access to the service. + +To enable Keycloak as a sign in option, set those variables to define the social provider and specify its configuration: +```ini +SOCIAL_PROVIDERS=allauth.socialaccount.providers.keycloak +SOCIALACCOUNT_PROVIDERS='{ "keycloak": { "KEYCLOAK_URL": "https://auth.example.com/", "KEYCLOAK_REALM": "master" } }' +``` + +1. Restart the service, login as superuser and open the `Admin` page. +2. Make sure that the correct `Domain Name` is defined at `Sites`. +3. Select `Social Application` and chose `Keycloak` from the provider list. +4. Provide an arbitrary name for your authentication provider, and enter the `Client-ID` and `Secret Key` values obtained from Keycloak earlier. +5. Make sure to add your `Site` to the list of available sites and save the new `Social Application`. + +You are now able to sign in using Keycloak. + ### Linking accounts To link an account to an already existing normal user go to the settings page of the user and link it. Here you can also unlink your account if you no longer want to use a social login method. From 9221533ae72d705e32ae061a63d00b2eeb3aae99 Mon Sep 17 00:00:00 2001 From: MaxJa4 <74194322+MaxJa4@users.noreply.github.com> Date: Sat, 15 Jan 2022 12:56:01 +0100 Subject: [PATCH 029/150] Added Apache2 in the bug report template Added Apache2 as selectable option in the bug report template --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2fdbcc3b..e5e1c3be 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -40,6 +40,7 @@ body: - SWAG - Caddy - Traefik + - Apache2 - Others (please state below) validations: required: true From 281535e756e6ecd5a7a378a1b6eb0ae0a1900a50 Mon Sep 17 00:00:00 2001 From: tomtjes Date: Sat, 15 Jan 2022 13:57:20 -0500 Subject: [PATCH 030/150] phrase FAQ as questions --- docs/faq.md | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index 16ef0472..c9c55725 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -2,7 +2,7 @@ There are several questions and issues that come up from time to time. Here are Please note that the existence of some questions is due the application not being perfect in some parts. Many of those shortcomings are planned to be fixed in future release but simply could not be addressed yet due to time limits. -## CSRF Errors +## Why am I getting CSRF Errors? If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers. If you are using swag by linuxserver you might need `proxy_set_header X-Forwarded-Proto $scheme;` in your nginx config. @@ -10,15 +10,15 @@ If you are using a plain ngix you might need `proxy_set_header Host $http_host;` Further discussions can be found in this [Issue #518](https://github.com/vabene1111/recipes/issues/518) -## Images not loading -If images are not loading this might be related to the same issue as the CSRF Errors. -A discussion about that can be found [Issue #452](https://github.com/vabene1111/recipes/issues/452) +## Why are images not loading? +If images are not loading this might be related to the same issue as the CSRF errors (see above). +A discussion about that can be found at [Issue #452](https://github.com/vabene1111/recipes/issues/452) The other common issue is that the recommended nginx container is removed from the deployment stack. If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or `GUNICORN_MEDIA` needs to be enabled to allow media serving by the application container itself. -## User Creation +## How can I create users? To create a new user click on your name (top right corner) and select system. There click on invite links and create a new invite link. It is not possible to create users through the admin because users must be assigned a default group and space. @@ -28,7 +28,7 @@ To change a users space you need to go to the admin and select User Infos. If you use an external auth provider or proxy authentication make sure to specify a default group and space in the environment configuration. -## Spaces +## What are spaces? Spaces are a feature used to separate one installation of Tandoor into several parts. In technical terms it is a multi tenant system. @@ -39,11 +39,16 @@ If you want to host the collection of your friends family or your neighbor you c Sharing between spaces is currently not possible but is planned for future releases. -## Create Admin user / reset passwords -To create a superuser or reset a lost password if access to the container is lost you need to +## How can I reset passwords? +To reset a lost password if access to the container is lost you need to 1. execute into the container using `docker-compose exec web_recipes sh` 2. activate the virtual environment `source venv/bin/activate` -3. run `python manage.py createsuperuser` and follow the steps shown. +3. run `python manage.py changepassword ` and follow the steps shown. -To change a password enter `python manage.py changepassword ` in step 3. \ No newline at end of file +## How can I add an admin user? +To create a superuser you need to + +1. execute into the container using `docker-compose exec web_recipes sh` +2. activate the virtual environment `source venv/bin/activate` +3. run `python manage.py createsuperuser` and follow the steps shown. \ No newline at end of file From 99b3ed84643386e1f18c7ad46ecbd1d8745000fd Mon Sep 17 00:00:00 2001 From: tomtjes Date: Sat, 15 Jan 2022 13:58:40 -0500 Subject: [PATCH 031/150] add FAQ for PWA --- docs/faq.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index c9c55725..768f19fa 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -2,6 +2,22 @@ There are several questions and issues that come up from time to time. Here are Please note that the existence of some questions is due the application not being perfect in some parts. Many of those shortcomings are planned to be fixed in future release but simply could not be addressed yet due to time limits. +## Is there a Tandoor app? +Tandoor can be installed as a progressive web app (PWA) on mobile and desktop devices. The PWA stores recently accessed recipes locally for offline use. + +### Mobile browsers + +#### Safari (iPhone/iPad) +Open Tandoor, click Safari's share button, select `Add to Home Screen` + +### Desktop browsers + +#### Google Chrome +Open Tandoor, open the menu behind the three vertical dots at the top right, select `Install Tandoor Recipes...` + +#### Microsoft Edge +Open Tandoor, open the menu behind the three horizontal dots at the top right, select `Apps > Install Tandoor Recipes` + ## Why am I getting CSRF Errors? If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers. From 24e42496a79273886b6b0ad37b7d94d29c6cba5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Jan 2022 01:02:54 +0000 Subject: [PATCH 032/150] Bump follow-redirects from 1.14.6 to 1.14.7 in /vue Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.6 to 1.14.7. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.6...v1.14.7) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] --- vue/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vue/yarn.lock b/vue/yarn.lock index 05940e26..4523eb4f 100644 --- a/vue/yarn.lock +++ b/vue/yarn.lock @@ -5369,9 +5369,9 @@ flush-write-stream@^1.0.0: readable-stream "^2.3.6" follow-redirects@^1.0.0, follow-redirects@^1.14.4: - version "1.14.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" - integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== + version "1.14.7" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685" + integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ== for-in@^1.0.2: version "1.0.2" From f11e07d347ffe5c5546de0c874acf77cce578b79 Mon Sep 17 00:00:00 2001 From: Josselin du PLESSIS Date: Fri, 14 Jan 2022 23:23:05 +0000 Subject: [PATCH 033/150] Translated using Weblate (French) Currently translated at 100.0% (509 of 509 strings) Translation: Tandoor/Recipes Backend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/fr/ --- cookbook/locale/fr/LC_MESSAGES/django.po | 461 ++++++++++++++--------- 1 file changed, 285 insertions(+), 176 deletions(-) diff --git a/cookbook/locale/fr/LC_MESSAGES/django.po b/cookbook/locale/fr/LC_MESSAGES/django.po index 01c314c7..b182a589 100644 --- a/cookbook/locale/fr/LC_MESSAGES/django.po +++ b/cookbook/locale/fr/LC_MESSAGES/django.po @@ -14,10 +14,10 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-11-08 16:27+0100\n" -"PO-Revision-Date: 2021-11-04 09:06+0000\n" -"Last-Translator: FrenchAnon \n" -"Language-Team: French \n" +"PO-Revision-Date: 2022-01-16 07:06+0000\n" +"Last-Translator: Josselin du PLESSIS \n" +"Language-Team: French \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -33,66 +33,52 @@ msgid "Ingredients" msgstr "Ingrédients" #: .\cookbook\forms.py:54 -#, fuzzy -#| msgid "Default" msgid "Default unit" -msgstr "Par défaut" +msgstr "Unité par défaut" #: .\cookbook\forms.py:55 -#, fuzzy -#| msgid "System Information" msgid "Use fractions" -msgstr "Informations système" +msgstr "Utiliser les fractions" #: .\cookbook\forms.py:56 msgid "Use KJ" -msgstr "" +msgstr "Utiliser les kJ" #: .\cookbook\forms.py:57 msgid "Theme" -msgstr "" +msgstr "Thème" #: .\cookbook\forms.py:58 msgid "Navbar color" -msgstr "" +msgstr "Couleur de la barre de navigation" #: .\cookbook\forms.py:59 msgid "Sticky navbar" -msgstr "" +msgstr "Barre de navigation permanente" #: .\cookbook\forms.py:60 -#, fuzzy -#| msgid "Default" msgid "Default page" -msgstr "Par défaut" +msgstr "Page par défaut" #: .\cookbook\forms.py:61 -#, fuzzy -#| msgid "Shopping Recipes" msgid "Show recent recipes" -msgstr "Recettes dans le panier" +msgstr "Montrer les recettes récentes" #: .\cookbook\forms.py:62 -#, fuzzy -#| msgid "Search" msgid "Search style" msgstr "Rechercher" #: .\cookbook\forms.py:63 msgid "Plan sharing" -msgstr "" +msgstr "Partage du planificateur" #: .\cookbook\forms.py:64 -#, fuzzy -#| msgid "Ingredients" msgid "Ingredient decimal places" -msgstr "Ingrédients" +msgstr "Nombre de décimales pour les ingrédients" #: .\cookbook\forms.py:65 -#, fuzzy -#| msgid "Shopping list currently empty" msgid "Shopping list auto sync period" -msgstr "La liste de courses est actuellement vide" +msgstr "Période de synchro automatique de la liste de courses" #: .\cookbook\forms.py:66 .\cookbook\templates\recipe_view.html:21 #: .\cookbook\templates\space.html:62 .\cookbook\templates\stats.html:47 @@ -124,6 +110,8 @@ msgstr "" #: .\cookbook\forms.py:76 msgid "Display nutritional energy amounts in joules instead of calories" msgstr "" +"Afficher les quantités d'énergie nutritionnelle en joules plutôt qu'en " +"calories" #: .\cookbook\forms.py:78 msgid "" @@ -242,7 +230,7 @@ msgstr "Stockage" #: .\cookbook\forms.py:260 msgid "Active" -msgstr "" +msgstr "Actif" #: .\cookbook\forms.py:265 msgid "Search String" @@ -279,16 +267,12 @@ msgid "Email address already taken!" msgstr "Adresse mail déjà utilisée !" #: .\cookbook\forms.py:367 -#, fuzzy -#| msgid "" -#| "An email address is not required but if present the invite link will be " -#| "send to the user." msgid "" "An email address is not required but if present the invite link will be sent " "to the user." msgstr "" -"Une adresse mail n'est pas requise mais le lien d'invitation sera envoyé à " -"l'utilisateur si elle est présente." +"Une adresse mail n'est pas requise mais si elle présente, le lien " +"d'invitation sera envoyé à l'utilisateur." #: .\cookbook\forms.py:382 msgid "Name already taken." @@ -303,62 +287,81 @@ msgid "" "Determines how fuzzy a search is if it uses trigram similarity matching (e." "g. low values mean more typos are ignored)." msgstr "" +"Détermine le degré de flou d'une recherche si elle utilise la correspondance " +"par similarité de trigrammes (par exemple, des valeurs faibles signifient " +"que davantage de fautes de frappe sont ignorées)." #: .\cookbook\forms.py:435 msgid "" "Select type method of search. Click here for " "full desciption of choices." msgstr "" +"Sélectionner la méthode de recherche. Cliquer ici pour une description complète des choix." #: .\cookbook\forms.py:436 msgid "" "Use fuzzy matching on units, keywords and ingredients when editing and " "importing recipes." msgstr "" +"Utilisez la correspondance floue sur les unités, les mots-clés et les " +"ingrédients lors de l'édition et de l'importation de recettes." #: .\cookbook\forms.py:438 msgid "" "Fields to search ignoring accents. Selecting this option can improve or " "degrade search quality depending on language" msgstr "" +"Champs à rechercher en ignorant les accents. La sélection de cette option " +"peut améliorer ou dégrader la qualité de la recherche en fonction de la " +"langue." #: .\cookbook\forms.py:440 msgid "" "Fields to search for partial matches. (e.g. searching for 'Pie' will return " "'pie' and 'piece' and 'soapie')" msgstr "" +"Champs à rechercher pour les correspondances partielles. (par exemple, la " +"recherche de \"Tarte\" renverra \"tarte\", \"tartelette\" et \"tartes\")" #: .\cookbook\forms.py:442 msgid "" "Fields to search for beginning of word matches. (e.g. searching for 'sa' " "will return 'salad' and 'sandwich')" msgstr "" +"Champs permettant de rechercher les correspondances de début de mot (par " +"exemple, si vous recherchez \"sa\", vous obtiendrez \"salade\" et \"sandwich" +"\")." #: .\cookbook\forms.py:444 msgid "" "Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) " "Note: this option will conflict with 'web' and 'raw' methods of search." msgstr "" +"Champs pour la recherche \"floue\" (par exemple, si vous recherchez \"rectte" +"\", vous trouverez \"recette\".) Remarque : cette option est incompatible " +"avec les méthodes de recherche \"web\" et \"brute\"." #: .\cookbook\forms.py:446 msgid "" "Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods " "only function with fulltext fields." msgstr "" +"Champs de recherche en texte intégral. Remarque : les méthodes de recherche " +"\"web\", \"phrase\" et \"raw\" ne fonctionnent qu'avec des champs en texte " +"intégral." #: .\cookbook\forms.py:450 -#, fuzzy -#| msgid "Search" msgid "Search Method" -msgstr "Rechercher" +msgstr "Méthode de recherche" #: .\cookbook\forms.py:451 msgid "Fuzzy Lookups" -msgstr "" +msgstr "Recherches floues" #: .\cookbook\forms.py:452 msgid "Ignore Accent" -msgstr "" +msgstr "Ignorer les accents" #: .\cookbook\forms.py:453 msgid "Partial Match" @@ -369,16 +372,12 @@ msgid "Starts Wtih" msgstr "Commence par" #: .\cookbook\forms.py:455 -#, fuzzy -#| msgid "Search" msgid "Fuzzy Search" -msgstr "Rechercher" +msgstr "Recherche floue" #: .\cookbook\forms.py:456 -#, fuzzy -#| msgid "Text" msgid "Full Text" -msgstr "Texte" +msgstr "Plein texte" #: .\cookbook\helper\AllAuthCustomAdapter.py:36 msgid "" @@ -495,19 +494,21 @@ msgstr "Rubrique" #: .\cookbook\management\commands\rebuildindex.py:14 msgid "Rebuilds full text search index on Recipe" -msgstr "" +msgstr "Reconstruction de l'index de recherche plein texte de Tandoor" #: .\cookbook\management\commands\rebuildindex.py:18 msgid "Only Postgress databases use full text search, no index to rebuild" msgstr "" +"Seules les bases de données Postgres utilisent la recherche en texte " +"intégral, sans index à reconstruire" #: .\cookbook\management\commands\rebuildindex.py:29 msgid "Recipe index rebuild complete." -msgstr "" +msgstr "La reconstruction de l'index des recettes est terminée." #: .\cookbook\management\commands\rebuildindex.py:31 msgid "Recipe index rebuild failed." -msgstr "" +msgstr "La reconstruction de l'index des recettes a échoué." #: .\cookbook\migrations\0047_auto_20200602_1133.py:14 msgid "Breakfast" @@ -560,11 +561,11 @@ msgstr "Grand" #: .\cookbook\models.py:212 .\cookbook\templates\generic\new_template.html:6 #: .\cookbook\templates\generic\new_template.html:14 msgid "New" -msgstr "Nouveau/Nouvelle" +msgstr "Nouveau" #: .\cookbook\models.py:396 msgid " is part of a recipe step and cannot be deleted" -msgstr "" +msgstr " fait partie d'une étape de la recette et ne peut être supprimé" #: .\cookbook\models.py:441 .\cookbook\templates\url_import.html:42 msgid "Text" @@ -587,37 +588,31 @@ msgstr "Recette" #: .\cookbook\models.py:871 .\cookbook\templates\search_info.html:28 msgid "Simple" -msgstr "" +msgstr "Simple" #: .\cookbook\models.py:872 .\cookbook\templates\search_info.html:33 msgid "Phrase" -msgstr "" +msgstr "Phrase" #: .\cookbook\models.py:873 .\cookbook\templates\search_info.html:38 msgid "Web" -msgstr "" +msgstr "Internet" #: .\cookbook\models.py:874 .\cookbook\templates\search_info.html:47 msgid "Raw" -msgstr "" +msgstr "Brut" #: .\cookbook\models.py:912 -#, fuzzy -#| msgid "Food" msgid "Food Alias" -msgstr "Aliment" +msgstr "Ingrédient équivalent" #: .\cookbook\models.py:912 -#, fuzzy -#| msgid "Units" msgid "Unit Alias" -msgstr "Unités" +msgstr "Unité" #: .\cookbook\models.py:912 -#, fuzzy -#| msgid "Keywords" msgid "Keyword Alias" -msgstr "Mots-clés" +msgstr "Alias de mot-clé" #: .\cookbook\serializer.py:157 msgid "File uploads are not enabled for this Space." @@ -744,7 +739,8 @@ msgid "" "for user %(user_display)s\n" " ." msgstr "" -"Confirmez que est une adresse mail de " +"Confirmez SVP que\n" +" est une adresse mail de " "l'utilisateur %(user_display)s." #: .\cookbook\templates\account\email_confirm.html:22 @@ -759,8 +755,9 @@ msgid "" " issue a new e-mail confirmation " "request." msgstr "" -"Ce lien de confirmation par mail est expiré ou invalide. Veuillez demander une nouvelle vérification par mail." +"Ce lien de confirmation reçu par mail est expiré ou invalide. Veuillez\n" +" demander une nouvelle vérification " +"par mail." #: .\cookbook\templates\account\login.html:8 .\cookbook\templates\base.html:289 msgid "Login" @@ -856,10 +853,8 @@ msgstr "" "minutes à suivre." #: .\cookbook\templates\account\password_reset_from_key.html:13 -#, fuzzy -#| msgid "API Token" msgid "Bad Token" -msgstr "Jeton API" +msgstr "Mauvais jeton" #: .\cookbook\templates\account\password_reset_from_key.html:25 #, python-format @@ -869,17 +864,19 @@ msgid "" " Please request a new " "password reset." msgstr "" +"Le lien de changement du mot de passe est invalide, probablement parce qu'il " +"a déjà été utilisé.\n" +" Merci de demander un nouveau changement de mot de passe." #: .\cookbook\templates\account\password_reset_from_key.html:33 -#, fuzzy -#| msgid "Change Password" msgid "change password" -msgstr "Modifier le mot de passe" +msgstr "modifier le mot de passe" #: .\cookbook\templates\account\password_reset_from_key.html:36 #: .\cookbook\templates\account\password_reset_from_key_done.html:19 msgid "Your password is now changed." -msgstr "" +msgstr "Votre mot de passe a été changé." #: .\cookbook\templates\account\password_set.html:6 #: .\cookbook\templates\account\password_set.html:16 @@ -959,16 +956,12 @@ msgid "Supermarket" msgstr "Supermarché" #: .\cookbook\templates\base.html:163 -#, fuzzy -#| msgid "Supermarket" msgid "Supermarket Category" -msgstr "Supermarché" +msgstr "Catégorie Supermarché" #: .\cookbook\templates\base.html:175 .\cookbook\views\lists.py:195 -#, fuzzy -#| msgid "Information" msgid "Automations" -msgstr "Information" +msgstr "Automatisations" #: .\cookbook\templates\base.html:189 .\cookbook\views\lists.py:215 msgid "Files" @@ -1029,7 +1022,7 @@ msgstr "GitHub" #: .\cookbook\templates\base.html:277 msgid "Translate Tandoor" -msgstr "" +msgstr "Traduire Tandoor" #: .\cookbook\templates\base.html:281 msgid "API Browser" @@ -1084,26 +1077,20 @@ msgid "Save" msgstr "Sauvegarder" #: .\cookbook\templates\batch\monitor.html:21 -#, fuzzy -#| msgid "Manage Email Settings" msgid "Manage External Storage" -msgstr "Gérer les paramètres de mails" +msgstr "Gérer le stockage externe" #: .\cookbook\templates\batch\monitor.html:28 msgid "Sync Now!" msgstr "Lancer la synchro !" #: .\cookbook\templates\batch\monitor.html:29 -#, fuzzy -#| msgid "Shopping Recipes" msgid "Show Recipes" msgstr "Recettes dans le panier" #: .\cookbook\templates\batch\monitor.html:30 -#, fuzzy -#| msgid "Show Links" msgid "Show Log" -msgstr "Afficher les liens" +msgstr "Afficher le journal" #: .\cookbook\templates\batch\waiting.html:4 #: .\cookbook\templates\batch\waiting.html:10 @@ -1152,8 +1139,8 @@ msgstr "" "\n" " Le formulaire suivant est utile lorsqu'il y a des doublons dans les " "unités ou les ingrédients.\n" -"Il fusionne deux unités ou ingrédients et met à jour toutes les recettes les " -"utilisant.\n" +" Il fusionne deux unités ou ingrédients et met à jour toutes les " +"recettes les utilisant.\n" " " #: .\cookbook\templates\forms\ingredients.html:26 @@ -1176,15 +1163,15 @@ msgstr "Êtes-vous certain de vouloir supprimer %(title)s : %(object)s " #: .\cookbook\templates\generic\delete_template.html:26 msgid "Protected" -msgstr "" +msgstr "Protégé" #: .\cookbook\templates\generic\delete_template.html:41 msgid "Cascade" -msgstr "" +msgstr "Cascade" #: .\cookbook\templates\generic\delete_template.html:72 msgid "Cancel" -msgstr "" +msgstr "Annuler" #: .\cookbook\templates\generic\edit_template.html:32 msgid "View" @@ -1269,9 +1256,9 @@ msgstr "" "\n" " Les champs Mot de passe et Token sont stockés en texte " "brutdans la base de données.\n" -"C'est nécessaire car ils sont utilisés pour faire des requêtes API, mais " -"cela accroît le risque que quelqu'un les vole.
\n" -"Pour limiter les risques, des tokens ou comptes avec un accès limité " +" C'est nécessaire car ils sont utilisés pour faire des requêtes API, " +"mais cela accroît le risque que quelqu'un les vole.
\n" +" Pour limiter les risques, des tokens ou comptes avec un accès limité " "devraient être utilisés.\n" " " @@ -1327,13 +1314,14 @@ msgstr "" "\n" " Markdown est un langage de balisage léger utilisé pour formatter du " "texte facilement.\n" -"Ce site utilise la bibliothèque Python Markdown pour convertir votre texte en un " -"joli format HTML. Sa documentation complète est consultable ici.\n" -"Une documentation incomplète mais probablement suffisante se trouve plus " -"bas.\n" +" Ce site utilise la bibliothèque Python Markdown \n" +" pour convertir votre texte en un joli format HTML. Sa documentation " +"complète est consultable\n" +" ici.\n" +" Une documentation incomplète mais probablement suffisante se trouve " +"plus bas.\n" " " #: .\cookbook\templates\markdown_info.html:25 @@ -1621,10 +1609,8 @@ msgstr "Page d'accueil" #: .\cookbook\templates\search_info.html:5 #: .\cookbook\templates\search_info.html:9 #: .\cookbook\templates\settings.html:165 -#, fuzzy -#| msgid "Search String" msgid "Search Settings" -msgstr "Texte recherché" +msgstr "Paramètres de recherche" #: .\cookbook\templates\search_info.html:10 msgid "" @@ -1637,12 +1623,19 @@ msgid "" "only available if you are using Postgres for your database.\n" " " msgstr "" +"\n" +" La création d'une expérience de recherche optimale est complexe et " +"dépend fortement de votre configuration personnelle. \n" +" La modification de l'un des paramètres de recherche peut avoir un " +"impact significatif sur la vitesse et la qualité des résultats.\n" +" Les configurations Méthodes de recherche, Trigrammes et Recherche " +"texte intégral ne sont disponibles que si vous utilisez Postgres comme base " +"de données.\n" +" " #: .\cookbook\templates\search_info.html:19 -#, fuzzy -#| msgid "Search" msgid "Search Methods" -msgstr "Rechercher" +msgstr "Méthodes de recherche" #: .\cookbook\templates\search_info.html:23 msgid "" @@ -1658,6 +1651,18 @@ msgid "" "html#TEXTSEARCH-PARSING-QUERIES>Postgresql's website.\n" " " msgstr "" +" \n" +" Les recherches en texte intégral tentent de normaliser les mots " +"fournis pour qu'ils correspondent aux variantes courantes. Par exemple : " +"\"forked\", \"forking\", \"forks\" seront tous normalisés en \"fork\".\n" +" Il existe plusieurs méthodes, décrites ci-dessous, qui " +"permettent de contrôler la façon dont la recherche doit réagir lorsque " +"plusieurs mots sont recherchés.\n" +" Des détails techniques complets sur leur fonctionnement peuvent " +"être consultés sur le site Postgresql's website." +"\n" +" " #: .\cookbook\templates\search_info.html:29 msgid "" @@ -1669,6 +1674,14 @@ msgid "" "selected for a full text search.\n" " " msgstr "" +" \n" +" Les recherches simples ignorent la ponctuation et les mots " +"courants tels que \"le\", \"a\", \"et\", et traiteront les mots séparés " +"comme il se doit.\n" +" Si vous recherchez \"pomme ou farine\", vous obtiendrez toutes " +"les recettes qui contiennent à la fois \"pomme\" et \"farine\" dans les " +"champs sélectionnés pour la recherche en texte intégral.\n" +" " #: .\cookbook\templates\search_info.html:34 msgid "" @@ -1680,6 +1693,13 @@ msgid "" "been selected for a full text search.\n" " " msgstr "" +" \n" +" Les recherches de phrases ignorent la ponctuation, mais " +"recherchent tous les mots dans l'ordre exact indiqué.\n" +" La recherche de \"pomme ou farine\" ne donnera que les recettes " +"qui contiennent l'expression exacte \"pomme ou farine\" dans l'un des champs " +"sélectionnés pour la recherche en texte intégral.\n" +" " #: .\cookbook\templates\search_info.html:39 msgid "" @@ -1699,6 +1719,24 @@ msgid "" "recipe that has the word 'butter' in any field included.\n" " " msgstr "" +" \n" +" Les recherches sur le Web simulent la fonctionnalité que l'on " +"trouve sur de nombreux sites de recherche sur le Web qui prennent en charge " +"une syntaxe spéciale.\n" +" En plaçant des guillemets autour de plusieurs mots, ces derniers " +"seront convertis en une phrase.\n" +" Le terme \"ou\" signifie que l'on recherche le mot (ou " +"l'expression) qui précède immédiatement \"ou\" OU le mot (ou l'expression) " +"qui suit immédiatement.\n" +" Le signe \"-\" indique que la recherche porte sur des recettes " +"qui ne comprennent pas le mot (ou la phrase) qui suit immédiatement. \n" +" Par exemple, si vous recherchez \"tarte aux pommes\" ou cerise -" +"beurre, vous obtiendrez toutes les recettes contenant l'expression \"tarte " +"aux pommes\" ou le mot \"cerise\". \n" +" dans tous les champs inclus dans la recherche en texte intégral, " +"mais exclure toute recette comportant le mot \"beurre\" dans tous les champs " +"inclus.\n" +" " #: .\cookbook\templates\search_info.html:48 msgid "" @@ -1707,6 +1745,11 @@ msgid "" "operators such as '|', '&' and '()'\n" " " msgstr "" +" \n" +" La recherche brute est similaire à la recherche sur le Web, mais " +"elle prend en compte les opérateurs de ponctuation tels que \"|\", \"&\" et " +"\"()\".\n" +" " #: .\cookbook\templates\search_info.html:59 msgid "" @@ -1722,12 +1765,21 @@ msgid "" "methods.\n" " " msgstr "" +" \n" +" Une autre approche de la recherche qui nécessite également " +"Postgresql est la recherche floue ou la similarité des trigrammes. Un " +"trigramme est un groupe de trois caractères consécutifs.\n" +" Par exemple, la recherche de \"apple\" créera x trigrammes \"app" +"\", \"ppl\", \"ple\" et créera un score de la proximité des mots avec les " +"trigrammes générés.\n" +" L'un des avantages de la recherche par trigamme est qu'une " +"recherche sur \"sandwich\" permet de trouver des mots mal orthographiés tels " +"que \"sandwhich\", qui ne seraient pas détectés par d'autres méthodes.\n" +" " #: .\cookbook\templates\search_info.html:69 -#, fuzzy -#| msgid "Search Recipe" msgid "Search Fields" -msgstr "Rechercher une recette" +msgstr "Champs de recherche" #: .\cookbook\templates\search_info.html:73 msgid "" @@ -1763,12 +1815,46 @@ msgid "" "full text results, it does match the trigram results.\n" " " msgstr "" +" \n" +" Unaccent est un cas particulier car il permet de rechercher un " +"champ \"non accentué\" pour chaque style de recherche qui tente d'ignorer " +"les valeurs accentuées. \n" +" Par exemple, si vous activez l'option \"non accentué\" pour \"Nom" +"\", toute recherche (commence par, contient, trigramme) tentera d'ignorer " +"les caractères accentués.\n" +" \n" +" Pour les autres options, vous pouvez activer la recherche sur un " +"ou tous les champs et ils seront combinés ensemble avec un 'OR' présumé.\n" +" Par exemple, si vous activez l'option \"Nom\" pour l'option " +"\"Commence par\", \"Nom\" et \"Description\" pour l'option \"Correspondance " +"partielle\" et \"Ingrédients\" et \"Mots-clés\" pour l'option \"Recherche " +"complète\".\n" +" et que vous recherchez \"pomme\", vous obtiendrez les recettes " +"qui ont.. :\n" +" - un nom de recette qui commence par \"pomme\".\n" +" - OU un nom de recette qui contient 'pomme'.\n" +" - OU une description de recette qui contient 'pomme'.\n" +" - OU une recette qui aura une correspondance de recherche en " +"texte intégral ('pomme' ou 'pommes') dans les ingrédients\n" +" - OU une recette qui aura une correspondance de recherche en " +"texte intégral dans les mots-clés.\n" +"\n" +" La combinaison d'un trop grand nombre de champs dans un trop " +"grand nombre de types de recherche peut avoir un impact négatif sur les " +"performances, créer des résultats en double ou renvoyer des résultats " +"inattendus.\n" +" Par exemple, l'activation de la recherche floue ou des " +"correspondances partielles interfère avec les méthodes de recherche sur le " +"Web. \n" +" La recherche de \"apple -pie\" à l'aide d'une recherche floue et " +"d'une recherche en texte intégral donnera la recette de la tarte aux " +"pommes. Bien qu'elle ne soit pas incluse dans les résultats du texte " +"intégral, elle correspond aux résultats de la recherche par trigramme.\n" +" " #: .\cookbook\templates\search_info.html:95 -#, fuzzy -#| msgid "Search" msgid "Search Index" -msgstr "Rechercher" +msgstr "Index de recherche" #: .\cookbook\templates\search_info.html:99 msgid "" @@ -1782,6 +1868,17 @@ msgid "" "the management command 'python manage.py rebuildindex'\n" " " msgstr "" +" \n" +" La recherche par trigramme et la recherche en texte intégral " +"reposent toutes deux sur les index de la base de données pour fonctionner " +"efficacement. \n" +" Vous pouvez reconstruire les index de tous les champs dans la " +"page d'administration des recettes, en sélectionnant toutes les recettes et " +"en exécutant la commande \"rebuild index for selected recipes\".\n" +" Vous pouvez également reconstruire les index en ligne de " +"commande en exécutant la commande de gestion \"python manage.py " +"rebuildindex\".\n" +" " #: .\cookbook\templates\settings.html:28 msgid "Account" @@ -1796,10 +1893,8 @@ msgid "API-Settings" msgstr "Paramètres d'API" #: .\cookbook\templates\settings.html:49 -#, fuzzy -#| msgid "Search String" msgid "Search-Settings" -msgstr "Texte recherché" +msgstr "Paramètres de recherche" #: .\cookbook\templates\settings.html:58 msgid "Name Settings" @@ -1855,22 +1950,29 @@ msgid "" "There are many options to configure the search depending on your personal " "preferences." msgstr "" +"Il existe de nombreuses options pour configurer la recherche en fonction de " +"vos préférences personnelles." #: .\cookbook\templates\settings.html:167 msgid "" "Usually you do not need to configure any of them and can just stick " "with either the default or one of the following presets." msgstr "" +"En général, vous n'avez pas besoin de configurer l'un d'entre eux et " +"pouvez simplement vous en tenir à la valeur par défaut ou à l'un des " +"préréglages suivants." #: .\cookbook\templates\settings.html:168 msgid "" "If you do want to configure the search you can read about the different " "options here." msgstr "" +"Si vous souhaitez configurer la recherche, vous pouvez consulter les " +"différentes options ici." #: .\cookbook\templates\settings.html:173 msgid "Fuzzy" -msgstr "" +msgstr "Flou" #: .\cookbook\templates\settings.html:174 msgid "" @@ -1878,29 +1980,34 @@ msgid "" "return more results than needed to make sure you find what you are looking " "for." msgstr "" +"Trouvez ce dont vous avez besoin même si votre recherche ou la recette " +"contient des fautes de frappe. Il se peut que vous obteniez plus de " +"résultats que nécessaire pour être sûr de trouver ce que vous cherchez." #: .\cookbook\templates\settings.html:175 msgid "This is the default behavior" -msgstr "" +msgstr "C'est le comportement par défaut" #: .\cookbook\templates\settings.html:176 #: .\cookbook\templates\settings.html:184 msgid "Apply" -msgstr "" +msgstr "Appliquer" #: .\cookbook\templates\settings.html:181 msgid "Precise" -msgstr "" +msgstr "Préciser" #: .\cookbook\templates\settings.html:182 msgid "" "Allows fine control over search results but might not return results if too " "many spelling mistakes are made." msgstr "" +"Permet un contrôle fin des résultats de la recherche mais peut ne pas donner " +"de résultats si trop de fautes d'orthographe sont commises." #: .\cookbook\templates\settings.html:183 msgid "Perfect for large Databases" -msgstr "" +msgstr "Parfait pour les grandes bases de données" #: .\cookbook\templates\setup.html:6 .\cookbook\templates\system.html:5 msgid "Cookbook Setup" @@ -1928,10 +2035,8 @@ msgid "Shopping List" msgstr "Liste de courses" #: .\cookbook\templates\shopping_list.html:34 -#, fuzzy -#| msgid "Open Shopping List" msgid "Try the new shopping list" -msgstr "Ouvrir la liste de courses" +msgstr "Essayer la nouvelle liste de courses" #: .\cookbook\templates\shopping_list.html:63 msgid "Search Recipe" @@ -2008,8 +2113,8 @@ msgid "" "You can sign in to your account using any of the following third party\n" " accounts:" msgstr "" -"Vous pouvez vous connecter à votre compte en utilisant un des comptes tiers " -"suivants :" +"Vous pouvez vous connecter à votre compte en utilisant un des \n" +" comptes tiers suivants :" #: .\cookbook\templates\socialaccount\connections.html:52 msgid "" @@ -2033,8 +2138,9 @@ msgid "" " %(provider_name)s account to login to\n" " %(site_name)s. As a final step, please complete the following form:" msgstr "" -"Vous êtes sur le point d'utiliser votre compte %(provider_name)s pour vous " -"connecter à %(site_name)s. Pour finaliser la requête, veuillez compléter le " +"Vous êtes sur le point d'utiliser\n" +" votre compte %(provider_name)s pour vous connecter à\n" +" %(site_name)s. Pour finaliser la requête, veuillez compléter le " "formulaire suivant :" #: .\cookbook\templates\socialaccount\snippets\provider_list.html:23 @@ -2190,8 +2296,9 @@ msgid "" msgstr "" "Publier les médias directement avec gunicorn/python n'est pas recommandé !\n" -"Veuillez suivre les étapes décrites ici pour mettre à jour votre installation.\n" +" Veuillez suivre les étapes décrites ici \n" +" pour mettre à jour votre installation.\n" " " #: .\cookbook\templates\system.html:57 .\cookbook\templates\system.html:73 @@ -2216,10 +2323,12 @@ msgid "" " " msgstr "" "\n" -" Vous n'avez pas de SECRET_KEY configurée dans votre " -"fichier.env. Django utilise par défaut la clé standard fournie " -"avec l'application qui est connue publiquement et non sécurisée ! Veuillez " -"définir SECRET_KEY dans le fichier.env\n" +" Vous n'avez pas de SECRET_KEY configuré dans votre " +"fichier.env. Django utilise par défaut\n" +" la clé standard fournie avec l'application qui est connue " +"publiquement et non sécurisée ! \n" +" Veuillez définir SECRET_KEY dans le fichier." +"env\n" " " #: .\cookbook\templates\system.html:78 @@ -2238,8 +2347,9 @@ msgid "" msgstr "" "\n" " Cette application est toujours en mode debug. Ce n'est sûrement " -"pas nécessaire. Désactivez le mode debug en définissant DEBUG=0 " -"dans le fichier .env.\n" +"pas nécessaire. Désactivez le mode debug\n" +" en définissant DEBUG=0 dans le fichier ." +"env.\n" " " #: .\cookbook\templates\system.html:93 @@ -2260,8 +2370,9 @@ msgid "" msgstr "" "\n" " Cette application ne tourne pas sur une base de données " -"Postgres. Ce n'est pas grave mais déconseillé car certaines fonctionnalités " -"ne fonctionnent qu'avec une base de données Postgres.\n" +"Postgres. Ce n'est pas grave mais déconseillé\n" +" car certaines fonctionnalités ne fonctionnent qu'avec une base " +"de données Postgres.\n" " " #: .\cookbook\templates\url_import.html:6 @@ -2279,11 +2390,11 @@ msgstr "Mettez-moi en favori !" #: .\cookbook\templates\url_import.html:36 msgid "URL" -msgstr "" +msgstr "URL" #: .\cookbook\templates\url_import.html:38 msgid "App" -msgstr "" +msgstr "App" #: .\cookbook\templates\url_import.html:62 msgid "Enter website URL" @@ -2440,12 +2551,14 @@ msgid "" "data feel free to post an example in the\n" " github issues." msgstr "" -" Seuls les sites webs contenant des données ld+json ou microdatas peuvent " -"actuellement être importés.\n" -"C'est le cas de la plupart des grands sites web. Si votre site ne peut pas " -"être importé alors qu'il est censé disposer\n" +" Seuls les sites webs contenant des données ld+json ou microdatas peuvent\n" +" actuellement être importés.\n" +" C'est le cas de la plupart des " +"grands sites web. Si votre site ne peut pas être importé\n" +" alors qu'il est censé disposer " "de données correctement structurées,\n" -"n'hésitez pas à publier un exemple dans un ticket sur GitHub." +" n'hésitez pas à publier un " +"exemple dans un ticket sur GitHub." #: .\cookbook\templates\url_import.html:641 msgid "Google ld+json Info" @@ -2466,7 +2579,7 @@ msgstr "Le paramètre « update_at » n'est pas correctement formatté" #: .\cookbook\views\api.py:152 #, python-brace-format msgid "No {self.basename} with id {pk} exists" -msgstr "" +msgstr "Il n'existe pas de {self.basename} avec l'identifiant {pk}" #: .\cookbook\views\api.py:156 msgid "Cannot merge with the same object!" @@ -2475,58 +2588,58 @@ msgstr "Un objet ne peut être fusionné avec lui-même !" #: .\cookbook\views\api.py:163 #, python-brace-format msgid "No {self.basename} with id {target} exists" -msgstr "" +msgstr "Il n'existe pas de {self.basename} avec l'id {target}" #: .\cookbook\views\api.py:168 -#, fuzzy -#| msgid "Cannot merge with the same object!" msgid "Cannot merge with child object!" -msgstr "Un objet ne peut être fusionné avec lui-même !" +msgstr "Impossible de fusionner avec l'objet enfant !" #: .\cookbook\views\api.py:201 #, python-brace-format msgid "{source.name} was merged successfully with {target.name}" -msgstr "" +msgstr "{source.name} a été fusionné avec succès avec {target.name}" #: .\cookbook\views\api.py:205 #, python-brace-format msgid "An error occurred attempting to merge {source.name} with {target.name}" msgstr "" +"Une erreur s'est produite lors de la tentative de fusion de {source.name} " +"avec {target.name}" #: .\cookbook\views\api.py:249 #, python-brace-format msgid "No {self.basename} with id {child} exists" -msgstr "" +msgstr "Il n'existe pas de {self.basename} avec l'id {child}" #: .\cookbook\views\api.py:258 #, python-brace-format msgid "{child.name} was moved successfully to the root." -msgstr "" +msgstr "{child.name} a été déplacé avec succès vers la racine." #: .\cookbook\views\api.py:261 .\cookbook\views\api.py:279 msgid "An error occurred attempting to move " -msgstr "" +msgstr "Une erreur s'est produite en essayant de déplacer " #: .\cookbook\views\api.py:264 msgid "Cannot move an object to itself!" -msgstr "" +msgstr "Impossible de déplacer un objet vers lui-même !" #: .\cookbook\views\api.py:270 #, python-brace-format msgid "No {self.basename} with id {parent} exists" -msgstr "" +msgstr "Il n'existe pas de {self.basename} avec l'id {parent}" #: .\cookbook\views\api.py:276 #, python-brace-format msgid "{child.name} was moved successfully to parent {parent.name}" -msgstr "" +msgstr "{child.name} a été déplacé avec succès vers le parent {parent.name}" #: .\cookbook\views\api.py:723 .\cookbook\views\data.py:42 #: .\cookbook\views\edit.py:129 .\cookbook\views\new.py:95 -#, fuzzy -#| msgid "This feature is not available in the demo version!" msgid "This feature is not yet available in the hosted version of tandoor!" -msgstr "Cette fonctionnalité n'est pas disponible dans la version d'essai !" +msgstr "" +"Cette fonctionnalité n'est pas encore disponible dans la version hébergée de " +"Tandoor !" #: .\cookbook\views\api.py:745 msgid "Sync successful!" @@ -2655,28 +2768,20 @@ msgid "Shopping Lists" msgstr "Listes de course" #: .\cookbook\views\lists.py:129 -#, fuzzy -#| msgid "Food" msgid "Foods" -msgstr "Aliment" +msgstr "Aliments" #: .\cookbook\views\lists.py:163 -#, fuzzy -#| msgid "Supermarket" msgid "Supermarkets" -msgstr "Supermarché" +msgstr "Supermarchés" #: .\cookbook\views\lists.py:179 -#, fuzzy -#| msgid "Shopping Recipes" msgid "Shopping Categories" -msgstr "Recettes dans le panier" +msgstr "Catégories de courses" #: .\cookbook\views\lists.py:232 -#, fuzzy -#| msgid "Shopping List" msgid "New Shopping List" -msgstr "Liste de courses" +msgstr "Nouvelle liste de courses" #: .\cookbook\views\new.py:126 msgid "Imported new recipe!" @@ -2766,16 +2871,20 @@ msgstr "Cette fonctionnalité n'est pas disponible dans la version d'essai !" #: .\cookbook\views\views.py:340 msgid "You must select at least one field to search!" msgstr "" +"Vous devez sélectionner au moins un champ pour effectuer une recherche !" #: .\cookbook\views\views.py:345 msgid "" "To use this search method you must select at least one full text search " "field!" msgstr "" +"Pour utiliser cette méthode de recherche, vous devez sélectionner au moins " +"un champ de recherche en texte intégral !" #: .\cookbook\views\views.py:349 msgid "Fuzzy search is not compatible with this search method!" msgstr "" +"La recherche floue n'est pas compatible avec cette méthode de recherche !" #: .\cookbook\views\views.py:452 msgid "" From 968b710b4971659cbfc1f25930fb0b523a4333e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B3=96=E5=A4=9A?= <1365143958@qq.com> Date: Sat, 15 Jan 2022 06:19:48 +0000 Subject: [PATCH 034/150] Translated using Weblate (Chinese (Simplified)) Currently translated at 28.6% (146 of 509 strings) Translation: Tandoor/Recipes Backend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/zh_Hans/ --- cookbook/locale/zh_CN/LC_MESSAGES/django.po | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cookbook/locale/zh_CN/LC_MESSAGES/django.po b/cookbook/locale/zh_CN/LC_MESSAGES/django.po index 215748f5..a1f38d6a 100644 --- a/cookbook/locale/zh_CN/LC_MESSAGES/django.po +++ b/cookbook/locale/zh_CN/LC_MESSAGES/django.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-11-08 16:27+0100\n" -"PO-Revision-Date: 2021-08-20 19:28+0000\n" -"Last-Translator: Danny Tsui \n" +"PO-Revision-Date: 2022-01-16 07:06+0000\n" +"Last-Translator: 糖多 <1365143958@qq.com>\n" "Language-Team: Chinese (Simplified) \n" "Language: zh_CN\n" @@ -17,7 +17,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" -"X-Generator: Weblate 4.7.2\n" +"X-Generator: Weblate 4.8\n" #: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125 #: .\cookbook\templates\forms\ingredients.html:34 @@ -1173,12 +1173,12 @@ msgstr "" #: .\cookbook\templates\include\log_cooking.html:19 msgid "Rating" -msgstr "" +msgstr "评分" #: .\cookbook\templates\include\log_cooking.html:27 #: .\cookbook\templates\include\recipe_open_modal.html:18 msgid "Close" -msgstr "" +msgstr "关闭" #: .\cookbook\templates\include\recipe_open_modal.html:32 msgid "Open Recipe" From 9b182f607627751a950304bd528424228858cef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B3=96=E5=A4=9A?= <1365143958@qq.com> Date: Sat, 15 Jan 2022 06:09:40 +0000 Subject: [PATCH 035/150] Translated using Weblate (Chinese (Simplified)) Currently translated at 32.0% (91 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/zh_Hans/ --- vue/src/locales/zh_Hans.json | 56 ++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/vue/src/locales/zh_Hans.json b/vue/src/locales/zh_Hans.json index 46f921a1..3ae2529e 100644 --- a/vue/src/locales/zh_Hans.json +++ b/vue/src/locales/zh_Hans.json @@ -7,41 +7,41 @@ "success_creating_resource": "", "success_updating_resource": "", "success_deleting_resource": "", - "import_running": "", - "all_fields_optional": "", - "convert_internal": "", - "show_only_internal": "", + "import_running": "正在导入,请稍候!", + "all_fields_optional": "所有字段都是可选的,可以留空。", + "convert_internal": "转换为内部菜谱", + "show_only_internal": "仅显示内部菜谱", "Log_Recipe_Cooking": "", "External_Recipe_Image": "外部菜谱图像", "Add_to_Shopping": "添加到购物", "Add_to_Plan": "添加到计划", "Step_start_time": "", - "Sort_by_new": "", + "Sort_by_new": "按新旧排序", "Recipes_per_page": "", "Manage_Books": "管理书籍", - "Meal_Plan": "", - "Select_Book": "", + "Meal_Plan": "用餐计划", + "Select_Book": "选择书籍", "Recipe_Image": "菜谱图像", "Import_finished": "导入完成", - "View_Recipes": "", + "View_Recipes": "查看菜谱", "Log_Cooking": "", "New_Recipe": "新菜谱", "Url_Import": "导入网址", "Reset_Search": "重置搜索", "Recently_Viewed": "最近浏览", "Load_More": "加载更多", - "Keywords": "关键字", + "Keywords": "关键词", "Books": "书籍", "Proteins": "蛋白质", "Fats": "脂肪", "Carbohydrates": "碳水化合物", "Calories": "卡路里", - "Energy": "", + "Energy": "能量", "Nutrition": "营养", "Date": "日期", "Share": "分享", "Export": "导出", - "Copy": "拷贝", + "Copy": "复制", "Rating": "评分", "Close": "关闭", "Link": "链接", @@ -66,8 +66,8 @@ "Cancel": "取消", "Delete": "删除", "Open": "打开", - "Ok": "打开", - "Save": "储存", + "Ok": "", + "Save": "保存", "Step": "步骤", "Search": "搜索", "Import": "导入", @@ -75,7 +75,33 @@ "Settings": "设置", "or": "或", "and": "与", - "Information": "更多资讯", + "Information": "更多信息", "Download": "下载", - "Create": "创立" + "Create": "创建", + "Table_of_Contents": "目录", + "Delete_Keyword": "删除关键词", + "Edit_Keyword": "编辑关键词", + "New_Keyword": "新关键词", + "Select_File": "选择文件", + "Merge_Keyword": "合并关键词", + "Hide_Keywords": "隐藏关键词", + "Image": "图片", + "Recipes": "菜谱", + "Move": "移动", + "Merge": "合并", + "confirm_delete": "您确定要删除 {object} 吗?", + "Save_and_View": "保存并查看", + "Edit_Recipe": "编辑菜谱", + "Move_Up": "上移", + "show_split_screen": "拆分视图", + "Move_Keyword": "移动关键词", + "Hide_Recipes": "隐藏菜谱", + "Move_Down": "下移", + "Step_Name": "步骤名称", + "Step_Type": "步骤类型", + "Enable_Amount": "启用金额", + "Disable_Amount": "禁用金额", + "Add_Step": "添加步骤", + "delete_confirmation": "你确定要删除 {source} 吗?", + "Search Settings": "搜索设置" } From 528767a8351ce25fe56331cb17c3cc5b388e5709 Mon Sep 17 00:00:00 2001 From: Josselin du PLESSIS Date: Fri, 14 Jan 2022 23:20:10 +0000 Subject: [PATCH 036/150] Translated using Weblate (French) Currently translated at 100.0% (284 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/ --- vue/src/locales/fr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vue/src/locales/fr.json b/vue/src/locales/fr.json index 79a44627..58f2693a 100644 --- a/vue/src/locales/fr.json +++ b/vue/src/locales/fr.json @@ -121,7 +121,7 @@ "del_confirmation_tree": "Êtes-vous sûr de vouloir supprimer {source} et tous ses enfants ?", "warning_feature_beta": "Cette fonctionnalité est actuellement en phase BETA (test). Veuillez vous attendre à des bugs et éventuellement à des changements avenir (éventuellement la perte de données liées aux fonctionnalités) lorsque vous utilisez cette fonctionnalité.", "confirm_delete": "Voulez-vous vraiment supprimer {objet} ?", - "Note": "Noter", + "Note": "Notes", "Add_Step": "Ajouter une étape", "Step_Name": "Nom de l'étape", "Parameter": "Paramètre", @@ -220,7 +220,7 @@ "Period": "Période", "Plan_Period_To_Show": "Montrer les semaines, mois ou années", "Auto_Planner": "Planning Auto", - "New_Cookbook": "Nouveau livres de recettes", + "New_Cookbook": "Nouveau livre de recettes", "Hide_Keyword": "Cacher les mots clés", "Clear": "Supprimer", "AddToShopping": "Ajouter à la liste de courses", From c2a763fa4cc09edf46048cd21f736261c20748e6 Mon Sep 17 00:00:00 2001 From: SMunos Date: Fri, 14 Jan 2022 23:19:21 +0000 Subject: [PATCH 037/150] Translated using Weblate (French) Currently translated at 100.0% (284 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/ --- vue/src/locales/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/locales/fr.json b/vue/src/locales/fr.json index 58f2693a..24f4fb2d 100644 --- a/vue/src/locales/fr.json +++ b/vue/src/locales/fr.json @@ -221,7 +221,7 @@ "Plan_Period_To_Show": "Montrer les semaines, mois ou années", "Auto_Planner": "Planning Auto", "New_Cookbook": "Nouveau livre de recettes", - "Hide_Keyword": "Cacher les mots clés", + "Hide_Keyword": "masquer les mots clefs", "Clear": "Supprimer", "AddToShopping": "Ajouter à la liste de courses", "IngredientInShopping": "Cet ingrédient est dans votre liste de courses.", From fed9cfeeb74fb630cd2bc14785042213e562e325 Mon Sep 17 00:00:00 2001 From: Oliver Cervera Date: Sun, 16 Jan 2022 15:44:28 +0000 Subject: [PATCH 038/150] Translated using Weblate (Italian) Currently translated at 96.6% (492 of 509 strings) Translation: Tandoor/Recipes Backend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/it/ --- cookbook/locale/it/LC_MESSAGES/django.po | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cookbook/locale/it/LC_MESSAGES/django.po b/cookbook/locale/it/LC_MESSAGES/django.po index 761e133a..1abfc620 100644 --- a/cookbook/locale/it/LC_MESSAGES/django.po +++ b/cookbook/locale/it/LC_MESSAGES/django.po @@ -12,7 +12,7 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-11-07 17:31+0100\n" -"PO-Revision-Date: 2021-11-12 20:06+0000\n" +"PO-Revision-Date: 2022-01-17 07:56+0000\n" "Last-Translator: Oliver Cervera \n" "Language-Team: Italian \n" @@ -260,10 +260,6 @@ msgid "Email address already taken!" msgstr "Questo indirizzo email è già in uso!" #: .\cookbook\forms.py:367 -#, fuzzy -#| msgid "" -#| "An email address is not required but if present the invite link will be " -#| "send to the user." msgid "" "An email address is not required but if present the invite link will be sent " "to the user." @@ -840,6 +836,10 @@ msgid "" " Please request a new " "password reset." msgstr "" +"Il link per il reset della password non è corretto, probabilmente perché è " +"stato già utilizzato.\n" +" Puoi richiedere un nuovo reset della password." #: .\cookbook\templates\account\password_reset_from_key.html:33 msgid "change password" @@ -932,10 +932,8 @@ msgid "Supermarket Category" msgstr "Categoria Supermercato" #: .\cookbook\templates\base.html:175 .\cookbook\views\lists.py:195 -#, fuzzy -#| msgid "Information" msgid "Automations" -msgstr "Informazioni" +msgstr "Automazioni" #: .\cookbook\templates\base.html:189 .\cookbook\views\lists.py:215 msgid "Files" @@ -1842,6 +1840,9 @@ msgid "" "return more results than needed to make sure you find what you are looking " "for." msgstr "" +"Cerca quello che ti serve anche se la ricerca o la ricetta contengono " +"errori. Potrebbe mostrare più risultati di quelli necessari per mostrarti " +"quello che stai cercando." #: .\cookbook\templates\settings.html:175 msgid "This is the default behavior" @@ -1864,7 +1865,7 @@ msgstr "" #: .\cookbook\templates\settings.html:183 msgid "Perfect for large Databases" -msgstr "" +msgstr "Perfetto per database grandi" #: .\cookbook\templates\setup.html:6 .\cookbook\templates\system.html:5 msgid "Cookbook Setup" From 0c603e36658eb1f96ec0460e3a29a6767db5334e Mon Sep 17 00:00:00 2001 From: Oliver Cervera Date: Sun, 16 Jan 2022 15:21:49 +0000 Subject: [PATCH 039/150] Translated using Weblate (Italian) Currently translated at 84.1% (239 of 284 strings) Translation: Tandoor/Recipes Frontend Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/ --- vue/src/locales/it.json | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/vue/src/locales/it.json b/vue/src/locales/it.json index bbd90a76..b9f365ac 100644 --- a/vue/src/locales/it.json +++ b/vue/src/locales/it.json @@ -1,8 +1,8 @@ { - "err_fetching_resource": "Si è verificato un errore nel recupero della risorsa!", + "err_fetching_resource": "Si è verificato un errore durante il recupero di una risorsa!", "err_creating_resource": "Si è verificato un errore durante la creazione di una risorsa!", - "err_updating_resource": "Si è verificato un errore durante l'aggiornamento della risorsa!", - "err_deleting_resource": "Si è verificato un errore durante la cancellazione della risorsa!", + "err_updating_resource": "Si è verificato un errore durante l'aggiornamento di una risorsa!", + "err_deleting_resource": "Si è verificato un errore durante la cancellazione di una risorsa!", "success_fetching_resource": "Risorsa recuperata con successo!", "success_creating_resource": "Risorsa creata con successo!", "success_updating_resource": "Risorsa aggiornata con successo!", @@ -208,5 +208,37 @@ "New_Cookbook": "Nuovo libro di ricette", "Hide_Keyword": "Nascondi parole chiave", "Clear": "Pulisci", - "Shopping_List_Empty": "La tua lista della spesa è vuota, puoi aggiungere elementi dal menù contestuale di una voce nel piano alimentare (clicca con il tasto destro sulla scheda o clicca con il tasto sinistro sull'icona del menù)" + "Shopping_List_Empty": "La tua lista della spesa è vuota, puoi aggiungere elementi dal menù contestuale di una voce nel piano alimentare (clicca con il tasto destro sulla scheda o clicca con il tasto sinistro sull'icona del menù)", + "success_moving_resource": "Risorsa spostata con successo!", + "Shopping_Categories": "Categorie di spesa", + "IngredientInShopping": "Questo ingrediente è nella tua lista della spesa.", + "RemoveFoodFromShopping": "Rimuovi {food} dalla tua lista della spesa", + "DelayFor": "Ritarda per {hours} ore", + "OfflineAlert": "Sei offline, le liste della spesa potrebbero non sincronizzarsi.", + "err_moving_resource": "Si è verificato un errore durante lo spostamento di una risorsa!", + "err_merging_resource": "Si è verificato un errore durante l'unione di una risorsa!", + "success_merging_resource": "Risorsa unita con successo!", + "Added_by": "Aggiunto da", + "Added_on": "Aggiunto il", + "AddToShopping": "Aggiungi a lista della spesa", + "NotInShopping": "{food} non è nella tua lista della spesa.", + "Undefined": "Non definito", + "AddFoodToShopping": "Aggiungi {food} alla tua lista della spesa", + "DeleteShoppingConfirm": "Sei sicuro di voler rimuovere tutto {food} dalla lista della spesa?", + "Add_Servings_to_Shopping": "Aggiungi {servings} porzioni alla spesa", + "Inherit": "Eredita", + "InheritFields": "Eredita i valori dei campi", + "ShowUncategorizedFood": "Mostra non definiti", + "GroupBy": "Raggruppa per", + "MoveCategory": "Sposta in: ", + "Warning": "Attenzione", + "NoCategory": "Nessuna categoria selezionata.", + "ShowDelayed": "Mostra elementi ritardati", + "Completed": "Completato", + "shopping_share": "Condividi lista della spesa", + "shopping_auto_sync": "Sincronizzazione automatica", + "err_move_self": "Non è possibile muovere un elemento in sé stesso", + "nothing": "Nulla da fare", + "show_sql": "Mostra SQL", + "Search Settings": "Impostazioni di ricerca" } From b3e971fe09b96a4b877501611ec424157a4f579f Mon Sep 17 00:00:00 2001 From: Matthias Lohr Date: Mon, 17 Jan 2022 11:21:36 +0100 Subject: [PATCH 040/150] allow to specify an actual path using DATABASE_URL --- recipes/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/settings.py b/recipes/settings.py index dcbbfc07..e5283271 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -258,7 +258,7 @@ WSGI_APPLICATION = 'recipes.wsgi.application' # Load settings from env files if os.getenv('DATABASE_URL'): match = re.match( - r'(?P\w+):\/\/(?P[\w\d_-]+)(:(?P[^@]+))?@(?P[^:/]+)(:(?P\d+))?(\/(?P[\w\d_-]+))?', + r'(?P\w+):\/\/(?P[\w\d_-]+)(:(?P[^@]+))?@(?P[^:/]+)(:(?P\d+))?(\/(?P[\w\d\/\._-]+))?', os.getenv('DATABASE_URL') ) settings = match.groupdict() From 745bb58c7ec8e4026a48fceca0baf20af9cbdd50 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Sun, 9 Jan 2022 18:25:38 +0100 Subject: [PATCH 041/150] fixed valid filter on invite link counter --- cookbook/forms.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cookbook/forms.py b/cookbook/forms.py index 37a32638..e47acac0 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django import forms from django.conf import settings from django.core.exceptions import ValidationError @@ -350,7 +352,7 @@ class InviteLinkForm(forms.ModelForm): def clean(self): space = self.cleaned_data['space'] if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter( - space=space).count()) >= space.max_users: + space=space).filter(valid_until__gte=datetime.today()).count()) >= space.max_users: raise ValidationError(_('Maximum number of users for this space reached.')) def clean_email(self): From 8a7c4e11c924acaed05bc8935eb0ed8cd2de720a Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 15:16:13 +0100 Subject: [PATCH 042/150] fixed invite link counting --- cookbook/forms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cookbook/forms.py b/cookbook/forms.py index e47acac0..26e51885 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -351,8 +351,8 @@ class InviteLinkForm(forms.ModelForm): def clean(self): space = self.cleaned_data['space'] - if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter( - space=space).filter(valid_until__gte=datetime.today()).count()) >= space.max_users: + if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + + InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users: raise ValidationError(_('Maximum number of users for this space reached.')) def clean_email(self): From 37971acb48735a528cb696accc361c511821c2b8 Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 17 Jan 2022 08:26:34 -0600 Subject: [PATCH 043/150] refactor recipe search --- cookbook/helper/recipe_search.py | 561 ++++-- cookbook/models.py | 150 +- cookbook/serializer.py | 5 +- cookbook/templates/url_import.html | 126 +- cookbook/views/api.py | 26 +- .../apps/RecipeEditView/RecipeEditView.vue | 1596 ++++++++--------- .../RecipeSearchView/RecipeSearchView.vue | 4 +- vue/src/components/AddRecipeToBook.vue | 198 +- vue/src/components/GenericMultiselect.vue | 4 + 9 files changed, 1474 insertions(+), 1196 deletions(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index cd6f79dd..673b7d0d 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -3,7 +3,7 @@ from datetime import timedelta from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity from django.core.cache import caches -from django.db.models import Avg, Case, Count, Func, Max, OuterRef, Q, Subquery, Value, When +from django.db.models import Avg, Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When from django.db.models.functions import Coalesce, Substr from django.utils import timezone, translation from django.utils.translation import gettext as _ @@ -12,194 +12,446 @@ from cookbook.filters import RecipeFilter from cookbook.helper.HelperFunctions import Round, str2bool from cookbook.helper.permission_helper import has_group_permission from cookbook.managers import DICTIONARY -from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog +from cookbook.models import CookLog, Food, Keyword, Recipe, SearchPreference, ViewLog from recipes import settings # TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected # TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering -def search_recipes(request, queryset, params): - if request.user.is_authenticated: - search_prefs = request.user.searchpreference - else: - search_prefs = SearchPreference() - search_string = params.get('query', '').strip() - search_rating = int(params.get('rating', 0)) - search_keywords = params.getlist('keywords', []) - search_foods = params.getlist('foods', []) - search_books = params.getlist('books', []) - search_steps = params.getlist('steps', []) - search_units = params.get('units', None) +class RecipeSearch(): + _postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql'] - search_keywords_or = str2bool(params.get('keywords_or', True)) - search_foods_or = str2bool(params.get('foods_or', True)) - search_books_or = str2bool(params.get('books_or', True)) - - search_internal = str2bool(params.get('internal', False)) - search_random = str2bool(params.get('random', False)) - search_new = str2bool(params.get('new', False)) - search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently? - orderby = [] - - # only sort by recent not otherwise filtering/sorting - if search_last_viewed > 0: - last_viewed_recipes = ViewLog.objects.filter( - created_by=request.user, space=request.space, - created_at__gte=timezone.now() - timedelta(days=14) # TODO make recent days a setting - ).order_by('-pk').values_list('recipe__pk', flat=True) - last_viewed_recipes = list(dict.fromkeys(last_viewed_recipes))[:search_last_viewed] # removes duplicates from list prior to slicing - - # return queryset.annotate(last_view=Max('viewlog__pk')).annotate(new=Case(When(pk__in=last_viewed_recipes, then=('last_view')), default=Value(0))).filter(new__gt=0).order_by('-new') - # queryset that only annotates most recent view (higher pk = lastest view) - queryset = queryset.annotate(recent=Coalesce(Max(Case(When(viewlog__created_by=request.user, then='viewlog__pk'))), Value(0))) - orderby += ['-recent'] - - # TODO create setting for default ordering - most cooked, rating, - # TODO create options for live sorting - # TODO make days of new recipe a setting - if search_new: - queryset = ( - queryset.annotate(new_recipe=Case( - When(created_at__gte=(timezone.now() - timedelta(days=7)), then=('pk')), default=Value(0), )) - ) - # only sort by new recipes if not otherwise filtering/sorting - orderby += ['-new_recipe'] - - search_type = search_prefs.search or 'plain' - if len(search_string) > 0: - unaccent_include = search_prefs.unaccent.values_list('field', flat=True) - - icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.icontains.values_list('field', flat=True)] - istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.istartswith.values_list('field', flat=True)] - trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.trigram.values_list('field', flat=True)] - fulltext_include = search_prefs.fulltext.values_list('field', flat=True) # fulltext doesn't use field name directly - - # if no filters are configured use name__icontains as default - if len(icontains_include) + len(istartswith_include) + len(trigram_include) + len(fulltext_include) == 0: - filters = [Q(**{"name__icontains": search_string})] + def __init__(self, request, **params): + self._request = request + self._queryset = None + self._params = {**params} + if self._request.user.is_authenticated: + self._search_prefs = request.user.searchpreference else: - filters = [] + self._search_prefs = SearchPreference() + self._string = params.get('query').strip() if params.get('query', None) else None + self._rating = self._params.get('rating', None) + self._keywords = self._params.get('keywords', None) + self._foods = self._params.get('foods', None) + self._books = self._params.get('books', None) + self._steps = self._params.get('steps', None) + self._units = self._params.get('units', None) + # TODO add created by + # TODO add created before/after + # TODO image exists + self._sort_order = self._params.get('sort_order', None) + # TODO add save - # dynamically build array of filters that will be applied - for f in icontains_include: - filters += [Q(**{"%s__icontains" % f: search_string})] + self._keywords_or = str2bool(self._params.get('keywords_or', True)) + self._foods_or = str2bool(self._params.get('foods_or', True)) + self._books_or = str2bool(self._params.get('books_or', True)) - for f in istartswith_include: - filters += [Q(**{"%s__istartswith" % f: search_string})] + self._internal = str2bool(self._params.get('internal', False)) + self._random = str2bool(self._params.get('random', False)) + self._new = str2bool(self._params.get('new', False)) + self._last_viewed = int(self._params.get('last_viewed', 0)) - if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']: - language = DICTIONARY.get(translation.get_language(), 'simple') - # django full text search https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/#searchquery - # TODO can options install this extension to further enhance search query language https://github.com/caub/pg-tsquery - # trigram breaks full text search 'websearch' and 'raw' capabilities and will be ignored if those methods are chosen - if search_type in ['websearch', 'raw']: - search_trigram = False - else: - search_trigram = True - search_query = SearchQuery( - search_string, - search_type=search_type, - config=language, + self._search_type = self._search_prefs.search or 'plain' + if self._string: + unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True) + self._icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)] + self._istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)] + self._trigram_include = None + self._fulltext_include = None + self._trigram = False + if self._postgres and self._string: + self._language = DICTIONARY.get(translation.get_language(), 'simple') + self._trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)] + self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) + + if self._search_type not in ['websearch', 'raw'] and self._trigram_include: + self._trigram = True + self.search_query = SearchQuery( + self._string, + search_type=self._search_type, + config=self._language, ) + self.search_rank = ( + SearchRank('name_search_vector', self.search_query, cover_density=True) + + SearchRank('desc_search_vector', self.search_query, cover_density=True) + + SearchRank('steps__search_vector', self.search_query, cover_density=True) + ) + self.orderby = [] + self._default_sort = ['-favorite'] # TODO add user setting + self._filters = None + self._fuzzy_match = None - # iterate through fields to use in trigrams generating a single trigram - if search_trigram and len(trigram_include) > 0: - trigram = None - for f in trigram_include: - if trigram: - trigram += TrigramSimilarity(f, search_string) - else: - trigram = TrigramSimilarity(f, search_string) - queryset = queryset.annotate(similarity=trigram) - filters += [Q(similarity__gt=search_prefs.trigram_threshold)] + def get_queryset(self, queryset): + self._queryset = queryset + self.recently_viewed_recipes(self._last_viewed) + self._favorite_recipes() + # self._last_viewed() + # self._last_cooked() + self.keyword_filters(keywords=self._keywords, operator=self._keywords_or) + self.food_filters(foods=self._foods, operator=self._foods_or) + self.book_filters(books=self._books, operator=self._books_or) + self.rating_filter(rating=self._rating) + self.internal_filter() + self.step_filters(steps=self._steps) + self.unit_filters(units=self._units) + self.string_filters(string=self._string) + # self._queryset = self._queryset.distinct() # TODO 2x check. maybe add filter of recipe__in after orderby + self._apply_order_by() + return self._queryset.filter(space=self._request.space) - if 'name' in fulltext_include: - filters += [Q(name_search_vector=search_query)] - if 'description' in fulltext_include: - filters += [Q(desc_search_vector=search_query)] - if 'instructions' in fulltext_include: - filters += [Q(steps__search_vector=search_query)] - if 'keywords' in fulltext_include: - filters += [Q(keywords__in=Subquery(Keyword.objects.filter(name__search=search_query).values_list('id', flat=True)))] - if 'foods' in fulltext_include: - filters += [Q(steps__ingredients__food__in=Subquery(Food.objects.filter(name__search=search_query).values_list('id', flat=True)))] + def _apply_order_by(self): + if self._random: + self._queryset = self._queryset.order_by("?") + else: + if self._sort_order: + self._queryset.order_by(*self._sort_order) + return + + order = [] # TODO add user preferences here: name, date cooked, rating, times cooked, date created, date viewed, random + if '-recent' in self.orderby and self._last_viewed: + order += ['-recent'] + + if '-rank' in self.orderby and '-simularity' in self.orderby: + self._queryset = self._queryset.annotate(score=Sum(F('rank')+F('simularity'))) + order += ['-score'] + elif '-rank' in self.orderby: + self._queryset = self._queryset.annotate(score=F('rank')) + order += ['-score'] + elif '-simularity' in self.orderby: + self._queryset = self._queryset.annotate(score=F('simularity')) + order += ['-score'] + for x in list(set(self.orderby)-set([*order, '-rank', '-simularity'])): + order += [x] + self._queryset = self._queryset.order_by(*order) + + def string_filters(self, string=None): + if not string: + return + + self.build_text_filters(self._string) + if self._postgres: + self.build_fulltext_filters(self._string) + self.build_trigram(self._string) + + if self._filters: query_filter = None - for f in filters: + for f in self._filters: if query_filter: query_filter |= f else: query_filter = f + self._queryset = self._queryset.filter(query_filter).distinct() + # TODO add annotation for simularity + if self._fulltext_include: + self._queryset = self._queryset.annotate(rank=self.search_rank) + self.orderby += ['-rank'] - # TODO add order by user settings - only do search rank and annotation if rank order is configured - search_rank = ( - SearchRank('name_search_vector', search_query, cover_density=True) - + SearchRank('desc_search_vector', search_query, cover_density=True) - + SearchRank('steps__search_vector', search_query, cover_density=True) - ) - queryset = queryset.filter(query_filter).annotate(rank=search_rank) - orderby += ['-rank'] + if self._fuzzy_match is not None: # this annotation is full text, not trigram + simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity') + self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0)) + self.orderby += ['-simularity'] else: - queryset = queryset.filter(name__icontains=search_string) + self._queryset = self._queryset.filter(name__icontains=self._string) - if len(search_keywords) > 0: - if search_keywords_or: + def recently_viewed_recipes(self, last_viewed=None): + if not last_viewed: + return + + last_viewed_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values( + 'recipe').annotate(recent=Max('created_at')).order_by('-recent') + last_viewed_recipes = last_viewed_recipes[:last_viewed] + self.orderby += ['-recent'] + self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=last_viewed_recipes.values('recipe'), then='viewlog__pk'))), Value(0))) + + def _favorite_recipes(self): + self.orderby += ['-favorite'] # default sort? + favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk') + ).values('recipe').annotate(count=Count('pk', distinct=True)).values('count') + self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), 0)) + + def keyword_filters(self, keywords=None, operator=True): + if not keywords: + return + if operator == True: # TODO creating setting to include descendants of keywords a setting - # for kw in Keyword.objects.filter(pk__in=search_keywords): - # search_keywords += list(kw.get_descendants().values_list('pk', flat=True)) - for kw in Keyword.objects.filter(pk__in=search_keywords): - search_keywords = [*search_keywords, *list(kw.get_descendants_and_self().values_list('pk', flat=True))] - queryset = queryset.filter(keywords__id__in=search_keywords) + self._queryset = self._queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=keywords))) else: # when performing an 'and' search returned recipes should include a parent OR any of its descedants # AND other keywords selected so filters are appended using keyword__id__in the list of keywords and descendants - for kw in Keyword.objects.filter(pk__in=search_keywords): - queryset = queryset.filter(keywords__id__in=list(kw.get_descendants_and_self().values_list('pk', flat=True))) + for kw in Keyword.objects.filter(pk__in=keywords): + self._queryset = self._queryset.filter(keywords__in=list(kw.get_descendants_and_self())) - if len(search_foods) > 0: - if search_foods_or: + def food_filters(self, foods=None, operator=True): + if not foods: + return + if operator == True: # TODO creating setting to include descendants of food a setting - for fd in Food.objects.filter(pk__in=search_foods): - search_foods = [*search_foods, *list(fd.get_descendants_and_self().values_list('pk', flat=True))] - queryset = queryset.filter(steps__ingredients__food__id__in=search_foods) + self._queryset = self._queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=foods))) else: # when performing an 'and' search returned recipes should include a parent OR any of its descedants # AND other foods selected so filters are appended using steps__ingredients__food__id__in the list of foods and descendants - for fd in Food.objects.filter(pk__in=search_foods): - queryset = queryset.filter(steps__ingredients__food__id__in=list(fd.get_descendants_and_self().values_list('pk', flat=True))) + for fd in Food.objects.filter(pk__in=foods): + self._queryset = self._queryset.filter(steps__ingredients__food__in=list(fd.get_descendants_and_self())) - if len(search_books) > 0: - if search_books_or: - queryset = queryset.filter(recipebookentry__book__id__in=search_books) - else: - for k in search_books: - queryset = queryset.filter(recipebookentry__book__id=k) + def unit_filters(self, units=None, operator=True): + if operator != True: + raise NotImplementedError + if not units: + return + self._queryset = self._queryset.filter(steps__ingredients__unit__id=units) - if search_rating: + def rating_filter(self, rating=None): + if rating is None: + return + rating = int(rating) # TODO make ratings a settings user-only vs all-users - queryset = queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=request.user, then='cooklog__rating'), default=Value(0))))) - if search_rating == -1: - queryset = queryset.filter(rating=0) + self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0))))) + if rating == 0: + self._queryset = self._queryset.filter(rating=0) else: - queryset = queryset.filter(rating__gte=search_rating) + self._queryset = self._queryset.filter(rating__gte=rating) - # probably only useful in Unit list view, so keeping it simple - if search_units: - queryset = queryset.filter(steps__ingredients__unit__id=search_units) + def internal_filter(self): + self._queryset = self._queryset.filter(internal=True) - # probably only useful in Unit list view, so keeping it simple - if search_steps: - queryset = queryset.filter(steps__id__in=search_steps) + def book_filters(self, books=None, operator=True): + if not books: + return + if operator == True: + self._queryset = self._queryset.filter(recipebookentry__book__id__in=books) + else: + for k in books: + self._queryset = self._queryset.filter(recipebookentry__book__id=k) - if search_internal: - queryset = queryset.filter(internal=True) + def step_filters(self, steps=None, operator=True): + if operator != True: + raise NotImplementedError + if not steps: + return + self._queryset = self._queryset.filter(steps__id__in=steps) - queryset = queryset.distinct() + def build_fulltext_filters(self, string=None): + if not string: + return + if self._fulltext_include: + if not self._filters: + self._filters = [] + if 'name' in self._fulltext_include: + self._filters += [Q(name_search_vector=self.search_query)] + if 'description' in self._fulltext_include: + self._filters += [Q(desc_search_vector=self.search_query)] + if 'steps__instruction' in self._fulltext_include: + self._filters += [Q(steps__search_vector=self.search_query)] + if 'keywords__name' in self._fulltext_include: + self._filters += [Q(keywords__in=Keyword.objects.filter(name__search=self.search_query))] + if 'steps__ingredients__food__name' in self._fulltext_include: + self._filters += [Q(steps__ingredients__food__in=Food.objects.filter(name__search=self.search_query))] - if search_random: - queryset = queryset.order_by("?") - else: - queryset = queryset.order_by(*orderby) - return queryset + def build_text_filters(self, string=None): + if not string: + return + + if not self._filters: + self._filters = [] + # dynamically build array of filters that will be applied + for f in self._icontains_include: + self._filters += [Q(**{"%s__icontains" % f: self._string})] + + for f in self._istartswith_include: + self._filters += [Q(**{"%s__istartswith" % f: self._string})] + + def build_trigram(self, string=None): + if not string: + return + if self._trigram: + trigram = None + for f in self._trigram_include: + if trigram: + trigram += TrigramSimilarity(f, self._string) + else: + trigram = TrigramSimilarity(f, self._string) + self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct( + ).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold) + self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))] + + +# def search_recipes(request, queryset, params): +# if request.user.is_authenticated: +# search_prefs = request.user.searchpreference +# else: +# search_prefs = SearchPreference() +# search_string = params.get('query', '').strip() +# search_rating = int(params.get('rating', 0)) +# search_keywords = params.getlist('keywords', []) +# search_foods = params.getlist('foods', []) +# search_books = params.getlist('books', []) +# search_steps = params.getlist('steps', []) +# search_units = params.get('units', None) + +# search_keywords_or = str2bool(params.get('keywords_or', True)) +# search_foods_or = str2bool(params.get('foods_or', True)) +# search_books_or = str2bool(params.get('books_or', True)) + +# search_internal = str2bool(params.get('internal', False)) +# search_random = str2bool(params.get('random', False)) +# search_new = str2bool(params.get('new', False)) +# search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently? +# orderby = [] + +# # only sort by recent not otherwise filtering/sorting +# if search_last_viewed > 0: +# last_viewed_recipes = ViewLog.objects.filter(created_by=request.user, space=request.space).values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:search_last_viewed] +# queryset = queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=last_viewed_recipes.values('recipe'), then='viewlog__pk'))), Value(0))).order_by('-recent') +# orderby += ['-recent'] +# # TODO add sort by favorite +# favorite_recipes = CookLog.objects.filter(created_by=request.user, space=request.space, recipe=OuterRef('pk')).values('recipe').annotate(count=Count('pk', distinct=True)).values('count') +# # TODO add to serialization and RecipeCard and RecipeView +# queryset = queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), 0)) + +# # TODO create setting for default ordering - most cooked, rating, +# # TODO create options for live sorting +# # TODO make days of new recipe a setting +# if search_new: +# queryset = ( +# queryset.annotate(new_recipe=Case( +# When(created_at__gte=(timezone.now() - timedelta(days=7)), then=('pk')), default=Value(0), )) +# ) +# # TODO create setting for 'new' recipes +# # only sort by new recipes if not otherwise filtering/sorting +# orderby += ['-new_recipe'] +# orderby += ['-favorite'] + +# search_type = search_prefs.search or 'plain' +# if len(search_string) > 0: +# unaccent_include = search_prefs.unaccent.values_list('field', flat=True) + +# icontains_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.icontains.values_list('field', flat=True)] +# istartswith_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.istartswith.values_list('field', flat=True)] +# trigram_include = [x + '__unaccent' if x in unaccent_include else x for x in search_prefs.trigram.values_list('field', flat=True)] +# fulltext_include = search_prefs.fulltext.values_list('field', flat=True) # fulltext doesn't use field name directly + +# # if no filters are configured use name__icontains as default +# if icontains_include or istartswith_include or trigram_include or fulltext_include: +# filters = [Q(**{"name__icontains": search_string})] +# else: +# filters = [] + +# # dynamically build array of filters that will be applied +# for f in icontains_include: +# filters += [Q(**{"%s__icontains" % f: search_string})] + +# for f in istartswith_include: +# filters += [Q(**{"%s__istartswith" % f: search_string})] + +# if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']: +# language = DICTIONARY.get(translation.get_language(), 'simple') +# # django full text search https://docs.djangoproject.com/en/3.2/ref/contrib/postgres/search/#searchquery +# # TODO can options install this extension to further enhance search query language https://github.com/caub/pg-tsquery +# # trigram breaks full text search 'websearch' and 'raw' capabilities and will be ignored if those methods are chosen +# if search_type in ['websearch', 'raw']: +# search_trigram = False +# else: +# search_trigram = True +# search_query = SearchQuery( +# search_string, +# search_type=search_type, +# config=language, +# ) + +# # iterate through fields to use in trigrams generating a single trigram +# if search_trigram and len(trigram_include) > 0: +# trigram = None +# for f in trigram_include: +# if trigram: +# trigram += TrigramSimilarity(f, search_string) +# else: +# trigram = TrigramSimilarity(f, search_string) +# queryset = queryset.annotate(similarity=trigram) +# filters += [Q(similarity__gt=search_prefs.trigram_threshold)] + +# if 'name' in fulltext_include: +# filters += [Q(name_search_vector=search_query)] +# if 'description' in fulltext_include: +# filters += [Q(desc_search_vector=search_query)] +# if 'instructions' in fulltext_include: +# filters += [Q(steps__search_vector=search_query)] +# if 'keywords' in fulltext_include: +# filters += [Q(keywords__in=Subquery(Keyword.objects.filter(name__search=search_query).values_list('id', flat=True)))] +# if 'foods' in fulltext_include: +# filters += [Q(steps__ingredients__food__in=Subquery(Food.objects.filter(name__search=search_query).values_list('id', flat=True)))] +# query_filter = None +# for f in filters: +# if query_filter: +# query_filter |= f +# else: +# query_filter = f + +# # TODO add order by user settings - only do search rank and annotation if rank order is configured +# search_rank = ( +# SearchRank('name_search_vector', search_query, cover_density=True) +# + SearchRank('desc_search_vector', search_query, cover_density=True) +# + SearchRank('steps__search_vector', search_query, cover_density=True) +# ) +# queryset = queryset.filter(query_filter).annotate(rank=search_rank) +# orderby += ['-rank'] +# else: +# queryset = queryset.filter(name__icontains=search_string) + +# if len(search_keywords) > 0: +# if search_keywords_or: +# # TODO creating setting to include descendants of keywords a setting +# # for kw in Keyword.objects.filter(pk__in=search_keywords): +# # search_keywords += list(kw.get_descendants().values_list('pk', flat=True)) +# queryset = queryset.filter(keywords__in=Keyword.include_descendants(Keyword.objects.filter(pk__in=search_keywords))) +# else: +# # when performing an 'and' search returned recipes should include a parent OR any of its descedants +# # AND other keywords selected so filters are appended using keyword__id__in the list of keywords and descendants +# for kw in Keyword.objects.filter(pk__in=search_keywords): +# queryset = queryset.filter(keywords__in=list(kw.get_descendants_and_self())) + +# if len(search_foods) > 0: +# if search_foods_or: +# # TODO creating setting to include descendants of food a setting +# queryset = queryset.filter(steps__ingredients__food__in=Food.include_descendants(Food.objects.filter(pk__in=search_foods))) +# else: +# # when performing an 'and' search returned recipes should include a parent OR any of its descedants +# # AND other foods selected so filters are appended using steps__ingredients__food__id__in the list of foods and descendants +# for fd in Food.objects.filter(pk__in=search_foods): +# queryset = queryset.filter(steps__ingredients__food__in=list(fd.get_descendants_and_self())) + +# if len(search_books) > 0: +# if search_books_or: +# queryset = queryset.filter(recipebookentry__book__id__in=search_books) +# else: +# for k in search_books: +# queryset = queryset.filter(recipebookentry__book__id=k) + +# if search_rating: +# # TODO make ratings a settings user-only vs all-users +# queryset = queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=request.user, then='cooklog__rating'), default=Value(0))))) +# if search_rating == -1: +# queryset = queryset.filter(rating=0) +# else: +# queryset = queryset.filter(rating__gte=search_rating) + +# # probably only useful in Unit list view, so keeping it simple +# if search_units: +# queryset = queryset.filter(steps__ingredients__unit__id=search_units) + +# # probably only useful in Unit list view, so keeping it simple +# if search_steps: +# queryset = queryset.filter(steps__id__in=search_steps) + +# if search_internal: +# queryset = queryset.filter(internal=True) + +# queryset = queryset.distinct() + +# if search_random: +# queryset = queryset.order_by("?") +# else: +# queryset = queryset.order_by(*orderby) +# return queryset class RecipeFacet(): @@ -223,6 +475,7 @@ class RecipeFacet(): self.Foods = self._cache.get('Foods', None) self.Books = self._cache.get('Books', None) self.Ratings = self._cache.get('Ratings', None) + # TODO Move Recent to recipe annotation/serializer: requrires change in RecipeSearch(), RecipeSearchView.vue and serializer self.Recent = self._cache.get('Recent', None) if self._queryset is not None: @@ -666,4 +919,10 @@ def old_search(request): # other[name] = [*other.get(name, []), x.name] # if x.hidden: # hidden[name] = [*hidden.get(name, []), x.name] -# print('---', x.name, ' - ', x.db_type, x.remote_name) +# print('---', x.name, ' - ', x.db_type) +# for field_type in [(char, 'char'), (number, 'number'), (other, 'other'), (date, 'date'), (image, 'image'), (one_to_many, 'one_to_many'), (many_to_one, 'many_to_one'), (many_to_many, 'many_to_many')]: +# print(f"{field_type[1]}:") +# for model in field_type[0]: +# print(f"--{model}") +# for field in field_type[0][model]: +# print(f" --{field}") diff --git a/cookbook/models.py b/cookbook/models.py index 07e23097..ad50db1b 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -151,6 +151,7 @@ class TreeModel(MP_Node): return super().add_root(**kwargs) # i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet + @staticmethod def include_descendants(queryset=None, filter=None): """ :param queryset: Model Queryset to add descendants @@ -1095,98 +1096,81 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis space = models.ForeignKey(Space, on_delete=models.CASCADE) -class ModelFilter(models.Model): - EQUAL = 'EQUAL' - NOT_EQUAL = 'NOT_EQUAL' - LESS_THAN = 'LESS_THAN' - GREATER_THAN = 'GREATER_THAN' - LESS_THAN_EQ = 'LESS_THAN_EQ' - GREATER_THAN_EQ = 'GREATER_THAN_EQ' - CONTAINS = 'CONTAINS' - NOT_CONTAINS = 'NOT_CONTAINS' - STARTS_WITH = 'STARTS_WITH' - NOT_STARTS_WITH = 'NOT_STARTS_WITH' - ENDS_WITH = 'ENDS_WITH' - NOT_ENDS_WITH = 'NOT_ENDS_WITH' - INCLUDES = 'INCLUDES' - NOT_INCLUDES = 'NOT_INCLUDES' - COUNT_EQ = 'COUNT_EQ' - COUNT_NEQ = 'COUNT_NEQ' - COUNT_LT = 'COUNT_LT' - COUNT_GT = 'COUNT_GT' +# class ModelFilter(models.Model): +# EQUAL = 'EQUAL' +# LESS_THAN = 'LESS_THAN' +# GREATER_THAN = 'GREATER_THAN' +# LESS_THAN_EQ = 'LESS_THAN_EQ' +# GREATER_THAN_EQ = 'GREATER_THAN_EQ' +# CONTAINS = 'CONTAINS' +# STARTS_WITH = 'STARTS_WITH' +# ENDS_WITH = 'ENDS_WITH' +# INCLUDES = 'INCLUDES' - OPERATION = ( - (EQUAL, _('is')), - (NOT_EQUAL, _('is not')), - (LESS_THAN, _('less than')), - (GREATER_THAN, _('greater than')), - (LESS_THAN_EQ, _('less or equal')), - (GREATER_THAN_EQ, _('greater or equal')), - (CONTAINS, _('contains')), - (NOT_CONTAINS, _('does not contain')), - (STARTS_WITH, _('starts with')), - (NOT_STARTS_WITH, _('does not start with')), - (INCLUDES, _('includes')), - (NOT_INCLUDES, _('does not include')), - (COUNT_EQ, _('count equals')), - (COUNT_NEQ, _('count does not equal')), - (COUNT_LT, _('count less than')), - (COUNT_GT, _('count greater than')), - ) +# OPERATION = ( +# (EQUAL, _('is')), +# (LESS_THAN, _('less than')), +# (GREATER_THAN, _('greater than')), +# (LESS_THAN_EQ, _('less or equal')), +# (GREATER_THAN_EQ, _('greater or equal')), +# (CONTAINS, _('contains')), +# (STARTS_WITH, _('starts with')), +# (INCLUDES, _('includes')), +# ) - STRING = 'STRING' - NUMBER = 'NUMBER' - BOOLEAN = 'BOOLEAN' - DATE = 'DATE' +# STRING = 'STRING' +# NUMBER = 'NUMBER' +# BOOLEAN = 'BOOLEAN' +# DATE = 'DATE' - FIELD_TYPE = ( - (STRING, _('string')), - (NUMBER, _('number')), - (BOOLEAN, _('boolean')), - (DATE, _('date')), - ) +# FIELD_TYPE = ( +# (STRING, _('string')), +# (NUMBER, _('number')), +# (BOOLEAN, _('boolean')), +# (DATE, _('date')), +# ) - field = models.CharField(max_length=32) - field_type = models.CharField(max_length=32, choices=(FIELD_TYPE)) - operation = models.CharField(max_length=32, choices=(OPERATION)) - negate = models.BooleanField(default=False,) - target_value = models.CharField(max_length=128) - sort = models.BooleanField(default=False,) - ascending = models.BooleanField(default=True,) +# field = models.CharField(max_length=32) +# field_type = models.CharField(max_length=32, choices=(FIELD_TYPE)) +# operation = models.CharField(max_length=32, choices=(OPERATION)) +# negate = models.BooleanField(default=False,) +# target_value = models.CharField(max_length=128) +# sort = models.BooleanField(default=False,) +# ascending = models.BooleanField(default=True,) - def __str__(self): - return f"{self.field} - {self.operation} - {self.target_value}" +# def __str__(self): +# return f"{self.field} - {self.operation} - {self.target_value}" -class SavedFilter(models.Model, PermissionModelMixin): - FOOD = 'FOOD' - UNIT = 'UNIT' - KEYWORD = "KEYWORD" - RECIPE = 'RECIPE' - BOOK = 'BOOK' +# class SavedFilter(models.Model, PermissionModelMixin): +# FOOD = 'FOOD' +# UNIT = 'UNIT' +# KEYWORD = "KEYWORD" +# RECIPE = 'RECIPE' +# BOOK = 'BOOK' - MODELS = ( - (FOOD, _('Food')), - (UNIT, _('Unit')), - (KEYWORD, _('Keyword')), - (RECIPE, _('Recipe')), - (BOOK, _('Book')) - ) +# MODELS = ( +# (FOOD, _('Food')), +# (UNIT, _('Unit')), +# (KEYWORD, _('Keyword')), +# (RECIPE, _('Recipe')), +# (BOOK, _('Book')) +# ) - name = models.CharField(max_length=128, ) - type = models.CharField(max_length=24, choices=(MODELS)), - description = models.CharField(max_length=256, blank=True) - shared = models.ManyToManyField(User, blank=True, related_name='filter_share') - created_by = models.ForeignKey(User, on_delete=models.CASCADE) - filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True) +# name = models.CharField(max_length=128, ) +# type = models.CharField(max_length=24, choices=(MODELS)), +# description = models.CharField(max_length=256, blank=True) +# shared = models.ManyToManyField(User, blank=True, related_name='filter_share') +# created_by = models.ForeignKey(User, on_delete=models.CASCADE) +# filter = models.ForeignKey(ModelFilter, on_delete=models.PROTECT, null=True) - objects = ScopedManager(space='space') - space = models.ForeignKey(Space, on_delete=models.CASCADE) +# objects = ScopedManager(space='space') +# space = models.ForeignKey(Space, on_delete=models.CASCADE) - def __str__(self): - return f"{self.type}: {self.name}" +# def __str__(self): +# return f"{self.type}: {self.name}" - class Meta: - constraints = [ - models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space') - ] +# class Meta: +# constraints = [ +# models.UniqueConstraint(fields=['space', 'name'], name='sf_unique_name_per_space') +# ] diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 0fa2ee21..fb1c3a22 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -519,7 +519,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer): def get_recipe_last_cooked(self, obj): try: - last = obj.cooklog_set.filter(created_by=self.context['request'].user).last() + last = obj.cooklog_set.filter(created_by=self.context['request'].user).order_by('created_at').last() if last: return last.created_at except TypeError: @@ -539,6 +539,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer): rating = serializers.SerializerMethodField('get_recipe_rating') last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked') new = serializers.SerializerMethodField('is_recipe_new') + recent = serializers.ReadOnlyField() def create(self, validated_data): pass @@ -551,7 +552,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer): fields = ( 'id', 'name', 'description', 'image', 'keywords', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', - 'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new' + 'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent' ) read_only_fields = ['image', 'created_by', 'created_at'] diff --git a/cookbook/templates/url_import.html b/cookbook/templates/url_import.html index 4a42a594..933f744b 100644 --- a/cookbook/templates/url_import.html +++ b/cookbook/templates/url_import.html @@ -498,6 +498,8 @@ :clear-on-select="true" :allow-empty="true" :preserve-search="true" + :internal-search="false" + :limit="options_limit" placeholder="{% trans 'Select one' %}" tag-placeholder="{% trans 'Select' %}" label="text" @@ -536,6 +538,8 @@ :clear-on-select="true" :allow-empty="false" :preserve-search="true" + :internal-search="false" + :limit="options_limit" label="text" track-by="id" :multiple="false" @@ -586,6 +590,8 @@ :clear-on-select="true" :hide-selected="true" :preserve-search="true" + :internal-search="false" + :limit="options_limit" placeholder="{% trans 'Select one' %}" tag-placeholder="{% trans 'Add Keyword' %}" :taggable="true" @@ -660,6 +666,7 @@ Vue.http.headers.common['X-CSRFToken'] = csrftoken; Vue.component('vue-multiselect', window.VueMultiselect.default) + import { ApiApiFactory } from "@/utils/openapi/api" let app = new Vue({ components: { @@ -693,7 +700,8 @@ import_duplicates: false, recipe_files: [], images: [], - mode: 'url' + mode: 'url', + options_limit:25 }, directives: { tabindex: { @@ -703,9 +711,9 @@ } }, mounted: function () { - this.searchKeywords('') - this.searchUnits('') - this.searchIngredients('') + // this.searchKeywords('') + // this.searchUnits('') + // this.searchIngredients('') let uri = window.location.search.substring(1); let params = new URLSearchParams(uri); q = params.get("id") @@ -877,51 +885,93 @@ this.$set(this.$refs.ingredient[index].$data, 'search', this.recipe_data.recipeIngredient[index].ingredient.text) }, searchKeywords: function (query) { + // this.keywords_loading = true + // this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => { + // this.keywords = response.data.results; + // this.keywords_loading = false + // }).catch((err) => { + // console.log(err) + // this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') + // }) + let apiFactory = new ApiApiFactory() + this.keywords_loading = true - this.$http.get("{% url 'dal_keyword' %}" + '?q=' + query).then((response) => { - this.keywords = response.data.results; - this.keywords_loading = false - }).catch((err) => { - console.log(err) - this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') - }) + apiFactory + .listKeywords(query, undefined, undefined, 1, this.options_limit) + .then((response) => { + this.keywords = response.data.results + this.keywords_loading = false + }) + .catch((err) => { + console.log(err) + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH) + }) }, searchUnits: function (query) { + let apiFactory = new ApiApiFactory() + this.units_loading = true - this.$http.get("{% url 'dal_unit' %}" + '?q=' + query).then((response) => { - this.units = response.data.results; - if (this.recipe_data !== undefined) { - for (let x of Array.from(this.recipe_data.recipeIngredient)) { - if (x.unit !== null && x.unit.text !== '') { - this.units = this.units.filter(item => item.text !== x.unit.text) - this.units.push(x.unit) + apiFactory + .listUnits(query, 1, this.options_limit) + .then((response) => { + this.units = response.data.results + + if (this.recipe !== undefined) { + for (let s of this.recipe.steps) { + for (let i of s.ingredients) { + if (i.unit !== null && i.unit.id === undefined) { + this.units.push(i.unit) + } + } } } - } - this.units_loading = false - }).catch((err) => { - console.log(err) - this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') - }) + this.units_loading = false + }) + .catch((err) => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH) + }) }, searchIngredients: function (query) { - this.ingredients_loading = true - this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => { - this.ingredients = response.data.results - if (this.recipe_data !== undefined) { - for (let x of Array.from(this.recipe_data.recipeIngredient)) { - if (x.ingredient.text !== '') { - this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text) - this.ingredients.push(x.ingredient) + // this.ingredients_loading = true + // this.$http.get("{% url 'dal_food' %}" + '?q=' + query).then((response) => { + // this.ingredients = response.data.results + // if (this.recipe_data !== undefined) { + // for (let x of Array.from(this.recipe_data.recipeIngredient)) { + // if (x.ingredient.text !== '') { + // this.ingredients = this.ingredients.filter(item => item.text !== x.ingredient.text) + // this.ingredients.push(x.ingredient) + // } + // } + // } + + // this.ingredients_loading = false + // }).catch((err) => { + // console.log(err) + // this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') + // }) + let apiFactory = new ApiApiFactory() + + this.foods_loading = true + apiFactory + .listFoods(query, undefined, undefined, 1, this.options_limit) + .then((response) => { + this.foods = response.data.results + + if (this.recipe !== undefined) { + for (let s of this.recipe.steps) { + for (let i of s.ingredients) { + if (i.food !== null && i.food.id === undefined) { + this.foods.push(i.food) + } + } } } - } - this.ingredients_loading = false - }).catch((err) => { - console.log(err) - this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger') - }) + this.foods_loading = false + }) + .catch((err) => { + StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH) + }) }, deleteNode: function (node, item, e) { e.stopPropagation() diff --git a/cookbook/views/api.py b/cookbook/views/api.py index a65c22fa..580942b2 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,7 +12,8 @@ from django.contrib.auth.models import User from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import FieldError, ValidationError from django.core.files import File -from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When +from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, ProtectedError, Q, + Subquery, Value, When) from django.db.models.fields.related import ForeignObjectRel from django.db.models.functions import Coalesce from django.http import FileResponse, HttpResponse, JsonResponse @@ -38,7 +39,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus CustomIsShare, CustomIsShared, CustomIsUser, group_required) from cookbook.helper.recipe_html_import import get_recipe_from_source -from cookbook.helper.recipe_search import RecipeFacet, old_search, search_recipes +from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search from cookbook.helper.recipe_url_import import get_from_scraper from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField, @@ -145,18 +146,18 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): if fuzzy: self.queryset = ( self.queryset - .annotate(exact=Case(When(name__iexact=query, then=(Value(100))), - default=Value(0))) # put exact matches at the top of the result set - .annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2) - .order_by('-exact', '-trigram') + .annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0))) + .annotate(trigram=TrigramSimilarity('name', query)) + .annotate(sort=F('starts')+F('trigram')) + .order_by('-sort') ) else: # TODO have this check unaccent search settings or other search preferences? self.queryset = ( self.queryset - .annotate(exact=Case(When(name__iexact=query, then=(Value(100))), - default=Value(0))) # put exact matches at the top of the result set - .filter(name__icontains=query).order_by('-exact', 'name') + .annotate(starts=Case(When(name__istartswith=query, then=(Value(100))), + default=Value(0))) # put exact matches at the top of the result set + .filter(name__icontains=query).order_by('-starts', 'name') ) updated_at = self.request.query_params.get('updated_at', None) @@ -652,8 +653,11 @@ class RecipeViewSet(viewsets.ModelViewSet): if not (share and self.detail): self.queryset = self.queryset.filter(space=self.request.space) - self.queryset = search_recipes(self.request, self.queryset, self.request.GET) - return super().get_queryset().prefetch_related('cooklog_set') + super().get_queryset() + # self.queryset = search_recipes(self.request, self.queryset, self.request.GET) + params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)} + self.queryset = RecipeSearch(self.request, **params).get_queryset(self.queryset).prefetch_related('cooklog_set') + return self.queryset def list(self, request, *args, **kwargs): if self.request.GET.get('debug', False): diff --git a/vue/src/apps/RecipeEditView/RecipeEditView.vue b/vue/src/apps/RecipeEditView/RecipeEditView.vue index f4810026..09ceb6e0 100644 --- a/vue/src/apps/RecipeEditView/RecipeEditView.vue +++ b/vue/src/apps/RecipeEditView/RecipeEditView.vue @@ -1,861 +1,841 @@ @@ -209,10 +231,12 @@
+ > {{ $t("Open") }} - {{ $t("Clear") }} + + {{ $t("Clear") }} +
@@ -220,37 +244,46 @@ -
+
- +
- +
-
- + - + - +
@@ -261,7 +294,7 @@ + {% endblock %} \ No newline at end of file diff --git a/cookbook/templates/account/signup.html b/cookbook/templates/account/signup.html index 2750af97..baceb2cb 100644 --- a/cookbook/templates/account/signup.html +++ b/cookbook/templates/account/signup.html @@ -71,4 +71,8 @@
+ + {% endblock %} \ No newline at end of file From e0b8d6fcc395fcbfee705c4a431c7544bbeb09d1 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 17:00:08 +0100 Subject: [PATCH 051/150] added exception catch to nextcloud importer to handle empty folders in sync --- cookbook/provider/nextcloud.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cookbook/provider/nextcloud.py b/cookbook/provider/nextcloud.py index d67c0244..743ad821 100644 --- a/cookbook/provider/nextcloud.py +++ b/cookbook/provider/nextcloud.py @@ -29,7 +29,11 @@ class Nextcloud(Provider): client = Nextcloud.get_client(monitor.storage) files = client.list(monitor.path) - files.pop(0) # remove first element because its the folder itself + + try: + files.pop(0) # remove first element because its the folder itself + except IndexError: + pass # folder is emtpy, no recipes will be imported import_count = 0 for file in files: From 85ecac3a17439de3d42262be3d6f8a1b507aaec2 Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 17 Jan 2022 10:10:38 -0600 Subject: [PATCH 052/150] Update recipe_search.py --- cookbook/helper/recipe_search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 673b7d0d..5c5412de 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -490,7 +490,7 @@ class RecipeFacet(): 'space': self._request.space, } elif self.hash_key is not None: - self._recipe_list = self._cache.get('recipe_list', None) + self._recipe_list = self._cache.get('recipe_list', []) self._search_params = { 'keyword_list': self._cache.get('keyword_list', None), 'food_list': self._cache.get('food_list', None), From 8b1e80efebc3092d7d296b348337f471903aa6cd Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 17:51:29 +0100 Subject: [PATCH 053/150] wip --- vue/src/components/Buttons/RecipeSwitcher.vue | 40 ++++++++--- vue/src/components/RecipeContextMenu.vue | 70 ++++++++++++++----- vue/src/locales/en.json | 7 +- 3 files changed, 84 insertions(+), 33 deletions(-) diff --git a/vue/src/components/Buttons/RecipeSwitcher.vue b/vue/src/components/Buttons/RecipeSwitcher.vue index be8b0e9e..922ce208 100644 --- a/vue/src/components/Buttons/RecipeSwitcher.vue +++ b/vue/src/components/Buttons/RecipeSwitcher.vue @@ -25,7 +25,7 @@ {{ r.name }} + ">{{ r.name }} x

@@ -38,6 +38,16 @@ ">{{ r.name }} + +
TEST
+ +
+ {{ r.name }} + +
@@ -60,7 +70,8 @@ export default { related_recipes: [], planned_recipes: [], pinned_recipes: [], - recipes: {} + recipes: {}, + test : [] } }, computed: { @@ -84,7 +95,7 @@ export default { navRecipe: function (recipe) { if (this.is_recipe_view) { - this.$emit("switch", this.recipes[recipe.id]) + this.$emit("switch", recipe) } else { window.location.href = this.resolveDjangoUrl("view_recipe", recipe.id) } @@ -93,16 +104,23 @@ export default { let apiClient = new ApiApiFactory() let recipe_list = [...this.related_recipes, ...this.planned_recipes, ...this.pinned_recipes] + let recipe_ids = [] recipe_list.forEach((recipe) => { - if (!recipe_ids.includes(recipe.id)) { - recipe_ids.push(recipe.id) + let id = recipe.id + if (id === undefined){ + id = recipe + } + + if (!recipe_ids.includes(id)) { + recipe_ids.push(id) } }) - + console.log(recipe_list, recipe_ids) recipe_ids.forEach((id) => { apiClient.retrieveRecipe(id).then((result) => { this.recipes[id] = result.data + this.test.push(result.data) }) }) @@ -111,12 +129,14 @@ export default { let apiClient = new ApiApiFactory() // get related recipes and save them for later - return apiClient.relatedRecipe(this.recipe, {query: {levels: 2}}).then((result) => { - this.related_recipes = result.data - }) + if (this.recipe){ + return apiClient.relatedRecipe(this.recipe, {query: {levels: 2}}).then((result) => { + this.related_recipes = result.data + }) + } }, loadPinnedRecipes: function () { - let pinned_recipe_ids = localStorage.getItem('pinned_recipes') || [] + let pinned_recipe_ids = JSON.parse(localStorage.getItem('pinned_recipes')) || [] this.pinned_recipes = pinned_recipe_ids }, loadMealPlans: function () { diff --git a/vue/src/components/RecipeContextMenu.vue b/vue/src/components/RecipeContextMenu.vue index 7cd24e5e..11b75b58 100644 --- a/vue/src/components/RecipeContextMenu.vue +++ b/vue/src/components/RecipeContextMenu.vue @@ -1,38 +1,60 @@ From 5724ef951132379e6326122f3637957cdf2d8fef Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 17 Jan 2022 14:02:58 -0600 Subject: [PATCH 063/150] fix boolean directive --- vue/src/components/GenericHorizontalCard.vue | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/vue/src/components/GenericHorizontalCard.vue b/vue/src/components/GenericHorizontalCard.vue index 8f130b47..b12cf6a0 100644 --- a/vue/src/components/GenericHorizontalCard.vue +++ b/vue/src/components/GenericHorizontalCard.vue @@ -8,12 +8,12 @@ :class="{ 'border border-primary': over, shake: isError }" :style="{ 'cursor:grab': useDrag }" :draggable="useDrag" - @[useDrag&&`dragover`].prevent - @[useDrag&&`dragenter`].prevent - @[useDrag&&`dragstart`]="handleDragStart($event)" - @[useDrag&&`dragenter`]="handleDragEnter($event)" - @[useDrag&&`dragleave`]="handleDragLeave($event)" - @[useDrag&&`drop`]="handleDragDrop($event)" + @[useDrag&&`dragover`||``].prevent + @[useDrag&&`dragenter`||``].prevent + @[useDrag&&`dragstart`||``]="handleDragStart($event)" + @[useDrag&&`dragenter`||``]="handleDragEnter($event)" + @[useDrag&&`dragleave`||``]="handleDragLeave($event)" + @[useDrag&&`drop`||``]="handleDragDrop($event)" > @@ -27,6 +27,7 @@
{{ getFullname }}
+ +
{{ item[child_count] }} {{ itemName }}
From c8c29e1b5a7db269c486a9fc9d6ec5cbf29b8e58 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 21:14:22 +0100 Subject: [PATCH 064/150] fixed performance issue --- cookbook/helper/recipe_search.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index 2e46bd77..35581b59 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -647,7 +647,7 @@ class RecipeFacet(): depth = getattr(keyword, 'depth', 0) + 1 steplen = depth * Keyword.steplen - return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0) + return queryset.annotate(count=Coalesce(1, 0) ).filter(depth=depth, count__gt=0 ).values('id', 'name', 'count', 'numchild').order_by('name') @@ -655,7 +655,7 @@ class RecipeFacet(): depth = getattr(food, 'depth', 0) + 1 steplen = depth * Food.steplen - return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0) + return queryset.annotate(count=Coalesce(1, 0) ).filter(depth__lte=depth, count__gt=0 ).values('id', 'name', 'count', 'numchild').order_by('name') From b9065f7052188bbbd24930008a7df4cc157ba6ef Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 22:03:57 +0100 Subject: [PATCH 065/150] added space deletion feature --- cookbook/admin.py | 49 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/cookbook/admin.py b/cookbook/admin.py index 73e86ecf..a252185f 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -15,7 +15,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, - TelegramBot, Unit, UserFile, UserPreference, ViewLog) + TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation) class CustomUserAdmin(UserAdmin): @@ -29,11 +29,52 @@ admin.site.register(User, CustomUserAdmin) admin.site.unregister(Group) +@admin.action(description='Delete all data from a space') +def delete_space_action(modeladmin, request, queryset): + for space in queryset: + CookLog.objects.filter(space=space).delete() + ViewLog.objects.filter(space=space).delete() + ImportLog.objects.filter(space=space).delete() + BookmarkletImport.objects.filter(space=space).delete() + + Comment.objects.filter(recipe__space=space).delete() + Keyword.objects.filter(space=space).delete() + Food.objects.filter(space=space).delete() + Unit.objects.filter(space=space).delete() + Ingredient.objects.filter(space=space).delete() + Step.objects.filter(space=space).delete() + NutritionInformation.objects.filter(space=space).delete() + RecipeBookEntry.objects.filter(book__space=space).delete() + RecipeBook.objects.filter(space=space).delete() + MealType.objects.filter(space=space).delete() + MealPlan.objects.filter(space=space).delete() + ShareLink.objects.filter(space=space).delete() + Recipe.objects.filter(space=space).delete() + + RecipeImport.objects.filter(space=space).delete() + SyncLog.objects.filter(sync__space=space).delete() + Sync.objects.filter(space=space).delete() + Storage.objects.filter(space=space).delete() + + ShoppingListEntry.objects.filter(shoppinglist__space=space).delete() + ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete() + ShoppingList.objects.filter(space=space).delete() + + SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete() + SupermarketCategory.objects.filter(space=space).delete() + Supermarket.objects.filter(space=space).delete() + + InviteLink.objects.filter(space=space).delete() + UserFile.objects.filter(space=space).delete() + Automation.objects.filter(space=space).delete() + + class SpaceAdmin(admin.ModelAdmin): list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing') search_fields = ('name', 'created_by__username') list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing') date_hierarchy = 'created_at' + actions = [delete_space_action] admin.site.register(Space, SpaceAdmin) @@ -128,7 +169,7 @@ def sort_tree(modeladmin, request, queryset): class KeywordAdmin(TreeAdmin): form = movenodeform_factory(Keyword) ordering = ('space', 'path',) - search_fields = ('name', ) + search_fields = ('name',) actions = [sort_tree, enable_tree_sorting, disable_tree_sorting] @@ -171,13 +212,15 @@ class RecipeAdmin(admin.ModelAdmin): admin.site.register(Recipe, RecipeAdmin) admin.site.register(Unit) + + # admin.site.register(FoodInheritField) class FoodAdmin(TreeAdmin): form = movenodeform_factory(Keyword) ordering = ('space', 'path',) - search_fields = ('name', ) + search_fields = ('name',) actions = [sort_tree, enable_tree_sorting, disable_tree_sorting] From d04e9518cbb77c5fcc1d7d9bd225ea82f4e5001c Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 22:13:36 +0100 Subject: [PATCH 066/150] fixed telegram shopping bot --- cookbook/views/telegram.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/cookbook/views/telegram.py b/cookbook/views/telegram.py index 9c73ba7b..d816bc92 100644 --- a/cookbook/views/telegram.py +++ b/cookbook/views/telegram.py @@ -45,21 +45,17 @@ def hook(request, token): tb.save() if tb.chat_id == str(data['message']['chat']['id']): - sl = ShoppingList.objects.filter(Q(created_by=tb.created_by)).filter(finished=False, space=tb.space).order_by('-created_at').first() - if not sl: - sl = ShoppingList.objects.create(created_by=tb.created_by, space=tb.space) - request.space = tb.space # TODO this is likely a bad idea. Verify and test request.user = tb.created_by ingredient_parser = IngredientParser(request, False) amount, unit, ingredient, note = ingredient_parser.parse(data['message']['text']) f = ingredient_parser.get_food(ingredient) u = ingredient_parser.get_unit(unit) - sl.entries.add( - ShoppingListEntry.objects.create( - food=f, unit=u, amount=amount - ) + + ShoppingListEntry.objects.create( + food=f, unit=u, amount=amount, created_by=request.user ) + return JsonResponse({'data': data['message']['text']}) except Exception: pass From c27933548d23372653a92a69df463bd405a1686f Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 22:28:02 +0100 Subject: [PATCH 067/150] fixed order of delete --- cookbook/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cookbook/admin.py b/cookbook/admin.py index a252185f..625b2a6b 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -39,9 +39,9 @@ def delete_space_action(modeladmin, request, queryset): Comment.objects.filter(recipe__space=space).delete() Keyword.objects.filter(space=space).delete() + Ingredient.objects.filter(space=space).delete() Food.objects.filter(space=space).delete() Unit.objects.filter(space=space).delete() - Ingredient.objects.filter(space=space).delete() Step.objects.filter(space=space).delete() NutritionInformation.objects.filter(space=space).delete() RecipeBookEntry.objects.filter(book__space=space).delete() From 54721a0a62315bc656fe89011e74376ff6da7611 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 22:37:14 +0100 Subject: [PATCH 068/150] also added space to bot --- cookbook/views/telegram.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cookbook/views/telegram.py b/cookbook/views/telegram.py index d816bc92..6c98bdf0 100644 --- a/cookbook/views/telegram.py +++ b/cookbook/views/telegram.py @@ -52,9 +52,7 @@ def hook(request, token): f = ingredient_parser.get_food(ingredient) u = ingredient_parser.get_unit(unit) - ShoppingListEntry.objects.create( - food=f, unit=u, amount=amount, created_by=request.user - ) + ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space) return JsonResponse({'data': data['message']['text']}) except Exception: From 532d32c194d9fa3ef4b39c79d95379e48a40fe8c Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Mon, 17 Jan 2022 22:41:38 +0100 Subject: [PATCH 069/150] fixed shopping user save setting would not work --- cookbook/serializer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index ce8d48de..3584138c 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -165,9 +165,10 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer): read_only_fields = ['id'] -class UserPreferenceSerializer(serializers.ModelSerializer): +class UserPreferenceSerializer(WritableNestedModelSerializer): food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True) plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True) + shopping_share = UserNameSerializer(many=True, allow_null=True, required=False) def create(self, validated_data): if not validated_data.get('user', None): From c60141940d658352aa102b5e6412eecf71ae65db Mon Sep 17 00:00:00 2001 From: Kaibu Date: Mon, 17 Jan 2022 23:02:42 +0100 Subject: [PATCH 070/150] shopping list ux improvements --- .../ShoppingListView/ShoppingListView.vue | 2437 +++++++++-------- vue/src/components/Modals/LookupInput.vue | 319 +-- vue/src/components/ShoppingLineItem.vue | 581 ++-- 3 files changed, 1739 insertions(+), 1598 deletions(-) diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index f26fb7d1..64fa073d 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -1,147 +1,184 @@ @@ -1241,26 +1294,54 @@ export default { diff --git a/vue/src/components/Modals/LookupInput.vue b/vue/src/components/Modals/LookupInput.vue index 24dde754..90ca7f3f 100644 --- a/vue/src/components/Modals/LookupInput.vue +++ b/vue/src/components/Modals/LookupInput.vue @@ -1,172 +1,173 @@ diff --git a/vue/src/components/ShoppingLineItem.vue b/vue/src/components/ShoppingLineItem.vue index 97055615..a72f20f0 100644 --- a/vue/src/components/ShoppingLineItem.vue +++ b/vue/src/components/ShoppingLineItem.vue @@ -1,287 +1,322 @@ @@ -296,4 +331,28 @@ export default { /* left: 0; top: 50%; width: 100%; /* …with the top across the middle */ /* border-bottom: 1px solid #000; /* …and with a border on the top */ /* } */ +.checkbox-control { + font-size: 0.6rem +} + +.checkbox-control-mobile { + font-size: 1rem +} + +.rotate { + -moz-transition: all 0.25s linear; + -webkit-transition: all 0.25s linear; + transition: all 0.25s linear; +} + +.rotated { + -moz-transform: rotate(90deg); + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.unit-badge-lg { + font-size: 1rem !important; + font-weight: 500 !important; +} From d8d76ae9e0863cf11842871cf5c35759fc2ba7fb Mon Sep 17 00:00:00 2001 From: smilerz Date: Mon, 17 Jan 2022 16:12:54 -0600 Subject: [PATCH 071/150] fix missing label supermarket category --- vue/src/components/Modals/LookupInput.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vue/src/components/Modals/LookupInput.vue b/vue/src/components/Modals/LookupInput.vue index 24dde754..5d6dd7c7 100644 --- a/vue/src/components/Modals/LookupInput.vue +++ b/vue/src/components/Modals/LookupInput.vue @@ -82,7 +82,7 @@ export default { } else { arrayValues = [{ id: -1, name: this_value }] } - if (this.form?.ordered && this.first_run && arrayValues.length > 0) { + if (this.form?.ordered && this.first_run) { return this.flattenItems(arrayValues) } else { return arrayValues From ef4ce62f5b4daf6fc1449cc9a49d0e0ee7e967c8 Mon Sep 17 00:00:00 2001 From: Kaibu Date: Mon, 17 Jan 2022 23:48:57 +0100 Subject: [PATCH 072/150] custom class selection for lookupinput comp --- vue/src/components/Modals/LookupInput.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vue/src/components/Modals/LookupInput.vue b/vue/src/components/Modals/LookupInput.vue index 5d6dd7c7..66a9863e 100644 --- a/vue/src/components/Modals/LookupInput.vue +++ b/vue/src/components/Modals/LookupInput.vue @@ -1,6 +1,6 @@