Coverage for cookbook/models.py: 85%

918 statements  

« 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 

6 

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 

25 

26from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT, 

27 SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT) 

28 

29 

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 

35 

36 

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 

48 

49 

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

60 

61 

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) 

65 

66 

67def oauth_token_get_owner(self): 

68 return self.user 

69 

70 

71oauth2_provider.models.AccessToken.add_to_class('get_owner', oauth_token_get_owner) 

72 

73 

74def get_model_name(model): 

75 return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower() 

76 

77 

78class TreeManager(MP_NodeManager): 

79 def create(self, *args, **kwargs): 

80 return self.get_or_create(*args, **kwargs)[0] 

81 

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 

91 

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 

113 

114 

115class TreeModel(MP_Node): 

116 _full_name_separator = ' > ' 

117 

118 def __str__(self): 

119 return f"{self.name}" 

120 

121 @property 

122 def parent(self): 

123 parent = self.get_parent() 

124 if parent: 

125 return self.get_parent().id 

126 return None 

127 

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) 

136 

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] 

146 

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) 

154 

155 def has_children(self): 

156 return self.get_num_children() > 0 

157 

158 def get_num_children(self): 

159 return self.get_children().count() 

160 

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) 

166 

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

179 

180 return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants) 

181 

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

191 

192 return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants) 

193 

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 """ 

199 

200 queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen)) 

201 nodes = list(set(queryset.values_list('root', 'depth'))) 

202 

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) 

207 

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 # """ 

214 

215 # queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen)) 

216 # nodes = list(set(queryset.values_list('root', 'depth'))) 

217 

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) 

222 

223 class Meta: 

224 abstract = True 

225 

226 

227class PermissionModelMixin: 

228 @staticmethod 

229 def get_space_key(): 

230 return ('space',) 

231 

232 def get_space_kwarg(self): 

233 return '__'.join(self.get_space_key()) 

234 

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 

241 

242 def get_shared(self): 

243 if getattr(self, 'shared', None): 

244 return self.shared.all() 

245 return [] 

246 

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

254 

255 

256class FoodInheritField(models.Model, PermissionModelMixin): 

257 field = models.CharField(max_length=32, unique=True) 

258 name = models.CharField(max_length=64, unique=True) 

259 

260 def __str__(self): 

261 return _(self.name) 

262 

263 @staticmethod 

264 def get_name(self): 

265 return _(self.name) 

266 

267 

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) 

282 

283 internal_note = models.TextField(blank=True, null=True) 

284 

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

294 

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

308 

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

313 

314 ShoppingListEntry.objects.filter(shoppinglist__space=self).delete() 

315 ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete() 

316 ShoppingList.objects.filter(space=self).delete() 

317 

318 SupermarketCategoryRelation.objects.filter(supermarket__space=self).delete() 

319 SupermarketCategory.objects.filter(space=self).delete() 

320 Supermarket.objects.filter(space=self).delete() 

321 

322 InviteLink.objects.filter(space=self).delete() 

323 UserFile.objects.filter(space=self).delete() 

324 Automation.objects.filter(space=self).delete() 

325 self.delete() 

326 

327 def get_owner(self): 

328 return self.created_by 

329 

330 def get_space(self): 

331 return self 

332 

333 def __str__(self): 

334 return self.name 

335 

336 

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' 

345 

346 THEMES = ( 

347 (TANDOOR, 'Tandoor'), 

348 (BOOTSTRAP, 'Bootstrap'), 

349 (DARKLY, 'Darkly'), 

350 (FLATLY, 'Flatly'), 

351 (SUPERHERO, 'Superhero'), 

352 (TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'), 

353 ) 

354 

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' 

364 

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 ) 

375 

376 # Default Page 

377 SEARCH = 'SEARCH' 

378 PLAN = 'PLAN' 

379 BOOKS = 'BOOKS' 

380 

381 PAGES = ( 

382 (SEARCH, _('Search')), 

383 (PLAN, _('Meal-Plan')), 

384 (BOOKS, _('Books')), 

385 ) 

386 

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) 

413 

414 created_at = models.DateTimeField(auto_now_add=True) 

415 objects = ScopedManager(space='space') 

416 

