Coverage for cookbook/forms.py: 78%

217 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2023-12-29 01:02 +0100

1from datetime import datetime 

2 

3from django import forms 

4from django.conf import settings 

5from django.core.exceptions import ValidationError 

6from django.forms import NumberInput, widgets 

7from django.utils.translation import gettext_lazy as _ 

8from django_scopes import scopes_disabled 

9from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField 

10from hcaptcha.fields import hCaptchaField 

11 

12from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry, 

13 SearchPreference, Space, Storage, Sync, User, UserPreference) 

14 

15 

16class SelectWidget(widgets.Select): 

17 class Media: 

18 js = ('custom/js/form_select.js',) 

19 

20 

21class MultiSelectWidget(widgets.SelectMultiple): 

22 class Media: 

23 js = ('custom/js/form_multiselect.js',) 

24 

25 

26# Yes there are some stupid browsers that still dont support this but 

27# I dont support people using these browsers. 

28class DateWidget(forms.DateInput): 

29 input_type = 'date' 

30 

31 def __init__(self, **kwargs): 

32 kwargs["format"] = "%Y-%m-%d" 

33 super().__init__(**kwargs) 

34 

35 

36class UserPreferenceForm(forms.ModelForm): 

37 prefix = 'preference' 

38 

39 def __init__(self, *args, **kwargs): 

40 space = kwargs.pop('space') 

41 super().__init__(*args, **kwargs) 

42 self.fields['plan_share'].queryset = User.objects.filter(userspace__space=space).all() 

43 

44 class Meta: 

45 model = UserPreference 

46 fields = ( 

47 'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color', 

48 'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed', 'show_step_ingredients', 'ingredient_context', 

49 ) 

50 

51 labels = { 

52 'default_unit': _('Default unit'), 

53 'use_fractions': _('Use fractions'), 

54 'use_kj': _('Use KJ'), 

55 'theme': _('Theme'), 

56 'nav_color': _('Navbar color'), 

57 'sticky_navbar': _('Sticky navbar'), 

58 'default_page': _('Default page'), 

59 'plan_share': _('Plan sharing'), 

60 'ingredient_decimals': _('Ingredient decimal places'), 

61 'shopping_auto_sync': _('Shopping list auto sync period'), 

62 'comments': _('Comments'), 

63 'left_handed': _('Left-handed mode'), 

64 'show_step_ingredients': _('Show step ingredients table'), 

65 'ingredient_context': _('Ingredient context menu') 

66 } 

67 

68 help_texts = { 

69 'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), 

70 'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), 

71 'use_fractions': _( 

72 'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), 

73 'use_kj': _('Display nutritional energy amounts in joules instead of calories'), 

74 'plan_share': _('Users with whom newly created meal plans should be shared by default.'), 

75 'shopping_share': _('Users with whom to share shopping lists.'), 

76 'ingredient_decimals': _('Number of decimals to round ingredients.'), 

77 'comments': _('If you want to be able to create and see comments underneath recipes.'), 

78 'shopping_auto_sync': _( 

79 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 

80 'of mobile data. If lower than instance limit it is reset when saving.' 

81 ), 

82 'sticky_navbar': _('Makes the navbar stick to the top of the page.'), 

83 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 

84 'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'), 

85 'left_handed': _('Will optimize the UI for use with your left hand.'), 

86 'show_step_ingredients': _('Add ingredients table next to recipe steps. Applies at creation time for manually created and URL imported recipes. Individual steps can be overridden in the edit recipe view.'), 

87 'ingredient_context': _("Show an ingredient context menu when viewing a recipe.") 

88 } 

89 

90 widgets = { 

91 'plan_share': MultiSelectWidget, 

92 'shopping_share': MultiSelectWidget, 

93 } 

94 

95 

96class UserNameForm(forms.ModelForm): 

97 prefix = 'name' 

98 

99 class Meta: 

100 model = User 

101 fields = ('first_name', 'last_name') 

102 

103 help_texts = { 

104 'first_name': _('Both fields are optional. If none are given the username will be displayed instead') 

105 } 

106 

107 

108class ExternalRecipeForm(forms.ModelForm): 

109 file_path = forms.CharField(disabled=True, required=False) 

110 file_uid = forms.CharField(disabled=True, required=False) 

111 

112 def __init__(self, *args, **kwargs): 

113 space = kwargs.pop('space') 

114 super().__init__(*args, **kwargs) 

115 self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all() 

116 

117 class Meta: 

