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:
vabene1111
2021-06-30 15:06:03 +02:00
183 changed files with 32502 additions and 13884 deletions

View File

@@ -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):

View File

@@ -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]))

View File

@@ -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()

View File

@@ -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')

View File

@@ -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)

View File

@@ -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
View 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', {})

View File

@@ -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):