Merge shopping_list develop

This commit is contained in:
Tiago Rascazzi
2022-01-04 13:19:34 -05:00
104 changed files with 10372 additions and 5067 deletions

View File

@@ -1,23 +1,22 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group, User
from django.contrib.postgres.search import SearchVector
from django.utils import translation
from django_scopes import scopes_disabled
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User, Group
from django_scopes import scopes_disabled
from django.utils import translation
from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
MealPlan, MealType, NutritionInformation, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
ImportLog, TelegramBot, BookmarkletImport, UserFile, SearchPreference)
from cookbook.managers import DICTIONARY
from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog,
Ingredient, InviteLink, Keyword, MealPlan, MealType, NutritionInformation,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog)
class CustomUserAdmin(UserAdmin):
def has_add_permission(self, request, obj=None):
@@ -129,6 +128,7 @@ def sort_tree(modeladmin, request, queryset):
class KeywordAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
@@ -171,11 +171,13 @@ class RecipeAdmin(admin.ModelAdmin):
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
# admin.site.register(FoodInheritField)
class FoodAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
@@ -280,7 +282,7 @@ admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
class ShoppingListEntryAdmin(admin.ModelAdmin):
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
list_display = ('id', 'food', 'unit', 'list_recipe', 'created_by', 'created_at', 'checked')
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)

View File

@@ -12,29 +12,27 @@ class CookbookConfig(AppConfig):
name = 'cookbook'
def ready(self):
# post_save signal is only necessary if using full-text search on postgres
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
import cookbook.signals # noqa
import cookbook.signals # noqa
# if not settings.DISABLE_TREE_FIX_STARTUP:
# # when starting up run fix_tree to:
# # a) make sure that nodes are sorted when switching between sort modes
# # b) fix problems, if any, with tree consistency
# with scopes_disabled():
# try:
# from cookbook.models import Food, Keyword
# Keyword.fix_tree(fix_paths=True)
# Food.fix_tree(fix_paths=True)
# except OperationalError:
# if DEBUG:
# traceback.print_exc()
# pass # if model does not exist there is no need to fix it
# except ProgrammingError:
# if DEBUG:
# traceback.print_exc()
# pass # if migration has not been run database cannot be fixed yet
# except Exception:
# if DEBUG:
# traceback.print_exc()
# pass # dont break startup just because fix could not run, need to investigate cases when this happens
if not settings.DISABLE_TREE_FIX_STARTUP:
# when starting up run fix_tree to:
# a) make sure that nodes are sorted when switching between sort modes
# b) fix problems, if any, with tree consistency
with scopes_disabled():
try:
from cookbook.models import Keyword, Food
#Keyword.fix_tree(fix_paths=True) # disabled for now, causes to many unknown issues
#Food.fix_tree(fix_paths=True)
except OperationalError:
if DEBUG:
traceback.print_exc()
pass # if model does not exist there is no need to fix it
except ProgrammingError:
if DEBUG:
traceback.print_exc()
pass # if migration has not been run database cannot be fixed yet
except Exception:
if DEBUG:
traceback.print_exc()
pass # dont break startup just because fix could not run, need to investigate cases when this happens

View File

@@ -1,16 +1,14 @@
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.forms import widgets, NumberInput
from django.forms import NumberInput, widgets
from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField
from .models import (Comment, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, Storage, Sync, User,
UserPreference, MealType, Space,
SearchPreference)
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
class SelectWidget(widgets.Select):
@@ -37,7 +35,10 @@ class UserPreferenceForm(forms.ModelForm):
prefix = 'preference'
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
if x := kwargs.get('instance', None):
space = x.space
else:
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
@@ -46,8 +47,7 @@ class UserPreferenceForm(forms.ModelForm):
fields = (
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
'comments'
'plan_share', 'ingredient_decimals', 'comments',
)
labels = {
@@ -74,8 +74,8 @@ class UserPreferenceForm(forms.ModelForm):
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
# noqa: E501
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
'plan_share': _(
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
'shopping_share': _('Users with whom to share shopping lists.'),
# noqa: E501
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
@@ -84,11 +84,14 @@ class UserPreferenceForm(forms.ModelForm):
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
),
'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501
'sticky_navbar': _('Makes the navbar stick to the top of the page.'), # noqa: E501
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
}
widgets = {
'plan_share': MultiSelectWidget
'plan_share': MultiSelectWidget,
'shopping_share': MultiSelectWidget,
}
@@ -224,6 +227,7 @@ class StorageForm(forms.ModelForm):
}
# TODO: Deprecate
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
@@ -263,6 +267,7 @@ class SyncForm(forms.ModelForm):
}
# TODO deprecate
class BatchEditForm(forms.Form):
search = forms.CharField(label=_('Search String'))
keywords = forms.ModelMultipleChoiceField(
@@ -299,6 +304,7 @@ class ImportRecipeForm(forms.ModelForm):
}
# TODO deprecate
class MealPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
@@ -466,3 +472,70 @@ class SearchPreferenceForm(forms.ModelForm):
'trigram': MultiSelectWidget,
'fulltext': MultiSelectWidget,
}
class ShoppingPreferenceForm(forms.ModelForm):
prefix = 'shopping'
class Meta:
model = UserPreference
fields = (
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
'mealplan_autoinclude_related', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
)
help_texts = {
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
'csv_delim': _('Delimiter to use for CSV exports.'),
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
}
labels = {
'shopping_share': _('Share Shopping List'),
'shopping_auto_sync': _('Autosync'),
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
'mealplan_autoinclude_related': _('Include Related'),
'default_delay': _('Default Delay Hours'),
'filter_to_supermarket': _('Filter to Supermarket'),
'shopping_recent_days': _('Recent Days'),
'csv_delim': _('CSV Delimiter'),
"csv_prefix_label": _("List Prefix")
}
widgets = {
'shopping_share': MultiSelectWidget
}
class SpacePreferenceForm(forms.ModelForm):
prefix = 'space'
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
help_text=_("Reset all food to inherit the fields configured."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # populates the post
self.fields['food_inherit'].queryset = Food.inheritable_fields
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit',)
help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'), }
widgets = {
'food_inherit': MultiSelectWidget
}

View File

@@ -0,0 +1,13 @@
from django.db.models import Func
class Round(Func):
function = 'ROUND'
template = '%(function)s(%(expressions)s, 0)'
def str2bool(v):
if type(v) == bool:
return v
else:
return v.lower() in ("yes", "true", "1")

View File

@@ -2,11 +2,9 @@
Source: https://djangosnippets.org/snippets/1703/
"""
from django.conf import settings
from django.core.cache import caches
from cookbook.models import ShareLink
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
from django.core.cache import caches
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy
@@ -14,6 +12,8 @@ from django.utils.translation import gettext as _
from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink
def get_allowed_groups(groups_required):
"""
@@ -205,6 +205,9 @@ class CustomIsShared(permissions.BasePermission):
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
# temporary hack to make old shopping list work with new shopping list
if obj.__class__.__name__ == 'ShoppingList':
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj)

View File

@@ -8,24 +8,13 @@ from django.db.models.functions import Coalesce
from django.utils import timezone, translation
from cookbook.filters import RecipeFilter
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.helper.permission_helper import has_group_permission
from cookbook.managers import DICTIONARY
from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog
from recipes import settings
class Round(Func):
function = 'ROUND'
template = '%(function)s(%(expressions)s, 0)'
def str2bool(v):
if type(v) == bool:
return v
else:
return v.lower() in ("yes", "true", "1")
# 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):
@@ -41,7 +30,6 @@ def search_recipes(request, queryset, params):
search_steps = params.getlist('steps', [])
search_units = params.get('units', None)
# TODO I think default behavior should be 'AND' which is how most sites operate with facet/filters based on results
search_keywords_or = str2bool(params.get('keywords_or', True))
search_foods_or = str2bool(params.get('foods_or', True))
search_books_or = str2bool(params.get('books_or', True))
@@ -49,7 +37,7 @@ def search_recipes(request, queryset, params):
search_internal = str2bool(params.get('internal', False))
search_random = str2bool(params.get('random', False))
search_new = str2bool(params.get('new', False))
search_last_viewed = int(params.get('last_viewed', 0))
search_last_viewed = int(params.get('last_viewed', 0)) # not included in schema currently?
orderby = []
# only sort by recent not otherwise filtering/sorting
@@ -208,24 +196,18 @@ def search_recipes(request, queryset, params):
return queryset
# TODO: This might be faster https://github.com/django-treebeard/django-treebeard/issues/115
def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
"""
Gets an annotated list from a queryset.
:param qs:
recipe queryset to build facets from
:param request:
the web request that contains the necessary query parameters
:param use_cache:
will find results in cache, if any, and return them or empty list.
will save the list of recipes IDs in the cache for future processing
:param hash_key:
the cache key of the recipe list to process
only evaluated if the use_cache parameter is false
"""
@@ -300,7 +282,6 @@ def get_facet(qs=None, request=None, use_cache=True, hash_key=None):
foods = Food.objects.filter(ingredient__step__recipe__in=recipe_list, space=request.space).annotate(recipe_count=Count('ingredient'))
food_a = annotated_qs(foods, root=True, fill=True)
# TODO add rating facet
facets['Keywords'] = fill_annotated_parents(kw_a, keyword_list)
facets['Foods'] = fill_annotated_parents(food_a, food_list)
# TODO add book facet
@@ -373,8 +354,6 @@ def annotated_qs(qs, root=False, fill=False):
dirty = False
current_node = node_queue[-1]
depth = current_node.get_depth()
# TODO if node is at the wrong depth for some reason this fails
# either create a 'fix node' page, or automatically move the node to the root
parent_id = current_node.parent
if root and depth > 1 and parent_id not in nodes_list:
parent_id = current_node.parent

View File

@@ -0,0 +1,155 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe,
SupermarketCategoryRelation)
from recipes import settings
def shopping_helper(qs, request):
supermarket = request.query_params.get('supermarket', None)
checked = request.query_params.get('checked', 'recent')
user = request.user
supermarket_order = ['food__supermarket_category__name', 'food__name']
# TODO created either scheduled task or startup task to delete very old shopping list entries
# TODO create user preference to define 'very old'
if supermarket:
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
supermarket_order = ['supermarket_order'] + supermarket_order
if checked in ['false', 0, '0']:
qs = qs.filter(checked=False)
elif checked in ['true', 1, '1']:
qs = qs.filter(checked=True)
elif checked in ['recent']:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
"""
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
:param list_recipe: Modify an existing ShoppingListRecipe
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
:param mealplan: alternatively use a mealplan recipe as source of ingredients
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
"""
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
if not r:
raise ValueError(_("You must supply a recipe or mealplan"))
created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
if not created_by:
raise ValueError(_("You must supply a created_by"))
try:
servings = float(servings)
except (ValueError, TypeError):
servings = getattr(mealplan, 'servings', 1.0)
servings_factor = servings / r.servings
shared_users = list(created_by.get_shopping_share())
shared_users.append(created_by)
if list_recipe:
created = False
else:
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
created = True
related_step_ing = []
if servings == 0 and not created:
list_recipe.delete()
return []
elif ingredients:
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
else:
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
ingredients = ingredients.exclude(food__food_onhand=True)
if related := created_by.userpreference.mealplan_autoinclude_related:
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
related_recipes = r.get_related_recipes()
for x in related_recipes:
# related recipe is a Step serving size is driven by recipe serving size
# TODO once/if Steps can have a serving size this needs to be refactored
if exclude_onhand:
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
related_step_ing += Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space).values_list('id', flat=True)
else:
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
x_ing = []
if ingredients.filter(food__recipe=x).exists():
for ing in ingredients.filter(food__recipe=x):
if exclude_onhand:
x_ing = Ingredient.objects.filter(step__recipe=x, food__food_onhand=False, space=space)
else:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
for i in [x for x in x_ing]:
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(servings_factor),
created_by=created_by,
space=space,
)
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
ingredients = ingredients.exclude(food__recipe=x)
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
if not append:
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# delete shopping list entries not included in ingredients
existing_list.exclude(ingredient__in=ingredients).delete()
# add shopping list entries that did not previously exist
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# if servings have changed, update the ShoppingListRecipe and existing Entrys
if servings <= 0:
servings = 1
if not created and list_recipe.servings != servings:
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
list_recipe.servings = servings
list_recipe.save()
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
sle.save()
# add any missing Entrys
for i in [x for x in add_ingredients if x.food]:
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(servings_factor),
created_by=created_by,
space=space,
)
# return all shopping list items
return list_recipe

View File

@@ -3,7 +3,7 @@ import json
import traceback
import uuid
from io import BytesIO, StringIO
from zipfile import ZipFile, BadZipFile
from zipfile import BadZipFile, ZipFile
from bs4 import Tag
from django.core.exceptions import ObjectDoesNotExist

View File

@@ -2,13 +2,15 @@
import annoying.fields
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField, SearchVector
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.db import migrations, models
from django.db.models import deletion
from django_scopes import scopes_disabled
from django.utils import translation
from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
from cookbook.models import (Index, PermissionModelMixin, Recipe, Step, allSearchFields,
nameSearchField)
def set_default_search_vector(apps, schema_editor):
@@ -16,8 +18,6 @@ def set_default_search_vector(apps, schema_editor):
return
language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled():
# TODO this approach doesn't work terribly well if multiple languages are in use
# I'm also uncertain about forcing unaccent here
Recipe.objects.all().update(
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)

