mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-01 04:10:06 -05:00
Fix after rebase
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
13
cookbook/helper/HelperFunctions.py
Normal file
13
cookbook/helper/HelperFunctions.py
Normal 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")
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
40
cookbook/helper/shopping_helper.py
Normal file
40
cookbook/helper/shopping_helper.py
Normal 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')
|
||||
@@ -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
|
||||
|
||||
149
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
149
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal 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),
|
||||
]
|
||||
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal 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),
|
||||
]
|
||||
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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', ]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -339,10 +339,10 @@
|
||||
{% user_prefs request as prefs%}
|
||||
{{ prefs|json_script:'user_preference' }}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% block script %}
|
||||
|
||||
{% endblock script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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/>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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')
|
||||
|
||||
17
cookbook/templates/shoppinglist_template.html
Normal file
17
cookbook/templates/shoppinglist_template.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user