Coverage for cookbook/views/api.py: 65%
1071 statements
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 01:02 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 01:02 +0100
1import datetime
2import io
3import json
4import mimetypes
5import pathlib
6import re
7import threading
8import traceback
9import uuid
10from collections import OrderedDict
11from json import JSONDecodeError
12from urllib.parse import unquote
13from zipfile import ZipFile
15import requests
16import validators
17from annoying.decorators import ajax_request
18from annoying.functions import get_object_or_None
19from django.contrib import messages
20from django.contrib.auth.models import Group, User
21from django.contrib.postgres.search import TrigramSimilarity
22from django.core.cache import caches
23from django.core.exceptions import FieldError, ValidationError
24from django.core.files import File
25from django.db.models import Case, Count, OuterRef, ProtectedError, Q, Subquery, Value, When
26from django.db.models.fields.related import ForeignObjectRel
27from django.db.models.functions import Coalesce, Lower
28from django.db.models.signals import post_save
29from django.http import FileResponse, HttpResponse, HttpResponseRedirect, JsonResponse
30from django.shortcuts import get_object_or_404, redirect
31from django.urls import reverse
32from django.utils import timezone
33from django.utils.translation import gettext as _
34from django_scopes import scopes_disabled
35from icalendar import Calendar, Event
36from oauth2_provider.models import AccessToken
37from PIL import UnidentifiedImageError
38from recipe_scrapers import scrape_me
39from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
40from requests.exceptions import MissingSchema
41from rest_framework import decorators, status, viewsets
42from rest_framework.authtoken.views import ObtainAuthToken
43from rest_framework.decorators import api_view, permission_classes
44from rest_framework.exceptions import APIException, PermissionDenied
45from rest_framework.pagination import PageNumberPagination
46from rest_framework.parsers import MultiPartParser
47from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
48from rest_framework.response import Response
49from rest_framework.schemas import DefaultSchema
50from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
51from rest_framework.views import APIView
52from rest_framework.viewsets import ViewSetMixin
53from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
55from cookbook.forms import ImportForm
56from cookbook.helper import recipe_url_import as helper
57from cookbook.helper.HelperFunctions import str2bool
58from cookbook.helper.image_processing import handle_image
59from cookbook.helper.ingredient_parser import IngredientParser
60from cookbook.helper.open_data_importer import OpenDataImporter
61from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly,
62 CustomIsShared, CustomIsSpaceOwner, CustomIsUser,
63 CustomRecipePermission, CustomTokenHasReadWriteScope,
64 CustomTokenHasScope, CustomUserPermission,
65 IsReadOnlyDRF, above_space_limit, group_required,
66 has_group_permission, is_space_owner,
67 switch_user_active_space)
68from cookbook.helper.recipe_search import RecipeSearch
69from cookbook.helper.recipe_url_import import (clean_dict, get_from_youtube_scraper,
70 get_images_from_soup)
71from cookbook.helper.scrapers.scrapers import text_scraper
72from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
73from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
74 FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink,
75 Keyword, MealPlan, MealType, Property, PropertyType, Recipe,
76 RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
77 ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
78 Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
79 SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
80 ViewLog)
81from cookbook.provider.dropbox import Dropbox
82from cookbook.provider.local import Local
83from cookbook.provider.nextcloud import Nextcloud
84from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
85from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
86 AutoMealPlanSerializer, BookmarkletImportListSerializer,
87 BookmarkletImportSerializer, CookLogSerializer,
88 CustomFilterSerializer, ExportLogSerializer,
89 FoodInheritFieldSerializer, FoodSerializer,
90 FoodShoppingUpdateSerializer, FoodSimpleSerializer,
91 GroupSerializer, ImportLogSerializer, IngredientSerializer,
92 IngredientSimpleSerializer, InviteLinkSerializer,
93 KeywordSerializer, MealPlanSerializer, MealTypeSerializer,
94 PropertySerializer, PropertyTypeSerializer,
95 RecipeBookEntrySerializer, RecipeBookSerializer,
96 RecipeExportSerializer, RecipeFromSourceSerializer,
97 RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer,
98 RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
99 ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
100 ShoppingListRecipeSerializer, ShoppingListSerializer,
101 SpaceSerializer, StepSerializer, StorageSerializer,
102 SupermarketCategoryRelationSerializer,
103 SupermarketCategorySerializer, SupermarketSerializer,
104 SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
105 UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
106 UserSerializer, UserSpaceSerializer, ViewLogSerializer)
107from cookbook.views.import_export import get_integration
108from recipes import settings
109from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY
112class StandardFilterMixin(ViewSetMixin):
113 def get_queryset(self):
114 queryset = self.queryset
115 query = self.request.query_params.get('query', None)
116 if query is not None:
117 queryset = queryset.filter(name__icontains=query)
119 updated_at = self.request.query_params.get('updated_at', None)
120 if updated_at is not None:
121 try:
122 queryset = queryset.filter(updated_at__gte=updated_at)
123 except FieldError:
124 pass
125 except ValidationError:
126 raise APIException(_('Parameter updated_at incorrectly formatted'))
128 limit = self.request.query_params.get('limit', None)
129 random = self.request.query_params.get('random', False)
130 if limit is not None:
131 if random:
132 queryset = queryset.order_by("?")[:int(limit)]
133 else:
134 queryset = queryset[:int(limit)]
135 return queryset
138class DefaultPagination(PageNumberPagination):
139 page_size = 50
140 page_size_query_param = 'page_size'
141 max_page_size = 200
144class ExtendedRecipeMixin():
145 '''
146 ExtendedRecipe annotates a queryset with recipe_image and recipe_count values
147 '''
149 @classmethod
150 def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False):
151 extended = str2bool(request.query_params.get('extended', None))
152 if extended:
153 recipe_filter = serializer.recipe_filter
154 images = serializer.images
155 space = request.space
157 # add a recipe count annotation to the query
158 # explanation on construction https://stackoverflow.com/a/43771738/15762829
159 recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk', distinct=True)).values('count')
160 queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
162 # add a recipe image annotation to the query
163 image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(
164 image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
165 if tree:
166 image_children_subquery = Recipe.objects.filter(
167 **{f"{recipe_filter}__path__startswith": OuterRef('path')},
168 space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
169 else:
170 image_children_subquery = None
171 if images:
172 queryset = queryset.annotate(recipe_image=Coalesce(*images, image_subquery, image_children_subquery))
173 else:
174 queryset = queryset.annotate(recipe_image=Coalesce(image_subquery, image_children_subquery))
175 return queryset
178class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
179 schema = FilterSchema()
181 def get_queryset(self):
182 self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
183 query = self.request.query_params.get('query', None)
184 if self.request.user.is_authenticated:
185 fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in
186 self.request.user.searchpreference.trigram.values_list(
187 'field', flat=True)])
188 else:
189 fuzzy = True
191 if query is not None and query not in ["''", '']:
192 if fuzzy and (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'):
193 if self.request.user.is_authenticated and any(
194 [self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
195 self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
196 else:
197 self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
198 self.queryset = self.queryset.order_by('-trigram')
199 else:
200 # TODO have this check unaccent search settings or other search preferences?
201 filter = Q(name__icontains=query)
202 if self.request.user.is_authenticated:
203 if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
204 filter |= Q(name__unaccent__icontains=query)
206 self.queryset = (
207 self.queryset.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
208 default=Value(0))) # put exact matches at the top of the result set
209 .filter(filter).order_by('-starts', Lower('name').asc())
210 )
212 updated_at = self.request.query_params.get('updated_at', None)
213 if updated_at is not None:
214 try:
215 self.queryset = self.queryset.filter(updated_at__gte=updated_at)
216 except FieldError:
217 pass
218 except ValidationError:
219 raise APIException(_('Parameter updated_at incorrectly formatted'))
221 limit = self.request.query_params.get('limit', None)
222 random = self.request.query_params.get('random', False)
223 if random:
224 self.queryset = self.queryset.order_by("?")
225 if limit is not None:
226 self.queryset = self.queryset[:int(limit)]
227 return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class)
230class MergeMixin(ViewSetMixin):
231 @decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
232 @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
233 def merge(self, request, pk, target):
234 self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]."
236 try:
237 source = self.model.objects.get(pk=pk, space=self.request.space)
238 except (self.model.DoesNotExist):
239 content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
240 return Response(content, status=status.HTTP_404_NOT_FOUND)
242 if int(target) == source.id:
243 content = {'error': True, 'msg': _('Cannot merge with the same object!')}
244 return Response(content, status=status.HTTP_403_FORBIDDEN)
246 else:
247 try:
248 target = self.model.objects.get(pk=target, space=self.request.space)
249 except (self.model.DoesNotExist):
250 content = {'error': True, 'msg': _(f'No {self.basename} with id {target} exists')}
251 return Response(content, status=status.HTTP_404_NOT_FOUND)
253 try:
254 if target in source.get_descendants_and_self():
255 content = {'error': True, 'msg': _('Cannot merge with child object!')}
256 return Response(content, status=status.HTTP_403_FORBIDDEN)
257 isTree = True
258 except AttributeError:
259 # AttributeError probably means its not a tree, so can safely ignore
260 isTree = False
262 try:
263 if isinstance(source, Food):
264 source.properties.remove()
266 for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
267 linkManager = getattr(source, link.get_accessor_name())
268 related = linkManager.all()
269 # link to foreign relationship could be OneToMany or ManyToMany
270 if link.one_to_many:
271 for r in related:
272 setattr(r, link.field.name, target)
273 r.save()
274 elif link.many_to_many:
275 for r in related:
276 getattr(r, link.field.name).add(target)
277 getattr(r, link.field.name).remove(source)
278 r.save()
279 else:
280 # a new scenario exists and needs to be handled
281 raise NotImplementedError
282 if isTree:
283 if self.model.node_order_by:
284 node_location = 'sorted-child'
285 else:
286 node_location = 'last-child'
288 children = source.get_children().exclude(id=target.id)
289 for c in children:
290 c.move(target, node_location)
291 content = {'msg': _(f'{source.name} was merged successfully with {target.name}')}
292 source.delete()
293 return Response(content, status=status.HTTP_200_OK)
294 except Exception:
295 traceback.print_exc()
296 content = {'error': True,
297 'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
298 return Response(content, status=status.HTTP_400_BAD_REQUEST)
301class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
302 schema = TreeSchema()
303 model = None
305 def get_queryset(self):
306 root = self.request.query_params.get('root', None)
307 tree = self.request.query_params.get('tree', None)
309 if root:
310 if root.isnumeric():
311 try:
312 root = int(root)
313 except ValueError:
314 self.queryset = self.model.objects.none()
316 if root == 0:
317 self.queryset = self.model.get_root_nodes()
318 else:
319 self.queryset = self.model.objects.get(id=root).get_children()
320 elif tree:
321 if tree.isnumeric():
322 try:
323 self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self()
324 except self.model.DoesNotExist:
325 self.queryset = self.model.objects.none()
326 else:
327 return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True)
328 self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
330 return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
331 tree=True)
333 @decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
334 @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
335 def move(self, request, pk, parent):
336 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."
337 if self.model.node_order_by:
338 node_location = 'sorted'
339 else:
340 node_location = 'last'
342 try:
343 child = self.model.objects.get(pk=pk, space=self.request.space)
344 except (self.model.DoesNotExist):
345 content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
346 return Response(content, status=status.HTTP_404_NOT_FOUND)
348 parent = int(parent)
349 # parent 0 is root of the tree
350 if parent == 0:
351 try:
352 with scopes_disabled():
353 child.move(self.model.get_first_root_node(), f'{node_location}-sibling')
354 content = {'msg': _(f'{child.name} was moved successfully to the root.')}
355 return Response(content, status=status.HTTP_200_OK)
356 except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
357 content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
358 return Response(content, status=status.HTTP_400_BAD_REQUEST)
359 elif parent == child.id:
360 content = {'error': True, 'msg': _('Cannot move an object to itself!')}
361 return Response(content, status=status.HTTP_403_FORBIDDEN)
363 try:
364 parent = self.model.objects.get(pk=parent, space=self.request.space)
365 except (self.model.DoesNotExist):
366 content = {'error': True, 'msg': _(f'No {self.basename} with id {parent} exists')}
367 return Response(content, status=status.HTTP_404_NOT_FOUND)
369 try:
370 with scopes_disabled():
371 child.move(parent, f'{node_location}-child')
372 content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')}
373 return Response(content, status=status.HTTP_200_OK)
374 except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
375 content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
376 return Response(content, status=status.HTTP_400_BAD_REQUEST)
379class UserViewSet(viewsets.ModelViewSet):
380 """
381 list:
382 optional parameters
384 - **filter_list**: array of user id's to get names for
385 """
386 queryset = User.objects
387 serializer_class = UserSerializer
388 permission_classes = [CustomUserPermission & CustomTokenHasReadWriteScope]
389 http_method_names = ['get', 'patch']
391 def get_queryset(self):
392 queryset = self.queryset.filter(userspace__space=self.request.space)
393 try:
394 filter_list = self.request.query_params.get('filter_list', None)
395 if filter_list is not None:
396 queryset = queryset.filter(pk__in=json.loads(filter_list))
397 except ValueError:
398 raise APIException('Parameter filter_list incorrectly formatted')
400 return queryset
403class GroupViewSet(viewsets.ModelViewSet):
404 queryset = Group.objects.all()
405 serializer_class = GroupSerializer
406 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
407 http_method_names = ['get', ]
410class SpaceViewSet(viewsets.ModelViewSet):
411 queryset = Space.objects
412 serializer_class = SpaceSerializer
413 permission_classes = [IsReadOnlyDRF & CustomIsUser | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
414 http_method_names = ['get', 'patch']
416 def get_queryset(self):
417 return self.queryset.filter(id=self.request.space.id)
420class UserSpaceViewSet(viewsets.ModelViewSet):
421 queryset = UserSpace.objects
422 serializer_class = UserSpaceSerializer
423 permission_classes = [(CustomIsSpaceOwner | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope]
424 http_method_names = ['get', 'patch', 'delete']
425 pagination_class = DefaultPagination
427 def destroy(self, request, *args, **kwargs):
428 if request.space.created_by == UserSpace.objects.get(pk=kwargs['pk']).user:
429 raise APIException('Cannot delete Space owner permission.')
430 return super().destroy(request, *args, **kwargs)
432 def get_queryset(self):
433 internal_note = self.request.query_params.get('internal_note', None)
434 if internal_note is not None:
435 self.queryset = self.queryset.filter(internal_note=internal_note)
437 if is_space_owner(self.request.user, self.request.space):
438 return self.queryset.filter(space=self.request.space)
439 else:
440 return self.queryset.filter(user=self.request.user, space=self.request.space)
443class UserPreferenceViewSet(viewsets.ModelViewSet):
444 queryset = UserPreference.objects
445 serializer_class = UserPreferenceSerializer
446 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
447 http_method_names = ['get', 'patch', ]
449 def get_queryset(self):
450 with scopes_disabled(): # need to disable scopes as user preference is no longer a spaced method
451 return self.queryset.filter(user=self.request.user)
454class StorageViewSet(viewsets.ModelViewSet):
455 # TODO handle delete protect error and adjust test
456 queryset = Storage.objects
457 serializer_class = StorageSerializer
458 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
460 def get_queryset(self):
461 return self.queryset.filter(space=self.request.space)
464class SyncViewSet(viewsets.ModelViewSet):
465 queryset = Sync.objects
466 serializer_class = SyncSerializer
467 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
469 def get_queryset(self):
470 return self.queryset.filter(space=self.request.space)
473class SyncLogViewSet(viewsets.ReadOnlyModelViewSet):
474 queryset = SyncLog.objects
475 serializer_class = SyncLogSerializer
476 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
477 pagination_class = DefaultPagination
479 def get_queryset(self):
480 return self.queryset.filter(sync__space=self.request.space)
483class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
484 queryset = Supermarket.objects
485 serializer_class = SupermarketSerializer
486 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
488 def get_queryset(self):
489 self.queryset = self.queryset.filter(space=self.request.space)
490 return super().get_queryset()
493class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
494 queryset = SupermarketCategory.objects
495 model = SupermarketCategory
496 serializer_class = SupermarketCategorySerializer
497 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
499 def get_queryset(self):
500 self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
501 return super().get_queryset()
504class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
505 queryset = SupermarketCategoryRelation.objects
506 serializer_class = SupermarketCategoryRelationSerializer
507 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
508 pagination_class = DefaultPagination
510 def get_queryset(self):
511 self.queryset = self.queryset.filter(supermarket__space=self.request.space).order_by('order')
512 return super().get_queryset()
515class KeywordViewSet(viewsets.ModelViewSet, TreeMixin):
516 queryset = Keyword.objects
517 model = Keyword
518 serializer_class = KeywordSerializer
519 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
520 pagination_class = DefaultPagination
523class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin):
524 queryset = Unit.objects
525 model = Unit
526 serializer_class = UnitSerializer
527 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
528 pagination_class = DefaultPagination
531class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet):
532 queryset = FoodInheritField.objects
533 serializer_class = FoodInheritFieldSerializer
534 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
536 def get_queryset(self):
537 # exclude fields not yet implemented
538 self.queryset = Food.inheritable_fields
539 return super().get_queryset()
542class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
543 queryset = Food.objects
544 model = Food
545 serializer_class = FoodSerializer
546 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
547 pagination_class = DefaultPagination
549 def get_queryset(self):
550 shared_users = []
551 if c := caches['default'].get(
552 f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', None):
553 shared_users = c
554 else:
555 try:
556 shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
557 self.request.user.id]
558 caches['default'].set(
559 f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}',
560 shared_users, timeout=5 * 60)
561 # TODO ugly hack that improves API performance significantly, should be done properly
562 except AttributeError: # Anonymous users (using share links) don't have shared users
563 pass
565 self.queryset = super().get_queryset()
567 return self.queryset \
568 .prefetch_related('onhand_users', 'inherit_fields', 'child_inherit_fields', 'substitute') \
569 .select_related('recipe', 'supermarket_category')
571 def get_serializer_class(self):
572 if self.request and self.request.query_params.get('simple', False):
573 return FoodSimpleSerializer
574 return self.serializer_class
576 @decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer, )
577 # TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
578 def shopping(self, request, pk):
579 if self.request.space.demo:
580 raise PermissionDenied(detail='Not available in demo', code=None)
581 obj = self.get_object()
582 shared_users = list(self.request.user.get_shopping_share())
583 shared_users.append(request.user)
584 if request.data.get('_delete', False) == 'true':
585 ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space,
586 created_by__in=shared_users).delete()
587 content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
588 return Response(content, status=status.HTTP_204_NO_CONTENT)
590 amount = request.data.get('amount', 1)
591 unit = request.data.get('unit', None)
592 content = {'msg': _(f'{obj.name} was added to the shopping list.')}
594 ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space,
595 created_by=request.user)
596 return Response(content, status=status.HTTP_204_NO_CONTENT)
598 @decorators.action(detail=True, methods=['POST'], )
599 def fdc(self, request, pk):
600 """
601 updates the food with all possible data from the FDC Api
602 if properties with a fdc_id already exist they will be overridden, if existing properties don't have a fdc_id they won't be changed
603 """
604 food = self.get_object()
606 response = requests.get(f'https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key={FDC_API_KEY}')
607 if response.status_code == 429:
608 return JsonResponse({'msg', 'API Key Rate Limit reached/exceeded, see https://api.data.gov/docs/rate-limits/ for more information. Configure your key in Tandoor using environment FDC_API_KEY variable.'}, status=429,
609 json_dumps_params={'indent': 4})
611 try:
612 data = json.loads(response.content)
614 food_property_list = []
616 # delete all properties where the property type has a fdc_id as these should be overridden
617 for fp in food.properties.all():
618 if fp.property_type.fdc_id:
619 fp.delete()
621 for pt in PropertyType.objects.filter(space=request.space, fdc_id__gte=0).all():
622 if pt.fdc_id:
623 for fn in data['foodNutrients']:
624 if fn['nutrient']['id'] == pt.fdc_id:
625 food_property_list.append(Property(
626 property_type_id=pt.id,
627 property_amount=round(fn['amount'], 2),
628 import_food_id=food.id,
629 space=self.request.space,
630 ))
632 Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
634 property_food_relation_list = []
635 for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ):
636 property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
638 FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
639 Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None)
641 return self.retrieve(request, pk)
642 except Exception:
643 traceback.print_exc()
644 return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'}, status=500, json_dumps_params={'indent': 4})
646 @decorators.action(detail=True, methods=['GET'], serializer_class=FoodSimpleSerializer, )
647 def substitutes(self, request, pk):
648 if self.request.space.demo:
649 raise PermissionDenied(detail='Not available in demo', code=None)
650 obj = self.get_object()
651 if obj.get_space() != request.space:
652 raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
654 onhand = str2bool(request.query_params.get('onhand', False))
655 shopping_users = None
656 if onhand:
657 shopping_users = [*request.user.get_shopping_share(), request.user]
658 qs = obj.get_substitutes(onhand=onhand, shopping_users=shopping_users)
659 return Response(self.serializer_class(qs, many=True).data)
661 def destroy(self, *args, **kwargs):
662 try:
663 return (super().destroy(self, *args, **kwargs))
664 except ProtectedError as e:
665 content = {'error': True, 'msg': e.args[0]}
666 return Response(content, status=status.HTTP_403_FORBIDDEN)
669class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
670 queryset = RecipeBook.objects
671 serializer_class = RecipeBookSerializer
672 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
674 def get_queryset(self):
675 self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
676 space=self.request.space).distinct()
677 return super().get_queryset()
680class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
681 """
682 list:
683 optional parameters
685 - **recipe**: id of recipe - only return books for that recipe
686 - **book**: id of book - only return recipes in that book
688 """
689 queryset = RecipeBookEntry.objects
690 serializer_class = RecipeBookEntrySerializer
691 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
693 def get_queryset(self):
694 queryset = self.queryset.filter(
695 Q(book__created_by=self.request.user) | Q(book__shared=self.request.user)).filter(
696 book__space=self.request.space).distinct()
698 recipe_id = self.request.query_params.get('recipe', None)
699 if recipe_id is not None:
700 queryset = queryset.filter(recipe__pk=recipe_id)
702 book_id = self.request.query_params.get('book', None)
703 if book_id is not None:
704 queryset = queryset.filter(book__pk=book_id)
705 return queryset
708class MealPlanViewSet(viewsets.ModelViewSet):
709 """
710 list:
711 optional parameters
713 - **from_date**: filter from (inclusive) a certain date onward
714 - **to_date**: filter upward to (inclusive) certain date
715 - **meal_type**: filter meal plans based on meal_type ID
717 """
718 queryset = MealPlan.objects
719 serializer_class = MealPlanSerializer
720 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
721 query_params = [
722 QueryParam(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
723 QueryParam(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
724 QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='int'),
725 ]
726 schema = QueryParamAutoSchema()
728 def get_queryset(self):
729 queryset = self.queryset.filter(
730 Q(created_by=self.request.user)
731 | Q(shared=self.request.user)
732 ).filter(space=self.request.space).distinct().all()
734 from_date = self.request.query_params.get('from_date', None)
735 if from_date is not None:
736 queryset = queryset.filter(to_date__gte=from_date)
738 to_date = self.request.query_params.get('to_date', None)
739 if to_date is not None:
740 queryset = queryset.filter(to_date__lte=to_date)
742 meal_type = self.request.query_params.getlist('meal_type', [])
743 if meal_type:
744 queryset = queryset.filter(meal_type__in=meal_type)
746 return queryset
749class AutoPlanViewSet(viewsets.ViewSet):
750 def create(self, request):
751 serializer = AutoMealPlanSerializer(data=request.data)
753 if serializer.is_valid():
754 keywords = serializer.validated_data['keywords']
755 start_date = serializer.validated_data['start_date']
756 end_date = serializer.validated_data['end_date']
757 servings = serializer.validated_data['servings']
758 shared = serializer.get_initial().get('shared', None)
759 shared_pks = list()
760 if shared is not None:
761 for i in range(len(shared)):
762 shared_pks.append(shared[i]['id'])
764 days = min((end_date - start_date).days + 1, 14)
766 recipes = Recipe.objects.values('id', 'name')
767 meal_plans = list()
769 for keyword in keywords:
770 recipes = recipes.filter(keywords__name=keyword['name'])
772 if len(recipes) == 0:
773 return Response(serializer.data)
774 recipes = list(recipes.order_by('?')[:days])
776 for i in range(0, days):
777 day = start_date + datetime.timedelta(i)
778 recipe = recipes[i % len(recipes)]
779 args = {'recipe_id': recipe['id'], 'servings': servings,
780 'created_by': request.user,
781 'meal_type_id': serializer.validated_data['meal_type_id'],
782 'note': '', 'from_date': day, 'to_date': day, 'space': request.space}
784 m = MealPlan(**args)
785 meal_plans.append(m)
787 MealPlan.objects.bulk_create(meal_plans)
789 for m in meal_plans:
790 m.shared.set(shared_pks)
792 if request.data.get('addshopping', False):
793 SLR = RecipeShoppingEditor(user=request.user, space=request.space)
794 SLR.create(mealplan=m, servings=servings)
796 else:
797 post_save.send(
798 sender=m.__class__,
799 instance=m,
800 created=True,
801 update_fields=None,
802 )
804 return Response(serializer.data)
806 return Response(serializer.errors, 400)
809class MealTypeViewSet(viewsets.ModelViewSet):
810 """
811 returns list of meal types created by the
812 requesting user ordered by the order field.
813 """
814 queryset = MealType.objects
815 serializer_class = MealTypeSerializer
816 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
818 def get_queryset(self):
819 queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter(
820 space=self.request.space).all()
821 return queryset
824class IngredientViewSet(viewsets.ModelViewSet):
825 queryset = Ingredient.objects
826 serializer_class = IngredientSerializer
827 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
828 pagination_class = DefaultPagination
830 def get_serializer_class(self):
831 if self.request and self.request.query_params.get('simple', False):
832 return IngredientSimpleSerializer
833 return self.serializer_class
835 def get_queryset(self):
836 queryset = self.queryset.filter(step__recipe__space=self.request.space)
837 food = self.request.query_params.get('food', None)
838 if food and re.match(r'^(\d)+$', food):
839 queryset = queryset.filter(food_id=food)
841 unit = self.request.query_params.get('unit', None)
842 if unit and re.match(r'^(\d)+$', unit):
843 queryset = queryset.filter(unit_id=unit)
845 return queryset.select_related('food')
848class StepViewSet(viewsets.ModelViewSet):
849 queryset = Step.objects
850 serializer_class = StepSerializer
851 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
852 pagination_class = DefaultPagination
853 query_params = [
854 QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
855 QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
856 ]
857 schema = QueryParamAutoSchema()
859 def get_queryset(self):
860 recipes = self.request.query_params.getlist('recipe', [])
861 query = self.request.query_params.get('query', None)
862 if len(recipes) > 0:
863 self.queryset = self.queryset.filter(recipe__in=recipes)
864 if query is not None:
865 self.queryset = self.queryset.filter(Q(name__icontains=query) | Q(recipe__name__icontains=query))
866 return self.queryset.filter(recipe__space=self.request.space)
869class RecipePagination(PageNumberPagination):
870 page_size = 25
871 page_size_query_param = 'page_size'
872 max_page_size = 100
874 def paginate_queryset(self, queryset, request, view=None):
875 if queryset is None:
876 raise Exception
877 return super().paginate_queryset(queryset, request, view)
879 def get_paginated_response(self, data):
880 return Response(OrderedDict([
881 ('count', self.page.paginator.count),
882 ('next', self.get_next_link()),
883 ('previous', self.get_previous_link()),
884 ('results', data),
885 ]))
888class RecipeViewSet(viewsets.ModelViewSet):
889 queryset = Recipe.objects
890 serializer_class = RecipeSerializer
891 # TODO split read and write permission for meal plan guest
892 permission_classes = [CustomRecipePermission & CustomTokenHasReadWriteScope]
893 pagination_class = RecipePagination
895 query_params = [
896 QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
897 QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'),
898 QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
899 QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'),
900 QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'),
901 QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'),
902 QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
903 QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
904 QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
905 QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
906 QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
907 QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
908 QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
909 QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
910 QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
911 QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
912 QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
913 QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
914 QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
915 QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
916 QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
917 QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
918 QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
919 QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
920 QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
921 QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
922 QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
923 QueryParam(name='never_used_food', description=_('Filter recipes that contain food that have never been used. [''true''/''<b>false</b>'']')),
924 ]
925 schema = QueryParamAutoSchema()
927 def get_queryset(self):
928 share = self.request.query_params.get('share', None)
930 if self.detail: # if detail request and not list, private condition is verified by permission class
931 if not share: # filter for space only if not shared
932 self.queryset = self.queryset.filter(space=self.request.space).prefetch_related(
933 'keywords',
934 'shared',
935 'properties',
936 'properties__property_type',
937 'steps',
938 'steps__ingredients',
939 'steps__ingredients__step_set',
940 'steps__ingredients__step_set__recipe_set',
941 'steps__ingredients__food',
942 'steps__ingredients__food__properties',
943 'steps__ingredients__food__properties__property_type',
944 'steps__ingredients__food__inherit_fields',
945 'steps__ingredients__food__supermarket_category',
946 'steps__ingredients__food__onhand_users',
947 'steps__ingredients__food__substitute',
948 'steps__ingredients__food__child_inherit_fields',
950 'steps__ingredients__unit',
951 'steps__ingredients__unit__unit_conversion_base_relation',
952 'steps__ingredients__unit__unit_conversion_base_relation__base_unit',
953 'steps__ingredients__unit__unit_conversion_converted_relation',
954 'steps__ingredients__unit__unit_conversion_converted_relation__converted_unit',
955 'cooklog_set',
956 ).select_related('nutrition')
958 return super().get_queryset()
960 self.queryset = self.queryset.filter(space=self.request.space).filter(
961 Q(private=False) | (Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user)))
962 )
964 params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x
965 in list(self.request.GET)}
966 search = RecipeSearch(self.request, **params)
967 self.queryset = search.get_queryset(self.queryset).prefetch_related('keywords', 'cooklog_set')
968 return self.queryset
970 def list(self, request, *args, **kwargs):
971 if self.request.GET.get('debug', False):
972 return JsonResponse({
973 'new': str(self.get_queryset().query),
974 })
975 return super().list(request, *args, **kwargs)
977 def get_serializer_class(self):
978 if self.action == 'list':
979 return RecipeOverviewSerializer
980 return self.serializer_class
982 @decorators.action(
983 detail=True,
984 methods=['PUT'],
985 serializer_class=RecipeImageSerializer,
986 parser_classes=[MultiPartParser],
987 )
988 def image(self, request, pk):
989 obj = self.get_object()
991 if obj.get_space() != request.space:
992 raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
994 serializer = self.serializer_class(obj, data=request.data, partial=True)
996 if serializer.is_valid():
997 serializer.save()
998 image = None
999 filetype = ".jpeg" # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways
1001 if 'image' in serializer.validated_data:
1002 image = obj.image
1003 filetype = mimetypes.guess_extension(serializer.validated_data['image'].content_type) or filetype
1004 elif 'image_url' in serializer.validated_data:
1005 try:
1006 url = serializer.validated_data['image_url']
1007 if validators.url(url, public=True):
1008 response = requests.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"})
1009 image = File(io.BytesIO(response.content))
1010 filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
1011 except UnidentifiedImageError as e:
1012 print(e)
1013 pass
1014 except MissingSchema as e:
1015 print(e)
1016 pass
1017 except Exception as e:
1018 print(e)
1019 pass
1021 if image is not None:
1022 img = handle_image(request, image, filetype)
1023 obj.image.save(f'{uuid.uuid4()}_{obj.pk}{filetype}', img)
1024 obj.save()
1025 return Response(serializer.data)
1026 else:
1027 obj.image = None
1028 obj.save()
1029 return Response(serializer.data)
1031 return Response(serializer.errors, 400)
1033 # TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating
1034 # DRF only allows one action in a decorator action without overriding get_operation_id_base()
1035 @decorators.action(
1036 detail=True,
1037 methods=['PUT'],
1038 serializer_class=RecipeShoppingUpdateSerializer,
1039 )
1040 def shopping(self, request, pk):
1041 if self.request.space.demo:
1042 raise PermissionDenied(detail='Not available in demo', code=None)
1043 obj = self.get_object()
1044 ingredients = request.data.get('ingredients', None)
1046 servings = request.data.get('servings', None)
1047 list_recipe = request.data.get('list_recipe', None)
1048 mealplan = request.data.get('mealplan', None)
1049 SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan, servings=servings)
1051 content = {'msg': _(f'{obj.name} was added to the shopping list.')}
1052 http_status = status.HTTP_204_NO_CONTENT
1053 if servings and servings <= 0:
1054 result = SLR.delete()
1055 elif list_recipe:
1056 result = SLR.edit(servings=servings, ingredients=ingredients)
1057 else:
1058 result = SLR.create(servings=servings, ingredients=ingredients)
1060 if not result:
1061 content = {'msg': ('An error occurred')}
1062 http_status = status.HTTP_500_INTERNAL_SERVER_ERROR
1063 else:
1064 content = {'msg': _(f'{obj.name} was added to the shopping list.')}
1065 http_status = status.HTTP_204_NO_CONTENT
1067 return Response(content, status=http_status)
1069 @decorators.action(
1070 detail=True,
1071 methods=['GET'],
1072 serializer_class=RecipeSimpleSerializer
1073 )
1074 def related(self, request, pk):
1075 obj = self.get_object()
1076 if obj.get_space() != request.space:
1077 raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
1078 try:
1079 levels = int(request.query_params.get('levels', 1))
1080 except (ValueError, TypeError):
1081 levels = 1
1082 qs = obj.get_related_recipes(
1083 levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
1084 return Response(self.serializer_class(qs, many=True).data)
1087class UnitConversionViewSet(viewsets.ModelViewSet):
1088 queryset = UnitConversion.objects
1089 serializer_class = UnitConversionSerializer
1090 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1091 query_params = [
1092 QueryParam(name='food_id', description='ID of food to filter for', qtype='int'),
1093 ]
1094 schema = QueryParamAutoSchema()
1096 def get_queryset(self):
1097 food_id = self.request.query_params.get('food_id', None)
1098 if food_id is not None:
1099 self.queryset = self.queryset.filter(food_id=food_id)
1101 return self.queryset.filter(space=self.request.space)
1104class PropertyTypeViewSet(viewsets.ModelViewSet):
1105 queryset = PropertyType.objects
1106 serializer_class = PropertyTypeSerializer
1107 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1109 def get_queryset(self):
1110 return self.queryset.filter(space=self.request.space)
1113class PropertyViewSet(viewsets.ModelViewSet):
1114 queryset = Property.objects
1115 serializer_class = PropertySerializer
1116 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1118 def get_queryset(self):
1119 return self.queryset.filter(space=self.request.space)
1122class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
1123 queryset = ShoppingListRecipe.objects
1124 serializer_class = ShoppingListRecipeSerializer
1125 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
1127 def get_queryset(self):
1128 self.queryset = self.queryset.filter(
1129 Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
1130 return self.queryset.filter(
1131 Q(shoppinglist__created_by=self.request.user)
1132 | Q(shoppinglist__shared=self.request.user)
1133 | Q(entries__created_by=self.request.user)
1134 | Q(entries__created_by__in=list(self.request.user.get_shopping_share()))
1135 ).distinct().all()
1138class ShoppingListEntryViewSet(viewsets.ModelViewSet):
1139 queryset = ShoppingListEntry.objects
1140 serializer_class = ShoppingListEntrySerializer
1141 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
1142 query_params = [
1143 QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
1144 QueryParam(name='checked', description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
1145 ),
1146 QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
1147 ]
1148 schema = QueryParamAutoSchema()
1150 def get_queryset(self):
1151 self.queryset = self.queryset.filter(space=self.request.space)
1153 self.queryset = self.queryset.filter(
1154 Q(created_by=self.request.user)
1155 | Q(shoppinglist__shared=self.request.user)
1156 | Q(created_by__in=list(self.request.user.get_shopping_share()))
1157 ).prefetch_related(
1158 'created_by',
1159 'food',
1160 'food__properties',
1161 'food__properties__property_type',
1162 'food__inherit_fields',
1163 'food__supermarket_category',
1164 'food__onhand_users',
1165 'food__substitute',
1166 'food__child_inherit_fields',
1168 'unit',
1169 'list_recipe',
1170 'list_recipe__mealplan',
1171 'list_recipe__mealplan__recipe',
1172 ).distinct().all()
1174 if pk := self.request.query_params.getlist('id', []):
1175 self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk])
1177 if 'checked' in self.request.query_params or 'recent' in self.request.query_params:
1178 return shopping_helper(self.queryset, self.request)
1180 # TODO once old shopping list is removed this needs updated to sharing users in preferences
1181 return self.queryset
1184# TODO deprecate
1185class ShoppingListViewSet(viewsets.ModelViewSet):
1186 queryset = ShoppingList.objects
1187 serializer_class = ShoppingListSerializer
1188 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
1190 def get_queryset(self):
1191 return self.queryset.filter(
1192 Q(created_by=self.request.user)
1193 | Q(shared=self.request.user)
1194 | Q(created_by__in=list(self.request.user.get_shopping_share()))
1195 ).filter(space=self.request.space).distinct()
1197 def get_serializer_class(self):
1198 try:
1199 autosync = self.request.query_params.get('autosync', False)
1200 if autosync:
1201 return ShoppingListAutoSyncSerializer
1202 except AttributeError: # Needed for the openapi schema to determine a serializer without a request
1203 pass
1204 return self.serializer_class
1207class ViewLogViewSet(viewsets.ModelViewSet):
1208 queryset = ViewLog.objects
1209 serializer_class = ViewLogSerializer
1210 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
1211 pagination_class = DefaultPagination
1213 def get_queryset(self):
1214 # working backwards from the test - this is supposed to be limited to user view logs only??
1215 return self.queryset.filter(created_by=self.request.user).filter(space=self.request.space)
1218class CookLogViewSet(viewsets.ModelViewSet):
1219 queryset = CookLog.objects
1220 serializer_class = CookLogSerializer
1221 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
1222 pagination_class = DefaultPagination
1224 def get_queryset(self):
1225 return self.queryset.filter(space=self.request.space)
1228class ImportLogViewSet(viewsets.ModelViewSet):
1229 queryset = ImportLog.objects
1230 serializer_class = ImportLogSerializer
1231 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1232 pagination_class = DefaultPagination
1234 def get_queryset(self):
1235 return self.queryset.filter(space=self.request.space)
1238class ExportLogViewSet(viewsets.ModelViewSet):
1239 queryset = ExportLog.objects
1240 serializer_class = ExportLogSerializer
1241 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1242 pagination_class = DefaultPagination
1244 def get_queryset(self):
1245 return self.queryset.filter(space=self.request.space)
1248class BookmarkletImportViewSet(viewsets.ModelViewSet):
1249 queryset = BookmarkletImport.objects
1250 serializer_class = BookmarkletImportSerializer
1251 permission_classes = [CustomIsUser & CustomTokenHasScope]
1252 required_scopes = ['bookmarklet']
1254 def get_serializer_class(self):
1255 if self.action == 'list':
1256 return BookmarkletImportListSerializer
1257 return self.serializer_class
1259 def get_queryset(self):
1260 return self.queryset.filter(space=self.request.space).all()
1263class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
1264 queryset = UserFile.objects
1265 serializer_class = UserFileSerializer
1266 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1267 parser_classes = [MultiPartParser]
1269 def get_queryset(self):
1270 self.queryset = self.queryset.filter(space=self.request.space).all()
1271 return super().get_queryset()
1274class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
1275 queryset = Automation.objects
1276 serializer_class = AutomationSerializer
1277 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1279 def get_queryset(self):
1280 self.queryset = self.queryset.filter(space=self.request.space).all()
1281 return super().get_queryset()
1284class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin):
1285 queryset = InviteLink.objects
1286 serializer_class = InviteLinkSerializer
1287 permission_classes = [CustomIsSpaceOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
1289 def get_queryset(self):
1291 internal_note = self.request.query_params.get('internal_note', None)
1292 if internal_note is not None:
1293 self.queryset = self.queryset.filter(internal_note=internal_note)
1295 if is_space_owner(self.request.user, self.request.space):
1296 self.queryset = self.queryset.filter(space=self.request.space).all()
1297 return super().get_queryset()
1298 else:
1299 return None
1302class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
1303 queryset = CustomFilter.objects
1304 serializer_class = CustomFilterSerializer
1305 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
1307 def get_queryset(self):
1308 self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
1309 space=self.request.space).distinct()
1310 return super().get_queryset()
1313class AccessTokenViewSet(viewsets.ModelViewSet):
1314 queryset = AccessToken.objects
1315 serializer_class = AccessTokenSerializer
1316 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
1318 def get_queryset(self):
1319 return self.queryset.filter(user=self.request.user)
1322# -------------- DRF custom views --------------------
1324class AuthTokenThrottle(AnonRateThrottle):
1325 rate = '10/day'
1328class RecipeImportThrottle(UserRateThrottle):
1329 rate = DRF_THROTTLE_RECIPE_URL_IMPORT
1332class CustomAuthToken(ObtainAuthToken):
1333 throttle_classes = [AuthTokenThrottle]
1335 def post(self, request, *args, **kwargs):
1336 serializer = self.serializer_class(data=request.data, context={'request': request})
1337 serializer.is_valid(raise_exception=True)
1338 user = serializer.validated_data['user']
1339 if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(scope__contains='write').first():
1340 access_token = token
1341 else:
1342 access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}',
1343 expires=(timezone.now() + timezone.timedelta(days=365 * 5)),
1344 scope='read write app')
1345 return Response({
1346 'id': access_token.id,
1347 'token': access_token.token,
1348 'scope': access_token.scope,
1349 'expires': access_token.expires,
1350 'user_id': access_token.user.pk,
1351 'test': user.pk
1352 })
1355class RecipeUrlImportView(ObtainAuthToken, viewsets.ModelViewSet):
1356 throttle_classes = [RecipeImportThrottle]
1357 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
1359 def post(self, request, *args, **kwargs):
1360 """
1361 function to retrieve a recipe from a given url or source string
1362 :param request: standard request with additional post parameters
1363 - url: url to use for importing recipe
1364 - data: if no url is given recipe is imported from provided source data
1365 - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes
1366 :return: JsonResponse containing the parsed json and images
1367 """
1368 scrape = None
1369 serializer = RecipeFromSourceSerializer(data=request.data)
1370 if serializer.is_valid():
1372 if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (
1373 bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
1374 serializer.validated_data['url'] = bookmarklet.url
1375 serializer.validated_data['data'] = bookmarklet.html
1376 bookmarklet.delete()
1378 url = serializer.validated_data.get('url', None)
1379 data = unquote(serializer.validated_data.get('data', None))
1380 if not url and not data:
1381 return Response({
1382 'error': True,
1383 'msg': _('Nothing to do.')
1384 }, status=status.HTTP_400_BAD_REQUEST)
1386 elif url and not data:
1387 if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
1388 if validators.url(url, public=True):
1389 return Response({
1390 'recipe_json': get_from_youtube_scraper(url, request),
1391 'recipe_images': [],
1392 }, status=status.HTTP_200_OK)
1393 if re.match(
1394 '^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
1395 url):
1396 recipe_json = requests.get(
1397 url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1],
1398 '') + '?share=' +
1399 re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
1400 recipe_json = clean_dict(recipe_json, 'id')
1401 serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
1402 if serialized_recipe.is_valid():
1403 recipe = serialized_recipe.save()
1404 if validators.url(recipe_json['image'], public=True):
1405 recipe.image = File(handle_image(request,
1406 File(io.BytesIO(requests.get(recipe_json['image']).content),
1407 name='image'),
1408 filetype=pathlib.Path(recipe_json['image']).suffix),
1409 name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
1410 recipe.save()
1411 return Response({
1412 'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk}))
1413 }, status=status.HTTP_201_CREATED)
1414 else:
1415 try:
1416 if validators.url(url, public=True):
1417 scrape = scrape_me(url_path=url, wild_mode=True)
1419 else:
1420 return Response({
1421 'error': True,
1422 'msg': _('Invalid Url')
1423 }, status=status.HTTP_400_BAD_REQUEST)
1424 except NoSchemaFoundInWildMode:
1425 pass
1426 except requests.exceptions.ConnectionError:
1427 return Response({
1428 'error': True,
1429 'msg': _('Connection Refused.')
1430 }, status=status.HTTP_400_BAD_REQUEST)
1431 except requests.exceptions.MissingSchema:
1432 return Response({
1433 'error': True,
1434 'msg': _('Bad URL Schema.')
1435 }, status=status.HTTP_400_BAD_REQUEST)
1436 else:
1437 try:
1438 data_json = json.loads(data)
1439 if '@context' not in data_json:
1440 data_json['@context'] = 'https://schema.org'
1441 if '@type' not in data_json:
1442 data_json['@type'] = 'Recipe'
1443 data = "<script type='application/ld+json'>" + json.dumps(data_json) + "</script>"
1444 except JSONDecodeError:
1445 pass
1446 scrape = text_scraper(text=data, url=url)
1447 if not url and (found_url := scrape.schema.data.get('url', None)):
1448 scrape = text_scraper(text=data, url=found_url)
1450 if scrape:
1451 return Response({
1452 'recipe_json': helper.get_from_scraper(scrape, request),
1453 'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))),
1454 }, status=status.HTTP_200_OK)
1456 else:
1457 return Response({
1458 'error': True,
1459 'msg': _('No usable data could be found.')
1460 }, status=status.HTTP_400_BAD_REQUEST)
1461 else:
1462 return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
1465@api_view(['GET'])
1466# @schema(AutoSchema()) #TODO add proper schema
1467@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope])
1468# TODO add rate limiting
1469def reset_food_inheritance(request):
1470 """
1471 function to reset inheritance from api, see food method for docs
1472 """
1473 try:
1474 Food.reset_inheritance(space=request.space)
1475 return Response({'message': 'success', }, status=status.HTTP_200_OK)
1476 except Exception:
1477 traceback.print_exc()
1478 return Response({}, status=status.HTTP_400_BAD_REQUEST)
1481@api_view(['GET'])
1482# @schema(AutoSchema()) #TODO add proper schema
1483@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope])
1484# TODO add rate limiting
1485def switch_active_space(request, space_id):
1486 """
1487 api endpoint to switch space function
1488 """
1489 try:
1490 space = get_object_or_404(Space, id=space_id)
1491 user_space = switch_user_active_space(request.user, space)
1492 if user_space:
1493 return Response(UserSpaceSerializer().to_representation(instance=user_space), status=status.HTTP_200_OK)
1494 else:
1495 return Response("not found", status=status.HTTP_404_NOT_FOUND)
1496 except Exception:
1497 traceback.print_exc()
1498 return Response({}, status=status.HTTP_400_BAD_REQUEST)
1501@api_view(['GET'])
1502# @schema(AutoSchema()) #TODO add proper schema
1503@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
1504def download_file(request, file_id):
1505 """
1506 function to download a user file securely (wrapping as zip to prevent any context based XSS problems)
1507 temporary solution until a real file manager is implemented
1508 """
1509 try:
1510 uf = UserFile.objects.get(space=request.space, pk=file_id)
1512 in_memory = io.BytesIO()
1513 zf = ZipFile(in_memory, mode="w")
1514 zf.writestr(uf.file.name, uf.file.file.read())
1515 zf.close()
1517 response = HttpResponse(in_memory.getvalue(), content_type='application/force-download')
1518 response['Content-Disposition'] = 'attachment; filename="' + uf.name + '.zip"'
1519 return response
1521 except Exception:
1522 traceback.print_exc()
1523 return Response({}, status=status.HTTP_400_BAD_REQUEST)
1526@api_view(['POST'])
1527# @schema(AutoSchema()) #TODO add proper schema
1528@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
1529def import_files(request):
1530 """
1531 function to handle files passed by application importer
1532 """
1533 limit, msg = above_space_limit(request.space)
1534 if limit:
1535 return Response({'error': True, 'msg': _('File is above space limit')}, status=status.HTTP_400_BAD_REQUEST)
1537 form = ImportForm(request.POST, request.FILES)
1538 if form.is_valid() and request.FILES != {}:
1539 try:
1540 integration = get_integration(request, form.cleaned_data['type'])
1542 il = ImportLog.objects.create(type=form.cleaned_data['type'], created_by=request.user, space=request.space)
1543 files = []
1544 for f in request.FILES.getlist('files'):
1545 files.append({'file': io.BytesIO(f.read()), 'name': f.name})
1546 t = threading.Thread(target=integration.do_import, args=[files, il, form.cleaned_data['duplicates']])
1547 t.setDaemon(True)
1548 t.start()
1550 return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
1551 except NotImplementedError:
1552 return Response({'error': True, 'msg': _('Importing is not implemented for this provider')},
1553 status=status.HTTP_400_BAD_REQUEST)
1554 else:
1555 return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
1558class ImportOpenData(APIView):
1559 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
1561 def get(self, request, format=None):
1562 response = requests.get('https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/meta.json')
1563 metadata = json.loads(response.content)
1564 return Response(metadata)
1566 def post(self, request, *args, **kwargs):
1567 # TODO validate data
1568 print(request.data)
1569 selected_version = request.data['selected_version']
1570 update_existing = str2bool(request.data['update_existing'])
1571 use_metric = str2bool(request.data['use_metric'])
1573 response = requests.get(f'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/{selected_version}.json') # TODO catch 404, timeout, ...
1574 data = json.loads(response.content)
1576 response_obj = {}
1578 data_importer = OpenDataImporter(request, data, update_existing=update_existing, use_metric=use_metric)
1579 response_obj['unit'] = len(data_importer.import_units())
1580 response_obj['category'] = len(data_importer.import_category())
1581 response_obj['property'] = len(data_importer.import_property())
1582 response_obj['store'] = len(data_importer.import_supermarket())
1583 response_obj['food'] = len(data_importer.import_food())
1584 response_obj['conversion'] = len(data_importer.import_conversion())
1586 return Response(response_obj)
1589def get_recipe_provider(recipe):
1590 if recipe.storage.method == Storage.DROPBOX:
1591 return Dropbox
1592 elif recipe.storage.method == Storage.NEXTCLOUD:
1593 return Nextcloud
1594 elif recipe.storage.method == Storage.LOCAL:
1595 return Local
1596 else:
1597 raise Exception('Provider not implemented')
1600def update_recipe_links(recipe):
1601 if not recipe.link:
1602 # TODO response validation in apis
1603 recipe.link = get_recipe_provider(recipe).get_share_link(recipe)
1605 recipe.save()
1608@group_required('user')
1609def get_external_file_link(request, recipe_id):
1610 recipe = get_object_or_404(Recipe, pk=recipe_id, space=request.space)
1611 if not recipe.link:
1612 update_recipe_links(recipe)
1614 return HttpResponse(recipe.link)
1617@group_required('guest')
1618def get_recipe_file(request, recipe_id):
1619 recipe = get_object_or_404(Recipe, pk=recipe_id, space=request.space)
1620 if recipe.storage:
1621 return FileResponse(get_recipe_provider(recipe).get_file(recipe))
1622 else:
1623 return FileResponse()
1626@group_required('user')
1627def sync_all(request):
1628 if request.space.demo or settings.HOSTED:
1629 messages.add_message(request, messages.ERROR,
1630 _('This feature is not yet available in the hosted version of tandoor!'))
1631 return redirect('index')
1633 monitors = Sync.objects.filter(active=True).filter(space=request.user.userspace_set.filter(active=1).first().space)
1635 error = False
1636 for monitor in monitors:
1637 if monitor.storage.method == Storage.DROPBOX:
1638 ret = Dropbox.import_all(monitor)
1639 if not ret:
1640 error = True
1641 if monitor.storage.method == Storage.NEXTCLOUD:
1642 ret = Nextcloud.import_all(monitor)
1643 if not ret:
1644 error = True
1645 if monitor.storage.method == Storage.LOCAL:
1646 ret = Local.import_all(monitor)
1647 if not ret:
1648 error = True
1650 if not error:
1651 messages.add_message(
1652 request, messages.SUCCESS, _('Sync successful!')
1653 )
1654 return redirect('list_recipe_import')
1655 else:
1656 messages.add_message(
1657 request, messages.ERROR, _('Error synchronizing with Storage')
1658 )
1659 return redirect('list_recipe_import')
1662@api_view(['GET'])
1663# @schema(AutoSchema()) #TODO add proper schema
1664@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
1665def share_link(request, pk):
1666 if request.space.allow_sharing and has_group_permission(request.user, ('user',)):
1667 recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
1668 link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space)
1669 return JsonResponse({'pk': pk, 'share': link.uuid,
1670 'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
1671 else:
1672 return JsonResponse({'error': 'sharing_disabled'}, status=403)
1675@group_required('user')
1676@ajax_request
1677def log_cooking(request, recipe_id):
1678 recipe = get_object_or_None(Recipe, id=recipe_id)
1679 if recipe:
1680 log = CookLog.objects.create(created_by=request.user, recipe=recipe, space=request.space)
1681 servings = request.GET['s'] if 's' in request.GET else None
1682 if servings and re.match(r'^([1-9])+$', servings):
1683 log.servings = int(servings)
1685 rating = request.GET['r'] if 'r' in request.GET else None
1686 if rating and re.match(r'^([1-9])+$', rating):
1687 log.rating = int(rating)
1688 log.save()
1689 return {'msg': 'updated successfully'}
1691 return {'error': 'recipe does not exist'}
1694@group_required('user')
1695def get_plan_ical(request, from_date, to_date):
1696 queryset = MealPlan.objects.filter(
1697 Q(created_by=request.user) | Q(shared=request.user)
1698 ).filter(space=request.user.userspace_set.filter(active=1).first().space).distinct().all()
1700 if from_date is not None:
1701 queryset = queryset.filter(date__gte=from_date)
1703 if to_date is not None:
1704 queryset = queryset.filter(date__lte=to_date)
1706 cal = Calendar()
1708 for p in queryset:
1709 event = Event()
1710 event['uid'] = p.id
1711 event.add('dtstart', p.from_date)
1712 if p.to_date:
1713 event.add('dtend', p.to_date)
1714 else:
1715 event.add('dtend', p.from_date)
1716 event['summary'] = f'{p.meal_type.name}: {p.get_label()}'
1717 event['description'] = p.note
1718 cal.add_component(event)
1720 response = FileResponse(io.BytesIO(cal.to_ical()))
1721 response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics' # noqa: E501
1723 return response
1726@group_required('admin')
1727def get_backup(request):
1728 if not request.user.is_superuser:
1729 return HttpResponse('', status=403)
1732@group_required('user')
1733def ingredient_from_string(request):
1734 text = request.POST['text']
1736 ingredient_parser = IngredientParser(request, False)
1737 amount, unit, food, note = ingredient_parser.parse(text)
1739 return JsonResponse(
1740 {
1741 'amount': amount,
1742 'unit': unit,
1743 'food': food,
1744 'note': note
1745 },
1746 status=200
1747 )