View File

@@ -0,0 +1,144 @@
# Generated by Django 3.2.7 on 2021-10-01 20:52
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
from django_scopes import scopes_disabled
from cookbook.models import PermissionModelMixin, ShoppingListEntry
def copy_values_to_sle(apps, schema_editor):
with scopes_disabled():
entries = ShoppingListEntry.objects.all()
for entry in entries:
if entry.shoppinglist_set.first():
entry.created_by = entry.shoppinglist_set.first().created_by
entry.space = entry.shoppinglist_set.first().space
if entries:
ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ])
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0158_userpreference_use_kj'),
]
operations = [
migrations.AddField(
model_name='shoppinglistentry',
name='completed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='shoppinglistentry',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='shoppinglistentry',
name='created_by',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'),
preserve_default=False,
),
migrations.AddField(
model_name='userpreference',
name='shopping_share',
field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='shoppinglistentry',
name='space',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
preserve_default=False,
),
migrations.AddField(
model_name='shoppinglistrecipe',
name='mealplan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.mealplan'),
),
migrations.AddField(
model_name='shoppinglistrecipe',
name='name',
field=models.CharField(blank=True, default='', max_length=32),
),
migrations.AddField(
model_name='shoppinglistentry',
name='ingredient',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ingredient'),
),
migrations.AlterField(
model_name='shoppinglistentry',
name='unit',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'),
),
migrations.AddField(
model_name='userpreference',
name='mealplan_autoadd_shopping',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='userpreference',
name='mealplan_autoexclude_onhand',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='shoppinglistentry',
name='list_recipe',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='cookbook.shoppinglistrecipe'),
),
migrations.CreateModel(
name='FoodInheritField',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('field', models.CharField(max_length=32, unique=True)),
('name', models.CharField(max_length=64, unique=True)),
],
bases=(models.Model, PermissionModelMixin),
),
migrations.AddField(
model_name='userpreference',
name='mealplan_autoinclude_related',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='food',
name='inherit_fields',
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
),
migrations.AddField(
model_name='space',
name='food_inherit',
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
),
migrations.AddField(
model_name='shoppinglistentry',
name='delay_until',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='userpreference',
name='default_delay',
field=models.DecimalField(decimal_places=4, default=4, max_digits=8),
),
migrations.AddField(
model_name='userpreference',
name='filter_to_supermarket',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='userpreference',
name='shopping_recent_days',
field=models.PositiveIntegerField(default=7),
),
migrations.RenameField(
model_name='food',
old_name='ignore_shopping',
new_name='food_onhand',
),
migrations.RunPython(copy_values_to_sle),
]

View File

@@ -0,0 +1,50 @@
# Generated by Django 3.2.7 on 2021-10-01 22:34
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.utils import timezone
from django.utils.timezone import utc
from django_scopes import scopes_disabled
from cookbook.models import FoodInheritField, ShoppingListEntry
def delete_orphaned_sle(apps, schema_editor):
with scopes_disabled():
# shopping list entry is orphaned - delete it
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
def create_inheritfields(apps, schema_editor):
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
FoodInheritField.objects.create(name='Diet', field='diet')
FoodInheritField.objects.create(name='Substitute', field='substitute')
FoodInheritField.objects.create(name='Substitute Children', field='substitute_children')
FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings')
def set_completed_at(apps, schema_editor):
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
month_ago = today_start - timedelta(days=30)
with scopes_disabled():
ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0159_add_shoppinglistentry_fields'),
]
operations = [
migrations.RunPython(delete_orphaned_sle),
migrations.RunPython(create_inheritfields),
migrations.RunPython(set_completed_at),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.8 on 2021-11-03 23:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0160_delete_shoppinglist_orphans'),
]
operations = [
migrations.AlterField(
model_name='shoppinglistentry',
name='food',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_entries', to='cookbook.food'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.2.9 on 2021-11-30 22:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0161_alter_shoppinglistentry_food'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='csv_delim',
field=models.CharField(default=',', max_length=2),
),
migrations.AddField(
model_name='userpreference',
name='csv_prefix',
field=models.CharField(blank=True, max_length=10),
),
]

View File

@@ -35,7 +35,20 @@ def get_user_name(self):
return self.username
def get_shopping_share(self):
# get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
return User.objects.raw(' '.join([
'SELECT auth_user.id FROM auth_user',
'INNER JOIN cookbook_userpreference',
'ON (auth_user.id = cookbook_userpreference.user_id)',
'INNER JOIN cookbook_userpreference_shopping_share',
'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
]))
auth.models.User.add_to_class('get_user_name', get_user_name)
auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
def get_model_name(model):
@@ -54,6 +67,9 @@ class TreeManager(MP_NodeManager):
except self.model.DoesNotExist:
with scopes_disabled():
try:
defaults = kwargs.pop('defaults', None)
if defaults:
kwargs = {**kwargs, **defaults}
# ManyToMany fields can't be set this way, so pop them out to save for later
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
@@ -78,6 +94,13 @@ class TreeModel(MP_Node):
else:
return f"{self.name}"
# MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
def move(self, *args, **kwargs):
super().move(*args, **kwargs)
# treebeard bypasses ORM, need to retrieve the object again to avoid writing previous state back to disk
obj = self.__class__.objects.get(id=self.id)
obj.save()
@property
def parent(self):
parent = self.get_parent()
@@ -124,6 +147,47 @@ class TreeModel(MP_Node):
with scopes_disabled():
return super().add_root(**kwargs)
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
def include_descendants(queryset=None, filter=None):
"""
:param queryset: Model Queryset to add descendants
:param filter: Filter (exclude) the descendants nodes with the provided Q filter
"""
descendants = Q()
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
nodes = queryset.values('path', 'depth')
for node in nodes:
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants)
def exclude_descendants(queryset=None, filter=None):
"""
:param queryset: Model Queryset to add descendants
:param filter: Filter (include) the descendants nodes with the provided Q filter
"""
descendants = Q()
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
nodes = queryset.values('path', 'depth')
for node in nodes:
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants)
def include_ancestors(queryset=None):
"""
:param queryset: Model Queryset to add ancestors
:param filter: Filter (include) the ancestors nodes with the provided Q filter
"""
queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
nodes = list(set(queryset.values_list('root', 'depth')))
ancestors = Q()
for node in nodes:
ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors)
class Meta:
abstract = True
@@ -157,6 +221,18 @@ class PermissionModelMixin:
raise NotImplementedError('get space for method not implemented and standard fields not available')
class FoodInheritField(models.Model, PermissionModelMixin):
field = models.CharField(max_length=32, unique=True)
name = models.CharField(max_length=64, unique=True)
def __str__(self):
return _(self.name)
@staticmethod
def get_name(self):
return _(self.name)
class Space(ExportModelOperationsMixin('space'), models.Model):
name = models.CharField(max_length=128, default='Default')
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
@@ -167,6 +243,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
max_users = models.IntegerField(default=0)
allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
def __str__(self):
return self.name
@@ -245,10 +322,21 @@ class UserPreference(models.Model, PermissionModelMixin):
plan_share = models.ManyToManyField(
User, blank=True, related_name='plan_share_default'
)
shopping_share = models.ManyToManyField(
User, blank=True, related_name='shopping_share'
)
ingredient_decimals = models.IntegerField(default=2)
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
shopping_auto_sync = models.IntegerField(default=5)
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True)
filter_to_supermarket = models.BooleanField(default=False)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
csv_delim = models.CharField(max_length=2, default=",")
csv_prefix = models.CharField(max_length=10, blank=True,)
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
@@ -363,8 +451,8 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
name = models.CharField(max_length=64)
icon = models.CharField(max_length=16, blank=True, null=True)
description = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager)
@@ -393,13 +481,18 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
# exclude fields not implemented yet
inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings'])
# WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals
if SORT_TREE_BY_NAME:
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)
ignore_shopping = models.BooleanField(default=False)
food_onhand = models.BooleanField(default=False) # inherited field
description = models.TextField(default='', blank=True)
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True) # inherited field: is this name better as inherit instead of ignore inherit? which is more intuitive?
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager)
@@ -413,6 +506,35 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
else:
return super().delete()
@staticmethod
def reset_inheritance(space=None):
# resets inheritted fields to the space defaults and updates all inheritted fields to root object values
inherit = space.food_inherit.all()
# remove all inherited fields from food
Through = Food.objects.filter(space=space).first().inherit_fields.through
Through.objects.all().delete()
# food is going to inherit attributes
if space.food_inherit.all().count() > 0:
# ManyToMany cannot be updated through an UPDATE operation
for i in inherit:
Through.objects.bulk_create([
Through(food_id=x, foodinheritfield_id=i.id)
for x in Food.objects.filter(space=space).values_list('id', flat=True)
])
inherit = inherit.values_list('field', flat=True)
if 'food_onhand' in inherit:
# get food at root that have children that need updated
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=True)).update(food_onhand=True)
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, food_onhand=False)).update(food_onhand=False)
if 'supermarket_category' in inherit:
# when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants
# find top node that has category set
category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space))
for root in category_roots:
root.get_descendants().update(supermarket_category=root.supermarket_category)
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
@@ -534,6 +656,21 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
def __str__(self):
return self.name
def get_related_recipes(self, levels=1):
# recipes for step recipe
step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe'))
# recipes for foods
food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe'))
related_recipes = Recipe.objects.filter(step_recipes | food_recipes)
if levels == 1:
return related_recipes
# this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?)
# for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios
sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe'))
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
class Meta():
indexes = (
GinIndex(fields=["name_search_vector"]),
@@ -660,8 +797,10 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
name = models.CharField(max_length=32, blank=True, default='')
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
objects = ScopedManager(space='recipe__space')
@@ -677,20 +816,26 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
def get_owner(self):
try:
return self.shoppinglist_set.first().created_by
return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
except AttributeError:
return None
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
food = models.ForeignKey(Food, on_delete=models.CASCADE)
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
food = models.ForeignKey(Food, on_delete=models.CASCADE, related_name='shopping_entries')
unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True)
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
order = models.IntegerField(default=0)
checked = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
completed_at = models.DateTimeField(null=True, blank=True)
delay_until = models.DateTimeField(null=True, blank=True)
objects = ScopedManager(space='shoppinglist__space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@staticmethod
def get_space_key():
@@ -702,12 +847,14 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
def __str__(self):
return f'Shopping list entry {self.id}'
# TODO deprecate
def get_shared(self):
return self.shoppinglist_set.first().shared.all()
# TODO deprecate
def get_owner(self):
try:
return self.shoppinglist_set.first().created_by
return self.created_by or self.shoppinglist_set.first().created_by
except AttributeError:
return None

View File

@@ -11,12 +11,14 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, ImportLog,
Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket,
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit,
UserFile, UserPreference, ViewLog)
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown
@@ -37,9 +39,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
if bool(int(
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
return fields
except AttributeError:
pass
except KeyError:
except (AttributeError, KeyError) as e:
pass
try:
del fields['image']
@@ -95,6 +95,8 @@ class CustomDecimalField(serializers.Field):
class SpaceFilterSerializer(serializers.ListSerializer):
def to_representation(self, data):
if self.context.get('request', None) is None:
return
if (type(data) == QuerySet and data.query.is_sliced):
# if query is sliced it came from api request not nested serializer
return super().to_representation(data)
@@ -136,20 +138,45 @@ class UserNameSerializer(WritableNestedModelSerializer):
fields = ('id', 'username')
class UserPreferenceSerializer(serializers.ModelSerializer):
plan_share = UserNameSerializer(many=True, read_only=True)
class FoodInheritFieldSerializer(WritableNestedModelSerializer):
name = serializers.CharField(allow_null=True, allow_blank=True, required=False)
field = serializers.CharField(allow_null=True, allow_blank=True, required=False)
def create(self, validated_data):
if validated_data['user'] != self.context['request'].user:
# don't allow writing to FoodInheritField via API
return FoodInheritField.objects.get(**validated_data)
def update(self, instance, validated_data):
# don't allow writing to FoodInheritField via API
return FoodInheritField.objects.get(**validated_data)
class Meta:
model = FoodInheritField
fields = ('id', 'name', 'field', )
read_only_fields = ['id']
class UserPreferenceSerializer(serializers.ModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
def create(self, validated_data):
if not validated_data.get('user', None):
raise ValidationError(_('A user is required'))
if (validated_data['user'] != self.context['request'].user):
raise NotFound()
return super().create(validated_data)
def update(self, instance, validated_data):
# don't allow writing to FoodInheritField via API
return super().update(instance, validated_data)
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
'comments'
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', 'filter_to_supermarket'
)
@@ -255,25 +282,11 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
label = serializers.SerializerMethodField('get_label')
# image = serializers.SerializerMethodField('get_image')
# numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'keywords'
def get_label(self, obj):
return str(obj)
# def get_image(self, obj):
# recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
# if recipes.count() == 0 and obj.has_children():
# recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
# if recipes.count() != 0:
# return random.choice(recipes).image.url
# else:
# return None
# def count_recipes(self, obj):
# return obj.recipe_set.filter(space=self.context['request'].space).all().count()
def create(self, validated_data):
# since multi select tags dont have id's
# duplicate names might be routed to create
@@ -286,26 +299,13 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
model = Keyword
fields = (
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
'updated_at')
read_only_fields = ('id', 'numchild', 'parent', 'image')
'updated_at', 'full_name')
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
# image = serializers.SerializerMethodField('get_image')
# numrecipe = serializers.SerializerMethodField('count_recipes')
recipe_filter = 'steps__ingredients__unit'
# def get_image(self, obj):
# recipes = Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
# if recipes.count() != 0:
# return random.choice(recipes).image.url
# else:
# return None
# def count_recipes(self, obj):
# return Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).count()
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
validated_data['space'] = self.context['request'].space
@@ -369,27 +369,13 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
# image = serializers.SerializerMethodField('get_image')
# numrecipe = serializers.SerializerMethodField('count_recipes')
shopping = serializers.SerializerMethodField('get_shopping_status')
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
recipe_filter = 'steps__ingredients__food'
# def get_image(self, obj):
# if obj.recipe and obj.space == obj.recipe.space:
# if obj.recipe.image and obj.recipe.image != '':
# return obj.recipe.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, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
# # if no recipes found - check whole tree
# if recipes.count() == 0 and obj.has_children():
# recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
# if recipes.count() != 0:
# return random.choice(recipes).image.url
# else:
# return None
# def count_recipes(self, obj):
# return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count()
def get_shopping_status(self, obj):
return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
@@ -403,16 +389,17 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
return obj
def update(self, instance, validated_data):
validated_data['name'] = validated_data['name'].strip()
if name := validated_data.get('name', None):
validated_data['name'] = name.strip()
return super(FoodSerializer, self).update(instance, validated_data)
class Meta:
model = Food
fields = (
'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent',
'numchild',
'numrecipe')
read_only_fields = ('id', 'numchild', 'parent', 'image')
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
class IngredientSerializer(WritableNestedModelSerializer):
@@ -621,53 +608,124 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
note_markdown = serializers.SerializerMethodField('get_note_markdown')
servings = CustomDecimalField()
shared = UserNameSerializer(many=True)
shared = UserNameSerializer(many=True, required=False, allow_null=True)
shopping = serializers.SerializerMethodField('in_shopping')
def get_note_markdown(self, obj):
return markdown(obj.note)
def in_shopping(self, obj):
return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists()
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
mealplan = super().create(validated_data)
if self.context['request'].data.get('addshopping', False):
list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space'])
return mealplan
class Meta:
model = MealPlan
fields = (
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
'meal_type_name'
'meal_type_name', 'shopping'
)
read_only_fields = ('created_by',)
# TODO deprecate
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
recipe_name = serializers.ReadOnlyField(source='recipe.name')
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
servings = CustomDecimalField()
def get_name(self, obj):
if not isinstance(value := obj.servings, Decimal):
value = Decimal(value)
value = value.quantize(Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
return (
obj.name
or getattr(obj.mealplan, 'title', None)
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
or obj.recipe.name
) + f' ({value:.2g})'
def update(self, instance, validated_data):
if 'servings' in validated_data:
list_from_recipe(
list_recipe=instance,
servings=validated_data['servings'],
created_by=self.context['request'].user,
space=self.context['request'].space
)
return super().update(instance, validated_data)
class Meta:
model = ShoppingListRecipe
fields = ('id', 'recipe', 'recipe_name', 'servings')
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
read_only_fields = ('id',)
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True, required=False)
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
amount = CustomDecimalField()
created_by = UserNameSerializer(read_only=True)
completed_at = serializers.DateTimeField(allow_null=True, required=False)
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)
# autosync values are only needed for frequent 'checked' value updating
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
for f in list(set(fields) - set(['id', 'checked'])):
del fields[f]
return fields
def run_validation(self, data):
if (
data.get('checked', False)
and self.root.instance
and not self.root.instance.checked
):
# if checked flips from false to true set completed datetime
data['completed_at'] = timezone.now()
elif not data.get('checked', False):
# if not checked set completed to None
data['completed_at'] = None
else:
# otherwise don't write anything
if 'completed_at' in data:
del data['completed_at']
return super().run_validation(data)
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
class Meta:
model = ShoppingListEntry
fields = (
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked'
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
# TODO deprecate
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingListEntry
fields = ('id', 'checked')
# TODO deprecate
class ShoppingListSerializer(WritableNestedModelSerializer):
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
@@ -688,6 +746,7 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
read_only_fields = ('id', 'created_by',)
# TODO deprecate
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
@@ -802,7 +861,7 @@ class FoodExportSerializer(FoodSerializer):
class Meta:
model = Food
fields = ('name', 'ignore_shopping', 'supermarket_category')
fields = ('name', 'food_onhand', 'supermarket_category',)
class IngredientExportSerializer(WritableNestedModelSerializer):
@@ -847,3 +906,24 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update"))
ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
"List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_("Providing a list_recipe ID and servings of 0 will delete that shopping list."))
class Meta:
model = Recipe
fields = ['id', 'list_recipe', 'ingredients', 'servings', ]
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Amount of food to add to the shopping list"))
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list"))
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, help_text=_("When set to true will delete all food from active shopping lists."))
class Meta:
model = Recipe
fields = ['id', 'amount', 'unit', 'delete', ]

