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

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 

14 

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 

54 

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 

110 

111 

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) 

118 

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')) 

127 

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 

136 

137 

138class DefaultPagination(PageNumberPagination): 

139 page_size = 50 

140 page_size_query_param = 'page_size' 

141 max_page_size = 200 

142 

143 

144class ExtendedRecipeMixin(): 

145 ''' 

146 ExtendedRecipe annotates a queryset with recipe_image and recipe_count values 

147 ''' 

148 

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 

156 

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)) 

161 

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 

176 

177 

178class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin): 

179 schema = FilterSchema() 

180 

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 

190 

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) 

205 

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 ) 

211 

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')) 

220 

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) 

228 

229 

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]." 

235 

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) 

241 

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) 

245 

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) 

252 

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 

261 

262 try: 

263 if isinstance(source, Food): 

264 source.properties.remove() 

265 

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' 

287 

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) 

299 

300 

301class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin): 

302 schema = TreeSchema() 

303 model = None 

304 

305 def get_queryset(self): 

306 root = self.request.query_params.get('root', None) 

307 tree = self.request.query_params.get('tree', None) 

308 

309 if root: 

310 if root.isnumeric(): 

311 try: 

312 root = int(root) 

313 except ValueError: 

314 self.queryset = self.model.objects.none() 

315 

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()) 

329 

330 return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, 

331 tree=True) 

332 

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' 

341 

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) 

347 

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) 

362 

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) 

368 

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) 

377 

378 

379class UserViewSet(viewsets.ModelViewSet): 

380 """ 

381 list: 

382 optional parameters 

383 

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'] 

390 

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') 

399 

400 return queryset 

401 

402 

403class GroupViewSet(viewsets.ModelViewSet): 

404 queryset = Group.objects.all() 

405 serializer_class = GroupSerializer 

406 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

407 http_method_names = ['get', ] 

408 

409 

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'] 

415 

416 def get_queryset(self): 

417 return self.queryset.filter(id=self.request.space.id) 

418 

419 

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 

426 

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) 

431 

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) 

436 

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) 

441 

442 

443class UserPreferenceViewSet(viewsets.ModelViewSet): 

444 queryset = UserPreference.objects 

445 serializer_class = UserPreferenceSerializer 

446 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

447 http_method_names = ['get', 'patch', ] 

448 

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) 

452 

453 

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] 

459 

460 def get_queryset(self): 

461 return self.queryset.filter(space=self.request.space) 

462 

463 

464class SyncViewSet(viewsets.ModelViewSet): 

465 queryset = Sync.objects 

466 serializer_class = SyncSerializer 

467 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

468 

469 def get_queryset(self): 

470 return self.queryset.filter(space=self.request.space) 

471 

472 

473class SyncLogViewSet(viewsets.ReadOnlyModelViewSet): 

474 queryset = SyncLog.objects 

475 serializer_class = SyncLogSerializer 

476 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

477 pagination_class = DefaultPagination 

478 

479 def get_queryset(self): 

480 return self.queryset.filter(sync__space=self.request.space) 

481 

482 

483class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

484 queryset = Supermarket.objects 

485 serializer_class = SupermarketSerializer 

486 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

487 

488 def get_queryset(self): 

489 self.queryset = self.queryset.filter(space=self.request.space) 

490 return super().get_queryset() 

491 

492 

493class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): 

494 queryset = SupermarketCategory.objects 

495 model = SupermarketCategory 

496 serializer_class = SupermarketCategorySerializer 

497 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

498 

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() 

502 

503 

504class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

505 queryset = SupermarketCategoryRelation.objects 

506 serializer_class = SupermarketCategoryRelationSerializer 

507 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

508 pagination_class = DefaultPagination 

509 

510 def get_queryset(self): 

511 self.queryset = self.queryset.filter(supermarket__space=self.request.space).order_by('order') 

512 return super().get_queryset() 

513 

514 

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 

521 

522 

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 

529 

530 

531class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet): 

532 queryset = FoodInheritField.objects 

533 serializer_class = FoodInheritFieldSerializer 

534 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

535 

536 def get_queryset(self): 

537 # exclude fields not yet implemented 

538 self.queryset = Food.inheritable_fields 

539 return super().get_queryset() 

540 

541 

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 

548 

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 

564 

565 self.queryset = super().get_queryset() 

566 

567 return self.queryset \ 

568 .prefetch_related('onhand_users', 'inherit_fields', 'child_inherit_fields', 'substitute') \ 

569 .select_related('recipe', 'supermarket_category') 

570 

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 

575 

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) 

589 

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.')} 

593 

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) 

597 

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() 

605 

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}) 

610 

611 try: 

612 data = json.loads(response.content) 

613 

614 food_property_list = [] 

615 

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() 

620 

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 )) 

631 

632 Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',)) 

633 

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])) 

637 

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) 

640 

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}) 

645 

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) 

653 

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) 

660 

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) 

667 

668 

669class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

670 queryset = RecipeBook.objects 

671 serializer_class = RecipeBookSerializer 

672 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

673 

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() 

678 

679 

680class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet): 

681 """ 

