Add an optional plural name for unit and food. Additional options to show always the plural name for unit and food for an ingredient is added.
1274 lines
51 KiB
Python
1274 lines
51 KiB
Python
import operator
|
|
import pathlib
|
|
import re
|
|
import uuid
|
|
from datetime import date, timedelta
|
|
|
|
import oauth2_provider.models
|
|
from PIL import Image
|
|
from annoying.fields import AutoOneToOneField
|
|
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 InMemoryUploadedFile, UploadedFile
|
|
from django.core.validators import MinLengthValidator
|
|
from django.db import IntegrityError, models
|
|
from django.db.models import Index, ProtectedError, Q, Avg, Max
|
|
from django.db.models.fields.related import ManyToManyField
|
|
from django.db.models.functions import Substr
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext as _
|
|
from django_prometheus.models import ExportModelOperationsMixin
|
|
from django_scopes import ScopedManager, scopes_disabled
|
|
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
|
|
|
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
|
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
|
|
|
|
|
|
def get_user_display_name(self):
|
|
if not (name := f"{self.first_name} {self.last_name}") == " ":
|
|
return name
|
|
else:
|
|
return self.username
|
|
|
|
|
|
def get_active_space(self):
|
|
"""
|
|
Returns the active space of a user or in case no space is actives raises an *** exception
|
|
CAREFUL: cannot be used in django scopes with scope() function because passing None as a scope context means no space checking is enforced (at least I think)!!
|
|
:param self: user
|
|
:return: space currently active for user
|
|
"""
|
|
try:
|
|
return self.userspace_set.filter(active=True).first().space
|
|
except AttributeError:
|
|
return None
|
|
|
|
|
|
def get_shopping_share(self):
|
|
# get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
|
|
return User.objects.raw(' '.join([
|
|
'SELECT auth_user.id FROM auth_user',
|
|
'INNER JOIN cookbook_userpreference',
|
|
'ON (auth_user.id = cookbook_userpreference.user_id)',
|
|
'INNER JOIN cookbook_userpreference_shopping_share',
|
|
'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
|
|
'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
|
|
]))
|
|
|
|
|
|
auth.models.User.add_to_class('get_user_display_name', get_user_display_name)
|
|
auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
|
|
auth.models.User.add_to_class('get_active_space', get_active_space)
|
|
|
|
|
|
def oauth_token_get_owner(self):
|
|
return self.user
|
|
|
|
|
|
oauth2_provider.models.AccessToken.add_to_class('get_owner', oauth_token_get_owner)
|
|
|
|
|
|
def get_model_name(model):
|
|
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
|
|
|
|
|
|
class TreeManager(MP_NodeManager):
|
|
def create(self, *args, **kwargs):
|
|
return self.get_or_create(*args, **kwargs)[0]
|
|
|
|
# model.Manager get_or_create() is not compatible with MP_Tree
|
|
def get_or_create(self, *args, **kwargs):
|
|
kwargs['name'] = kwargs['name'].strip()
|
|
|
|
if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first():
|
|
return obj, False
|
|
else:
|
|
with scopes_disabled():
|
|
try:
|
|
defaults = kwargs.pop('defaults', None)
|
|
if defaults:
|
|
kwargs = {**kwargs, **defaults}
|
|
# ManyToMany fields can't be set this way, so pop them out to save for later
|
|
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
|
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
|
obj = self.model.add_root(**kwargs)
|
|
for field in many_to_many:
|
|
field_model = getattr(obj, field).model
|
|
for related_obj in many_to_many[field]:
|
|
if isinstance(related_obj, User):
|
|
getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
|
|
else:
|
|
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
|
|
return obj, True
|
|
except IntegrityError as e:
|
|
if 'Key (path)' in e.args[0]:
|
|
self.model.fix_tree(fix_paths=True)
|
|
return self.model.add_root(**kwargs), True
|
|
|
|
|
|
class TreeModel(MP_Node):
|
|
_full_name_separator = ' > '
|
|
|
|
def __str__(self):
|
|
if self.icon:
|
|
return f"{self.icon} {self.name}"
|
|
else:
|
|
return f"{self.name}"
|
|
|
|
@property
|
|
def parent(self):
|
|
parent = self.get_parent()
|
|
if parent:
|
|
return self.get_parent().id
|
|
return None
|
|
|
|
@property
|
|
def full_name(self):
|
|
"""
|
|
Returns a string representation of a tree node and it's ancestors,
|
|
e.g. 'Cuisine > Asian > Chinese > Catonese'.
|
|
"""
|
|
names = [node.name for node 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 node 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 node 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()
|
|
|
|
# use self.objects.get_or_create() instead
|
|
@classmethod
|
|
def add_root(self, **kwargs):
|
|
with scopes_disabled():
|
|
return super().add_root(**kwargs)
|
|
|
|
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
|
|
@staticmethod
|
|
def include_descendants(queryset=None, filter=None):
|
|
"""
|
|
:param queryset: Model Queryset to add descendants
|
|
:param filter: Filter (exclude) the descendants nodes with the provided Q filter
|
|
"""
|
|
descendants = Q()
|
|
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
|
nodes = queryset.values('path', 'depth')
|
|
for node in nodes:
|
|
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
|
|
|
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants)
|
|
|
|
def exclude_descendants(queryset=None, filter=None):
|
|
"""
|
|
:param queryset: Model Queryset to add descendants
|
|
:param filter: Filter (include) the descendants nodes with the provided Q filter
|
|
"""
|
|
descendants = Q()
|
|
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
|
nodes = queryset.values('path', 'depth')
|
|
for node in nodes:
|
|
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
|
|
|
return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants)
|
|
|
|
def include_ancestors(queryset=None):
|
|
"""
|
|
:param queryset: Model Queryset to add ancestors
|
|
:param filter: Filter (include) the ancestors nodes with the provided Q filter
|
|
"""
|
|
|
|
queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
|
|
nodes = list(set(queryset.values_list('root', 'depth')))
|
|
|
|
ancestors = Q()
|
|
for node in nodes:
|
|
ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
|
|
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class PermissionModelMixin:
|
|
@staticmethod
|
|
def get_space_key():
|
|
return ('space',)
|
|
|
|
def get_space_kwarg(self):
|
|
return '__'.join(self.get_space_key())
|
|
|
|
def get_owner(self):
|
|
if getattr(self, 'created_by', None):
|
|
return self.created_by
|
|
if getattr(self, 'user', None):
|
|
return self.user
|
|
return None
|
|
|
|
def get_shared(self):
|
|
if getattr(self, 'shared', None):
|
|
return self.shared.all()
|
|
return []
|
|
|
|
def get_space(self):
|
|
p = '.'.join(self.get_space_key())
|
|
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 FoodInheritField(models.Model, PermissionModelMixin):
|
|
field = models.CharField(max_length=32, unique=True)
|
|
name = models.CharField(max_length=64, unique=True)
|
|
|
|
def __str__(self):
|
|
return _(self.name)
|
|
|
|
@staticmethod
|
|
def get_name(self):
|
|
return _(self.name)
|
|
|
|
|
|
class Space(ExportModelOperationsMixin('space'), models.Model):
|
|
name = models.CharField(max_length=128, default='Default')
|
|
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image')
|
|
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)
|
|
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)
|
|
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
|
show_facet_count = models.BooleanField(default=False)
|
|
|
|
def safe_delete(self):
|
|
"""
|
|
Safely deletes a space by deleting all objects belonging to the space first and then deleting the space itself
|
|
"""
|
|
CookLog.objects.filter(space=self).delete()
|
|
ViewLog.objects.filter(space=self).delete()
|
|
ImportLog.objects.filter(space=self).delete()
|
|
BookmarkletImport.objects.filter(space=self).delete()
|
|
CustomFilter.objects.filter(space=self).delete()
|
|
|
|
Comment.objects.filter(recipe__space=self).delete()
|
|
Keyword.objects.filter(space=self).delete()
|
|
Ingredient.objects.filter(space=self).delete()
|
|
Food.objects.filter(space=self).delete()
|
|
Unit.objects.filter(space=self).delete()
|
|
Step.objects.filter(space=self).delete()
|
|
NutritionInformation.objects.filter(space=self).delete()
|
|
RecipeBookEntry.objects.filter(book__space=self).delete()
|
|
RecipeBook.objects.filter(space=self).delete()
|
|
MealType.objects.filter(space=self).delete()
|
|
MealPlan.objects.filter(space=self).delete()
|
|
ShareLink.objects.filter(space=self).delete()
|
|
Recipe.objects.filter(space=self).delete()
|
|
|
|
RecipeImport.objects.filter(space=self).delete()
|
|
SyncLog.objects.filter(sync__space=self).delete()
|
|
Sync.objects.filter(space=self).delete()
|
|
Storage.objects.filter(space=self).delete()
|
|
|
|
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
|
|
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
|
|
ShoppingList.objects.filter(space=self).delete()
|
|
|
|
SupermarketCategoryRelation.objects.filter(supermarket__space=self).delete()
|
|
SupermarketCategory.objects.filter(space=self).delete()
|
|
Supermarket.objects.filter(space=self).delete()
|
|
|
|
InviteLink.objects.filter(space=self).delete()
|
|
UserFile.objects.filter(space=self).delete()
|
|
Automation.objects.filter(space=self).delete()
|
|
self.delete()
|
|
|
|
def get_owner(self):
|
|
return self.created_by
|
|
|
|
def get_space(self):
|
|
return self
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class UserPreference(models.Model, PermissionModelMixin):
|
|
# Themes
|
|
BOOTSTRAP = 'BOOTSTRAP'
|
|
DARKLY = 'DARKLY'
|
|
FLATLY = 'FLATLY'
|
|
SUPERHERO = 'SUPERHERO'
|
|
TANDOOR = 'TANDOOR'
|
|
|
|
THEMES = (
|
|
(TANDOOR, 'Tandoor'),
|
|
(BOOTSTRAP, 'Bootstrap'),
|
|
(DARKLY, 'Darkly'),
|
|
(FLATLY, 'Flatly'),
|
|
(SUPERHERO, 'Superhero'),
|
|
)
|
|
|
|
# Nav colors
|
|
PRIMARY = 'PRIMARY'
|
|
SECONDARY = 'SECONDARY'
|
|
SUCCESS = 'SUCCESS'
|
|
INFO = 'INFO'
|
|
WARNING = 'WARNING'
|
|
DANGER = 'DANGER'
|
|
LIGHT = 'LIGHT'
|
|
DARK = 'DARK'
|
|
|
|
COLORS = (
|
|
(PRIMARY, 'Primary'),
|
|
(SECONDARY, 'Secondary'),
|
|
(SUCCESS, 'Success'),
|
|
(INFO, 'Info'),
|
|
(WARNING, 'Warning'),
|
|
(DANGER, 'Danger'),
|
|
(LIGHT, 'Light'),
|
|
(DARK, 'Dark')
|
|
)
|
|
|
|
# Default Page
|
|
SEARCH = 'SEARCH'
|
|
PLAN = 'PLAN'
|
|
BOOKS = 'BOOKS'
|
|
|
|
PAGES = (
|
|
(SEARCH, _('Search')),
|
|
(PLAN, _('Meal-Plan')),
|
|
(BOOKS, _('Books')),
|
|
)
|
|
|
|
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
|
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True,blank=True, related_name='user_image')
|
|
theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
|
|
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
|
|
default_unit = models.CharField(max_length=32, default='g')
|
|
use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
|
|
use_kj = models.BooleanField(default=KJ_PREF_DEFAULT)
|
|
default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH)
|
|
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
|
|
shopping_share = models.ManyToManyField(User, blank=True, related_name='shopping_share')
|
|
ingredient_decimals = models.IntegerField(default=2)
|
|
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
|
shopping_auto_sync = models.IntegerField(default=5)
|
|
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
|
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
|
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
|
mealplan_autoinclude_related = models.BooleanField(default=True)
|
|
shopping_add_onhand = models.BooleanField(default=False)
|
|
filter_to_supermarket = models.BooleanField(default=False)
|
|
left_handed = models.BooleanField(default=False)
|
|
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
|
|
shopping_recent_days = models.PositiveIntegerField(default=7)
|
|
csv_delim = models.CharField(max_length=2, default=",")
|
|
csv_prefix = models.CharField(max_length=10, blank=True, )
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return str(self.user)
|
|
|
|
|
|
class UserSpace(models.Model, PermissionModelMixin):
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
groups = models.ManyToManyField(Group)
|
|
|
|
# there should always only be one active space although permission methods are written in such a way
|
|
# that having more than one active space should just break certain parts of the application and not leak any data
|
|
active = models.BooleanField(default=False)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
|
|
class Storage(models.Model, PermissionModelMixin):
|
|
DROPBOX = 'DB'
|
|
NEXTCLOUD = 'NEXTCLOUD'
|
|
LOCAL = 'LOCAL'
|
|
STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'), (LOCAL, 'Local'))
|
|
|
|
name = models.CharField(max_length=128)
|
|
method = models.CharField(
|
|
choices=STORAGE_TYPES, max_length=128, default=DROPBOX
|
|
)
|
|
username = models.CharField(max_length=128, blank=True, null=True)
|
|
password = models.CharField(max_length=128, blank=True, null=True)
|
|
token = models.CharField(max_length=512, blank=True, null=True)
|
|
url = models.URLField(blank=True, null=True)
|
|
path = models.CharField(blank=True, default='', max_length=256)
|
|
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Sync(models.Model, PermissionModelMixin):
|
|
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
|
|
path = models.CharField(max_length=512, default="")
|
|
active = models.BooleanField(default=True)
|
|
last_checked = models.DateTimeField(null=True)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.path
|
|
|
|
|
|
class SupermarketCategory(models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
|
description = models.TextField(blank=True, null=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space')
|
|
]
|
|
|
|
|
|
class Supermarket(models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
|
description = models.TextField(blank=True, null=True)
|
|
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space')
|
|
]
|
|
|
|
|
|
class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
|
|
supermarket = models.ForeignKey(Supermarket, on_delete=models.CASCADE, related_name='category_to_supermarket')
|
|
category = models.ForeignKey(SupermarketCategory, on_delete=models.CASCADE, related_name='category_to_supermarket')
|
|
order = models.IntegerField(default=0)
|
|
|
|
objects = ScopedManager(space='supermarket__space')
|
|
|
|
@staticmethod
|
|
def get_space_key():
|
|
return 'supermarket', 'space'
|
|
|
|
class Meta:
|
|
ordering = ('order',)
|
|
|
|
|
|
class SyncLog(models.Model, PermissionModelMixin):
|
|
sync = models.ForeignKey(Sync, on_delete=models.CASCADE)
|
|
status = models.CharField(max_length=32)
|
|
msg = models.TextField(default="")
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
objects = ScopedManager(space='sync__space')
|
|
|
|
def __str__(self):
|
|
return f"{self.created_at}:{self.sync} - {self.status}"
|
|
|
|
|
|
class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelMixin):
|
|
if SORT_TREE_BY_NAME:
|
|
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)
|
|
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
|
|
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space')
|
|
]
|
|
indexes = (Index(fields=['id', 'name']),)
|
|
|
|
|
|
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
|
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
|
description = models.TextField(blank=True, null=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space')
|
|
]
|
|
|
|
|
|
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
|
# TODO when savings a food as substitute children - assume children and descednants are also substitutes for siblings
|
|
# exclude fields not implemented yet
|
|
inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', ])
|
|
# TODO add inherit children_inherit, parent_inherit, Do Not Inherit
|
|
|
|
# WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals
|
|
if SORT_TREE_BY_NAME:
|
|
node_order_by = ['name']
|
|
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
|
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
|
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
|
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field
|
|
ignore_shopping = models.BooleanField(default=False) # inherited field
|
|
onhand_users = models.ManyToManyField(User, blank=True)
|
|
description = models.TextField(default='', blank=True)
|
|
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True)
|
|
substitute = models.ManyToManyField("self", blank=True)
|
|
substitute_siblings = models.BooleanField(default=False)
|
|
substitute_children = models.BooleanField(default=False)
|
|
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def delete(self):
|
|
if self.ingredient_set.all().exclude(step=None).count() > 0:
|
|
raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
|
|
else:
|
|
return super().delete()
|
|
|
|
# MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
|
|
|
|
def move(self, *args, **kwargs):
|
|
super().move(*args, **kwargs)
|
|
# treebeard bypasses ORM, need to explicity save to trigger post save signals retrieve the object again to avoid writing previous state back to disk
|
|
obj = self.__class__.objects.get(id=self.id)
|
|
if parent := obj.get_parent():
|
|
# child should inherit what the parent defines it should inherit
|
|
fields = list(parent.child_inherit_fields.all() or parent.inherit_fields.all())
|
|
if len(fields) > 0:
|
|
obj.inherit_fields.set(fields)
|
|
obj.save()
|
|
|
|
@staticmethod
|
|
def reset_inheritance(space=None, food=None):
|
|
# resets inherited fields to the space defaults and updates all inherited fields to root object values
|
|
if food:
|
|
# if child inherit fields is preset children should be set to that, otherwise inherit this foods inherited fields
|
|
inherit = list((food.child_inherit_fields.all() or food.inherit_fields.all()).values('id', 'field'))
|
|
tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth + 1)
|
|
else:
|
|
inherit = list(space.food_inherit.all().values('id', 'field'))
|
|
tree_filter = Q(space=space)
|
|
|
|
# remove all inherited fields from food
|
|
trough = Food.inherit_fields.through
|
|
trough.objects.all().delete()
|
|
|
|
# food is going to inherit attributes
|
|
if len(inherit) > 0:
|
|
# ManyToMany cannot be updated through an UPDATE operation
|
|
for i in inherit:
|
|
trough.objects.bulk_create([
|
|
trough(food_id=x, foodinheritfield_id=i['id'])
|
|
for x in Food.objects.filter(tree_filter).values_list('id', flat=True)
|
|
])
|
|
|
|
inherit = [x['field'] for x in inherit]
|
|
for field in ['ignore_shopping', 'substitute_children', 'substitute_siblings']:
|
|
if field in inherit:
|
|
if food and getattr(food, field, None):
|
|
food.get_descendants().update(**{f"{field}": True})
|
|
elif food and not getattr(food, field, True):
|
|
food.get_descendants().update(**{f"{field}": False})
|
|
else:
|
|
# get food at root that have children that need updated
|
|
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, **{f"{field}": True}, space=space)).update(**{f"{field}": True})
|
|
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, **{f"{field}": False}, space=space)).update(**{f"{field}": False})
|
|
|
|
if 'supermarket_category' in inherit:
|
|
# when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants
|
|
if food and food.supermarket_category:
|
|
food.get_descendants().update(supermarket_category=food.supermarket_category)
|
|
elif food is None:
|
|
# find top node that has category set
|
|
category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space))
|
|
for root in category_roots:
|
|
root.get_descendants().update(supermarket_category=root.supermarket_category)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
|
|
]
|
|
indexes = (
|
|
Index(fields=['id']),
|
|
Index(fields=['name']),
|
|
)
|
|
|
|
|
|
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
|
# delete method on Food and Unit checks if they are part of a Recipe, if it is raises a ProtectedError instead of cascading the delete
|
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True)
|
|
unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
|
|
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
|
note = models.CharField(max_length=256, null=True, blank=True)
|
|
is_header = models.BooleanField(default=False)
|
|
no_amount = models.BooleanField(default=False)
|
|
always_use_plural_unit = models.BooleanField(default=False)
|
|
always_use_plural_food = models.BooleanField(default=False)
|
|
order = models.IntegerField(default=0)
|
|
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
|
|
|
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
food = ""
|
|
unit = ""
|
|
if self.always_use_plural_food and self.food.plural_name not in (None, "") and not self.no_amount:
|
|
food = self.food.plural_name
|
|
else:
|
|
if self.amount > 1 and self.food.plural_name not in (None, "") and not self.no_amount:
|
|
food = self.food.plural_name
|
|
else:
|
|
food = str(self.food)
|
|
if self.always_use_plural_unit and self.unit.plural_name not in (None, "") and not self.no_amount:
|
|
unit = self.unit.plural_name
|
|
else:
|
|
if self.amount > 1 and self.unit.plural_name not in (None, "") and not self.no_amount:
|
|
unit = self.unit.plural_name
|
|
else:
|
|
unit = str(self.unit)
|
|
return str(self.amount) + ' ' + str(unit) + ' ' + str(food)
|
|
|
|
class Meta:
|
|
ordering = ['order', 'pk']
|
|
indexes = (
|
|
Index(fields=['id']),
|
|
)
|
|
|
|
|
|
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128, default='', blank=True)
|
|
instruction = models.TextField(blank=True)
|
|
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)
|
|
step_recipe = models.ForeignKey('Recipe', default=None, blank=True, null=True, on_delete=models.PROTECT)
|
|
|
|
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
|
|
return render_instructions(self)
|
|
|
|
def __str__(self):
|
|
return f'{self.pk} {self.name}'
|
|
|
|
class Meta:
|
|
ordering = ['order', 'pk']
|
|
indexes = (GinIndex(fields=["search_vector"]),)
|
|
|
|
|
|
class NutritionInformation(models.Model, PermissionModelMixin):
|
|
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
|
carbohydrates = models.DecimalField(
|
|
default=0, decimal_places=16, max_digits=32
|
|
)
|
|
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
|
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
|
source = models.CharField(max_length=512, default="", null=True, blank=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return f'Nutrition {self.pk}'
|
|
|
|
|
|
# class NutritionType(models.Model, PermissionModelMixin):
|
|
# name = models.CharField(max_length=128)
|
|
# icon = models.CharField(max_length=16, blank=True, null=True)
|
|
# description = models.CharField(max_length=512, blank=True, null=True)
|
|
#
|
|
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
# objects = ScopedManager(space='space')
|
|
|
|
class RecipeManager(models.Manager.from_queryset(models.QuerySet)):
|
|
def get_queryset(self):
|
|
return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at'))
|
|
|
|
|
|
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128)
|
|
description = models.CharField(max_length=512, blank=True, null=True)
|
|
servings = models.IntegerField(default=1)
|
|
servings_text = models.CharField(default='', blank=True, max_length=32)
|
|
image = models.ImageField(upload_to='recipes/', blank=True, null=True)
|
|
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
|
|
file_uid = models.CharField(max_length=256, default="", blank=True)
|
|
file_path = models.CharField(max_length=512, default="", blank=True)
|
|
link = models.CharField(max_length=512, null=True, blank=True)
|
|
cors_link = models.CharField(max_length=1024, null=True, blank=True)
|
|
keywords = models.ManyToManyField(Keyword, blank=True)
|
|
steps = models.ManyToManyField(Step, blank=True)
|
|
working_time = models.IntegerField(default=0)
|
|
waiting_time = models.IntegerField(default=0)
|
|
internal = models.BooleanField(default=False)
|
|
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
|
|
show_ingredient_overview = models.BooleanField(default=True)
|
|
private = models.BooleanField(default=False)
|
|
shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with')
|
|
|
|
source_url = models.CharField(max_length=1024, default=None, blank=True, null=True)
|
|
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
name_search_vector = SearchVectorField(null=True)
|
|
desc_search_vector = SearchVectorField(null=True)
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
|
|
objects = ScopedManager(space='space', _manager_class=RecipeManager)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def get_related_recipes(self, levels=1):
|
|
# recipes for step recipe
|
|
step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe'))
|
|
# recipes for foods
|
|
food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe'))
|
|
related_recipes = Recipe.objects.filter(step_recipes | food_recipes)
|
|
if levels == 1:
|
|
return related_recipes
|
|
|
|
# this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?)
|
|
# for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios
|
|
sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe'))
|
|
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
|
|
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
|
|
|
|
class Meta():
|
|
indexes = (
|
|
GinIndex(fields=["name_search_vector"]),
|
|
GinIndex(fields=["desc_search_vector"]),
|
|
Index(fields=['id']),
|
|
Index(fields=['name']),
|
|
)
|
|
|
|
|
|
class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin):
|
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
|
text = models.TextField()
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
objects = ScopedManager(space='recipe__space')
|
|
|
|
@staticmethod
|
|
def get_space_key():
|
|
return 'recipe', 'space'
|
|
|
|
def get_space(self):
|
|
return self.recipe.space
|
|
|
|
def __str__(self):
|
|
return self.text
|
|
|
|
|
|
class RecipeImport(models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128)
|
|
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
|
|
file_uid = models.CharField(max_length=256, default="")
|
|
file_path = models.CharField(max_length=512, default="")
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128)
|
|
description = models.TextField(blank=True)
|
|
icon = models.CharField(max_length=16, blank=True, null=True)
|
|
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta():
|
|
indexes = (Index(fields=['name']),)
|
|
|
|
|
|
class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin):
|
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
|
book = models.ForeignKey(RecipeBook, on_delete=models.CASCADE)
|
|
|
|
objects = ScopedManager(space='book__space')
|
|
|
|
@staticmethod
|
|
def get_space_key():
|
|
return 'book', 'space'
|
|
|
|
def __str__(self):
|
|
return self.recipe.name
|
|
|
|
def get_owner(self):
|
|
try:
|
|
return self.book.created_by
|
|
except AttributeError:
|
|
return None
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['recipe', 'book'], name='rbe_unique_name_per_space')
|
|
]
|
|
|
|
|
|
class MealType(models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=128)
|
|
order = models.IntegerField(default=0)
|
|
icon = models.CharField(max_length=16, blank=True, null=True)
|
|
color = models.CharField(max_length=7, blank=True, null=True)
|
|
default = models.BooleanField(default=False, blank=True)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
|
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
|
|
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
|
title = models.CharField(max_length=64, blank=True, default='')
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
|
|
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
|
|
note = models.TextField(blank=True)
|
|
date = models.DateField()
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def get_label(self):
|
|
if self.title:
|
|
return self.title
|
|
return str(self.recipe)
|
|
|
|
def get_meal_name(self):
|
|
return self.meal_type.name
|
|
|
|
def __str__(self):
|
|
return f'{self.get_label()} - {self.date} - {self.meal_type.name}'
|
|
|
|
|
|
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=32, blank=True, default='')
|
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated
|
|
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
|
mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
|
|
|
|
objects = ScopedManager(space='recipe__space')
|
|
|
|
@staticmethod
|
|
def get_space_key():
|
|
return 'recipe', 'space'
|
|
|
|
def get_space(self):
|
|
return self.recipe.space
|
|
|
|
def __str__(self):
|
|
return f'Shopping list recipe {self.id} - {self.recipe}'
|
|
|
|
def get_owner(self):
|
|
try:
|
|
return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
|
|
except AttributeError:
|
|
return None
|
|
|
|
|
|
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
|
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
|
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, related_name='shopping_entries')
|
|
unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
|
|
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True)
|
|
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
|
order = models.IntegerField(default=0)
|
|
checked = models.BooleanField(default=False)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
completed_at = models.DateTimeField(null=True, blank=True)
|
|
delay_until = models.DateTimeField(null=True, blank=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
@staticmethod
|
|
def get_space_key():
|
|
return 'shoppinglist', 'space'
|
|
|
|
def get_space(self):
|
|
return self.shoppinglist_set.first().space
|
|
|
|
def __str__(self):
|
|
return f'Shopping list entry {self.id}'
|
|
|
|
def get_shared(self):
|
|
try:
|
|
return self.shoppinglist_set.first().shared.all()
|
|
except AttributeError:
|
|
return self.created_by.userpreference.shopping_share.all()
|
|
|
|
def get_owner(self):
|
|
try:
|
|
return self.created_by or self.shoppinglist_set.first().created_by
|
|
except AttributeError:
|
|
return None
|
|
|
|
|
|
class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, PermissionModelMixin):
|
|
uuid = models.UUIDField(default=uuid.uuid4)
|
|
note = models.TextField(blank=True, null=True)
|
|
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
|
|
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
|
|
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
|
|
supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL)
|
|
finished = models.BooleanField(default=False)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return f'Shopping list {self.id}'
|
|
|
|
def get_shared(self):
|
|
try:
|
|
return self.shared.all() or self.created_by.userpreference.shopping_share.all()
|
|
except AttributeError:
|
|
return []
|
|
|
|
|
|
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)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return f'{self.recipe} - {self.uuid}'
|
|
|
|
|
|
def default_valid_until():
|
|
return date.today() + timedelta(days=14)
|
|
|
|
|
|
class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, PermissionModelMixin):
|
|
uuid = models.UUIDField(default=uuid.uuid4)
|
|
email = models.EmailField(blank=True)
|
|
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
|
valid_until = models.DateField(default=default_valid_until)
|
|
used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by')
|
|
reusable = models.BooleanField(default=False)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return f'{self.uuid}'
|
|
|
|
|
|
class TelegramBot(models.Model, PermissionModelMixin):
|
|
token = models.CharField(max_length=256)
|
|
name = models.CharField(max_length=128, default='', blank=True)
|
|
chat_id = models.CharField(max_length=128, default='', blank=True)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
webhook_token = models.UUIDField(default=uuid.uuid4)
|
|
|
|
objects = ScopedManager(space='space')
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
|
|
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
|
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
created_at = models.DateTimeField(default=timezone.now)
|
|
rating = models.IntegerField(null=True)
|
|
servings = models.IntegerField(default=0)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.recipe.name
|
|
|
|
class Meta():
|
|
indexes = (
|
|
Index(fields=['id']),
|
|
Index(fields=['recipe']),
|
|
Index(fields=['-created_at']),
|
|
Index(fields=['rating']),
|
|
Index(fields=['created_by']),
|
|
Index(fields=['created_by', 'rating']),
|
|
)
|
|
|
|
|
|
class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
|
|
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
objects = ScopedManager(space='space')
|
|
|
|
def __str__(self):
|
|
return self.recipe.name
|
|
|
|
class Meta():
|
|
indexes = (
|
|
Index(fields=['recipe']),
|
|
Index(fields=['-created_at']),
|
|
Index(fields=['created_by']),
|
|
Index(fields=['recipe', '-created_at', 'created_by']),
|
|
)
|
|
|
|
|
|
class ImportLog(models.Model, PermissionModelMixin):
|
|
type = models.CharField(max_length=32)
|
|
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)
|
|
|
|
objects = ScopedManager(space='space')
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
|
|
def __str__(self):
|
|
return f"{self.created_at}:{self.type}"
|
|
|
|
|
|
class ExportLog(models.Model, PermissionModelMixin):
|
|
type = models.CharField(max_length=32)
|
|
running = models.BooleanField(default=True)
|
|
msg = models.TextField(default="")
|
|
|
|
total_recipes = models.IntegerField(default=0)
|
|
exported_recipes = models.IntegerField(default=0)
|
|
cache_duration = models.IntegerField(default=0)
|
|
possibly_not_expired = models.BooleanField(default=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 __str__(self):
|
|
return f"{self.created_at}:{self.type}"
|
|
|
|
|
|
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
|
|
html = models.TextField()
|
|
url = models.CharField(max_length=256, null=True, 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)
|
|
|
|
|
|
# field names used to configure search behavior - all data populated during data migration
|
|
# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield
|
|
class SearchFields(models.Model, PermissionModelMixin):
|
|
name = models.CharField(max_length=32, unique=True)
|
|
field = models.CharField(max_length=64, unique=True)
|
|
|
|
def __str__(self):
|
|
return _(self.name)
|
|
|
|
@staticmethod
|
|
def get_name(self):
|
|
return _(self.name)
|
|
|
|
|
|
class SearchPreference(models.Model, PermissionModelMixin):
|
|
# Search Style (validation parsleyjs.org)
|
|
# phrase or plain or raw (websearch and trigrams are mutually exclusive)
|
|
SIMPLE = 'plain'
|
|
PHRASE = 'phrase'
|
|
WEB = 'websearch'
|
|
RAW = 'raw'
|
|
SEARCH_STYLE = (
|
|
(SIMPLE, _('Simple')),
|
|
(PHRASE, _('Phrase')),
|
|
(WEB, _('Web')),
|
|
(RAW, _('Raw'))
|
|
)
|
|
|
|
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
|
search = models.CharField(choices=SEARCH_STYLE, max_length=32, default=SIMPLE)
|
|
|
|
lookup = models.BooleanField(default=False)
|
|
unaccent = models.ManyToManyField(SearchFields, related_name="unaccent_fields", blank=True)
|
|
icontains = models.ManyToManyField(SearchFields, related_name="icontains_fields", blank=True)
|
|
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)
|
|
trigram_threshold = models.DecimalField(default=0.2, decimal_places=2, max_digits=3)
|
|
|
|
|
|
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 is_image(self):
|
|
try:
|
|
img = Image.open(self.file.file.file)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
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)
|
|
|
|
|
|
class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
|
|
FOOD_ALIAS = 'FOOD_ALIAS'
|
|
UNIT_ALIAS = 'UNIT_ALIAS'
|
|
KEYWORD_ALIAS = 'KEYWORD_ALIAS'
|
|
|
|
type = models.CharField(max_length=128,
|
|
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')),))
|
|
name = models.CharField(max_length=128, default='')
|
|
description = models.TextField(blank=True, null=True)
|
|
|
|
param_1 = models.CharField(max_length=128, blank=True, null=True)
|
|
param_2 = models.CharField(max_length=128, blank=True, null=True)
|
|
param_3 = models.CharField(max_length=128, blank=True, null=True)
|
|
|
|
disabled = models.BooleanField(default=False)
|
|
|
|
updated_at = models.DateTimeField(auto_now=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)
|
|
|
|
|
|
class CustomFilter(models.Model, PermissionModelMixin):
|
|
RECIPE = 'RECIPE'
|
|
FOOD = 'FOOD'
|
|
KEYWORD = 'KEYWORD'
|
|
|
|
MODELS = (
|
|
(RECIPE, _('Recipe')),
|
|
(FOOD, _('Food')),
|
|
(KEYWORD, _('Keyword')),
|
|
)
|
|
|
|
name = models.CharField(max_length=128, null=False, blank=False)
|
|
type = models.CharField(max_length=128, choices=(MODELS), default=MODELS[0])
|
|
# could use JSONField, but requires installing extension on SQLite, don't need to search the objects, so seems unecessary
|
|
search = models.TextField(blank=False, null=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
|
shared = models.ManyToManyField(User, blank=True, related_name='f_shared_with')
|
|
|
|
objects = ScopedManager(space='space')
|
|
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space')
|
|
]
|