Merge branch 'feature/export-progress' into develop

This commit is contained in:
vabene1111
2022-01-28 15:41:49 +01:00
committed by GitHub
141 changed files with 14841 additions and 8379 deletions

View File

@@ -15,7 +15,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog)
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation)
class CustomUserAdmin(UserAdmin):
@@ -29,11 +29,52 @@ admin.site.register(User, CustomUserAdmin)
admin.site.unregister(Group)
@admin.action(description='Delete all data from a space')
def delete_space_action(modeladmin, request, queryset):
for space in queryset:
CookLog.objects.filter(space=space).delete()
ViewLog.objects.filter(space=space).delete()
ImportLog.objects.filter(space=space).delete()
BookmarkletImport.objects.filter(space=space).delete()
Comment.objects.filter(recipe__space=space).delete()
Keyword.objects.filter(space=space).delete()
Ingredient.objects.filter(space=space).delete()
Food.objects.filter(space=space).delete()
Unit.objects.filter(space=space).delete()
Step.objects.filter(space=space).delete()
NutritionInformation.objects.filter(space=space).delete()
RecipeBookEntry.objects.filter(book__space=space).delete()
RecipeBook.objects.filter(space=space).delete()
MealType.objects.filter(space=space).delete()
MealPlan.objects.filter(space=space).delete()
ShareLink.objects.filter(space=space).delete()
Recipe.objects.filter(space=space).delete()
RecipeImport.objects.filter(space=space).delete()
SyncLog.objects.filter(sync__space=space).delete()
Sync.objects.filter(space=space).delete()
Storage.objects.filter(space=space).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=space).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete()
ShoppingList.objects.filter(space=space).delete()
SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete()
SupermarketCategory.objects.filter(space=space).delete()
Supermarket.objects.filter(space=space).delete()
InviteLink.objects.filter(space=space).delete()
UserFile.objects.filter(space=space).delete()
Automation.objects.filter(space=space).delete()
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username')
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at'
actions = [delete_space_action]
admin.site.register(Space, SpaceAdmin)
@@ -128,7 +169,7 @@ def sort_tree(modeladmin, request, queryset):
class KeywordAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
@@ -136,8 +177,8 @@ admin.site.register(Keyword, KeywordAdmin)
class StepAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'order')
search_fields = ('name', 'type')
list_display = ('name', 'order',)
search_fields = ('name',)
admin.site.register(Step, StepAdmin)
@@ -171,13 +212,15 @@ class RecipeAdmin(admin.ModelAdmin):
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
# admin.site.register(FoodInheritField)
class FoodAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name', )
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]

View File