682 list: 

683 optional parameters 

684 

685 - **recipe**: id of recipe - only return books for that recipe 

686 - **book**: id of book - only return recipes in that book 

687 

688 """ 

689 queryset = RecipeBookEntry.objects 

690 serializer_class = RecipeBookEntrySerializer 

691 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

692 

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() 

697 

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) 

701 

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 

706 

707 

708class MealPlanViewSet(viewsets.ModelViewSet): 

709 """ 

710 list: 

711 optional parameters 

712 

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 

716 

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() 

727 

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() 

733 

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) 

737 

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) 

741 

742 meal_type = self.request.query_params.getlist('meal_type', []) 

743 if meal_type: 

744 queryset = queryset.filter(meal_type__in=meal_type) 

745 

746 return queryset 

747 

748 

749class AutoPlanViewSet(viewsets.ViewSet): 

750 def create(self, request): 

751 serializer = AutoMealPlanSerializer(data=request.data) 

752 

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']) 

763 

764 days = min((end_date - start_date).days + 1, 14) 

765 

766 recipes = Recipe.objects.values('id', 'name') 

767 meal_plans = list() 

768 

769 for keyword in keywords: 

770 recipes = recipes.filter(keywords__name=keyword['name']) 

771 

772 if len(recipes) == 0: 

773 return Response(serializer.data) 

774 recipes = list(recipes.order_by('?')[:days]) 

775 

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} 

783 

784 m = MealPlan(**args) 

785 meal_plans.append(m) 

786 

787 MealPlan.objects.bulk_create(meal_plans) 

788 

789 for m in meal_plans: 

790 m.shared.set(shared_pks) 

791 

792 if request.data.get('addshopping', False): 

793 SLR = RecipeShoppingEditor(user=request.user, space=request.space) 

794 SLR.create(mealplan=m, servings=servings) 

795 

796 else: 

797 post_save.send( 

798 sender=m.__class__, 

799 instance=m, 

800 created=True, 

801 update_fields=None, 

802 ) 

803 

804 return Response(serializer.data) 

805 

806 return Response(serializer.errors, 400) 

807 

808 

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] 

817 

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 

822 

823 

824class IngredientViewSet(viewsets.ModelViewSet): 

825 queryset = Ingredient.objects 

826 serializer_class = IngredientSerializer 

827 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

828 pagination_class = DefaultPagination 

829 

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 

834 

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) 

840 

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) 

844 

845 return queryset.select_related('food') 

846 

847 

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() 

858 

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) 

867 

868 

869class RecipePagination(PageNumberPagination): 

870 page_size = 25 

871 page_size_query_param = 'page_size' 

872 max_page_size = 100 

873 

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) 

878 

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 ])) 

886 

887 

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 

894 

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() 

926 

927 def get_queryset(self): 

928 share = self.request.query_params.get('share', None) 

929 

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', 

949 

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') 

957 

958 return super().get_queryset() 

959 

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 ) 

963 

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 

969 

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) 

976 

977 def get_serializer_class(self): 

978 if self.action == 'list': 

979 return RecipeOverviewSerializer 

980 return self.serializer_class 

981 

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() 

990 

991 if obj.get_space() != request.space: 

992 raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403) 

993 

994 serializer = self.serializer_class(obj, data=request.data, partial=True) 

995 

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 

1000 

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 

1020 

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) 

1030 

1031 return Response(serializer.errors, 400) 

1032 

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) 

1045 

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) 

1050 

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) 

1059 

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 

1066 

1067 return Response(content, status=http_status) 

1068 

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) 

1085 

1086 

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() 

