work on settings component

This commit is contained in:
vabene1111
2024-09-09 18:41:25 +02:00
parent abc2dc8437
commit 252a7207f6
14 changed files with 674 additions and 143 deletions

View File

@@ -1338,7 +1338,7 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
validated_data['space'] = self.context['request'].space validated_data['space'] = self.context['request'].space
obj = super().create(validated_data) obj = super().create(validated_data)
if obj.email and EMAIL_HOST is not '': if obj.email and EMAIL_HOST != '':
try: try:
if InviteLink.objects.filter(space=self.context['request'].space, if InviteLink.objects.filter(space=self.context['request'].space,
created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20: created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
@@ -1426,6 +1426,21 @@ class LocalizationSerializer(serializers.Serializer):
fields = '__ALL__' 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 # Export/Import Serializers
class KeywordExportSerializer(KeywordSerializer): class KeywordExportSerializer(KeywordSerializer):

View File

@@ -65,6 +65,7 @@ router.register(r'view-log', api.ViewLogViewSet)
router.register(r'access-token', api.AccessTokenViewSet) router.register(r'access-token', api.AccessTokenViewSet)
router.register(r'localization', api.LocalizationViewSet, basename='localization') router.register(r'localization', api.LocalizationViewSet, basename='localization')
router.register(r'server-settings', api.ServerSettingsViewSet, basename='server-settings')
for p in PLUGINS: for p in PLUGINS:
if c := locate(f'{p["module"]}.urls.{p["api_router_name"]}'): if c := locate(f'{p["module"]}.urls.{p["api_router_name"]}'):

View File

@@ -60,30 +60,47 @@ from cookbook.helper.HelperFunctions import str2bool, validate_import_url
from cookbook.helper.image_processing import handle_image from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.open_data_importer import OpenDataImporter from cookbook.helper.open_data_importer import OpenDataImporter
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly, CustomIsShared, CustomIsSpaceOwner, CustomIsUser, CustomIsGuest, from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly, CustomIsShared,
CustomRecipePermission, CustomTokenHasReadWriteScope, CustomTokenHasScope, CustomUserPermission, IsReadOnlyDRF, above_space_limit, CustomIsSpaceOwner, CustomIsUser, CustomIsGuest,
group_required, has_group_permission, is_space_owner, switch_user_active_space 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_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.shopping_helper import RecipeShoppingEditor, shopping_helper from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, ConnectorConfig, CookLog, CustomFilter, ExportLog, Food, FoodInheritField, FoodProperty, ImportLog, Ingredient, from cookbook.models import (Automation, BookmarkletImport, ConnectorConfig, CookLog, CustomFilter, ExportLog, Food,
InviteLink, Keyword, MealPlan, MealType, Property, PropertyType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingListEntry, FoodInheritField, FoodProperty, ImportLog, Ingredient,
ShoppingListRecipe, Space, Step, Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, 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 UserFile, UserPreference, UserSpace, ViewLog
) )
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, AutoMealPlanSerializer, BookmarkletImportListSerializer, BookmarkletImportSerializer, from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, AutoMealPlanSerializer,
ConnectorConfigConfigSerializer, CookLogSerializer, CustomFilterSerializer, ExportLogSerializer, FoodInheritFieldSerializer, FoodSerializer, BookmarkletImportListSerializer, BookmarkletImportSerializer,
FoodShoppingUpdateSerializer, FoodSimpleSerializer, GroupSerializer, ImportLogSerializer, IngredientSerializer, IngredientSimpleSerializer, ConnectorConfigConfigSerializer, CookLogSerializer, CustomFilterSerializer,
InviteLinkSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer, PropertySerializer, PropertyTypeSerializer, ExportLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
RecipeBookEntrySerializer, RecipeBookSerializer, RecipeExportSerializer, RecipeFlatSerializer, RecipeFromSourceSerializer, RecipeImageSerializer, FoodShoppingUpdateSerializer, FoodSimpleSerializer, GroupSerializer,
RecipeOverviewSerializer, RecipeSerializer, RecipeShoppingUpdateSerializer, RecipeSimpleSerializer, ShoppingListEntryBulkSerializer, ImportLogSerializer, IngredientSerializer, IngredientSimpleSerializer,
ShoppingListEntrySerializer, ShoppingListRecipeSerializer, SpaceSerializer, StepSerializer, StorageSerializer, InviteLinkSerializer, KeywordSerializer, MealPlanSerializer, MealTypeSerializer,
SupermarketCategoryRelationSerializer, SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, PropertySerializer, PropertyTypeSerializer,
UnitConversionSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer, UserSerializer, UserSpaceSerializer, ViewLogSerializer, ImportImageSerializer, LocalizationSerializer 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 cookbook.views.import_export import get_integration
from recipes import settings 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=[ @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='query', description='lookup if query string is contained within the name, case insensitive',
OpenApiParameter(name='updated_at', description='if model has an updated_at timestamp, filter only models updated at or after datetime', type=str, examples=[DateExample]), 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='limit', description='limit number of entries to return', type=str),
OpenApiParameter(name='random', description='randomly orders entries (only works together with limit)', 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 # add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829 # 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)) queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query # add a recipe image annotation to the query
@@ -168,8 +189,11 @@ class ExtendedRecipeMixin():
@extend_schema_view(list=extend_schema(parameters=[ @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='query', description='lookup if query string is contained within the name, case insensitive',
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 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='limit', description='limit number of entries to return', type=str),
OpenApiParameter(name='random', description='randomly orders entries (only works together with limit)', 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) query = self.request.query_params.get('query', None)
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
fuzzy = self.request.user.searchpreference.lookup or any( 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: else:
fuzzy = True fuzzy = True
@@ -188,7 +213,8 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin):
if fuzzy and (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'): if fuzzy and (settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'):
if ( if (
self.request.user.is_authenticated 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)) self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
else: else:
@@ -198,11 +224,13 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin):
# TODO have this check unaccent search settings or other search preferences? # TODO have this check unaccent search settings or other search preferences?
filter = Q(name__icontains=query) filter = Q(name__icontains=query)
if self.request.user.is_authenticated: 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) filter |= Q(name__unaccent__icontains=query)
self.queryset = ( 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', .filter(filter).order_by('-starts',
Lower('name').asc())) Lower('name').asc()))
@@ -227,7 +255,8 @@ class FuzzyFilterMixin(viewsets.ModelViewSet, ExtendedRecipeMixin):
class MergeMixin(ViewSetMixin): class MergeMixin(ViewSetMixin):
@extend_schema(parameters=[ @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<target>[^/.]+)', methods=['PUT'], ) @decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@@ -296,17 +325,21 @@ class MergeMixin(ViewSetMixin):
return Response(content, status=status.HTTP_200_OK) return Response(content, status=status.HTTP_200_OK)
except Exception: except Exception:
traceback.print_exc() 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) return Response(content, status=status.HTTP_400_BAD_REQUEST)
@extend_schema_view( @extend_schema_view(
list=extend_schema(parameters=[ 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), OpenApiParameter(name='tree', description='Return all self and children of {obj} with ID [int].', type=int),
]), ]),
move=extend_schema(parameters=[ 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): class TreeMixin(MergeMixin, FuzzyFilterMixin):
@@ -334,10 +367,12 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
except self.model.DoesNotExist: except self.model.DoesNotExist:
self.queryset = self.model.objects.none() self.queryset = self.model.objects.none()
else: 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()) 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.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer)) @decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@@ -419,10 +454,12 @@ class SpaceViewSet(viewsets.ModelViewSet):
queryset = Space.objects queryset = Space.objects
serializer_class = SpaceSerializer serializer_class = SpaceSerializer
permission_classes = [IsReadOnlyDRF & CustomIsGuest | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope] permission_classes = [IsReadOnlyDRF & CustomIsGuest | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
http_method_names = ['get', 'patch'] http_method_names = ['get', 'patch']
def get_queryset(self): 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)) @extend_schema(responses=SpaceSerializer(many=False))
@decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=SpaceSerializer, ) @decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=SpaceSerializer, )
@@ -588,13 +625,15 @@ class FoodViewSet(TreeMixin):
else: else:
try: try:
shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id] 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 # TODO ugly hack that improves API performance significantly, should be done properly
except AttributeError: # Anonymous users (using share links) don't have shared users except AttributeError: # Anonymous users (using share links) don't have shared users
pass pass
self.queryset = super().get_queryset() 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])) # onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users]))
return self.queryset \ return self.queryset \
.annotate(shopping_status=Exists(shopping_status)) \ .annotate(shopping_status=Exists(shopping_status)) \
@@ -617,7 +656,8 @@ class FoodViewSet(TreeMixin):
shared_users = list(self.request.user.get_shopping_share()) shared_users = list(self.request.user.get_shopping_share())
shared_users.append(request.user) shared_users.append(request.user)
if request.data.get('_delete', False) == 'true': 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.')} content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
return Response(content, status=status.HTTP_204_NO_CONTENT) return Response(content, status=status.HTTP_204_NO_CONTENT)
@@ -625,7 +665,8 @@ class FoodViewSet(TreeMixin):
unit = request.data.get('unit', None) unit = request.data.get('unit', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')} 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) return Response(content, status=status.HTTP_204_NO_CONTENT)
@decorators.action(detail=True, methods=['POST'], ) @decorators.action(detail=True, methods=['POST'], )
@@ -649,9 +690,10 @@ class FoodViewSet(TreeMixin):
status=429, status=429,
json_dumps_params={'indent': 4}) json_dumps_params={'indent': 4})
if response.status_code != 200: 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=****'}, return JsonResponse({
status=response.status_code, 'msg': f'Error while requesting FDC data using url https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key=****'},
json_dumps_params={'indent': 4}) status=response.status_code,
json_dumps_params={'indent': 4})
food.properties_food_amount = 100 food.properties_food_amount = 100
food.properties_food_unit = Unit.objects.get_or_create( food.properties_food_unit = Unit.objects.get_or_create(
@@ -680,12 +722,14 @@ class FoodViewSet(TreeMixin):
property_found = True property_found = True
food_property_list.append( food_property_list.append(
Property(property_type_id=pt.id, 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, space=self.request.space,
)) ))
if not property_found: if not property_found:
food_property_list.append( 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, space=self.request.space,
)) ))
@@ -695,12 +739,14 @@ class FoodViewSet(TreeMixin):
for p in properties: for p in properties:
property_food_relation_list.append(Food.properties.through(food_id=food.id, property_id=p.pk)) 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) return self.retrieve(request, pk)
except Exception: except Exception:
traceback.print_exc() 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): def destroy(self, *args, **kwargs):
try: try:
@@ -711,8 +757,10 @@ class FoodViewSet(TreeMixin):
@extend_schema_view(list=extend_schema(parameters=[ @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_field', description='Field to order recipe books on', type=str,
OpenApiParameter(name='order_direction', description='Order ascending or descending', type=str, enum=['asc', 'desc']), enum=['id', 'name', 'order']),
OpenApiParameter(name='order_direction', description='Order ascending or descending', type=str,
enum=['asc', 'desc']),
])) ]))
class RecipeBookViewSet(StandardFilterModelViewSet): class RecipeBookViewSet(StandardFilterModelViewSet):
queryset = RecipeBook.objects queryset = RecipeBook.objects
@@ -729,7 +777,8 @@ class RecipeBookViewSet(StandardFilterModelViewSet):
ordering = f"{'' if order_direction == 'asc' else '-'}{order_field}" 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() return super().get_queryset()
@@ -744,7 +793,9 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet):
pagination_class = DefaultPagination pagination_class = DefaultPagination
def get_queryset(self): 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) recipe_id = self.request.query_params.get('recipe', None)
if recipe_id is not None: if recipe_id is not None:
@@ -757,14 +808,19 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet):
MealPlanViewQueryParameters = [ MealPlanViewQueryParameters = [
OpenApiParameter(name='from_date', description=_('Filter meal plans from date (inclusive).'), type=str, examples=[DateExample]), OpenApiParameter(name='from_date', description=_('Filter meal plans from date (inclusive).'), type=str,
OpenApiParameter(name='to_date', description=_('Filter meal plans to date (inclusive).'), type=str, examples=[DateExample]), examples=[DateExample]),
OpenApiParameter(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), type=str, many=True), 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), @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): class MealPlanViewSet(viewsets.ModelViewSet):
queryset = MealPlan.objects queryset = MealPlan.objects
serializer_class = MealPlanSerializer serializer_class = MealPlanSerializer
@@ -772,7 +828,8 @@ class MealPlanViewSet(viewsets.ModelViewSet):
pagination_class = DefaultPagination pagination_class = DefaultPagination
def get_queryset(self): 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) from_date = self.request.query_params.get('from_date', None)
if from_date is not None: if from_date is not None:
@@ -870,7 +927,8 @@ class MealTypeViewSet(viewsets.ModelViewSet):
pagination_class = DefaultPagination pagination_class = DefaultPagination
def get_queryset(self): 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 return queryset
@@ -903,7 +961,8 @@ class IngredientViewSet(viewsets.ModelViewSet):
@extend_schema_view(list=extend_schema(parameters=[ @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), OpenApiParameter(name='query', description=_('Query string matched (fuzzy) against object name.'), type=str),
])) ]))
class StepViewSet(viewsets.ModelViewSet): class StepViewSet(viewsets.ModelViewSet):
@@ -933,32 +992,68 @@ class RecipePagination(PageNumberPagination):
return super().paginate_queryset(queryset, request, view) return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data): 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=[ @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='query', description=_(
OpenApiParameter(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), type=int, many=True), 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.'), type=str),
OpenApiParameter(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), type=int, many=True), OpenApiParameter(name='keywords', description=_(
OpenApiParameter(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), type=int, many=True), 'ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), type=int,
OpenApiParameter(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), type=int, many=True), 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='keywords_or',
OpenApiParameter(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), type=int, many=True), description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'),
OpenApiParameter(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), type=int, many=True), 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='keywords_and',
OpenApiParameter(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), type=int, many=True), description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'),
OpenApiParameter(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), type=int, many=True), 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='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='rating', description=_(
OpenApiParameter(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.'), type=int, many=True), 'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), type=int),
OpenApiParameter(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), type=int, many=True), OpenApiParameter(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.'),
OpenApiParameter(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), type=int, many=True), 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_or',
OpenApiParameter(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), type=int, many=True), description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), type=int,
OpenApiParameter(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']'), type=bool), many=True),
OpenApiParameter(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')), OpenApiParameter(name='books_and',
OpenApiParameter(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')), description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), type=int,
OpenApiParameter(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), 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''/''<b>false</b>'']'),
type=bool),
OpenApiParameter(name='random',
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
OpenApiParameter(name='new',
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
OpenApiParameter(name='timescooked', description=_(
'Filter recipes cooked X times or more. Negative values returns cooked less than X times'), type=int),
OpenApiParameter( OpenApiParameter(
name='cookedon', name='cookedon',
description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.'), 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( OpenApiParameter(
name='viewedon', 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, type=str,
examples=[DateExample, BeforeDateExample] examples=[DateExample, BeforeDateExample]
), ),
OpenApiParameter(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']'), type=bool), OpenApiParameter(name='makenow',
description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']'),
type=bool),
])) ]))
class RecipeViewSet(viewsets.ModelViewSet): class RecipeViewSet(viewsets.ModelViewSet):
queryset = Recipe.objects 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 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 if not share: # filter for space only if not shared
self.queryset = self.queryset.filter( self.queryset = self.queryset.filter(
space=self.request.space).prefetch_related('keywords', 'shared', 'properties', 'properties__property_type', 'steps', 'steps__ingredients', space=self.request.space).prefetch_related('keywords', 'shared', 'properties',
'steps__ingredients__step_set', 'steps__ingredients__step_set__recipe_set', 'steps__ingredients__food', 'properties__property_type', 'steps',
'steps__ingredients__food__properties', 'steps__ingredients__food__properties__property_type', 'steps__ingredients',
'steps__ingredients__food__inherit_fields', 'steps__ingredients__food__supermarket_category', 'steps__ingredients__step_set',
'steps__ingredients__food__onhand_users', 'steps__ingredients__food__substitute', 'steps__ingredients__step_set__recipe_set',
'steps__ingredients__food__child_inherit_fields', 'steps__ingredients__unit', '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',
'steps__ingredients__unit__unit_conversion_base_relation__base_unit', 'steps__ingredients__unit__unit_conversion_base_relation__base_unit',
'steps__ingredients__unit__unit_conversion_converted_relation', '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') ).select_related('nutrition')
return super().get_queryset() return super().get_queryset()
self.queryset = self.queryset.filter( 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) search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('keywords', 'cooklog_set') self.queryset = search.get_queryset(self.queryset).prefetch_related('keywords', 'cooklog_set')
return self.queryset return self.queryset
@@ -1030,7 +1139,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
return RecipeOverviewSerializer return RecipeOverviewSerializer
return self.serializer_class 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): def image(self, request, pk):
obj = self.get_object() obj = self.get_object()
@@ -1051,7 +1161,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
try: try:
url = serializer.validated_data['image_url'] url = serializer.validated_data['image_url']
if validate_import_url(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)) image = File(io.BytesIO(response.content))
filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
except UnidentifiedImageError as e: except UnidentifiedImageError as e:
@@ -1088,7 +1199,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
servings = request.data.get('servings', None) servings = request.data.get('servings', None)
list_recipe = request.data.get('list_recipe', None) list_recipe = request.data.get('list_recipe', None)
mealplan = request.data.get('mealplan', 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.')} content = {'msg': _(f'{obj.name} was added to the shopping list.')}
http_status = status.HTTP_204_NO_CONTENT http_status = status.HTTP_204_NO_CONTENT
@@ -1117,19 +1229,22 @@ class RecipeViewSet(viewsets.ModelViewSet):
levels = int(request.query_params.get('levels', 1)) levels = int(request.query_params.get('levels', 1))
except (ValueError, TypeError): except (ValueError, TypeError):
levels = 1 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) return Response(self.serializer_class(qs, many=True).data)
@extend_schema(responses=RecipeFlatSerializer(many=True)) @extend_schema(responses=RecipeFlatSerializer(many=True))
@decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=RecipeFlatSerializer, ) @decorators.action(detail=False, pagination_class=None, methods=['GET'], serializer_class=RecipeFlatSerializer, )
def flat(self, request): def flat(self, request):
# TODO limit fields retrieved but .values() kills image # 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) 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): class UnitConversionViewSet(viewsets.ModelViewSet):
queryset = UnitConversion.objects queryset = UnitConversion.objects
serializer_class = UnitConversionSerializer 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)) 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) return self.queryset.filter(Q(entries__isnull=True)
| Q(entries__created_by=self.request.user) | 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=[ @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( OpenApiParameter(
name='checked', name='checked',
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> \ description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> \
- ''recent'' includes unchecked items and recently completed items.') - ''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): class ShoppingListEntryViewSet(viewsets.ModelViewSet):
queryset = ShoppingListEntry.objects queryset = ShoppingListEntry.objects
@@ -1210,10 +1329,17 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
self.queryset = self.queryset.filter( self.queryset = self.queryset.filter(
Q(created_by=self.request.user) 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', | Q(created_by__in=list(self.request.user.get_shopping_share()))).prefetch_related('created_by', 'food',
'food__inherit_fields', 'food__supermarket_category', 'food__onhand_users', 'food__properties',
'food__substitute', 'food__child_inherit_fields', 'unit', 'list_recipe', 'food__properties__property_type',
'list_recipe__mealplan', 'list_recipe__mealplan__recipe', '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() ).distinct().all()
if pk := self.request.query_params.getlist('id', []): if pk := self.request.query_params.getlist('id', []):
@@ -1223,7 +1349,8 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
return shopping_helper(self.queryset, self.request) return shopping_helper(self.queryset, self.request)
elif not self.detail: elif not self.detail:
today_start = timezone.now().replace(hour=0, minute=0, second=0) 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)) self.queryset = self.queryset.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
try: try:
@@ -1240,15 +1367,18 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
else: else:
return self.queryset[:1000] 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): def bulk(self, request):
serializer = self.serializer_class(data=request.data) serializer = self.serializer_class(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
print(serializer.validated_data) print(serializer.validated_data)
bulk_entries = ShoppingListEntry.objects.filter(Q(created_by=self.request.user) 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, | Q(
id__in=serializer.validated_data['ids']) 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(), ) bulk_entries.update(checked=(checked := serializer.validated_data['checked']), updated_at=timezone.now(), )
# update the onhand for food if shopping_add_onhand is True # 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) 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): class CookLogViewSet(viewsets.ModelViewSet):
queryset = CookLog.objects queryset = CookLog.objects
serializer_class = CookLogSerializer serializer_class = CookLogSerializer
@@ -1405,7 +1536,8 @@ class CustomFilterViewSet(StandardFilterModelViewSet):
filter_type = self.request.query_params.getlist('type', []) filter_type = self.request.query_params.getlist('type', [])
if filter_type: if filter_type:
self.queryset.filter(type__in=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() return super().get_queryset()
@@ -1437,7 +1569,8 @@ class CustomAuthToken(ObtainAuthToken):
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) serializer.is_valid(raise_exception=True)
user = serializer.validated_data['user'] 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 access_token = token
else: else:
access_token = AccessToken.objects.create(user=user, access_token = AccessToken.objects.create(user=user,
@@ -1472,7 +1605,8 @@ class RecipeUrlImportView(APIView):
serializer = RecipeFromSourceSerializer(data=request.data) serializer = RecipeFromSourceSerializer(data=request.data)
if serializer.is_valid(): 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['url'] = bookmarklet.url
serializer.validated_data['data'] = bookmarklet.html serializer.validated_data['data'] = bookmarklet.html
bookmarklet.delete() bookmarklet.delete()
@@ -1485,10 +1619,14 @@ class RecipeUrlImportView(APIView):
elif url and not data: elif url and not data:
if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url): if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
if validate_import_url(url): if validate_import_url(url):
return Response({'recipe_json': get_from_youtube_scraper(url, request), 'recipe_images': [], }, status=status.HTTP_200_OK) return Response({'recipe_json': get_from_youtube_scraper(url, request), 'recipe_images': [], },
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): 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( 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() + re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
recipe_json = clean_dict(recipe_json, 'id') recipe_json = clean_dict(recipe_json, 'id')
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request}) serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
@@ -1496,27 +1634,34 @@ class RecipeUrlImportView(APIView):
recipe = serialized_recipe.save() recipe = serialized_recipe.save()
if validate_import_url(recipe_json['image']): if validate_import_url(recipe_json['image']):
recipe.image = File(handle_image(request, 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), filetype=pathlib.Path(recipe_json['image']).suffix),
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}') name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
recipe.save() 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: else:
try: try:
if validate_import_url(url): if validate_import_url(url):
html = requests.get( html = requests.get(
url, 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 ).content
scrape = scrape_html(org_url=url, html=html, supported_only=False) scrape = scrape_html(org_url=url, html=html, supported_only=False)
else: 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: except NoSchemaFoundInWildMode:
pass pass
except requests.exceptions.ConnectionError: 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: 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: else:
try: try:
data_json = json.loads(data) data_json = json.loads(data)
@@ -1539,7 +1684,8 @@ class RecipeUrlImportView(APIView):
status=status.HTTP_200_OK) status=status.HTTP_200_OK)
else: 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: else:
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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) return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
except NotImplementedError: 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: else:
return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
@@ -1671,7 +1818,8 @@ class ImportOpenData(APIView):
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope] permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
def get(self, request, format=None): 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) metadata = json.loads(response.content)
return Response(metadata) return Response(metadata)
@@ -1683,7 +1831,8 @@ class ImportOpenData(APIView):
update_existing = str2bool(request.data['update_existing']) update_existing = str2bool(request.data['update_existing'])
use_metric = str2bool(request.data['use_metric']) 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) data = json.loads(response.content)
response_obj = {} response_obj = {}
@@ -1722,6 +1871,30 @@ class LocalizationViewSet(viewsets.GenericViewSet):
return Response(LocalizationSerializer(langs, many=True).data) 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): def get_recipe_provider(recipe):
if recipe.storage.method == Storage.DROPBOX: if recipe.storage.method == Storage.DROPBOX:
return Dropbox return Dropbox
@@ -1768,7 +1941,8 @@ def get_recipe_file(request, recipe_id):
# TODO change to some sort of asynchronous trigger # TODO change to some sort of asynchronous trigger
def sync_all(request): def sync_all(request):
if request.space.demo or settings.HOSTED: 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') return redirect('index')
monitors = Sync.objects.filter(active=True).filter(space=request.user.userspace_set.filter(active=1).first().space) 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( @extend_schema(
request=inline_serializer(name="ShareLinkSerializer", fields={'pk': IntegerField()}), 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']) @api_view(['GET'])
# @schema(AutoSchema()) #TODO add proper schema https://drf-spectacular.readthedocs.io/en/latest/customization.html#replace-views-with-openapiviewextension # @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',)): if request.space.allow_sharing and has_group_permission(request.user, ('user',)):
recipe = get_object_or_404(Recipe, pk=pk, space=request.space) recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
link = ShareLink.objects.create(recipe=recipe, created_by=request.user, 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: else:
return JsonResponse({'error': 'sharing_disabled'}, status=403) return JsonResponse({'error': 'sharing_disabled'}, status=403)
@@ -1835,7 +2011,9 @@ def meal_plans_to_ical(queryset, filename):
@extend_schema( @extend_schema(
request=inline_serializer(name="IngredientStringSerializer", fields={'text': CharField()}), 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']) @api_view(['POST'])
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope]) @permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])

View File

@@ -27,7 +27,7 @@ import UserSpaceSettings from "@/components/settings/UserSpaceSettings.vue";
const routes = [ const routes = [
{path: '/', component: StartPage, name: 'view_home'}, {path: '/', component: StartPage, name: 'view_home'},
{path: '/test', component: TestPage, name: 'view_test'}, {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: [ children: [
{path: 'account', component: AccountSettings, name: 'view_settings_account'}, {path: 'account', component: AccountSettings, name: 'view_settings_account'},
{path: 'cosmetic', component: CosmeticSettings, name: 'view_settings_cosmetic'}, {path: 'cosmetic', component: CosmeticSettings, name: 'view_settings_cosmetic'},

View File

@@ -1,8 +1,9 @@
<template> <template>
<v-input> <v-input :hint="hint" persistent-hint :label="label">
<!--TODO resolve-on-load false for now, race condition with model class, make prop once better solution is found --> <!-- TODO resolve-on-load false for now, race condition with model class, make prop once better solution is found -->
<!-- TODO strange behavior/layering issues with appendTo body, find soltion to make it work --> <!-- TODO strange behavior/layering issues with appendTo body, find solution to make it work -->
<!-- TODO label is not showing for some reason -->
<Multiselect <Multiselect
:id="id" :id="id"
@@ -23,9 +24,9 @@
:can-clear="canClear" :can-clear="canClear"
:can-deselect="canClear" :can-deselect="canClear"
:limit="limit" :limit="limit"
placeholder="TODO ADD LOCALIZED PLACEHOLDER" :placeholder="$t('Search')"
noOptionsText="TODO ADD LOCALIZED NO-OPTIONS" :noOptionsText="$t('No_Results')"
noResultsText="TODO ADD LOCALIZED NO-RESULTS" :noResultsText="$t('No_Results')"
/> />
</v-input> </v-input>
@@ -59,6 +60,9 @@ const props = defineProps({
noOptionsText: {type: String, default: undefined}, noOptionsText: {type: String, default: undefined},
noResultsText: {type: String, default: undefined}, noResultsText: {type: String, default: undefined},
label: {type: String, default: ''},
hint: {type: String, default: ''},
// not verified // not verified
search_on_load: {type: Boolean, default: false}, search_on_load: {type: Boolean, default: false},

View File

@@ -1,10 +1,59 @@
<template> <template>
<v-form> <v-form>
<p class="text-h6">{{ $t('Cosmetic') }}</p> <p class="text-h6">{{ $t('Shopping_list') }}</p>
<v-divider class="mb-3"></v-divider> <v-divider class="mb-3"></v-divider>
<ModelSelect :hint="$t('shopping_share_desc')" :label="$t('shopping_share')" model="User" :allow-create="false"
v-model="useUserPreferenceStore().userSettings.shoppingShare" item-label="displayName"
mode="tags"></ModelSelect>
<v-btn class="mt-3" color="success" @click="useUserPreferenceStore().updateUserSettings()" prepend-icon="$save">{{$t('Save')}}</v-btn> <v-number-input
class="mt-2"
:label="$t('shopping_auto_sync')"
:hint="$t('shopping_auto_sync_desc')"
persistent-hint
controlVariant="split"
v-model="useUserPreferenceStore().userSettings.shoppingAutoSync"
:step="useUserPreferenceStore().serverSettings.shoppingMinAutosyncInterval"
min="0"
>
<template #append>
<v-btn @click="useUserPreferenceStore().userSettings.shoppingAutoSync = 0">{{$t('Disable')}}</v-btn>
</template>
</v-number-input>
<v-checkbox :label="$t('mealplan_autoadd_shopping')" :hint="$t('mealplan_autoadd_shopping_desc')" persistent-hint v-model="useUserPreferenceStore().userSettings.mealplanAutoaddShopping"></v-checkbox>
<v-checkbox :label="$t('mealplan_autoexclude_onhand')" :hint="$t('mealplan_autoexclude_onhand_desc')" persistent-hint v-model="useUserPreferenceStore().userSettings.mealplanAutoexcludeOnhand"></v-checkbox>
<v-checkbox :label="$t('mealplan_autoinclude_related')" :hint="$t('mealplan_autoinclude_related_desc')" persistent-hint v-model="useUserPreferenceStore().userSettings.mealplanAutoincludeRelated"></v-checkbox>
<v-checkbox :label="$t('shopping_add_onhand')" :hint="$t('shopping_add_onhand_desc')" persistent-hint v-model="useUserPreferenceStore().userSettings.shoppingAddOnhand"></v-checkbox>
<v-checkbox :label="$t('filter_to_supermarket')" :hint="$t('filter_to_supermarket_desc')" persistent-hint v-model="useUserPreferenceStore().userSettings.filterToSupermarket"></v-checkbox>
<v-number-input
class="mt-2"
:label="$t('default_delay')"
:hint="$t('default_delay_desc')"
persistent-hint
controlVariant="split"
v-model="useUserPreferenceStore().userSettings.defaultDelay"
min="1"
></v-number-input>
<v-number-input
class="mt-2"
:label="$t('shopping_recent_days')"
:hint="$t('shopping_recent_days_desc')"
persistent-hint
controlVariant="split"
v-model="useUserPreferenceStore().userSettings.shoppingRecentDays"
min="0"
></v-number-input>
<v-text-field :label="$t('csv_delim_label')" :hint="$t('csv_delim_help')" persistent-hint v-model="useUserPreferenceStore().userSettings.csvDelim"></v-text-field>
<v-text-field :label="$t('csv_prefix_label')" :hint="$t('csv_prefix_help')" persistent-hint v-model="useUserPreferenceStore().userSettings.csvPrefix"></v-text-field>
<v-btn class="mt-3" color="success" @click="useUserPreferenceStore().updateUserSettings()" prepend-icon="$save">
{{ $t('Save') }}
</v-btn>
</v-form> </v-form>
</template> </template>
@@ -12,8 +61,9 @@
<script setup lang="ts"> <script setup lang="ts">
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore"; import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {VNumberInput} from 'vuetify/labs/VNumberInput' //TODO remove once component is out of labs
</script> </script>
<style scoped> <style scoped>

View File

@@ -31,7 +31,7 @@ onMounted(() => {
const api = new ApiApi() const api = new ApiApi()
api.apiSpaceList().then(r => { api.apiSpaceList().then(r => {
spaces.value = r spaces.value = r.results
}).catch(err => { }).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err) useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
}) })

View File

@@ -63,6 +63,7 @@ models/PaginatedRecipeBookList.ts
models/PaginatedRecipeOverviewList.ts models/PaginatedRecipeOverviewList.ts
models/PaginatedShoppingListEntryList.ts models/PaginatedShoppingListEntryList.ts
models/PaginatedShoppingListRecipeList.ts models/PaginatedShoppingListRecipeList.ts
models/PaginatedSpaceList.ts
models/PaginatedStepList.ts models/PaginatedStepList.ts
models/PaginatedSupermarketCategoryList.ts models/PaginatedSupermarketCategoryList.ts
models/PaginatedSupermarketCategoryRelationList.ts models/PaginatedSupermarketCategoryRelationList.ts
@@ -126,6 +127,7 @@ models/RecipeImage.ts
models/RecipeOverview.ts models/RecipeOverview.ts
models/RecipeShoppingUpdate.ts models/RecipeShoppingUpdate.ts
models/RecipeSimple.ts models/RecipeSimple.ts
models/ServerSettings.ts
models/ShareLink.ts models/ShareLink.ts
models/ShoppingListEntry.ts models/ShoppingListEntry.ts
models/ShoppingListEntryBulk.ts models/ShoppingListEntryBulk.ts

View File

@@ -62,6 +62,7 @@ import type {
PaginatedRecipeOverviewList, PaginatedRecipeOverviewList,
PaginatedShoppingListEntryList, PaginatedShoppingListEntryList,
PaginatedShoppingListRecipeList, PaginatedShoppingListRecipeList,
PaginatedSpaceList,
PaginatedStepList, PaginatedStepList,
PaginatedSupermarketCategoryList, PaginatedSupermarketCategoryList,
PaginatedSupermarketCategoryRelationList, PaginatedSupermarketCategoryRelationList,
@@ -124,6 +125,7 @@ import type {
RecipeImage, RecipeImage,
RecipeShoppingUpdate, RecipeShoppingUpdate,
RecipeSimple, RecipeSimple,
ServerSettings,
ShareLink, ShareLink,
ShoppingListEntry, ShoppingListEntry,
ShoppingListEntryBulk, ShoppingListEntryBulk,
@@ -239,6 +241,8 @@ import {
PaginatedShoppingListEntryListToJSON, PaginatedShoppingListEntryListToJSON,
PaginatedShoppingListRecipeListFromJSON, PaginatedShoppingListRecipeListFromJSON,
PaginatedShoppingListRecipeListToJSON, PaginatedShoppingListRecipeListToJSON,
PaginatedSpaceListFromJSON,
PaginatedSpaceListToJSON,
PaginatedStepListFromJSON, PaginatedStepListFromJSON,
PaginatedStepListToJSON, PaginatedStepListToJSON,
PaginatedSupermarketCategoryListFromJSON, PaginatedSupermarketCategoryListFromJSON,
@@ -363,6 +367,8 @@ import {
RecipeShoppingUpdateToJSON, RecipeShoppingUpdateToJSON,
RecipeSimpleFromJSON, RecipeSimpleFromJSON,
RecipeSimpleToJSON, RecipeSimpleToJSON,
ServerSettingsFromJSON,
ServerSettingsToJSON,
ShareLinkFromJSON, ShareLinkFromJSON,
ShareLinkToJSON, ShareLinkToJSON,
ShoppingListEntryFromJSON, ShoppingListEntryFromJSON,
@@ -1281,6 +1287,11 @@ export interface ApiShoppingListRecipeUpdateRequest {
shoppingListRecipe: ShoppingListRecipe; shoppingListRecipe: ShoppingListRecipe;
} }
export interface ApiSpaceListRequest {
page?: number;
pageSize?: number;
}
export interface ApiSpacePartialUpdateRequest { export interface ApiSpacePartialUpdateRequest {
id: number; id: number;
patchedSpace?: PatchedSpace; patchedSpace?: PatchedSpace;
@@ -8769,6 +8780,34 @@ export class ApiApi extends runtime.BaseAPI {
await this.apiResetFoodInheritanceCreateRaw(initOverrides); await this.apiResetFoodInheritanceCreateRaw(initOverrides);
} }
/**
*/
async apiServerSettingsCurrentRetrieveRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ServerSettings>> {
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.apiKey) {
headerParameters["Authorization"] = await this.configuration.apiKey("Authorization"); // ApiKeyAuth authentication
}
const response = await this.request({
path: `/api/server-settings/current/`,
method: 'GET',
headers: headerParameters,
query: queryParameters,
}, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => ServerSettingsFromJSON(jsonValue));
}
/**
*/
async apiServerSettingsCurrentRetrieve(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<ServerSettings> {
const response = await this.apiServerSettingsCurrentRetrieveRaw(initOverrides);
return await response.value();
}
/** /**
*/ */
async apiShareLinkRetrieveRaw(requestParameters: ApiShareLinkRetrieveRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ShareLink>> { async apiShareLinkRetrieveRaw(requestParameters: ApiShareLinkRetrieveRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<ShareLink>> {
@@ -9336,9 +9375,17 @@ export class ApiApi extends runtime.BaseAPI {
/** /**
*/ */
async apiSpaceListRaw(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<Array<Space>>> { async apiSpaceListRaw(requestParameters: ApiSpaceListRequest, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<runtime.ApiResponse<PaginatedSpaceList>> {
const queryParameters: any = {}; const queryParameters: any = {};
if (requestParameters['page'] != null) {
queryParameters['page'] = requestParameters['page'];
}
if (requestParameters['pageSize'] != null) {
queryParameters['page_size'] = requestParameters['pageSize'];
}
const headerParameters: runtime.HTTPHeaders = {}; const headerParameters: runtime.HTTPHeaders = {};
if (this.configuration && this.configuration.apiKey) { if (this.configuration && this.configuration.apiKey) {
@@ -9352,13 +9399,13 @@ export class ApiApi extends runtime.BaseAPI {
query: queryParameters, query: queryParameters,
}, initOverrides); }, initOverrides);
return new runtime.JSONApiResponse(response, (jsonValue) => jsonValue.map(SpaceFromJSON)); return new runtime.JSONApiResponse(response, (jsonValue) => PaginatedSpaceListFromJSON(jsonValue));
} }
/** /**
*/ */
async apiSpaceList(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<Space>> { async apiSpaceList(requestParameters: ApiSpaceListRequest = {}, initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<PaginatedSpaceList> {
const response = await this.apiSpaceListRaw(initOverrides); const response = await this.apiSpaceListRaw(requestParameters, initOverrides);
return await response.value(); return await response.value();
} }

View File

@@ -0,0 +1,93 @@
/* tslint:disable */
/* eslint-disable */
/**
* Tandoor
* Tandoor API Docs
*
* The version of the OpenAPI document: 0.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { mapValues } from '../runtime';
import type { Space } from './Space';
import {
SpaceFromJSON,
SpaceFromJSONTyped,
SpaceToJSON,
} from './Space';
/**
*
* @export
* @interface PaginatedSpaceList
*/
export interface PaginatedSpaceList {
/**
*
* @type {number}
* @memberof PaginatedSpaceList
*/
count: number;
/**
*
* @type {string}
* @memberof PaginatedSpaceList
*/
next?: string;
/**
*
* @type {string}
* @memberof PaginatedSpaceList
*/
previous?: string;
/**
*
* @type {Array<Space>}
* @memberof PaginatedSpaceList
*/
results: Array<Space>;
}
/**
* Check if a given object implements the PaginatedSpaceList interface.
*/
export function instanceOfPaginatedSpaceList(value: object): boolean {
if (!('count' in value)) return false;
if (!('results' in value)) return false;
return true;
}
export function PaginatedSpaceListFromJSON(json: any): PaginatedSpaceList {
return PaginatedSpaceListFromJSONTyped(json, false);
}
export function PaginatedSpaceListFromJSONTyped(json: any, ignoreDiscriminator: boolean): PaginatedSpaceList {
if (json == null) {
return json;
}
return {
'count': json['count'],
'next': json['next'] == null ? undefined : json['next'],
'previous': json['previous'] == null ? undefined : json['previous'],
'results': ((json['results'] as Array<any>).map(SpaceFromJSON)),
};
}
export function PaginatedSpaceListToJSON(value?: PaginatedSpaceList | null): any {
if (value == null) {
return value;
}
return {
'count': value['count'],
'next': value['next'],
'previous': value['previous'],
'results': ((value['results'] as Array<any>).map(SpaceToJSON)),
};
}

View File

@@ -0,0 +1,115 @@
/* tslint:disable */
/* eslint-disable */
/**
* Tandoor
* Tandoor API Docs
*
* The version of the OpenAPI document: 0.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { mapValues } from '../runtime';
/**
*
* @export
* @interface ServerSettings
*/
export interface ServerSettings {
/**
*
* @type {string}
* @memberof ServerSettings
*/
shoppingMinAutosyncInterval: string;
/**
*
* @type {boolean}
* @memberof ServerSettings
*/
enablePdfExport: boolean;
/**
*
* @type {boolean}
* @memberof ServerSettings
*/
disableExternalConnectors: boolean;
/**
*
* @type {string}
* @memberof ServerSettings
*/
termsUrl: string;
/**
*
* @type {string}
* @memberof ServerSettings
*/
privacyUrl: string;
/**
*
* @type {string}
* @memberof ServerSettings
*/
imprintUrl: string;
/**
*
* @type {boolean}
* @memberof ServerSettings
*/
hosted: boolean;
}
/**
* Check if a given object implements the ServerSettings interface.
*/
export function instanceOfServerSettings(value: object): boolean {
if (!('shoppingMinAutosyncInterval' in value)) return false;
if (!('enablePdfExport' in value)) return false;
if (!('disableExternalConnectors' in value)) return false;
if (!('termsUrl' in value)) return false;
if (!('privacyUrl' in value)) return false;
if (!('imprintUrl' in value)) return false;
if (!('hosted' in value)) return false;
return true;
}
export function ServerSettingsFromJSON(json: any): ServerSettings {
return ServerSettingsFromJSONTyped(json, false);
}
export function ServerSettingsFromJSONTyped(json: any, ignoreDiscriminator: boolean): ServerSettings {
if (json == null) {
return json;
}
return {
'shoppingMinAutosyncInterval': json['shopping_min_autosync_interval'],
'enablePdfExport': json['enable_pdf_export'],
'disableExternalConnectors': json['disable_external_connectors'],
'termsUrl': json['terms_url'],
'privacyUrl': json['privacy_url'],
'imprintUrl': json['imprint_url'],
'hosted': json['hosted'],
};
}
export function ServerSettingsToJSON(value?: ServerSettings | null): any {
if (value == null) {
return value;
}
return {
'shopping_min_autosync_interval': value['shoppingMinAutosyncInterval'],
'enable_pdf_export': value['enablePdfExport'],
'disable_external_connectors': value['disableExternalConnectors'],
'terms_url': value['termsUrl'],
'privacy_url': value['privacyUrl'],
'imprint_url': value['imprintUrl'],
'hosted': value['hosted'],
};
}

View File

@@ -60,6 +60,7 @@ export * from './PaginatedRecipeBookList';
export * from './PaginatedRecipeOverviewList'; export * from './PaginatedRecipeOverviewList';
export * from './PaginatedShoppingListEntryList'; export * from './PaginatedShoppingListEntryList';
export * from './PaginatedShoppingListRecipeList'; export * from './PaginatedShoppingListRecipeList';
export * from './PaginatedSpaceList';
export * from './PaginatedStepList'; export * from './PaginatedStepList';
export * from './PaginatedSupermarketCategoryList'; export * from './PaginatedSupermarketCategoryList';
export * from './PaginatedSupermarketCategoryRelationList'; export * from './PaginatedSupermarketCategoryRelationList';
@@ -123,6 +124,7 @@ export * from './RecipeImage';
export * from './RecipeOverview'; export * from './RecipeOverview';
export * from './RecipeShoppingUpdate'; export * from './RecipeShoppingUpdate';
export * from './RecipeSimple'; export * from './RecipeSimple';
export * from './ServerSettings';
export * from './ShareLink'; export * from './ShareLink';
export * from './ShoppingListEntry'; export * from './ShoppingListEntry';
export * from './ShoppingListEntryBulk'; export * from './ShoppingListEntryBulk';

View File

@@ -93,7 +93,11 @@ export const useMessageStore = defineStore('message_store', () => {
function addPreparedMessage(preparedMessage: PreparedMessage) { function addPreparedMessage(preparedMessage: PreparedMessage) {
if (preparedMessage == PreparedMessage.UPDATE_SUCCESS) { if (preparedMessage == PreparedMessage.UPDATE_SUCCESS) {
addMessage(MessageType.SUCCESS, 'Updated Successfully', 7000, {}) // TODO localize and make more useful ? addMessage(MessageType.SUCCESS, 'Updated Successfully', 7000, {}) // TODO localize and make more useful ?
}
if (preparedMessage == PreparedMessage.DELETE_SUCCESS) {
addMessage(MessageType.SUCCESS, 'Created Successfully', 7000, {}) addMessage(MessageType.SUCCESS, 'Created Successfully', 7000, {})
}
if (preparedMessage == PreparedMessage.CREATE_SUCCESS) {
addMessage(MessageType.SUCCESS, 'Deleted Successfully', 7000, {}) addMessage(MessageType.SUCCESS, 'Deleted Successfully', 7000, {})
} }
} }

View File

@@ -1,10 +1,11 @@
import {acceptHMRUpdate, defineStore} from 'pinia' import {acceptHMRUpdate, defineStore} from 'pinia'
import {useStorage} from "@vueuse/core"; import {useStorage} from "@vueuse/core";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore"; import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import {ApiApi, Space, UserPreference} from "@/openapi"; import {ApiApi, ServerSettings, Space, UserPreference} from "@/openapi";
const DEVICE_SETTINGS_KEY = 'TANDOOR_DEVICE_SETTINGS' const DEVICE_SETTINGS_KEY = 'TANDOOR_DEVICE_SETTINGS'
const USER_PREFERENCE_KEY = 'TANDOOR_USER_PREFERENCE' const USER_PREFERENCE_KEY = 'TANDOOR_USER_PREFERENCE'
const SERVER_SETTINGS_KEY = 'TANDOOR_SERVER_SETTINGS'
const ACTIVE_SPACE_KEY = 'TANDOOR_ACTIVE_SPACE' const ACTIVE_SPACE_KEY = 'TANDOOR_ACTIVE_SPACE'
class DeviceSettings { class DeviceSettings {
@@ -29,6 +30,10 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
* database user settings, cache in local storage in case application is started offline * database user settings, cache in local storage in case application is started offline
*/ */
let userSettings = useStorage(USER_PREFERENCE_KEY, {} as UserPreference) let userSettings = useStorage(USER_PREFERENCE_KEY, {} as UserPreference)
/**
* some defaults and values returned by server
*/
let serverSettings = useStorage(SERVER_SETTINGS_KEY, {} as ServerSettings)
/** /**
* database user settings, cache in local storage in case application is started offline * database user settings, cache in local storage in case application is started offline
*/ */
@@ -59,11 +64,24 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
api.apiUserPreferencePartialUpdate({user: userSettings.value.user, patchedUserPreference: userSettings.value}).then(r => { api.apiUserPreferencePartialUpdate({user: userSettings.value.user, patchedUserPreference: userSettings.value}).then(r => {
userSettings.value = r userSettings.value = r
useMessageStore().addPreparedMessage(PreparedMessage.UPDATE_SUCCESS)
}).catch(err => { }).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err) useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}) })
} }
/**
* retrieves server settings from API
*/
function loadServerSettings() {
let api = new ApiApi()
api.apiServerSettingsCurrentRetrieve().then(r => {
serverSettings.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
}
/** /**
* load data for currently active space * load data for currently active space
*/ */
@@ -91,10 +109,12 @@ export const useUserPreferenceStore = defineStore('user_preference_store', () =>
// always load user settings on first initialization of store // always load user settings on first initialization of store
loadUserSettings() loadUserSettings()
// always load server settings on first initialization of store
loadServerSettings()
// always load active space on first initialization of store // always load active space on first initialization of store
loadActiveSpace() loadActiveSpace()
return {deviceSettings, userSettings, activeSpace, loadUserSettings, updateUserSettings, switchSpace} return {deviceSettings, userSettings, serverSettings, activeSpace, loadUserSettings, loadServerSettings,updateUserSettings, switchSpace}
}) })
// enable hot reload for store // enable hot reload for store