Coverage for cookbook/forms.py: 78%
217 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
1from datetime import datetime
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
12from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry,
13 SearchPreference, Space, Storage, Sync, User, UserPreference)
16class SelectWidget(widgets.Select):
17 class Media:
18 js = ('custom/js/form_select.js',)
21class MultiSelectWidget(widgets.SelectMultiple):
22 class Media:
23 js = ('custom/js/form_multiselect.js',)
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'
31 def __init__(self, **kwargs):
32 kwargs["format"] = "%Y-%m-%d"
33 super().__init__(**kwargs)
36class UserPreferenceForm(forms.ModelForm):
37 prefix = 'preference'
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()
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 )
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 }
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 }
90 widgets = {
91 'plan_share': MultiSelectWidget,
92 'shopping_share': MultiSelectWidget,
93 }
96class UserNameForm(forms.ModelForm):
97 prefix = 'name'
99 class Meta:
100 model = User
101 fields = ('first_name', 'last_name')
103 help_texts = {
104 'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
105 }
108class ExternalRecipeForm(forms.ModelForm):
109 file_path = forms.CharField(disabled=True, required=False)
110 file_uid = forms.CharField(disabled=True, required=False)
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()
117 class Meta:
118 model = Recipe
119 fields = (
120 'name', 'description', 'servings', 'working_time', 'waiting_time',
121 'file_path', 'file_uid', 'keywords'
122 )
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 }
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'
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 ))
172class MultipleFileInput(forms.ClearableFileInput):
173 allow_multiple_selected = True
176class MultipleFileField(forms.FileField):
177 def __init__(self, *args, **kwargs):
178 kwargs.setdefault("widget", MultipleFileInput())
179 super().__init__(*args, **kwargs)
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
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)
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)
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()
208class CommentForm(forms.ModelForm):
209 prefix = 'comment'
211 class Meta:
212 model = Comment
213 fields = ('text',)
215 labels = {
216 'text': _('Add your comment: '),
217 }
218 widgets = {
219 'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}),
220 }
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 )
241 class Meta:
242 model = Storage
243 fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
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 }
251# TODO: Deprecate
252class RecipeBookEntryForm(forms.ModelForm):
253 prefix = 'bookmark'
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()
260 class Meta:
261 model = RecipeBookEntry
262 fields = ('book',)
264 field_classes = {
265 'book': SafeModelChoiceField,
266 }
269class SyncForm(forms.ModelForm):
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()
276 class Meta:
277 model = Sync
278 fields = ('storage', 'path', 'active')
280 field_classes = {
281 'storage': SafeModelChoiceField,
282 }
284 labels = {
285 'storage': _('Storage'),
286 'path': _('Path'),
287 'active': _('Active')
288 }
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 )
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')
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()
312 class Meta:
313 model = Recipe
314 fields = ('name', 'keywords', 'file_path', 'file_uid')
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 }
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()
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.'))
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!'))
346 return email
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 }
359class SpaceCreateForm(forms.Form):
360 prefix = 'create'
361 name = forms.CharField()
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
371class SpaceJoinForm(forms.Form):
372 prefix = 'join'
373 token = forms.CharField()
376class AllAuthSignupForm(forms.Form):
377 captcha = hCaptchaField()
378 terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
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')
387 def signup(self, request, user):
388 pass
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 )
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)
413 class Meta:
414 model = SearchPreference
415 fields = (
416 'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
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 }
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 }
444 widgets = {
445 'search': SelectWidget,
446 'unaccent': MultiSelectWidget,
447 'icontains': MultiSelectWidget,
448 'istartswith': MultiSelectWidget,
449 'trigram': MultiSelectWidget,
450 'fulltext': MultiSelectWidget,
451 }
454class ShoppingPreferenceForm(forms.ModelForm):
455 prefix = 'shopping'
457 class Meta:
458 model = UserPreference
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 )
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.'),
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 }
496 widgets = {
497 'shopping_share': MultiSelectWidget
498 }
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."))
506# def __init__(self, *args, **kwargs):
507# super().__init__(*args, **kwargs) # populates the post
508# self.fields['food_inherit'].queryset = Food.inheritable_fields
510# class Meta:
511# model = Space
513# fields = ('food_inherit', 'reset_food_inherit', 'use_plural')
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# }
520# widgets = {
521# 'food_inherit': MultiSelectWidget
522# }