417 def __str__(self): 

418 return str(self.user) 

419 

420 

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) 

425 

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) 

429 

430 invite_link = models.ForeignKey("InviteLink", on_delete=models.PROTECT, null=True, blank=True) 

431 internal_note = models.TextField(blank=True, null=True) 

432 

433 created_at = models.DateTimeField(auto_now_add=True) 

434 updated_at = models.DateTimeField(auto_now=True) 

435 

436 

437class Storage(models.Model, PermissionModelMixin): 

438 DROPBOX = 'DB' 

439 NEXTCLOUD = 'NEXTCLOUD' 

440 LOCAL = 'LOCAL' 

441 STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'), (LOCAL, 'Local')) 

442 

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) 

453 

454 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

455 objects = ScopedManager(space='space') 

456 

457 def __str__(self): 

458 return self.name 

459 

460 

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) 

468 

469 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

470 objects = ScopedManager(space='space') 

471 

472 def __str__(self): 

473 return self.path 

474 

475 

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) 

480 

481 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

482 objects = ScopedManager(space='space') 

483 

484 def __str__(self): 

485 return self.name 

486 

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 ] 

492 

493 

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) 

499 

500 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

501 objects = ScopedManager(space='space') 

502 

503 def __str__(self): 

504 return self.name 

505 

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 ] 

511 

512 

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) 

517 

518 objects = ScopedManager(space='supermarket__space') 

519 

520 @staticmethod 

521 def get_space_key(): 

522 return 'supermarket', 'space' 

523 

524 class Meta: 

525 constraints = [ 

526 models.UniqueConstraint(fields=['supermarket', 'category'], name='unique_sm_category_relation') 

527 ] 

528 ordering = ('order',) 

529 

530 

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) 

536 

537 objects = ScopedManager(space='sync__space') 

538 

539 def __str__(self): 

540 return f"{self.created_at}:{self.sync} - {self.status}" 

541 

542 

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 

550 

551 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

552 objects = ScopedManager(space='space', _manager_class=TreeManager) 

553 

554 class Meta: 

555 constraints = [ 

556 models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space') 

557 ] 

558 indexes = (Index(fields=['id', 'name']),) 

559 

560 

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) 

567 

568 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

569 objects = ScopedManager(space='space') 

570 

571 def __str__(self): 

572 return self.name 

573 

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 ] 

579 

580 

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 

586 

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

603 

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) 

607 

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) 

611 

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) 

615 

616 def __str__(self): 

617 return self.name 

618 

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

624 

625 # MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal 

626 

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

637 

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) 

648 

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 

653 

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) 

664 

665 # remove all inherited fields from food 

666 trough = Food.inherit_fields.through 

667 trough.objects.all().delete() 

668 

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

677 

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

689 

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) 

699 

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 ) 

709 

710 

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

716 

717 food = models.ForeignKey('Food', on_delete=models.CASCADE, null=True, blank=True) 

718 

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) 

722 

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

726 

727 def __str__(self): 

728 return f'{self.base_amount} {self.base_unit} -> {self.converted_amount} {self.converted_unit} {self.food}' 

729 

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 ] 

735 

736 

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) 

749 

750 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

751 objects = ScopedManager(space='space') 

752 

753 def __str__(self): 

754 return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}' 

755 

756 class Meta: 

757 ordering = ['order', 'pk'] 

758 indexes = ( 

759 Index(fields=['id']), 

760 ) 

761 

762 

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) 

774 

775 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

776 objects = ScopedManager(space='space') 

777 

778 def get_instruction_render(self): 

779 from cookbook.helper.template_helper import render_instructions 

780 return render_instructions(self) 

781 

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}" 

786 

787 class Meta: 

788 ordering = ['order', 'pk'] 

789 indexes = (GinIndex(fields=["search_vector"]),) 

790 

791 

792class PropertyType(models.Model, PermissionModelMixin): 

793 NUTRITION = 'NUTRITION' 

794 ALLERGEN = 'ALLERGEN' 

795 PRICE = 'PRICE' 

796 GOAL = 'GOAL' 

797 OTHER = 'OTHER' 

798 

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) 

806 

807 fdc_id = models.IntegerField(null=True, default=None, blank=True) 

