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:
@ -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)
|
||||
|
Reference in New Issue
Block a user