food to tree (model, api, serializer)

This commit is contained in:
smilerz 2021-08-15 08:39:45 -05:00
parent 6ec0c1c71d
commit 24a575c2d5
15 changed files with 399 additions and 76 deletions

View File

@ -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 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): def search_recipes(request, queryset, params):
search_prefs = request.user.searchpreference search_prefs = request.user.searchpreference
search_string = params.get('query', '') search_string = params.get('query', '')

View File

@ -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),
),
]

View File

@ -48,8 +48,6 @@ class TreeManager(MP_NodeManager):
class TreeModel(MP_Node): class TreeModel(MP_Node):
objects = ScopedManager(space='space', _manager_class=TreeManager)
_full_name_separator = ' > ' _full_name_separator = ' > '
def __str__(self): def __str__(self):
@ -344,7 +342,9 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
description = models.TextField(default="", blank=True) description = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager)
class Meta: class Meta:
constraints = [ constraints = [
@ -353,7 +353,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
indexes = (Index(fields=['id', 'name']),) 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)]) name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
description = models.TextField(blank=True, null=True) 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)]) name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL) 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) 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) description = models.TextField(default='', blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') objects = ScopedManager(space='space', _manager_class=TreeManager)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -289,34 +289,32 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer): class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False) supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
image = serializers.SerializerMethodField('get_image') 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): def get_image(self, obj):
if obj.recipe: if obj.recipe:
recipes = Recipe.objects.filter(id=obj.recipe).exclude(image__isnull=True).exclude(image__exact='') recipes = Recipe.objects.filter(id=obj.recipe).exclude(image__isnull=True).exclude(image__exact='')
if len(recipes) == 0: if len(recipes) == 0:
return recipes.image.url 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='') 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): # if no recipes found - check whole tree
# return obj.recipe_set.all().count() 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): def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip() validated_data['name'] = validated_data['name'].strip()
validated_data['space'] = self.context['request'].space validated_data['space'] = self.context['request'].space
supermarket = validated_data.pop('supermarket_category')
obj, created = Food.objects.get_or_create(**validated_data) 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 return obj
def update(self, instance, validated_data): def update(self, instance, validated_data):
@ -325,7 +323,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
class Meta: class Meta:
model = Food 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): class IngredientSerializer(WritableNestedModelSerializer):

View File

@ -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 %}
<div id="app" >
<food-list-view></food-list-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
</script>
{% render_bundle 'food_list_view' %}
{% endblock %}

View File

@ -3,7 +3,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load l10n %} {% load l10n %}
{% comment %} TODO Can this be combined with Food template? {% endcomment %}
{% block title %}{% trans 'Keywords' %}{% endblock %} {% block title %}{% trans 'Keywords' %}{% endblock %}
{% block content_fluid %} {% block content_fluid %}

View File

@ -5,7 +5,7 @@ from django.contrib import auth
from django.urls import reverse from django.urls import reverse
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from cookbook.models import Keyword, CookLog from cookbook.models import CookLog
LIST_URL = 'api:cooklog-list' LIST_URL = 'api:cooklog-list'
DETAIL_URL = 'api:cooklog-detail' DETAIL_URL = 'api:cooklog-detail'

View File