118 model = Recipe 

119 fields = ( 

120 'name', 'description', 'servings', 'working_time', 'waiting_time', 

121 'file_path', 'file_uid', 'keywords' 

122 ) 

123 

124 labels = { 

125 'name': _('Name'), 

126 'keywords': _('Keywords'), 

127 'working_time': _('Preparation time in minutes'), 

128 'waiting_time': _('Waiting time (cooking/baking) in minutes'), 

129 'file_path': _('Path'), 

130 'file_uid': _('Storage UID'), 

131 } 

132 widgets = {'keywords': MultiSelectWidget} 

133 field_classes = { 

134 'keywords': SafeModelMultipleChoiceField, 

135 } 

136 

137 

138class ImportExportBase(forms.Form): 

139 DEFAULT = 'DEFAULT' 

140 PAPRIKA = 'PAPRIKA' 

141 NEXTCLOUD = 'NEXTCLOUD' 

142 MEALIE = 'MEALIE' 

143 CHOWDOWN = 'CHOWDOWN' 

144 SAFFRON = 'SAFFRON' 

145 CHEFTAP = 'CHEFTAP' 

146 PEPPERPLATE = 'PEPPERPLATE' 

147 RECIPEKEEPER = 'RECIPEKEEPER' 

148 RECETTETEK = 'RECETTETEK' 

149 RECIPESAGE = 'RECIPESAGE' 

150 DOMESTICA = 'DOMESTICA' 

151 MEALMASTER = 'MEALMASTER' 

152 MELARECIPES = 'MELARECIPES' 

153 REZKONV = 'REZKONV' 

154 OPENEATS = 'OPENEATS' 

155 PLANTOEAT = 'PLANTOEAT' 

156 COOKBOOKAPP = 'COOKBOOKAPP' 

157 COPYMETHAT = 'COPYMETHAT' 

158 COOKMATE = 'COOKMATE' 

159 REZEPTSUITEDE = 'REZEPTSUITEDE' 

160 PDF = 'PDF' 

161 