1095 

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) 

1100 

1101 return self.queryset.filter(space=self.request.space) 

1102 

1103 

1104class PropertyTypeViewSet(viewsets.ModelViewSet): 

1105 queryset = PropertyType.objects 

1106 serializer_class = PropertyTypeSerializer 

1107 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1108 

1109 def get_queryset(self): 

1110 return self.queryset.filter(space=self.request.space) 

1111 

1112 

1113class PropertyViewSet(viewsets.ModelViewSet): 

1114 queryset = Property.objects 

1115 serializer_class = PropertySerializer 

1116 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1117 

1118 def get_queryset(self): 

1119 return self.queryset.filter(space=self.request.space) 

1120 

1121 

1122class ShoppingListRecipeViewSet(viewsets.ModelViewSet): 

1123 queryset = ShoppingListRecipe.objects 

1124 serializer_class = ShoppingListRecipeSerializer 

1125 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

1126 

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() 

1136 

1137 

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() 

1149 

1150 def get_queryset(self): 

1151 self.queryset = self.queryset.filter(space=self.request.space) 

1152 

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', 

1167 

1168 'unit', 

1169 'list_recipe', 

1170 'list_recipe__mealplan', 

1171 'list_recipe__mealplan__recipe', 

1172 ).distinct().all() 

1173 

1174 if pk := self.request.query_params.getlist('id', []): 

1175 self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk]) 

1176 

1177 if 'checked' in self.request.query_params or 'recent' in self.request.query_params: 

1178 return shopping_helper(self.queryset, self.request) 

1179 

1180 # TODO once old shopping list is removed this needs updated to sharing users in preferences 

1181 return self.queryset 

1182 

1183 

1184# TODO deprecate 

1185class ShoppingListViewSet(viewsets.ModelViewSet): 

1186 queryset = ShoppingList.objects 

1187 serializer_class = ShoppingListSerializer 

1188 permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] 

1189 

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() 

1196 

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 

1205 

1206 

1207class ViewLogViewSet(viewsets.ModelViewSet): 

1208 queryset = ViewLog.objects 

1209 serializer_class = ViewLogSerializer 

1210 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

1211 pagination_class = DefaultPagination 

1212 

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) 

1216 

1217 

1218class CookLogViewSet(viewsets.ModelViewSet): 

1219 queryset = CookLog.objects 

1220 serializer_class = CookLogSerializer 

1221 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

1222 pagination_class = DefaultPagination 

1223 

1224 def get_queryset(self): 

1225 return self.queryset.filter(space=self.request.space) 

1226 

1227 

1228class ImportLogViewSet(viewsets.ModelViewSet): 

1229 queryset = ImportLog.objects 

1230 serializer_class = ImportLogSerializer 

1231 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1232 pagination_class = DefaultPagination 

1233 

1234 def get_queryset(self): 

1235 return self.queryset.filter(space=self.request.space) 

1236 

1237 

1238class ExportLogViewSet(viewsets.ModelViewSet): 

1239 queryset = ExportLog.objects 

1240 serializer_class = ExportLogSerializer 

1241 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1242 pagination_class = DefaultPagination 

1243 

1244 def get_queryset(self): 

1245 return self.queryset.filter(space=self.request.space) 

1246 

1247 

1248class BookmarkletImportViewSet(viewsets.ModelViewSet): 

1249 queryset = BookmarkletImport.objects 

1250 serializer_class = BookmarkletImportSerializer 

1251 permission_classes = [CustomIsUser & CustomTokenHasScope] 

1252 required_scopes = ['bookmarklet'] 

1253 

1254 def get_serializer_class(self): 

1255 if self.action == 'list': 

1256 return BookmarkletImportListSerializer 

1257 return self.serializer_class 

1258 

1259 def get_queryset(self): 

1260 return self.queryset.filter(space=self.request.space).all() 

1261 

1262 

1263class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

1264 queryset = UserFile.objects 

1265 serializer_class = UserFileSerializer 

1266 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1267 parser_classes = [MultiPartParser] 

1268 

1269 def get_queryset(self): 

1270 self.queryset = self.queryset.filter(space=self.request.space).all() 

1271 return super().get_queryset() 

1272 

1273 

1274class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

1275 queryset = Automation.objects 

1276 serializer_class = AutomationSerializer 

1277 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1278 

1279 def get_queryset(self): 