View File

@@ -1,47 +1,123 @@
from decimal import Decimal
from functools import wraps
from django.conf import settings
from django.contrib.postgres.search import SearchVector
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import translation
from cookbook.models import Recipe, Step
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.managers import DICTIONARY
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
ShoppingListEntry, Step)
SQLITE = True
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
SQLITE = False
# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals
def skip_signal(signal_func):
@wraps(signal_func)
def _decorator(sender, instance, **kwargs):
if not instance:
return None
if hasattr(instance, 'skip_signal'):
return None
return signal_func(sender, instance, **kwargs)
return _decorator
# TODO there is probably a way to generalize this
@receiver(post_save, sender=Recipe)
@skip_signal
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
if not instance:
if SQLITE:
return
# needed to ensure search vector update doesn't trigger recursion
if hasattr(instance, '_dirty'):
return
language = DICTIONARY.get(translation.get_language(), 'simple')
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
try:
instance._dirty = True
instance.skip_signal = True
instance.save()
finally:
del instance._dirty
del instance.skip_signal
@receiver(post_save, sender=Step)
@skip_signal
def update_step_search_vector(sender, instance=None, created=False, **kwargs):
if SQLITE:
return
language = DICTIONARY.get(translation.get_language(), 'simple')
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
try:
instance.skip_signal = True
instance.save()
finally:
del instance.skip_signal
@receiver(post_save, sender=Food)
@skip_signal
def update_food_inheritance(sender, instance=None, created=False, **kwargs):
if not instance:
return
# needed to ensure search vector update doesn't trigger recursion
if hasattr(instance, '_dirty'):
inherit = instance.inherit_fields.all()
# nothing to apply from parent and nothing to apply to children
if (not instance.parent or inherit.count() == 0) and instance.numchild == 0:
return
language = DICTIONARY.get(translation.get_language(), 'simple')
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
inherit = inherit.values_list('field', flat=True)
# apply changes from parent to instance for each inheritted field
if instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'food_onhand' in inherit:
instance.food_onhand = parent.food_onhand
# if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change
if 'supermarket_category' in inherit and parent.supermarket_category:
instance.supermarket_category = parent.supermarket_category
try:
instance.skip_signal = True
instance.save()
finally:
del instance.skip_signal
try:
instance._dirty = True
instance.save()
finally:
del instance._dirty
# TODO figure out how to generalize this
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
_save = []
for child in instance.get_children().filter(inherit_fields__field='food_onhand'):
child.food_onhand = instance.food_onhand
_save.append(child)
# don't cascade empty supermarket category
if instance.supermarket_category:
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
for child in instance.get_children().filter(inherit_fields__field='supermarket_category'):
child.supermarket_category = instance.supermarket_category
_save.append(child)
for child in set(_save):
child.save()
@receiver(post_save, sender=MealPlan)
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
user = instance.get_owner()
if not user.userpreference.mealplan_autoadd_shopping:
return
if not created and instance.shoppinglistrecipe_set.exists():
for x in instance.shoppinglistrecipe_set.all():
if instance.servings != x.servings:
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
elif created:
# if creating a mealplan - perform shopping list activities
kwargs = {
'mealplan': instance,
'space': instance.space,
'created_by': user,
'servings': instance.servings
}
list_recipe = list_from_recipe(**kwargs)

File diff suppressed because one or more lines are too long

View File

@@ -339,7 +339,6 @@
{% user_prefs request as prefs%}
{{ prefs|json_script:'user_preference' }}
</div>
{% block script %}

View File

@@ -1,32 +0,0 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% comment %} {% load l10n %} {% endcomment %}
{% block title %}{{ title }}{% endblock %}
{% block content_fluid %}
<div id="app" >
<checklist-view></checklist-view>
</div>
{% endblock %}
{% block script %}
{{ config | json_script:"model_config" }}
{% 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 'checklist_view' %}
{% endblock %}

View File

@@ -18,12 +18,23 @@
{% endif %}
<div class="table-container">
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
{% if create_url %}
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
<span class="col col-md-9">
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
{% if create_url %}
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
</a>
{% endif %}
</h3>
</span>
{% if request.resolver_match.url_name in 'list_shopping_list' %}
<span class="col-md-3">
<a href="{% url 'view_shopping_new' %}" class="float-right">
<button class="btn btn-outline-secondary shadow-none">
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
</button>
</a>
{% endif %}
</h3>
</span>
{% endif %}
{% if filter %}
<br/>

View File

@@ -1,4 +1,5 @@
{% load i18n %}
{% comment %} TODO: Deprecate {% endcomment %}
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
<div class="modal-dialog" role="document">
@@ -77,4 +78,4 @@
$('#id_rating_show').html(rating.val() + '/5')
});
</script>
</script>

View File

@@ -48,6 +48,13 @@
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Search-Settings' %}</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if active_tab == 'shopping' %} active {% endif %}" id="shopping-tab" data-toggle="tab"
href="#shopping" role="tab"
aria-controls="search"
aria-selected="{% if active_tab == 'shopping' %} 'true' {% else %} 'false' {% endif %}">
{% trans 'Shopping-Settings' %}</a>
</li>
</ul>
@@ -195,6 +202,17 @@
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
</div>
<div class="tab-pane {% if active_tab == 'shopping' %} active {% endif %}" id="shopping" role="tabpanel"
aria-labelledby="shopping-tab">
<h4>{% trans 'Shopping Settings' %}</h4>
<form action="./#shopping" method="post" id="id_shopping_form">
{% csrf_token %}
{{ shopping_form|crispy }}
<button class="btn btn-success" type="submit" name="shopping_form" id="shopping_form_button"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
</div>
</div>
@@ -224,5 +242,26 @@
$('.nav-tabs a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash;
})
// listen for events
{% comment %} $(document).ready(function(){
hideShow()
// call hideShow when the user clicks on the mealplan_autoadd checkbox
$("#id_shopping-mealplan_autoadd_shopping").click(function(event){
hideShow()
});
})
function hideShow(){
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true)
{
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
$('#div_id_shopping-mealplan_autoinclude_related').show();
}
else
{
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
$('#div_id_shopping-mealplan_autoinclude_related').hide();
} {% endcomment %}
}
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %}
<div id="app">
<shopping-list-view></shopping-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 'shopping_list_view' %} {% endblock %}

View File

