From 24a575c2d54c60f9a7a0d618c87d4c7579267d1b Mon Sep 17 00:00:00 2001 From: smilerz Date: Sun, 15 Aug 2021 08:39:45 -0500 Subject: [PATCH] food to tree (model, api, serializer) --- cookbook/helper/recipe_search.py | 1 - cookbook/migrations/food_to_tree.py | 57 ++++ cookbook/models.py | 12 +- cookbook/serializer.py | 31 ++- cookbook/templates/model/food_template.html | 31 +++ .../keyword_template.html} | 2 +- cookbook/tests/api/test_api_cook_log.py | 2 +- cookbook/tests/api/test_api_food.py | 248 +++++++++++++++++- cookbook/tests/api/test_api_keyword.py | 14 +- .../tests/api/test_api_shopping_list_entry.py | 6 +- cookbook/tests/conftest.py | 4 +- cookbook/urls.py | 16 +- cookbook/views/api.py | 40 ++- cookbook/views/trees.py | 7 +- recipes/settings.py | 4 +- 15 files changed, 399 insertions(+), 76 deletions(-) create mode 100644 cookbook/migrations/food_to_tree.py create mode 100644 cookbook/templates/model/food_template.html rename cookbook/templates/{generic/tree_template.html => model/keyword_template.html} (90%) diff --git a/cookbook/helper/recipe_search.py b/cookbook/helper/recipe_search.py index b4b31b12a..6c26f7787 100644 --- a/cookbook/helper/recipe_search.py +++ b/cookbook/helper/recipe_search.py @@ -12,7 +12,6 @@ from cookbook.models import Food, Keyword, Recipe, ViewLog # TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected -# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering def search_recipes(request, queryset, params): search_prefs = request.user.searchpreference search_string = params.get('query', '') diff --git a/cookbook/migrations/food_to_tree.py b/cookbook/migrations/food_to_tree.py new file mode 100644 index 000000000..bfdcc3513 --- /dev/null +++ b/cookbook/migrations/food_to_tree.py @@ -0,0 +1,57 @@ +# Generated by Django 3.2.5 on 2021-08-14 15:40 + +from treebeard.mp_tree import MP_Node +from django.db import migrations, models +from django_scopes import scopes_disabled +# update if needed +steplen = MP_Node.steplen +alphabet = MP_Node.alphabet +node_order_by = ["name"] + + +def update_paths(apps, schema_editor): + with scopes_disabled(): + Node = apps.get_model("cookbook", "Food") + nodes = Node.objects.all().order_by(*node_order_by) + for i, node in enumerate(nodes, 1): + # for default values, this resolves to: "{:04d}".format(i) + node.path = f"{{:{alphabet[0]}{steplen}d}}".format(i) + if nodes: + Node.objects.bulk_update(nodes, ["path"]) + + +def backwards(apps, schema_editor): + """nothing to do""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0147_auto_20210813_1829'), + ] + + operations = [ + migrations.AddField( + model_name='food', + name='depth', + field=models.PositiveIntegerField(default=0), + preserve_default=False, + ), + migrations.AddField( + model_name='food', + name='numchild', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='food', + name='path', + field=models.CharField(default=0, max_length=255, unique=False), + preserve_default=False, + ), + migrations.RunPython(update_paths, backwards), + migrations.AlterField( + model_name="food", + name="path", + field=models.CharField(max_length=255, unique=True), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 7913678b0..1c237302d 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -48,8 +48,6 @@ class TreeManager(MP_NodeManager): class TreeModel(MP_Node): - objects = ScopedManager(space='space', _manager_class=TreeManager) - _full_name_separator = ' > ' def __str__(self): @@ -344,7 +342,9 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM description = models.TextField(default="", blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + space = models.ForeignKey(Space, on_delete=models.CASCADE) + objects = ScopedManager(space='space', _manager_class=TreeManager) class Meta: constraints = [ @@ -353,7 +353,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM indexes = (Index(fields=['id', 'name']),) -class Unit(ExportModelOperationsMixin('unit'), TreeModel, PermissionModelMixin): +class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin): name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) description = models.TextField(blank=True, null=True) @@ -369,7 +369,9 @@ class Unit(ExportModelOperationsMixin('unit'), TreeModel, PermissionModelMixin): ] -class Food(ExportModelOperationsMixin('food'), models.Model, PermissionModelMixin): +class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): + # TODO add find and fix problem functions + node_order_by = ['name'] name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL) supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) @@ -377,7 +379,7 @@ class Food(ExportModelOperationsMixin('food'), models.Model, PermissionModelMixi description = models.TextField(default='', blank=True) space = models.ForeignKey(Space, on_delete=models.CASCADE) - objects = ScopedManager(space='space') + objects = ScopedManager(space='space', _manager_class=TreeManager) def __str__(self): return self.name diff --git a/cookbook/serializer.py b/cookbook/serializer.py index a8bfc90e7..7dbb39dee 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -289,34 +289,32 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer): supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) image = serializers.SerializerMethodField('get_image') - #numrecipe = serializers.SerializerMethodField('count_recipes') + numrecipe = serializers.SerializerMethodField('count_recipes') - # TODO check if it is a recipe and get that image first def get_image(self, obj): if obj.recipe: recipes = Recipe.objects.filter(id=obj.recipe).exclude(image__isnull=True).exclude(image__exact='') if len(recipes) == 0: return recipes.image.url + # if food is not also a recipe, look for recipe images that use the food recipes = Recipe.objects.filter(steps__ingredients__food=obj).exclude(image__isnull=True).exclude(image__exact='') - if len(recipes) == 0: - recipes = Recipe.objects.filter(keywords__in=obj.get_tree()).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree - # if len(recipes) != 0: - # return random.choice(recipes).image.url - # else: - # return None - return None - # def count_recipes(self, obj): - # return obj.recipe_set.all().count() + # if no recipes found - check whole tree + if len(recipes) == 0: + recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_tree()).exclude(image__isnull=True).exclude(image__exact='') + + if len(recipes) != 0: + return random.choice(recipes).image.url + else: + return None + + def count_recipes(self, obj): + return Recipe.objects.filter(steps__ingredients__food=obj).count() def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() validated_data['space'] = self.context['request'].space - supermarket = validated_data.pop('supermarket_category') obj, created = Food.objects.get_or_create(**validated_data) - if supermarket: - obj.supermarket_category, created = SupermarketCategory.objects.get_or_create(name=supermarket['name'], space=self.context['request'].space) - obj.save() return obj def update(self, instance, validated_data): @@ -325,7 +323,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer): class Meta: model = Food - fields = ('id', 'name', 'recipe', 'ignore_shopping', 'supermarket_category', 'image') + fields = ('id', 'name', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe') + read_only_fields = ('id', 'numchild', 'parent', 'image') class IngredientSerializer(WritableNestedModelSerializer): diff --git a/cookbook/templates/model/food_template.html b/cookbook/templates/model/food_template.html new file mode 100644 index 000000000..b4e555db6 --- /dev/null +++ b/cookbook/templates/model/food_template.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% load i18n %} +{% load l10n %} +{% comment %} TODO Can this be combined with Keyword template? {% endcomment %} +{% block title %}{% trans 'Food' %}{% endblock %} + +{% block content_fluid %} + +
+ +
+ + +{% endblock %} + + +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} + + + + {% render_bundle 'food_list_view' %} +{% endblock %} \ No newline at end of file diff --git a/cookbook/templates/generic/tree_template.html b/cookbook/templates/model/keyword_template.html similarity index 90% rename from cookbook/templates/generic/tree_template.html rename to cookbook/templates/model/keyword_template.html index e177dc637..26fc4e90b 100644 --- a/cookbook/templates/generic/tree_template.html +++ b/cookbook/templates/model/keyword_template.html @@ -3,7 +3,7 @@ {% load static %} {% load i18n %} {% load l10n %} - +{% comment %} TODO Can this be combined with Food template? {% endcomment %} {% block title %}{% trans 'Keywords' %}{% endblock %} {% block content_fluid %} diff --git a/cookbook/tests/api/test_api_cook_log.py b/cookbook/tests/api/test_api_cook_log.py index fb2730f1f..2fbf7bd3b 100644 --- a/cookbook/tests/api/test_api_cook_log.py +++ b/cookbook/tests/api/test_api_cook_log.py @@ -5,7 +5,7 @@ from django.contrib import auth from django.urls import reverse from django_scopes import scopes_disabled -from cookbook.models import Keyword, CookLog +from cookbook.models import CookLog LIST_URL = 'api:cooklog-list' DETAIL_URL = 'api:cooklog-detail' diff --git a/cookbook/tests/api/test_api_food.py b/cookbook/tests/api/test_api_food.py index 6619d8b3e..a1dc8e6ae 100644 --- a/cookbook/tests/api/test_api_food.py +++ b/cookbook/tests/api/test_api_food.py @@ -4,10 +4,21 @@ import pytest from django.urls import reverse from django_scopes import scopes_disabled -from cookbook.models import Food +from cookbook.models import Food, Ingredient + +# ------------------ IMPORTANT ------------------- +# +# if changing any capabilities associated with food +# you will need to ensure that it is tested against both +# SqlLite and PostgresSQL +# adding load_env() to settings.py will enable Postgress access +# +# ------------------ IMPORTANT ------------------- LIST_URL = 'api:food-list' DETAIL_URL = 'api:food-detail' +MOVE_URL = 'api:food-move' +MERGE_URL = 'api:food-merge' @pytest.fixture() @@ -15,11 +26,46 @@ def obj_1(space_1): return Food.objects.get_or_create(name='test_1', space=space_1)[0] +@pytest.fixture() +def obj_1_1(obj_1, space_1): + return obj_1.add_child(name='test_1_1', space=space_1) + + +@pytest.fixture() +def obj_1_1_1(obj_1_1, space_1): + return obj_1_1.add_child(name='test_1_1_1', space=space_1) + + @pytest.fixture def obj_2(space_1): return Food.objects.get_or_create(name='test_2', space=space_1)[0] +@pytest.fixture() +def obj_3(space_2): + return Food.objects.get_or_create(name='test_3', space=space_2)[0] + + +@pytest.fixture() +def step_1_s1(obj_1, space_1): + return Ingredient.objects.create(food=obj_1, space=space_1) + + +@pytest.fixture() +def step_2_s1(obj_2, space_1): + return Ingredient.objects.create(food=obj_2, space=space_1) + + +@pytest.fixture() +def step_3_s2(obj_3, space_2): + return Ingredient.objects.create(food=obj_3, space=space_2) + + +@pytest.fixture() +def step_1_1_s1(obj_1_1, space_1): + return Ingredient.objects.create(food=obj_1_1, space=space_1) + + @pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 403], @@ -32,31 +78,37 @@ def test_list_permission(arg, request): def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2): - assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2 - assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0 + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0 obj_1.space = space_2 obj_1.save() - assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1 - assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1 + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1 def test_list_filter(obj_1, obj_2, u1_s1): r = u1_s1.get(reverse(LIST_URL)) assert r.status_code == 200 response = json.loads(r.content) - assert len(response) == 2 - assert response[0]['name'] == obj_1.name + assert response['count'] == 2 + assert response['results'][0]['name'] == obj_1.name + + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content) + assert len(response['results']) == 1 response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?limit=1').content) - assert len(response) == 1 + assert len(response['results']) == 1 + + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=''&limit=1').content) + assert len(response['results']) == 1 response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content) - assert len(response) == 0 + assert response['count'] == 0 response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[4:]}').content) - assert len(response) == 1 + assert response['count'] == 1 @pytest.mark.parametrize("arg", [ @@ -107,7 +159,10 @@ def test_add(arg, request, u1_s2): assert r.status_code == 404 -def test_add_duplicate(u1_s1, u1_s2, obj_1): +@pytest.mark.django_db(transaction=True) +def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3): + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1 + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1 r = u1_s1.post( reverse(LIST_URL), {'name': obj_1.name}, @@ -116,6 +171,8 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1): response = json.loads(r.content) assert r.status_code == 201 assert response['id'] == obj_1.id + assert response['name'] == obj_1.name + assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1 r = u1_s2.post( reverse(LIST_URL), @@ -125,9 +182,13 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1): response = json.loads(r.content) assert r.status_code == 201 assert response['id'] != obj_1.id + assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2 -def test_delete(u1_s1, u1_s2, obj_1): +def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1): + with scopes_disabled(): + assert Food.objects.count() == 3 + r = u1_s2.delete( reverse( DETAIL_URL, @@ -135,14 +196,173 @@ def test_delete(u1_s1, u1_s2, obj_1): ) ) assert r.status_code == 404 + with scopes_disabled(): + assert Food.objects.count() == 3 r = u1_s1.delete( reverse( DETAIL_URL, - args={obj_1.id} + args={obj_1_1.id} ) ) assert r.status_code == 204 with scopes_disabled(): - assert Food.objects.count() == 0 + assert Food.objects.count() == 1 + assert Food.find_problems() == ([], [], [], [], []) + + +def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1): + url = reverse(MOVE_URL, args=[obj_1_1.id, obj_2.id]) + with scopes_disabled(): + assert obj_1.get_num_children() == 1 + assert obj_1.get_descendant_count() == 2 + assert Food.get_root_nodes().filter(space=space_1).count() == 2 + + # move child to new parent, only HTTP put method should work + r = u1_s1.get(url) + assert r.status_code == 405 + r = u1_s1.post(url) + assert r.status_code == 405 + r = u1_s1.delete(url) + assert r.status_code == 405 + r = u1_s1.put(url) + assert r.status_code == 200 + with scopes_disabled(): + # django-treebeard bypasses django ORM so object needs retrieved again + obj_1 = Food.objects.get(pk=obj_1.id) + obj_2 = Food.objects.get(pk=obj_2.id) + assert obj_1.get_num_children() == 0 + assert obj_1.get_descendant_count() == 0 + assert obj_2.get_num_children() == 1 + assert obj_2.get_descendant_count() == 2 + + # move child to root + r = u1_s1.put(reverse(MOVE_URL, args=[obj_1_1.id, 0])) + assert r.status_code == 200 + with scopes_disabled(): + assert Food.get_root_nodes().filter(space=space_1).count() == 3 + + # attempt to move to non-existent parent + r = u1_s1.put( + reverse(MOVE_URL, args=[obj_1.id, 9999]) + ) + assert r.status_code == 400 + + # attempt to move to wrong space + r = u1_s1.put( + reverse(MOVE_URL, args=[obj_1_1.id, obj_3.id]) + ) + assert r.status_code == 400 + + # run diagnostic to find problems - none should be found + with scopes_disabled(): + assert Food.find_problems() == ([], [], [], [], []) + + +# this seems overly long - should it be broken into smaller pieces? +def test_merge( + u1_s1, + obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, + step_1_s1, step_2_s1, step_3_s2, step_1_1_s1, + space_1 +): + with scopes_disabled(): + assert obj_1.get_num_children() == 1 + assert obj_1.get_descendant_count() == 2 + assert Food.get_root_nodes().filter(space=space_1).count() == 2 + assert Food.objects.filter(space=space_1).count() == 4 + assert obj_1.ingredient_set.count() == 1 + assert obj_2.ingredient_set.count() == 1 + assert obj_3.ingredient_set.count() == 1 + assert obj_1_1.ingredient_set.count() == 1 + assert obj_1_1_1.ingredient_set.count() == 0 + + # merge food with no children and no ingredient with another food, only HTTP put method should work + url = reverse(MERGE_URL, args=[obj_1_1_1.id, obj_2.id]) + r = u1_s1.get(url) + assert r.status_code == 405 + r = u1_s1.post(url) + assert r.status_code == 405 + r = u1_s1.delete(url) + assert r.status_code == 405 + r = u1_s1.put(url) + assert r.status_code == 200 + with scopes_disabled(): + # django-treebeard bypasses django ORM so object needs retrieved again + obj_1 = Food.objects.get(pk=obj_1.id) + obj_2 = Food.objects.get(pk=obj_2.id) + assert Food.objects.filter(pk=obj_1_1_1.id).count() == 0 + assert obj_1.get_num_children() == 1 + assert obj_1.get_descendant_count() == 1 + assert obj_2.get_num_children() == 0 + assert obj_2.get_descendant_count() == 0 + assert obj_1.ingredient_set.count() == 1 + assert obj_2.ingredient_set.count() == 1 + assert obj_3.ingredient_set.count() == 1 + assert obj_1_1.ingredient_set.count() == 1 + + # merge food with children to another food + r = u1_s1.put(reverse(MERGE_URL, args=[obj_1.id, obj_2.id])) + assert r.status_code == 200 + with scopes_disabled(): + # django-treebeard bypasses django ORM so object needs retrieved again + obj_2 = Food.objects.get(pk=obj_2.id) + assert Food.objects.filter(pk=obj_1.id).count() == 0 + assert obj_2.get_num_children() == 1 + assert obj_2.get_descendant_count() == 1 + assert obj_2.ingredient_set.count() == 2 + assert obj_3.ingredient_set.count() == 1 + assert obj_1_1.ingredient_set.count() == 1 + + # attempt to merge with non-existent parent + r = u1_s1.put( + reverse(MERGE_URL, args=[obj_1_1.id, 9999]) + ) + assert r.status_code == 400 + + # attempt to move to wrong space + r = u1_s1.put( + reverse(MERGE_URL, args=[obj_2.id, obj_3.id]) + ) + assert r.status_code == 400 + + # attempt to merge with child + r = u1_s1.put( + reverse(MERGE_URL, args=[obj_2.id, obj_1_1.id]) + ) + assert r.status_code == 403 + + # attempt to merge with self + r = u1_s1.put( + reverse(MERGE_URL, args=[obj_2.id, obj_2.id]) + ) + assert r.status_code == 403 + + # run diagnostic to find problems - none should be found + with scopes_disabled(): + assert Food.find_problems() == ([], [], [], [], []) + + +def test_root_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1): + # should return root objects in the space (obj_1, obj_2), ignoring query filters + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content) + assert len(response['results']) == 2 + + with scopes_disabled(): + obj_2.move(obj_1, 'sorted-child') + # should return direct children of obj_1 (obj_1_1, obj_2), ignoring query filters + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}').content) + assert response['count'] == 2 + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}&query={obj_2.name[4:]}').content) + assert response['count'] == 2 + + +def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1): + with scopes_disabled(): + obj_2.move(obj_1, 'sorted-child') + # should return full tree starting at obj_1 (obj_1_1_1, obj_2), ignoring query filters + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}').content) + assert response['count'] == 4 + response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content) + assert response['count'] == 4 diff --git a/cookbook/tests/api/test_api_keyword.py b/cookbook/tests/api/test_api_keyword.py index feff3925e..6213ebe4a 100644 --- a/cookbook/tests/api/test_api_keyword.py +++ b/cookbook/tests/api/test_api_keyword.py @@ -49,28 +49,22 @@ def obj_3(space_2): @pytest.fixture() def recipe_1_s1(obj_1, recipe_1_s1, space_1): - recipe_1_s1.keywords.add(obj_1.id) - return recipe_1_s1 + return recipe_1_s1.keywords.add(obj_1) @pytest.fixture() def recipe_2_s1(obj_2, recipe_2_s1, space_1): - recipe_2_s1.keywords.add(obj_2.id) - return recipe_1_s1 + return recipe_2_s1.keywords.add(obj_2) @pytest.fixture() def recipe_3_s2(u1_s2, obj_3, space_2): - r = get_random_recipe(space_2, u1_s2) - r.keywords.add(obj_3.id) - return r + return get_random_recipe(space_2, u1_s2).keywords.add(obj_3) @pytest.fixture() def recipe_1_1_s1(u1_s1, obj_1_1, space_1): - r = get_random_recipe(space_1, u1_s1) - r.keywords.add(obj_1_1.id) - return r + return get_random_recipe(space_1, u1_s1).keywords.add(obj_1_1) @pytest.mark.parametrize("arg", [ diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 323e20043..71a1f8f6e 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -6,7 +6,7 @@ from django.forms import model_to_dict from django.urls import reverse from django_scopes import scopes_disabled -from cookbook.models import RecipeBook, Storage, Sync, SyncLog, ShoppingList, ShoppingListEntry, Food +from cookbook.models import ShoppingList, ShoppingListEntry, Food LIST_URL = 'api:shoppinglistentry-list' DETAIL_URL = 'api:shoppinglistentry-detail' @@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail' @pytest.fixture() def obj_1(space_1, u1_s1): - e = ShoppingListEntry.objects.create(food=Food.objects.create(name='test 1', space=space_1)) + e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 1', space=space_1)[0]) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1): @pytest.fixture def obj_2(space_1, u1_s1): - e = ShoppingListEntry.objects.create(food=Food.objects.create(name='test 2', space=space_1)) + e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 2', space=space_1)[0]) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e diff --git a/cookbook/tests/conftest.py b/cookbook/tests/conftest.py index 15ab9acdc..48c4e9c6c 100644 --- a/cookbook/tests/conftest.py +++ b/cookbook/tests/conftest.py @@ -62,7 +62,7 @@ def get_random_recipe(space_1, u1_s1): s1.ingredients.add( Ingredient.objects.create( amount=1, - food=Food.objects.create(name=uuid.uuid4(), space=space_1, ), + food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0], unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ), note=uuid.uuid4(), space=space_1, @@ -72,7 +72,7 @@ def get_random_recipe(space_1, u1_s1): s2.ingredients.add( Ingredient.objects.create( amount=1, - food=Food.objects.create(name=uuid.uuid4(), space=space_1, ), + food=Food.objects.get_or_create(name=str(uuid.uuid4()), space=space_1)[0], unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ), note=uuid.uuid4(), space=space_1, diff --git a/cookbook/urls.py b/cookbook/urls.py index 3fa345e90..e21ecde1f 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -87,7 +87,7 @@ urlpatterns = [ path('edit/recipe/convert//', edit.convert_recipe, name='edit_convert_recipe'), path('edit/storage//', edit.edit_storage, name='edit_storage'), - path('edit/ingredient/', edit.edit_ingredients, name='edit_food'), + path('edit/ingredient/', edit.edit_ingredients, name='edit_food'), # TODO is this still needed? path('delete/recipe-source//', delete.delete_recipe_source, name='delete_recipe_source'), @@ -109,9 +109,9 @@ urlpatterns = [ path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'), path('api/share-link/', api.share_link, name='api_share_link'), - path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # TODO is this deprecated? - path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this deprecated? - path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), # TODO is this deprecated? + path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # TODO is this still needed? + path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this still needed? + path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), path('telegram/setup/', telegram.setup_bot, name='telegram_setup'), path('telegram/remove/', telegram.remove_bot, name='telegram_remove'), @@ -135,9 +135,13 @@ urlpatterns = [ name='web_manifest'), ] +# generic_models = ( +# Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, +# Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink +# ) generic_models = ( Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, - Comment, RecipeBookEntry, Food, ShoppingList, InviteLink + Comment, RecipeBookEntry, ShoppingList, InviteLink ) for m in generic_models: @@ -176,7 +180,7 @@ for m in generic_models: ) ) -tree_models = [Keyword] +tree_models = [Keyword, Food] for m in tree_models: py_name = get_model_name(m) url_name = py_name.replace('_', '-') diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 0006a5ed7..0421d8eba 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -12,7 +12,8 @@ from django.contrib.auth.models import User from django.contrib.postgres.search import TrigramSimilarity from django.core.exceptions import FieldError, ValidationError from django.core.files import File -from django.db.models import Case, Q, Value, When +from django.db.models import ManyToManyField, Q +from django.db.models.fields.related_descriptors import ManyToManyDescriptor from django.http import FileResponse, HttpResponse, JsonResponse from django_scopes import scopes_disabled from django.shortcuts import redirect, get_object_or_404 @@ -136,6 +137,7 @@ class FuzzyFilterMixin(ViewSetMixin): class TreeMixin(FuzzyFilterMixin): model = None + related_models = [{'model': None, 'field': None}] schema = TreeSchema() def get_queryset(self): @@ -155,7 +157,7 @@ class TreeMixin(FuzzyFilterMixin): elif tree: if tree.isnumeric(): try: - self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self() + self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self().filter(space=self.request.space) except self.model.DoesNotExist: self.queryset = self.model.objects.none() else: @@ -226,18 +228,24 @@ class TreeMixin(FuzzyFilterMixin): if target in source.get_descendants_and_self(): content = {'error': True, 'msg': _('Cannot merge with child object!')} return Response(content, status=status.HTTP_403_FORBIDDEN) - ######################################################################## - # TODO this needs abstracted to update steps instead of recipes for food merge - ######################################################################## - recipes = Recipe.objects.filter(**{"%ss" % self.basename: source}, space=self.request.space) - for r in recipes: - getattr(r, self.basename + 's').add(target) - getattr(r, self.basename + 's').remove(source) - r.save() - children = source.get_children().exclude(id=target.id) - for c in children: - c.move(target, 'sorted-child') + for model in self.related_models: + if isinstance(getattr(model['model'], model['field']), ManyToManyDescriptor): + related = model['model'].objects.filter(**{model['field'] + "__id": source.id}, space=self.request.space) + else: + related = model['model'].objects.filter(**{model['field']: source}, space=self.request.space) + + for r in related: + try: + getattr(r, model['field']).add(target) + getattr(r, model['field']).remove(source) + r.save() + except AttributeError: + setattr(r, model['field'], target) + r.save() + children = source.get_children().exclude(id=target.id) + for c in children: + c.move(target, 'sorted-child') content = {'msg': _(f'{source.name} was merged successfully with {target.name}')} source.delete() return Response(content, status=status.HTTP_200_OK) @@ -341,6 +349,7 @@ class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMi class KeywordViewSet(viewsets.ModelViewSet, TreeMixin): queryset = Keyword.objects model = Keyword + related_models = [{'model': Recipe, 'field': 'keywords'}] serializer_class = KeywordSerializer permission_classes = [CustomIsUser] pagination_class = DefaultPagination @@ -356,10 +365,13 @@ class UnitViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): return super().get_queryset() -class FoodViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): +class FoodViewSet(viewsets.ModelViewSet, TreeMixin): queryset = Food.objects + model = Food + related_models = [{'model': Ingredient, 'field': 'food'}] serializer_class = FoodSerializer permission_classes = [CustomIsUser] + pagination_class = DefaultPagination class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): diff --git a/cookbook/views/trees.py b/cookbook/views/trees.py index aacf36291..5ff369891 100644 --- a/cookbook/views/trees.py +++ b/cookbook/views/trees.py @@ -5,4 +5,9 @@ from cookbook.helper.permission_helper import group_required @group_required('user') def keyword(request): - return render(request, 'generic/tree_template.html', {}) + return render(request, 'model/keyword_template.html', {}) + + +@group_required('user') +def food(request): + return render(request, 'model/food_template.html', {}) diff --git a/recipes/settings.py b/recipes/settings.py index 1d487072b..38847284e 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -16,8 +16,8 @@ import re from django.contrib import messages from django.utils.translation import gettext_lazy as _ -# from dotenv import load_dotenv -# load_dotenv() +from dotenv import load_dotenv +load_dotenv() BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Get vars from .env files