1280 self.queryset = self.queryset.filter(space=self.request.space).all() 

1281 return super().get_queryset() 

1282 

1283 

1284class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

1285 queryset = InviteLink.objects 

1286 serializer_class = InviteLinkSerializer 

1287 permission_classes = [CustomIsSpaceOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] 

1288 

1289 def get_queryset(self): 

1290 

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) 

1294 

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 

1300 

1301 

1302class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin): 

1303 queryset = CustomFilter.objects 

1304 serializer_class = CustomFilterSerializer 

1305 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

1306 

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() 

1311 

1312 

1313class AccessTokenViewSet(viewsets.ModelViewSet): 

1314 queryset = AccessToken.objects 

1315 serializer_class = AccessTokenSerializer 

1316 permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope] 

1317 

1318 def get_queryset(self): 

1319 return self.queryset.filter(user=self.request.user) 

1320 

1321 

1322# -------------- DRF custom views -------------------- 

1323 

1324class AuthTokenThrottle(AnonRateThrottle): 

1325 rate = '10/day' 

1326 

1327 

1328class RecipeImportThrottle(UserRateThrottle): 

1329 rate = DRF_THROTTLE_RECIPE_URL_IMPORT 

1330 

1331 

1332class CustomAuthToken(ObtainAuthToken): 

1333 throttle_classes = [AuthTokenThrottle] 

1334 

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 }) 

1353 

1354 

1355class RecipeUrlImportView(ObtainAuthToken, viewsets.ModelViewSet): 

1356 throttle_classes = [RecipeImportThrottle] 

1357 permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] 

1358 

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(): 

1371 

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() 

1377 

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) 

1385 

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) 

1418 

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) 

1449 

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) 

1455 

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) 

1463 

1464 

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) 

1479 

1480 

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) 

1499 

1500 

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) 

1511 

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() 

1516 

1517 response = HttpResponse(in_memory.getvalue(), content_type='application/force-download') 

1518 response['Content-Disposition'] = 'attachment; filename="' + uf.name + '.zip"' 

1519 return response 

1520 

1521 except Exception: 

1522 traceback.print_exc() 

1523 return Response({}, status=status.HTTP_400_BAD_REQUEST) 

1524 

1525 

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) 

1536 

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']) 

1541 

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() 

1549 

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) 

1556 

1557 

1558class ImportOpenData(APIView): 

1559 permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] 

1560 

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) 

1565 

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']) 

1572 

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) 

1575 

1576 response_obj = {} 

1577 

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()) 

1585 

1586 return Response(response_obj) 

1587 

1588 

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') 

1598 

1599 

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) 

1604 

1605 recipe.save() 

1606 

1607 

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) 

1613 

1614 return HttpResponse(recipe.link) 

1615 

1616 

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() 

1624 

1625 

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') 

1632 

1633 monitors = Sync.objects.filter(active=True).filter(space=request.user.userspace_set.filter(active=1).first().space) 

1634 

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 

1649 

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') 

1660 

1661 

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) 

1673 

1674 

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) 

1684 

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'} 

1690 

1691 return {'error': 'recipe does not exist'} 

1692 

1693 

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() 

1699 

1700 if from_date is not None: 

1701 queryset = queryset.filter(date__gte=from_date) 

1702 

1703 if to_date is not None: 

1704 queryset = queryset.filter(date__lte=to_date) 

1705 

1706 cal = Calendar() 

1707 

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) 

1719 

1720 response = FileResponse(io.BytesIO(cal.to_ical())) 

1721 response["Content-Disposition"] = f'attachment; filename=meal_plan_{from_date}-{to_date}.ics' # noqa: E501 

1722 

1723 return response 

1724 

1725 

1726@group_required('admin') 

1727def get_backup(request): 

1728 if not request.user.is_superuser: 

1729 return HttpResponse('', status=403) 

1730 

1731 

1732@group_required('user') 

1733def ingredient_from_string(request): 

1734 text = request.POST['text'] 

1735 

1736 ingredient_parser = IngredientParser(request, False) 

1737 amount, unit, food, note = ingredient_parser.parse(text) 

1738 

1739 return JsonResponse( 

1740 { 

1741 'amount': amount, 

1742 'unit': unit, 

1743 'food': food, 

1744 'note': note 

1745 }, 

1746 status=200 

1747 )