@@ -1,165 +1,188 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load crispy_forms_filters %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Space Settings" %}{% endblock %}
{%block title %} {% trans "Space Settings" %} {% endblock %}
{% block extra_head %}
{{ form.media }}
{{ space_form.media }}
{% include 'include/vue_base.html' %}
{% endblock %}
{% block content %}
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
</ol>
</nav>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
</ol>
</nav>
<h3><span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }} <small>{% if HOSTED %}
<a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small></h3>
<h3>
<span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }}
<small>{% if HOSTED %} <a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small>
</h3>
<br/>
<br />
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans 'Number of objects' %}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{% trans 'Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes }} /
{% if request.space.max_recipes > 0 %}
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
<li class="list-group-item">{% trans 'Keywords' %} : <span
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
<li class="list-group-item">{% trans 'Units' %} : <span
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
<li class="list-group-item">{% trans 'Ingredients' %} : <span
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
</ul>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans 'Objects stats' %}
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
<li class="list-group-item">{% trans 'External Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
<li class="list-group-item">{% trans 'Comments' %} : <span
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
</ul>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">{% trans 'Number of objects' %}</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
{% trans 'Recipes' %} :
<span class="badge badge-pill badge-info"
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
else %}∞{% endif %}</span
>
</li>
<li class="list-group-item">
{% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
</li>
<li class="list-group-item">
{% trans 'Units' %} : <span class="badge badge-pill badge-info">{{ counts.units }}</span>
</li>
<li class="list-group-item">
{% trans 'Ingredients' %} :
<span class="badge badge-pill badge-info">{{ counts.ingredients }}</span>
</li>
<li class="list-group-item">
{% trans 'Recipe Imports' %} :
<span class="badge badge-pill badge-info">{{ counts.recipe_import }}</span>
</li>
</ul>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col col-md-12">
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
{% if request.space.max_users > 0 %}
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a>
</h4>
<div class="col-md-6">
<div class="card">
<div class="card-header">{% trans 'Objects stats' %}</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
{% trans 'Recipes without Keywords' %} :
<span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
</li>
<li class="list-group-item">
{% trans 'External Recipes' %} :
<span class="badge badge-pill badge-info">{{ counts.recipes_external }}</span>
</li>
<li class="list-group-item">
{% trans 'Internal Recipes' %} :
<span class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span>
</li>
<li class="list-group-item">
{% trans 'Comments' %} : <span class="badge badge-pill badge-info">{{ counts.comments }}</span>
</li>
</ul>
</div>
</div>
<br>
<div class="row">
<div class="col col-md-12">
{% if space_users %}
<table class="table table-bordered">
<tr>
<th>{% trans 'User' %}</th>
<th>{% trans 'Groups' %}</th>
<th>{% trans 'Edit' %}</th>
</tr>
{% for u in space_users %}
<tr>
<td>
{{ u.user.username }}
</td>
<td>
{{ u.user.groups.all |join:", " }}
</td>
<td>
{% if u.user != request.user %}
<div class="input-group mb-3">
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
style="height: 44px">
<option value="admin">{% trans 'admin' %}</option>
<option value="user">{% trans 'user' %}</option>
<option value="guest">{% trans 'guest' %}</option>
<option value="remove">{% trans 'remove' %}</option>
</select>
<span class="input-group-append">
<a class="btn btn-warning"
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
</span>
</div>
{% else %}
{% trans 'You cannot edit yourself.' %}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{% trans 'There are no members in your space yet!' %}</p>
{% endif %}
</div>
</div>
<br />
<br />
<form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
<div class="row">
<div class="col col-md-12">
<h4>
{% trans 'Members' %}
<small class="text-muted"
>{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
%}∞{% endif %}</small
>
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
>
</h4>
</div>
</div>
<br />
<div class="row">
<div class="col col-md-12">
<h4>{% trans 'Invite Links' %}</h4>
{% render_table invite_links %}
</div>
<div class="row">
<div class="col col-md-12">
{% if space_users %}
<table class="table table-bordered">
<tr>
<th>{% trans 'User' %}</th>
<th>{% trans 'Groups' %}</th>
<th>{% trans 'Edit' %}</th>
</tr>
{% for u in space_users %}
<tr>
<td>{{ u.user.username }}</td>
<td>{{ u.user.groups.all |join:", " }}</td>
<td>
{% if u.user != request.user %}
<div class="input-group mb-3">
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
<option value="admin">{% trans 'admin' %}</option>
<option value="user">{% trans 'user' %}</option>
<option value="guest">{% trans 'guest' %}</option>
<option value="remove">{% trans 'remove' %}</option>
</select>
<span class="input-group-append">
<a class="btn btn-warning" :href="editUserUrl({{ u.pk }}, {{ u.space.pk }})"
>{% trans 'Update' %}</a
>
</span>
</div>
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>{% trans 'There are no members in your space yet!' %}</p>
{% endif %}
</div>
</div>
<br/>
<br/>
<br/>
<div class="row">
<div class="col col-md-12">
<h4>{% trans 'Invite Links' %}</h4>
{% render_table invite_links %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<h4>{% trans 'Space Settings' %}</h4>
<form action="." method="post">
{% csrf_token %}
{{ space_form|crispy }}
<button class="btn btn-success" type="submit" name="space_form"><i
class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
</div>
</div>
<br />
<br />
<br />
{% endblock %} {% block script %}
<script type="application/javascript">
let app = new Vue({
delimiters: ['[[', ']]'],
el: '#id_base_container',
data: {
users: {
{% for u in space_users %}
'{{ u.pk }}': 'none',
{% endfor %}
}
},
mounted: function () {
},
methods: {
editUserUrl: function (user_id, space_id) {
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
}
}
});
</script>
{% endblock %}
{% block script %}
<script type="application/javascript">
let app = new Vue({
delimiters: ['[[', ']]'],
el: '#id_base_container',
data: {
users: {
{% for u in space_users %}
'{{ u.pk }}': 'none',
{% endfor %}
}
},
mounted: function () {
},
methods: {
editUserUrl: function (user_id, space_id) {
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
}
}
});
</script>
{% endblock %}

View File

@@ -715,7 +715,6 @@
},
methods: {
makeToast: function (title, message, variant = null) {
//TODO remove duplicate function in favor of central one
this.$bvToast.toast(message, {
title: title,
variant: variant,

View File

@@ -1,12 +1,14 @@
import json
import pytest
from django.contrib import auth
from django_scopes import scopes_disabled
from django.urls import reverse
from django_scopes import scope, scopes_disabled
from pytest_factoryboy import LazyFixture, register
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
from cookbook.models import Food, FoodInheritField, Ingredient, ShoppingList, ShoppingListEntry
from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory,
SupermarketCategoryFactory)
# ------------------ IMPORTANT -------------------
#
@@ -27,78 +29,50 @@ else:
node_location = 'last-child'
@pytest.fixture()
def obj_1(space_1):
return Food.objects.get_or_create(name='test_1', space=space_1)[0]
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
register(SupermarketCategoryFactory, 'cat_1', space=LazyFixture('space_1'))
@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 true():
# return True
@pytest.fixture
def obj_2(space_1):
return Food.objects.get_or_create(name='test_2', space=space_1)[0]
def false():
return False
@pytest.fixture
def non_exist():
return {}
@pytest.fixture()
def obj_3(space_2):
return Food.objects.get_or_create(name='test_3', space=space_2)[0]
def obj_tree_1(request, space_1):
try:
params = request.param # request.param is a magic variable
except AttributeError:
params = {}
objs = []
inherit = params.pop('inherit', False)
objs.extend(FoodFactory.create_batch(3, space=space_1, **params))
# set all foods to inherit everything
if inherit:
inherit = Food.inheritable_fields
Through = Food.objects.filter(space=space_1).first().inherit_fields.through
for i in inherit:
Through.objects.bulk_create([
Through(food_id=x, foodinheritfield_id=i.id)
for x in Food.objects.filter(space=space_1).values_list('id', flat=True)
])
@pytest.fixture()
def ing_1_s1(obj_1, space_1):
return Ingredient.objects.create(food=obj_1, space=space_1)
@pytest.fixture()
def ing_2_s1(obj_2, space_1):
return Ingredient.objects.create(food=obj_2, space=space_1)
@pytest.fixture()
def ing_3_s2(obj_3, space_2):
return Ingredient.objects.create(food=obj_3, space=space_2)
@pytest.fixture()
def ing_1_1_s1(obj_1_1, space_1):
return Ingredient.objects.create(food=obj_1_1, space=space_1)
@pytest.fixture()
def sle_1_s1(obj_1, u1_s1, space_1):
e = ShoppingListEntry.objects.create(food=obj_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@pytest.fixture()
def sle_2_s1(obj_2, u1_s1, space_1):
return ShoppingListEntry.objects.create(food=obj_2)
@pytest.fixture()
def sle_3_s2(obj_3, u1_s2, space_2):
e = ShoppingListEntry.objects.create(food=obj_3)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
s.entries.add(e)
return e
@pytest.fixture()
def sle_1_1_s1(obj_1_1, u1_s1, space_1):
e = ShoppingListEntry.objects.create(food=obj_1_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
objs[0].move(objs[1], node_location)
objs[1].move(objs[2], node_location)
return Food.objects.get(id=objs[1].id) # whenever you move/merge a tree it's safest to re-get the object
@pytest.mark.parametrize("arg", [
@@ -128,7 +102,10 @@ def test_list_filter(obj_1, obj_2, u1_s1):
assert r.status_code == 200
response = json.loads(r.content)
assert response['count'] == 2
assert response['results'][0]['name'] == obj_1.name
assert obj_1.name in [x['name'] for x in response['results']]
assert obj_2.name in [x['name'] for x in response['results']]
assert response['results'][0]['name'] < response['results'][1]['name']
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content)
assert len(response['results']) == 1
@@ -142,7 +119,7 @@ def test_list_filter(obj_1, obj_2, u1_s1):
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content)
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 response['count'] == 1
@@ -194,7 +171,6 @@ def test_add(arg, request, u1_s2):
assert r.status_code == 404
@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
@@ -220,9 +196,9 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3):
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
def test_delete(u1_s1, u1_s2, obj_1, obj_tree_1):
with scopes_disabled():
assert Food.objects.count() == 3
assert Food.objects.count() == 4
r = u1_s2.delete(
reverse(
@@ -232,18 +208,19 @@ def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
)
assert r.status_code == 404
with scopes_disabled():
assert Food.objects.count() == 3
assert Food.objects.count() == 4
# should delete self and child, leaving parent
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={obj_1_1.id}
args={obj_tree_1.id}
)
)
assert r.status_code == 204
with scopes_disabled():
assert Food.objects.count() == 1
assert Food.objects.count() == 2
assert Food.find_problems() == ([], [], [], [], [])
@@ -283,13 +260,16 @@ def test_integrity(u1_s1, recipe_1_s1):
assert Ingredient.objects.count() == 9
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
def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1):
with scope(space=space_1):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 2
assert Food.get_root_nodes().filter(space=space_1).count() == 2
url = reverse(MOVE_URL, args=[obj_tree_1.id, obj_2.id])
# move child to new parent, only HTTP put method should work
r = u1_s1.get(url)
assert r.status_code == 405
@@ -301,61 +281,107 @@ def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
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)
parent = Food.objects.get(pk=parent.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 parent.get_num_children() == 0
assert parent.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 == 404
# 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 == 404
# run diagnostic to find problems - none should be found
with scopes_disabled():
assert Food.find_problems() == ([], [], [], [], [])
def test_merge(
u1_s1,
obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3,
ing_1_s1, ing_2_s1, ing_3_s2, ing_1_1_s1,
sle_1_s1, sle_2_s1, sle_3_s2, sle_1_1_s1,
space_1
):
def test_move_errors(u1_s1, obj_tree_1, obj_3, space_1):
with scope(space=space_1):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
# move child to root
r = u1_s1.put(reverse(MOVE_URL, args=[obj_tree_1.id, 0]))
assert r.status_code == 200
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
assert obj_1.shoppinglistentry_set.count() == 1
assert obj_2.shoppinglistentry_set.count() == 1
assert obj_3.shoppinglistentry_set.count() == 1
assert obj_1_1.shoppinglistentry_set.count() == 1
assert obj_1_1_1.shoppinglistentry_set.count() == 0
# merge food with no children and no ingredient/shopping list entry with another food, only HTTP put method should work
url = reverse(MERGE_URL, args=[obj_1_1_1.id, obj_2.id])
# attempt to move to non-existent parent
r = u1_s1.put(
reverse(MOVE_URL, args=[parent.id, 9999])
)
assert r.status_code == 404
# attempt to move non-existent mode to parent
r = u1_s1.put(
reverse(MOVE_URL, args=[9999, parent.id])
)
assert r.status_code == 404
# attempt to move to wrong space
r = u1_s1.put(
reverse(MOVE_URL, args=[obj_tree_1.id, obj_3.id])
)
assert r.status_code == 404
# TODO: figure out how to generalize this to be all related objects
def test_merge_ingredients(obj_tree_1, u1_s1, space_1):
with scope(space=space_1):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
IngredientFactory.create(food=parent, space=space_1)
IngredientFactory.create(food=child, space=space_1)
assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 2
assert Ingredient.objects.count() == 2
assert parent.ingredient_set.count() == 1
assert obj_tree_1.ingredient_set.count() == 0
assert child.ingredient_set.count() == 1
# merge food (with connected ingredient) with children to another food
r = u1_s1.put(reverse(MERGE_URL, args=[child.id, obj_tree_1.id]))
assert r.status_code == 200
with scope(space=space_1):
# django-treebeard bypasses django ORM so object needs retrieved again
with pytest.raises(Food.DoesNotExist):
Food.objects.get(pk=child.id)
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
assert obj_tree_1.ingredient_set.count() == 1 # now has child's ingredient
def test_merge_shopping_entries(obj_tree_1, u1_s1, space_1):
with scope(space=space_1):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
ShoppingListEntryFactory.create(food=parent, space=space_1)
ShoppingListEntryFactory.create(food=child, space=space_1)
assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 2
assert ShoppingListEntry.objects.count() == 2
assert parent.shopping_entries.count() == 1
assert obj_tree_1.shopping_entries.count() == 0
assert child.shopping_entries.count() == 1
# merge food (with connected shoppinglistentry) with children to another food
r = u1_s1.put(reverse(MERGE_URL, args=[child.id, obj_tree_1.id]))
assert r.status_code == 200
with scope(space=space_1):
# django-treebeard bypasses django ORM so object needs retrieved again
with pytest.raises(Food.DoesNotExist):
Food.objects.get(pk=child.id)
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
assert obj_tree_1.shopping_entries.count() == 1 # now has child's ingredient
def test_merge(u1_s1, obj_tree_1, obj_1, obj_3, space_1):
with scope(space=space_1):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 2
assert Food.get_root_nodes().filter(space=space_1).count() == 2
assert Food.objects.count() == 4
# merge food with no children with another food, only HTTP put method should work
url = reverse(MERGE_URL, args=[child.id, obj_tree_1.id])
r = u1_s1.get(url)
assert r.status_code == 405
r = u1_s1.post(url)
@@ -364,88 +390,142 @@ def test_merge(
assert r.status_code == 405
r = u1_s1.put(url)
assert r.status_code == 200
with scopes_disabled():
with scope(space=space_1):
# django-treebeard bypasses django ORM so object needs retrieved again
with pytest.raises(Food.DoesNotExist):
Food.objects.get(pk=child.id)
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
assert parent.get_num_children() == 1
assert parent.get_descendant_count() == 1
# merge food with children with another food
r = u1_s1.put(reverse(MERGE_URL, args=[parent.id, obj_1.id]))
assert r.status_code == 200
with scope(space=space_1):
# django-treebeard bypasses django ORM so object needs retrieved again
with pytest.raises(Food.DoesNotExist):
Food.objects.get(pk=parent.id)
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
assert obj_1.shoppinglistentry_set.count() == 1
assert obj_2.shoppinglistentry_set.count() == 1
assert obj_3.shoppinglistentry_set.count() == 1
assert obj_1_1.shoppinglistentry_set.count() == 1
# merge food (with connected ingredient/shoppinglistentry) 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
assert obj_2.shoppinglistentry_set.count() == 2
assert obj_3.shoppinglistentry_set.count() == 1
assert obj_1_1.shoppinglistentry_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 == 404
# attempt to move to wrong space
r = u1_s1.put(
reverse(MERGE_URL, args=[obj_2.id, obj_3.id])
)
assert r.status_code == 404
# 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):
def test_merge_errors(u1_s1, obj_tree_1, obj_3, space_1):
with scope(space=space_1):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
# attempt to merge with non-existent parent
r = u1_s1.put(
reverse(MERGE_URL, args=[obj_tree_1.id, 9999])
)
assert r.status_code == 404
# attempt to merge non-existent node to parent
r = u1_s1.put(
reverse(MERGE_URL, args=[9999, obj_tree_1.id])
)
assert r.status_code == 404
# attempt to move to wrong space
r = u1_s1.put(
reverse(MERGE_URL, args=[obj_tree_1.id, obj_3.id])
)
assert r.status_code == 404
# attempt to merge with child
r = u1_s1.put(
reverse(MERGE_URL, args=[parent.id, obj_tree_1.id])
)
assert r.status_code == 403
# attempt to merge with self
r = u1_s1.put(
reverse(MERGE_URL, args=[obj_tree_1.id, obj_tree_1.id])
)
assert r.status_code == 403
def test_root_filter(obj_tree_1, obj_2, obj_3, u1_s1):
with scope(space=obj_tree_1.space):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
# 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, node_location)
# 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)
obj_2.move(parent, node_location)
# should return direct children of parent (obj_tree_1, obj_2), ignoring query filters
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.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)
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.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, node_location)
# 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)
def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1):
with scope(space=obj_tree_1.space):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
obj_2.move(parent, node_location)
# should return full tree starting at parent (obj_tree_1, obj_2), ignoring query filters
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.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)
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}&query={obj_2.name[4:]}').content)
assert response['count'] == 4
# This is more about the model than the API - should this be moved to a different test?
@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'),
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'),
({'food_onhand': True, 'inherit': True}, 'food_onhand', True, 'false'),
({'food_onhand': True, 'inherit': False}, 'food_onhand', False, 'false'),
], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter
def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
with scope(space=obj_tree_1.space):
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
new_val = request.getfixturevalue(new_val)
# if this test passes it demonstrates that inheritance works
# when moving to a parent as each food is created with a different category
assert (getattr(parent, field) == getattr(obj_tree_1, field)) in [inherit, True]
assert (getattr(obj_tree_1, field) == getattr(child, field)) in [inherit, True]
# change parent to a new value
setattr(parent, field, new_val)
with scope(space=parent.space):
parent.save() # trigger post-save signal
# get the objects again because values are cached
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
child = Food.objects.get(id=child.id)
# when changing parent value the obj value should be same if inherited
assert (getattr(obj_tree_1, field) == new_val) == inherit
assert (getattr(child, field) == new_val) == inherit
@pytest.mark.parametrize("obj_tree_1", [
({'has_category': True, 'inherit': False, 'food_onhand': True}),
], indirect=['obj_tree_1'])
def test_reset_inherit(obj_tree_1, space_1):
with scope(space=space_1):
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
obj_tree_1.food_onhand = False
assert parent.food_onhand == child.food_onhand
assert parent.food_onhand != obj_tree_1.food_onhand
assert parent.supermarket_category != child.supermarket_category
assert parent.supermarket_category != obj_tree_1.supermarket_category
parent.reset_inheritance(space=space_1)
# djangotree bypasses ORM and need to be retrieved again
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
parent = obj_tree_1.get_parent()
child = obj_tree_1.get_descendants()[0]
assert parent.food_onhand == obj_tree_1.food_onhand == child.food_onhand
assert parent.supermarket_category == obj_tree_1.supermarket_category == child.supermarket_category

