food to tree (model, api, serializer)
This commit is contained in:
parent
6ec0c1c71d
commit
24a575c2d5
@ -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', '')
|
||||
|
57
cookbook/migrations/food_to_tree.py
Normal file
57
cookbook/migrations/food_to_tree.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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 no recipes found - check whole tree
|
||||
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
|
||||
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 obj.recipe_set.all().count()
|
||||
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):
|
||||
|
31
cookbook/templates/model/food_template.html
Normal file
31
cookbook/templates/model/food_template.html
Normal 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 %}
|
@ -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 %}
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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", [
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -87,7 +87,7 @@ urlpatterns = [
|
||||
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/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'),
|
||||
|
||||
@ -109,9 +109,9 @@ urlpatterns = [
|
||||
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('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/<int:pk>', telegram.setup_bot, name='telegram_setup'),
|
||||
path('telegram/remove/<int:pk>', 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('_', '-')
|
||||
|
@ -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,14 +228,20 @@ 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)
|
||||
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:
|
||||
@ -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):
|
||||
|
@ -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', {})
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user