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

@ -1,3 +1,5 @@
import operator
import pathlib
import re
import uuid
from datetime import date, timedelta
@ -8,14 +10,15 @@ from django.contrib import auth
from django.contrib.auth.models import Group, User
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models import Index
from django.utils import timezone
from django.utils.translation import gettext as _
from treebeard.mp_tree import MP_Node, MP_NodeManager
from django_scopes import ScopedManager, scopes_disabled
from django_prometheus.models import ExportModelOperationsMixin
from django_scopes import ScopedManager
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
STICKY_NAV_PREF_DEFAULT)
@ -57,18 +60,22 @@ class PermissionModelMixin:
def get_space(self):
p = '.'.join(self.get_space_key())
if getattr(self, p, None):
return getattr(self, p)
raise NotImplementedError('get space for method not implemented and standard fields not available')
try:
if space := operator.attrgetter(p)(self):
return space
except AttributeError:
raise NotImplementedError('get space for method not implemented and standard fields not available')
class Space(ExportModelOperationsMixin('space'), models.Model):
name = models.CharField(max_length=128, default='Default')
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
created_at = models.DateTimeField(auto_now_add=True)
message = models.CharField(max_length=512, default='', blank=True)
max_recipes = models.IntegerField(default=0)
allow_files = models.BooleanField(default=True)
max_file_storage_mb = models.IntegerField(default=0, help_text=_('Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'))
max_users = models.IntegerField(default=0)
allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False)
def __str__(self):
@ -152,6 +159,7 @@ class UserPreference(models.Model, PermissionModelMixin):
shopping_auto_sync = models.IntegerField(default=5)
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
objects = ScopedManager(space='space')
@ -209,6 +217,7 @@ class SupermarketCategory(models.Model, PermissionModelMixin):
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
@ -224,6 +233,7 @@ class Supermarket(models.Model, PermissionModelMixin):
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
@ -254,7 +264,9 @@ class SyncLog(models.Model, PermissionModelMixin):
return f"{self.created_at}:{self.sync} - {self.status}"
class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionModelMixin):
class Keyword(ExportModelOperationsMixin('keyword'), MP_Node, PermissionModelMixin):
# TODO create get_or_create method
node_order_by = ['name']
name = models.CharField(max_length=64)
icon = models.CharField(max_length=16, blank=True, null=True)
description = models.TextField(default="", blank=True)
@ -262,7 +274,9 @@ class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionMod
updated_at = models.DateTimeField(auto_now=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
objects = ScopedManager(space='space', _manager_class=MP_NodeManager)
_full_name_separator = ' > '
def __str__(self):
if self.icon:
@ -270,6 +284,63 @@ class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionMod
else:
return f"{self.name}"
@property
def parent(self):
parent = self.get_parent()
if parent:
return self.get_parent().id
return None
@classmethod
def get_or_create(self, **kwargs):
# an attempt to mimic get_or_create functionality with Keywords
# function attempts to get the keyword,
# if the length of the return is 0 will add a root node
kwargs['name'] = kwargs['name'].strip()
q = self.get_tree().filter(name=kwargs['name'], space=kwargs['space'])
if len(q) != 0:
return q[0]
else:
return Keyword.add_root(**kwargs)
@property
def full_name(self):
"""
Returns a string representation of the keyword and it's ancestors,
e.g. 'Cuisine > Asian > Chinese > Catonese'.
"""
names = [keyword.name for keyword in self.get_ancestors_and_self()]
return self._full_name_separator.join(names)
def get_ancestors_and_self(self):
"""
Gets ancestors and includes itself. Use treebeard's get_ancestors
if you don't want to include the keyword itself. It's a separate
function as it's commonly used in templates.
"""
if self.is_root():
return [self]
return list(self.get_ancestors()) + [self]
def get_descendants_and_self(self):
"""
Gets descendants and includes itself. Use treebeard's get_descendants
if you don't want to include the keyword itself. It's a separate
function as it's commonly used in templates.
"""
return self.get_tree(self)
def has_children(self):
return self.get_num_children() > 0
def get_num_children(self):
return self.get_children().count()
@classmethod
def add_root(self, **kwargs):
with scopes_disabled():
return super().add_root(**kwargs)
class Meta:
unique_together = (('space', 'name'),)
indexes = (Index(fields=['id', 'name']), )
@ -286,6 +357,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
@ -303,6 +375,7 @@ class Food(ExportModelOperationsMixin('food'), models.Model, PermissionModelMixi
return self.name
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('space', 'name'),)
indexes = (Index(fields=['id', 'name']), )
@ -316,14 +389,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
no_amount = models.BooleanField(default=False)
order = models.IntegerField(default=0)
objects = ScopedManager(space='step__recipe__space')
@staticmethod
def get_space_key():
return 'step', 'recipe', 'space'
def get_space(self):
return self.step_set.first().recipe_set.first().space
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def __str__(self):
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.food)
@ -336,10 +403,11 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
TEXT = 'TEXT'
TIME = 'TIME'
FILE = 'FILE'
name = models.CharField(max_length=128, default='', blank=True)
type = models.CharField(
choices=((TEXT, _('Text')), (TIME, _('Time')),),
choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')),),
default=TEXT,
max_length=16
)
@ -347,17 +415,12 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
ingredients = models.ManyToManyField(Ingredient, blank=True)
time = models.IntegerField(default=0, blank=True)
order = models.IntegerField(default=0)
file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
show_as_header = models.BooleanField(default=True)
search_vector = SearchVectorField(null=True)
objects = ScopedManager(space='recipe__space')
@staticmethod
def get_space_key():
return 'recipe', 'space'
def get_space(self):
return self.recipe_set.first().space
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def get_instruction_render(self):
from cookbook.helper.template_helper import render_instructions
@ -379,17 +442,11 @@ class NutritionInformation(models.Model, PermissionModelMixin):
max_length=512, default="", null=True, blank=True
)
objects = ScopedManager(space='recipe__space')
@staticmethod
def get_space_key():
return 'recipe', 'space'
def get_space(self):
return self.recipe_set.first().space
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def __str__(self):
return 'Nutrition'
return f'Nutrition {self.pk}'
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
@ -501,6 +558,7 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
return None
class Meta:
# TODO according to this https://docs.djangoproject.com/en/3.1/ref/models/options/#unique-together should not be used
unique_together = (('recipe', 'book'),)
@ -615,6 +673,8 @@ class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, Pe
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
uuid = models.UUIDField(default=uuid.uuid4)
request_count = models.IntegerField(default=0)
abuse_blocked = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
@ -631,7 +691,6 @@ def default_valid_until():
class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, PermissionModelMixin):
uuid = models.UUIDField(default=uuid.uuid4)
username = models.CharField(blank=True, max_length=64)
email = models.EmailField(blank=True)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
valid_until = models.DateField(default=default_valid_until)
@ -699,6 +758,10 @@ class ImportLog(models.Model, PermissionModelMixin):
running = models.BooleanField(default=True)
msg = models.TextField(default="")
keyword = models.ForeignKey(Keyword, null=True, blank=True, on_delete=models.SET_NULL)
total_recipes = models.IntegerField(default=0)
imported_recipes = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
@ -764,3 +827,20 @@ class SearchPreference(models.Model, PermissionModelMixin):
istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True)
fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)
class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128)
file = models.FileField(upload_to='files/')
file_size_kb = models.IntegerField(default=0, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
def save(self, *args, **kwargs):
if hasattr(self.file, 'file') and isinstance(self.file.file, UploadedFile) or isinstance(self.file.file, InMemoryUploadedFile):
self.file.name = f'{uuid.uuid4()}' + pathlib.Path(self.file.name).suffix
self.file_size_kb = round(self.file.size / 1000)
super(UserFile, self).save(*args, **kwargs)