View File

@@ -0,0 +1,96 @@
# test create
# test create units
# test amounts
# test create wrong space
# test sharing
# test delete
# test delete checked (nothing should happen)
# test delete not shared (nothing happens)
# test delete shared
import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scope, scopes_disabled
from cookbook.models import Food, ShoppingListEntry
from cookbook.tests.factories import FoodFactory
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
SHOPPING_FOOD_URL = 'api:food-shopping'
@pytest.fixture()
def food(request, space_1, u1_s1):
return FoodFactory(space=space_1)
def test_shopping_forbidden_methods(food, u1_s1):
r = u1_s1.post(
reverse(SHOPPING_FOOD_URL, args={food.id}))
assert r.status_code == 405
r = u1_s1.delete(
reverse(SHOPPING_FOOD_URL, args={food.id}))
assert r.status_code == 405
r = u1_s1.get(
reverse(SHOPPING_FOOD_URL, args={food.id}))
assert r.status_code == 405
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 204],
['u1_s2', 404],
['a1_s1', 204],
])
def test_shopping_food_create(request, arg, food):
c = request.getfixturevalue(arg[0])
r = c.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
assert r.status_code == arg[1]
if r.status_code == 204:
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 1
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 204],
['u1_s2', 404],
['a1_s1', 204],
])
def test_shopping_food_delete(request, arg, food):
c = request.getfixturevalue(arg[0])
r = c.put(
reverse(SHOPPING_FOOD_URL, args={food.id}),
{'_delete': "true"},
content_type='application/json'
)
assert r.status_code == arg[1]
if r.status_code == 204:
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
def test_shopping_food_share(u1_s1, u2_s1, food, space_1):
with scope(space=space_1):
user1 = auth.get_user(u1_s1)
user2 = auth.get_user(u2_s1)
food2 = FoodFactory(space=space_1)
r = u1_s1.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
r = u2_s1.put(reverse(SHOPPING_FOOD_URL, args={food2.id}))
sl_1 = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
sl_2 = json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert len(sl_1) == 1
assert len(sl_2) == 1
sl_1[0]['created_by']['id'] == user1.id
sl_2[0]['created_by']['id'] == user2.id
with scopes_disabled():
user1.userpreference.shopping_share.add(user2)
user1.userpreference.save()
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 1
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 2

View File

@@ -4,13 +4,16 @@ from datetime import datetime, timedelta
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from django_scopes import scope, scopes_disabled
from cookbook.models import Food, MealPlan, MealType
from cookbook.tests.factories import RecipeFactory
LIST_URL = 'api:mealplan-list'
DETAIL_URL = 'api:mealplan-detail'
# NOTE: auto adding shopping list from meal plan is tested in test_shopping_recipe as tests are identical
@pytest.fixture()
def meal_type(space_1, u1_s1):
@@ -106,7 +109,7 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
r = c.post(
reverse(LIST_URL),
{'recipe': {'id': recipe_1_s1.id, 'name': recipe_1_s1.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test','shared':[]},
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': []},
content_type='application/json'
)
response = json.loads(r.content)
@@ -139,3 +142,17 @@ def test_delete(u1_s1, u1_s2, obj_1):
assert r.status_code == 204
with scopes_disabled():
assert MealPlan.objects.count() == 0
def test_add_with_shopping(u1_s1, meal_type):
space = meal_type.space
with scope(space=space):
recipe = RecipeFactory.create(space=space)
r = u1_s1.post(
reverse(LIST_URL),
{'recipe': {'id': recipe.id, 'name': recipe.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': [], 'addshopping': True},
content_type='application/json'
)
assert len(json.loads(u1_s1.get(reverse('api:shoppinglistentry-list')).content)) == 10

View File

@@ -1,6 +1,6 @@
import json
import pytest
import pytest
from django.urls import reverse
from django_scopes import scopes_disabled

View File

@@ -0,0 +1,74 @@
import json
import factory
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from pytest_factoryboy import LazyFixture, register
from cookbook.tests.factories import RecipeFactory
RELATED_URL = 'api:recipe-related'
@pytest.fixture()
def recipe(request, space_1, u1_s1):
try:
params = request.param # request.param is a magic variable
except AttributeError:
params = {}
step_recipe = params.get('steps__count', 1)
steps__recipe_count = params.get('steps__recipe_count', 0)
steps__food_recipe_count = params.get('steps__food_recipe_count', {})
created_by = params.get('created_by', auth.get_user(u1_s1))
return RecipeFactory.create(
steps__recipe_count=steps__recipe_count,
steps__food_recipe_count=steps__food_recipe_count,
created_by=created_by,
space=space_1,
)
@pytest.mark.parametrize("arg", [
['g1_s1', 200],
['u1_s1', 200],
['u1_s2', 404],
['a1_s1', 200],
])
@pytest.mark.parametrize("recipe, related_count", [
({}, 0),
({'steps__recipe_count': 1}, 1), # shopping list from recipe with StepRecipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 1), # shopping list from recipe with food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 2), # shopping list from recipe with StepRecipe and food recipe
], indirect=['recipe'])
def test_get_related_recipes(request, arg, recipe, related_count, u1_s1, space_2):
c = request.getfixturevalue(arg[0])
r = c.get(reverse(RELATED_URL, args={recipe.id}))
assert r.status_code == arg[1]
if r.status_code == 200:
assert len(json.loads(r.content)) == related_count
@pytest.mark.parametrize("recipe", [
({'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}), # shopping list from recipe with food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe and food recipe
], indirect=['recipe'])
def test_related_mixed_space(request, recipe, u1_s2):
with scopes_disabled():
recipe.space = auth.get_user(u1_s2).userpreference.space
recipe.save()
assert len(json.loads(
u1_s2.get(
reverse(RELATED_URL, args={recipe.id})).content)) == 0
# TODO add tests for mealplan related when thats added
# TODO if/when related recipes includes multiple levels (related recipes of related recipes) add the following tests
# -- step recipes included in step recipes
# -- step recipes included in food recipes
# -- food recipes included in step recipes
# -- food recipes included in food recipes
# -- -- included recipes in the wrong space

View File

@@ -5,7 +5,7 @@ from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import RecipeBook, Storage, Sync, SyncLog, ShoppingList
from cookbook.models import RecipeBook, ShoppingList, Storage, Sync, SyncLog
LIST_URL = 'api:shoppinglist-list'
DETAIL_URL = 'api:shoppinglist-detail'
@@ -56,6 +56,21 @@ def test_share(obj_1, u1_s1, u2_s1, u1_s2):
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
def test_new_share(request, obj_1, u1_s1, u2_s1, u1_s2):
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
with scopes_disabled():
user = auth.get_user(u1_s1)
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
user.userpreference.save()
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 404],

View File

@@ -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 ShoppingList, ShoppingListEntry, Food
from cookbook.models import Food, ShoppingList, ShoppingListEntry
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.get_or_create(name='test 1', space=space_1)[0])
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@@ -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.get_or_create(name='test 2', space=space_1)[0])
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
with scopes_disabled():
s = ShoppingList.objects.first()
e = ShoppingListEntry.objects.first()
s.space = space_2
e.space = space_2
s.save()
e.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0

View File

@@ -0,0 +1,219 @@
import json
from datetime import timedelta
import factory
import pytest
from django.contrib import auth
from django.forms import model_to_dict
from django.urls import reverse
from django.utils import timezone
from django_scopes import scopes_disabled
from pytest_factoryboy import LazyFixture, register
from cookbook.models import ShoppingListEntry
from cookbook.tests.factories import ShoppingListEntryFactory
LIST_URL = 'api:shoppinglistentry-list'
DETAIL_URL = 'api:shoppinglistentry-detail'
@pytest.fixture
def sle(space_1, u1_s1):
user = auth.get_user(u1_s1)
return ShoppingListEntryFactory.create_batch(10, space=space_1, created_by=user)
@pytest.fixture
def sle_2(request):
try:
params = request.param # request.param is a magic variable
except AttributeError:
params = {}
u = request.getfixturevalue(params.get('user', 'u1_s1'))
user = auth.get_user(u)
count = params.get('count', 10)
return ShoppingListEntryFactory.create_batch(count, space=user.userpreference.space, created_by=user)
@ pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 200],
['u1_s1', 200],
['a1_s1', 200],
])
def test_list_permission(arg, request):
c = request.getfixturevalue(arg[0])
assert c.get(reverse(LIST_URL)).status_code == arg[1]
def test_list_space(sle, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
with scopes_disabled():
e = ShoppingListEntry.objects.first()
e.space = space_2
e.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 9
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
def test_get_detail(u1_s1, sle):
r = u1_s1.get(reverse(
DETAIL_URL,
args={sle[0].id}
))
assert json.loads(r.content)['id'] == sle[0].id
@ pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 404],
['u1_s1', 200],
['a1_s1', 404],
['g1_s2', 404],
['u1_s2', 404],
['a1_s2', 404],
])
def test_update(arg, request, sle):
c = request.getfixturevalue(arg[0])
new_val = float(sle[0].amount + 1)
r = c.patch(
reverse(
DETAIL_URL,
args={sle[0].id}
),
{'amount': new_val},
content_type='application/json'
)
assert r.status_code == arg[1]
if r.status_code == 200:
response = json.loads(r.content)
assert response['amount'] == new_val
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 201],
['u1_s1', 201],
['a1_s1', 201],
])
def test_add(arg, request, sle):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'food': model_to_dict(sle[0].food), 'amount': 1},
content_type='application/json'
)
response = json.loads(r.content)
print(r.content)
assert r.status_code == arg[1]
if r.status_code == 201:
assert response['food']['id'] == sle[0].food.pk
def test_delete(u1_s1, u1_s2, sle):
r = u1_s2.delete(
reverse(
DETAIL_URL,
args={sle[0].id}
)
)
assert r.status_code == 404
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={sle[0].id}
)
)
assert r.status_code == 204
@pytest.mark.parametrize("shared, count, sle_2", [
('g1_s1', 20, {'user': 'g1_s1'}),
('g1_s2', 10, {'user': 'g1_s2'}),
('u2_s1', 20, {'user': 'u2_s1'}),
('u1_s2', 10, {'user': 'u1_s2'}),
('a1_s1', 20, {'user': 'a1_s1'}),
('a1_s2', 10, {'user': 'a1_s2'}),
], indirect=['sle_2'])
def test_sharing(request, shared, count, sle_2, sle, u1_s1):
user = auth.get_user(u1_s1)
shared_client = request.getfixturevalue(shared)
shared_user = auth.get_user(shared_client)
# confirm shared user can't access shopping list items created by u1_s1
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
assert len(json.loads(shared_client.get(reverse(LIST_URL)).content)) == 10
user.userpreference.shopping_share.add(shared_user)
# confirm sharing user only sees their shopping list
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
r = shared_client.get(reverse(LIST_URL))
# confirm shared user sees their list and the list that's shared with them
assert len(json.loads(r.content)) == count
def test_completed(sle, u1_s1):
# check 1 entry
#
u1_s1.patch(
reverse(DETAIL_URL, args={sle[0].id}),
{'checked': True},
content_type='application/json'
)
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
assert len(r) == 10
# count unchecked entries
assert [x['checked'] for x in r].count(False) == 9
# confirm completed_at is populated
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 1
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=0').content)) == 9
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=1').content)) == 1
# uncheck entry
u1_s1.patch(
reverse(DETAIL_URL, args={sle[0].id}),
{'checked': False},
content_type='application/json'
)
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
assert [x['checked'] for x in r].count(False) == 10
# confirm completed_at value cleared
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 0
def test_recent(sle, u1_s1):
user = auth.get_user(u1_s1)
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# past_date within recent_days threshold
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days - 1)
sle[0].checked = True
sle[0].completed_at = past_date
sle[0].save()
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
assert len(r) == 10
assert [x['checked'] for x in r].count(False) == 9
# past_date outside recent_days threshold
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days + 2)
sle[0].completed_at = past_date
sle[0].save()
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
assert len(r) == 9
assert [x['checked'] for x in r].count(False) == 9
# user preference moved to include entry again
user.userpreference.shopping_recent_days = user.userpreference.shopping_recent_days + 4
user.userpreference.save()
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
assert len(r) == 10
assert [x['checked'] for x in r].count(False) == 9

