diff --git a/cookbook/serializer.py b/cookbook/serializer.py index d4265c465..89314df01 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -1238,12 +1238,13 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): 'recipe_mealplan', 'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until' ) - read_only_fields = ('id', 'created_by', 'created_at', 'updated_at',) + read_only_fields = ('id', 'created_by', 'created_at') class ShoppingListEntryBulkSerializer(serializers.Serializer): ids = serializers.ListField() checked = serializers.BooleanField() + timestamp = serializers.DateTimeField(read_only=True, required=False) # TODO deprecate diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 5ccb683d9..e69845839 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -30,7 +30,9 @@ from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone +from django.utils.dateparse import parse_datetime from django.utils.datetime_safe import date +from django.utils.timezone import make_aware from django.utils.translation import gettext as _ from django_scopes import scopes_disabled from drf_spectacular.types import OpenApiTypes @@ -111,6 +113,7 @@ from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY, GOOGLE DateExample = OpenApiExample('Date Format', value='1972-12-05', request_only=True) BeforeDateExample = OpenApiExample('Before Date Format', value='-1972-12-05', request_only=True) + class LoggingMixin(object): """ logs request counts to redis cache total/per user/ @@ -150,6 +153,7 @@ class LoggingMixin(object): pipe.execute() + @extend_schema_view(list=extend_schema(parameters=[ OpenApiParameter(name='query', description='lookup if query string is contained within the name, case insensitive', type=str), @@ -187,15 +191,36 @@ class StandardFilterModelViewSet(viewsets.ModelViewSet): class DefaultPagination(PageNumberPagination): + """ + Default pagination class to set the default and maximum page size + also annotates the current server timestamp to all results + """ page_size = 50 page_size_query_param = 'page_size' max_page_size = 200 + def get_paginated_response(self, data): + return Response({ + 'count': self.page.paginator.count, + 'next': self.get_next_link(), + 'previous': self.get_previous_link(), + 'timestamp': timezone.now().isoformat(), + 'results': data, + }) + + def get_paginated_response_schema(self, schema): + schema = super().get_paginated_response_schema(schema) + schema['properties']['timestamp'] = { + 'type': 'string', + 'format': 'date-time', + } + return schema + class ExtendedRecipeMixin(): - ''' + """ ExtendedRecipe annotates a queryset with recipe_image and recipe_count values - ''' + """ @classmethod def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False): @@ -1356,6 +1381,9 @@ class ShoppingListRecipeViewSet(LoggingMixin, viewsets.ModelViewSet): OpenApiParameter(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), type=int), + OpenApiParameter(name='updated_after', + description=_('Returns only elements updated after the given timestamp in ISO 8601 format.'), + type=datetime.datetime), ])) class ShoppingListEntryViewSet(LoggingMixin, viewsets.ModelViewSet): queryset = ShoppingListEntry.objects @@ -1381,12 +1409,14 @@ class ShoppingListEntryViewSet(LoggingMixin, viewsets.ModelViewSet): 'list_recipe__mealplan__recipe', ).distinct().all() + updated_after = self.request.query_params.get('updated_after', None) + if pk := self.request.query_params.getlist('id', []): self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk]) if 'checked' in self.request.query_params: return shopping_helper(self.queryset, self.request) - elif not self.detail: + elif not self.detail and not updated_after: 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)) @@ -1395,11 +1425,20 @@ class ShoppingListEntryViewSet(LoggingMixin, viewsets.ModelViewSet): try: last_autosync = self.request.query_params.get('last_autosync', None) if last_autosync: + print('DEPRECATION WARNING: using last_autosync is deprecated and will be removed in future versions, please use updated_after') last_autosync = datetime.datetime.fromtimestamp(int(last_autosync) / 1000, datetime.timezone.utc) self.queryset = self.queryset.filter(updated_at__gte=last_autosync) except Exception: traceback.print_exc() + try: + if updated_after: + updated_after = parse_datetime(updated_after) + print('adding filter updated_after', updated_after) + self.queryset = self.queryset.filter(updated_at__gte=updated_after) + except Exception: + traceback.print_exc() + # TODO once old shopping list is removed this needs updated to sharing users in preferences if self.detail: return self.queryset @@ -1413,12 +1452,15 @@ class ShoppingListEntryViewSet(LoggingMixin, viewsets.ModelViewSet): 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']) - bulk_entries.update(checked=(checked := serializer.validated_data['checked']), updated_at=timezone.now(), ) + 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'] + ) + + update_timestamp = timezone.now() + bulk_entries.update(checked=(checked := serializer.validated_data['checked']), updated_at=update_timestamp, ) + serializer.validated_data['timestamp'] = update_timestamp # update the onhand for food if shopping_add_onhand is True if request.user.userpreference.shopping_add_onhand: @@ -1430,7 +1472,7 @@ class ShoppingListEntryViewSet(LoggingMixin, viewsets.ModelViewSet): for f in foods: f.onhand_users.remove(*request.user.userpreference.shopping_share.all(), request.user) - return Response(serializer.data) + return Response(serializer.validated_data) else: return Response(serializer.errors, 400) diff --git a/vue3/src/components/display/ShoppingListView.vue b/vue3/src/components/display/ShoppingListView.vue index ce087ba0f..1453a2bf9 100644 --- a/vue3/src/components/display/ShoppingListView.vue +++ b/vue3/src/components/display/ShoppingListView.vue @@ -1,6 +1,8 @@ @@ -74,6 +77,22 @@ + + + + + Auto Sync Debug + + + currentlyUpdating: {{ useShoppingStore().currentlyUpdating }} + hasFocus: {{ useShoppingStore().autoSyncHasFocus }} + autoSyncTimeoutId: {{ useShoppingStore().autoSyncTimeoutId }} + autoSyncLastTimestamp: {{ useShoppingStore().autoSyncLastTimestamp }} + + + + + @@ -134,7 +153,13 @@ const groupingOptionsItems = computed(() => { }) onMounted(() => { + addEventListener("visibilitychange", (event) => { + useShoppingStore().autoSyncHasFocus = (document.visibilityState === 'visible') + }); + useShoppingStore().refreshFromAPI() + + autoSyncLoop() }) /** @@ -160,6 +185,23 @@ function addIngredient() { }) } +/** + * run the autosync function in a loop + */ +function autoSyncLoop() { + // this should not happen in production but sometimes in development with HMR + clearTimeout(useShoppingStore().autoSyncTimeoutId) + + let timeout = Math.max(useUserPreferenceStore().userSettings.shoppingAutoSync!, 1) * 1000 // if disabled (shopping_auto_sync=0) check again after 1 second if enabled + + useShoppingStore().autoSyncTimeoutId = setTimeout(() => { + if (useUserPreferenceStore().userSettings.shoppingAutoSync! > 0) { + useShoppingStore().autoSync() + } + autoSyncLoop() + }, timeout) +} +