@ -4,10 +4,21 @@ import pytest
from django.urls import reverse from django.urls import reverse
from django_scopes import scopes_disabled 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' LIST_URL = 'api:food-list'
DETAIL_URL = 'api:food-detail' DETAIL_URL = 'api:food-detail'
MOVE_URL = 'api:food-move'
MERGE_URL = 'api:food-merge'
@pytest.fixture() @pytest.fixture()
@ -15,11 +26,46 @@ def obj_1(space_1):
return Food.objects.get_or_create(name='test_1', space=space_1)[0] 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 @pytest.fixture
def obj_2(space_1): def obj_2(space_1):
return Food.objects.get_or_create(name='test_2', space=space_1)[0] 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", [ @pytest.mark.parametrize("arg", [
['a_u', 403], ['a_u', 403],
['g1_s1', 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): 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 json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0 assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 0
obj_1.space = space_2 obj_1.space = space_2
obj_1.save() obj_1.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1 assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1 assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1
def test_list_filter(obj_1, obj_2, u1_s1): def test_list_filter(obj_1, obj_2, u1_s1):
r = u1_s1.get(reverse(LIST_URL)) r = u1_s1.get(reverse(LIST_URL))
assert r.status_code == 200 assert r.status_code == 200
response = json.loads(r.content) response = json.loads(r.content)
assert len(response) == 2 assert response['count'] == 2
assert response[0]['name'] == obj_1.name 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) 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) 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) 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", [ @pytest.mark.parametrize("arg", [
@ -107,7 +159,10 @@ def test_add(arg, request, u1_s2):
assert r.status_code == 404 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( r = u1_s1.post(
reverse(LIST_URL), reverse(LIST_URL),
{'name': obj_1.name}, {'name': obj_1.name},
@ -116,6 +171,8 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1):
response = json.loads(r.content) response = json.loads(r.content)
assert r.status_code == 201 assert r.status_code == 201
assert response['id'] == obj_1.id 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( r = u1_s2.post(
reverse(LIST_URL), reverse(LIST_URL),
@ -125,9 +182,13 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1):
response = json.loads(r.content) response = json.loads(r.content)
assert r.status_code == 201 assert r.status_code == 201
assert response['id'] != obj_1.id 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( r = u1_s2.delete(
reverse( reverse(
DETAIL_URL, DETAIL_URL,
@ -135,14 +196,173 @@ def test_delete(u1_s1, u1_s2, obj_1):
) )
) )
assert r.status_code == 404 assert r.status_code == 404
with scopes_disabled():
assert Food.objects.count() == 3
r = u1_s1.delete( r = u1_s1.delete(
reverse( reverse(
DETAIL_URL, DETAIL_URL,
args={obj_1.id} args={obj_1_1.id}
) )
) )
assert r.status_code == 204 assert r.status_code == 204
with scopes_disabled(): 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

View File

@ -49,28 +49,22 @@ def obj_3(space_2):
@pytest.fixture() @pytest.fixture()
def recipe_1_s1(obj_1, recipe_1_s1, space_1): def recipe_1_s1(obj_1, recipe_1_s1, space_1):
recipe_1_s1.keywords.add(obj_1.id) return recipe_1_s1.keywords.add(obj_1)
return recipe_1_s1
@pytest.fixture() @pytest.fixture()
def recipe_2_s1(obj_2, recipe_2_s1, space_1): def recipe_2_s1(obj_2, recipe_2_s1, space_1):
recipe_2_s1.keywords.add(obj_2.id) return recipe_2_s1.keywords.add(obj_2)
return recipe_1_s1
@pytest.fixture() @pytest.fixture()
def recipe_3_s2(u1_s2, obj_3, space_2): def recipe_3_s2(u1_s2, obj_3, space_2):
r = get_random_recipe(space_2, u1_s2) return get_random_recipe(space_2, u1_s2).keywords.add(obj_3)
r.keywords.add(obj_3.id)
return r
@pytest.fixture() @pytest.fixture()
def recipe_1_1_s1(u1_s1, obj_1_1, space_1): def recipe_1_1_s1(u1_s1, obj_1_1, space_1):
r = get_random_recipe(space_1, u1_s1) return get_random_recipe(space_1, u1_s1).keywords.add(obj_1_1)
r.keywords.add(obj_1_1.id)
return r
@pytest.mark.parametrize("arg", [ @pytest.mark.parametrize("arg", [

View File

@ -6,7 +6,7 @@ from django.forms import model_to_dict
from django.urls import reverse from django.urls import reverse
from django_scopes import scopes_disabled 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' LIST_URL = 'api:shoppinglistentry-list'
DETAIL_URL = 'api:shoppinglistentry-detail' DETAIL_URL = 'api:shoppinglistentry-detail'
@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail'
@pytest.fixture() @pytest.fixture()
def obj_1(space_1, u1_s1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return e return e
@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1):
@pytest.fixture @pytest.fixture
def obj_2(space_1, u1_s1): 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 = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e) s.entries.add(e)
return e return e

View File

@ -62,7 +62,7 @@ def get_random_recipe(space_1, u1_s1):
s1.ingredients.add( s1.ingredients.add(
Ingredient.objects.create( Ingredient.objects.create(
amount=1, 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, ), unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ),
note=uuid.uuid4(), note=uuid.uuid4(),
space=space_1, space=space_1,
@ -72,7 +72,7 @@ def get_random_recipe(space_1, u1_s1):
s2.ingredients.add( s2.ingredients.add(
Ingredient.objects.create( Ingredient.objects.create(
amount=1, 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, ), unit=Unit.objects.create(name=uuid.uuid4(), space=space_1, ),
note=uuid.uuid4(), note=uuid.uuid4(),
space=space_1, space=space_1,

View File

@ -87,7 +87,7 @@ urlpatterns = [
path('edit/recipe/convert/<int:pk>/', edit.convert_recipe, name='edit_convert_recipe'), path('edit/recipe/convert/<int:pk>/', edit.convert_recipe, name='edit_convert_recipe'),
path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'), path('edit/storage/<int:pk>/', 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/<int:pk>/', delete.delete_recipe_source, name='delete_recipe_source'), path('delete/recipe-source/<int:pk>/', 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/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'),
path('api/share-link/<int:pk>', api.share_link, name='api_share_link'), path('api/share-link/<int:pk>', api.share_link, name='api_share_link'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # 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 deprecated? 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'), # TODO is this deprecated? path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'),
path('telegram/setup/<int:pk>', telegram.setup_bot, name='telegram_setup'), path('telegram/setup/<int:pk>', telegram.setup_bot, name='telegram_setup'),
path('telegram/remove/<int:pk>', telegram.remove_bot, name='telegram_remove'), path('telegram/remove/<int:pk>', telegram.remove_bot, name='telegram_remove'),
@ -135,9 +135,13 @@ urlpatterns = [
name='web_manifest'), name='web_manifest'),
] ]
# generic_models = (
# Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync,
# Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink
# )
generic_models = ( generic_models = (
Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync,
Comment, RecipeBookEntry, Food, ShoppingList, InviteLink Comment, RecipeBookEntry, ShoppingList, InviteLink
) )
for m in generic_models: 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: for m in tree_models:
py_name = get_model_name(m) py_name = get_model_name(m)
url_name = py_name.replace('_', '-') url_name = py_name.replace('_', '-')

View File

@ -12,7 +12,8 @@ from django.contrib.auth.models import User
from django.contrib.postgres.search import TrigramSimilarity from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.core.files import File 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.http import FileResponse, HttpResponse, JsonResponse
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from django.shortcuts import redirect, get_object_or_404 from django.shortcuts import redirect, get_object_or_404
@ -136,6 +137,7 @@ class FuzzyFilterMixin(ViewSetMixin):
class TreeMixin(FuzzyFilterMixin): class TreeMixin(FuzzyFilterMixin):
model = None model = None
related_models = [{'model': None, 'field': None}]
schema = TreeSchema() schema = TreeSchema()
def get_queryset(self): def get_queryset(self):
@ -155,7 +157,7 @@ class TreeMixin(FuzzyFilterMixin):
elif tree: elif tree:
if tree.isnumeric(): if tree.isnumeric():
try: 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: except self.model.DoesNotExist:
self.queryset = self.model.objects.none() self.queryset = self.model.objects.none()
else: else:
@ -226,18 +228,24 @@ class TreeMixin(FuzzyFilterMixin):
if target in source.get_descendants_and_self(): if target in source.get_descendants_and_self():
content = {'error': True, 'msg': _('Cannot merge with child object!')} content = {'error': True, 'msg': _('Cannot merge with child object!')}
return Response(content, status=status.HTTP_403_FORBIDDEN) 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: for model in self.related_models:
getattr(r, self.basename + 's').add(target) if isinstance(getattr(model['model'], model['field']), ManyToManyDescriptor):
getattr(r, self.basename + 's').remove(source) related = model['model'].objects.filter(**{model['field'] + "__id": source.id}, space=self.request.space)
r.save() else:
children = source.get_children().exclude(id=target.id) related = model['model'].objects.filter(**{model['field']: source}, space=self.request.space)
for c in children:
c.move(target, 'sorted-child') 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}')} content = {'msg': _(f'{source.name} was merged successfully with {target.name}')}
source.delete() source.delete()
return Response(content, status=status.HTTP_200_OK) return Response(content, status=status.HTTP_200_OK)
@ -341,6 +349,7 @@ class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMi
class KeywordViewSet(viewsets.ModelViewSet, TreeMixin): class KeywordViewSet(viewsets.ModelViewSet, TreeMixin):
queryset = Keyword.objects queryset = Keyword.objects
model = Keyword model = Keyword
related_models = [{'model': Recipe, 'field': 'keywords'}]
serializer_class = KeywordSerializer serializer_class = KeywordSerializer
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
pagination_class = DefaultPagination pagination_class = DefaultPagination
@ -356,10 +365,13 @@ class UnitViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
return super().get_queryset() return super().get_queryset()
class FoodViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
queryset = Food.objects queryset = Food.objects
model = Food
related_models = [{'model': Ingredient, 'field': 'food'}]
serializer_class = FoodSerializer serializer_class = FoodSerializer
permission_classes = [CustomIsUser] permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):

View File

@ -5,4 +5,9 @@ from cookbook.helper.permission_helper import group_required
@group_required('user') @group_required('user')
def keyword(request): 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', {})

View File

@ -16,8 +16,8 @@ import re
from django.contrib import messages from django.contrib import messages
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# from dotenv import load_dotenv from dotenv import load_dotenv
# load_dotenv() load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Get vars from .env files # Get vars from .env files