@@ -358,8 +358,8 @@ class InviteLinkForm(forms.ModelForm):
def clean(self):
space = self.cleaned_data['space']
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(
space=space).filter(valid_until__gte=datetime.today()).count()) >= space.max_users:
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() +
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
raise ValidationError(_('Maximum number of users for this space reached.'))
def clean_email(self):
@@ -442,7 +442,7 @@ class SearchPreferenceForm(forms.ModelForm):
help_texts = {
'search': _(
'Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
'unaccent': _(
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
@@ -461,7 +461,7 @@ class SearchPreferenceForm(forms.ModelForm):
'lookup': _('Fuzzy Lookups'),
'unaccent': _('Ignore Accent'),
'icontains': _("Partial Match"),
'istartswith': _("Starts Wtih"),
'istartswith': _("Starts With"),
'trigram': _("Fuzzy Search"),
'fulltext': _("Full Text")
}
@@ -535,10 +535,11 @@ class SpacePreferenceForm(forms.ModelForm):
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit',)
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count')
help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'), }
'food_inherit': _('Fields on food that should be inherited by default.'),
'show_facet_count': _('Show recipe counts on search filters'), }
widgets = {
'food_inherit': MultiSelectWidget

View File

@@ -7,7 +7,7 @@ class Round(Func):
def str2bool(v):
if type(v) == bool:
if type(v) == bool or v is None:
return v
else:
return v.lower() in ("yes", "true", "1")

View File

@@ -34,7 +34,7 @@ def has_group_permission(user, groups):
"""
Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks.
Unauthenticated users cant be member of any group thus always return false.
Unauthenticated users can't be member of any group thus always return false.
:param user: django auth user object
:param groups: list or tuple of groups the user should be checked for
:return: True if user is in allowed groups, false otherwise
@@ -206,7 +206,7 @@ class CustomIsShared(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
# temporary hack to make old shopping list work with new shopping list
if obj.__class__.__name__ == 'ShoppingList':
if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj)

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ def shopping_helper(qs, request):
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
# TODO refactor as class
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
"""
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
@@ -78,7 +79,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
elif ingredients:
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
else:
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
@@ -100,9 +101,9 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
if ingredients.filter(food__recipe=x).exists():
for ing in ingredients.filter(food__recipe=x):
if exclude_onhand:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
else:
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
for i in [x for x in x_ing]:
ShoppingListEntry.objects.create(
list_recipe=list_recipe,
@@ -125,7 +126,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# if servings have changed, update the ShoppingListRecipe and existing Entrys
# if servings have changed, update the ShoppingListRecipe and existing Entries
if servings <= 0:
servings = 1
@@ -137,7 +138,7 @@ def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
sle.save()
# add any missing Entrys
# add any missing Entries
for i in [x for x in add_ingredients if x.food]:
ShoppingListEntry.objects.create(

View File

@@ -2,7 +2,6 @@ import json
from io import BytesIO, StringIO
from re import match
from zipfile import ZipFile
from django.utils.text import get_valid_filename
from rest_framework.renderers import JSONRenderer
@@ -60,13 +59,12 @@ class Default(Integration):
recipe_zip_obj.close()
export_zip_obj.writestr(get_valid_filename(r.name) + '.zip', recipe_zip_stream.getvalue())
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
export_zip_obj.close()
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]

View File

@@ -46,7 +46,7 @@ class Integration:
try:
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}'
except ObjectDoesNotExist:
except (ObjectDoesNotExist, ValueError):
name = 'Import 1'
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
@@ -57,7 +57,7 @@ class Integration:
icon=icon,
space=request.space
)
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
self.keyword = parent.add_child(
name=f'{name} {str(uuid.uuid4())[0:8]}',
description=description,
@@ -98,6 +98,10 @@ class Integration:
el.running = False
el.save()
response = HttpResponse(export_file, content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
def import_file_name_filter(self, zip_info_object):
"""
@@ -269,7 +273,6 @@ class Integration:
"""
raise NotImplementedError('Method not implemented in integration')
def get_files_from_recipes(self, recipes, el, cookie):
"""
Takes a list of recipe object and converts it to a array containing each file.

View File

@@ -27,10 +27,10 @@ class RecetteTek(Integration):
def get_recipe_from_file(self, file):
# Create initial recipe with just a title and a decription
# Create initial recipe with just a title and a description
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
# set the description as an empty string for later use for the source URL, incase there is no description text.
# set the description as an empty string for later use for the source URL, in case there is no description text.
recipe.description = ''
try:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
self.stdout.write(self.style.WARNING(_('Only Postgress databases use full text search, no index to rebuild')))
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
try:
language = DICTIONARY.get(translation.get_language(), 'simple')

View File

@@ -140,5 +140,10 @@ class Migration(migrations.Migration):
old_name='ignore_shopping',
new_name='food_onhand',
),
migrations.AddField(
model_name='space',
name='show_facet_count',
field=models.BooleanField(default=False),
),
migrations.RunPython(copy_values_to_sle),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.2.11 on 2022-01-17 22:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0163_auto_20220105_0758'),
]
operations = [
# migrations.AddField(
# model_name='space',
# name='show_facet_count',
# field=models.BooleanField(default=False),
# ),
# removed due to quick fix in 0159 migration to maintain correct order
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.11 on 2022-01-18 19:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0164_space_show_facet_count'),
]
operations = [
migrations.RemoveField(
model_name='step',
name='type',
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.2.11 on 2022-01-20 14:39
from django.db import migrations, models
from django_scopes import scopes_disabled
def add_default_trigram(apps, schema_editor):
with scopes_disabled():
UserPreference = apps.get_model('cookbook', 'UserPreference')
UserPreference.objects.all().update(shopping_add_onhand=False)
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0165_remove_step_type'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='shopping_add_onhand',
field=models.BooleanField(default=False),
),
migrations.RunPython(add_default_trigram),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.11 on 2022-01-20 22:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0166_alter_userpreference_shopping_add_onhand'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='left_handed',
field=models.BooleanField(default=False),
),
]

View File

@@ -77,7 +77,10 @@ class TreeManager(MP_NodeManager):
for field in many_to_many:
field_model = getattr(obj, field).model
for related_obj in many_to_many[field]:
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
if isinstance(related_obj, User):
getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
else:
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
return obj, True
except IntegrityError as e:
if 'Key (path)' in e.args[0]:
@@ -148,6 +151,7 @@ class TreeModel(MP_Node):
return super().add_root(**kwargs)
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
@staticmethod
def include_descendants(queryset=None, filter=None):
"""
:param queryset: Model Queryset to add descendants
@@ -244,6 +248,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
show_facet_count = models.BooleanField(default=False)
def __str__(self):
return self.name
@@ -332,8 +337,9 @@ class UserPreference(models.Model, PermissionModelMixin):
mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=False)
filter_to_supermarket = models.BooleanField(default=False)
left_handed = models.BooleanField(default=False)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
csv_delim = models.CharField(max_length=2, default=",")
@@ -510,7 +516,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
@staticmethod
def reset_inheritance(space=None):
# resets inheritted fields to the space defaults and updates all inheritted fields to root object values
# resets inherited fields to the space defaults and updates all inherited fields to root object values
inherit = space.food_inherit.all()
# remove all inherited fields from food
@@ -571,17 +577,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
TEXT = 'TEXT'
TIME = 'TIME'
FILE = 'FILE'
RECIPE = 'RECIPE'
name = models.CharField(max_length=128, default='', blank=True)
type = models.CharField(
choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')), (RECIPE, _('Recipe')),),
default=TEXT,
max_length=16
)
instruction = models.TextField(blank=True)
ingredients = models.ManyToManyField(Ingredient, blank=True)
time = models.IntegerField(default=0, blank=True)
@@ -613,9 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin):
)
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
source = models.CharField(
max_length=512, default="", null=True, blank=True
)
source = models.CharField( max_length=512, default="", null=True, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@@ -624,6 +618,15 @@ class NutritionInformation(models.Model, PermissionModelMixin):
return f'Nutrition {self.pk}'
# class NutritionType(models.Model, PermissionModelMixin):
# name = models.CharField(max_length=128)
# icon = models.CharField(max_length=16, blank=True, null=True)
# description = models.CharField(max_length=512, blank=True, null=True)
#
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
# objects = ScopedManager(space='space')
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128)
description = models.CharField(max_length=512, blank=True, null=True)