808 # TODO show if empty property? 

809 # TODO formatting property? 

810 

811 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

812 objects = ScopedManager(space='space') 

813 

814 def __str__(self): 

815 return f'{self.name}' 

816 

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

823 

824 

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) 

828 

829 import_food_id = models.IntegerField(null=True, blank=True) # field to hold food id when importing properties from the open data project 

830 

831 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

832 objects = ScopedManager(space='space') 

833 

834 def __str__(self): 

835 return f'{self.property_amount} {self.property_type.unit} {self.property_type.name}' 

836 

837 class Meta: 

838 constraints = [ 

839 models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space') 

840 ] 

841 

842 

843class FoodProperty(models.Model): 

844 food = models.ForeignKey(Food, on_delete=models.CASCADE) 

845 property = models.ForeignKey(Property, on_delete=models.CASCADE) 

846 

847 class Meta: 

848 constraints = [ 

849 models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'), 

850 ] 

851 

852 

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) 

861 

862 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

863 objects = ScopedManager(space='space') 

864 

865 def __str__(self): 

866 return f'Nutrition {self.pk}' 

867 

868 

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

872 

873 

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

895 

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) 

900 

901 name_search_vector = SearchVectorField(null=True) 

902 desc_search_vector = SearchVectorField(null=True) 

903 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

904 

905 objects = ScopedManager(space='space', _manager_class=RecipeManager) 

906 

907 def __str__(self): 

908 return self.name 

909 

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 

918 

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) 

924 

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 ) 

932 

933 

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) 

940 

941 objects = ScopedManager(space='recipe__space') 

942 

943 @staticmethod 

944 def get_space_key(): 

945 return 'recipe', 'space' 

946 

947 def get_space(self): 

948 return self.recipe.space 

949 

950 def __str__(self): 

951 return self.text 

952 

953 

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) 

960 

961 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

962 objects = ScopedManager(space='space') 

963 

964 def __str__(self): 

965 return self.name 

966 

967 

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) 

974 

975 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

976 objects = ScopedManager(space='space') 

977 

978 def __str__(self): 

979 return self.name 

980 

981 class Meta(): 

982 indexes = (Index(fields=['name']),) 

983 

984 

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) 

988 

989 objects = ScopedManager(space='book__space') 

990 

991 @staticmethod 

992 def get_space_key(): 

993 return 'book', 'space' 

994 

995 def __str__(self): 

996 return self.recipe.name 

997 

998 def get_owner(self): 

999 try: 

1000 return self.book.created_by 

1001 except AttributeError: 

1002 return None 

1003 

1004 class Meta: 

1005 constraints = [ 

1006 models.UniqueConstraint(fields=['recipe', 'book'], name='rbe_unique_name_per_space') 

1007 ] 

1008 

1009 

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) 

1016 

1017 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1018 objects = ScopedManager(space='space') 

1019 

1020 def __str__(self): 

1021 return self.name 

1022 

1023 class Meta: 

1024 constraints = [ 

1025 models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'), 

1026 ] 

1027 

1028 

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

1039 

1040 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1041 objects = ScopedManager(space='space') 

1042 

1043 def get_label(self): 

1044 if self.title: 

1045 return self.title 

1046 return str(self.recipe) 

1047 

1048 def get_meal_name(self): 

1049 return self.meal_type.name 

1050 

1051 def __str__(self): 

1052 return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}' 

1053 

1054 

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) 

1060 

1061 objects = ScopedManager(space='recipe__space') 

1062 

1063 @staticmethod 

1064 def get_space_key(): 

1065 return 'recipe', 'space' 

1066 

1067 def get_space(self): 

1068 return self.recipe.space 

1069 

1070 def __str__(self): 

1071 return f'Shopping list recipe {self.id} - {self.recipe}' 

1072 

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 

1078 

1079 

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) 

1092 

1093 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1094 objects = ScopedManager(space='space') 

1095 

1096 @staticmethod 

1097 def get_space_key(): 

1098 return 'shoppinglist', 'space' 

1099 

1100 def get_space(self): 

1101 return self.shoppinglist_set.first().space 

1102 

1103 def __str__(self): 

