diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 6c991e436..565c1ae84 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -1338,7 +1338,7 @@ class InviteLinkSerializer(WritableNestedModelSerializer): validated_data['space'] = self.context['request'].space obj = super().create(validated_data) - if obj.email and EMAIL_HOST is not '': + if obj.email and EMAIL_HOST != '': try: if InviteLink.objects.filter(space=self.context['request'].space, created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20: @@ -1426,6 +1426,21 @@ class LocalizationSerializer(serializers.Serializer): fields = '__ALL__' +class ServerSettingsSerializer(serializers.Serializer): + # TODO add all other relevant settings including path/url related ones? + shopping_min_autosync_interval = serializers.CharField() + enable_pdf_export = serializers.BooleanField() + disable_external_connectors = serializers.BooleanField() + terms_url = serializers.CharField() + privacy_url = serializers.CharField() + imprint_url = serializers.CharField() + hosted = serializers.BooleanField() + + class Meta: + fields = '__ALL__' + read_only_fields = '__ALL__' + + # Export/Import Serializers class KeywordExportSerializer(KeywordSerializer): diff --git a/cookbook/urls.py b/cookbook/urls.py index 929339852..2e00935ae 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -65,6 +65,7 @@ router.register(r'view-log', api.ViewLogViewSet) router.register(r'access-token', api.AccessTokenViewSet) router.register(r'localization', api.LocalizationViewSet, basename='localization') +router.register(r'server-settings', api.ServerSettingsViewSet, basename='server-settings') for p in PLUGINS: if c := locate(f'{p["module"]}.urls.{p["api_router_name"]}'): diff --git a/cookbook/views/api.py b/cookbook/views/api.py index bd866c3e6..c556a4650 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -60,30 +60,47 @@ from cookbook.helper.HelperFunctions import str2bool, validate_import_url 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, CustomIsGuest, - 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, CustomIsGuest, + 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.shopping_helper import RecipeShoppingEditor, shopping_helper -from cookbook.models import (Automation, BookmarkletImport, ConnectorConfig, 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, +from cookbook.models import (Automation, BookmarkletImport, ConnectorConfig, 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.serializer import (AccessTokenSerializer, AutomationSerializer, AutoMealPlanSerializer, BookmarkletImportListSerializer, BookmarkletImportSerializer, - ConnectorConfigConfigSerializer, CookLogSerializer, CustomFilterSerializer, ExportLogSerializer, FoodInheritFieldSerializer, FoodSerializer, - FoodShoppingUpdateSerializer, FoodSimpleSerializer, GroupSerializer, ImportLogSerializer, IngredientSerializer, IngredientSimpleSerializer, - InviteLinkSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, PropertySerializer, PropertyTypeSerializer, - RecipeBookEntrySerializer, RecipeBookSerializer, RecipeExportSerializer, RecipeFlatSerializer, RecipeFromSourceSerializer, RecipeImageSerializer, - RecipeOverviewSerializer, RecipeSerializer, RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, ShoppingListEntryBulkSerializer, - ShoppingListEntrySerializer, ShoppingListRecipeSerializer, SpaceSerializer, StepSerializer, StorageSerializer, - SupermarketCategoryRelationSerializer, SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, - UnitConversionSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer, UserSerializer, UserSpaceSerializer, ViewLogSerializer, ImportImageSerializer, LocalizationSerializer +from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, AutoMealPlanSerializer, + BookmarkletImportListSerializer, BookmarkletImportSerializer, + ConnectorConfigConfigSerializer, CookLogSerializer, CustomFilterSerializer, + ExportLogSerializer, FoodInheritFieldSerializer, FoodSerializer, + FoodShoppingUpdateSerializer, FoodSimpleSerializer, GroupSerializer, + ImportLogSerializer, IngredientSerializer, IngredientSimpleSerializer, + InviteLinkSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, + PropertySerializer, PropertyTypeSerializer, + RecipeBookEntrySerializer, RecipeBookSerializer, RecipeExportSerializer, + RecipeFlatSerializer, RecipeFromSourceSerializer, RecipeImageSerializer, + RecipeOverviewSerializer, RecipeSerializer, RecipeShoppingUpdateSerializer, + RecipeSimpleSerializer, ShoppingListEntryBulkSerializer, + ShoppingListEntrySerializer, ShoppingListRecipeSerializer, SpaceSerializer, + StepSerializer, StorageSerializer, + SupermarketCategoryRelationSerializer, SupermarketCategorySerializer, + SupermarketSerializer, SyncLogSerializer, SyncSerializer, + UnitConversionSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer, + UserSerializer, UserSpaceSerializer, ViewLogSerializer, ImportImageSerializer, + LocalizationSerializer, ServerSettingsSerializer ) from cookbook.views.import_export import get_integration from recipes import settings @@ -94,8 +111,11 @@ BeforeDateExample = OpenApiExample('Before Date Format', value='-1972-12-05', re @extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='query', description='lookup if query string is contained within the name, case insensitive', type=str), - OpenApiParameter(name='updated_at', description='if model has an updated_at timestamp, filter only models updated at or after datetime', type=str, examples=[DateExample]), + OpenApiParameter(name='query', description='lookup if query string is contained within the name, case insensitive', + type=str), + OpenApiParameter(name='updated_at', + description='if model has an updated_at timestamp, filter only models updated at or after datetime', + type=str, examples=[DateExample]), OpenApiParameter(name='limit', description='limit number of entries to return', type=str), OpenApiParameter(name='random', description='randomly orders entries (only works together with limit)', type=str), ])) @@ -147,7 +167,8 @@ class ExtendedRecipeMixin(): # add a recipe count annotation to the query # explanation on construction https://stackoverflow.com/a/43771738/15762829 - recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk', distinct=True)).values('count') + recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values( + recipe_filter).annotate(count=Count('pk', distinct=True)).values('count') queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0)) # add a recipe image annotation to the query @@ -168,8 +189,11 @@ class ExtendedRecipeMixin(): @extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='query', description='lookup if query string is contained within the name, case insensitive', type=str), - OpenApiParameter(name='updated_at', description='if model has an updated_at timestamp, filter only models updated at or after datetime', type=str), # TODO format hint + OpenApiParameter(name='query', description='lookup if query string is contained within the name, case insensitive', + type=str), + OpenApiParameter(name='updated_at', + description='if model has an updated_at timestamp, filter only models updated at or after datetime', + type=str), # TODO format hint OpenApiParameter(name='limit', description='limit number of entries to return', type=str), OpenApiParameter(name='random', description='randomly orders entries (only works together with limit)', type=str), ])) @@ -180,7 +204,8 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin): 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)]) + [self.model.__name__.lower() in x for x in + self.request.user.searchpreference.trigram.values_list('field', flat=True)]) else: fuzzy = True @@ -188,7 +213,8 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin): 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)]) + 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)) else: @@ -198,11 +224,13 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin): # TODO have this check unaccent search settings or other search preferences? filter = Q(name__icontains=query) if self.request.user.is_authenticated: - if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): + if any([self.model.__name__.lower() in x for x in + self.request.user.searchpreference.unaccent.values_list('field', flat=True)]): 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 + 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())) @@ -227,7 +255,8 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin): class MergeMixin(ViewSetMixin): @extend_schema(parameters=[ - OpenApiParameter(name="target", description='The ID of the {obj} you want to merge with.', type=OpenApiTypes.INT, location=OpenApiParameter.PATH) + OpenApiParameter(name="target", description='The ID of the {obj} you want to merge with.', + type=OpenApiTypes.INT, location=OpenApiParameter.PATH) ]) @decorators.action(detail=True, url_path='merge/(?P[^/.]+)', methods=['PUT'], ) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @@ -296,17 +325,21 @@ 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) @extend_schema_view( list=extend_schema(parameters=[ - OpenApiParameter(name='root', description='Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.', type=int), + OpenApiParameter(name='root', + description='Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.', + type=int), OpenApiParameter(name='tree', description='Return all self and children of {obj} with ID [int].', type=int), ]), move=extend_schema(parameters=[ - OpenApiParameter(name="parent", description='The ID of the desired parent of the {obj}.', type=OpenApiTypes.INT, location=OpenApiParameter.PATH) + OpenApiParameter(name="parent", description='The ID of the desired parent of the {obj}.', type=OpenApiTypes.INT, + location=OpenApiParameter.PATH) ]) ) class TreeMixin(MergeMixin, FuzzyFilterMixin): @@ -334,10 +367,12 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin): except self.model.DoesNotExist: self.queryset = self.model.objects.none() else: - return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True) + 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[^/.]+)', methods=['PUT'], ) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @@ -419,10 +454,12 @@ class SpaceViewSet(viewsets.ModelViewSet): queryset = Space.objects serializer_class = SpaceSerializer permission_classes = [IsReadOnlyDRF & CustomIsGuest | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] + pagination_class = DefaultPagination http_method_names = ['get', 'patch'] def get_queryset(self): - return self.queryset.filter(id__in=UserSpace.objects.filter(user=self.request.user).values_list('space_id', flat=True)) + return self.queryset.filter( + id__in=UserSpace.objects.filter(user=self.request.user).values_list('space_id', flat=True)) @extend_schema(responses=SpaceSerializer(many=False)) @decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=SpaceSerializer, ) @@ -588,13 +625,15 @@ class FoodViewSet(TreeMixin): 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) + 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)) \ @@ -617,7 +656,8 @@ class FoodViewSet(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) @@ -625,7 +665,8 @@ class FoodViewSet(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'], ) @@ -649,9 +690,10 @@ class FoodViewSet(TreeMixin): 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, - json_dumps_params={'indent': 4}) + 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( @@ -680,12 +722,14 @@ class FoodViewSet(TreeMixin): 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 + property_amount=max(0, round(fn['amount'], 2)), + # sometimes FDC might return negative values which make no sense, set to 0 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 + Property(property_type_id=pt.id, property_amount=0, + # if field not in FDC data the food does not have that property space=self.request.space, )) @@ -695,12 +739,14 @@ class FoodViewSet(TreeMixin): for p in properties: property_food_relation_list.append(Food.properties.through(food_id=food.id, property_id=p.pk)) - 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',)) return self.retrieve(request, pk) except Exception: traceback.print_exc() - return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'}, status=500, json_dumps_params={'indent': 4}) + return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'}, + status=500, json_dumps_params={'indent': 4}) def destroy(self, *args, **kwargs): try: @@ -711,8 +757,10 @@ class FoodViewSet(TreeMixin): @extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='order_field', description='Field to order recipe books on', type=str, enum=['id', 'name', 'order']), - OpenApiParameter(name='order_direction', description='Order ascending or descending', type=str, enum=['asc', 'desc']), + OpenApiParameter(name='order_field', description='Field to order recipe books on', type=str, + enum=['id', 'name', 'order']), + OpenApiParameter(name='order_direction', description='Order ascending or descending', type=str, + enum=['asc', 'desc']), ])) class RecipeBookViewSet(StandardFilterModelViewSet): queryset = RecipeBook.objects @@ -729,7 +777,8 @@ class RecipeBookViewSet(StandardFilterModelViewSet): 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() @@ -744,7 +793,9 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet): pagination_class = DefaultPagination 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: @@ -757,14 +808,19 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet): MealPlanViewQueryParameters = [ - OpenApiParameter(name='from_date', description=_('Filter meal plans from date (inclusive).'), type=str, examples=[DateExample]), - OpenApiParameter(name='to_date', description=_('Filter meal plans to date (inclusive).'), type=str, examples=[DateExample]), - OpenApiParameter(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), type=str, many=True), + OpenApiParameter(name='from_date', description=_('Filter meal plans from date (inclusive).'), type=str, + examples=[DateExample]), + OpenApiParameter(name='to_date', description=_('Filter meal plans to date (inclusive).'), type=str, + examples=[DateExample]), + OpenApiParameter(name='meal_type', + description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), type=str, + many=True), ] @extend_schema_view(list=extend_schema(parameters=MealPlanViewQueryParameters), - ical=extend_schema(parameters=MealPlanViewQueryParameters, responses={(200, 'text/calendar'): OpenApiTypes.STR})) + ical=extend_schema(parameters=MealPlanViewQueryParameters, + responses={(200, 'text/calendar'): OpenApiTypes.STR})) class MealPlanViewSet(viewsets.ModelViewSet): queryset = MealPlan.objects serializer_class = MealPlanSerializer @@ -772,7 +828,8 @@ class MealPlanViewSet(viewsets.ModelViewSet): pagination_class = DefaultPagination 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: @@ -870,7 +927,8 @@ class MealTypeViewSet(viewsets.ModelViewSet): pagination_class = DefaultPagination 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 @@ -903,7 +961,8 @@ class IngredientViewSet(viewsets.ModelViewSet): @extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), type=int, many=True), + OpenApiParameter(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), + type=int, many=True), OpenApiParameter(name='query', description=_('Query string matched (fuzzy) against object name.'), type=str), ])) class StepViewSet(viewsets.ModelViewSet): @@ -933,32 +992,68 @@ 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), ])) @extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.'), type=str), - OpenApiParameter(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), type=int, many=True), - OpenApiParameter(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), type=int, many=True), - OpenApiParameter(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), type=int, many=True), - OpenApiParameter(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), type=int, many=True), - OpenApiParameter(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), type=int, many=True), - OpenApiParameter(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), type=int, many=True), - OpenApiParameter(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), type=int, many=True), - OpenApiParameter(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), type=int, many=True), - OpenApiParameter(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), type=int, many=True), - OpenApiParameter(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), type=int, many=True), + OpenApiParameter(name='query', description=_( + 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.'), type=str), + OpenApiParameter(name='keywords', description=_( + 'ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), type=int, + many=True), + OpenApiParameter(name='keywords_or', + description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), + type=int, many=True), + OpenApiParameter(name='keywords_and', + description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), + type=int, many=True), + OpenApiParameter(name='keywords_or_not', + description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), + type=int, many=True), + OpenApiParameter(name='keywords_and_not', + description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), + type=int, many=True), + OpenApiParameter(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), + type=int, many=True), + OpenApiParameter(name='foods_or', + description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), type=int, + many=True), + OpenApiParameter(name='foods_and', + description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), type=int, + many=True), + OpenApiParameter(name='foods_or_not', + description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), type=int, + many=True), + OpenApiParameter(name='foods_and_not', + description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), type=int, + many=True), OpenApiParameter(name='units', description=_('ID of unit a recipe should have.'), type=int), - OpenApiParameter(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), type=int), - OpenApiParameter(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.'), type=int, many=True), - OpenApiParameter(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), type=int, many=True), - OpenApiParameter(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), type=int, many=True), - OpenApiParameter(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), type=int, many=True), - OpenApiParameter(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), type=int, many=True), - OpenApiParameter(name='internal', description=_('If only internal recipes should be returned. [''true''/''false'']'), type=bool), - OpenApiParameter(name='random', description=_('Returns the results in randomized order. [''true''/''false'']')), - OpenApiParameter(name='new', description=_('Returns new results first in search results. [''true''/''false'']')), - OpenApiParameter(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), type=int), + OpenApiParameter(name='rating', description=_( + 'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), type=int), + OpenApiParameter(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.'), + type=int, many=True), + OpenApiParameter(name='books_or', + description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), type=int, + many=True), + OpenApiParameter(name='books_and', + description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), type=int, + many=True), + OpenApiParameter(name='books_or_not', + description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), type=int, + many=True), + OpenApiParameter(name='books_and_not', + description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), type=int, + many=True), + OpenApiParameter(name='internal', + description=_('If only internal recipes should be returned. [''true''/''false'']'), + type=bool), + OpenApiParameter(name='random', + description=_('Returns the results in randomized order. [''true''/''false'']')), + OpenApiParameter(name='new', + description=_('Returns new results first in search results. [''true''/''false'']')), + OpenApiParameter(name='timescooked', description=_( + 'Filter recipes cooked X times or more. Negative values returns cooked less than X times'), type=int), OpenApiParameter( name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.'), @@ -979,11 +1074,14 @@ class RecipePagination(PageNumberPagination): ), OpenApiParameter( name='viewedon', - description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.'), + description=_( + 'Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.'), type=str, examples=[DateExample, BeforeDateExample] ), - OpenApiParameter(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''false'']'), type=bool), + OpenApiParameter(name='makenow', + description=_('Filter recipes that can be made with OnHand food. [''true''/''false'']'), + type=bool), ])) class RecipeViewSet(viewsets.ModelViewSet): queryset = Recipe.objects @@ -998,24 +1096,35 @@ 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', + 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', + '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)))) + 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 @@ -1030,7 +1139,8 @@ 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() @@ -1051,7 +1161,8 @@ class RecipeViewSet(viewsets.ModelViewSet): try: url = serializer.validated_data['image_url'] if validate_import_url(url): - response = requests.get(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"}) + response = requests.get(url, headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"}) image = File(io.BytesIO(response.content)) filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype except UnidentifiedImageError as e: @@ -1088,7 +1199,8 @@ class RecipeViewSet(viewsets.ModelViewSet): servings = request.data.get('servings', None) list_recipe = request.data.get('list_recipe', None) mealplan = request.data.get('mealplan', None) - SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan, servings=servings) + SLR = RecipeShoppingEditor(request.user, request.space, id=list_recipe, recipe=obj, mealplan=mealplan, + servings=servings) content = {'msg': _(f'{obj.name} was added to the shopping list.')} http_status = status.HTTP_204_NO_CONTENT @@ -1117,19 +1229,22 @@ 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) @extend_schema(responses=RecipeFlatSerializer(many=True)) @decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=RecipeFlatSerializer, ) def flat(self, request): # TODO limit fields retrieved but .values() kills image - qs = Recipe.objects.filter(space=request.space).filter(Q(private=False) | (Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user)))).all() + qs = Recipe.objects.filter(space=request.space).filter(Q(private=False) | ( + Q(private=True) & (Q(created_by=self.request.user) | Q(shared=self.request.user)))).all() return Response(self.serializer_class(qs, many=True).data) -@extend_schema_view(list=extend_schema(parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int), ])) +@extend_schema_view(list=extend_schema( + parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int), ])) class UnitConversionViewSet(viewsets.ModelViewSet): queryset = UnitConversion.objects serializer_class = UnitConversionSerializer @@ -1187,17 +1302,21 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet): self.queryset = self.queryset.filter(Q(entries__space=self.request.space) | Q(recipe__space=self.request.space)) return self.queryset.filter(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() + | Q( + entries__created_by__in=list(self.request.user.get_shopping_share()))).distinct().all() @extend_schema_view(list=extend_schema(parameters=[ - OpenApiParameter(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), type=int), + OpenApiParameter(name='id', description=_( + 'Returns the shopping list entry with a primary key of id. Multiple values allowed.'), type=int), OpenApiParameter( name='checked', description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''recent'']
\ - ''recent'' includes unchecked items and recently completed items.') ), - OpenApiParameter(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), type=int), + OpenApiParameter(name='supermarket', + description=_('Returns the shopping list entries sorted by supermarket category order.'), + type=int), ])) class ShoppingListEntryViewSet(viewsets.ModelViewSet): queryset = ShoppingListEntry.objects @@ -1210,10 +1329,17 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): self.queryset = self.queryset.filter( Q(created_by=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', + | 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', []): @@ -1223,7 +1349,8 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): return shopping_helper(self.queryset, self.request) elif not self.detail: today_start = timezone.now().replace(hour=0, minute=0, second=0) - week_ago = today_start - datetime.timedelta(days=min(self.request.user.userpreference.shopping_recent_days, 14)) + week_ago = today_start - datetime.timedelta( + days=min(self.request.user.userpreference.shopping_recent_days, 14)) self.queryset = self.queryset.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) try: @@ -1240,15 +1367,18 @@ 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(): print(serializer.validated_data) bulk_entries = 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']) + | Q( + created_by__in=list(self.request.user.get_shopping_share()))).filter(space=request.space, + id__in=serializer.validated_data[ + 'ids']) bulk_entries.update(checked=(checked := serializer.validated_data['checked']), updated_at=timezone.now(), ) # update the onhand for food if shopping_add_onhand is True @@ -1277,7 +1407,8 @@ class ViewLogViewSet(viewsets.ModelViewSet): return self.queryset.filter(created_by=self.request.user).filter(space=self.request.space) -@extend_schema_view(list=extend_schema(parameters=[OpenApiParameter(name='recipe', description='Filter for entries with the given recipe', type=int), ])) +@extend_schema_view(list=extend_schema( + parameters=[OpenApiParameter(name='recipe', description='Filter for entries with the given recipe', type=int), ])) class CookLogViewSet(viewsets.ModelViewSet): queryset = CookLog.objects serializer_class = CookLogSerializer @@ -1405,7 +1536,8 @@ class CustomFilterViewSet(StandardFilterModelViewSet): filter_type = self.request.query_params.getlist('type', []) if filter_type: self.queryset.filter(type__in=filter_type) - 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() @@ -1437,7 +1569,8 @@ class CustomAuthToken(ObtainAuthToken): 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, @@ -1472,7 +1605,8 @@ 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() @@ -1485,10 +1619,14 @@ class RecipeUrlImportView(APIView): elif url and not data: if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url): if validate_import_url(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): + 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=' + 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}) @@ -1496,27 +1634,34 @@ class RecipeUrlImportView(APIView): recipe = serialized_recipe.save() if validate_import_url(recipe_json['image']): 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 validate_import_url(url): html = requests.get( url, - headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"} + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"} ).content scrape = scrape_html(org_url=url, html=html, supported_only=False) 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) @@ -1539,7 +1684,8 @@ class RecipeUrlImportView(APIView): 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) @@ -1661,7 +1807,8 @@ 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) @@ -1671,7 +1818,8 @@ class ImportOpenData(APIView): permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] def get(self, request, format=None): - response = requests.get('https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/meta.json') + response = requests.get( + 'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/meta.json') metadata = json.loads(response.content) return Response(metadata) @@ -1683,7 +1831,8 @@ class ImportOpenData(APIView): update_existing = str2bool(request.data['update_existing']) use_metric = str2bool(request.data['use_metric']) - response = requests.get(f'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/{selected_version}.json') # TODO catch 404, timeout, ... + response = requests.get( + f'https://raw.githubusercontent.com/TandoorRecipes/open-tandoor-data/main/build/{selected_version}.json') # TODO catch 404, timeout, ... data = json.loads(response.content) response_obj = {} @@ -1722,6 +1871,30 @@ class LocalizationViewSet(viewsets.GenericViewSet): return Response(LocalizationSerializer(langs, many=True).data) +# TODO implement schema +class ServerSettingsViewSet(viewsets.GenericViewSet): + permission_classes = [CustomIsGuest & CustomTokenHasReadWriteScope] + serializer_class = ServerSettingsSerializer + pagination_disabled = True + + def get_queryset(self): + return None + + @extend_schema(responses=ServerSettingsSerializer(many=False)) + @decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=ServerSettingsSerializer, ) + def current(self, request, *args, **kwargs): + s = dict() + s['shopping_min_autosync_interval'] = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL + s['enable_pdf_export'] = settings.ENABLE_PDF_EXPORT + s['disable_external_connectors'] = settings.DISABLE_EXTERNAL_CONNECTORS + s['terms_url'] = settings.TERMS_URL + s['privacy_url'] = settings.PRIVACY_URL + s['imprint_url'] = settings.IMPRINT_URL + s['hosted'] = settings.HOSTED + + return Response(ServerSettingsSerializer(s, many=False).data) + + def get_recipe_provider(recipe): if recipe.storage.method == Storage.DROPBOX: return Dropbox @@ -1768,7 +1941,8 @@ def get_recipe_file(request, recipe_id): # TODO change to some sort of asynchronous trigger 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) @@ -1798,7 +1972,8 @@ def sync_all(request): @extend_schema( request=inline_serializer(name="ShareLinkSerializer", fields={'pk': IntegerField()}), - responses=inline_serializer(name="ShareLinkSerializer", fields={'pk': IntegerField(), 'share': UUIDField(), 'link': CharField()}) + responses=inline_serializer(name="ShareLinkSerializer", + fields={'pk': IntegerField(), 'share': UUIDField(), 'link': CharField()}) ) @api_view(['GET']) # @schema(AutoSchema()) #TODO add proper schema https://drf-spectacular.readthedocs.io/en/latest/customization.html#replace-views-with-openapiviewextension @@ -1807,7 +1982,8 @@ def share_link(request, pk): 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) @@ -1835,7 +2011,9 @@ def meal_plans_to_ical(queryset, filename): @extend_schema( request=inline_serializer(name="IngredientStringSerializer", fields={'text': CharField()}), - responses=inline_serializer(name="ParsedIngredientSerializer", fields={'amount': IntegerField(), 'unit': CharField(), 'food': CharField(), 'note': CharField()}) + responses=inline_serializer(name="ParsedIngredientSerializer", + fields={'amount': IntegerField(), 'unit': CharField(), 'food': CharField(), + 'note': CharField()}) ) @api_view(['POST']) @permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) diff --git a/vue3/src/apps/tandoor/main.ts b/vue3/src/apps/tandoor/main.ts index 3eea376d4..ebbd4025b 100644 --- a/vue3/src/apps/tandoor/main.ts +++ b/vue3/src/apps/tandoor/main.ts @@ -27,7 +27,7 @@ import UserSpaceSettings from "@/components/settings/UserSpaceSettings.vue"; const routes = [ {path: '/', component: StartPage, name: 'view_home'}, {path: '/test', component: TestPage, name: 'view_test'}, - {path: '/settings', component: SettingsPage, name: 'view_settings', + {path: '/settings', component: SettingsPage, name: 'view_settings', redirect: 'settings/account', children: [ {path: 'account', component: AccountSettings, name: 'view_settings_account'}, {path: 'cosmetic', component: CosmeticSettings, name: 'view_settings_cosmetic'}, diff --git a/vue3/src/components/inputs/ModelSelect.vue b/vue3/src/components/inputs/ModelSelect.vue index a93d114e0..2000d7098 100644 --- a/vue3/src/components/inputs/ModelSelect.vue +++ b/vue3/src/components/inputs/ModelSelect.vue @@ -1,8 +1,9 @@