View File

@@ -29,7 +29,11 @@ class Nextcloud(Provider):
client = Nextcloud.get_client(monitor.storage)
files = client.list(monitor.path)
files.pop(0) # remove first element because its the folder itself
try:
files.pop(0) # remove first element because its the folder itself
except IndexError:
pass # folder is empty, no recipes will be imported
import_count = 0
for file in files:

View File

@@ -12,6 +12,7 @@ from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError
from rest_framework.fields import empty
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.shopping_helper import list_from_recipe
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
FoodInheritField, ImportLog, ExportLog, Ingredient, Keyword, MealPlan, MealType,
@@ -21,13 +22,18 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Fo
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import MEDIA_URL
class ExtendedRecipeMixin(serializers.ModelSerializer):
# adds image and recipe count to serializer when query param extended=1
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.SerializerMethodField('count_recipes')
# ORM path to this object from Recipe
recipe_filter = None
# list of ORM paths to any image
images = None
image = serializers.SerializerMethodField('get_image')
numrecipe = serializers.ReadOnlyField(source='recipe_count')
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)
@@ -37,8 +43,7 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
try:
if bool(int(
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError) as e:
pass
@@ -50,24 +55,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
return fields
def get_image(self, obj):
# TODO add caching
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude(
image__isnull=True).exclude(image__exact='')
try:
if recipes.count() == 0 and obj.has_children():
obj__in = self.recipe_filter + '__in'
recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude(
image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
except AttributeError:
# probably not a tree
pass
if recipes.count() != 0:
return random.choice(recipes).image.url
else:
return None
def count_recipes(self, obj):
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
if obj.recipe_image:
return MEDIA_URL + obj.recipe_image
class CustomDecimalField(serializers.Field):
@@ -98,7 +87,11 @@ class CustomOnHandField(serializers.Field):
return instance
def to_representation(self, obj):
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
shared_users = None
if request := self.context.get('request', None):
shared_users = getattr(request, '_shared_users', None)
if shared_users is None:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
return obj.onhand_users.filter(id__in=shared_users).exists()
def to_internal_value(self, data):
@@ -165,13 +158,19 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
class Meta:
model = FoodInheritField
fields = ('id', 'name', 'field', )
fields = ('id', 'name', 'field',)
read_only_fields = ['id']
class UserPreferenceSerializer(serializers.ModelSerializer):
class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
def get_food_children_exist(self, obj):
space = getattr(self.context.get('request', None), 'space', None)
return Food.objects.filter(depth__gt=0, space=space).exists()
def create(self, validated_data):
if not validated_data.get('user', None):
@@ -183,10 +182,10 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
)
@@ -379,14 +378,16 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
shopping = serializers.SerializerMethodField('get_shopping_status')
# shopping = serializers.SerializerMethodField('get_shopping_status')
shopping = serializers.ReadOnlyField(source='shopping_status')
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
food_onhand = CustomOnHandField(required=False, allow_null=True)
recipe_filter = 'steps__ingredients__food'
images = ['recipe__image']
def get_shopping_status(self, obj):
return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
# 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()
@@ -396,15 +397,20 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create(
name=validated_data.pop('supermarket_category')['name'],
space=self.context['request'].space)
onhand = validated_data.get('food_onhand', None)
onhand = validated_data.pop('food_onhand', None)
# assuming if on hand for user also onhand for shopping_share users
if not onhand is None:
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
if onhand:
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
if self.instance:
onhand_users = self.instance.onhand_users.all()
else:
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
onhand_users = []
if onhand:
validated_data['onhand_users'] = list(onhand_users) + shared_users
else:
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
obj, created = Food.objects.get_or_create(**validated_data)
return obj
@@ -425,7 +431,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
model = Food
fields = (
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping'
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@@ -472,12 +478,12 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
# check if root type is recipe to prevent infinite recursion
# can be improved later to allow multi level embedding
if obj.step_recipe and type(self.parent.root) == RecipeSerializer:
return StepRecipeSerializer(obj.step_recipe).data
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
class Meta:
model = Step
fields = (
'id', 'name', 'type', 'instruction', 'ingredients', 'ingredients_markdown',
'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown',
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 'step_recipe_data', 'numrecipe'
)
@@ -493,6 +499,10 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
class NutritionInformationSerializer(serializers.ModelSerializer):
carbohydrates = CustomDecimalField()
fats = CustomDecimalField()
proteins = CustomDecimalField()
calories = CustomDecimalField()
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
@@ -516,7 +526,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
def get_recipe_last_cooked(self, obj):
try:
last = obj.cooklog_set.filter(created_by=self.context['request'].user).last()
last = obj.cooklog_set.filter(created_by=self.context['request'].user).order_by('created_at').last()
if last:
return last.created_at
except TypeError:
@@ -525,7 +535,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
# TODO make days of new recipe a setting
def is_recipe_new(self, obj):
if obj.created_at > (timezone.now() - timedelta(days=7)):
if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)):
return True
else:
return False
@@ -536,6 +546,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
rating = serializers.SerializerMethodField('get_recipe_rating')
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
new = serializers.SerializerMethodField('is_recipe_new')
recent = serializers.ReadOnlyField()
def create(self, validated_data):
pass
@@ -548,7 +559,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
fields = (
'id', 'name', 'description', 'image', 'keywords', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at',
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new'
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
)
read_only_fields = ['image', 'created_by', 'created_at']
@@ -681,7 +692,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
) + f' ({value:.2g})'
def update(self, instance, validated_data):
if 'servings' in validated_data:
# TODO remove once old shopping list
if 'servings' in validated_data and self.context.get('view', None).__class__.__name__ != 'ShoppingListViewSet':
list_from_recipe(
list_recipe=instance,
servings=validated_data['servings'],
@@ -717,9 +729,9 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
def run_validation(self, data):
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
if (
data.get('checked', False)
and self.root.instance
and not self.root.instance.checked
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()
@@ -755,7 +767,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
'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',)
read_only_fields = ('id', 'created_by', 'created_at',)
# TODO deprecate
@@ -870,7 +882,7 @@ class AutomationSerializer(serializers.ModelSerializer):
# CORS, REST and Scopes aren't currently working
# Scopes are evaluating before REST has authenticated the user assiging a None space
# Scopes are evaluating before REST has authenticated the user assigning a None space
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
class BookmarkletImportSerializer(serializers.ModelSerializer):
def create(self, validated_data):
@@ -941,7 +953,7 @@ class StepExportSerializer(WritableNestedModelSerializer):
class Meta:
model = Step
fields = ('name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
fields = ('name', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
class RecipeExportSerializer(WritableNestedModelSerializer):

View File

@@ -72,7 +72,7 @@ def update_food_inheritance(sender, instance=None, created=False, **kwargs):
return
inherit = inherit.values_list('field', flat=True)
# apply changes from parent to instance for each inheritted field
# apply changes from parent to instance for each inherited field
if instance.parent and inherit.count() > 0:
parent = instance.get_parent()
if 'ignore_shopping' in inherit:
@@ -121,11 +121,3 @@ def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs
'servings': instance.servings
}
list_recipe = list_from_recipe(**kwargs)
# user = self.context['request'].user
# if user.userpreference.shopping_add_onhand:
# if checked := validated_data.get('checked', None):
# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
# elif checked == False:
# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)

View File

@@ -10461,4 +10461,9 @@ textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([clas
.form-control-search {
font-size: 20px;
}
.ghost {
opacity: 0.5 !important;
background: #b98766 !important;
}

View File

@@ -19,7 +19,7 @@
<div class="row">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<hr>
<form class="login" method="POST" action="{% url 'account_login' %}">
{% csrf_token %}
{{ form | crispy }}
@@ -29,12 +29,16 @@
{% endif %}
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% if SIGNUP_ENABLED %}
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
{% if EMAIL_ENABLED %}
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
{% endif %}
</form>
</div>
@@ -44,7 +48,7 @@
{% if socialaccount_providers %}
<div class="row" style="margin-top: 2vh">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<h5>{% trans "Social Login" %}</h5>
<span>{% trans 'You can use any of the following providers to sign in.' %}</span>
@@ -62,5 +66,8 @@
</div>
{% endif %}
<script>
$('#id_login').focus()
</script>
{% endblock %}

View File

@@ -71,4 +71,8 @@
</div>
<script>
$('#id_username').focus()
</script>
{% endblock %}

View File

@@ -54,7 +54,7 @@
<h2>{% trans 'Formatting' %}</h2>
<pre class="intro-code code-block"><code>
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
{% trans 'or by leaving a blank line inbetween.' %}
{% trans 'or by leaving a blank line in between.' %}
**{% trans 'This text is bold' %}**
*{% trans 'This text is italic' %}*
@@ -70,7 +70,7 @@
<div class="card">
<div class="card-body">
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
{% trans 'or by leaving a blank line in between.' %}<br/><br/>
<b>{% trans 'This text is bold' %}</b><br/>
<i>{% trans 'This text is italic' %}</i>
<blockquote>
@@ -82,7 +82,7 @@
<br/>
<h2>{% trans 'Lists' %}</h2>
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
{% trans 'Lists can ordered or unordered. It is <b>important to leave a blank line before the list!</b>' %}
<pre class="intro-code code-block"><code>
{% trans 'Ordered List' %}

View File

@@ -27,7 +27,7 @@
{% endblocktrans %}</p>
<h4>{% trans 'Simple' %}</h4>
<p> {% blocktrans %}
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat seperate words as required.
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat separate words as required.
Searching for 'apple or flour' will return any recipe that includes both 'apple' and 'flour' anywhere in the fields that have been selected for a full text search.
{% endblocktrans %}</p>
<h4>{% trans 'Phrase' %}</h4>
@@ -39,7 +39,7 @@
<p> {% blocktrans %}
Web searches simulate functionality found on many web search sites supporting special syntax.
Placing quotes around several words will convert those words into a phrase.
'or' is recongized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'or' is recognized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
'-' is recognized as searching for recipes that do not include the word (or phrase) that comes immediately after.
For example searching for 'apple pie' or cherry -butter will return any recipe that includes the phrase 'apple pie' or the word 'cherry'
in any field included in the full text search but exclude any recipe that has the word 'butter' in any field included.
@@ -59,7 +59,7 @@
{% blocktrans %}
Another approach to searching that also requires Postgresql is fuzzy search or trigram similarity. A trigram is a group of three consecutive characters.
For example searching for 'apple' will create x trigrams 'app', 'ppl', 'ple' and will create a score of how closely words match the generated trigrams.
One benefit of searching trigams is that a search for 'sandwich' will find mispelled words such as 'sandwhich' that would be missed by other methods.
One benefit of searching trigams is that a search for 'sandwich' will find misspelled words such as 'sandwhich' that would be missed by other methods.
{% endblocktrans %}
</div>

View File

@@ -263,7 +263,7 @@
{% if request.user.userpreference.shopping_auto_sync > 0 %}
<div class="row" v-if="!onLine">
<div class="col col-md-12">
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not syncronize.' %}</div>
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not synchronize.' %}</div>
</div>
</div>
{% endif %}
@@ -634,7 +634,7 @@
console.log('updating recipe', this.shopping_list.recipes[i])
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
let old_id = this.shopping_list.recipes[i].id
console.log("list recipe create respose ", response.body)
console.log("list recipe create response ", response.body)
this.$set(this.shopping_list.recipes, i, response.body)
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
console.log("found recipe updating ID")
@@ -834,7 +834,7 @@
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
for (let s of response.data.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null && i.food.food_onhand === false) {
if (!i.is_header && i.food !== null && !i.food.ignore_food) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,

View File

@@ -498,6 +498,8 @@
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
placeholder="{% trans 'Select one' %}"
tag-placeholder="{% trans 'Select' %}"
label="text"
@@ -536,6 +538,8 @@
:clear-on-select="true"
:allow-empty="false"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
label="text"
track-by="id"
:multiple="false"
@@ -586,6 +590,8 @@
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
placeholder="{% trans 'Select one' %}"
tag-placeholder="{% trans 'Add Keyword' %}"
:taggable="true"
@@ -654,7 +660,8 @@
</div>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% url 'javascript-catalog' %}">
</script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
@@ -693,7 +700,8 @@
import_duplicates: false,
recipe_files: [],
images: [],
mode: 'url'
mode: 'url',
options_limit:25
},
directives: {
tabindex: {
@@ -703,9 +711,9 @@
}
},
mounted: function () {
this.searchKeywords('')
this.searchUnits('')
this.searchIngredients('')
// this.searchKeywords('')
// this.searchUnits('')
// this.searchIngredients('')
let uri = window.location.search.substring(1);
let params = new URLSearchParams(uri);
q = params.get("id")
@@ -884,7 +892,20 @@
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
})
// let apiFactory = new ApiApiFactory()
// this.keywords_loading = true
// apiFactory
// .listKeywords(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.keywords = response.data.results
// this.keywords_loading = false
// })
// .catch((err) => {
// console.log(err)
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
searchUnits: function (query) {
this.units_loading = true
@@ -922,6 +943,29 @@
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
})
// let apiFactory = new ApiApiFactory()
// this.foods_loading = true
// apiFactory
// .listFoods(query, undefined, undefined, 1, this.options_limit)
// .then((response) => {
// this.foods = response.data.results
// if (this.recipe !== undefined) {
// for (let s of this.recipe.steps) {
// for (let i of s.ingredients) {
// if (i.food !== null && i.food.id === undefined) {
// this.foods.push(i.food)
// }
// }
// }
// }
// this.foods_loading = false
// })
// .catch((err) => {
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
// })
},
deleteNode: function (node, item, e) {
e.stopPropagation()

View File

@@ -200,7 +200,7 @@ def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
food = Food.objects.get(id=ingredients[2].food.id)
food.onhand_users.add(user)
food.save()
food = recipe.steps.filter(type=Step.RECIPE).first().step_recipe.steps.first().ingredients.first().food
food = recipe.steps.exclude(step_recipe=None).first().step_recipe.steps.first().ingredients.first().food
food = Food.objects.get(id=food.id)
food.onhand_users.add(user)
food.save()

View File

@@ -269,10 +269,8 @@ class StepFactory(factory.django.DjangoModelFactory):
return
if kwargs.get('has_recipe', False):
self.step_recipe = RecipeFactory(space=self.space)
self.type = Step.RECIPE
elif extracted:
self.step_recipe = extracted
self.type = Step.RECIPE
@factory.post_generation
def ingredients(self, create, extracted, **kwargs):

View File

@@ -12,8 +12,10 @@ from django.contrib.auth.models import User
from django.contrib.postgres.search import TrigramSimilarity
from django.core.exceptions import FieldError, ValidationError
from django.core.files import File
from django.db.models import Case, ProtectedError, Q, Value, When
from django.db.models import (Case, Count, Exists, F, IntegerField, OuterRef, ProtectedError, Q,
Subquery, Value, When)
from django.db.models.fields.related import ForeignObjectRel
from django.db.models.functions import Coalesce
from django.http import FileResponse, HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
@@ -30,13 +32,14 @@ from rest_framework.response import Response
from rest_framework.viewsets import ViewSetMixin
from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner,
CustomIsShare, CustomIsShared, CustomIsUser,
group_required)
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch, old_search
from cookbook.helper.recipe_url_import import get_from_scraper
from cookbook.helper.shopping_helper import list_from_recipe, shopping_helper
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, FoodInheritField,
@@ -100,7 +103,38 @@ class DefaultPagination(PageNumberPagination):
max_page_size = 200
class FuzzyFilterMixin(ViewSetMixin):
class ExtendedRecipeMixin():
'''
ExtendedRecipe annotates a queryset with recipe_image and recipe_count values
'''
@classmethod
def annotate_recipe(self, queryset=None, request=None, serializer=None, tree=False):
extended = str2bool(request.query_params.get('extended', None))
if extended:
recipe_filter = serializer.recipe_filter
images = serializer.images
space = request.space
# add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count')
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
if tree:
image_children_subquery = Recipe.objects.filter(**{f"{recipe_filter}__path__startswith": OuterRef('path')},
space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
else:
image_children_subquery = None
if images:
queryset = queryset.annotate(recipe_image=Coalesce(*images, image_subquery, image_children_subquery))
else:
queryset = queryset.annotate(recipe_image=Coalesce(image_subquery, image_children_subquery))
return queryset
class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
schema = FilterSchema()
def get_queryset(self):
@@ -112,18 +146,18 @@ class FuzzyFilterMixin(ViewSetMixin):
if fuzzy:
self.queryset = (
self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2)
.order_by('-exact', '-trigram')
.annotate(starts=Case(When(name__istartswith=query, then=(Value(.3, output_field=IntegerField()))), default=Value(0)))
.annotate(trigram=TrigramSimilarity('name', query))
.annotate(sort=F('starts')+F('trigram'))
.order_by('-sort')
)
else:
# TODO have this check unaccent search settings or other search preferences?
self.queryset = (
self.queryset
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-exact', 'name')
.annotate(starts=Case(When(name__istartswith=query, then=(Value(100))),
default=Value(0))) # put exact matches at the top of the result set
.filter(name__icontains=query).order_by('-starts', 'name')
)
updated_at = self.request.query_params.get('updated_at', None)
@@ -141,12 +175,12 @@ class FuzzyFilterMixin(ViewSetMixin):
if random:
self.queryset = self.queryset.order_by("?")
self.queryset = self.queryset[:int(limit)]
return self.queryset
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class)
class MergeMixin(ViewSetMixin):
@decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@ decorators.action(detail=True, url_path='merge/(?P<target>[^/.]+)', methods=['PUT'], )
@ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def merge(self, request, pk, target):
self.description = f"Merge {self.basename} onto target {self.basename} with ID of [int]."
@@ -211,7 +245,7 @@ class MergeMixin(ViewSetMixin):
return Response(content, status=status.HTTP_400_BAD_REQUEST)
class TreeMixin(MergeMixin, FuzzyFilterMixin):
class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
schema = TreeSchema()
model = None
@@ -237,11 +271,13 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
else:
return super().get_queryset()
return self.queryset.filter(space=self.request.space).order_by('name')
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True)
self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
@decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True)
@ decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@ decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
def move(self, request, pk, parent):
self.description = f"Move {self.basename} to be a child of {self.basename} with ID of [int]. Use ID: 0 to move {self.basename} to the root."
if self.model.node_order_by:
@@ -364,7 +400,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
permission_classes = [CustomIsUser]
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space)
self.queryset = self.queryset.filter(space=self.request.space).order_by('name')
return super().get_queryset()
@@ -413,7 +449,15 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
def get_queryset(self):
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id]
self.queryset = super().get_queryset()
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id')
# onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users]))
return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users', 'inherit_fields').select_related('recipe', 'supermarket_category')
@ decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer,)
# TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
def shopping(self, request, pk):
if self.request.space.demo:
@@ -561,7 +605,9 @@ class RecipePagination(PageNumberPagination):
max_page_size = 100
def paginate_queryset(self, queryset, request, view=None):
self.facets = get_facet(qs=queryset, request=request)
if queryset is None:
raise Exception
self.facets = RecipeFacet(request, queryset=queryset)
return super().paginate_queryset(queryset, request, view)
def get_paginated_response(self, data):
@@ -570,7 +616,7 @@ class RecipePagination(PageNumberPagination):
('next', self.get_next_link()),
('previous', self.get_previous_link()),
('results', data),
('facets', self.facets)
('facets', self.facets.get_facets(from_cache=True))
]))
@@ -607,9 +653,11 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
return super().get_queryset()
# self.queryset = search_recipes(self.request, self.queryset, self.request.GET)
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset
def list(self, request, *args, **kwargs):
if self.request.GET.get('debug', False):
@@ -1051,7 +1099,7 @@ def recipe_from_source(request):
return JsonResponse(
{
'error': True,
'msg': _('No useable data could be found.')
'msg': _('No usable data could be found.')
},
status=400
)
@@ -1100,10 +1148,20 @@ def ingredient_from_string(request):
@group_required('user')
def get_facets(request):
key = request.GET.get('hash', None)
food = request.GET.get('food', None)
keyword = request.GET.get('keyword', None)
facets = RecipeFacet(request, hash_key=key)
if food:
results = facets.add_food_children(food)
elif keyword:
results = facets.add_keyword_children(keyword)
else:
results = facets.get_facets()
return JsonResponse(
{
'facets': get_facet(request=request, use_cache=False, hash_key=key),
'facets': results,
},
status=200
)

