Coverage for cookbook/models.py: 85%
918 statements
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 01:02 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2023-12-29 01:02 +0100
1import operator
2import pathlib
3import re
4import uuid
5from datetime import date, timedelta
7import oauth2_provider.models
8from annoying.fields import AutoOneToOneField
9from django.contrib import auth
10from django.contrib.auth.models import Group, User
11from django.contrib.postgres.indexes import GinIndex
12from django.contrib.postgres.search import SearchVectorField
13from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
14from django.core.validators import MinLengthValidator
15from django.db import IntegrityError, models
16from django.db.models import Avg, Index, Max, ProtectedError, Q
17from django.db.models.fields.related import ManyToManyField
18from django.db.models.functions import Substr
19from django.utils import timezone
20from django.utils.translation import gettext as _
21from django_prometheus.models import ExportModelOperationsMixin
22from django_scopes import ScopedManager, scopes_disabled
23from PIL import Image
24from treebeard.mp_tree import MP_Node, MP_NodeManager
26from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
27 SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
30def get_user_display_name(self):
31 if not (name := f"{self.first_name} {self.last_name}") == " ":
32 return name
33 else:
34 return self.username
37def get_active_space(self):
38 """
39 Returns the active space of a user or in case no space is actives raises an *** exception
40 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)!!
41 :param self: user
42 :return: space currently active for user
43 """
44 try:
45 return self.userspace_set.filter(active=True).first().space
46 except AttributeError:
47 return None
50def get_shopping_share(self):
51 # get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
52 return User.objects.raw(' '.join([
53 'SELECT auth_user.id FROM auth_user',
54 'INNER JOIN cookbook_userpreference',
55 'ON (auth_user.id = cookbook_userpreference.user_id)',
56 'INNER JOIN cookbook_userpreference_shopping_share',
57 'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
58 'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
59 ]))
62auth.models.User.add_to_class('get_user_display_name', get_user_display_name)
63auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
64auth.models.User.add_to_class('get_active_space', get_active_space)
67def oauth_token_get_owner(self):
68 return self.user
71oauth2_provider.models.AccessToken.add_to_class('get_owner', oauth_token_get_owner)
74def get_model_name(model):
75 return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
78class TreeManager(MP_NodeManager):
79 def create(self, *args, **kwargs):
80 return self.get_or_create(*args, **kwargs)[0]
82 # model.Manager get_or_create() is not compatible with MP_Tree
83 def get_or_create(self, *args, **kwargs):
84 kwargs['name'] = kwargs['name'].strip()
85 if hasattr(self, 'space'):
86 if obj := self.filter(name__iexact=kwargs['name'], space=kwargs['space']).first():
87 return obj, False
88 else:
89 if obj := self.filter(name__iexact=kwargs['name']).first():
90 return obj, False
92 with scopes_disabled():
93 try:
94 defaults = kwargs.pop('defaults', None)
95 if defaults:
96 kwargs = {**kwargs, **defaults}
97 # ManyToMany fields can't be set this way, so pop them out to save for later
98 fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
99 many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
100 obj = self.model.add_root(**kwargs)
101 for field in many_to_many:
102 field_model = getattr(obj, field).model
103 for related_obj in many_to_many[field]:
104 if isinstance(related_obj, User):
105 getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
106 else:
107 getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
108 return obj, True
109 except IntegrityError as e:
110 if 'Key (path)' in e.args[0]:
111 self.model.fix_tree(fix_paths=True)
112 return self.model.add_root(**kwargs), True
115class TreeModel(MP_Node):
116 _full_name_separator = ' > '
118 def __str__(self):
119 return f"{self.name}"
121 @property
122 def parent(self):
123 parent = self.get_parent()
124 if parent:
125 return self.get_parent().id
126 return None
128 @property
129 def full_name(self):
130 """
131 Returns a string representation of a tree node and it's ancestors,
132 e.g. 'Cuisine > Asian > Chinese > Catonese'.
133 """
134 names = [node.name for node in self.get_ancestors_and_self()]
135 return self._full_name_separator.join(names)
137 def get_ancestors_and_self(self):
138 """
139 Gets ancestors and includes itself. Use treebeard's get_ancestors
140 if you don't want to include the node itself. It's a separate
141 function as it's commonly used in templates.
142 """
143 if self.is_root():
144 return [self]
145 return list(self.get_ancestors()) + [self]
147 def get_descendants_and_self(self):
148 """
149 Gets descendants and includes itself. Use treebeard's get_descendants
150 if you don't want to include the node itself. It's a separate
151 function as it's commonly used in templates.
152 """
153 return self.get_tree(self)
155 def has_children(self):
156 return self.get_num_children() > 0
158 def get_num_children(self):
159 return self.get_children().count()
161 # use self.objects.get_or_create() instead
162 @classmethod
163 def add_root(self, **kwargs):
164 with scopes_disabled():
165 return super().add_root(**kwargs)
167 # i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
168 @staticmethod
169 def include_descendants(queryset=None, filter=None):
170 """
171 :param queryset: Model Queryset to add descendants
172 :param filter: Filter (exclude) the descendants nodes with the provided Q filter
173 """
174 descendants = Q()
175 # TODO filter the queryset nodes to exclude descendants of objects in the queryset
176 nodes = queryset.values('path', 'depth')
177 for node in nodes:
178 descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
180 return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants)
182 def exclude_descendants(queryset=None, filter=None):
183 """
184 :param queryset: Model Queryset to add descendants
185 :param filter: Filter (include) the descendants nodes with the provided Q filter
186 """
187 descendants = Q()
188 nodes = queryset.values('path', 'depth')
189 for node in nodes:
190 descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
192 return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants)
194 def include_ancestors(queryset=None, filter=None):
195 """
196 :param queryset: Model Queryset to add ancestors
197 :param filter: Filter (include) the ancestors nodes with the provided Q filter
198 """
200 queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
201 nodes = list(set(queryset.values_list('root', 'depth')))
203 ancestors = Q()
204 for node in nodes:
205 ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
206 return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors)
208 # This doesn't work as expected - it excludes parrallel branches that share a common ancestor at similar lengths
209 # def exclude_ancestors(queryset=None):
210 # """
211 # :param queryset: Model Queryset to exclude ancestors
212 # :param filter: Filter (include) the ancestors nodes with the provided Q filter
213 # """
215 # queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
216 # nodes = list(set(queryset.values_list('root', 'depth')))
218 # ancestors = Q()
219 # for node in nodes:
220 # ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
221 # return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(ancestors)
223 class Meta:
224 abstract = True
227class PermissionModelMixin:
228 @staticmethod
229 def get_space_key():
230 return ('space',)
232 def get_space_kwarg(self):
233 return '__'.join(self.get_space_key())
235 def get_owner(self):
236 if getattr(self, 'created_by', None):
237 return self.created_by
238 if getattr(self, 'user', None):
239 return self.user
240 return None
242 def get_shared(self):
243 if getattr(self, 'shared', None):
244 return self.shared.all()
245 return []
247 def get_space(self):
248 p = '.'.join(self.get_space_key())
249 try:
250 if space := operator.attrgetter(p)(self):
251 return space
252 except AttributeError:
253 raise NotImplementedError('get space for method not implemented and standard fields not available')
256class FoodInheritField(models.Model, PermissionModelMixin):
257 field = models.CharField(max_length=32, unique=True)
258 name = models.CharField(max_length=64, unique=True)
260 def __str__(self):
261 return _(self.name)
263 @staticmethod
264 def get_name(self):
265 return _(self.name)
268class Space(ExportModelOperationsMixin('space'), models.Model):
269 name = models.CharField(max_length=128, default='Default')
270 image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image')
271 created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
272 created_at = models.DateTimeField(auto_now_add=True)
273 message = models.CharField(max_length=512, default='', blank=True)
274 max_recipes = models.IntegerField(default=0)
275 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.'))
276 max_users = models.IntegerField(default=0)
277 use_plural = models.BooleanField(default=True)
278 allow_sharing = models.BooleanField(default=True)
279 no_sharing_limit = models.BooleanField(default=False)
280 demo = models.BooleanField(default=False)
281 food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
283 internal_note = models.TextField(blank=True, null=True)
285 def safe_delete(self):
286 """
287 Safely deletes a space by deleting all objects belonging to the space first and then deleting the space itself
288 """
289 CookLog.objects.filter(space=self).delete()
290 ViewLog.objects.filter(space=self).delete()
291 ImportLog.objects.filter(space=self).delete()
292 BookmarkletImport.objects.filter(space=self).delete()
293 CustomFilter.objects.filter(space=self).delete()
295 Comment.objects.filter(recipe__space=self).delete()
296 Keyword.objects.filter(space=self).delete()
297 Ingredient.objects.filter(space=self).delete()
298 Food.objects.filter(space=self).delete()
299 Unit.objects.filter(space=self).delete()
300 Step.objects.filter(space=self).delete()
301 NutritionInformation.objects.filter(space=self).delete()
302 RecipeBookEntry.objects.filter(book__space=self).delete()
303 RecipeBook.objects.filter(space=self).delete()
304 MealType.objects.filter(space=self).delete()
305 MealPlan.objects.filter(space=self).delete()
306 ShareLink.objects.filter(space=self).delete()
307 Recipe.objects.filter(space=self).delete()
309 RecipeImport.objects.filter(space=self).delete()
310 SyncLog.objects.filter(sync__space=self).delete()
311 Sync.objects.filter(space=self).delete()
312 Storage.objects.filter(space=self).delete()
314 ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
315 ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
316 ShoppingList.objects.filter(space=self).delete()
318 SupermarketCategoryRelation.objects.filter(supermarket__space=self).delete()
319 SupermarketCategory.objects.filter(space=self).delete()
320 Supermarket.objects.filter(space=self).delete()
322 InviteLink.objects.filter(space=self).delete()
323 UserFile.objects.filter(space=self).delete()
324 Automation.objects.filter(space=self).delete()
325 self.delete()
327 def get_owner(self):
328 return self.created_by
330 def get_space(self):
331 return self
333 def __str__(self):
334 return self.name
337class UserPreference(models.Model, PermissionModelMixin):
338 # Themes
339 BOOTSTRAP = 'BOOTSTRAP'
340 DARKLY = 'DARKLY'
341 FLATLY = 'FLATLY'
342 SUPERHERO = 'SUPERHERO'
343 TANDOOR = 'TANDOOR'
344 TANDOOR_DARK = 'TANDOOR_DARK'
346 THEMES = (
347 (TANDOOR, 'Tandoor'),
348 (BOOTSTRAP, 'Bootstrap'),
349 (DARKLY, 'Darkly'),
350 (FLATLY, 'Flatly'),
351 (SUPERHERO, 'Superhero'),
352 (TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'),
353 )
355 # Nav colors
356 PRIMARY = 'PRIMARY'
357 SECONDARY = 'SECONDARY'
358 SUCCESS = 'SUCCESS'
359 INFO = 'INFO'
360 WARNING = 'WARNING'
361 DANGER = 'DANGER'
362 LIGHT = 'LIGHT'
363 DARK = 'DARK'
365 COLORS = (
366 (PRIMARY, 'Primary'),
367 (SECONDARY, 'Secondary'),
368 (SUCCESS, 'Success'),
369 (INFO, 'Info'),
370 (WARNING, 'Warning'),
371 (DANGER, 'Danger'),
372 (LIGHT, 'Light'),
373 (DARK, 'Dark')
374 )
376 # Default Page
377 SEARCH = 'SEARCH'
378 PLAN = 'PLAN'
379 BOOKS = 'BOOKS'
381 PAGES = (
382 (SEARCH, _('Search')),
383 (PLAN, _('Meal-Plan')),
384 (BOOKS, _('Books')),
385 )
387 user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
388 image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image')
389 theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
390 nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
391 default_unit = models.CharField(max_length=32, default='g')
392 use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
393 use_kj = models.BooleanField(default=KJ_PREF_DEFAULT)
394 default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH)
395 plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
396 shopping_share = models.ManyToManyField(User, blank=True, related_name='shopping_share')
397 ingredient_decimals = models.IntegerField(default=2)
398 comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
399 shopping_auto_sync = models.IntegerField(default=5)
400 sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
401 mealplan_autoadd_shopping = models.BooleanField(default=False)
402 mealplan_autoexclude_onhand = models.BooleanField(default=True)
403 mealplan_autoinclude_related = models.BooleanField(default=True)
404 shopping_add_onhand = models.BooleanField(default=False)
405 filter_to_supermarket = models.BooleanField(default=False)
406 left_handed = models.BooleanField(default=False)
407 show_step_ingredients = models.BooleanField(default=True)
408 default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
409 shopping_recent_days = models.PositiveIntegerField(default=7)
410 csv_delim = models.CharField(max_length=2, default=",")
411 csv_prefix = models.CharField(max_length=10, blank=True, )
412 ingredient_context = models.BooleanField(default=False)
414 created_at = models.DateTimeField(auto_now_add=True)
415 objects = ScopedManager(space='space')
417 def __str__(self):
418 return str(self.user)
421class UserSpace(models.Model, PermissionModelMixin):
422 user = models.ForeignKey(User, on_delete=models.CASCADE)
423 space = models.ForeignKey(Space, on_delete=models.CASCADE)
424 groups = models.ManyToManyField(Group)
426 # there should always only be one active space although permission methods are written in such a way
427 # that having more than one active space should just break certain parts of the application and not leak any data
428 active = models.BooleanField(default=False)
430 invite_link = models.ForeignKey("InviteLink", on_delete=models.PROTECT, null=True, blank=True)
431 internal_note = models.TextField(blank=True, null=True)
433 created_at = models.DateTimeField(auto_now_add=True)
434 updated_at = models.DateTimeField(auto_now=True)
437class Storage(models.Model, PermissionModelMixin):
438 DROPBOX = 'DB'
439 NEXTCLOUD = 'NEXTCLOUD'
440 LOCAL = 'LOCAL'
441 STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'), (LOCAL, 'Local'))
443 name = models.CharField(max_length=128)
444 method = models.CharField(
445 choices=STORAGE_TYPES, max_length=128, default=DROPBOX
446 )
447 username = models.CharField(max_length=128, blank=True, null=True)
448 password = models.CharField(max_length=128, blank=True, null=True)
449 token = models.CharField(max_length=512, blank=True, null=True)
450 url = models.URLField(blank=True, null=True)
451 path = models.CharField(blank=True, default='', max_length=256)
452 created_by = models.ForeignKey(User, on_delete=models.PROTECT)
454 space = models.ForeignKey(Space, on_delete=models.CASCADE)
455 objects = ScopedManager(space='space')
457 def __str__(self):
458 return self.name
461class Sync(models.Model, PermissionModelMixin):
462 storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
463 path = models.CharField(max_length=512, default="")
464 active = models.BooleanField(default=True)
465 last_checked = models.DateTimeField(null=True)
466 created_at = models.DateTimeField(auto_now_add=True)
467 updated_at = models.DateTimeField(auto_now=True)
469 space = models.ForeignKey(Space, on_delete=models.CASCADE)
470 objects = ScopedManager(space='space')
472 def __str__(self):
473 return self.path
476class SupermarketCategory(models.Model, PermissionModelMixin):
477 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
478 description = models.TextField(blank=True, null=True)
479 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
481 space = models.ForeignKey(Space, on_delete=models.CASCADE)
482 objects = ScopedManager(space='space')
484 def __str__(self):
485 return self.name
487 class Meta:
488 constraints = [
489 models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
490 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_category_unique_open_data_slug_per_space')
491 ]
494class Supermarket(models.Model, PermissionModelMixin):
495 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
496 description = models.TextField(blank=True, null=True)
497 categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
498 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
500 space = models.ForeignKey(Space, on_delete=models.CASCADE)
501 objects = ScopedManager(space='space')
503 def __str__(self):
504 return self.name
506 class Meta:
507 constraints = [
508 models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space'),
509 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_unique_open_data_slug_per_space')
510 ]
513class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
514 supermarket = models.ForeignKey(Supermarket, on_delete=models.CASCADE, related_name='category_to_supermarket')
515 category = models.ForeignKey(SupermarketCategory, on_delete=models.CASCADE, related_name='category_to_supermarket')
516 order = models.IntegerField(default=0)
518 objects = ScopedManager(space='supermarket__space')
520 @staticmethod
521 def get_space_key():
522 return 'supermarket', 'space'
524 class Meta:
525 constraints = [
526 models.UniqueConstraint(fields=['supermarket', 'category'], name='unique_sm_category_relation')
527 ]
528 ordering = ('order',)
531class SyncLog(models.Model, PermissionModelMixin):
532 sync = models.ForeignKey(Sync, on_delete=models.CASCADE)
533 status = models.CharField(max_length=32)
534 msg = models.TextField(default="")
535 created_at = models.DateTimeField(auto_now_add=True)
537 objects = ScopedManager(space='sync__space')
539 def __str__(self):
540 return f"{self.created_at}:{self.sync} - {self.status}"
543class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelMixin):
544 if SORT_TREE_BY_NAME:
545 node_order_by = ['name']
546 name = models.CharField(max_length=64)
547 description = models.TextField(default="", blank=True)
548 created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
549 updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
551 space = models.ForeignKey(Space, on_delete=models.CASCADE)
552 objects = ScopedManager(space='space', _manager_class=TreeManager)
554 class Meta:
555 constraints = [
556 models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space')
557 ]
558 indexes = (Index(fields=['id', 'name']),)
561class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
562 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
563 plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
564 description = models.TextField(blank=True, null=True)
565 base_unit = models.TextField(max_length=256, null=True, blank=True, default=None)
566 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
568 space = models.ForeignKey(Space, on_delete=models.CASCADE)
569 objects = ScopedManager(space='space')
571 def __str__(self):
572 return self.name
574 class Meta:
575 constraints = [
576 models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space'),
577 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_unique_open_data_slug_per_space')
578 ]
581class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
582 # TODO when savings a food as substitute children - assume children and descednants are also substitutes for siblings
583 # exclude fields not implemented yet
584 inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', ])
585 # TODO add inherit children_inherit, parent_inherit, Do Not Inherit
587 # WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals
588 if SORT_TREE_BY_NAME:
589 node_order_by = ['name']
590 name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
591 plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
592 recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
593 url = models.CharField(max_length=1024, blank=True, null=True, default='')
594 supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field
595 ignore_shopping = models.BooleanField(default=False) # inherited field
596 onhand_users = models.ManyToManyField(User, blank=True)
597 description = models.TextField(default='', blank=True)
598 inherit_fields = models.ManyToManyField(FoodInheritField, blank=True)
599 substitute = models.ManyToManyField("self", blank=True)
600 substitute_siblings = models.BooleanField(default=False)
601 substitute_children = models.BooleanField(default=False)
602 child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
604 properties = models.ManyToManyField("Property", blank=True, through='FoodProperty')
605 properties_food_amount = models.DecimalField(default=100, max_digits=16, decimal_places=2, blank=True)
606 properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True)
608 preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
609 preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
610 fdc_id = models.IntegerField(null=True, default=None, blank=True)
612 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
613 space = models.ForeignKey(Space, on_delete=models.CASCADE)
614 objects = ScopedManager(space='space', _manager_class=TreeManager)
616 def __str__(self):
617 return self.name
619 def delete(self):
620 if self.ingredient_set.all().exclude(step=None).count() > 0:
621 raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
622 else:
623 return super().delete()
625 # MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
627 def move(self, *args, **kwargs):
628 super().move(*args, **kwargs)
629 # treebeard bypasses ORM, need to explicity save to trigger post save signals retrieve the object again to avoid writing previous state back to disk
630 obj = self.__class__.objects.get(id=self.id)
631 if parent := obj.get_parent():
632 # child should inherit what the parent defines it should inherit
633 fields = list(parent.child_inherit_fields.all() or parent.inherit_fields.all())
634 if len(fields) > 0:
635 obj.inherit_fields.set(fields)
636 obj.save()
638 def get_substitutes(self, onhand=False, shopping_users=None):
639 # filters = ~Q(id=self.id)
640 filters = Q()
641 if self.substitute:
642 filters |= Q(id__in=self.substitute.values('id'))
643 if self.substitute_children:
644 filters |= Q(path__startswith=self.path, depth__gt=self.depth)
645 if self.substitute_siblings:
646 sibling_path = self.path[:Food.steplen * (self.depth - 1)]
647 filters |= Q(path__startswith=sibling_path, depth=self.depth)
649 qs = Food.objects.filter(filters).exclude(id=self.id)
650 if onhand:
651 qs = qs.filter(onhand_users__in=shopping_users)
652 return qs
654 @staticmethod
655 def reset_inheritance(space=None, food=None):
656 # resets inherited fields to the space defaults and updates all inherited fields to root object values
657 if food:
658 # if child inherit fields is preset children should be set to that, otherwise inherit this foods inherited fields
659 inherit = list((food.child_inherit_fields.all() or food.inherit_fields.all()).values('id', 'field'))
660 tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth + 1)
661 else:
662 inherit = list(space.food_inherit.all().values('id', 'field'))
663 tree_filter = Q(space=space)
665 # remove all inherited fields from food
666 trough = Food.inherit_fields.through
667 trough.objects.all().delete()
669 # food is going to inherit attributes
670 if len(inherit) > 0:
671 # ManyToMany cannot be updated through an UPDATE operation
672 for i in inherit:
673 trough.objects.bulk_create([
674 trough(food_id=x, foodinheritfield_id=i['id'])
675 for x in Food.objects.filter(tree_filter).values_list('id', flat=True)
676 ])
678 inherit = [x['field'] for x in inherit]
679 for field in ['ignore_shopping', 'substitute_children', 'substitute_siblings']:
680 if field in inherit:
681 if food and getattr(food, field, None):
682 food.get_descendants().update(**{f"{field}": True})
683 elif food and not getattr(food, field, True):
684 food.get_descendants().update(**{f"{field}": False})
685 else:
686 # get food at root that have children that need updated
687 Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, **{f"{field}": True}, space=space)).update(**{f"{field}": True})
688 Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, **{f"{field}": False}, space=space)).update(**{f"{field}": False})
690 if 'supermarket_category' in inherit:
691 # when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants
692 if food and food.supermarket_category:
693 food.get_descendants().update(supermarket_category=food.supermarket_category)
694 elif food is None:
695 # find top node that has category set
696 category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space))
697 for root in category_roots:
698 root.get_descendants().update(supermarket_category=root.supermarket_category)
700 class Meta:
701 constraints = [
702 models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space'),
703 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='food_unique_open_data_slug_per_space')
704 ]
705 indexes = (
706 Index(fields=['id']),
707 Index(fields=['name']),
708 )
711class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin):
712 base_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
713 base_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_base_relation')
714 converted_amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
715 converted_unit = models.ForeignKey('Unit', on_delete=models.CASCADE, related_name='unit_conversion_converted_relation')
717 food = models.ForeignKey('Food', on_delete=models.CASCADE, null=True, blank=True)
719 created_by = models.ForeignKey(User, on_delete=models.PROTECT)
720 created_at = models.DateTimeField(auto_now_add=True)
721 updated_at = models.DateTimeField(auto_now=True)
723 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
724 space = models.ForeignKey(Space, on_delete=models.CASCADE)
725 objects = ScopedManager(space='space')
727 def __str__(self):
728 return f'{self.base_amount} {self.base_unit} -> {self.converted_amount} {self.converted_unit} {self.food}'
730 class Meta:
731 constraints = [
732 models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space'),
733 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_conversion_unique_open_data_slug_per_space')
734 ]
737class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
738 # 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
739 food = models.ForeignKey(Food, on_delete=models.CASCADE, null=True, blank=True)
740 unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
741 amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
742 note = models.CharField(max_length=256, null=True, blank=True)
743 is_header = models.BooleanField(default=False)
744 no_amount = models.BooleanField(default=False)
745 always_use_plural_unit = models.BooleanField(default=False)
746 always_use_plural_food = models.BooleanField(default=False)
747 order = models.IntegerField(default=0)
748 original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
750 space = models.ForeignKey(Space, on_delete=models.CASCADE)
751 objects = ScopedManager(space='space')
753 def __str__(self):
754 return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
756 class Meta:
757 ordering = ['order', 'pk']
758 indexes = (
759 Index(fields=['id']),
760 )
763class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
764 name = models.CharField(max_length=128, default='', blank=True)
765 instruction = models.TextField(blank=True)
766 ingredients = models.ManyToManyField(Ingredient, blank=True)
767 time = models.IntegerField(default=0, blank=True)
768 order = models.IntegerField(default=0)
769 file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
770 show_as_header = models.BooleanField(default=True)
771 show_ingredients_table = models.BooleanField(default=True)
772 search_vector = SearchVectorField(null=True)
773 step_recipe = models.ForeignKey('Recipe', default=None, blank=True, null=True, on_delete=models.PROTECT)
775 space = models.ForeignKey(Space, on_delete=models.CASCADE)
776 objects = ScopedManager(space='space')
778 def get_instruction_render(self):
779 from cookbook.helper.template_helper import render_instructions
780 return render_instructions(self)
782 def __str__(self):
783 if not self.recipe_set.exists():
784 return f"{self.pk}: {_('Orphaned Step')}"
785 return f"{self.pk}: {self.name}" if self.name else f"Step: {self.pk}"
787 class Meta:
788 ordering = ['order', 'pk']
789 indexes = (GinIndex(fields=["search_vector"]),)
792class PropertyType(models.Model, PermissionModelMixin):
793 NUTRITION = 'NUTRITION'
794 ALLERGEN = 'ALLERGEN'
795 PRICE = 'PRICE'
796 GOAL = 'GOAL'
797 OTHER = 'OTHER'
799 name = models.CharField(max_length=128)
800 unit = models.CharField(max_length=64, blank=True, null=True)
801 order = models.IntegerField(default=0)
802 description = models.CharField(max_length=512, blank=True, null=True)
803 category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')),
804 (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
805 open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
807 fdc_id = models.IntegerField(null=True, default=None, blank=True)
808 # TODO show if empty property?
809 # TODO formatting property?
811 space = models.ForeignKey(Space, on_delete=models.CASCADE)
812 objects = ScopedManager(space='space')
814 def __str__(self):
815 return f'{self.name}'
817 class Meta:
818 constraints = [
819 models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'),
820 models.UniqueConstraint(fields=['space', 'open_data_slug'], name='property_type_unique_open_data_slug_per_space')
821 ]
822 ordering = ('order',)
825class Property(models.Model, PermissionModelMixin):
826 property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32)
827 property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT)
829 import_food_id = models.IntegerField(null=True, blank=True) # field to hold food id when importing properties from the open data project
831 space = models.ForeignKey(Space, on_delete=models.CASCADE)
832 objects = ScopedManager(space='space')
834 def __str__(self):
835 return f'{self.property_amount} {self.property_type.unit} {self.property_type.name}'
837 class Meta:
838 constraints = [
839 models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space')
840 ]
843class FoodProperty(models.Model):
844 food = models.ForeignKey(Food, on_delete=models.CASCADE)
845 property = models.ForeignKey(Property, on_delete=models.CASCADE)
847 class Meta:
848 constraints = [
849 models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'),
850 ]
853class NutritionInformation(models.Model, PermissionModelMixin):
854 fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
855 carbohydrates = models.DecimalField(
856 default=0, decimal_places=16, max_digits=32
857 )
858 proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
859 calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
860 source = models.CharField(max_length=512, default="", null=True, blank=True)
862 space = models.ForeignKey(Space, on_delete=models.CASCADE)
863 objects = ScopedManager(space='space')
865 def __str__(self):
866 return f'Nutrition {self.pk}'
869class RecipeManager(models.Manager.from_queryset(models.QuerySet)):
870 def get_queryset(self):
871 return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at'))
874class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
875 name = models.CharField(max_length=128)
876 description = models.CharField(max_length=512, blank=True, null=True)
877 servings = models.IntegerField(default=1)
878 servings_text = models.CharField(default='', blank=True, max_length=32)
879 image = models.ImageField(upload_to='recipes/', blank=True, null=True)
880 storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
881 file_uid = models.CharField(max_length=256, default="", blank=True)
882 file_path = models.CharField(max_length=512, default="", blank=True)
883 link = models.CharField(max_length=512, null=True, blank=True)
884 cors_link = models.CharField(max_length=1024, null=True, blank=True)
885 keywords = models.ManyToManyField(Keyword, blank=True)
886 steps = models.ManyToManyField(Step, blank=True)
887 working_time = models.IntegerField(default=0)
888 waiting_time = models.IntegerField(default=0)
889 internal = models.BooleanField(default=False)
890 nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
891 properties = models.ManyToManyField(Property, blank=True)
892 show_ingredient_overview = models.BooleanField(default=True)
893 private = models.BooleanField(default=False)
894 shared = models.ManyToManyField(User, blank=True, related_name='recipe_shared_with')
896 source_url = models.CharField(max_length=1024, default=None, blank=True, null=True)
897 created_by = models.ForeignKey(User, on_delete=models.PROTECT)
898 created_at = models.DateTimeField(auto_now_add=True)
899 updated_at = models.DateTimeField(auto_now=True)
901 name_search_vector = SearchVectorField(null=True)
902 desc_search_vector = SearchVectorField(null=True)
903 space = models.ForeignKey(Space, on_delete=models.CASCADE)
905 objects = ScopedManager(space='space', _manager_class=RecipeManager)
907 def __str__(self):
908 return self.name
910 def get_related_recipes(self, levels=1):
911 # recipes for step recipe
912 step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe'))
913 # recipes for foods
914 food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe'))
915 related_recipes = Recipe.objects.filter(step_recipes | food_recipes)
916 if levels == 1:
917 return related_recipes
919 # this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?)
920 # for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios
921 sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe'))
922 sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
923 return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
925 class Meta():
926 indexes = (
927 GinIndex(fields=["name_search_vector"]),
928 GinIndex(fields=["desc_search_vector"]),
929 Index(fields=['id']),
930 Index(fields=['name']),
931 )
934class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin):
935 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
936 text = models.TextField()
937 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
938 created_at = models.DateTimeField(auto_now_add=True)
939 updated_at = models.DateTimeField(auto_now=True)
941 objects = ScopedManager(space='recipe__space')
943 @staticmethod
944 def get_space_key():
945 return 'recipe', 'space'
947 def get_space(self):
948 return self.recipe.space
950 def __str__(self):
951 return self.text
954class RecipeImport(models.Model, PermissionModelMixin):
955 name = models.CharField(max_length=128)
956 storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
957 file_uid = models.CharField(max_length=256, default="")
958 file_path = models.CharField(max_length=512, default="")
959 created_at = models.DateTimeField(auto_now_add=True)
961 space = models.ForeignKey(Space, on_delete=models.CASCADE)
962 objects = ScopedManager(space='space')
964 def __str__(self):
965 return self.name
968class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
969 name = models.CharField(max_length=128)
970 description = models.TextField(blank=True)
971 shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
972 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
973 filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
975 space = models.ForeignKey(Space, on_delete=models.CASCADE)
976 objects = ScopedManager(space='space')
978 def __str__(self):
979 return self.name
981 class Meta():
982 indexes = (Index(fields=['name']),)
985class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin):
986 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
987 book = models.ForeignKey(RecipeBook, on_delete=models.CASCADE)
989 objects = ScopedManager(space='book__space')
991 @staticmethod
992 def get_space_key():
993 return 'book', 'space'
995 def __str__(self):
996 return self.recipe.name
998 def get_owner(self):
999 try:
1000 return self.book.created_by
1001 except AttributeError:
1002 return None
1004 class Meta:
1005 constraints = [
1006 models.UniqueConstraint(fields=['recipe', 'book'], name='rbe_unique_name_per_space')
1007 ]
1010class MealType(models.Model, PermissionModelMixin):
1011 name = models.CharField(max_length=128)
1012 order = models.IntegerField(default=0)
1013 color = models.CharField(max_length=7, blank=True, null=True)
1014 default = models.BooleanField(default=False, blank=True)
1015 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1017 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1018 objects = ScopedManager(space='space')
1020 def __str__(self):
1021 return self.name
1023 class Meta:
1024 constraints = [
1025 models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
1026 ]
1029class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
1030 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
1031 servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
1032 title = models.CharField(max_length=64, blank=True, default='')
1033 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1034 shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
1035 meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
1036 note = models.TextField(blank=True)
1037 from_date = models.DateField()
1038 to_date = models.DateField()
1040 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1041 objects = ScopedManager(space='space')
1043 def get_label(self):
1044 if self.title:
1045 return self.title
1046 return str(self.recipe)
1048 def get_meal_name(self):
1049 return self.meal_type.name
1051 def __str__(self):
1052 return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}'
1055class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
1056 name = models.CharField(max_length=32, blank=True, default='')
1057 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated
1058 servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
1059 mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
1061 objects = ScopedManager(space='recipe__space')
1063 @staticmethod
1064 def get_space_key():
1065 return 'recipe', 'space'
1067 def get_space(self):
1068 return self.recipe.space
1070 def __str__(self):
1071 return f'Shopping list recipe {self.id} - {self.recipe}'
1073 def get_owner(self):
1074 try:
1075 return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
1076 except AttributeError:
1077 return None
1080class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
1081 list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
1082 food = models.ForeignKey(Food, on_delete=models.CASCADE, related_name='shopping_entries')
1083 unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
1084 ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True)
1085 amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
1086 order = models.IntegerField(default=0)
1087 checked = models.BooleanField(default=False)
1088 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1089 created_at = models.DateTimeField(auto_now_add=True)
1090 completed_at = models.DateTimeField(null=True, blank=True)
1091 delay_until = models.DateTimeField(null=True, blank=True)
1093 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1094 objects = ScopedManager(space='space')
1096 @staticmethod
1097 def get_space_key():
1098 return 'shoppinglist', 'space'
1100 def get_space(self):
1101 return self.shoppinglist_set.first().space
1103 def __str__(self):
1104 return f'Shopping list entry {self.id}'
1106 def get_shared(self):
1107 try:
1108 return self.shoppinglist_set.first().shared.all()
1109 except AttributeError:
1110 return self.created_by.userpreference.shopping_share.all()
1112 def get_owner(self):
1113 try:
1114 return self.created_by or self.shoppinglist_set.first().created_by
1115 except AttributeError:
1116 return None
1119class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, PermissionModelMixin):
1120 uuid = models.UUIDField(default=uuid.uuid4)
1121 note = models.TextField(blank=True, null=True)
1122 recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
1123 entries = models.ManyToManyField(ShoppingListEntry, blank=True)
1124 shared = models.ManyToManyField(User, blank=True, related_name='list_share')
1125 supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL)
1126 finished = models.BooleanField(default=False)
1127 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1128 created_at = models.DateTimeField(auto_now_add=True)
1130 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1131 objects = ScopedManager(space='space')
1133 def __str__(self):
1134 return f'Shopping list {self.id}'
1136 def get_shared(self):
1137 try:
1138 return self.shared.all() or self.created_by.userpreference.shopping_share.all()
1139 except AttributeError:
1140 return []
1143class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
1144 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
1145 uuid = models.UUIDField(default=uuid.uuid4)
1146 request_count = models.IntegerField(default=0)
1147 abuse_blocked = models.BooleanField(default=False)
1148 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1149 created_at = models.DateTimeField(auto_now_add=True)
1151 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1152 objects = ScopedManager(space='space')
1154 def __str__(self):
1155 return f'{self.recipe} - {self.uuid}'
1158def default_valid_until():
1159 return date.today() + timedelta(days=14)
1162class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, PermissionModelMixin):
1163 uuid = models.UUIDField(default=uuid.uuid4)
1164 email = models.EmailField(blank=True)
1165 group = models.ForeignKey(Group, on_delete=models.CASCADE)
1166 valid_until = models.DateField(default=default_valid_until)
1167 used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by')
1168 reusable = models.BooleanField(default=False)
1169 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1170 created_at = models.DateTimeField(auto_now_add=True)
1172 internal_note = models.TextField(blank=True, null=True)
1174 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1175 objects = ScopedManager(space='space')
1177 def __str__(self):
1178 return f'{self.uuid}'
1181class TelegramBot(models.Model, PermissionModelMixin):
1182 token = models.CharField(max_length=256)
1183 name = models.CharField(max_length=128, default='', blank=True)
1184 chat_id = models.CharField(max_length=128, default='', blank=True)
1185 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1186 webhook_token = models.UUIDField(default=uuid.uuid4)
1188 objects = ScopedManager(space='space')
1189 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1191 def __str__(self):
1192 return f"{self.name}"
1195class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
1196 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
1197 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1198 created_at = models.DateTimeField(default=timezone.now)
1199 rating = models.IntegerField(null=True)
1200 servings = models.IntegerField(default=0)
1202 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1203 objects = ScopedManager(space='space')
1205 def __str__(self):
1206 return self.recipe.name
1208 class Meta():
1209 indexes = (
1210 Index(fields=['id']),
1211 Index(fields=['recipe']),
1212 Index(fields=['-created_at']),
1213 Index(fields=['rating']),
1214 Index(fields=['created_by']),
1215 Index(fields=['created_by', 'rating']),
1216 )
1219class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
1220 recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
1221 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1222 created_at = models.DateTimeField(auto_now_add=True)
1224 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1225 objects = ScopedManager(space='space')
1227 def __str__(self):
1228 return self.recipe.name
1230 class Meta():
1231 indexes = (
1232 Index(fields=['recipe']),
1233 Index(fields=['-created_at']),
1234 Index(fields=['created_by']),
1235 Index(fields=['recipe', '-created_at', 'created_by']),
1236 )
1239class ImportLog(models.Model, PermissionModelMixin):
1240 type = models.CharField(max_length=32)
1241 running = models.BooleanField(default=True)
1242 msg = models.TextField(default="")
1243 keyword = models.ForeignKey(Keyword, null=True, blank=True, on_delete=models.SET_NULL)
1245 total_recipes = models.IntegerField(default=0)
1246 imported_recipes = models.IntegerField(default=0)
1248 created_at = models.DateTimeField(auto_now_add=True)
1249 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1251 objects = ScopedManager(space='space')
1252 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1254 def __str__(self):
1255 return f"{self.created_at}:{self.type}"
1258class ExportLog(models.Model, PermissionModelMixin):
1259 type = models.CharField(max_length=32)
1260 running = models.BooleanField(default=True)
1261 msg = models.TextField(default="")
1263 total_recipes = models.IntegerField(default=0)
1264 exported_recipes = models.IntegerField(default=0)
1265 cache_duration = models.IntegerField(default=0)
1266 possibly_not_expired = models.BooleanField(default=True)
1268 created_at = models.DateTimeField(auto_now_add=True)
1269 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1271 objects = ScopedManager(space='space')
1272 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1274 def __str__(self):
1275 return f"{self.created_at}:{self.type}"
1278class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
1279 html = models.TextField()
1280 url = models.CharField(max_length=256, null=True, blank=True)
1281 created_at = models.DateTimeField(auto_now_add=True)
1282 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1284 objects = ScopedManager(space='space')
1285 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1288# field names used to configure search behavior - all data populated during data migration
1289# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield
1290class SearchFields(models.Model, PermissionModelMixin):
1291 name = models.CharField(max_length=32, unique=True)
1292 field = models.CharField(max_length=64, unique=True)
1294 def __str__(self):
1295 return _(self.name)
1297 @staticmethod
1298 def get_name(self):
1299 return _(self.name)
1302class SearchPreference(models.Model, PermissionModelMixin):
1303 # Search Style (validation parsleyjs.org)
1304 # phrase or plain or raw (websearch and trigrams are mutually exclusive)
1305 SIMPLE = 'plain'
1306 PHRASE = 'phrase'
1307 WEB = 'websearch'
1308 RAW = 'raw'
1309 SEARCH_STYLE = (
1310 (SIMPLE, _('Simple')),
1311 (PHRASE, _('Phrase')),
1312 (WEB, _('Web')),
1313 (RAW, _('Raw'))
1314 )
1316 user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
1317 search = models.CharField(choices=SEARCH_STYLE, max_length=32, default=SIMPLE)
1319 lookup = models.BooleanField(default=False)
1320 unaccent = models.ManyToManyField(SearchFields, related_name="unaccent_fields", blank=True)
1321 icontains = models.ManyToManyField(SearchFields, related_name="icontains_fields", blank=True)
1322 istartswith = models.ManyToManyField(SearchFields, related_name="istartswith_fields", blank=True)
1323 trigram = models.ManyToManyField(SearchFields, related_name="trigram_fields", blank=True)
1324 fulltext = models.ManyToManyField(SearchFields, related_name="fulltext_fields", blank=True)
1325 trigram_threshold = models.DecimalField(default=0.2, decimal_places=2, max_digits=3)
1328class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
1329 name = models.CharField(max_length=128)
1330 file = models.FileField(upload_to='files/')
1331 file_size_kb = models.IntegerField(default=0, blank=True)
1332 created_at = models.DateTimeField(auto_now_add=True)
1333 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1335 objects = ScopedManager(space='space')
1336 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1338 def is_image(self):
1339 try:
1340 Image.open(self.file.file.file)
1341 return True
1342 except Exception:
1343 return False
1345 def save(self, *args, **kwargs):
1346 if hasattr(self.file, 'file') and isinstance(self.file.file, UploadedFile) or isinstance(self.file.file, InMemoryUploadedFile):
1347 self.file.name = f'{uuid.uuid4()}' + pathlib.Path(self.file.name).suffix
1348 self.file_size_kb = round(self.file.size / 1000)
1349 super(UserFile, self).save(*args, **kwargs)
1352class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
1353 FOOD_ALIAS = 'FOOD_ALIAS'
1354 UNIT_ALIAS = 'UNIT_ALIAS'
1355 KEYWORD_ALIAS = 'KEYWORD_ALIAS'
1356 DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE'
1357 INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE'
1358 NEVER_UNIT = 'NEVER_UNIT'
1359 TRANSPOSE_WORDS = 'TRANSPOSE_WORDS'
1360 FOOD_REPLACE = 'FOOD_REPLACE'
1361 UNIT_REPLACE = 'UNIT_REPLACE'
1362 NAME_REPLACE = 'NAME_REPLACE'
1364 type = models.CharField(max_length=128,
1365 choices=(
1366 (FOOD_ALIAS, _('Food Alias')),
1367 (UNIT_ALIAS, _('Unit Alias')),
1368 (KEYWORD_ALIAS, _('Keyword Alias')),
1369 (DESCRIPTION_REPLACE, _('Description Replace')),
1370 (INSTRUCTION_REPLACE, _('Instruction Replace')),
1371 (NEVER_UNIT, _('Never Unit')),
1372 (TRANSPOSE_WORDS, _('Transpose Words')),
1373 (FOOD_REPLACE, _('Food Replace')),
1374 (UNIT_REPLACE, _('Unit Replace')),
1375 (NAME_REPLACE, _('Name Replace')),
1376 ))
1377 name = models.CharField(max_length=128, default='')
1378 description = models.TextField(blank=True, null=True)
1380 param_1 = models.CharField(max_length=128, blank=True, null=True)
1381 param_2 = models.CharField(max_length=128, blank=True, null=True)
1382 param_3 = models.CharField(max_length=128, blank=True, null=True)
1384 order = models.IntegerField(default=1000)
1386 disabled = models.BooleanField(default=False)
1388 updated_at = models.DateTimeField(auto_now=True)
1389 created_at = models.DateTimeField(auto_now_add=True)
1390 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1392 objects = ScopedManager(space='space')
1393 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1396class CustomFilter(models.Model, PermissionModelMixin):
1397 RECIPE = 'RECIPE'
1398 FOOD = 'FOOD'
1399 KEYWORD = 'KEYWORD'
1401 MODELS = (
1402 (RECIPE, _('Recipe')),
1403 (FOOD, _('Food')),
1404 (KEYWORD, _('Keyword')),
1405 )
1407 name = models.CharField(max_length=128, null=False, blank=False)
1408 type = models.CharField(max_length=128, choices=(MODELS), default=MODELS[0])
1409 # could use JSONField, but requires installing extension on SQLite, don't need to search the objects, so seems unecessary
1410 search = models.TextField(blank=False, null=False)
1411 created_at = models.DateTimeField(auto_now_add=True)
1412 created_by = models.ForeignKey(User, on_delete=models.CASCADE)
1413 shared = models.ManyToManyField(User, blank=True, related_name='f_shared_with')
1415 objects = ScopedManager(space='space')
1416 space = models.ForeignKey(Space, on_delete=models.CASCADE)
1418 def __str__(self):
1419 return self.name
1421 class Meta:
1422 constraints = [
1423 models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space')
1424 ]