View File

@@ -0,0 +1,243 @@
import json
from datetime import timedelta
import factory
import pytest
# work around for bug described here https://stackoverflow.com/a/70312265/15762829
from django.conf import settings
from django.contrib import auth
from django.forms import model_to_dict
from django.urls import reverse
from django.utils import timezone
from django_scopes import scope, scopes_disabled
from pytest_factoryboy import LazyFixture, register
from cookbook.models import Food, Ingredient, ShoppingListEntry, Step
from cookbook.tests.factories import (IngredientFactory, MealPlanFactory, RecipeFactory,
StepFactory, UserFactory)
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
from django.db.backends.postgresql.features import DatabaseFeatures
DatabaseFeatures.can_defer_constraint_checks = False
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
SHOPPING_RECIPE_URL = 'api:recipe-shopping'
@pytest.fixture()
def user2(request, u1_s1):
try:
params = request.param # request.param is a magic variable
except AttributeError:
params = {}
user = auth.get_user(u1_s1)
user.userpreference.mealplan_autoadd_shopping = params.get('mealplan_autoadd_shopping', True)
user.userpreference.mealplan_autoinclude_related = params.get('mealplan_autoinclude_related', True)
user.userpreference.mealplan_autoexclude_onhand = params.get('mealplan_autoexclude_onhand', True)
user.userpreference.save()
return u1_s1
@pytest.fixture()
def recipe(request, space_1, u1_s1):
try:
params = request.param # request.param is a magic variable
except AttributeError:
params = {}
# step_recipe = params.get('steps__count', 1)
# steps__recipe_count = params.get('steps__recipe_count', 0)
# steps__food_recipe_count = params.get('steps__food_recipe_count', {})
params['created_by'] = params.get('created_by', auth.get_user(u1_s1))
params['space'] = space_1
return RecipeFactory(**params)
# return RecipeFactory.create(
# steps__recipe_count=steps__recipe_count,
# steps__food_recipe_count=steps__food_recipe_count,
# created_by=created_by,
# space=space_1,
# )
@pytest.mark.parametrize("arg", [
['g1_s1', 204],
['u1_s1', 204],
['u1_s2', 404],
['a1_s1', 204],
])
@pytest.mark.parametrize("recipe, sle_count", [
({}, 10),
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
], indirect=['recipe'])
def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
c = request.getfixturevalue(arg[0])
user = auth.get_user(c)
user.userpreference.mealplan_autoadd_shopping = True
user.userpreference.save()
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
url = reverse(SHOPPING_RECIPE_URL, args={recipe.id})
r = c.put(url)
assert r.status_code == arg[1]
# only PUT method should work
if r.status_code == 204: # skip anonymous user
r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)
assert len(r) == sle_count # recipe factory creates 10 ingredients by default
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
# user in space can't see shopping list
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
# after share, user in space can see shopping list
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# confirm that the author of the recipe doesn't have access to shopping list
if c != u1_s1:
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
r = c.get(url)
assert r.status_code == 405
r = c.post(url)
assert r.status_code == 405
r = c.delete(url)
assert r.status_code == 405
@pytest.mark.parametrize("recipe, sle_count", [
({}, 10),
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
], indirect=['recipe'])
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u2_s1):
# tests editing shopping list via recipe or mealplan
with scopes_disabled():
user = auth.get_user(u1_s1)
user2 = auth.get_user(u2_s1)
user.userpreference.mealplan_autoinclude_related = True
user.userpreference.mealplan_autoadd_shopping = True
user.userpreference.shopping_share.add(user2)
user.userpreference.save()
if use_mealplan:
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
else:
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
all_ing = [x['ingredient'] for x in r]
keep_ing = all_ing[1:-1] # remove first and last element
del keep_ing[int(len(keep_ing)/2)] # remove a middle element
list_recipe = r[0]['list_recipe']
amount_sum = sum([x['amount'] for x in r])
# test modifying shopping list as different user
# test increasing servings size of recipe shopping list
if use_mealplan:
mealplan.servings = 2*recipe.servings
mealplan.save()
else:
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'servings': 2*recipe.servings},
content_type='application/json'
)
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert sum([x['amount'] for x in r]) == amount_sum * 2
assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# testing decreasing servings size of recipe shopping list
if use_mealplan:
mealplan.servings = .5 * recipe.servings
mealplan.save()
else:
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'servings': .5 * recipe.servings},
content_type='application/json'
)
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert sum([x['amount'] for x in r]) == amount_sum * .5
assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
# test removing 2 items from shopping list
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'ingredients': keep_ing},
content_type='application/json'
)
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert len(r) == sle_count - 3
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - 3
# add all ingredients to existing shopping list - don't change serving size
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
{'list_recipe': list_recipe, 'ingredients': all_ing},
content_type='application/json'
)
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert sum([x['amount'] for x in r]) == amount_sum * .5
assert len(r) == sle_count
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
@pytest.mark.parametrize("user2, sle_count", [
({'mealplan_autoadd_shopping': False}, (0, 18)),
({'mealplan_autoinclude_related': False}, (9, 9)),
({'mealplan_autoexclude_onhand': False}, (20, 20)),
({'mealplan_autoexclude_onhand': False, 'mealplan_autoinclude_related': False}, (10, 10)),
], indirect=['user2'])
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
@pytest.mark.parametrize("recipe", [({'steps__recipe_count': 1})], indirect=['recipe'])
def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
with scopes_disabled():
user = auth.get_user(user2)
# setup recipe with 10 ingredients, 1 step recipe with 10 ingredients, 2 food onhand(from recipe and step_recipe)
ingredients = Ingredient.objects.filter(step__recipe=recipe)
food = Food.objects.get(id=ingredients[2].food.id)
food.food_onhand = True
food.save()
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
food = Food.objects.get(id=food.id)
food.food_onhand = True
food.save()
if use_mealplan:
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[0]
else:
user2.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[1]
def test_shopping_recipe_mixed_authors(u1_s1, u2_s1):
with scopes_disabled():
user1 = auth.get_user(u1_s1)
user2 = auth.get_user(u2_s1)
space = user1.userpreference.space
user3 = UserFactory(space=space)
recipe1 = RecipeFactory(created_by=user1, space=space)
recipe2 = RecipeFactory(created_by=user2, space=space)
recipe3 = RecipeFactory(created_by=user3, space=space)
food = Food.objects.get(id=recipe1.steps.first().ingredients.first().food.id)
food.recipe = recipe2
food.save()
recipe1.steps.add(StepFactory(step_recipe=recipe3, ingredients__count=0, space=space))
recipe1.save()
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe1.id}))
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 29
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
# TODO test adding recipe with ingredients that are not food
@pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe'])
def test_shopping_with_header_ingredient(u1_s1, recipe):
# with scope(space=recipe.space):
# recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1))
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)) == 11

View File

@@ -23,8 +23,8 @@ def test_list_permission(arg, request):
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 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
with scopes_disabled():
recipe_1_s1.space = space_2
@@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 0
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
@pytest.mark.parametrize("arg", [
['a_u', 403],

View File

@@ -49,7 +49,7 @@ def ing_3_s2(obj_3, space_2, u2_s2):
@pytest.fixture()
def sle_1_s1(obj_1, u1_s1, space_1):
e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1))
e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1), 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)
return e
@@ -57,12 +57,12 @@ def sle_1_s1(obj_1, u1_s1, space_1):
@pytest.fixture()
def sle_2_s1(obj_2, u1_s1, space_1):
return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1))
return ShoppingListEntry.objects.create(unit=obj_2, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,)
@pytest.fixture()
def sle_3_s2(obj_3, u2_s2, space_2):
e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2))
e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2), created_by=auth.get_user(u2_s2), space=space_2)
s = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2)
s.entries.add(e)
return e

View File

@@ -1,11 +1,11 @@
from cookbook.models import UserPreference
import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from django_scopes import scope, scopes_disabled
from cookbook.models import Food, UserPreference
LIST_URL = 'api:userpreference-list'
DETAIL_URL = 'api:userpreference-detail'
@@ -109,3 +109,32 @@ def test_preference_delete(u1_s1, u2_s1):
)
)
assert r.status_code == 204
def test_default_inherit_fields(u1_s1, u1_s2, space_1, space_2):
food_inherit_fields = Food.inheritable_fields
assert len([x.field for x in food_inherit_fields]) > 0
# by default space food will not inherit any fields, so all of them will be ignored
assert space_1.food_inherit.all().count() == 0
r = u1_s1.get(
reverse(DETAIL_URL, args={auth.get_user(u1_s1).id}),
)
assert len([x['field'] for x in json.loads(r.content)['food_inherit_default']]) == 0
# inherit all possible fields
with scope(space=space_1):
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True))
assert space_1.food_inherit.all().count() == Food.inheritable_fields.count() > 0
# now by default, food is inheriting all of the possible fields
r = u1_s1.get(
reverse(DETAIL_URL, args={auth.get_user(u1_s1).id}),
)
assert len([x['field'] for x in json.loads(r.content)['food_inherit_default']]) == space_1.food_inherit.all().count()
# other spaces and users in those spaces not effected
r = u1_s2.get(
reverse(DETAIL_URL, args={auth.get_user(u1_s2).id}),
)
assert space_2.food_inherit.all().count() == 0 == len([x['field'] for x in json.loads(r.content)['food_inherit_default']])

View File

