From 7b7b726ec56789a2b7acac7d00708e574f667741 Mon Sep 17 00:00:00 2001 From: vabene1111 Date: Fri, 14 Mar 2025 13:51:49 +0100 Subject: [PATCH] migration to store space and created_by on ShoppingListRecipe ATTENTION: This deletes all old shopping list recipes that do not have entries associated with it. This should not matter but just for the record --- cookbook/helper/shopping_helper.py | 2 +- ..._shoppinglistrecipe_created_by_and_more.py | 53 +++++++++++++++++++ ..._shoppinglistrecipe_created_by_and_more.py | 26 +++++++++ cookbook/models.py | 21 ++------ cookbook/serializer.py | 10 +++- .../api/test_api_shopping_list_recipe.py | 2 +- 6 files changed, 93 insertions(+), 21 deletions(-) create mode 100644 cookbook/migrations/0220_shoppinglistrecipe_created_by_and_more.py create mode 100644 cookbook/migrations/0221_alter_shoppinglistrecipe_created_by_and_more.py diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index c4c5659b2..098bd60d5 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -113,7 +113,7 @@ class RecipeShoppingEditor(): if not self.servings: self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0) - self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings) + self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings, space=self.space, created_by=self.created_by) if ingredients: self._add_ingredients(ingredients=ingredients) diff --git a/cookbook/migrations/0220_shoppinglistrecipe_created_by_and_more.py b/cookbook/migrations/0220_shoppinglistrecipe_created_by_and_more.py new file mode 100644 index 000000000..c8dac6ed9 --- /dev/null +++ b/cookbook/migrations/0220_shoppinglistrecipe_created_by_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.18 on 2025-03-14 10:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +from django.db.models import F, Count +from django_scopes import scopes_disabled + + +def add_space_and_owner_to_shopping_list_recipe(apps, schema_editor): + print('migrating shopping list recipe space attribute, this might take a while ...') + with scopes_disabled(): + ShoppingListRecipe = apps.get_model('cookbook', 'ShoppingListRecipe') + + # delete all shopping list recipes that do not have entries as those are of no use anyway + ShoppingListRecipe.objects.annotate(entry_count=Count('entries')).filter(entry_count__lte=0).delete() + + shopping_list_recipes = ShoppingListRecipe.objects.all().prefetch_related('entries') + update_list = [] + + for slr in shopping_list_recipes: + if entry := slr.entries.first(): + if entry.space and entry.created_by: + slr.space = entry.space + slr.created_by = entry.created_by + update_list.append(slr) + else: + print(slr, 'missing data on entry') + else: + print(slr, 'missing entry') + + ShoppingListRecipe.objects.bulk_update(update_list, ['space', 'created_by'], batch_size=500) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0219_connectorconfig_supports_description_field'), + ] + + operations = [ + migrations.AddField( + model_name='shoppinglistrecipe', + name='created_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='shoppinglistrecipe', + name='space', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'), + ), + migrations.RunPython(add_space_and_owner_to_shopping_list_recipe), + ] diff --git a/cookbook/migrations/0221_alter_shoppinglistrecipe_created_by_and_more.py b/cookbook/migrations/0221_alter_shoppinglistrecipe_created_by_and_more.py new file mode 100644 index 000000000..a27f7ad34 --- /dev/null +++ b/cookbook/migrations/0221_alter_shoppinglistrecipe_created_by_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.18 on 2025-03-14 12:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('cookbook', '0220_shoppinglistrecipe_created_by_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='shoppinglistrecipe', + name='created_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='shoppinglistrecipe', + name='space', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index e41560992..27618706d 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -1179,31 +1179,18 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin): name = models.CharField(max_length=32, blank=True, default='') - recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated servings = models.DecimalField(default=1, max_digits=8, decimal_places=4) + recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True) - objects = ScopedManager(space='recipe__space') + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + space = models.ForeignKey(Space, on_delete=models.CASCADE) - @staticmethod - def get_space_key(): - return 'recipe', 'space' - - def get_space(self): - return self.recipe.space + objects = ScopedManager(space='space') def __str__(self): return f'Shopping list recipe {self.id} - {self.recipe}' - def get_owner(self): - try: - if not self.entries.exists(): - return 'orphan' - else: - return getattr(self.entries.first(), 'created_by', None) - except AttributeError: - return None - class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin): list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries') diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 72be4c5d9..cf136c4dd 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -1093,6 +1093,7 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date') mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name') servings = CustomDecimalField() + created_by = UserSerializer(read_only=True) def get_name(self, obj): if not isinstance(value := obj.servings, Decimal): @@ -1106,6 +1107,11 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): or obj.recipe.name ) + f' ({value:.2g})' + def create(self, validated_data): + validated_data['space'] = self.context['request'].space + validated_data['created_by'] = self.context['request'].user + return super().create(validated_data) + def update(self, instance, validated_data): # TODO remove once old shopping list if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet': @@ -1116,8 +1122,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): class Meta: model = ShoppingListRecipe fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date', - 'mealplan_type') - read_only_fields = ('id',) + 'mealplan_type', 'created_by') + read_only_fields = ('id', 'created_by',) class ShoppingListEntrySerializer(WritableNestedModelSerializer): diff --git a/cookbook/tests/api/test_api_shopping_list_recipe.py b/cookbook/tests/api/test_api_shopping_list_recipe.py index bd8de2f40..10fd77724 100644 --- a/cookbook/tests/api/test_api_shopping_list_recipe.py +++ b/cookbook/tests/api/test_api_shopping_list_recipe.py @@ -12,7 +12,7 @@ DETAIL_URL = 'api:shoppinglistrecipe-detail' @pytest.fixture() def obj_1(space_1, u1_s1, recipe_1_s1): - r = ShoppingListRecipe.objects.create(recipe=recipe_1_s1, servings=1) + r = ShoppingListRecipe.objects.create(recipe=recipe_1_s1, servings=1, space=space_1, created_by=auth.get_user(u1_s1)) for ing in r.recipe.steps.first().ingredients.all(): ShoppingListEntry.objects.create(list_recipe=r, ingredient=ing, food=ing.food, unit=ing.unit, amount=ing.amount, created_by=auth.get_user(u1_s1), space=space_1) return r