1104 return f'Shopping list entry {self.id}' 

1105 

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

1111 

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 

1117 

1118 

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) 

1129 

1130 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1131 objects = ScopedManager(space='space') 

1132 

1133 def __str__(self): 

1134 return f'Shopping list {self.id}' 

1135 

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 [] 

1141 

1142 

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) 

1150 

1151 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1152 objects = ScopedManager(space='space') 

1153 

1154 def __str__(self): 

1155 return f'{self.recipe} - {self.uuid}' 

1156 

1157 

1158def default_valid_until(): 

1159 return date.today() + timedelta(days=14) 

1160 

1161 

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) 

1171 

1172 internal_note = models.TextField(blank=True, null=True) 

1173 

1174 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1175 objects = ScopedManager(space='space') 

1176 

1177 def __str__(self): 

1178 return f'{self.uuid}' 

1179 

1180 

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) 

1187 

1188 objects = ScopedManager(space='space') 

1189 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1190 

1191 def __str__(self): 

1192 return f"{self.name}" 

1193 

1194 

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) 

1201 

1202 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1203 objects = ScopedManager(space='space') 

1204 

1205 def __str__(self): 

1206 return self.recipe.name 

1207 

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 ) 

1217 

1218 

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) 

1223 

1224 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1225 objects = ScopedManager(space='space') 

1226 

1227 def __str__(self): 

1228 return self.recipe.name 

1229 

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 ) 

1237 

1238 

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) 

1244 

1245 total_recipes = models.IntegerField(default=0) 

1246 imported_recipes = models.IntegerField(default=0) 

1247 

1248 created_at = models.DateTimeField(auto_now_add=True) 

1249 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

1250 

1251 objects = ScopedManager(space='space') 

1252 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1253 

1254 def __str__(self): 

1255 return f"{self.created_at}:{self.type}" 

1256 

1257 

1258class ExportLog(models.Model, PermissionModelMixin): 

1259 type = models.CharField(max_length=32) 

1260 running = models.BooleanField(default=True) 

1261 msg = models.TextField(default="") 

1262 

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) 

1267 

1268 created_at = models.DateTimeField(auto_now_add=True) 

1269 created_by = models.ForeignKey(User, on_delete=models.CASCADE) 

1270 

1271 objects = ScopedManager(space='space') 

1272 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1273 

1274 def __str__(self): 

1275 return f"{self.created_at}:{self.type}" 

1276 

1277 

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) 

1283 

1284 objects = ScopedManager(space='space') 

1285 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1286 

1287 

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) 

1293 

1294 def __str__(self): 

1295 return _(self.name) 

1296 

1297 @staticmethod 

1298 def get_name(self): 

1299 return _(self.name) 

1300 

1301 

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 ) 

1315 

1316 user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True) 

1317 search = models.CharField(choices=SEARCH_STYLE, max_length=32, default=SIMPLE) 

1318 

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) 

1326 

1327 

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) 

1334 

1335 objects = ScopedManager(space='space') 

1336 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1337 

1338 def is_image(self): 

1339 try: 

1340 Image.open(self.file.file.file) 

1341 return True 

1342 except Exception: 

1343 return False 

1344 

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) 

1350 

1351 

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' 

1363 

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) 

1379 

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) 

1383 

1384 order = models.IntegerField(default=1000) 

1385 

1386 disabled = models.BooleanField(default=False) 

1387 

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) 

1391 

1392 objects = ScopedManager(space='space') 

1393 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1394 

1395 

1396class CustomFilter(models.Model, PermissionModelMixin): 

1397 RECIPE = 'RECIPE' 

1398 FOOD = 'FOOD' 

1399 KEYWORD = 'KEYWORD' 

1400 

1401 MODELS = ( 

1402 (RECIPE, _('Recipe')), 

1403 (FOOD, _('Food')), 

1404 (KEYWORD, _('Keyword')), 

1405 ) 

1406 

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

1414 

1415 objects = ScopedManager(space='space') 

1416 space = models.ForeignKey(Space, on_delete=models.CASCADE) 

1417 

1418 def __str__(self): 

1419 return self.name 

1420 

1421 class Meta: 

1422 constraints = [ 

1423 models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space') 

1424 ]