@@ -5,14 +5,22 @@ import uuid
import pytest
from django.contrib import auth
from django.contrib.auth.models import User, Group
from django.contrib.auth.models import Group, User
from django_scopes import scopes_disabled
from pytest_factoryboy import LazyFixture, register
from cookbook.models import Space, Recipe, Step, Ingredient, Food, Unit
from cookbook.models import Food, Ingredient, Recipe, Space, Step, Unit
from cookbook.tests.factories import FoodFactory, SpaceFactory, UserFactory
register(SpaceFactory, 'space_1')
register(SpaceFactory, 'space_2')
# register(FoodFactory, space=LazyFixture('space_2'))
# TODO refactor clients to be factories
# hack from https://github.com/raphaelm/django-scopes to disable scopes for all fixtures
# does not work on yield fixtures as only one yield can be used per fixture (i think)
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
if inspect.isgeneratorfunction(fixturedef.func):
@@ -27,23 +35,23 @@ def enable_db_access_for_all_tests(db):
pass
@pytest.fixture()
def space_1():
with scopes_disabled():
return Space.objects.get_or_create(name='space_1')[0]
# @pytest.fixture()
# def space_1():
# with scopes_disabled():
# return Space.objects.get_or_create(name='space_1')[0]
@pytest.fixture()
def space_2():
with scopes_disabled():
return Space.objects.get_or_create(name='space_2')[0]
# @pytest.fixture()
# def space_2():
# with scopes_disabled():
# return Space.objects.get_or_create(name='space_2')[0]
# ---------------------- OBJECT FIXTURES ---------------------
def get_random_recipe(space_1, u1_s1):
r = Recipe.objects.create(
name=uuid.uuid4(),
name=str(uuid.uuid4()),
waiting_time=20,
working_time=20,
servings=4,
@@ -52,8 +60,8 @@ def get_random_recipe(space_1, u1_s1):
internal=True,
)
s1 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, )
s2 = Step.objects.create(name=uuid.uuid4(), instruction=uuid.uuid4(), space=space_1, )
s1 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, )
s2 = Step.objects.create(name=str(uuid.uuid4()), instruction=str(uuid.uuid4()), space=space_1, )
r.steps.add(s1)
r.steps.add(s2)
@@ -63,8 +71,8 @@ def get_random_recipe(space_1, u1_s1):
Ingredient.objects.create(
amount=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(),
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ),
note=str(uuid.uuid4()),
space=space_1,
)
)
@@ -73,8 +81,8 @@ def get_random_recipe(space_1, u1_s1):
Ingredient.objects.create(
amount=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(),
unit=Unit.objects.create(name=str(uuid.uuid4()), space=space_1, ),
note=str(uuid.uuid4()),
space=space_1,
)
)
@@ -176,25 +184,17 @@ def create_user(client, space, **kwargs):
c = copy.deepcopy(client)
with scopes_disabled():
group = kwargs.pop('group', None)
username = kwargs.pop('username', uuid.uuid4())
user = UserFactory(space=space, groups=group)
user = User.objects.create(username=username, **kwargs)
if group:
user.groups.add(Group.objects.get(name=group))
user.userpreference.space = space
user.userpreference.save()
c.force_login(user)
return c
# anonymous user
@pytest.fixture()
def a_u(client):
return copy.deepcopy(client)
# users without any group
@pytest.fixture()
def ng1_s1(client, space_1):
return create_user(client, space_1)

View File

@@ -0,0 +1,372 @@
from decimal import Decimal
import factory
import pytest
from django.contrib import auth
from django.contrib.auth.models import Group, User
from django_scopes import scopes_disabled
from faker import Factory as FakerFactory
from pytest_factoryboy import register
from cookbook.models import Step
# this code will run immediately prior to creating the model object useful when you want a reverse relationship
# log = factory.RelatedFactory(
# UserLogFactory,
# factory_related_name='user',
# action=models.UserLog.ACTION_CREATE,
# )
faker = FakerFactory.create()
@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
pass
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
if inspect.isgeneratorfunction(fixturedef.func):
yield
else:
with scopes_disabled():
yield
@register
class SpaceFactory(factory.django.DjangoModelFactory):
"""Space factory."""
name = factory.LazyAttribute(lambda x: faker.word())
@classmethod
def _create(cls, model_class, **kwargs):
with scopes_disabled():
return super()._create(model_class, **kwargs)
class Meta:
model = 'cookbook.Space'
@register
class UserFactory(factory.django.DjangoModelFactory):
"""User factory."""
username = factory.LazyAttribute(lambda x: faker.simple_profile()['username'])
first_name = factory.LazyAttribute(lambda x: faker.first_name())
last_name = factory.LazyAttribute(lambda x: faker.last_name())
email = factory.LazyAttribute(lambda x: faker.email())
space = factory.SubFactory(SpaceFactory)
@factory.post_generation
def groups(self, create, extracted, **kwargs):
if not create:
return
if extracted:
self.groups.add(Group.objects.get(name=extracted))
@factory.post_generation
def userpreference(self, create, extracted, **kwargs):
if not create:
return
if extracted:
for prefs in extracted:
self.userpreference[prefs] = extracted[prefs]/0 # intentionally break so it can be debugged later
self.userpreference.space = self.space
self.userpreference.save()
class Meta:
model = User
@register
class SupermarketCategoryFactory(factory.django.DjangoModelFactory):
"""SupermarketCategory factory."""
name = factory.LazyAttribute(lambda x: faker.word())
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
space = factory.SubFactory(SpaceFactory)
class Meta:
model = 'cookbook.SupermarketCategory'
django_get_or_create = ('name', 'space',)
# @factory.django.mute_signals(post_save)
@register
class FoodFactory(factory.django.DjangoModelFactory):
"""Food factory."""
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
supermarket_category = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_category),
yes_declaration=factory.SubFactory(SupermarketCategoryFactory, space=factory.SelfAttribute('..space')),
no_declaration=None
)
recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_recipe),
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
no_declaration=None
)
space = factory.SubFactory(SpaceFactory)
class Params:
has_category = False
has_recipe = False
class Meta:
model = 'cookbook.Food'
django_get_or_create = ('name', 'space',)
@register
class UnitFactory(factory.django.DjangoModelFactory):
"""Unit factory."""
name = factory.LazyAttribute(lambda x: faker.word())
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
space = factory.SubFactory(SpaceFactory)
class Meta:
model = 'cookbook.Unit'
django_get_or_create = ('name', 'space',)
@register
class KeywordFactory(factory.django.DjangoModelFactory):
"""Keyword factory."""
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=2, variable_nb_words=False))
# icon = models.CharField(max_length=16, blank=True, null=True)
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
space = factory.SubFactory(SpaceFactory)
num = None # used on upstream factories to generate num keywords
class Params:
num = None
class Meta:
model = 'cookbook.Keyword'
django_get_or_create = ('name', 'space',)
exclude = ('num')
@register
class IngredientFactory(factory.django.DjangoModelFactory):
"""Ingredient factory."""
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space'))
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space'))
amount = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
note = factory.LazyAttribute(lambda x: faker.sentence(nb_words=8))
is_header = False
no_amount = False
space = factory.SubFactory(SpaceFactory)
class Meta:
model = 'cookbook.Ingredient'
@register
class MealTypeFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
order = 0
# icon =
color = factory.LazyAttribute(lambda x: faker.safe_hex_color())
default = False
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
space = factory.SubFactory(SpaceFactory)
class Meta:
model = 'cookbook.MealType'
@register
class MealPlanFactory(factory.django.DjangoModelFactory):
recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_recipe),
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
no_declaration=None
)
servings = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=1000)/100))
title = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
meal_type = factory.SubFactory(MealTypeFactory, space=factory.SelfAttribute('..space'))
note = factory.LazyAttribute(lambda x: faker.paragraph())
date = factory.LazyAttribute(lambda x: faker.future_date())
space = factory.SubFactory(SpaceFactory)
class Params:
has_recipe = True
class Meta:
model = 'cookbook.MealPlan'
@register
class ShoppingListRecipeFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_recipe),
yes_declaration=factory.SubFactory('cookbook.tests.factories.RecipeFactory', space=factory.SelfAttribute('..space')),
no_declaration=None
)
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=10))
mealplan = factory.SubFactory(MealPlanFactory, space=factory.SelfAttribute('..space'))
space = factory.SubFactory(SpaceFactory)
class Params:
has_recipe = False
class Meta:
model = 'cookbook.ShoppingListRecipe'
@register
class ShoppingListEntryFactory(factory.django.DjangoModelFactory):
"""ShoppingListEntry factory."""
list_recipe = factory.Maybe(
factory.LazyAttribute(lambda x: x.has_mealplan),
yes_declaration=factory.SubFactory(ShoppingListRecipeFactory, space=factory.SelfAttribute('..space')),
no_declaration=None
)
food = factory.SubFactory(FoodFactory, space=factory.SelfAttribute('..space'))
unit = factory.SubFactory(UnitFactory, space=factory.SelfAttribute('..space'))
# # ingredient = factory.SubFactory(IngredientFactory)
amount = factory.LazyAttribute(lambda x: Decimal(faker.random_int(min=1, max=100))/10)
order = factory.Sequence(int)
checked = False
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
created_at = factory.LazyAttribute(lambda x: faker.past_date())
completed_at = None
delay_until = None
space = factory.SubFactory(SpaceFactory)
class Params:
has_mealplan = False
class Meta:
model = 'cookbook.ShoppingListEntry'
@register
class StepFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=5))
instruction = factory.LazyAttribute(lambda x: ''.join(faker.paragraphs(nb=5)))
# TODO add optional recipe food, make dependent on recipe, make number of recipes a Params
ingredients__count = 10 # default number of ingredients to add
ingredients__header = 0
time = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=1000))
order = factory.Sequence(lambda x: x)
# file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
show_as_header = True
step_recipe__has_recipe = False
ingredients__food_recipe_count = 0
space = factory.SubFactory(SpaceFactory)
@factory.post_generation
def step_recipe(self, create, extracted, **kwargs):
if not create:
return
if kwargs.get('has_recipe', False):
self.step_recipe = RecipeFactory(space=self.space)
self.type = Step.RECIPE
elif extracted:
self.step_recipe = extracted
self.type = Step.RECIPE
@factory.post_generation
def ingredients(self, create, extracted, **kwargs):
if not create:
return
num_ing = kwargs.get('count', 0)
num_food_recipe = kwargs.get('food_recipe_count', 0)
if num_ing > 0:
for i in range(num_ing):
if num_food_recipe > 0:
has_recipe = True
num_food_recipe = num_food_recipe-1
else:
has_recipe = False
self.ingredients.add(IngredientFactory(space=self.space, food__has_recipe=has_recipe))
num_header = kwargs.get('header', 0)
#######################################################
#######################################################
if num_header > 0:
for i in range(num_header):
self.ingredients.add(IngredientFactory(food=None, unit=None, amount=0, is_header=True, space=self.space))
elif extracted:
for ing in extracted:
self.ingredients.add(ing)
class Meta:
model = 'cookbook.Step'
@register
class RecipeFactory(factory.django.DjangoModelFactory):
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=7))
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
servings = factory.LazyAttribute(lambda x: faker.random_int(min=1, max=20))
servings_text = factory.LazyAttribute(lambda x: faker.sentence(nb_words=1)) # TODO generate list of expected servings text that can be iterated through
keywords__count = 5 # default number of keywords to generate
steps__count = 1 # default number of steps to create
steps__recipe_count = 0 # default number of step recipes to create
steps__food_recipe_count = {} # by default, don't create food recipes, to override {'steps__food_recipe_count': {'step': 0, 'count': 1}}
working_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360))
waiting_time = factory.LazyAttribute(lambda x: faker.random_int(min=0, max=360))
internal = False
created_by = factory.SubFactory(UserFactory, space=factory.SelfAttribute('..space'))
created_at = factory.LazyAttribute(lambda x: faker.date_this_decade())
space = factory.SubFactory(SpaceFactory)
@factory.post_generation
def keywords(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return
num_kw = kwargs.get('count', 0)
if num_kw > 0:
for i in range(num_kw):
self.keywords.add(KeywordFactory(space=self.space))
elif extracted:
for kw in extracted:
self.keywords.add(kw)
@factory.post_generation
def steps(self, create, extracted, **kwargs):
if not create:
return
food_recipe_count = kwargs.get('food_recipe_count', {})
num_steps = kwargs.get('count', 0)
num_recipe_steps = kwargs.get('recipe_count', 0)
num_ing_headers = kwargs.get('ingredients__header', 0)
if num_steps > 0:
for i in range(num_steps):
ing_recipe_count = 0
if food_recipe_count.get('step', None) == i:
ing_recipe_count = food_recipe_count.get('count', 0)
self.steps.add(StepFactory(space=self.space, ingredients__food_recipe_count=ing_recipe_count, ingredients__header=num_ing_headers))
num_ing_headers+-1
if num_recipe_steps > 0:
for j in range(num_recipe_steps):
self.steps.add(StepFactory(space=self.space, step_recipe__has_recipe=True, ingredients__count=0))
if extracted and (num_steps + num_recipe_steps == 0):
for step in extracted:
self.steps.add(step)
# image = models.ImageField(upload_to='recipes/', blank=True, null=True) #TODO test recipe image api https://factoryboy.readthedocs.io/en/stable/orms.html#factory.django.ImageField
# storage = models.ForeignKey(
# Storage, on_delete=models.PROTECT, blank=True, null=True
# )
# file_uid = models.CharField(max_length=256, default="", blank=True)
# file_path = models.CharField(max_length=512, default="", blank=True)
# link = models.CharField(max_length=512, null=True, blank=True)
# cors_link = models.CharField(max_length=1024, null=True, blank=True)
# nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
# updated_at = models.DateTimeField(auto_now=True)
class Meta:
model = 'cookbook.Recipe'

View File

@@ -15,33 +15,35 @@ from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, R
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
router = routers.DefaultRouter()
router.register(r'user-name', api.UserNameViewSet, basename='username')
router.register(r'user-preference', api.UserPreferenceViewSet)
router.register(r'storage', api.StorageViewSet)
router.register(r'sync', api.SyncViewSet)
router.register(r'sync-log', api.SyncLogViewSet)
router.register(r'keyword', api.KeywordViewSet)
router.register(r'unit', api.UnitViewSet)
router.register(r'automation', api.AutomationViewSet)
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
router.register(r'cook-log', api.CookLogViewSet)
router.register(r'food', api.FoodViewSet)
router.register(r'step', api.StepViewSet)
router.register(r'recipe', api.RecipeViewSet)
router.register(r'food-inherit-field', api.FoodInheritFieldViewSet)
router.register(r'import-log', api.ImportLogViewSet)
router.register(r'ingredient', api.IngredientViewSet)
router.register(r'keyword', api.KeywordViewSet)
router.register(r'meal-plan', api.MealPlanViewSet)
router.register(r'meal-type', api.MealTypeViewSet)
router.register(r'recipe', api.RecipeViewSet)
router.register(r'recipe-book', api.RecipeBookViewSet)
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
router.register(r'shopping-list', api.ShoppingListViewSet)
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
router.register(r'view-log', api.ViewLogViewSet)
router.register(r'cook-log', api.CookLogViewSet)
router.register(r'recipe-book', api.RecipeBookViewSet)
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
router.register(r'step', api.StepViewSet)
router.register(r'storage', api.StorageViewSet)
router.register(r'supermarket', api.SupermarketViewSet)
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
router.register(r'import-log', api.ImportLogViewSet)
router.register(r'bookmarklet-import', api.BookmarkletImportViewSet)
router.register(r'sync', api.SyncViewSet)
router.register(r'sync-log', api.SyncLogViewSet)
router.register(r'unit', api.UnitViewSet)
router.register(r'user-file', api.UserFileViewSet)
router.register(r'automation', api.AutomationViewSet)
router.register(r'user-name', api.UserNameViewSet, basename='username')
router.register(r'user-preference', api.UserPreferenceViewSet)
router.register(r'view-log', api.ViewLogViewSet)
urlpatterns = [
path('', views.index, name='index'),

View File

@@ -38,21 +38,25 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient,
Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry,
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step,
Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog)
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
ImportLog, Ingredient, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry,
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
CookLogSerializer, FoodSerializer, ImportLogSerializer,
CookLogSerializer, FoodInheritFieldSerializer, FoodSerializer,
FoodShoppingUpdateSerializer, ImportLogSerializer,
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
MealTypeSerializer, RecipeBookEntrySerializer,
RecipeBookSerializer, RecipeImageSerializer,
RecipeOverviewSerializer, RecipeSerializer,
RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
ShoppingListRecipeSerializer, ShoppingListSerializer,
StepSerializer, StorageSerializer,
@@ -221,6 +225,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
root = int(root)
except ValueError:
self.queryset = self.model.objects.none()
if root == 0:
self.queryset = self.model.get_root_nodes()
else:
@@ -390,6 +395,17 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin):
pagination_class = DefaultPagination
class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet):
queryset = FoodInheritField.objects
serializer_class = FoodInheritFieldSerializer
permission_classes = [CustomIsUser]
def get_queryset(self):
# exclude fields not yet implemented
self.queryset = Food.inheritable_fields
return super().get_queryset()
class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
queryset = Food.objects
model = Food
@@ -397,6 +413,26 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
# TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
def shopping(self, request, pk):
if self.request.space.demo:
raise PermissionDenied(detail='Not available in demo', code=None)
obj = self.get_object()
shared_users = list(self.request.user.get_shopping_share())
shared_users.append(request.user)
if request.data.get('_delete', False) == 'true':
ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, created_by__in=shared_users).delete()
content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
return Response(content, status=status.HTTP_204_NO_CONTENT)
amount = request.data.get('amount', 1)
unit = request.data.get('unit', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, created_by=request.user)
return Response(content, status=status.HTTP_204_NO_CONTENT)
def destroy(self, *args, **kwargs):
try:
return (super().destroy(self, *args, **kwargs))
@@ -424,7 +460,7 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
- **recipe**: id of recipe - only return books for that recipe
- **book**: id of book - only return recipes in that book
"""
"""
queryset = RecipeBookEntry.objects
serializer_class = RecipeBookEntrySerializer
permission_classes = [CustomIsOwner]
@@ -504,8 +540,7 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
]
schema = QueryParamAutoSchema()
@@ -547,31 +582,27 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
query_params = [
QueryParam(name='query', description=_(
'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'),
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='keywords_or', description=_(
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
QueryParam(name='foods_or', description=_(
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
QueryParam(name='books_or', description=_(
'If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
QueryParam(name='internal',
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random',
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new',
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='keywords_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
QueryParam(name='foods_or', description=_('If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
QueryParam(name='books_or', description=_('If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
]
schema = QueryParamAutoSchema()
def get_queryset(self):
if self.detail:
self.queryset = self.queryset.filter(space=self.request.space)
return super().get_queryset()
share = self.request.query_params.get('share', None)
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
@@ -625,6 +656,43 @@ class RecipeViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
return Response(serializer.errors, 400)
# TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating
# DRF only allows one action in a decorator action without overriding get_operation_id_base()
@decorators.action(
detail=True,
methods=['PUT'],
serializer_class=RecipeShoppingUpdateSerializer,
)
def shopping(self, request, pk):
if self.request.space.demo:
raise PermissionDenied(detail='Not available in demo', code=None)
obj = self.get_object()
ingredients = request.data.get('ingredients', None)
servings = request.data.get('servings', None)
list_recipe = ShoppingListRecipe.objects.filter(id=request.data.get('list_recipe', None)).first()
if servings is None:
servings = getattr(list_recipe, 'servings', obj.servings)
# created_by needs to be sticky to original creator as it is 'their' shopping list
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
created_by = getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', request.user)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=created_by)
return Response(content, status=status.HTTP_204_NO_CONTENT)
@decorators.action(
detail=True,
methods=['GET'],
serializer_class=RecipeSimpleSerializer
)
def related(self, request, pk):
obj = self.get_object()
if obj.get_space() != request.space:
raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
qs = obj.get_related_recipes(levels=1) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
# mealplans= TODO get todays mealplans
return Response(self.serializer_class(qs, many=True).data)
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
queryset = ShoppingListRecipe.objects
@@ -632,9 +700,13 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
shoppinglist__space=self.request.space).distinct().all()
Q(shoppinglist__created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
| Q(entries__created_by=self.request.user)
| Q(entries__created_by__in=list(self.request.user.get_shopping_share()))
).distinct().all()
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
@@ -642,34 +714,46 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner | CustomIsShared]
query_params = [
QueryParam(name='id',
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
qtype='int'),
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
QueryParam(
name='checked',
description=_(
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
),
QueryParam(name='supermarket',
description=_('Returns the shopping list entries sorted by supermarket category order.'),
qtype='int'),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
]
schema = QueryParamAutoSchema()
def get_queryset(self):
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user) | Q(shoppinglist__shared=self.request.user)).filter(
shoppinglist__space=self.request.space).distinct().all()
self.queryset = self.queryset.filter(space=self.request.space)
self.queryset = self.queryset.filter(
Q(created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).distinct().all()
if pk := self.request.query_params.getlist('id', []):
self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk])
if 'checked' in self.request.query_params or 'recent' in self.request.query_params:
return shopping_helper(self.queryset, self.request)
# TODO once old shopping list is removed this needs updated to sharing users in preferences
return self.queryset
# TODO deprecate
class ShoppingListViewSet(viewsets.ModelViewSet):
queryset = ShoppingList.objects
serializer_class = ShoppingListSerializer
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
space=self.request.space).distinct()
return self.queryset.filter(
Q(created_by=self.request.user)
| Q(shared=self.request.user)
| Q(created_by__in=list(self.request.user.get_shopping_share()))
).filter(space=self.request.space).distinct()
def get_serializer_class(self):
try:

View File

@@ -22,8 +22,8 @@ from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.permission_helper import group_required, has_group_permission
from cookbook.helper.recipe_url_import import parse_cooktime
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe,
RecipeImport, Step, Sync, Unit, UserPreference)
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync,
Unit, UserPreference)
from cookbook.tables import SyncTable
from recipes import settings
@@ -111,8 +111,8 @@ def batch_edit(request):
'Batch edit done. %(count)d recipe was updated.',
'Batch edit done. %(count)d Recipes where updated.',
count) % {
'count': count,
}
'count': count,
}
messages.add_message(request, messages.SUCCESS, msg)
return redirect('data_batch_edit')

View File

@@ -7,10 +7,9 @@ from django_tables2 import RequestConfig
from cookbook.filters import ShoppingListFilter
from cookbook.helper.permission_helper import group_required
from cookbook.models import (InviteLink, RecipeImport,
ShoppingList, Storage, SyncLog, UserFile)
from cookbook.tables import (ImportLogTable, InviteLinkTable,
RecipeImportTable, ShoppingListTable, StorageTable)
from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile
from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable,
StorageTable)
@group_required('admin')
@@ -40,20 +39,6 @@ def recipe_import(request):
)
# @group_required('user')
# def food(request):
# f = FoodFilter(request.GET, queryset=Food.objects.filter(space=request.space).all().order_by('pk'))
# table = IngredientTable(f.qs)
# RequestConfig(request, paginate={'per_page': 25}).configure(table)
# return render(
# request,
# 'generic/list_template.html',
# {'title': _("Ingredients"), 'table': table, 'filter': f}
# )
@group_required('user')
def shopping_list(request):
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter(
@@ -204,7 +189,7 @@ def automation(request):
def user_file(request):
try:
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
'file_size_kb__sum'] / 1000
'file_size_kb__sum'] / 1000
except TypeError:
current_file_size_mb = 0
@@ -240,15 +225,11 @@ def step(request):
@group_required('user')
def shopping_list_new(request):
# recipe-param is the name of the parameters used when filtering recipes by this attribute
# model-name is the models.js name of the model, probably ALL-CAPS
return render(
request,
'generic/checklist_template.html',
'shoppinglist_template.html',
{
"title": _("New Shopping List"),
"config": {
'model': "SHOPPING_LIST", # *REQUIRED* name of the model in models.js
}
}
)

