mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-06 06:38:03 -05:00
Merge branch 'feature/keywords-rework' into feature/fulltext-search
# Conflicts: # cookbook/admin.py # cookbook/helper/recipe_search.py # cookbook/models.py # cookbook/static/vue/js/import_response_view.js # cookbook/static/vue/js/offline_view.js # cookbook/static/vue/js/recipe_search_view.js # cookbook/static/vue/js/recipe_view.js # cookbook/static/vue/js/supermarket_view.js # cookbook/templates/sw.js # cookbook/views/api.py # cookbook/views/views.py # vue/src/locales/en.json # vue/webpack-stats.json # vue/yarn.lock
This commit is contained in:
@@ -14,19 +14,22 @@ from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.files import File
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||
from django_scopes import scopes_disabled
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from icalendar import Calendar, Event
|
||||
from recipe_scrapers import scrape_me, WebsiteNotImplementedError, NoSchemaFoundInWildMode
|
||||
from rest_framework import decorators, viewsets
|
||||
from rest_framework import decorators, status, viewsets
|
||||
from rest_framework.exceptions import APIException, PermissionDenied
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.schemas.openapi import AutoSchema
|
||||
from rest_framework.schemas.utils import is_list_view
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
from treebeard.exceptions import PathOverflow, InvalidMoveToDescendant, InvalidPosition
|
||||
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest,
|
||||
CustomIsOwner, CustomIsShare,
|
||||
@@ -40,10 +43,11 @@ from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
|
||||
MealType, Recipe, RecipeBook, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Step,
|
||||
Storage, Sync, SyncLog, Unit, UserPreference,
|
||||
ViewLog, RecipeBookEntry, Supermarket, ImportLog, BookmarkletImport, SupermarketCategory)
|
||||
ViewLog, RecipeBookEntry, Supermarket, ImportLog, BookmarkletImport, SupermarketCategory, UserFile, ShareLink)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
from cookbook.schemas import RecipeSchema, TreeSchema
|
||||
from cookbook.serializer import (FoodSerializer, IngredientSerializer,
|
||||
KeywordSerializer, MealPlanSerializer,
|
||||
MealTypeSerializer, RecipeBookSerializer,
|
||||
@@ -57,7 +61,7 @@ from cookbook.serializer import (FoodSerializer, IngredientSerializer,
|
||||
UserNameSerializer, UserPreferenceSerializer,
|
||||
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer,
|
||||
RecipeOverviewSerializer, SupermarketSerializer, ImportLogSerializer,
|
||||
BookmarkletImportSerializer, SupermarketCategorySerializer)
|
||||
BookmarkletImportSerializer, SupermarketCategorySerializer, UserFileSerializer)
|
||||
|
||||
|
||||
class StandardFilterMixin(ViewSetMixin):
|
||||
@@ -87,24 +91,30 @@ class StandardFilterMixin(ViewSetMixin):
|
||||
return queryset
|
||||
|
||||
|
||||
class DefaultPagination(PageNumberPagination):
|
||||
page_size = 50
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 200
|
||||
|
||||
|
||||
class FuzzyFilterMixin(ViewSetMixin):
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
query = self.request.query_params.get('query', None)
|
||||
fuzzy = self.request.user.searchpreference.lookup
|
||||
|
||||
if query is not None and query != '':
|
||||
if query is not None and query not in ["''", '']:
|
||||
if fuzzy:
|
||||
queryset = queryset.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2).order_by("-trigram")
|
||||
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2).order_by("-trigram")
|
||||
else:
|
||||
# TODO have this check unaccent search settings?
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
self.queryset = self.queryset.filter(name__icontains=query)
|
||||
|
||||
updated_at = self.request.query_params.get('updated_at', None)
|
||||
if updated_at is not None:
|
||||
try:
|
||||
queryset = queryset.filter(updated_at__gte=updated_at)
|
||||
self.queryset = self.queryset.filter(updated_at__gte=updated_at)
|
||||
except FieldError:
|
||||
pass
|
||||
except ValidationError:
|
||||
@@ -114,9 +124,121 @@ class FuzzyFilterMixin(ViewSetMixin):
|
||||
random = self.request.query_params.get('random', False)
|
||||
if limit is not None:
|
||||
if random:
|
||||
queryset = queryset.order_by("?")
|
||||
queryset = queryset[:int(limit)]
|
||||
return queryset
|
||||
self.queryset = self.queryset.order_by("?")
|
||||
self.queryset = self.queryset[:int(limit)]
|
||||
return self.queryset
|
||||
|
||||
|
||||
class TreeMixin(FuzzyFilterMixin):
|
||||
model = None
|
||||
schema = TreeSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
root = self.request.query_params.get('root', None)
|
||||
tree = self.request.query_params.get('tree', None)
|
||||
|
||||
if root:
|
||||
if root.isnumeric():
|
||||
try:
|
||||
root = int(root)
|
||||
except self.model.DoesNotExist:
|
||||
self.queryset = self.model.objects.none()
|
||||
if root == 0:
|
||||
self.queryset = self.model.get_root_nodes().filter(space=self.request.space)
|
||||
else:
|
||||
self.queryset = self.model.objects.get(id=root).get_children().filter(space=self.request.space)
|
||||
elif tree:
|
||||
if tree.isnumeric():
|
||||
try:
|
||||
self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self().filter(space=self.request.space)
|
||||
except Keyword.DoesNotExist:
|
||||
self.queryset = self.model.objects.none()
|
||||
else:
|
||||
return super().get_queryset()
|
||||
return self.queryset
|
||||
|
||||
@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."
|
||||
|
||||
try:
|
||||
child = self.model.objects.get(pk=pk, space=self.request.space)
|
||||
except (self.model.DoesNotExist):
|
||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {child} exists')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
parent = int(parent)
|
||||
# parent 0 is root of the tree
|
||||
if parent == 0:
|
||||
try:
|
||||
with scopes_disabled():
|
||||
child.move(self.model.get_first_root_node(), 'sorted-sibling')
|
||||
content = {'msg': _(f'{child.name} was moved successfully to the root.')}
|
||||
return Response(content, status=status.HTTP_200_OK)
|
||||
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
|
||||
content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
parent = self.model.objects.get(pk=parent, space=self.request.space)
|
||||
except (self.model.DoesNotExist):
|
||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {parent} exists')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
with scopes_disabled():
|
||||
child.move(parent, 'sorted-child')
|
||||
content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')}
|
||||
return Response(content, status=status.HTTP_200_OK)
|
||||
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
|
||||
content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@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]."
|
||||
|
||||
try:
|
||||
source = self.model.objects.get(pk=pk, space=self.request.space)
|
||||
except (self.model.DoesNotExist):
|
||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if int(target) == source.id:
|
||||
content = {'error': True, 'msg': _('Cannot merge with the same object!')}
|
||||
return Response(content, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
else:
|
||||
try:
|
||||
target = self.model.objects.get(pk=target, space=self.request.space)
|
||||
except (self.model.DoesNotExist):
|
||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {target} exists')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
if target in source.get_descendants_and_self():
|
||||
content = {'error': True, 'msg': _('Cannot merge with child object!')}
|
||||
return Response(content, status=status.HTTP_403_FORBIDDEN)
|
||||
########################################################################
|
||||
# this needs abstracted to update steps instead of recipes for food merge
|
||||
########################################################################
|
||||
recipes = Recipe.objects.filter(**{"%ss" % self.basename: source}, space=self.request.space)
|
||||
|
||||
for r in recipes:
|
||||
getattr(r, self.basename + 's').add(target)
|
||||
getattr(r, self.basename + 's').remove(source)
|
||||
r.save()
|
||||
children = source.get_children().exclude(id=target.id)
|
||||
for c in children:
|
||||
c.move(target, 'sorted-child')
|
||||
content = {'msg': _(f'{source.name} was merged successfully with {target.name}')}
|
||||
source.delete()
|
||||
return Response(content, status=status.HTTP_200_OK)
|
||||
except Exception:
|
||||
content = {'error': True, 'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class UserNameViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
@@ -200,22 +322,14 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
class KeywordViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
|
||||
"""
|
||||
list:
|
||||
optional parameters
|
||||
class KeywordViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
# TODO check if fuzzyfilter is conflicting - may also need to create 'tree filter' mixin
|
||||
|
||||
- **query**: search keywords for a string contained
|
||||
in the keyword name (case in-sensitive)
|
||||
- **limit**: limits the amount of returned results
|
||||
"""
|
||||
queryset = Keyword.objects
|
||||
model = Keyword
|
||||
serializer_class = KeywordSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
return super().get_queryset()
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
|
||||
class UnitViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
|
||||
@@ -325,68 +439,11 @@ class RecipePagination(PageNumberPagination):
|
||||
max_page_size = 100
|
||||
|
||||
|
||||
# TODO move to separate class to cleanup
|
||||
class RecipeSchema(AutoSchema):
|
||||
|
||||
def get_path_parameters(self, path, method):
|
||||
if not is_list_view(path, method, self.view):
|
||||
return super(RecipeSchema, self).get_path_parameters(path, method)
|
||||
|
||||
parameters = super().get_path_parameters(path, method)
|
||||
parameters.append({
|
||||
"name": 'query', "in": "query", "required": False,
|
||||
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'keywords', "in": "query", "required": False,
|
||||
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'foods', "in": "query", "required": False,
|
||||
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'books', "in": "query", "required": False,
|
||||
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'keywords_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'foods_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'books_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'internal', "in": "query", "required": False,
|
||||
"description": 'true or false. If only internal recipes should be returned or not.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'random', "in": "query", "required": False,
|
||||
"description": 'true or false. returns the results in randomized order.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
return parameters
|
||||
|
||||
|
||||
class RecipeViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recipe.objects
|
||||
serializer_class = RecipeSerializer
|
||||
# TODO split read and write permission for meal plan guest
|
||||
permission_classes = [CustomIsShare | CustomIsGuest]
|
||||
|
||||
pagination_class = RecipePagination
|
||||
|
||||
schema = RecipeSchema()
|
||||
@@ -427,16 +484,9 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
img = Image.open(obj.image)
|
||||
|
||||
basewidth = 720
|
||||
wpercent = (basewidth / float(img.size[0]))
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
|
||||
im_io = io.BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
obj.image = File(im_io, name=f'{uuid.uuid4()}_{obj.pk}.png')
|
||||
img, filetype = handle_image(request, obj.image)
|
||||
obj.image = File(img, name=f'{uuid.uuid4()}_{obj.pk}{filetype}')
|
||||
obj.save()
|
||||
|
||||
return Response(serializer.data)
|
||||
@@ -528,8 +578,18 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet):
|
||||
return self.queryset.filter(space=self.request.space).all()
|
||||
|
||||
|
||||
# -------------- non django rest api views --------------------
|
||||
class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = UserFile.objects
|
||||
serializer_class = UserFileSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space).all()
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
# -------------- non django rest api views --------------------
|
||||
def get_recipe_provider(recipe):
|
||||
if recipe.storage.method == Storage.DROPBOX:
|
||||
return Dropbox
|
||||
@@ -604,6 +664,16 @@ def sync_all(request):
|
||||
return redirect('list_recipe_import')
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def share_link(request, pk):
|
||||
if request.space.allow_sharing:
|
||||
recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
|
||||
link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space)
|
||||
return JsonResponse({'pk': pk, 'share': link.uuid, 'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
|
||||
else:
|
||||
return JsonResponse({'error': 'sharing_disabled'}, status=403)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@ajax_request
|
||||
def log_cooking(request, recipe_id):
|
||||
|
||||
@@ -17,6 +17,7 @@ from PIL import Image, UnidentifiedImageError
|
||||
from requests.exceptions import MissingSchema
|
||||
|
||||
from cookbook.forms import BatchEditForm, SyncForm
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
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,
|
||||
@@ -142,22 +143,22 @@ def import_url(request):
|
||||
)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=data['recipeInstructions'],
|
||||
instruction=data['recipeInstructions'], space=request.space,
|
||||
)
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
all_keywords = Keyword.get_tree()
|
||||
for kw in data['keywords']:
|
||||
# if k := Keyword.objects.filter(name=kw['text'], space=request.space).first():
|
||||
# recipe.keywords.add(k)
|
||||
# elif data['all_keywords']:
|
||||
# k = Keyword.objects.create(name=kw['text'], space=request.space)
|
||||
# recipe.keywords.add(k)
|
||||
k, created = Keyword.objects.get_or_create(name=kw['text'].strip(), space=request.space)
|
||||
recipe.keywords.add(k)
|
||||
q = all_keywords.filter(name=kw['text'], space=request.space)
|
||||
if len(q) != 0:
|
||||
recipe.keywords.add(q[0])
|
||||
elif data['all_keywords']:
|
||||
k = Keyword.add_root(name=kw['text'], space=request.space)
|
||||
recipe.keywords.add(k)
|
||||
|
||||
for ing in data['recipeIngredient']:
|
||||
ingredient = Ingredient()
|
||||
ingredient = Ingredient(space=request.space,)
|
||||
|
||||
if ing['ingredient']['text'] != '':
|
||||
ingredient.food, f_created = Food.objects.get_or_create(
|
||||
@@ -188,23 +189,20 @@ def import_url(request):
|
||||
if 'image' in data and data['image'] != '' and data['image'] is not None:
|
||||
try:
|
||||
response = requests.get(data['image'])
|
||||
img = Image.open(BytesIO(response.content))
|
||||
|
||||
# todo move image processing to dedicated function
|
||||
basewidth = 720
|
||||
wpercent = (basewidth / float(img.size[0]))
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
img = img.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
img, filetype = handle_image(request, BytesIO(response.content))
|
||||
recipe.image = File(
|
||||
im_io, name=f'{uuid.uuid4()}_{recipe.pk}.png'
|
||||
img, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}'
|
||||
)
|
||||
recipe.save()
|
||||
except UnidentifiedImageError:
|
||||
except UnidentifiedImageError as e:
|
||||
print(e)
|
||||
pass
|
||||
except MissingSchema:
|
||||
except MissingSchema as e:
|
||||
print(e)
|
||||
pass
|
||||
except Exception as e:
|
||||
print(e)
|
||||
pass
|
||||
|
||||
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import UpdateView
|
||||
from django.views.generic.edit import FormMixin
|
||||
@@ -45,7 +46,7 @@ def convert_recipe(request, pk):
|
||||
|
||||
@group_required('user')
|
||||
def internal_recipe_update(request, pk):
|
||||
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() > request.space.max_recipes: # TODO move to central helper function
|
||||
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() > request.space.max_recipes: # TODO move to central helper function
|
||||
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
|
||||
return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
|
||||
|
||||
@@ -94,7 +95,7 @@ class KeywordUpdate(GroupRequiredMixin, UpdateView):
|
||||
# TODO add msg box
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('edit_keyword', kwargs={'pk': self.object.pk})
|
||||
return reverse('list_keyword')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
@@ -287,14 +288,15 @@ def edit_ingredients(request):
|
||||
new_unit = units_form.cleaned_data['new_unit']
|
||||
old_unit = units_form.cleaned_data['old_unit']
|
||||
if new_unit != old_unit:
|
||||
recipe_ingredients = Ingredient.objects.filter(unit=old_unit, step__recipe__space=request.space).all()
|
||||
for i in recipe_ingredients:
|
||||
i.unit = new_unit
|
||||
i.save()
|
||||
with scopes_disabled():
|
||||
recipe_ingredients = Ingredient.objects.filter(unit=old_unit).filter(Q(step__recipe__space=request.space) | Q(step__recipe__isnull=True)).all()
|
||||
for i in recipe_ingredients:
|
||||
i.unit = new_unit
|
||||
i.save()
|
||||
|
||||
old_unit.delete()
|
||||
success = True
|
||||
messages.add_message(request, messages.SUCCESS, _('Units merged!'))
|
||||
old_unit.delete()
|
||||
success = True
|
||||
messages.add_message(request, messages.SUCCESS, _('Units merged!'))
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR, _('Cannot merge with the same object!'))
|
||||
|
||||
@@ -303,7 +305,7 @@ def edit_ingredients(request):
|
||||
new_food = food_form.cleaned_data['new_food']
|
||||
old_food = food_form.cleaned_data['old_food']
|
||||
if new_food != old_food:
|
||||
ingredients = Ingredient.objects.filter(food=old_food, step__recipe__space=request.space).all()
|
||||
ingredients = Ingredient.objects.filter(food=old_food).filter(Q(step__recipe__space=request.space) | Q(step__recipe__isnull=True)).all()
|
||||
for i in ingredients:
|
||||
i.food = new_food
|
||||
i.save()
|
||||
|
||||
@@ -18,7 +18,9 @@ from cookbook.integration.domestica import Domestica
|
||||
from cookbook.integration.mealie import Mealie
|
||||
from cookbook.integration.mealmaster import MealMaster
|
||||
from cookbook.integration.nextcloud_cookbook import NextcloudCookbook
|
||||
from cookbook.integration.openeats import OpenEats
|
||||
from cookbook.integration.paprika import Paprika
|
||||
from cookbook.integration.recipekeeper import RecipeKeeper
|
||||
from cookbook.integration.recettetek import RecetteTek
|
||||
from cookbook.integration.recipesage import RecipeSage
|
||||
from cookbook.integration.rezkonv import RezKonv
|
||||
@@ -45,6 +47,8 @@ def get_integration(request, export_type):
|
||||
return Pepperplate(request, export_type)
|
||||
if export_type == ImportExportBase.DOMESTICA:
|
||||
return Domestica(request, export_type)
|
||||
if export_type == ImportExportBase.RECIPEKEEPER:
|
||||
return RecipeKeeper(request, export_type)
|
||||
if export_type == ImportExportBase.RECETTETEK:
|
||||
return RecetteTek(request, export_type)
|
||||
if export_type == ImportExportBase.RECIPESAGE:
|
||||
@@ -53,6 +57,8 @@ def get_integration(request, export_type):
|
||||
return RezKonv(request, export_type)
|
||||
if export_type == ImportExportBase.MEALMASTER:
|
||||
return MealMaster(request, export_type)
|
||||
if export_type == ImportExportBase.OPENEATS:
|
||||
return OpenEats(request, export_type)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db.models.functions import Lower
|
||||
from django.shortcuts import render
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from cookbook.filters import FoodFilter, ShoppingListFilter
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import (Food, InviteLink, Keyword, RecipeImport,
|
||||
from cookbook.models import (Food, InviteLink, RecipeImport,
|
||||
ShoppingList, Storage, SyncLog)
|
||||
from cookbook.tables import (ImportLogTable, IngredientTable, InviteLinkTable,
|
||||
KeywordTable, RecipeImportTable,
|
||||
ShoppingListTable, StorageTable)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def keyword(request):
|
||||
table = KeywordTable(Keyword.objects.filter(space=request.space).all())
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'generic/list_template.html',
|
||||
{'title': _("Keyword"), 'table': table, 'create_url': 'new_keyword'}
|
||||
)
|
||||
RecipeImportTable, ShoppingListTable, StorageTable)
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
@@ -71,8 +57,8 @@ def food(request):
|
||||
@group_required('user')
|
||||
def shopping_list(request):
|
||||
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(
|
||||
Q(created_by=request.user) |
|
||||
Q(shared=request.user), space=request.space
|
||||
Q(created_by=request.user)
|
||||
| Q(shared=request.user), space=request.space
|
||||
).all().order_by('finished', 'created_at'))
|
||||
|
||||
table = ShoppingListTable(f.qs)
|
||||
|
||||
@@ -28,7 +28,7 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
|
||||
fields = ('name',)
|
||||
|
||||
def form_valid(self, form):
|
||||
if self.request.space.max_recipes != 0 and Recipe.objects.filter(space=self.request.space).count() >= self.request.space.max_recipes: # TODO move to central helper function
|
||||
if self.request.space.max_recipes != 0 and Recipe.objects.filter(space=self.request.space).count() >= self.request.space.max_recipes: # TODO move to central helper function
|
||||
messages.add_message(self.request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
@@ -41,7 +41,7 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
|
||||
obj.space = self.request.space
|
||||
obj.internal = True
|
||||
obj.save()
|
||||
obj.steps.add(Step.objects.create())
|
||||
obj.steps.add(Step.objects.create(space=self.request.space))
|
||||
return HttpResponseRedirect(reverse('edit_recipe', kwargs={'pk': obj.pk}))
|
||||
|
||||
def get_success_url(self):
|
||||
@@ -68,10 +68,9 @@ class KeywordCreate(GroupRequiredMixin, CreateView):
|
||||
success_url = reverse_lazy('list_keyword')
|
||||
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
obj.space = self.request.space
|
||||
obj.save()
|
||||
return HttpResponseRedirect(reverse('edit_keyword', kwargs={'pk': obj.pk}))
|
||||
form.cleaned_data['space'] = self.request.space
|
||||
form.save()
|
||||
return HttpResponseRedirect(reverse('list_keyword'))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(KeywordCreate, self).get_context_data(**kwargs)
|
||||
@@ -225,7 +224,7 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
|
||||
if InviteLink.objects.filter(space=self.request.space, created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
|
||||
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(self.request.user.username)
|
||||
message += _(' to join their Tandoor Recipes space ') + escape(self.request.space.name) + '.\n\n'
|
||||
message += _('Click the following link to activate your account: ') + self.request.build_absolute_uri(reverse('view_signup', args=[str(obj.uuid)])) + '\n\n'
|
||||
message += _('Click the following link to activate your account: ') + self.request.build_absolute_uri(reverse('view_invite', args=[str(obj.uuid)])) + '\n\n'
|
||||
message += _('If the link does not work use the following code to manually join the space: ') + str(obj.uuid) + '\n\n'
|
||||
message += _('The invitation is valid until ') + str(obj.valid_until) + '\n\n'
|
||||
message += _('Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub ') + 'https://github.com/vabene1111/recipes/'
|
||||
@@ -245,7 +244,7 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
|
||||
except (SMTPException, BadHeaderError, TimeoutError):
|
||||
messages.add_message(self.request, messages.ERROR, _('Email to user could not be send, please share link manually.'))
|
||||
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
return HttpResponseRedirect(reverse('view_space'))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(InviteLinkCreate, self).get_context_data(**kwargs)
|
||||
|
||||
8
cookbook/views/trees.py
Normal file
8
cookbook/views/trees.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def keyword(request):
|
||||
return render(request, 'generic/tree_template.html', {})
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from allauth.account.forms import SignupForm
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
@@ -11,6 +12,8 @@ from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Avg, Q, Sum
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.db.models import Avg, Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
@@ -25,13 +28,16 @@ from cookbook.filters import RecipeFilter
|
||||
from cookbook.forms import (CommentForm, Recipe, User,
|
||||
UserCreateForm, UserNameForm, UserPreference,
|
||||
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
|
||||
SearchPreferenceForm)
|
||||
SearchPreferenceForm, AllAuthSignupForm,
|
||||
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm, AllAuthSignupForm, SearchPreferenceForm)
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
|
||||
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
|
||||
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
|
||||
RecipeBook, RecipeBookEntry, ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
|
||||
Food)
|
||||
Food, UserFile, ShareLink)
|
||||
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
|
||||
ViewLogTable)
|
||||
ViewLogTable, InviteLinkTable)
|
||||
from cookbook.views.data import Object
|
||||
from recipes.version import BUILD_REF, VERSION_NUMBER
|
||||
|
||||
@@ -55,6 +61,9 @@ def index(request):
|
||||
return HttpResponseRedirect(reverse('view_search'))
|
||||
|
||||
|
||||
# faceting
|
||||
# unaccent / likely will perform full table scan
|
||||
# create tests
|
||||
def search(request):
|
||||
if has_group_permission(request.user, ('guest',)):
|
||||
if request.user.userpreference.search_style == UserPreference.NEW:
|
||||
@@ -91,6 +100,7 @@ def search(request):
|
||||
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
def search_v2(request):
|
||||
return render(request, 'search.html', {})
|
||||
|
||||
@@ -111,19 +121,21 @@ def no_space(request):
|
||||
created_space = Space.objects.create(
|
||||
name=create_form.cleaned_data['name'],
|
||||
created_by=request.user,
|
||||
allow_files=settings.SPACE_DEFAULT_FILES,
|
||||
max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
|
||||
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
|
||||
max_users=settings.SPACE_DEFAULT_MAX_USERS,
|
||||
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
|
||||
)
|
||||
request.user.userpreference.space = created_space
|
||||
request.user.userpreference.save()
|
||||
request.user.groups.add(Group.objects.filter(name='admin').get())
|
||||
|
||||
messages.add_message(request, messages.SUCCESS, _('You have successfully created your own recipe space. Start by adding some recipes or invite other people to join you.'))
|
||||
messages.add_message(request, messages.SUCCESS,
|
||||
_('You have successfully created your own recipe space. Start by adding some recipes or invite other people to join you.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if join_form.is_valid():
|
||||
return HttpResponseRedirect(reverse('view_signup', args=[join_form.cleaned_data['token']]))
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[join_form.cleaned_data['token']]))
|
||||
else:
|
||||
if settings.SOCIAL_DEFAULT_ACCESS:
|
||||
request.user.userpreference.space = Space.objects.first()
|
||||
@@ -131,7 +143,7 @@ def no_space(request):
|
||||
request.user.groups.add(Group.objects.get(name=settings.SOCIAL_DEFAULT_GROUP))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
if 'signup_token' in request.session:
|
||||
return HttpResponseRedirect(reverse('view_signup', args=[request.session.pop('signup_token', '')]))
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
|
||||
|
||||
create_form = SpaceCreateForm()
|
||||
join_form = SpaceJoinForm()
|
||||
@@ -233,6 +245,17 @@ def supermarket(request):
|
||||
return render(request, 'supermarket.html', {})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def files(request):
|
||||
try:
|
||||
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
|
||||
'file_size_kb__sum'] / 1000
|
||||
except TypeError:
|
||||
current_file_size_mb = 0
|
||||
return render(request, 'files.html',
|
||||
{'current_file_size_mb': current_file_size_mb, 'max_file_size_mb': request.space.max_file_storage_mb})
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def meal_plan_entry(request, pk):
|
||||
plan = MealPlan.objects.filter(space=request.space).get(pk=pk)
|
||||
@@ -291,8 +314,6 @@ def user_settings(request):
|
||||
active_tab = 'account'
|
||||
|
||||
user_name_form = UserNameForm(instance=request.user)
|
||||
password_form = PasswordChangeForm(request.user)
|
||||
password_form.fields['old_password'].widget.attrs.pop("autofocus", None)
|
||||
|
||||
if request.method == "POST":
|
||||
if 'preference_form' in request.POST:
|
||||
@@ -388,7 +409,6 @@ def user_settings(request):
|
||||
return render(request, 'settings.html', {
|
||||
'preference_form': preference_form,
|
||||
'user_name_form': user_name_form,
|
||||
'password_form': password_form,
|
||||
'api_token': api_token,
|
||||
'search_form': search_form,
|
||||
'active_tab': active_tab
|
||||
@@ -467,7 +487,7 @@ def setup(request):
|
||||
return render(request, 'setup.html', {'form': form})
|
||||
|
||||
|
||||
def signup(request, token):
|
||||
def invite_link(request, token):
|
||||
with scopes_disabled():
|
||||
try:
|
||||
token = UUID(token, version=4)
|
||||
@@ -478,7 +498,8 @@ def signup(request, token):
|
||||
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
|
||||
if request.user.is_authenticated:
|
||||
if request.user.userpreference.space:
|
||||
messages.add_message(request, messages.WARNING, _('You are already member of a space and therefore cannot join this one.'))
|
||||
messages.add_message(request, messages.WARNING,
|
||||
_('You are already member of a space and therefore cannot join this one.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
link.used_by = request.user
|
||||
@@ -492,48 +513,16 @@ def signup(request, token):
|
||||
messages.add_message(request, messages.SUCCESS, _('Successfully joined space.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
else:
|
||||
request.session['signup_token'] = token
|
||||
request.session['signup_token'] = str(token)
|
||||
return HttpResponseRedirect(reverse('account_signup'))
|
||||
|
||||
if request.method == 'POST':
|
||||
updated_request = request.POST.copy()
|
||||
if link.username != '':
|
||||
updated_request.update({'name': link.username})
|
||||
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
form = UserCreateForm(updated_request)
|
||||
|
||||
if form.is_valid():
|
||||
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']: # noqa: E501
|
||||
form.add_error('password', _('Passwords dont match!'))
|
||||
else:
|
||||
user = User(username=form.cleaned_data['name'], )
|
||||
try:
|
||||
validate_password(form.cleaned_data['password'], user=user)
|
||||
user.set_password(form.cleaned_data['password'])
|
||||
user.save()
|
||||
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
|
||||
|
||||
link.used_by = user
|
||||
link.save()
|
||||
|
||||
request.user.groups.clear()
|
||||
user.groups.add(link.group)
|
||||
|
||||
user.userpreference.space = link.space
|
||||
user.userpreference.save()
|
||||
return HttpResponseRedirect(reverse('account_login'))
|
||||
except ValidationError as e:
|
||||
for m in e:
|
||||
form.add_error('password', m)
|
||||
else:
|
||||
form = UserCreateForm()
|
||||
|
||||
if link.username != '':
|
||||
form.fields['name'].initial = link.username
|
||||
form.fields['name'].disabled = True
|
||||
return render(request, 'account/signup.html', {'form': form, 'link': link})
|
||||
|
||||
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
# TODO deprecated with 0.16.2 remove at some point
|
||||
def signup(request, token):
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[token]))
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
@@ -553,7 +542,11 @@ def space(request):
|
||||
|
||||
counts.recipes_no_keyword = Recipe.objects.filter(keywords=None, space=request.space).count()
|
||||
|
||||
return render(request, 'space.html', {'space_users': space_users, 'counts': counts})
|
||||
invite_links = InviteLinkTable(
|
||||
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})
|
||||
|
||||
|
||||
# TODO super hacky and quick solution, safe but needs rework
|
||||
@@ -584,6 +577,19 @@ def space_change_member(request, user_id, space_id, group):
|
||||
return HttpResponseRedirect(reverse('view_space'))
|
||||
|
||||
|
||||
def report_share_abuse(request, token):
|
||||
if not settings.SHARING_ABUSE:
|
||||
messages.add_message(request, messages.WARNING,
|
||||
_('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.'))
|
||||
else:
|
||||
if link := ShareLink.objects.filter(uuid=token).first():
|
||||
link.abuse_blocked = True
|
||||
link.save()
|
||||
messages.add_message(request, messages.WARNING,
|
||||
_('Recipe sharing link has been disabled! For additional information please contact the page administrator.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
||||
def markdown_info(request):
|
||||
return render(request, 'markdown_info.html', {})
|
||||
|
||||
@@ -604,6 +610,7 @@ def offline(request):
|
||||
def test(request):
|
||||
if not settings.DEBUG:
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
return JsonResponse(parse('Pane (raffermo o secco) 80 g'), safe=False)
|
||||
|
||||
|
||||
def test2(request):
|
||||
|
||||
Reference in New Issue
Block a user