diff --git a/.idea/dictionaries/vaben.xml b/.idea/dictionaries/vaben.xml index e910c82d..a8b8f4df 100644 --- a/.idea/dictionaries/vaben.xml +++ b/.idea/dictionaries/vaben.xml @@ -1,6 +1,7 @@ + mealplan pinia selfhosted unapplied diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index ffcf5bea..c7772a07 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -27,9 +27,6 @@ def shopping_helper(qs, request): elif checked in ['true', 1, '1']: qs = qs.filter(checked=True) elif checked in ['recent']: - today_start = timezone.now().replace(hour=0, minute=0, second=0) - week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days) - qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) supermarket_order = ['checked'] + supermarket_order return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') diff --git a/cookbook/migrations/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py index 9246c9b0..ca7ead35 100644 --- a/cookbook/migrations/0159_add_shoppinglistentry_fields.py +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -6,11 +6,12 @@ from django.conf import settings from django.db import migrations, models from django_scopes import scopes_disabled -from cookbook.models import PermissionModelMixin, ShoppingListEntry +from cookbook.models import PermissionModelMixin def copy_values_to_sle(apps, schema_editor): with scopes_disabled(): + ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry') entries = ShoppingListEntry.objects.all() for entry in entries: if entry.shoppinglist_set.first(): diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py index 26e08656..6966eae6 100644 --- a/cookbook/migrations/0160_delete_shoppinglist_orphans.py +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -1,25 +1,22 @@ # Generated by Django 3.2.7 on 2021-10-01 22:34 -import datetime from datetime import timedelta -import django.db.models.deletion from django.conf import settings -from django.db import migrations, models +from django.db import migrations from django.utils import timezone -from django.utils.timezone import utc from django_scopes import scopes_disabled -from cookbook.models import FoodInheritField, ShoppingListEntry - def delete_orphaned_sle(apps, schema_editor): + ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry') with scopes_disabled(): # shopping list entry is orphaned - delete it ShoppingListEntry.objects.filter(shoppinglist=None).delete() def create_inheritfields(apps, schema_editor): + FoodInheritField = apps.get_model('cookbook', 'FoodInheritField') FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category') FoodInheritField.objects.create(name='On Hand', field='food_onhand') FoodInheritField.objects.create(name='Diet', field='diet') @@ -29,6 +26,7 @@ def create_inheritfields(apps, schema_editor): def set_completed_at(apps, schema_editor): + ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry') today_start = timezone.now().replace(hour=0, minute=0, second=0) # arbitrary - keeping all of the closed shopping list items out of the 'recent' view month_ago = today_start - timedelta(days=30) diff --git a/cookbook/migrations/0210_shoppinglistentry_updated_at.py b/cookbook/migrations/0210_shoppinglistentry_updated_at.py new file mode 100644 index 00000000..28c2a135 --- /dev/null +++ b/cookbook/migrations/0210_shoppinglistentry_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-01-28 10:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0209_remove_space_use_plural'), + ] + + operations = [ + migrations.AddField( + model_name='shoppinglistentry', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 9954133d..a0a2cbf3 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -763,7 +763,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss objects = ScopedManager(space='space') def __str__(self): - return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}' + return f'{self.pk}: {self.amount} ' + (self.food.name if self.food else ' ') + (self.unit.name if self.unit else '') class Meta: ordering = ['order', 'pk'] @@ -1099,6 +1099,8 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model checked = models.BooleanField(default=False) created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) delay_until = models.DateTimeField(null=True, blank=True) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index df8aff88..4c7e2dfb 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -57,7 +57,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): api_serializer = None # extended values are computationally expensive and not needed in normal circumstances try: - if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: + if str2bool( + self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: return fields except (AttributeError, KeyError): pass @@ -81,12 +82,14 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): class OpenDataModelMixin(serializers.ModelSerializer): def create(self, validated_data): - if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '': + if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[ + 'open_data_slug'].strip() == '': validated_data['open_data_slug'] = None return super().create(validated_data) def update(self, instance, validated_data): - if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '': + if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[ + 'open_data_slug'].strip() == '': validated_data['open_data_slug'] = None return super().update(instance, validated_data) @@ -122,7 +125,8 @@ class CustomOnHandField(serializers.Field): if not self.context["request"].user.is_authenticated: return [] shared_users = [] - if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): + if c := caches['default'].get( + f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): shared_users = c else: try: @@ -332,7 +336,8 @@ class UserSpaceSerializer(WritableNestedModelSerializer): class Meta: model = UserSpace - fields = ('id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',) + fields = ( + 'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',) read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space') @@ -382,13 +387,15 @@ class UserPreferenceSerializer(WritableNestedModelSerializer): class Meta: model = UserPreference fields = ( - 'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', 'use_fractions', 'use_kj', + 'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', + 'use_fractions', 'use_kj', 'plan_share', 'nav_sticky', 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay', 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', - 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', 'food_children_exist' + 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', + 'food_children_exist' ) @@ -477,10 +484,13 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin) if x := validated_data.get('name', None): validated_data['plural_name'] = x.strip() - if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first(): + if unit := Unit.objects.filter( + Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), + space=space).first(): return unit - obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj def update(self, instance, validated_data): @@ -500,7 +510,8 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) - obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj def update(self, instance, validated_data): @@ -525,7 +536,8 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) - obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj class Meta: @@ -540,7 +552,8 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) - obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj class Meta: @@ -665,12 +678,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR properties = validated_data.pop('properties', None) - obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit, + obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, + properties_food_unit=properties_food_unit, defaults=validated_data) if properties and len(properties) > 0: for p in properties: - obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space)) + obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], + property_amount=p['property_amount'], space=space)) return obj @@ -702,7 +717,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR 'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id', 'food_onhand', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping', - 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug', + 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', + 'open_data_slug', ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') @@ -726,7 +742,8 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer): uch = UnitConversionHelper(self.context['request'].space) conversions = [] for c in uch.get_conversions(obj): - conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper + conversions.append( + {'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper return conversions else: return [] @@ -828,7 +845,8 @@ class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin class Meta: model = UnitConversion - fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug') + fields = ( + 'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug') class NutritionInformationSerializer(serializers.ModelSerializer): @@ -898,7 +916,8 @@ class RecipeSerializer(RecipeBaseSerializer): fields = ( 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url', - 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating', + 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', + 'file_path', 'servings_text', 'rating', 'last_cooked', 'private', 'shared', ) @@ -977,7 +996,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer): def create(self, validated_data): book = validated_data['book'] recipe = validated_data['recipe'] - if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared(): + if not book.get_owner() == self.context['request'].user and not self.context[ + 'request'].user in book.get_shared(): raise NotFound(detail=None, code=None) obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe) return obj @@ -1041,6 +1061,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): name = serializers.SerializerMethodField('get_name') # should this be done at the front end? recipe_name = serializers.ReadOnlyField(source='recipe.name') mealplan_note = serializers.ReadOnlyField(source='mealplan.note') + mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date') + mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name') servings = CustomDecimalField() def get_name(self, obj): @@ -1064,14 +1086,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): class Meta: model = ShoppingListRecipe - fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note') + fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date', + 'mealplan_type') read_only_fields = ('id',) class ShoppingListEntrySerializer(WritableNestedModelSerializer): food = FoodSerializer(allow_null=True) unit = UnitSerializer(allow_null=True, required=False) - ingredient_note = serializers.ReadOnlyField(source='ingredient.note') recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True) amount = CustomDecimalField() created_by = UserSerializer(read_only=True) @@ -1082,7 +1104,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): # autosync values are only needed for frequent 'checked' value updating if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))): - for f in list(set(fields) - set(['id', 'checked'])): + for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])): del fields[f] return fields @@ -1124,11 +1146,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): class Meta: model = ShoppingListEntry fields = ( - 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', + 'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked', 'recipe_mealplan', - 'created_by', 'created_at', 'completed_at', 'delay_until' + 'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until' ) - read_only_fields = ('id', 'created_by', 'created_at',) + read_only_fields = ('id', 'created_by', 'created_at','updated_at',) + + +class ShoppingListEntryBulkSerializer(serializers.Serializer): + ids = serializers.ListField() + checked = serializers.BooleanField() # TODO deprecate @@ -1283,7 +1310,8 @@ class InviteLinkSerializer(WritableNestedModelSerializer): class Meta: model = InviteLink fields = ( - 'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',) + 'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', + 'created_at',) read_only_fields = ('id', 'uuid', 'created_by', 'created_at',) diff --git a/cookbook/static/themes/tandoor.min.css b/cookbook/static/themes/tandoor.min.css index 5744a8d8..36abfb42 100644 --- a/cookbook/static/themes/tandoor.min.css +++ b/cookbook/static/themes/tandoor.min.css @@ -480,7 +480,7 @@ hr { margin-top: 1rem; margin-bottom: 1rem; border: 0; - border-top: 1px solid rgba(0, 0, 0, .1) + border-top: 1px solid #ced4da; } .small, small { diff --git a/cookbook/static/themes/tandoor_dark.min.css b/cookbook/static/themes/tandoor_dark.min.css index 68a14037..cd72ad7c 100644 --- a/cookbook/static/themes/tandoor_dark.min.css +++ b/cookbook/static/themes/tandoor_dark.min.css @@ -2850,89 +2850,41 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt color: #fff } -.btn-primary:hover { - background: transparent; - color: #b98766; - border: 1px solid #b98766 -} - .btn-secondary { transition: all .5s ease-in-out; color: #fff } -.btn-secondary:hover { - background: transparent; - color: #b55e4f; - border: 1px solid #b55e4f -} - .btn-success { transition: all .5s ease-in-out; color: #fff } -.btn-success:hover { - background: transparent; - color: #82aa8b; - border: 1px solid #82aa8b -} - .btn-info { transition: all .5s ease-in-out; color: #fff } -.btn-info:hover { - background: transparent; - color: #385f84; - border: 1px solid #385f84 -} - .btn-warning { transition: all .5s ease-in-out; color: #fff } -.btn-warning:hover { - background: transparent; - color: #eaaa21; - border: 1px solid #eaaa21 -} - .btn-danger { transition: all .5s ease-in-out; color: #fff } -.btn-danger:hover { - background: transparent; - color: #a7240e; - border: 1px solid #a7240e -} - .btn-light { transition: all .5s ease-in-out; color: #fff } -.btn-light:hover { - background-color: hsla(0, 0%, 18%, .5); - color: #cfd5cd; - border: 1px solid hsla(0, 0%, 18%, .5) -} - .btn-dark { transition: all .5s ease-in-out; color: #fff } -.btn-dark:hover { - background: transparent; - color: #221e1e; - border: 1px solid #221e1e -} - .btn-opacity-primary { color: #b98766; background-color: #0012a7; @@ -6155,7 +6107,7 @@ a.close.disabled { padding: .5rem .75rem; margin-bottom: 0; font-size: 1rem; - background-color: #f7f7f7; + background-color: #242424; border-bottom: 1px solid #ebebeb; border-top-left-radius: calc(.3rem - 1px); border-top-right-radius: calc(.3rem - 1px) diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index 51f4d9f1..14b3a18f 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -404,7 +404,7 @@ {% endif %} -
diff --git a/cookbook/templates/shoppinglist_template.html b/cookbook/templates/shoppinglist_template.html index c8c1d3f5..ceb61342 100644 --- a/cookbook/templates/shoppinglist_template.html +++ b/cookbook/templates/shoppinglist_template.html @@ -2,6 +2,7 @@ {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} + {% block title %} {{ title }} {% endblock %} {% block content_fluid %} @@ -10,16 +11,21 @@
-{% endblock %} {% block script %} {% if debug %} - -{% else %} - -{% endif %} +{% endblock %} - +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} -{% render_bundle 'shopping_list_view' %} {% endblock %} + + + + {% render_bundle 'shopping_list_view' %} +{% endblock %} diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 0ea9a2d8..06319bf6 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -13,7 +13,9 @@ DETAIL_URL = 'api:shoppinglistentry-detail' @pytest.fixture() def obj_1(space_1, u1_s1): - e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), + food=Food.objects.get_or_create(name='test 1', space=space_1)[0], + space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -21,7 +23,9 @@ def obj_1(space_1, u1_s1): @pytest.fixture def obj_2(space_1, u1_s1): - e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), + food=Food.objects.get_or_create(name='test 2', space=space_1)[0], + space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -79,6 +83,29 @@ def test_update(arg, request, obj_1): assert response['amount'] == 2 +@pytest.mark.parametrize("arg", [ + ['a_u', 403], + ['g1_s1', 403], + ['u1_s1', 200], + ['a1_s1', 200], + ['g1_s2', 403], + ['u1_s2', 200], + ['a1_s2', 200], +]) +def test_bulk_update(arg, request, obj_1, obj_2): + c = request.getfixturevalue(arg[0]) + r = c.post( + reverse(LIST_URL, ) + 'bulk/', + {'ids': [obj_1.id, obj_2.id], 'checked': True}, + content_type='application/json' + ) + assert r.status_code == arg[1] + assert r + if r.status_code == 200: + obj_1.refresh_from_db() + assert obj_1.checked == (arg[0] == 'u1_s1') + + @pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 201], diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index a1e78f7b..f1266d1e 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -214,6 +214,9 @@ def test_completed(sle, u1_s1): def test_recent(sle, u1_s1): user = auth.get_user(u1_s1) + user.userpreference.shopping_recent_days = 7 # hardcoded API limit 14 days + user.userpreference.save() + today_start = timezone.now().replace(hour=0, minute=0, second=0) # past_date within recent_days threshold diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index 473a631c..5001aca2 100644 --- a/cookbook/tests/api/test_api_shopping_recipe.py +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -7,7 +7,7 @@ from django.contrib import auth from django.urls import reverse from django_scopes import scopes_disabled -from cookbook.models import Food, Ingredient +from cookbook.models import Food, Ingredient, ShoppingListRecipe, ShoppingListEntry from cookbook.tests.factories import MealPlanFactory, RecipeFactory, StepFactory, UserFactory if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': @@ -126,7 +126,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id})) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) assert [x['created_by']['id'] for x in r].count(user.id) == sle_count - all_ing = [x['ingredient'] for x in r] + all_ing = list(ShoppingListEntry.objects.filter(list_recipe__recipe=recipe).all().values_list('ingredient', flat=True)) keep_ing = all_ing[1:-1] # remove first and last element del keep_ing[int(len(keep_ing) / 2)] # remove a middle element list_recipe = r[0]['list_recipe'] diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 59e40564..e4c91b2b 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -30,6 +30,7 @@ from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone +from django.utils.timezone import make_aware from django.utils.translation import gettext as _ from django_scopes import scopes_disabled from icalendar import Calendar, Event @@ -102,7 +103,8 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, UnitConversionSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer, - UserSerializer, UserSpaceSerializer, ViewLogSerializer) + UserSerializer, UserSpaceSerializer, ViewLogSerializer, + ShoppingListEntryBulkSerializer) from cookbook.views.import_export import get_integration from recipes import settings from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT @@ -480,6 +482,7 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet): class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): + schema = FilterSchema() queryset = Supermarket.objects serializer_class = SupermarketSerializer permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] @@ -489,7 +492,7 @@ class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): return super().get_queryset() -class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): +class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin, MergeMixin): queryset = SupermarketCategory.objects model = SupermarketCategory serializer_class = SupermarketCategorySerializer @@ -1160,11 +1163,47 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): 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 or 'recent' in self.request.query_params: + if 'checked' in self.request.query_params: return shopping_helper(self.queryset, self.request) + elif not self.detail: + today_start = timezone.now().replace(hour=0, minute=0, second=0) + week_ago = today_start - datetime.timedelta(days=min(self.request.user.userpreference.shopping_recent_days, 14)) + self.queryset = self.queryset.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) + + try: + last_autosync = self.request.query_params.get('last_autosync', None) + if last_autosync: + last_autosync = datetime.datetime.fromtimestamp(int(last_autosync) / 1000, datetime.timezone.utc) + self.queryset = self.queryset.filter(updated_at__gte=last_autosync) + except: + traceback.print_exc() # TODO once old shopping list is removed this needs updated to sharing users in preferences - return self.queryset + if self.detail: + return self.queryset + else: + return self.queryset[:1000] + + @decorators.action( + detail=False, + methods=['POST'], + serializer_class=ShoppingListEntryBulkSerializer, + permission_classes=[CustomIsUser] + ) + def bulk(self, request): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + ShoppingListEntry.objects.filter( + Q(created_by=self.request.user) + | Q(shoppinglist__shared=self.request.user) + | Q(created_by__in=list(self.request.user.get_shopping_share())) + ).filter(space=request.space, id__in=serializer.validated_data['ids']).update( + checked=serializer.validated_data['checked'], + updated_at=timezone.now(), + ) + return Response(serializer.data) + else: + return Response(serializer.errors, 400) # TODO deprecate @@ -1174,11 +1213,13 @@ class ShoppingListViewSet(viewsets.ModelViewSet): permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): - return self.queryset.filter( + self.queryset = self.queryset.filter( Q(created_by=self.request.user) | Q(shared=self.request.user) | Q(created_by__in=list(self.request.user.get_shopping_share())) - ).filter(space=self.request.space).distinct() + ).filter(space=self.request.space) + + return self.queryset.distinct() def get_serializer_class(self): try: @@ -1247,6 +1288,7 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet): class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin): + schema = FilterSchema() queryset = UserFile.objects serializer_class = UserFileSerializer permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] diff --git a/vue/package.json b/vue/package.json index 4f56ee31..3e069f81 100644 --- a/vue/package.json +++ b/vue/package.json @@ -23,7 +23,6 @@ "babel-loader": "^9.1.0", "bootstrap-vue": "^2.23.1", "core-js": "^3.29.1", - "html2pdf.js": "^0.10.1", "lodash": "^4.17.21", "mavon-editor": "^2.10.4", "moment": "^2.29.4", @@ -86,7 +85,8 @@ "parser": "@typescript-eslint/parser" }, "rules": { - "no-unused-vars": "off" + "no-unused-vars": "off", + "vue/no-unused-components": "warn" } }, "browserslist": [ diff --git a/vue/src/apps/ShoppingListView/ShoppingListView.vue b/vue/src/apps/ShoppingListView/ShoppingListView.vue index 66867227..095513ac 100644 --- a/vue/src/apps/ShoppingListView/ShoppingListView.vue +++ b/vue/src/apps/ShoppingListView/ShoppingListView.vue @@ -1,585 +1,456 @@