View File

@@ -22,13 +22,13 @@ from django_tables2 import RequestConfig
from rest_framework.authtoken.models import Token
from cookbook.filters import RecipeFilter
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference,
UserPreferenceForm)
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User,
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid
from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword, MealPlan, RecipeImport,
SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit,
UserFile, ViewLog)
from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword,
MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink,
ShoppingList, Space, Unit, UserFile, ViewLog)
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
ViewLogTable)
from cookbook.views.data import Object
@@ -254,13 +254,13 @@ def latest_shopping_list(request):
@group_required('user')
def shopping_list(request, pk=None):
def shopping_list(request, pk=None): # TODO deprecate
html_list = request.GET.getlist('r')
recipes = []
for r in html_list:
r = r.replace('[', '').replace(']', '')
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r): # vulnerable to DoS
rid, multiplier = r.split(',')
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
@@ -304,10 +304,6 @@ def user_settings(request):
up.use_kj = form.cleaned_data['use_kj']
up.sticky_navbar = form.cleaned_data['sticky_navbar']
up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
up.save()
elif 'user_name_form' in request.POST:
@@ -378,10 +374,31 @@ def user_settings(request):
sp.trigram_threshold = 0.1
sp.save()
elif 'shopping_form' in request.POST:
shopping_form = ShoppingPreferenceForm(request.POST, prefix='shopping')
if shopping_form.is_valid():
if not up:
up = UserPreference(user=request.user)
up.shopping_share.set(shopping_form.cleaned_data['shopping_share'])
up.mealplan_autoadd_shopping = shopping_form.cleaned_data['mealplan_autoadd_shopping']
up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand']
up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related']
up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync']
up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket']
up.default_delay = shopping_form.cleaned_data['default_delay']
up.shopping_recent_days = shopping_form.cleaned_data['shopping_recent_days']
up.csv_delim = shopping_form.cleaned_data['csv_delim']
up.csv_prefix = shopping_form.cleaned_data['csv_prefix']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
up.save()
if up:
preference_form = UserPreferenceForm(instance=up, space=request.space)
preference_form = UserPreferenceForm(instance=up)
shopping_form = ShoppingPreferenceForm(instance=up)
else:
preference_form = UserPreferenceForm(space=request.space)
shopping_form = ShoppingPreferenceForm(space=request.space)
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
sp.fulltext.all())
@@ -406,6 +423,7 @@ def user_settings(request):
'user_name_form': user_name_form,
'api_token': api_token,
'search_form': search_form,
'shopping_form': shopping_form,
'active_tab': active_tab
})
@@ -541,7 +559,22 @@ def space(request):
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all())
RequestConfig(request, paginate={'per_page': 25}).configure(invite_links)
return render(request, 'space.html', {'space_users': space_users, 'counts': counts, 'invite_links': invite_links})
space_form = SpacePreferenceForm(instance=request.space)
space_form.base_fields['food_inherit'].queryset = Food.inheritable_fields
if request.method == "POST" and 'space_form' in request.POST:
form = SpacePreferenceForm(request.POST, prefix='space')
if form.is_valid():
request.space.food_inherit.set(form.cleaned_data['food_inherit'])
if form.cleaned_data['reset_food_inherit']:
Food.reset_inheritance(space=request.space)
return render(request, 'space.html', {
'space_users': space_users,
'counts': counts,
'invite_links': invite_links,
'space_form': space_form
})
# TODO super hacky and quick solution, safe but needs rework