Fix after rebase

This commit is contained in:
smilerz
2021-10-28 07:35:30 -05:00
parent 4a747f5cd4
commit 9827c3ffd5
74 changed files with 5651 additions and 2647 deletions

View File

@@ -280,7 +280,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

@@ -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, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
class SelectWidget(widgets.Select):
@@ -19,6 +17,7 @@ class SelectWidget(widgets.Select):
class MultiSelectWidget(widgets.SelectMultiple):
class Media:
js = ('custom/js/form_multiselect.js',)
@@ -46,8 +45,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 = {
@@ -75,20 +73,26 @@ class UserPreferenceForm(forms.ModelForm):
# 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.'),
'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
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
'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
),
'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,
}
@@ -261,6 +265,7 @@ class SyncForm(forms.ModelForm):
}
# TODO deprecate
class BatchEditForm(forms.Form):
search = forms.CharField(label=_('Search String'))
keywords = forms.ModelMultipleChoiceField(
@@ -297,6 +302,7 @@ class ImportRecipeForm(forms.ModelForm):
}
# TODO deprecate
class MealPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
@@ -419,10 +425,8 @@ class UserCreateForm(forms.Form):
class SearchPreferenceForm(forms.ModelForm):
prefix = 'search'
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2,
widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
help_text=_(
'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
@@ -464,3 +468,59 @@ 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'
)
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_autoexclude_onhand': _('When automatically adding a meal plan to the shopping list, exclude ingredients that are on hand.'),
'mealplan_autoinclude_related': _('When automatically adding a meal plan to the shopping list, include all related recipes.'),
'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
}
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'),
}
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."))
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):
"""
@@ -79,7 +79,11 @@ def is_object_shared(user, obj):
# share checks for relevant objects
if not user.is_authenticated:
return False
return user in obj.get_shared()
if obj.__class__.__name__ == 'ShoppingListEntry':
# shopping lists are shared all or none and stored in user preferences
return obj.created_by in user.get_shopping_share()
else:
return user in obj.get_shared()
def share_link_valid(recipe, share):

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):
@@ -49,7 +38,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,6 +197,7 @@ 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.

View File

@@ -0,0 +1,40 @@
from datetime import timedelta
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 SupermarketCategoryRelation
from recipes import settings
def shopping_helper(qs, request):
supermarket = request.query_params.get('supermarket', None)
checked = request.query_params.get('checked', 'recent')
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'
# qs = qs.annotate(supermarket_category=Coalesce(F('food__supermarket_category__name'), Value(_('Undefined'))))
# TODO add supermarket to API - order by category order
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)
# TODO make recent a user setting
week_ago = today_start - timedelta(days=7)
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')

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 django.core.exceptions import ObjectDoesNotExist
from django.core.files import File

View File

@@ -0,0 +1,149 @@
# 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='food',
name='on_hand',
field=models.BooleanField(default=False),
),
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='food',
name='inherit',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='userpreference',
name='mealplan_autoinclude_related',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='food',
name='ignore_inherit',
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.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='Ignore Shopping', field='ignore_shopping')
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

@@ -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):
@@ -78,6 +91,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 +144,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 +218,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 +240,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 +319,18 @@ 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.IntegerField(default=4)
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
@@ -363,8 +445,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,6 +475,10 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
# exclude fields not implemented yet
inherit_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)])
@@ -400,6 +486,9 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
ignore_shopping = models.BooleanField(default=False)
description = models.TextField(default='', blank=True)
on_hand = models.BooleanField(default=False)
inherit = models.BooleanField(default=False)
ignore_inherit = models.ManyToManyField(FoodInheritField, blank=True) # is this 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 +502,38 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
else:
return super().delete()
@staticmethod
def reset_inheritance(space=None):
inherit = space.food_inherit.all()
ignore_inherit = Food.inherit_fields.difference(inherit)
# food is going to inherit attributes
if space.food_inherit.all().count() > 0:
# using update to avoid creating a N*depth! save signals
Food.objects.filter(space=space).update(inherit=True)
# ManyToMany cannot be updated through an UPDATE operation
Through = Food.objects.first().ignore_inherit.through
Through.objects.all().delete()
for i in ignore_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 'ignore_shopping' 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, ignore_shopping=True)).update(ignore_shopping=True)
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=False)).update(ignore_shopping=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)
else: # food is not going to inherit any attributes
Food.objects.filter(space=space).update(inherit=False)
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
@@ -534,6 +655,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"]),
@@ -552,7 +688,7 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
objects = ScopedManager(space='recipe__space')
@staticmethod
@ staticmethod
def get_space_key():
return 'recipe', 'space'
@@ -600,7 +736,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
objects = ScopedManager(space='book__space')
@staticmethod
@ staticmethod
def get_space_key():
return 'book', 'space'
@@ -647,6 +783,18 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.get_owner().userpreference.mealplan_autoadd_shopping:
kwargs = {
'mealplan': self,
'space': self.space,
'created_by': self.get_owner()
}
if self.get_owner().userpreference.mealplan_autoexclude_onhand:
kwargs['ingredients'] = Ingredient.objects.filter(step__recipe=self.recipe, food__on_hand=False, space=self.space).values_list('id', flat=True)
ShoppingListEntry.list_from_recipe(**kwargs)
def get_label(self):
if self.title:
return self.title
@@ -660,12 +808,14 @@ 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')
@staticmethod
@ staticmethod
def get_space_key():
return 'recipe', 'space'
@@ -677,22 +827,101 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
def get_owner(self):
try:
return self.shoppinglist_set.first().created_by
return self.entries.first().created_by or self.shoppinglist_set.first().created_by
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)
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
food = models.ForeignKey(Food, on_delete=models.CASCADE)
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
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
@classmethod
@atomic
def list_from_recipe(self, list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None):
"""
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
"""
# TODO cascade to related recipes
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(mealplan, 'created_by', None) or getattr(list_recipe, 'created_by', None)
if not created_by:
raise ValueError(_("You must supply a created_by"))
if type(servings) not in [int, float]:
servings = getattr(mealplan, 'servings', 1.0)
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
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)
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(ingredients.values_list('id', flat=True)) - 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
servings_factor = servings / r.servings
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))
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
shoppinglist = [
ShoppingListEntry(
list_recipe=list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(servings_factor),
created_by=created_by,
space=space
)
for i in [x for x in add_ingredients if not x.food.ignore_shopping]
]
ShoppingListEntry.objects.bulk_create(shoppinglist)
# return all shopping list items
print('end of servings')
return ShoppingListEntry.objects.filter(list_recipe=list_recipe)
@ staticmethod
def get_space_key():
return 'shoppinglist', 'space'
@@ -702,12 +931,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
@@ -863,7 +1094,7 @@ class SearchFields(models.Model, PermissionModelMixin):
def __str__(self):
return _(self.name)
@staticmethod
@ staticmethod
def get_name(self):
return _(self.name)

View File

@@ -4,6 +4,7 @@ from decimal import Decimal
from gettext import gettext as _
from django.contrib.auth.models import User
from django.db import transaction
from django.db.models import Avg, QuerySet, Sum
from django.urls import reverse
from django.utils import timezone
@@ -11,12 +12,13 @@ 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.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
@@ -61,7 +63,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
# probably not a tree
pass
if recipes.count() != 0:
return random.choice(recipes).image.url
return recipes.order_by('?')[:1][0].image.url
else:
return None
@@ -78,7 +80,7 @@ class CustomDecimalField(serializers.Field):
def to_representation(self, value):
if not isinstance(value, Decimal):
value = Decimal(value)
return round(value, 2).normalize()
return round(value, 3).normalize()
def to_internal_value(self, data):
if type(data) == int or type(data) == float:
@@ -136,8 +138,27 @@ class UserNameSerializer(WritableNestedModelSerializer):
fields = ('id', 'username')
class FoodInheritFieldSerializer(UniqueFieldsMixin):
def create(self, validated_data):
# 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', ]
class UserPreferenceSerializer(serializers.ModelSerializer):
plan_share = UserNameSerializer(many=True, read_only=True)
# food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', read_only=True)
food_ignore_default = serializers.SerializerMethodField('get_ignore_default')
def get_ignore_default(self, obj):
return FoodInheritFieldSerializer(Food.inherit_fields.difference(obj.space.food_inherit.all()), many=True).data
def create(self, validated_data):
if validated_data['user'] != self.context['request'].user:
@@ -149,7 +170,8 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
'comments'
'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_ignore_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share'
)
@@ -255,25 +277,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
@@ -285,27 +293,14 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
class Meta:
model = Keyword
fields = (
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
'updated_at')
read_only_fields = ('id', 'numchild', 'parent', 'image')
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe')
read_only_fields = ('id', 'label', 'image', 'parent', 'numchild', 'numrecipe')
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 +364,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')
ignore_inherit = FoodInheritFieldSerializer(many=True)
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 +384,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', 'ignore_shopping', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'on_hand', 'inherit', 'ignore_inherit',
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
class IngredientSerializer(WritableNestedModelSerializer):
@@ -559,6 +541,9 @@ class RecipeSerializer(RecipeBaseSerializer):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
def update(self, instance, validated_data):
return super().update(instance, validated_data)
class RecipeImageSerializer(WritableNestedModelSerializer):
class Meta:
@@ -628,7 +613,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
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):
ShoppingListEntry.list_from_recipe(mealplan=mealplan, space=validated_data['space'], created_by=validated_data['created_by'])
return mealplan
class Meta:
model = MealPlan
@@ -640,34 +628,98 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
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:
ShoppingListEntry.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)
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 +740,7 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
read_only_fields = ('id', 'created_by',)
# TODO deprecate
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
@@ -802,7 +855,7 @@ class FoodExportSerializer(FoodSerializer):
class Meta:
model = Food
fields = ('name', 'ignore_shopping', 'supermarket_category')
fields = ('name', 'ignore_shopping', 'supermarket_category', 'on_hand')
class IngredientExportSerializer(WritableNestedModelSerializer):
@@ -847,3 +900,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,80 @@
from functools import wraps
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.managers import DICTIONARY
from cookbook.models import Food, FoodInheritField, Recipe, Step
# 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:
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):
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 = Food.inherit_fields.difference(instance.ignore_inherit.all())
# nothing to apply from parent and nothing to apply to children
if (not instance.inherit or 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.inherit and instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'ignore_shopping' in inherit:
instance.ignore_shopping = parent.ignore_shopping
# 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
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='ignore_shopping').update(ignore_shopping=instance.ignore_shopping)
# don't cascade empty supermarket category
if instance.supermarket_category:
instance.get_children().filter(inherit=True).exclude(ignore_inherit__field='supermarket_category').update(supermarket_category=instance.supermarket_category)

View File

@@ -339,10 +339,10 @@
{% user_prefs request as prefs%}
{{ prefs|json_script:'user_preference' }}
</div>
{% block script %}
{% endblock script %}
<script type="application/javascript">

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

@@ -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
$(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();
}
}
</script>
{% endblock %}

View File

@@ -655,6 +655,7 @@
if (this.shopping_list.entries.length === 0) {
this.edit_mode = true
}
console.log(response.data)
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')

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

@@ -1,10 +1,9 @@
import json
import pytest
from django.contrib import auth
from django_scopes import scopes_disabled
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
@@ -74,7 +73,7 @@ def ing_1_1_s1(obj_1_1, space_1):
@pytest.fixture()
def sle_1_s1(obj_1, u1_s1, space_1):
e = ShoppingListEntry.objects.create(food=obj_1)
e = ShoppingListEntry.objects.create(food=obj_1, 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
@@ -82,12 +81,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(food=obj_2)
return ShoppingListEntry.objects.create(food=obj_2, created_by=auth.get_user(u1_s1), space=space_1,)
@pytest.fixture()
def sle_3_s2(obj_3, u1_s2, space_2):
e = ShoppingListEntry.objects.create(food=obj_3)
e = ShoppingListEntry.objects.create(food=obj_3, created_by=auth.get_user(u1_s2), space=space_2)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
s.entries.add(e)
return e
@@ -95,7 +94,7 @@ def sle_3_s2(obj_3, u1_s2, space_2):
@pytest.fixture()
def sle_1_1_s1(obj_1_1, u1_s1, space_1):
e = ShoppingListEntry.objects.create(food=obj_1_1)
e = ShoppingListEntry.objects.create(food=obj_1_1, 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
@@ -449,3 +448,10 @@ def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
assert response['count'] == 4
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content)
assert response['count'] == 4
# TODO test inherit creating, moving for each field type
# TODO test ignore inherit for each field type
# TODO test with grand-children
# - flow from parent through child and grand-child
# - flow from parent stop when child is ignore inherit

View File

@@ -111,3 +111,16 @@ def test_delete(u1_s1, u1_s2, recipe_1_s1):
assert r.status_code == 204
assert not Recipe.objects.filter(pk=recipe_1_s1.id).exists()
# TODO test related_recipes api
# -- step recipes
# -- ingredient recipes
# -- recipe wrong space
# -- steps wrong space
# -- ingredients wrong space
# -- 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

@@ -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
@@ -114,3 +117,15 @@ def test_delete(u1_s1, u1_s2, obj_1):
)
assert r.status_code == 204
# TODO test sharing
# TODO test completed entries still visible if today, but not yesterday
# TODO test create shopping list from recipe
# TODO test delete shopping list from recipe - include created by, shared with and not shared with
# TODO test create shopping list from food
# TODO test delete shopping list from food - include created by, shared with and not shared with
# TODO test create shopping list from mealplan
# TODO test create shopping list from recipe, excluding ingredients
# TODO test auto creating shopping list from meal plan
# TODO test excluding on-hand when auto creating shopping list

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,5 +1,3 @@
from cookbook.models import UserPreference
import json
import pytest
@@ -7,6 +5,8 @@ from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import UserPreference
LIST_URL = 'api:userpreference-list'
DETAIL_URL = 'api:userpreference-detail'
@@ -109,3 +109,6 @@ def test_preference_delete(u1_s1, u2_s1):
)
)
assert r.status_code == 204
# TODO test existance of default food_inherit fields, test multiple users same space work and users in difference space do not

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

@@ -36,25 +36,27 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, Cus
CustomIsShare, CustomIsShared, CustomIsUser,
group_required)
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import get_facet, search_recipes, old_search
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 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, TreeSchema, QueryParamAutoSchema, QueryParam
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,
@@ -361,8 +363,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsUser]
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space)
return super().get_queryset()
return self.queryset.filter(space=self.request.space)
class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
@@ -392,6 +393,16 @@ 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
return Food.inherit_fields
class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
queryset = Food.objects
model = Food
@@ -399,6 +410,23 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
def shopping(self, request, pk):
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))
@@ -549,27 +577,18 @@ 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()
@@ -627,16 +646,49 @@ class RecipeViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
return Response(serializer.errors, 400)
@decorators.action(
detail=True,
methods=['PUT'],
serializer_class=RecipeShoppingUpdateSerializer,
)
def shopping(self, request, pk):
obj = self.get_object()
ingredients = request.data.get('ingredients', None)
servings = request.data.get('servings', obj.servings)
list_recipe = request.data.get('list_recipe', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
# TODO: Consider if this should be a Recipe method
ShoppingListEntry.list_from_recipe(list_recipe=list_recipe, recipe=obj, ingredients=ingredients, servings=servings, space=request.space, created_by=request.user)
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=2) # TODO: make levels a user setting, included in request data, keep solely in the backend?
return Response(self.serializer_class(qs, many=True).data)
# TODO deprecate
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
queryset = ShoppingListRecipe.objects
serializer_class = ShoppingListRecipeSerializer
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):
@@ -644,35 +696,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 bool(int(self.request.query_params.get('recent', False))):
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]
# TODO update to include settings shared user - make both work for a period of time
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()
# TODO deprecate
def get_serializer_class(self):
try:
autosync = self.request.query_params.get('autosync', False)

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
@@ -244,11 +229,9 @@ def shopping_list_new(request):
# 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
@@ -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,28 @@ 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']
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 +420,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 +556,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.inherit_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