remove ShoppingList model and tests

This commit is contained in:
smilerz
2024-02-20 17:09:18 -06:00
parent 21094eecc6
commit c8c8792ea8
16 changed files with 303 additions and 960 deletions

View File

@@ -30,7 +30,6 @@ from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import make_aware
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
@@ -58,59 +57,40 @@ from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.open_data_importer import OpenDataImporter
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly,
CustomIsShared, CustomIsSpaceOwner, CustomIsUser,
CustomRecipePermission, CustomTokenHasReadWriteScope,
CustomTokenHasScope, CustomUserPermission,
IsReadOnlyDRF, above_space_limit, group_required,
has_group_permission, is_space_owner,
switch_user_active_space)
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly, CustomIsShared, CustomIsSpaceOwner, CustomIsUser, CustomRecipePermission,
CustomTokenHasReadWriteScope, CustomTokenHasScope, CustomUserPermission, IsReadOnlyDRF, above_space_limit, group_required,
has_group_permission, is_space_owner, switch_user_active_space,
)
from cookbook.helper.recipe_search import RecipeSearch
from cookbook.helper.recipe_url_import import (clean_dict, get_from_youtube_scraper,
get_images_from_soup)
from cookbook.helper.recipe_url_import import clean_dict, get_from_youtube_scraper, get_images_from_soup
from cookbook.helper.scrapers.scrapers import text_scraper
from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink,
Keyword, MealPlan, MealType, Property, PropertyType, Recipe,
RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
ViewLog)
from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food, FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink, Keyword,
MealPlan, MealType, Property, PropertyType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingListEntry, ShoppingListRecipe, Space, Step,
Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
ViewLog,
)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
AutoMealPlanSerializer, BookmarkletImportListSerializer,
BookmarkletImportSerializer, CookLogSerializer,
CustomFilterSerializer, ExportLogSerializer,
FoodInheritFieldSerializer, FoodSerializer,
FoodShoppingUpdateSerializer, FoodSimpleSerializer,
GroupSerializer, ImportLogSerializer, IngredientSerializer,
IngredientSimpleSerializer, InviteLinkSerializer,
KeywordSerializer, MealPlanSerializer, MealTypeSerializer,
PropertySerializer, PropertyTypeSerializer,
RecipeBookEntrySerializer, RecipeBookSerializer,
RecipeExportSerializer, RecipeFromSourceSerializer,
RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer,
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
ShoppingListRecipeSerializer, ShoppingListSerializer,
SpaceSerializer, StepSerializer, StorageSerializer,
SupermarketCategoryRelationSerializer,
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
UserSerializer, UserSpaceSerializer, ViewLogSerializer,
ShoppingListEntryBulkSerializer)
from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, AutoMealPlanSerializer, BookmarkletImportListSerializer, BookmarkletImportSerializer,
CookLogSerializer, CustomFilterSerializer, ExportLogSerializer, FoodInheritFieldSerializer, FoodSerializer, FoodShoppingUpdateSerializer,
FoodSimpleSerializer, GroupSerializer, ImportLogSerializer, IngredientSerializer, IngredientSimpleSerializer, InviteLinkSerializer,
KeywordSerializer, MealPlanSerializer, MealTypeSerializer, PropertySerializer, PropertyTypeSerializer, RecipeBookEntrySerializer,
RecipeBookSerializer, RecipeExportSerializer, RecipeFromSourceSerializer, RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer,
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, ShoppingListEntryBulkSerializer, ShoppingListEntrySerializer,
ShoppingListRecipeSerializer, SpaceSerializer, StepSerializer, StorageSerializer, SupermarketCategoryRelationSerializer,
SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, UnitConversionSerializer, UnitSerializer,
UserFileSerializer, UserPreferenceSerializer, UserSerializer, UserSpaceSerializer, ViewLogSerializer,
)
from cookbook.views.import_export import get_integration
from recipes import settings
from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT
from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY
class StandardFilterMixin(ViewSetMixin):
def get_queryset(self):
queryset = self.queryset
query = self.request.query_params.get('query', None)
@@ -161,12 +141,13 @@ class ExtendedRecipeMixin():
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(
image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
image_subquery = Recipe.objects.filter(**{
recipe_filter: OuterRef('id')
}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
if tree:
image_children_subquery = Recipe.objects.filter(
**{f"{recipe_filter}__path__startswith": OuterRef('path')},
space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
image_children_subquery = Recipe.objects.filter(**{
f"{recipe_filter}__path__startswith": OuterRef('path')
}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
else:
image_children_subquery = None
if images:
@@ -183,17 +164,17 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
query = self.request.query_params.get('query', None)
if self.request.user.is_authenticated:
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in
self.request.user.searchpreference.trigram.values_list(
'field', flat=True)])
fuzzy = self.request.user.searchpreference.lookup or any(
[self.model.__name__.lower() in x for x in self.request.user.searchpreference.trigram.values_list('field', flat=True)])
else:
fuzzy = True
if query is not None and query not in ["''", '']:
if fuzzy and (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'):
if self.request.user.is_authenticated and any(
[self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
[self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]
):
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
else:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
self.queryset = self.queryset.order_by('-trigram')
@@ -205,10 +186,9 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
filter |= Q(name__unaccent__icontains=query)
self.queryset = (
self.queryset.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.filter(filter).order_by('-starts', Lower('name').asc())
)
self.queryset.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
.filter(filter).order_by('-starts',
Lower('name').asc()))
updated_at = self.request.query_params.get('updated_at', None)
if updated_at is not None:
@@ -229,6 +209,7 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
class MergeMixin(ViewSetMixin):
@decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def merge(self, request, pk, target):
@@ -296,8 +277,7 @@ class MergeMixin(ViewSetMixin):
return Response(content, status=status.HTTP_200_OK)
except Exception:
traceback.print_exc()
content = {'error': True,
'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
content = {'error': True, 'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
return Response(content, status=status.HTTP_400_BAD_REQUEST)
@@ -330,8 +310,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True)
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
tree=True)
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True)
@decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@@ -552,23 +531,18 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
def get_queryset(self):
shared_users = []
if c := caches['default'].get(
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', None):
if c := caches['default'].get(f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', None):
shared_users = c
else:
try:
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
self.request.user.id]
caches['default'].set(
f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}',
shared_users, timeout=5 * 60)
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id]
caches['default'].set(f'shopping_shared_users_{self.request.space.id}_{self.request.user.id}', shared_users, timeout=5 * 60)
# TODO ugly hack that improves API performance significantly, should be done properly
except AttributeError: # Anonymous users (using share links) don't have shared users
pass
self.queryset = super().get_queryset()
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'),
checked=False).values('id')
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id')
# onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users]))
return self.queryset \
.annotate(shopping_status=Exists(shopping_status)) \
@@ -589,8 +563,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
shared_users = list(self.request.user.get_shopping_share())
shared_users.append(request.user)
if request.data.get('_delete', False) == 'true':
ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space,
created_by__in=shared_users).delete()
ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, created_by__in=shared_users).delete()
content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
return Response(content, status=status.HTTP_204_NO_CONTENT)
@@ -598,8 +571,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
unit = request.data.get('unit', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space,
created_by=request.user)
ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, created_by=request.user)
return Response(content, status=status.HTTP_204_NO_CONTENT)
@decorators.action(detail=True, methods=['POST'], )
@@ -610,19 +582,31 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
"""
food = self.get_object()
if not food.fdc_id:
return JsonResponse({'msg': 'Food has no FDC ID associated.'}, status=400,
json_dumps_params={'indent': 4})
return JsonResponse({'msg': 'Food has no FDC ID associated.'}, status=400, json_dumps_params={'indent': 4})
response = requests.get(f'https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key={FDC_API_KEY}')
if response.status_code == 429:
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,
json_dumps_params={'indent': 4})
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,
json_dumps_params={'indent': 4})
if response.status_code != 200:
return JsonResponse({'msg': f'Error while requesting FDC data using url https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key=****'}, status=response.status_code,
return JsonResponse({'msg': f'Error while requesting FDC data using url https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key=****'},
status=response.status_code,
json_dumps_params={'indent': 4})
food.properties_food_amount = 100
food.properties_food_unit = Unit.objects.get_or_create(base_unit__iexact='g', space=self.request.space, defaults={'name': 'g', 'base_unit': 'g', 'space': self.request.space})[0]
food.properties_food_unit = Unit.objects.get_or_create(base_unit__iexact='g',
space=self.request.space,
defaults={
'name': 'g',
'base_unit': 'g',
'space': self.request.space
})[0]
food.save()
try:
@@ -641,27 +625,27 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
for fn in data['foodNutrients']:
if fn['nutrient']['id'] == pt.fdc_id:
property_found = True
food_property_list.append(Property(
property_type_id=pt.id,
property_amount=max(0, round(fn['amount'], 2)), # sometimes FDC might return negative values which make no sense, set to 0
import_food_id=food.id,
space=self.request.space,
))
food_property_list.append(
Property(property_type_id=pt.id,
property_amount=max(0, round(fn['amount'], 2)), # sometimes FDC might return negative values which make no sense, set to 0
import_food_id=food.id,
space=self.request.space,
))
if not property_found:
food_property_list.append(Property(
property_type_id=pt.id,
property_amount=0, # if field not in FDC data the food does not have that property
import_food_id=food.id,
space=self.request.space,
))
food_property_list.append(
Property(property_type_id=pt.id,
property_amount=0, # if field not in FDC data the food does not have that property
import_food_id=food.id,
space=self.request.space,
))
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type', ))
property_food_relation_list = []
for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ):
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id', ))
Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None)
return self.retrieve(request, pk)
@@ -691,8 +675,7 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
ordering = f"{'' if order_direction == 'asc' else '-'}{order_field}"
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
space=self.request.space).distinct().order_by(ordering)
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space).distinct().order_by(ordering)
return super().get_queryset()
@@ -710,9 +693,7 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
def get_queryset(self):
queryset = self.queryset.filter(
Q(book__created_by=self.request.user) | Q(book__shared=self.request.user)).filter(
book__space=self.request.space).distinct()
queryset = self.queryset.filter(Q(book__created_by=self.request.user) | Q(book__shared=self.request.user)).filter(book__space=self.request.space).distinct()
recipe_id = self.request.query_params.get('recipe', None)
if recipe_id is not None:
@@ -745,10 +726,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
schema = QueryParamAutoSchema()
def get_queryset(self):
queryset = self.queryset.filter(
Q(created_by=self.request.user)
| Q(shared=self.request.user)
).filter(space=self.request.space).distinct().all()
queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space).distinct().all()
from_date = self.request.query_params.get('from_date', None)
if from_date is not None:
@@ -766,6 +744,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
class AutoPlanViewSet(viewsets.ViewSet):
def create(self, request):
serializer = AutoMealPlanSerializer(data=request.data)
@@ -795,10 +774,16 @@ class AutoPlanViewSet(viewsets.ViewSet):
for i in range(0, days):
day = start_date + datetime.timedelta(i)
recipe = recipes[i % len(recipes)]
args = {'recipe_id': recipe['id'], 'servings': servings,
'created_by': request.user,
'meal_type_id': serializer.validated_data['meal_type_id'],
'note': '', 'from_date': day, 'to_date': day, 'space': request.space}
args = {
'recipe_id': recipe['id'],
'servings': servings,
'created_by': request.user,
'meal_type_id': serializer.validated_data['meal_type_id'],
'note': '',
'from_date': day,
'to_date': day,
'space': request.space
}
m = MealPlan(**args)
meal_plans.append(m)
@@ -813,12 +798,7 @@ class AutoPlanViewSet(viewsets.ViewSet):
SLR.create(mealplan=m, servings=servings)
else:
post_save.send(
sender=m.__class__,
instance=m,
created=True,
update_fields=None,
)
post_save.send(sender=m.__class__, instance=m, created=True, update_fields=None, )
return Response(serializer.data)
@@ -835,8 +815,7 @@ class MealTypeViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
def get_queryset(self):
queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter(
space=self.request.space).all()
queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter(space=self.request.space).all()
return queryset
@@ -896,12 +875,7 @@ class RecipePagination(PageNumberPagination):
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):
return Response(OrderedDict([
('count', self.page.paginator.count),
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data),
]))
return Response(OrderedDict([('count', self.page.paginator.count), ('next', self.get_next_link()), ('previous', self.get_previous_link()), ('results', data), ]))
class RecipeViewSet(viewsets.ModelViewSet):
@@ -930,15 +904,15 @@ class RecipeViewSet(viewsets.ModelViewSet):
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [true/<b>false</b>]')),
QueryParam(name='random', description=_('Returns the results in randomized order. [true/<b>false</b>]')),
QueryParam(name='new', description=_('Returns new results first in search results. [true/<b>false</b>]')),
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [true/<b>false</b>]')),
]
schema = QueryParamAutoSchema()
@@ -947,49 +921,32 @@ class RecipeViewSet(viewsets.ModelViewSet):
if self.detail: # if detail request and not list, private condition is verified by permission class
if not share: # filter for space only if not shared
self.queryset = self.queryset.filter(space=self.request.space).prefetch_related(
'keywords',
'shared',
'properties',
'properties__property_type',
'steps',
'steps__ingredients',
'steps__ingredients__step_set',
'steps__ingredients__step_set__recipe_set',
'steps__ingredients__food',
'steps__ingredients__food__properties',
'steps__ingredients__food__properties__property_type',
'steps__ingredients__food__inherit_fields',
'steps__ingredients__food__supermarket_category',
'steps__ingredients__food__onhand_users',
'steps__ingredients__food__substitute',
'steps__ingredients__food__child_inherit_fields',
'steps__ingredients__unit',
'steps__ingredients__unit__unit_conversion_base_relation',
'steps__ingredients__unit__unit_conversion_base_relation__base_unit',
'steps__ingredients__unit__unit_conversion_converted_relation',
'steps__ingredients__unit__unit_conversion_converted_relation__converted_unit',
'cooklog_set',
).select_related('nutrition')
self.queryset = self.queryset.filter(
space=self.request.space).prefetch_related('keywords', 'shared', 'properties', 'properties__property_type', 'steps', 'steps__ingredients',
'steps__ingredients__step_set', 'steps__ingredients__step_set__recipe_set', 'steps__ingredients__food',
'steps__ingredients__food__properties', 'steps__ingredients__food__properties__property_type',
'steps__ingredients__food__inherit_fields', 'steps__ingredients__food__supermarket_category',
'steps__ingredients__food__onhand_users', 'steps__ingredients__food__substitute',
'steps__ingredients__food__child_inherit_fields', 'steps__ingredients__unit',
'steps__ingredients__unit__unit_conversion_base_relation',
'steps__ingredients__unit__unit_conversion_base_relation__base_unit',
'steps__ingredients__unit__unit_conversion_converted_relation',
'steps__ingredients__unit__unit_conversion_converted_relation__converted_unit', 'cooklog_set',
).select_related('nutrition')
return super().get_queryset()
self.queryset = self.queryset.filter(space=self.request.space).filter(
Q(private=False) | (Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user)))
)
self.queryset = self.queryset.filter(
space=self.request.space).filter(Q(private=False) | (Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user))))
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x
in list(self.request.GET)}
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('keywords', 'cooklog_set')
return self.queryset
def list(self, request, *args, **kwargs):
if self.request.GET.get('debug', False):
return JsonResponse({
'new': str(self.get_queryset().query),
})
return JsonResponse({'new': str(self.get_queryset().query), })
return super().list(request, *args, **kwargs)
def get_serializer_class(self):
@@ -997,12 +954,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
return RecipeOverviewSerializer
return self.serializer_class
@decorators.action(
detail=True,
methods=['PUT'],
serializer_class=RecipeImageSerializer,
parser_classes=[MultiPartParser],
)
@decorators.action(detail=True, methods=['PUT'], serializer_class=RecipeImageSerializer, parser_classes=[MultiPartParser], )
def image(self, request, pk):
obj = self.get_object()
@@ -1050,11 +1002,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
# TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating
# DRF only allows one action in a decorator action without overriding get_operation_id_base()
@decorators.action(
detail=True,
methods=['PUT'],
serializer_class=RecipeShoppingUpdateSerializer,
)
@decorators.action(detail=True, methods=['PUT'], serializer_class=RecipeShoppingUpdateSerializer, )
def shopping(self, request, pk):
if self.request.space.demo:
raise PermissionDenied(detail='Not available in demo', code=None)
@@ -1084,11 +1032,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
return Response(content, status=http_status)
@decorators.action(
detail=True,
methods=['GET'],
serializer_class=RecipeSimpleSerializer
)
@decorators.action(detail=True, methods=['GET'], serializer_class=RecipeSimpleSerializer)
def related(self, request, pk):
obj = self.get_object()
if obj.get_space() != request.space:
@@ -1097,8 +1041,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
levels = int(request.query_params.get('levels', 1))
except (ValueError, TypeError):
levels = 1
qs = obj.get_related_recipes(
levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
qs = obj.get_related_recipes(levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
return Response(self.serializer_class(qs, many=True).data)
@@ -1106,9 +1049,7 @@ class UnitConversionViewSet(viewsets.ModelViewSet):
queryset = UnitConversion.objects
serializer_class = UnitConversionSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
query_params = [
QueryParam(name='food_id', description='ID of food to filter for', qtype='int'),
]
query_params = [QueryParam(name='food_id', description='ID of food to filter for', qtype='int'), ]
schema = QueryParamAutoSchema()
def get_queryset(self):
@@ -1143,14 +1084,12 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
def get_queryset(self):
self.queryset = self.queryset.filter(
Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
self.queryset = self.queryset.filter(Q(entries__space=self.request.space) | Q(recipe__space=self.request.space))
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
Q(entries__isnull=True)
| Q(entries__created_by=self.request.user)
| Q(entries__created_by__in=list(self.request.user.get_shopping_share()))
).distinct().all()
).distinct().all()
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
@@ -1159,8 +1098,10 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
query_params = [
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
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.')
),
QueryParam(name='checked',
description=_(
'Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includeks unchecked items and recently completed items.'
)),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
]
schema = QueryParamAutoSchema()
@@ -1170,24 +1111,11 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
self.queryset = self.queryset.filter(
Q(created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).prefetch_related(
'created_by',
'food',
'food__properties',
'food__properties__property_type',
'food__inherit_fields',
'food__supermarket_category',
'food__onhand_users',
'food__substitute',
'food__child_inherit_fields',
'unit',
'list_recipe',
'list_recipe__mealplan',
'list_recipe__mealplan__recipe',
).distinct().all()
| Q(created_by__in=list(self.request.user.get_shopping_share()))).prefetch_related('created_by', 'food', 'food__properties', 'food__properties__property_type',
'food__inherit_fields', 'food__supermarket_category', 'food__onhand_users',
'food__substitute', 'food__child_inherit_fields', 'unit', 'list_recipe',
'list_recipe__mealplan', 'list_recipe__mealplan__recipe',
).distinct().all()
if pk := self.request.query_params.getlist('id', []):
self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk])
@@ -1204,7 +1132,7 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
if last_autosync:
last_autosync = datetime.datetime.fromtimestamp(int(last_autosync) / 1000, datetime.timezone.utc)
self.queryset = self.queryset.filter(updated_at__gte=last_autosync)
except:
except Exception:
traceback.print_exc()
# TODO once old shopping list is removed this needs updated to sharing users in preferences
@@ -1213,53 +1141,20 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
else:
return self.queryset[:1000]
@decorators.action(
detail=False,
methods=['POST'],
serializer_class=ShoppingListEntryBulkSerializer,
permission_classes=[CustomIsUser]
)
@decorators.action(detail=False, methods=['POST'], serializer_class=ShoppingListEntryBulkSerializer, permission_classes=[CustomIsUser])
def bulk(self, request):
serializer = self.serializer_class(data=request.data)
if serializer.is_valid():
ShoppingListEntry.objects.filter(
Q(created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=request.space, id__in=serializer.validated_data['ids']).update(
checked=serializer.validated_data['checked'],
updated_at=timezone.now(),
)
ShoppingListEntry.objects.filter(Q(created_by=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))).filter(space=request.space, id__in=serializer.validated_data['ids']
).update(checked=serializer.validated_data['checked'],
updated_at=timezone.now(),
)
return Response(serializer.data)
else:
return Response(serializer.errors, 400)
# TODO deprecate
class ShoppingListViewSet(viewsets.ModelViewSet):
queryset = ShoppingList.objects
serializer_class = ShoppingListSerializer
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
def get_queryset(self):
self.queryset = self.queryset.filter(
Q(created_by=self.request.user)
| Q(shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=self.request.space)
return self.queryset.distinct()
def get_serializer_class(self):
try:
autosync = self.request.query_params.get('autosync', False)
if autosync:
return ShoppingListAutoSyncSerializer
except AttributeError: # Needed for the openapi schema to determine a serializer without a request
pass
return self.serializer_class
class ViewLogViewSet(viewsets.ModelViewSet):
queryset = ViewLog.objects
serializer_class = ViewLogSerializer
@@ -1362,8 +1257,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
def get_queryset(self):
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
space=self.request.space).distinct()
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space).distinct()
return super().get_queryset()
@@ -1378,6 +1272,7 @@ class AccessTokenViewSet(viewsets.ModelViewSet):
# -------------- DRF custom views --------------------
class AuthTokenThrottle(AnonRateThrottle):
rate = '10/day'
@@ -1390,15 +1285,14 @@ class CustomAuthToken(ObtainAuthToken):
throttle_classes = [AuthTokenThrottle]
def post(self, request, *args, **kwargs):
serializer = self.serializer_class(data=request.data,
context={'request': request})
serializer = self.serializer_class(data=request.data, context={'request': request})
serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user']
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(
scope__contains='write').first():
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(scope__contains='write').first():
access_token = token
else:
access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}',
access_token = AccessToken.objects.create(user=user,
token=f'tda_{str(uuid.uuid4()).replace("-", "_")}',
expires=(timezone.now() + timezone.timedelta(days=365 * 5)),
scope='read write app')
return Response({
@@ -1428,8 +1322,7 @@ class RecipeUrlImportView(APIView):
serializer = RecipeFromSourceSerializer(data=request.data)
if serializer.is_valid():
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (
bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
serializer.validated_data['url'] = bookmarklet.url
serializer.validated_data['data'] = bookmarklet.html
bookmarklet.delete()
@@ -1437,61 +1330,40 @@ class RecipeUrlImportView(APIView):
url = serializer.validated_data.get('url', None)
data = unquote(serializer.validated_data.get('data', None))
if not url and not data:
return Response({
'error': True,
'msg': _('Nothing to do.')
}, status=status.HTTP_400_BAD_REQUEST)
return Response({'error': True, 'msg': _('Nothing to do.')}, status=status.HTTP_400_BAD_REQUEST)
elif url and not data:
if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
if validators.url(url, public=True):
return Response({
'recipe_json': get_from_youtube_scraper(url, request),
'recipe_images': [],
}, status=status.HTTP_200_OK)
if re.match(
'^(.)*/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}$',
url):
return Response({'recipe_json': get_from_youtube_scraper(url, request), 'recipe_images': [], }, status=status.HTTP_200_OK)
if re.match('^(.)*/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}$', url):
recipe_json = requests.get(
url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1],
'') + '?share=' +
re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1], '') + '?share='
+ re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
recipe_json = clean_dict(recipe_json, 'id')
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
if serialized_recipe.is_valid():
recipe = serialized_recipe.save()
if validators.url(recipe_json['image'], public=True):
recipe.image = File(handle_image(request,
File(io.BytesIO(requests.get(recipe_json['image']).content),
name='image'),
File(io.BytesIO(requests.get(recipe_json['image']).content), name='image'),
filetype=pathlib.Path(recipe_json['image']).suffix),
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
recipe.save()
return Response({
'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk}))
}, status=status.HTTP_201_CREATED)
return Response({'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk}))}, status=status.HTTP_201_CREATED)
else:
try:
if validators.url(url, public=True):
scrape = scrape_me(url_path=url, wild_mode=True)
else:
return Response({
'error': True,
'msg': _('Invalid Url')
}, status=status.HTTP_400_BAD_REQUEST)
return Response({'error': True, 'msg': _('Invalid Url')}, status=status.HTTP_400_BAD_REQUEST)
except NoSchemaFoundInWildMode:
pass
except requests.exceptions.ConnectionError:
return Response({
'error': True,
'msg': _('Connection Refused.')
}, status=status.HTTP_400_BAD_REQUEST)
return Response({'error': True, 'msg': _('Connection Refused.')}, status=status.HTTP_400_BAD_REQUEST)
except requests.exceptions.MissingSchema:
return Response({
'error': True,
'msg': _('Bad URL Schema.')
}, status=status.HTTP_400_BAD_REQUEST)
return Response({'error': True, 'msg': _('Bad URL Schema.')}, status=status.HTTP_400_BAD_REQUEST)
else:
try:
data_json = json.loads(data)
@@ -1510,13 +1382,11 @@ class RecipeUrlImportView(APIView):
return Response({
'recipe_json': helper.get_from_scraper(scrape, request),
'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))),
}, status=status.HTTP_200_OK)
},
status=status.HTTP_200_OK)
else:
return Response({
'error': True,
'msg': _('No usable data could be found.')
}, status=status.HTTP_400_BAD_REQUEST)
return Response({'error': True, 'msg': _('No usable data could be found.')}, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -1608,8 +1478,7 @@ def import_files(request):
return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
except NotImplementedError:
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')},
status=status.HTTP_400_BAD_REQUEST)
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')}, status=status.HTTP_400_BAD_REQUEST)
else:
return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
@@ -1693,8 +1562,7 @@ def get_recipe_file(request, recipe_id):
@group_required('user')
def sync_all(request):
if request.space.demo or settings.HOSTED:
messages.add_message(request, messages.ERROR,
_('This feature is not yet available in the hosted version of tandoor!'))
messages.add_message(request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
return redirect('index')
monitors = Sync.objects.filter(active=True).filter(space=request.user.userspace_set.filter(active=1).first().space)
@@ -1715,14 +1583,10 @@ def sync_all(request):
error = True
if not error:
messages.add_message(
request, messages.SUCCESS, _('Sync successful!')
)
messages.add_message(request, messages.SUCCESS, _('Sync successful!'))
return redirect('list_recipe_import')
else:
messages.add_message(
request, messages.ERROR, _('Error synchronizing with Storage')
)
messages.add_message(request, messages.ERROR, _('Error synchronizing with Storage'))
return redirect('list_recipe_import')
@@ -1730,11 +1594,10 @@ def sync_all(request):
# @schema(AutoSchema()) #TODO add proper schema
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
def share_link(request, pk):
if request.space.allow_sharing and has_group_permission(request.user, ('user',)):
if request.space.allow_sharing and has_group_permission(request.user, ('user', )):
recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space)
return JsonResponse({'pk': pk, 'share': link.uuid,
'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
return JsonResponse({'pk': pk, 'share': link.uuid, 'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
else:
return JsonResponse({'error': 'sharing_disabled'}, status=403)
@@ -1760,9 +1623,8 @@ def log_cooking(request, recipe_id):
@group_required('user')
def get_plan_ical(request, from_date, to_date):
queryset = MealPlan.objects.filter(
Q(created_by=request.user) | Q(shared=request.user)
).filter(space=request.user.userspace_set.filter(active=1).first().space).distinct().all()
queryset = MealPlan.objects.filter(Q(created_by=request.user)
| Q(shared=request.user)).filter(space=request.user.userspace_set.filter(active=1).first().space).distinct().all()
if from_date is not None:
queryset = queryset.filter(from_date__gte=from_date)
@@ -1803,12 +1665,4 @@ def ingredient_from_string(request):
ingredient_parser = IngredientParser(request, False)
amount, unit, food, note = ingredient_parser.parse(text)
return JsonResponse(
{
'amount': amount,
'unit': unit,
'food': food,
'note': note
},
status=200
)
return JsonResponse({'amount': amount, 'unit': unit, 'food': food, 'note': note}, status=200)