View File

@@ -45,21 +45,15 @@ def hook(request, token):
tb.save()
if tb.chat_id == str(data['message']['chat']['id']):
sl = ShoppingList.objects.filter(Q(created_by=tb.created_by)).filter(finished=False, space=tb.space).order_by('-created_at').first()
if not sl:
sl = ShoppingList.objects.create(created_by=tb.created_by, space=tb.space)
request.space = tb.space # TODO this is likely a bad idea. Verify and test
request.user = tb.created_by
ingredient_parser = IngredientParser(request, False)
amount, unit, ingredient, note = ingredient_parser.parse(data['message']['text'])
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
sl.entries.add(
ShoppingListEntry.objects.create(
food=f, unit=u, amount=amount
)
)
ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space)
return JsonResponse({'data': data['message']['text']})
except Exception:
pass

View File

@@ -411,7 +411,7 @@ def user_settings(request):
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
# these fields require postgress - just disable them if postgress isn't available
# these fields require postgresql - just disable them if postgresql isn't available
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
'django.db.backends.postgresql']:
search_form.fields['search'].disabled = True
@@ -567,6 +567,8 @@ def space(request):
form = SpacePreferenceForm(request.POST, prefix='space')
if form.is_valid():
request.space.food_inherit.set(form.cleaned_data['food_inherit'])
request.space.show_facet_count = form.cleaned_data['show_facet_count']
request.space.save()
if form.cleaned_data['reset_food_inherit']:
Food.reset_inheritance(space=request.space)