162 type = forms.ChoiceField(choices=( 

163 (DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), 

164 (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), 

165 (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'), 

166 (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'), 

167 (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'), 

168 (COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de') 

169 )) 

170 

171 

172class MultipleFileInput(forms.ClearableFileInput): 

173 allow_multiple_selected = True 

174 

175 

176class MultipleFileField(forms.FileField): 

177 def __init__(self, *args, **kwargs): 

178 kwargs.setdefault("widget", MultipleFileInput()) 

179 super().__init__(*args, **kwargs) 

180 

181 def clean(self, data, initial=None): 

182 single_file_clean = super().clean 

183 if isinstance(data, (list, tuple)): 

184 result = [single_file_clean(d, initial) for d in data] 

185 else: 

186 result = single_file_clean(data, initial) 

187 return result 

188 

189 

190class ImportForm(ImportExportBase): 

191 files = MultipleFileField(required=True) 

192 duplicates = forms.BooleanField(help_text=_( 

193 'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), 

194 required=False) 

195 

196 

197class ExportForm(ImportExportBase): 

198 recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False) 

199 all = forms.BooleanField(required=False) 

200 custom_filter = forms.IntegerField(required=False) 

201 

202 def __init__(self, *args, **kwargs): 

203 space = kwargs.pop('space') 

204 super().__init__(*args, **kwargs) 

205 self.fields['recipes'].queryset = Recipe.objects.filter(space=space).all() 

206 

207 

208class CommentForm(forms.ModelForm): 

209 prefix = 'comment' 

210 

211 class Meta: 

212 model = Comment 

213 fields = ('text',) 

214 

215 labels = { 

216 'text': _('Add your comment: '), 

217 } 

218 widgets = { 

219 'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), 

220 } 

221 

222 

223class StorageForm(forms.ModelForm): 

224 username = forms.CharField( 

225 widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), 

226 required=False 

227 ) 

228 password = forms.CharField( 

229 widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), 

230 required=False, 

231 help_text=_('Leave empty for dropbox and enter app password for nextcloud.') 

232 ) 

233 token = forms.CharField( 

234 widget=forms.TextInput( 

235 attrs={'autocomplete': 'new-password', 'type': 'password'} 

236 ), 

237 required=False, 

238 help_text=_('Leave empty for nextcloud and enter api token for dropbox.') 

239 ) 

240 

241 class Meta: 

242 model = Storage 

243 fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path') 

244 

245 help_texts = { 

246 'url': _( 

247 'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), 

248 } 

249 

250 

251# TODO: Deprecate 

252class RecipeBookEntryForm(forms.ModelForm): 

253 prefix = 'bookmark' 

254 

255 def __init__(self, *args, **kwargs): 

256 space = kwargs.pop('space') 

257 super().__init__(*args, **kwargs) 

258 self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all() 

259 

260 class Meta: 

261 model = RecipeBookEntry 

262 fields = ('book',) 

263 

264 field_classes = { 

265 'book': SafeModelChoiceField, 

266 } 

267 

268 

269class SyncForm(forms.ModelForm): 

270 

271 def __init__(self, *args, **kwargs): 

272 space = kwargs.pop('space') 

273 super().__init__(*args, **kwargs) 

274 self.fields['storage'].queryset = Storage.objects.filter(space=space).all() 

275 

276 class Meta: 

277 model = Sync 

278 fields = ('storage', 'path', 'active') 

279 

280 field_classes = { 

281 'storage': SafeModelChoiceField, 

282 } 

283 

284 labels = { 

285 'storage': _('Storage'), 

286 'path': _('Path'), 

287 'active': _('Active') 

288 } 

289 

290 

291# TODO deprecate 

292class BatchEditForm(forms.Form): 

293 search = forms.CharField(label=_('Search String')) 

294 keywords = forms.ModelMultipleChoiceField( 

295 queryset=Keyword.objects.none(), 

296 required=False, 

297 widget=MultiSelectWidget 

298 ) 

299 

300 def __init__(self, *args, **kwargs): 

301 space = kwargs.pop('space') 

302 super().__init__(*args, **kwargs) 

303 self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all().order_by('id') 

304 

305 

306class ImportRecipeForm(forms.ModelForm): 

307 def __init__(self, *args, **kwargs): 

308 space = kwargs.pop('space') 

309 super().__init__(*args, **kwargs) 

310 self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all() 

311 

312 class Meta: 

313 model = Recipe 

314 fields = ('name', 'keywords', 'file_path', 'file_uid') 

315 

316 labels = { 

317 'name': _('Name'), 

318 'keywords': _('Keywords'), 

319 'file_path': _('Path'), 

320 'file_uid': _('File ID'), 

321 } 

322 widgets = {'keywords': MultiSelectWidget} 

323 field_classes = { 

324 'keywords': SafeModelChoiceField, 

325 } 

326 

327 

328class InviteLinkForm(forms.ModelForm): 

329 def __init__(self, *args, **kwargs): 

330 user = kwargs.pop('user') 

331 super().__init__(*args, **kwargs) 

332 self.fields['space'].queryset = Space.objects.filter(created_by=user).all() 

333 

334 def clean(self): 

335 space = self.cleaned_data['space'] 

336 if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + 

337 InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users: 

338 raise ValidationError(_('Maximum number of users for this space reached.')) 

339 

340 def clean_email(self): 

341 email = self.cleaned_data['email'] 

342 with scopes_disabled(): 

343 if email != '' and User.objects.filter(email=email).exists(): 

344 raise ValidationError(_('Email address already taken!')) 

345 

346 return email 

347 

348 class Meta: 

349 model = InviteLink 

350 fields = ('email', 'group', 'valid_until', 'space') 

351 help_texts = { 

352 'email': _('An email address is not required but if present the invite link will be sent to the user.'), 

353 } 

354 field_classes = { 

355 'space': SafeModelChoiceField, 

356 } 

357 

358 

359class SpaceCreateForm(forms.Form): 

360 prefix = 'create' 

361 name = forms.CharField() 

362 

363 def clean_name(self): 

364 name = self.cleaned_data['name'] 

365 with scopes_disabled(): 

366 if Space.objects.filter(name=name).exists(): 

367 raise ValidationError(_('Name already taken.')) 

368 return name 

369 

370 

371class SpaceJoinForm(forms.Form): 

372 prefix = 'join' 

373 token = forms.CharField() 

374 

375 

376class AllAuthSignupForm(forms.Form): 

377 captcha = hCaptchaField() 

378 terms = forms.BooleanField(label=_('Accept Terms and Privacy')) 

379 

380 def __init__(self, **kwargs): 

381 super(AllAuthSignupForm, self).__init__(**kwargs) 

382 if settings.PRIVACY_URL == '' and settings.TERMS_URL == '': 

383 self.fields.pop('terms') 

384 if settings.HCAPTCHA_SECRET == '': 

385 self.fields.pop('captcha') 

386 

387 def signup(self, request, user): 

388 pass 

389 

390 

391class UserCreateForm(forms.Form): 

392 name = forms.CharField(label='Username') 

393 password = forms.CharField( 

394 widget=forms.TextInput( 

395 attrs={'autocomplete': 'new-password', 'type': 'password'} 

396 ) 

397 ) 

398 password_confirm = forms.CharField( 

399 widget=forms.TextInput( 

400 attrs={'autocomplete': 'new-password', 'type': 'password'} 

401 ) 

402 ) 

403 

404 

405class SearchPreferenceForm(forms.ModelForm): 

406 prefix = 'search' 

407 trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, 

408 widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), 

409 help_text=_( 

410 'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) 

411 preset = forms.CharField(widget=forms.HiddenInput(), required=False) 

412 

413 class Meta: 

414 model = SearchPreference 

415 fields = ( 

416 'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold') 

417 

418 help_texts = { 

419 'search': _( 

420 'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'), 

421 'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 

422 'unaccent': _( 

423 'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 

424 'icontains': _( 

425 "Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 

426 'istartswith': _( 

427 "Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 

428 'trigram': _( 

429 "Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), 

430 'fulltext': _( 

431 "Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."), 

432 } 

433 

434 labels = { 

435 'search': _('Search Method'), 

436 'lookup': _('Fuzzy Lookups'), 

437 'unaccent': _('Ignore Accent'), 

438 'icontains': _("Partial Match"), 

439 'istartswith': _("Starts With"), 

440 'trigram': _("Fuzzy Search"), 

441 'fulltext': _("Full Text") 

442 } 

443 

444 widgets = { 

445 'search': SelectWidget, 

446 'unaccent': MultiSelectWidget, 

447 'icontains': MultiSelectWidget, 

448 'istartswith': MultiSelectWidget, 

449 'trigram': MultiSelectWidget, 

450 'fulltext': MultiSelectWidget, 

451 } 

452 

453 

454class ShoppingPreferenceForm(forms.ModelForm): 

455 prefix = 'shopping' 

456 

457 class Meta: 

458 model = UserPreference 

459 

460 fields = ( 

461 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', 

462 'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix' 

463 ) 

464 

465 help_texts = { 

466 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), 

467 'shopping_auto_sync': _( 

468 'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' 

469 'of mobile data. If lower than instance limit it is reset when saving.' 

470 ), 

471 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), 

472 'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'), 

473 'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'), 

474 'default_delay': _('Default number of hours to delay a shopping list entry.'), 

475 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), 

476 'shopping_recent_days': _('Days of recent shopping list entries to display.'), 

477 'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."), 

478 'csv_delim': _('Delimiter to use for CSV exports.'), 

479 'csv_prefix': _('Prefix to add when copying list to the clipboard.'), 

480 

481 } 

482 labels = { 

483 'shopping_share': _('Share Shopping List'), 

484 'shopping_auto_sync': _('Autosync'), 

485 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), 

486 'mealplan_autoexclude_onhand': _('Exclude On Hand'), 

487 'mealplan_autoinclude_related': _('Include Related'), 

488 'default_delay': _('Default Delay Hours'), 

489 'filter_to_supermarket': _('Filter to Supermarket'), 

490 'shopping_recent_days': _('Recent Days'), 

491 'csv_delim': _('CSV Delimiter'), 

492 "csv_prefix_label": _("List Prefix"), 

493 'shopping_add_onhand': _("Auto On Hand"), 

494 } 

495 

496 widgets = { 

497 'shopping_share': MultiSelectWidget 

498 } 

499 

500 

501# class SpacePreferenceForm(forms.ModelForm): 

502# prefix = 'space' 

503# reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False, 

504# help_text=_("Reset all food to inherit the fields configured.")) 

505 

506# def __init__(self, *args, **kwargs): 

507# super().__init__(*args, **kwargs) # populates the post 

508# self.fields['food_inherit'].queryset = Food.inheritable_fields 

509 

510# class Meta: 

511# model = Space 

512 

513# fields = ('food_inherit', 'reset_food_inherit', 'use_plural') 

514 

515# help_texts = { 

516# 'food_inherit': _('Fields on food that should be inherited by default.'), 

517# 'use_plural': _('Use the plural form for units and food inside this space.'), 

518# } 

519 

520# widgets = { 

521# 'food_inherit': MultiSelectWidget 

522# }