diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f81b1fb..202de7b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,31 +10,80 @@ jobs: max-parallel: 4 matrix: python-version: ['3.10'] + node-version: ['18'] steps: - - uses: actions/checkout@v4 - - name: Set up Python 3.10 + - uses: actions/checkout@v3 + - uses: awalsh128/cache-apt-pkgs-action@v1.3.1 + with: + packages: libsasl2-dev python3-dev libldap2-dev libssl-dev + version: 1.0 + + # Setup python & dependencies + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: '3.10' - # Build Vue frontend - - uses: actions/setup-node@v4 - with: - node-version: '18' - - name: Install Vue dependencies - working-directory: ./vue - run: yarn install - - name: Build Vue dependencies - working-directory: ./vue - run: yarn build - - name: Install Django dependencies + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install Python Dependencies run: | - sudo apt-get -y update - sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev python -m pip install --upgrade pip pip install -r requirements.txt + + - name: Cache StaticFiles + uses: actions/cache@v3 + id: django_cache + with: + path: | + ./cookbook/static + ./vue/webpack-stats.json + ./staticfiles + key: | + ${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }} + + # Build Vue frontend & Dependencies + - name: Set up Node ${{ matrix.node-version }} + if: steps.django_cache.outputs.cache-hit != 'true' + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + cache-dependency-path: ./vue/yarn.lock + + - name: Install Vue dependencies + if: steps.django_cache.outputs.cache-hit != 'true' + working-directory: ./vue + run: yarn install + + - name: Build Vue dependencies + if: steps.django_cache.outputs.cache-hit != 'true' + working-directory: ./vue + run: yarn build + + - name: Compile Django StatisFiles + if: steps.django_cache.outputs.cache-hit != 'true' + run: | python3 manage.py collectstatic --noinput python3 manage.py collectstatic_js_reverse + + - uses: actions/cache/save@v3 + if: steps.django_cache.outputs.cache-hit != 'true' + with: + path: | + ./cookbook/static + ./vue/webpack-stats.json + ./staticfiles + key: | + ${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }} + - name: Django Testing project - run: | - pytest + run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() + with: + comment_mode: off + files: | + junit/test-results-${{ matrix.python-version }}.xml diff --git a/.idea/dictionaries/vaben.xml b/.idea/dictionaries/vaben.xml index e910c82d..a8b8f4df 100644 --- a/.idea/dictionaries/vaben.xml +++ b/.idea/dictionaries/vaben.xml @@ -1,6 +1,7 @@ + mealplan pinia selfhosted unapplied diff --git a/cookbook/admin.py b/cookbook/admin.py index 82324628..fc148afe 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -315,8 +315,8 @@ admin.site.register(MealPlan, MealPlanAdmin) class MealTypeAdmin(admin.ModelAdmin): - list_display = ('name', 'created_by', 'order') - search_fields = ('name', 'created_by__username') + list_display = ('name', 'space', 'created_by', 'order') + search_fields = ('name', 'space', 'created_by__username') admin.site.register(MealType, MealTypeAdmin) diff --git a/cookbook/forms.py b/cookbook/forms.py index 4226af9c..ba716c0a 100644 --- a/cookbook/forms.py +++ b/cookbook/forms.py @@ -1,5 +1,6 @@ from datetime import datetime +from allauth.account.forms import ResetPasswordForm, SignupForm from django import forms from django.conf import settings from django.core.exceptions import ValidationError @@ -9,18 +10,19 @@ from django_scopes import scopes_disabled from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from hcaptcha.fields import hCaptchaField -from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry, - SearchPreference, Space, Storage, Sync, User, UserPreference) +from .models import Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference class SelectWidget(widgets.Select): + class Media: - js = ('custom/js/form_select.js',) + js = ('custom/js/form_select.js', ) class MultiSelectWidget(widgets.SelectMultiple): + class Media: - js = ('custom/js/form_multiselect.js',) + js = ('custom/js/form_multiselect.js', ) # Yes there are some stupid browsers that still dont support this but @@ -40,9 +42,7 @@ class UserNameForm(forms.ModelForm): model = User fields = ('first_name', 'last_name') - help_texts = { - 'first_name': _('Both fields are optional. If none are given the username will be displayed instead') - } + help_texts = {'first_name': _('Both fields are optional. If none are given the username will be displayed instead')} class ExternalRecipeForm(forms.ModelForm): @@ -56,23 +56,14 @@ class ExternalRecipeForm(forms.ModelForm): class Meta: model = Recipe - fields = ( - 'name', 'description', 'servings', 'working_time', 'waiting_time', - 'file_path', 'file_uid', 'keywords' - ) + fields = ('name', 'description', 'servings', 'working_time', 'waiting_time', 'file_path', 'file_uid', 'keywords') labels = { - 'name': _('Name'), - 'keywords': _('Keywords'), - 'working_time': _('Preparation time in minutes'), - 'waiting_time': _('Waiting time (cooking/baking) in minutes'), - 'file_path': _('Path'), - 'file_uid': _('Storage UID'), + 'name': _('Name'), 'keywords': _('Keywords'), 'working_time': _('Preparation time in minutes'), 'waiting_time': _('Waiting time (cooking/baking) in minutes'), + 'file_path': _('Path'), 'file_uid': _('Storage UID'), } widgets = {'keywords': MultiSelectWidget} - field_classes = { - 'keywords': SafeModelMultipleChoiceField, - } + field_classes = {'keywords': SafeModelMultipleChoiceField, } class ImportExportBase(forms.Form): @@ -99,14 +90,11 @@ class ImportExportBase(forms.Form): REZEPTSUITEDE = 'REZEPTSUITEDE' PDF = 'PDF' - type = forms.ChoiceField(choices=( - (DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), - (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), - (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'), - (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'), - (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'), - (COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de') - )) + type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), + (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), + (DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'), + (PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'), + (COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de'))) class MultipleFileInput(forms.ClearableFileInput): @@ -114,6 +102,7 @@ class MultipleFileInput(forms.ClearableFileInput): class MultipleFileField(forms.FileField): + def __init__(self, *args, **kwargs): kwargs.setdefault("widget", MultipleFileInput()) super().__init__(*args, **kwargs) @@ -129,9 +118,8 @@ class MultipleFileField(forms.FileField): class ImportForm(ImportExportBase): files = MultipleFileField(required=True) - duplicates = forms.BooleanField(help_text=_( - 'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), - required=False) + duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), + required=False) class ExportForm(ImportExportBase): @@ -150,60 +138,44 @@ class CommentForm(forms.ModelForm): class Meta: model = Comment - fields = ('text',) + fields = ('text', ) - labels = { - 'text': _('Add your comment: '), - } - widgets = { - 'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), - } + labels = {'text': _('Add your comment: '), } + widgets = {'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), } class StorageForm(forms.ModelForm): - username = forms.CharField( - widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), - required=False - ) - password = forms.CharField( - widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), - required=False, - help_text=_('Leave empty for dropbox and enter app password for nextcloud.') - ) - token = forms.CharField( - widget=forms.TextInput( - attrs={'autocomplete': 'new-password', 'type': 'password'} - ), - required=False, - help_text=_('Leave empty for nextcloud and enter api token for dropbox.') - ) + username = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), required=False) + password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), + required=False, + help_text=_('Leave empty for dropbox and enter app password for nextcloud.')) + token = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}), + required=False, + help_text=_('Leave empty for nextcloud and enter api token for dropbox.')) class Meta: model = Storage fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path') - help_texts = { - 'url': _( - 'Leave empty for dropbox and enter only base url for nextcloud (/remote.php/webdav/ is added automatically)'), - } + help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (/remote.php/webdav/ is added automatically)'), } # TODO: Deprecate -class RecipeBookEntryForm(forms.ModelForm): - prefix = 'bookmark' +# class RecipeBookEntryForm(forms.ModelForm): +# prefix = 'bookmark' - def __init__(self, *args, **kwargs): - space = kwargs.pop('space') - super().__init__(*args, **kwargs) - self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all() +# def __init__(self, *args, **kwargs): +# space = kwargs.pop('space') +# super().__init__(*args, **kwargs) +# self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all() - class Meta: - model = RecipeBookEntry - fields = ('book',) +# class Meta: +# model = RecipeBookEntry +# fields = ('book',) - field_classes = { - 'book': SafeModelChoiceField, - } +# field_classes = { +# 'book': SafeModelChoiceField, +# } class SyncForm(forms.ModelForm): @@ -217,25 +189,15 @@ class SyncForm(forms.ModelForm): model = Sync fields = ('storage', 'path', 'active') - field_classes = { - 'storage': SafeModelChoiceField, - } + field_classes = {'storage': SafeModelChoiceField, } - labels = { - 'storage': _('Storage'), - 'path': _('Path'), - 'active': _('Active') - } + labels = {'storage': _('Storage'), 'path': _('Path'), 'active': _('Active')} # TODO deprecate class BatchEditForm(forms.Form): search = forms.CharField(label=_('Search String')) - keywords = forms.ModelMultipleChoiceField( - queryset=Keyword.objects.none(), - required=False, - widget=MultiSelectWidget - ) + keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.none(), required=False, widget=MultiSelectWidget) def __init__(self, *args, **kwargs): space = kwargs.pop('space') @@ -244,6 +206,7 @@ class BatchEditForm(forms.Form): class ImportRecipeForm(forms.ModelForm): + def __init__(self, *args, **kwargs): space = kwargs.pop('space') super().__init__(*args, **kwargs) @@ -253,19 +216,13 @@ class ImportRecipeForm(forms.ModelForm): model = Recipe fields = ('name', 'keywords', 'file_path', 'file_uid') - labels = { - 'name': _('Name'), - 'keywords': _('Keywords'), - 'file_path': _('Path'), - 'file_uid': _('File ID'), - } + labels = {'name': _('Name'), 'keywords': _('Keywords'), 'file_path': _('Path'), 'file_uid': _('File ID'), } widgets = {'keywords': MultiSelectWidget} - field_classes = { - 'keywords': SafeModelChoiceField, - } + field_classes = {'keywords': SafeModelChoiceField, } class InviteLinkForm(forms.ModelForm): + def __init__(self, *args, **kwargs): user = kwargs.pop('user') super().__init__(*args, **kwargs) @@ -273,8 +230,8 @@ class InviteLinkForm(forms.ModelForm): def clean(self): space = self.cleaned_data['space'] - if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + - InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users: + if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + + InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users: raise ValidationError(_('Maximum number of users for this space reached.')) def clean_email(self): @@ -288,12 +245,8 @@ class InviteLinkForm(forms.ModelForm): class Meta: model = InviteLink fields = ('email', 'group', 'valid_until', 'space') - help_texts = { - 'email': _('An email address is not required but if present the invite link will be sent to the user.'), - } - field_classes = { - 'space': SafeModelChoiceField, - } + help_texts = {'email': _('An email address is not required but if present the invite link will be sent to the user.'), } + field_classes = {'space': SafeModelChoiceField, } class SpaceCreateForm(forms.Form): @@ -313,12 +266,12 @@ class SpaceJoinForm(forms.Form): token = forms.CharField() -class AllAuthSignupForm(forms.Form): +class AllAuthSignupForm(SignupForm): captcha = hCaptchaField() terms = forms.BooleanField(label=_('Accept Terms and Privacy')) def __init__(self, **kwargs): - super(AllAuthSignupForm, self).__init__(**kwargs) + super().__init__(**kwargs) if settings.PRIVACY_URL == '' and settings.TERMS_URL == '': self.fields.pop('terms') if settings.HCAPTCHA_SECRET == '': @@ -328,135 +281,120 @@ class AllAuthSignupForm(forms.Form): pass +class CustomPasswordResetForm(ResetPasswordForm): + captcha = hCaptchaField() + + def __init__(self, **kwargs): + super(CustomPasswordResetForm, self).__init__(**kwargs) + if settings.HCAPTCHA_SECRET == '': + self.fields.pop('captcha') + + class UserCreateForm(forms.Form): name = forms.CharField(label='Username') - password = forms.CharField( - widget=forms.TextInput( - attrs={'autocomplete': 'new-password', 'type': 'password'} - ) - ) - password_confirm = forms.CharField( - widget=forms.TextInput( - attrs={'autocomplete': 'new-password', 'type': 'password'} - ) - ) + password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'})) + password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'})) class SearchPreferenceForm(forms.ModelForm): prefix = 'search' - trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, + trigram_threshold = forms.DecimalField(min_value=0.01, + max_value=1, + decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}), - help_text=_( - 'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) + help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).')) preset = forms.CharField(widget=forms.HiddenInput(), required=False) class Meta: model = SearchPreference - fields = ( - 'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold') + fields = ('search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold') help_texts = { - 'search': _( - 'Select type method of search. Click here for full description of choices.'), - 'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), - 'unaccent': _( - 'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), - 'icontains': _( - "Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), - 'istartswith': _( - "Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), - 'trigram': _( - "Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), - 'fulltext': _( - "Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."), + 'search': _('Select type method of search. Click here for full description of choices.'), 'lookup': + _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 'unaccent': + _('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 'icontains': + _("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 'istartswith': + _("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 'trigram': + _("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), 'fulltext': + _("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."), } labels = { - 'search': _('Search Method'), - 'lookup': _('Fuzzy Lookups'), - 'unaccent': _('Ignore Accent'), - 'icontains': _("Partial Match"), - 'istartswith': _("Starts With"), - 'trigram': _("Fuzzy Search"), - 'fulltext': _("Full Text") + 'search': _('Search Method'), 'lookup': _('Fuzzy Lookups'), 'unaccent': _('Ignore Accent'), 'icontains': _("Partial Match"), 'istartswith': _("Starts With"), + 'trigram': _("Fuzzy Search"), 'fulltext': _("Full Text") } widgets = { - 'search': SelectWidget, - 'unaccent': MultiSelectWidget, - 'icontains': MultiSelectWidget, - 'istartswith': MultiSelectWidget, - 'trigram': MultiSelectWidget, - 'fulltext': MultiSelectWidget, + 'search': SelectWidget, 'unaccent': MultiSelectWidget, 'icontains': MultiSelectWidget, 'istartswith': MultiSelectWidget, 'trigram': MultiSelectWidget, 'fulltext': + MultiSelectWidget, } -class ShoppingPreferenceForm(forms.ModelForm): - prefix = 'shopping' +# class ShoppingPreferenceForm(forms.ModelForm): +# prefix = 'shopping' - class Meta: - model = UserPreference +# class Meta: +# model = UserPreference - fields = ( - 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', - 'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix' - ) +# fields = ( +# 'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand', +# 'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix' +# ) - help_texts = { - 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), - 'shopping_auto_sync': _( - '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 ' - 'of mobile data. If lower than instance limit it is reset when saving.' - ), - 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), - 'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'), - 'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'), - 'default_delay': _('Default number of hours to delay a shopping list entry.'), - 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), - 'shopping_recent_days': _('Days of recent shopping list entries to display.'), - 'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."), - 'csv_delim': _('Delimiter to use for CSV exports.'), - 'csv_prefix': _('Prefix to add when copying list to the clipboard.'), +# help_texts = { +# 'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'), +# 'shopping_auto_sync': _( +# '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 ' +# 'of mobile data. If lower than instance limit it is reset when saving.' +# ), +# 'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'), +# 'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'), +# 'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'), +# 'default_delay': _('Default number of hours to delay a shopping list entry.'), +# 'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'), +# 'shopping_recent_days': _('Days of recent shopping list entries to display.'), +# 'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."), +# 'csv_delim': _('Delimiter to use for CSV exports.'), +# 'csv_prefix': _('Prefix to add when copying list to the clipboard.'), - } - labels = { - 'shopping_share': _('Share Shopping List'), - 'shopping_auto_sync': _('Autosync'), - 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), - 'mealplan_autoexclude_onhand': _('Exclude On Hand'), - 'mealplan_autoinclude_related': _('Include Related'), - 'default_delay': _('Default Delay Hours'), - 'filter_to_supermarket': _('Filter to Supermarket'), - 'shopping_recent_days': _('Recent Days'), - 'csv_delim': _('CSV Delimiter'), - "csv_prefix_label": _("List Prefix"), - 'shopping_add_onhand': _("Auto On Hand"), - } +# } +# labels = { +# 'shopping_share': _('Share Shopping List'), +# 'shopping_auto_sync': _('Autosync'), +# 'mealplan_autoadd_shopping': _('Auto Add Meal Plan'), +# 'mealplan_autoexclude_onhand': _('Exclude On Hand'), +# 'mealplan_autoinclude_related': _('Include Related'), +# 'default_delay': _('Default Delay Hours'), +# 'filter_to_supermarket': _('Filter to Supermarket'), +# 'shopping_recent_days': _('Recent Days'), +# 'csv_delim': _('CSV Delimiter'), +# "csv_prefix_label": _("List Prefix"), +# 'shopping_add_onhand': _("Auto On Hand"), +# } - widgets = { - 'shopping_share': MultiSelectWidget - } +# widgets = { +# 'shopping_share': MultiSelectWidget +# } +# class SpacePreferenceForm(forms.ModelForm): +# prefix = 'space' +# reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False, +# help_text=_("Reset all food to inherit the fields configured.")) -class SpacePreferenceForm(forms.ModelForm): - prefix = 'space' - reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False, - help_text=_("Reset all food to inherit the fields configured.")) +# def __init__(self, *args, **kwargs): +# super().__init__(*args, **kwargs) # populates the post +# self.fields['food_inherit'].queryset = Food.inheritable_fields - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) # populates the post - self.fields['food_inherit'].queryset = Food.inheritable_fields +# class Meta: +# model = Space - class Meta: - model = Space +# fields = ('food_inherit', 'reset_food_inherit',) - fields = ('food_inherit', 'reset_food_inherit',) +# help_texts = { +# 'food_inherit': _('Fields on food that should be inherited by default.'), +# 'use_plural': _('Use the plural form for units and food inside this space.'), +# } - help_texts = { - 'food_inherit': _('Fields on food that should be inherited by default.'), - 'use_plural': _('Use the plural form for units and food inside this space.'), - } - - widgets = { - 'food_inherit': MultiSelectWidget - } +# widgets = { +# 'food_inherit': MultiSelectWidget +# } diff --git a/cookbook/helper/automation_helper.py b/cookbook/helper/automation_helper.py index a86d405b..fa333fb3 100644 --- a/cookbook/helper/automation_helper.py +++ b/cookbook/helper/automation_helper.py @@ -98,7 +98,7 @@ class AutomationEngine: try: return self.food_aliases[food.lower()] except KeyError: - return food + return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE) else: if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first(): return automation.param_2 diff --git a/cookbook/helper/open_data_importer.py b/cookbook/helper/open_data_importer.py index e709f2f5..92f3f4da 100644 --- a/cookbook/helper/open_data_importer.py +++ b/cookbook/helper/open_data_importer.py @@ -1,5 +1,6 @@ from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion) +import re class OpenDataImporter: @@ -21,66 +22,141 @@ class OpenDataImporter: def import_units(self): datatype = 'unit' - insert_list = [] + existing_data = {} + for obj in Unit.objects.filter(space=self.request.space, open_data_slug__isnull=False).values('pk', 'name', 'open_data_slug'): + existing_data[obj['open_data_slug']] = obj + + update_list = [] + create_list = [] + for u in list(self.data[datatype].keys()): - insert_list.append(Unit( + obj = Unit( name=self.data[datatype][u]['name'], plural_name=self.data[datatype][u]['plural_name'], base_unit=self.data[datatype][u]['base_unit'] if self.data[datatype][u]['base_unit'] != '' else None, open_data_slug=u, space=self.request.space - )) + ) + if obj.open_data_slug in existing_data: + obj.pk = existing_data[obj.open_data_slug]['pk'] + update_list.append(obj) + else: + create_list.append(obj) - if self.update_existing: - return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=( - 'name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',)) - else: - return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) + total_count = 0 + if self.update_existing and len(update_list) > 0: + Unit.objects.bulk_update(update_list, ('name', 'plural_name', 'base_unit', 'open_data_slug')) + total_count += len(update_list) + + if len(create_list) > 0: + Unit.objects.bulk_create(create_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) + total_count += len(create_list) + + return total_count def import_category(self): datatype = 'category' - insert_list = [] + existing_data = {} + for obj in SupermarketCategory.objects.filter(space=self.request.space, open_data_slug__isnull=False).values('pk', 'name', 'open_data_slug'): + existing_data[obj['open_data_slug']] = obj + + update_list = [] + create_list = [] + for k in list(self.data[datatype].keys()): - insert_list.append(SupermarketCategory( + obj = SupermarketCategory( name=self.data[datatype][k]['name'], open_data_slug=k, space=self.request.space - )) + ) - return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) + if obj.open_data_slug in existing_data: + obj.pk = existing_data[obj.open_data_slug]['pk'] + update_list.append(obj) + else: + create_list.append(obj) + + total_count = 0 + if self.update_existing and len(update_list) > 0: + SupermarketCategory.objects.bulk_update(update_list, ('name', 'open_data_slug')) + total_count += len(update_list) + + if len(create_list) > 0: + SupermarketCategory.objects.bulk_create(create_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) + total_count += len(create_list) + + return total_count def import_property(self): datatype = 'property' - insert_list = [] + existing_data = {} + for obj in PropertyType.objects.filter(space=self.request.space, open_data_slug__isnull=False).values('pk', 'name', 'open_data_slug'): + existing_data[obj['open_data_slug']] = obj + + update_list = [] + create_list = [] + for k in list(self.data[datatype].keys()): - insert_list.append(PropertyType( + obj = PropertyType( name=self.data[datatype][k]['name'], unit=self.data[datatype][k]['unit'], open_data_slug=k, space=self.request.space - )) + ) + if obj.open_data_slug in existing_data: + obj.pk = existing_data[obj.open_data_slug]['pk'] + update_list.append(obj) + else: + create_list.append(obj) - return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) + total_count = 0 + if self.update_existing and len(update_list) > 0: + PropertyType.objects.bulk_update(update_list, ('name', 'open_data_slug')) + total_count += len(update_list) + + if len(create_list) > 0: + PropertyType.objects.bulk_create(create_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) + total_count += len(create_list) + + return total_count def import_supermarket(self): datatype = 'store' + existing_data = {} + for obj in Supermarket.objects.filter(space=self.request.space, open_data_slug__isnull=False).values('pk', 'name', 'open_data_slug'): + existing_data[obj['open_data_slug']] = obj + + update_list = [] + create_list = [] + self._update_slug_cache(SupermarketCategory, 'category') - insert_list = [] for k in list(self.data[datatype].keys()): - insert_list.append(Supermarket( + obj = Supermarket( name=self.data[datatype][k]['name'], open_data_slug=k, space=self.request.space - )) + ) + if obj.open_data_slug in existing_data: + obj.pk = existing_data[obj.open_data_slug]['pk'] + update_list.append(obj) + else: + create_list.append(obj) + + total_count = 0 + if self.update_existing and len(update_list) > 0: + Supermarket.objects.bulk_update(update_list, ('name', 'open_data_slug')) + total_count += len(update_list) + + if len(create_list) > 0: + Supermarket.objects.bulk_create(create_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',)) + total_count += len(create_list) # always add open data slug if matching supermarket is found, otherwise relation might fail - supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',)) self._update_slug_cache(Supermarket, 'store') - insert_list = [] for k in list(self.data[datatype].keys()): relations = [] order = 0 @@ -96,68 +172,49 @@ class OpenDataImporter: SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',)) - return supermarkets + return total_count def import_food(self): - identifier_list = [] datatype = 'food' - for k in list(self.data[datatype].keys()): - identifier_list.append(self.data[datatype][k]['name']) - identifier_list.append(self.data[datatype][k]['plural_name']) - - existing_objects_flat = [] - existing_objects = {} - for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'): - existing_objects_flat.append(f[1]) - existing_objects_flat.append(f[2]) - existing_objects[f[1]] = f - existing_objects[f[2]] = f self._update_slug_cache(Unit, 'unit') self._update_slug_cache(PropertyType, 'property') + self._update_slug_cache(SupermarketCategory, 'category') + + existing_data = {} + for obj in Food.objects.filter(space=self.request.space, open_data_slug__isnull=False).values('pk', 'name', 'open_data_slug'): + existing_data[obj['open_data_slug']] = obj - insert_list = [] - insert_list_flat = [] update_list = [] - update_field_list = [] + create_list = [] + for k in list(self.data[datatype].keys()): - if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat): - if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_flat): - insert_list.append({'data': { - 'name': self.data[datatype][k]['name'], - 'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, - 'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']], - 'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None, - 'open_data_slug': k, - 'space': self.request.space.id, - }}) - # build a fake second flat array to prevent duplicate foods from being inserted. - # trying to insert a duplicate would throw a db error :( - insert_list_flat.append(self.data[datatype][k]['name']) - insert_list_flat.append(self.data[datatype][k]['plural_name']) + + obj = { + 'name': self.data[datatype][k]['name'], + 'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, + 'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']], + 'fdc_id': re.sub(r'\D', '', self.data[datatype][k]['fdc_id']) if self.data[datatype][k]['fdc_id'] != '' else None, + 'open_data_slug': k, + 'space': self.request.space.id, + } + + if self.update_existing: + obj['space'] = self.request.space + obj['pk'] = existing_data[obj['open_data_slug']]['pk'] + obj = Food(**obj) + update_list.append(obj) else: - if self.data[datatype][k]['name'] in existing_objects: - existing_food_id = existing_objects[self.data[datatype][k]['name']][0] - else: - existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0] + create_list.append({'data': obj}) - if self.update_existing: - update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ] - update_list.append(Food( - id=existing_food_id, - name=self.data[datatype][k]['name'], - plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, - supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_category']], - fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None, - open_data_slug=k, - )) - else: - update_field_list = ['open_data_slug', ] - update_list.append(Food(id=existing_food_id, open_data_slug=k, )) + total_count = 0 + if self.update_existing and len(update_list) > 0: + Food.objects.bulk_update(update_list, ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ]) + total_count += len(update_list) - Food.load_bulk(insert_list, None) - if len(update_list) > 0: - Food.objects.bulk_update(update_list, update_field_list) + if len(create_list) > 0: + Food.load_bulk(create_list, None) + total_count += len(create_list) self._update_slug_cache(Food, 'food') @@ -185,16 +242,25 @@ class OpenDataImporter: FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',)) - return insert_list + update_list + return total_count def import_conversion(self): datatype = 'conversion' - insert_list = [] + self._update_slug_cache(Food, 'food') + self._update_slug_cache(Unit, 'unit') + + existing_data = {} + for obj in UnitConversion.objects.filter(space=self.request.space, open_data_slug__isnull=False).values('pk', 'open_data_slug'): + existing_data[obj['open_data_slug']] = obj + + update_list = [] + create_list = [] + for k in list(self.data[datatype].keys()): # try catch here because sometimes key "k" is not set for he food cache try: - insert_list.append(UnitConversion( + obj = UnitConversion( base_amount=self.data[datatype][k]['base_amount'], base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']], converted_amount=self.data[datatype][k]['converted_amount'], @@ -203,8 +269,23 @@ class OpenDataImporter: open_data_slug=k, space=self.request.space, created_by=self.request.user, - )) + ) + + if obj.open_data_slug in existing_data: + obj.pk = existing_data[obj.open_data_slug]['pk'] + update_list.append(obj) + else: + create_list.append(obj) except KeyError: print(str(k) + ' is not in self.slug_id_cache["food"]') - return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug')) + total_count = 0 + if self.update_existing and len(update_list) > 0: + UnitConversion.objects.bulk_update(update_list, ('base_unit', 'base_amount', 'converted_unit', 'converted_amount', 'food',)) + total_count += len(update_list) + + if len(create_list) > 0: + UnitConversion.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug')) + total_count += len(create_list) + + return total_count diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index 416bb54a..aa0bb2d8 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -231,7 +231,7 @@ def get_recipe_properties(space, property_data): 'id': pt.id, 'name': pt.name, }, - 'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']), + 'property_amount': parse_servings(property_data[properties[p]]) / parse_servings(property_data['servingSize']), }) return recipe_properties diff --git a/cookbook/helper/shopping_helper.py b/cookbook/helper/shopping_helper.py index ffcf5bea..c7772a07 100644 --- a/cookbook/helper/shopping_helper.py +++ b/cookbook/helper/shopping_helper.py @@ -27,9 +27,6 @@ def shopping_helper(qs, request): elif checked in ['true', 1, '1']: qs = qs.filter(checked=True) elif checked in ['recent']: - today_start = timezone.now().replace(hour=0, minute=0, second=0) - week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days) - qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) supermarket_order = ['checked'] + supermarket_order return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe') diff --git a/cookbook/integration/rezeptsuitede.py b/cookbook/integration/rezeptsuitede.py index afe3e543..3df81424 100644 --- a/cookbook/integration/rezeptsuitede.py +++ b/cookbook/integration/rezeptsuitede.py @@ -1,6 +1,6 @@ import base64 from io import BytesIO -from xml import etree +from lxml import etree from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text @@ -53,7 +53,10 @@ class Rezeptsuitede(Integration): u = ingredient_parser.get_unit(ingredient.attrib['unit']) amount = 0 if ingredient.attrib['qty'].strip() != '': - amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty']) + try: + amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty']) + except ValueError: # sometimes quantities contain words which cant be parsed + pass ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, )) try: diff --git a/cookbook/locale/de/LC_MESSAGES/django.po b/cookbook/locale/de/LC_MESSAGES/django.po index ae2a9075..bdcde5e0 100644 --- a/cookbook/locale/de/LC_MESSAGES/django.po +++ b/cookbook/locale/de/LC_MESSAGES/django.po @@ -15,8 +15,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-18 14:28+0200\n" -"PO-Revision-Date: 2023-11-22 18:19+0000\n" -"Last-Translator: Spreez \n" +"PO-Revision-Date: 2024-02-13 16:19+0000\n" +"Last-Translator: Kirstin Seidel-Gebert \n" "Language-Team: German \n" "Language: de\n" @@ -161,7 +161,7 @@ msgstr "Name" #: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88 msgid "Keywords" -msgstr "Schlüsselwörter" +msgstr "Schlagwörter" #: .\cookbook\forms.py:125 msgid "Preparation time in minutes" @@ -1436,9 +1436,9 @@ msgid "" " " msgstr "" "\n" -" Passwort und Token werden im Klartext in der Datenbank " +" Kennwort und Token werden im Klartext in der Datenbank " "gespeichert.\n" -" Dies ist notwendig da Passwort oder Token benötigt werden, um API-" +" Dies ist notwendig da Kennwort oder Token benötigt werden, um API-" "Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich.
" "\n" " Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder " diff --git a/cookbook/locale/it/LC_MESSAGES/django.po b/cookbook/locale/it/LC_MESSAGES/django.po index 6750519c..3dca22c1 100644 --- a/cookbook/locale/it/LC_MESSAGES/django.po +++ b/cookbook/locale/it/LC_MESSAGES/django.po @@ -12,8 +12,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-18 14:28+0200\n" -"PO-Revision-Date: 2024-02-01 17:22+0000\n" -"Last-Translator: Lorenzo \n" +"PO-Revision-Date: 2024-02-17 19:16+0000\n" +"Last-Translator: Andrea \n" "Language-Team: Italian \n" "Language: it\n" @@ -2093,7 +2093,7 @@ msgstr "Proprietario" #, fuzzy #| msgid "Create Space" msgid "Leave Space" -msgstr "Crea Istanza" +msgstr "Lascia Istanza" #: .\cookbook\templates\space_overview.html:78 #: .\cookbook\templates\space_overview.html:88 diff --git a/cookbook/locale/nl/LC_MESSAGES/django.po b/cookbook/locale/nl/LC_MESSAGES/django.po index 0cb0f122..0cc262cd 100644 --- a/cookbook/locale/nl/LC_MESSAGES/django.po +++ b/cookbook/locale/nl/LC_MESSAGES/django.po @@ -13,8 +13,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-18 14:28+0200\n" -"PO-Revision-Date: 2023-08-15 19:19+0000\n" -"Last-Translator: Jochum van der Heide \n" +"PO-Revision-Date: 2024-02-10 12:20+0000\n" +"Last-Translator: Jonan B \n" "Language-Team: Dutch \n" "Language: nl\n" @@ -159,7 +159,7 @@ msgstr "Naam" #: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88 msgid "Keywords" -msgstr "Etiketten" +msgstr "Trefwoorden" #: .\cookbook\forms.py:125 msgid "Preparation time in minutes" @@ -1224,7 +1224,7 @@ msgstr "Markdown gids" #: .\cookbook\templates\base.html:329 msgid "GitHub" -msgstr "GitHub" +msgstr "Github" #: .\cookbook\templates\base.html:331 msgid "Translate Tandoor" diff --git a/cookbook/locale/zh_CN/LC_MESSAGES/django.po b/cookbook/locale/zh_CN/LC_MESSAGES/django.po index 3025787a..084689d3 100644 --- a/cookbook/locale/zh_CN/LC_MESSAGES/django.po +++ b/cookbook/locale/zh_CN/LC_MESSAGES/django.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2023-05-18 14:28+0200\n" -"PO-Revision-Date: 2023-02-26 13:15+0000\n" -"Last-Translator: 吕楪 \n" +"PO-Revision-Date: 2024-02-15 03:19+0000\n" +"Last-Translator: dalan \n" "Language-Team: Chinese (Simplified) \n" "Language: zh_CN\n" @@ -480,34 +480,32 @@ msgid "One of queryset or hash_key must be provided" msgstr "必须提供 queryset 或 hash_key 之一" #: .\cookbook\helper\recipe_url_import.py:266 -#, fuzzy -#| msgid "Use fractions" msgid "reverse rotation" -msgstr "使用分数" +msgstr "反向旋转" #: .\cookbook\helper\recipe_url_import.py:267 msgid "careful rotation" -msgstr "" +msgstr "小心旋转" #: .\cookbook\helper\recipe_url_import.py:268 msgid "knead" -msgstr "" +msgstr "揉" #: .\cookbook\helper\recipe_url_import.py:269 msgid "thicken" -msgstr "" +msgstr "增稠" #: .\cookbook\helper\recipe_url_import.py:270 msgid "warm up" -msgstr "" +msgstr "预热" #: .\cookbook\helper\recipe_url_import.py:271 msgid "ferment" -msgstr "" +msgstr "发酵" #: .\cookbook\helper\recipe_url_import.py:272 msgid "sous-vide" -msgstr "" +msgstr "真空烹调法" #: .\cookbook\helper\shopping_helper.py:157 msgid "You must supply a servings size" @@ -549,10 +547,8 @@ msgid "Imported %s recipes." msgstr "导入了%s菜谱。" #: .\cookbook\integration\openeats.py:26 -#, fuzzy -#| msgid "Recipe Home" msgid "Recipe source:" -msgstr "菜谱主页" +msgstr "菜谱来源:" #: .\cookbook\integration\paprika.py:49 msgid "Notes" diff --git a/cookbook/management/commands/seed_basic_data.py b/cookbook/management/commands/seed_basic_data.py new file mode 100644 index 00000000..25e5ef98 --- /dev/null +++ b/cookbook/management/commands/seed_basic_data.py @@ -0,0 +1,25 @@ +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.postgres.search import SearchVector +from django.core.management.base import BaseCommand +from django.utils import translation +from django.utils.translation import gettext_lazy as _ +from django_scopes import scopes_disabled + +from cookbook.managers import DICTIONARY +from cookbook.models import Recipe, Step, Space + + +class Command(BaseCommand): + help = 'Seeds some basic data (space, account, food)' + + def handle(self, *args, **options): + with scopes_disabled(): + user = User.objects.get_or_create(username='test')[0] + user.set_password('test') + user.save() + + space = Space.objects.get_or_create( + name='Test Space', + created_by=user + )[0] diff --git a/cookbook/migrations/0159_add_shoppinglistentry_fields.py b/cookbook/migrations/0159_add_shoppinglistentry_fields.py index 9246c9b0..ca7ead35 100644 --- a/cookbook/migrations/0159_add_shoppinglistentry_fields.py +++ b/cookbook/migrations/0159_add_shoppinglistentry_fields.py @@ -6,11 +6,12 @@ from django.conf import settings from django.db import migrations, models from django_scopes import scopes_disabled -from cookbook.models import PermissionModelMixin, ShoppingListEntry +from cookbook.models import PermissionModelMixin def copy_values_to_sle(apps, schema_editor): with scopes_disabled(): + ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry') entries = ShoppingListEntry.objects.all() for entry in entries: if entry.shoppinglist_set.first(): diff --git a/cookbook/migrations/0160_delete_shoppinglist_orphans.py b/cookbook/migrations/0160_delete_shoppinglist_orphans.py index 26e08656..6966eae6 100644 --- a/cookbook/migrations/0160_delete_shoppinglist_orphans.py +++ b/cookbook/migrations/0160_delete_shoppinglist_orphans.py @@ -1,25 +1,22 @@ # Generated by Django 3.2.7 on 2021-10-01 22:34 -import datetime from datetime import timedelta -import django.db.models.deletion from django.conf import settings -from django.db import migrations, models +from django.db import migrations from django.utils import timezone -from django.utils.timezone import utc from django_scopes import scopes_disabled -from cookbook.models import FoodInheritField, ShoppingListEntry - def delete_orphaned_sle(apps, schema_editor): + ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry') with scopes_disabled(): # shopping list entry is orphaned - delete it ShoppingListEntry.objects.filter(shoppinglist=None).delete() def create_inheritfields(apps, schema_editor): + FoodInheritField = apps.get_model('cookbook', 'FoodInheritField') FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category') FoodInheritField.objects.create(name='On Hand', field='food_onhand') FoodInheritField.objects.create(name='Diet', field='diet') @@ -29,6 +26,7 @@ def create_inheritfields(apps, schema_editor): def set_completed_at(apps, schema_editor): + ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry') today_start = timezone.now().replace(hour=0, minute=0, second=0) # arbitrary - keeping all of the closed shopping list items out of the 'recent' view month_ago = today_start - timedelta(days=30) diff --git a/cookbook/migrations/0200_alter_propertytype_options_remove_keyword_icon_and_more.py b/cookbook/migrations/0200_alter_propertytype_options_remove_keyword_icon_and_more.py index a57ad977..03f7aa6d 100644 --- a/cookbook/migrations/0200_alter_propertytype_options_remove_keyword_icon_and_more.py +++ b/cookbook/migrations/0200_alter_propertytype_options_remove_keyword_icon_and_more.py @@ -13,22 +13,22 @@ def migrate_icons(apps, schema_editor): PropertyType = apps.get_model('cookbook', 'PropertyType') RecipeBook = apps.get_model('cookbook', 'RecipeBook') - duplicate_meal_types = MealType.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all() + duplicate_meal_types = MealType.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all() if len(duplicate_meal_types) > 0: raise RuntimeError(f'Duplicate MealTypes found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}') MealType.objects.update(name=Concat(F('icon'), Value(' '), F('name'))) - duplicate_meal_types = Keyword.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all() + duplicate_meal_types = Keyword.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all() if len(duplicate_meal_types) > 0: raise RuntimeError(f'Duplicate Keyword found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}') Keyword.objects.update(name=Concat(F('icon'), Value(' '), F('name'))) - duplicate_meal_types = PropertyType.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all() + duplicate_meal_types = PropertyType.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all() if len(duplicate_meal_types) > 0: raise RuntimeError(f'Duplicate PropertyType found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}') PropertyType.objects.update(name=Concat(F('icon'), Value(' '), F('name'))) - duplicate_meal_types = RecipeBook.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all() + duplicate_meal_types = RecipeBook.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all() if len(duplicate_meal_types) > 0: raise RuntimeError(f'Duplicate RecipeBook found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}') RecipeBook.objects.update(name=Concat(F('icon'), Value(' '), F('name'))) @@ -40,7 +40,7 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython( migrate_icons), + migrations.RunPython(migrate_icons), migrations.AlterModelOptions( name='propertytype', options={'ordering': ('order',)}, diff --git a/cookbook/migrations/0210_shoppinglistentry_updated_at.py b/cookbook/migrations/0210_shoppinglistentry_updated_at.py new file mode 100644 index 00000000..28c2a135 --- /dev/null +++ b/cookbook/migrations/0210_shoppinglistentry_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-01-28 10:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0209_remove_space_use_plural'), + ] + + operations = [ + migrations.AddField( + model_name='shoppinglistentry', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/cookbook/migrations/0211_recipebook_order.py b/cookbook/migrations/0211_recipebook_order.py new file mode 100644 index 00000000..c6044a0b --- /dev/null +++ b/cookbook/migrations/0211_recipebook_order.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.7 on 2024-02-16 19:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0210_shoppinglistentry_updated_at'), + ] + + operations = [ + migrations.AddField( + model_name='recipebook', + name='order', + field=models.IntegerField(default=0), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 9954133d..cb129e0c 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -320,10 +320,18 @@ class Space(ExportModelOperationsMixin('space'), models.Model): BookmarkletImport.objects.filter(space=self).delete() CustomFilter.objects.filter(space=self).delete() + Property.objects.filter(space=self).delete() + PropertyType.objects.filter(space=self).delete() + Comment.objects.filter(recipe__space=self).delete() - Keyword.objects.filter(space=self).delete() Ingredient.objects.filter(space=self).delete() - Food.objects.filter(space=self).delete() + Keyword.objects.filter(space=self).delete() + + # delete food in batches because treabeard might fail to delete otherwise + while Food.objects.filter(space=self).count() > 0: + pks = Food.objects.filter(space=self).values_list('pk')[:200] + Food.objects.filter(pk__in=pks).delete() + Unit.objects.filter(space=self).delete() Step.objects.filter(space=self).delete() NutritionInformation.objects.filter(space=self).delete() @@ -347,9 +355,11 @@ class Space(ExportModelOperationsMixin('space'), models.Model): SupermarketCategory.objects.filter(space=self).delete() Supermarket.objects.filter(space=self).delete() - InviteLink.objects.filter(space=self).delete() UserFile.objects.filter(space=self).delete() + UserSpace.objects.filter(space=self).delete() Automation.objects.filter(space=self).delete() + InviteLink.objects.filter(space=self).delete() + TelegramBot.objects.filter(space=self).delete() self.delete() def get_owner(self): @@ -442,6 +452,7 @@ class UserPreference(models.Model, PermissionModelMixin): self.use_fractions = FRACTION_PREF_DEFAULT return super().save(*args, **kwargs) + def __str__(self): return str(self.user) @@ -763,7 +774,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss objects = ScopedManager(space='space') def __str__(self): - return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}' + return f'{self.pk}: {self.amount} ' + (self.food.name if self.food else ' ') + (self.unit.name if self.unit else '') class Meta: ordering = ['order', 'pk'] @@ -983,6 +994,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod shared = models.ManyToManyField(User, blank=True, related_name='shared_with') created_by = models.ForeignKey(User, on_delete=models.CASCADE) filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL) + order = models.IntegerField(default=0) space = models.ForeignKey(Space, on_delete=models.CASCADE) objects = ScopedManager(space='space') @@ -1099,6 +1111,8 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model checked = models.BooleanField(default=False) created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + completed_at = models.DateTimeField(null=True, blank=True) delay_until = models.DateTimeField(null=True, blank=True) diff --git a/cookbook/serializer.py b/cookbook/serializer.py index 09e57103..873880d6 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -57,7 +57,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): api_serializer = None # extended values are computationally expensive and not needed in normal circumstances try: - if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: + if str2bool( + self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer: return fields except (AttributeError, KeyError): pass @@ -81,12 +82,14 @@ class ExtendedRecipeMixin(serializers.ModelSerializer): class OpenDataModelMixin(serializers.ModelSerializer): def create(self, validated_data): - if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '': + if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[ + 'open_data_slug'].strip() == '': validated_data['open_data_slug'] = None return super().create(validated_data) def update(self, instance, validated_data): - if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data['open_data_slug'].strip() == '': + if 'open_data_slug' in validated_data and validated_data['open_data_slug'] is not None and validated_data[ + 'open_data_slug'].strip() == '': validated_data['open_data_slug'] = None return super().update(instance, validated_data) @@ -122,7 +125,8 @@ class CustomOnHandField(serializers.Field): if not self.context["request"].user.is_authenticated: return [] shared_users = [] - if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): + if c := caches['default'].get( + f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None): shared_users = c else: try: @@ -332,7 +336,8 @@ class UserSpaceSerializer(WritableNestedModelSerializer): class Meta: model = UserSpace - fields = ('id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',) + fields = ( + 'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',) read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space') @@ -348,7 +353,7 @@ class MealTypeSerializer(SpacedModelSerializer, WritableNestedModelSerializer): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) validated_data['created_by'] = self.context['request'].user - obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, created_by=self.context['request'].user, defaults=validated_data) return obj class Meta: @@ -382,13 +387,15 @@ class UserPreferenceSerializer(WritableNestedModelSerializer): class Meta: model = UserPreference fields = ( - 'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', 'use_fractions', 'use_kj', + 'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', + 'use_fractions', 'use_kj', 'plan_share', 'nav_sticky', 'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay', 'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix', - 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', 'food_children_exist' + 'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', + 'food_children_exist' ) @@ -477,10 +484,13 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin) if x := validated_data.get('name', None): validated_data['plural_name'] = x.strip() - if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first(): + if unit := Unit.objects.filter( + Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), + space=space).first(): return unit - obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj def update(self, instance, validated_data): @@ -500,7 +510,8 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) - obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj def update(self, instance, validated_data): @@ -525,7 +536,8 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) - obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj class Meta: @@ -540,7 +552,8 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() space = validated_data.pop('space', self.context['request'].space) - obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data) + obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, + defaults=validated_data) return obj class Meta: @@ -665,12 +678,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR properties = validated_data.pop('properties', None) - obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit, + obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, + properties_food_unit=properties_food_unit, defaults=validated_data) if properties and len(properties) > 0: for p in properties: - obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space)) + obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], + property_amount=p['property_amount'], space=space)) return obj @@ -702,7 +717,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR 'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id', 'food_onhand', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping', - 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug', + 'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', + 'open_data_slug', ) read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe') @@ -726,7 +742,8 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer): uch = UnitConversionHelper(self.context['request'].space) conversions = [] for c in uch.get_conversions(obj): - conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper + conversions.append( + {'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper return conversions else: return [] @@ -828,7 +845,8 @@ class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin class Meta: model = UnitConversion - fields = ('id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug') + fields = ( + 'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug') class NutritionInformationSerializer(serializers.ModelSerializer): @@ -898,7 +916,8 @@ class RecipeSerializer(RecipeBaseSerializer): fields = ( 'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url', - 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating', + 'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', + 'file_path', 'servings_text', 'rating', 'last_cooked', 'private', 'shared', ) @@ -960,7 +979,7 @@ class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer) class Meta: model = RecipeBook - fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter') + fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter', 'order') read_only_fields = ('created_by',) @@ -977,7 +996,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer): def create(self, validated_data): book = validated_data['book'] recipe = validated_data['recipe'] - if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared(): + if not book.get_owner() == self.context['request'].user and not self.context[ + 'request'].user in book.get_shared(): raise NotFound(detail=None, code=None) obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe) return obj @@ -1041,6 +1061,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): name = serializers.SerializerMethodField('get_name') # should this be done at the front end? recipe_name = serializers.ReadOnlyField(source='recipe.name') mealplan_note = serializers.ReadOnlyField(source='mealplan.note') + mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date') + mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name') servings = CustomDecimalField() def get_name(self, obj): @@ -1064,14 +1086,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer): class Meta: model = ShoppingListRecipe - fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note') + fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date', + 'mealplan_type') read_only_fields = ('id',) class ShoppingListEntrySerializer(WritableNestedModelSerializer): food = FoodSerializer(allow_null=True) unit = UnitSerializer(allow_null=True, required=False) - ingredient_note = serializers.ReadOnlyField(source='ingredient.note') recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True) amount = CustomDecimalField() created_by = UserSerializer(read_only=True) @@ -1082,7 +1104,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): # autosync values are only needed for frequent 'checked' value updating if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))): - for f in list(set(fields) - set(['id', 'checked'])): + for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])): del fields[f] return fields @@ -1124,11 +1146,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer): class Meta: model = ShoppingListEntry fields = ( - 'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', + 'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked', 'recipe_mealplan', - 'created_by', 'created_at', 'completed_at', 'delay_until' + 'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until' ) - read_only_fields = ('id', 'created_by', 'created_at',) + read_only_fields = ('id', 'created_by', 'created_at','updated_at',) + + +class ShoppingListEntryBulkSerializer(serializers.Serializer): + ids = serializers.ListField() + checked = serializers.BooleanField() # TODO deprecate @@ -1283,7 +1310,8 @@ class InviteLinkSerializer(WritableNestedModelSerializer): class Meta: model = InviteLink fields = ( - 'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',) + 'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', + 'created_at',) read_only_fields = ('id', 'uuid', 'created_by', 'created_at',) diff --git a/cookbook/static/themes/tandoor.min.css b/cookbook/static/themes/tandoor.min.css index 5744a8d8..36abfb42 100644 --- a/cookbook/static/themes/tandoor.min.css +++ b/cookbook/static/themes/tandoor.min.css @@ -480,7 +480,7 @@ hr { margin-top: 1rem; margin-bottom: 1rem; border: 0; - border-top: 1px solid rgba(0, 0, 0, .1) + border-top: 1px solid #ced4da; } .small, small { diff --git a/cookbook/static/themes/tandoor_dark.min.css b/cookbook/static/themes/tandoor_dark.min.css index 68a14037..cd72ad7c 100644 --- a/cookbook/static/themes/tandoor_dark.min.css +++ b/cookbook/static/themes/tandoor_dark.min.css @@ -2850,89 +2850,41 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt color: #fff } -.btn-primary:hover { - background: transparent; - color: #b98766; - border: 1px solid #b98766 -} - .btn-secondary { transition: all .5s ease-in-out; color: #fff } -.btn-secondary:hover { - background: transparent; - color: #b55e4f; - border: 1px solid #b55e4f -} - .btn-success { transition: all .5s ease-in-out; color: #fff } -.btn-success:hover { - background: transparent; - color: #82aa8b; - border: 1px solid #82aa8b -} - .btn-info { transition: all .5s ease-in-out; color: #fff } -.btn-info:hover { - background: transparent; - color: #385f84; - border: 1px solid #385f84 -} - .btn-warning { transition: all .5s ease-in-out; color: #fff } -.btn-warning:hover { - background: transparent; - color: #eaaa21; - border: 1px solid #eaaa21 -} - .btn-danger { transition: all .5s ease-in-out; color: #fff } -.btn-danger:hover { - background: transparent; - color: #a7240e; - border: 1px solid #a7240e -} - .btn-light { transition: all .5s ease-in-out; color: #fff } -.btn-light:hover { - background-color: hsla(0, 0%, 18%, .5); - color: #cfd5cd; - border: 1px solid hsla(0, 0%, 18%, .5) -} - .btn-dark { transition: all .5s ease-in-out; color: #fff } -.btn-dark:hover { - background: transparent; - color: #221e1e; - border: 1px solid #221e1e -} - .btn-opacity-primary { color: #b98766; background-color: #0012a7; @@ -6155,7 +6107,7 @@ a.close.disabled { padding: .5rem .75rem; margin-bottom: 0; font-size: 1rem; - background-color: #f7f7f7; + background-color: #242424; border-bottom: 1px solid #ebebeb; border-top-left-radius: calc(.3rem - 1px); border-top-right-radius: calc(.3rem - 1px) diff --git a/cookbook/templates/account/password_reset.html b/cookbook/templates/account/password_reset.html index 60cfd702..7337440a 100644 --- a/cookbook/templates/account/password_reset.html +++ b/cookbook/templates/account/password_reset.html @@ -34,5 +34,14 @@ +
+
+ {% trans "Sign In" %} + {% if SIGNUP_ENABLED %} + - {% trans "Sign Up" %} + {% endif %} +
+
+ {% endblock %} \ No newline at end of file diff --git a/cookbook/templates/account/password_reset_done.html b/cookbook/templates/account/password_reset_done.html index b756e8ab..aca75783 100644 --- a/cookbook/templates/account/password_reset_done.html +++ b/cookbook/templates/account/password_reset_done.html @@ -7,11 +7,32 @@ {% block title %}{% trans "Password Reset" %}{% endblock %} {% block content %} -

{% trans "Password Reset" %}

+ {% if user.is_authenticated %} - {% include "account/snippets/already_logged_in.html" %} + {% include "account/snippets/already_logged_in.html" %} {% endif %} -

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

+
+
+

{% trans "Password Reset" %}

+
+
+ +
+
+
+

{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}

+
+
+ +
+
+ {% trans "Sign In" %} + {% if SIGNUP_ENABLED %} + - {% trans "Sign Up" %} + {% endif %} +
+
+ {% endblock %} \ No newline at end of file diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html index 51f4d9f1..14b3a18f 100644 --- a/cookbook/templates/base.html +++ b/cookbook/templates/base.html @@ -404,7 +404,7 @@ {% endif %} -
diff --git a/cookbook/templates/recipe_view.html b/cookbook/templates/recipe_view.html index 2ec2b117..c0f62d47 100644 --- a/cookbook/templates/recipe_view.html +++ b/cookbook/templates/recipe_view.html @@ -79,6 +79,7 @@ window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}' window.RECIPE_ID = {{recipe.pk}}; + window.RECIPE_SERVINGS = '{{ servings }}' window.SHARE_UID = '{{ share }}'; window.USER_PREF = { 'use_fractions': {% if request.user.userpreference.use_fractions %} true {% else %} false {% endif %}, diff --git a/cookbook/templates/shoppinglist_template.html b/cookbook/templates/shoppinglist_template.html index c8c1d3f5..ceb61342 100644 --- a/cookbook/templates/shoppinglist_template.html +++ b/cookbook/templates/shoppinglist_template.html @@ -2,6 +2,7 @@ {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} + {% block title %} {{ title }} {% endblock %} {% block content_fluid %} @@ -10,16 +11,21 @@
-{% endblock %} {% block script %} {% if debug %} - -{% else %} - -{% endif %} +{% endblock %} - +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} -{% render_bundle 'shopping_list_view' %} {% endblock %} + + + + {% render_bundle 'shopping_list_view' %} +{% endblock %} diff --git a/cookbook/templatetags/custom_tags.py b/cookbook/templatetags/custom_tags.py index cdacd8e7..3c0e2582 100644 --- a/cookbook/templatetags/custom_tags.py +++ b/cookbook/templatetags/custom_tags.py @@ -114,6 +114,7 @@ def page_help(page_name): 'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/', 'view_shopping': 'https://docs.tandoor.dev/features/shopping/', 'view_import': 'https://docs.tandoor.dev/features/import_export/', + 'data_import_url': 'https://docs.tandoor.dev/features/import_export/', 'view_export': 'https://docs.tandoor.dev/features/import_export/', 'list_automation': 'https://docs.tandoor.dev/features/automation/', } diff --git a/cookbook/tests/api/test_api_shopping_list_entry.py b/cookbook/tests/api/test_api_shopping_list_entry.py index 0ea9a2d8..06319bf6 100644 --- a/cookbook/tests/api/test_api_shopping_list_entry.py +++ b/cookbook/tests/api/test_api_shopping_list_entry.py @@ -13,7 +13,9 @@ DETAIL_URL = 'api:shoppinglistentry-detail' @pytest.fixture() def obj_1(space_1, u1_s1): - e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), + food=Food.objects.get_or_create(name='test 1', space=space_1)[0], + space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -21,7 +23,9 @@ def obj_1(space_1, u1_s1): @pytest.fixture def obj_2(space_1, u1_s1): - e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1) + e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), + food=Food.objects.get_or_create(name='test 2', space=space_1)[0], + space=space_1) s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, ) s.entries.add(e) return e @@ -79,6 +83,29 @@ def test_update(arg, request, obj_1): assert response['amount'] == 2 +@pytest.mark.parametrize("arg", [ + ['a_u', 403], + ['g1_s1', 403], + ['u1_s1', 200], + ['a1_s1', 200], + ['g1_s2', 403], + ['u1_s2', 200], + ['a1_s2', 200], +]) +def test_bulk_update(arg, request, obj_1, obj_2): + c = request.getfixturevalue(arg[0]) + r = c.post( + reverse(LIST_URL, ) + 'bulk/', + {'ids': [obj_1.id, obj_2.id], 'checked': True}, + content_type='application/json' + ) + assert r.status_code == arg[1] + assert r + if r.status_code == 200: + obj_1.refresh_from_db() + assert obj_1.checked == (arg[0] == 'u1_s1') + + @pytest.mark.parametrize("arg", [ ['a_u', 403], ['g1_s1', 201], diff --git a/cookbook/tests/api/test_api_shopping_list_entryv2.py b/cookbook/tests/api/test_api_shopping_list_entryv2.py index a1e78f7b..f1266d1e 100644 --- a/cookbook/tests/api/test_api_shopping_list_entryv2.py +++ b/cookbook/tests/api/test_api_shopping_list_entryv2.py @@ -214,6 +214,9 @@ def test_completed(sle, u1_s1): def test_recent(sle, u1_s1): user = auth.get_user(u1_s1) + user.userpreference.shopping_recent_days = 7 # hardcoded API limit 14 days + user.userpreference.save() + today_start = timezone.now().replace(hour=0, minute=0, second=0) # past_date within recent_days threshold diff --git a/cookbook/tests/api/test_api_shopping_recipe.py b/cookbook/tests/api/test_api_shopping_recipe.py index 473a631c..5001aca2 100644 --- a/cookbook/tests/api/test_api_shopping_recipe.py +++ b/cookbook/tests/api/test_api_shopping_recipe.py @@ -7,7 +7,7 @@ from django.contrib import auth from django.urls import reverse from django_scopes import scopes_disabled -from cookbook.models import Food, Ingredient +from cookbook.models import Food, Ingredient, ShoppingListRecipe, ShoppingListEntry from cookbook.tests.factories import MealPlanFactory, RecipeFactory, StepFactory, UserFactory if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql': @@ -126,7 +126,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id})) r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content) assert [x['created_by']['id'] for x in r].count(user.id) == sle_count - all_ing = [x['ingredient'] for x in r] + all_ing = list(ShoppingListEntry.objects.filter(list_recipe__recipe=recipe).all().values_list('ingredient', flat=True)) keep_ing = all_ing[1:-1] # remove first and last element del keep_ing[int(len(keep_ing) / 2)] # remove a middle element list_recipe = r[0]['list_recipe'] diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 59e40564..f2aa20cd 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -30,6 +30,7 @@ from django.http import FileResponse, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone +from django.utils.timezone import make_aware from django.utils.translation import gettext as _ from django_scopes import scopes_disabled from icalendar import Calendar, Event @@ -102,7 +103,8 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, SupermarketCategorySerializer, SupermarketSerializer, SyncLogSerializer, SyncSerializer, UnitConversionSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer, - UserSerializer, UserSpaceSerializer, ViewLogSerializer) + UserSerializer, UserSpaceSerializer, ViewLogSerializer, + ShoppingListEntryBulkSerializer) from cookbook.views.import_export import get_integration from recipes import settings from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT @@ -480,6 +482,7 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet): class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): + schema = FilterSchema() queryset = Supermarket.objects serializer_class = SupermarketSerializer permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] @@ -489,7 +492,7 @@ class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin): return super().get_queryset() -class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin): +class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin, MergeMixin): queryset = SupermarketCategory.objects model = SupermarketCategory serializer_class = SupermarketCategorySerializer @@ -659,8 +662,16 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin): permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): + order_field = self.request.GET.get('order_field') + order_direction = self.request.GET.get('order_direction') + + if not order_field: + order_field = 'id' + + ordering = f"{'' if order_direction == 'asc' else '-'}{order_field}" + self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter( - space=self.request.space).distinct() + space=self.request.space).distinct().order_by(ordering) return super().get_queryset() @@ -1160,11 +1171,47 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet): if pk := self.request.query_params.getlist('id', []): self.queryset = self.queryset.filter(food__id__in=[int(i) for i in pk]) - if 'checked' in self.request.query_params or 'recent' in self.request.query_params: + if 'checked' in self.request.query_params: return shopping_helper(self.queryset, self.request) + elif not self.detail: + today_start = timezone.now().replace(hour=0, minute=0, second=0) + week_ago = today_start - datetime.timedelta(days=min(self.request.user.userpreference.shopping_recent_days, 14)) + self.queryset = self.queryset.filter(Q(checked=False) | Q(completed_at__gte=week_ago)) + + try: + last_autosync = self.request.query_params.get('last_autosync', None) + if last_autosync: + last_autosync = datetime.datetime.fromtimestamp(int(last_autosync) / 1000, datetime.timezone.utc) + self.queryset = self.queryset.filter(updated_at__gte=last_autosync) + except: + traceback.print_exc() # TODO once old shopping list is removed this needs updated to sharing users in preferences - return self.queryset + if self.detail: + return self.queryset + else: + return self.queryset[:1000] + + @decorators.action( + detail=False, + methods=['POST'], + serializer_class=ShoppingListEntryBulkSerializer, + permission_classes=[CustomIsUser] + ) + def bulk(self, request): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + ShoppingListEntry.objects.filter( + Q(created_by=self.request.user) + | Q(shoppinglist__shared=self.request.user) + | Q(created_by__in=list(self.request.user.get_shopping_share())) + ).filter(space=request.space, id__in=serializer.validated_data['ids']).update( + checked=serializer.validated_data['checked'], + updated_at=timezone.now(), + ) + return Response(serializer.data) + else: + return Response(serializer.errors, 400) # TODO deprecate @@ -1174,11 +1221,13 @@ class ShoppingListViewSet(viewsets.ModelViewSet): permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope] def get_queryset(self): - return self.queryset.filter( + self.queryset = self.queryset.filter( Q(created_by=self.request.user) | Q(shared=self.request.user) | Q(created_by__in=list(self.request.user.get_shopping_share())) - ).filter(space=self.request.space).distinct() + ).filter(space=self.request.space) + + return self.queryset.distinct() def get_serializer_class(self): try: @@ -1247,6 +1296,7 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet): class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin): + schema = FilterSchema() queryset = UserFile.objects serializer_class = UserFileSerializer permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope] @@ -1555,6 +1605,7 @@ class ImportOpenData(APIView): # TODO validate data print(request.data) selected_version = request.data['selected_version'] + selected_datatypes = request.data['selected_datatypes'] update_existing = str2bool(request.data['update_existing']) use_metric = str2bool(request.data['use_metric']) @@ -1564,12 +1615,19 @@ class ImportOpenData(APIView): response_obj = {} data_importer = OpenDataImporter(request, data, update_existing=update_existing, use_metric=use_metric) - response_obj['unit'] = len(data_importer.import_units()) - response_obj['category'] = len(data_importer.import_category()) - response_obj['property'] = len(data_importer.import_property()) - response_obj['store'] = len(data_importer.import_supermarket()) - response_obj['food'] = len(data_importer.import_food()) - response_obj['conversion'] = len(data_importer.import_conversion()) + + if selected_datatypes['unit']['selected']: + response_obj['unit'] = data_importer.import_units() + if selected_datatypes['category']['selected']: + response_obj['category'] = data_importer.import_category() + if selected_datatypes['property']['selected']: + response_obj['property'] = data_importer.import_property() + if selected_datatypes['store']['selected']: + response_obj['store'] = data_importer.import_supermarket() + if selected_datatypes['food']['selected']: + response_obj['food'] = data_importer.import_food() + if selected_datatypes['conversion']['selected']: + response_obj['conversion'] = data_importer.import_conversion() return Response(response_obj) diff --git a/cookbook/views/edit.py b/cookbook/views/edit.py index 1726b2b5..84b6c411 100644 --- a/cookbook/views/edit.py +++ b/cookbook/views/edit.py @@ -9,8 +9,7 @@ from django.views.generic import UpdateView from django.views.generic.edit import FormMixin from cookbook.forms import CommentForm, ExternalRecipeForm, StorageForm, SyncForm -from cookbook.helper.permission_helper import (GroupRequiredMixin, OwnerRequiredMixin, - above_space_limit, group_required) +from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, above_space_limit, group_required from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync from cookbook.provider.dropbox import Dropbox from cookbook.provider.local import Local @@ -102,26 +101,16 @@ def edit_storage(request, pk): instance.save() - messages.add_message( - request, messages.SUCCESS, _('Storage saved!') - ) + messages.add_message(request, messages.SUCCESS, _('Storage saved!')) else: - messages.add_message( - request, - messages.ERROR, - _('There was an error updating this storage backend!') - ) + messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend!')) else: pseudo_instance = instance pseudo_instance.password = '__NO__CHANGE__' pseudo_instance.token = '__NO__CHANGE__' form = StorageForm(instance=pseudo_instance) - return render( - request, - 'generic/edit_template.html', - {'form': form, 'title': _('Storage')} - ) + return render(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')}) class CommentUpdate(OwnerRequiredMixin, UpdateView): @@ -135,9 +124,7 @@ class CommentUpdate(OwnerRequiredMixin, UpdateView): def get_context_data(self, **kwargs): context = super(CommentUpdate, self).get_context_data(**kwargs) context['title'] = _("Comment") - context['view_url'] = reverse( - 'view_recipe', args=[self.object.recipe.pk] - ) + context['view_url'] = reverse('view_recipe', args=[self.object.recipe.pk]) return context @@ -176,11 +163,7 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing): if self.object.storage.method == Storage.LOCAL: Local.rename_file(old_recipe, self.object.name) - self.object.file_path = "%s/%s%s" % ( - os.path.dirname(self.object.file_path), - self.object.name, - os.path.splitext(self.object.file_path)[1] - ) + self.object.file_path = "%s/%s%s" % (os.path.dirname(self.object.file_path), self.object.name, os.path.splitext(self.object.file_path)[1]) messages.add_message(self.request, messages.SUCCESS, _('Changes saved!')) return super(ExternalRecipeUpdate, self).form_valid(form) @@ -197,7 +180,5 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing): context['title'] = _("Recipe") context['view_url'] = reverse('view_recipe', args=[self.object.pk]) if self.object.storage: - context['delete_external_url'] = reverse( - 'delete_recipe_source', args=[self.object.pk] - ) + context['delete_external_url'] = reverse('delete_recipe_source', args=[self.object.pk]) return context diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 1c12c77d..55474494 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -1,10 +1,10 @@ import json import os import re +import subprocess from datetime import datetime from io import StringIO from uuid import UUID -import subprocess from django.apps import apps from django.conf import settings @@ -23,17 +23,14 @@ from django.utils import timezone from django.utils.translation import gettext as _ from django_scopes import scopes_disabled -from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, - SpaceJoinForm, User, UserCreateForm, UserPreference) +from cookbook.forms import CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, SpaceJoinForm, User, UserCreateForm, UserPreference from cookbook.helper.HelperFunctions import str2bool -from cookbook.helper.permission_helper import (group_required, has_group_permission, - share_link_valid, switch_user_active_space) -from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, - ShareLink, Space, UserSpace, ViewLog) +from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid, switch_user_active_space +from cookbook.models import Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink, Space, UserSpace, ViewLog from cookbook.tables import CookLogTable, ViewLogTable from cookbook.templatetags.theming_tags import get_theming_values from cookbook.version_info import VERSION_INFO -from recipes.settings import PLUGINS, BASE_DIR +from recipes.settings import BASE_DIR, PLUGINS def index(request): @@ -44,11 +41,7 @@ def index(request): return HttpResponseRedirect(reverse_lazy('view_search')) try: - page_map = { - UserPreference.SEARCH: reverse_lazy('view_search'), - UserPreference.PLAN: reverse_lazy('view_plan'), - UserPreference.BOOKS: reverse_lazy('view_books'), - } + page_map = {UserPreference.SEARCH: reverse_lazy('view_search'), UserPreference.PLAN: reverse_lazy('view_plan'), UserPreference.BOOKS: reverse_lazy('view_books'), } return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page)) except UserPreference.DoesNotExist: @@ -57,7 +50,7 @@ def index(request): # TODO need to deprecate def search(request): - if has_group_permission(request.user, ('guest',)): + if has_group_permission(request.user, ('guest', )): return render(request, 'search.html', {}) else: if request.user.is_authenticated: @@ -84,14 +77,13 @@ def space_overview(request): _('You have the reached the maximum amount of spaces that can be owned by you.') + f' ({request.user.userpreference.max_owned_spaces})') return HttpResponseRedirect(reverse('view_space_overview')) - created_space = Space.objects.create( - name=create_form.cleaned_data['name'], - created_by=request.user, - max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES, - max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES, - max_users=settings.SPACE_DEFAULT_MAX_USERS, - allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING, - ) + created_space = Space.objects.create(name=create_form.cleaned_data['name'], + created_by=request.user, + max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES, + max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES, + max_users=settings.SPACE_DEFAULT_MAX_USERS, + allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING, + ) user_space = UserSpace.objects.create(space=created_space, user=request.user, active=False) user_space.groups.add(Group.objects.filter(name='admin').get()) @@ -135,23 +127,18 @@ def recipe_view(request, pk, share=None): recipe = get_object_or_404(Recipe, pk=pk) if not request.user.is_authenticated and not share_link_valid(recipe, share): - messages.add_message(request, messages.ERROR, - _('You do not have the required permissions to view this page!')) + messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path) - if not (has_group_permission(request.user, - ('guest',)) and recipe.space == request.space) and not share_link_valid(recipe, - share): - messages.add_message(request, messages.ERROR, - _('You do not have the required permissions to view this page!')) + if not (has_group_permission(request.user, ('guest', )) and recipe.space == request.space) and not share_link_valid(recipe, share): + messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!')) return HttpResponseRedirect(reverse('index')) comments = Comment.objects.filter(recipe__space=request.space, recipe=recipe) if request.method == "POST": if not request.user.is_authenticated: - messages.add_message(request, messages.ERROR, - _('You do not have the required permissions to perform this action!')) + messages.add_message(request, messages.ERROR, _('You do not have the required permissions to perform this action!')) return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share})) comment_form = CommentForm(request.POST, prefix='comment') @@ -167,13 +154,14 @@ def recipe_view(request, pk, share=None): comment_form = CommentForm() if request.user.is_authenticated: - if not ViewLog.objects.filter(recipe=recipe, created_by=request.user, - created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)), - space=request.space).exists(): + if not ViewLog.objects.filter(recipe=recipe, created_by=request.user, created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)), space=request.space).exists(): ViewLog.objects.create(recipe=recipe, created_by=request.user, space=request.space) + if request.method == "GET": + servings = request.GET.get("servings") return render(request, 'recipe_view.html', - {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, }) + {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, 'servings': servings }) + @group_required('user') @@ -238,12 +226,8 @@ def shopping_settings(request): if search_form.is_valid(): if not sp: sp = SearchPreferenceForm(user=request.user) - fields_searched = ( - len(search_form.cleaned_data['icontains']) - + len(search_form.cleaned_data['istartswith']) - + len(search_form.cleaned_data['trigram']) - + len(search_form.cleaned_data['fulltext']) - ) + fields_searched = (len(search_form.cleaned_data['icontains']) + len(search_form.cleaned_data['istartswith']) + len(search_form.cleaned_data['trigram']) + + len(search_form.cleaned_data['fulltext'])) if search_form.cleaned_data['preset'] == 'fuzzy': sp.search = SearchPreference.SIMPLE sp.lookup = True @@ -268,13 +252,10 @@ def shopping_settings(request): elif fields_searched == 0: search_form.add_error(None, _('You must select at least one field to search!')) search_error = True - elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len( - search_form.cleaned_data['fulltext']) == 0: - search_form.add_error('search', - _('To use this search method you must select at least one full text search field!')) + elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(search_form.cleaned_data['fulltext']) == 0: + search_form.add_error('search', _('To use this search method you must select at least one full text search field!')) search_error = True - elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len( - search_form.cleaned_data['trigram']) > 0: + elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(search_form.cleaned_data['trigram']) > 0: search_form.add_error(None, _('Fuzzy search is not compatible with this search method!')) search_error = True else: @@ -290,8 +271,7 @@ def shopping_settings(request): else: search_error = True - fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len( - sp.fulltext.all()) + fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(sp.fulltext.all()) if sp and not search_error and fields_searched > 0: search_form = SearchPreferenceForm(instance=sp) elif not search_error: @@ -304,23 +284,13 @@ def shopping_settings(request): sp.fulltext.clear() sp.save() - return render(request, 'settings.html', { - 'search_form': search_form, - }) + return render(request, 'settings.html', {'search_form': search_form, }) @group_required('guest') def history(request): - view_log = ViewLogTable( - ViewLog.objects.filter( - created_by=request.user, space=request.space - ).order_by('-created_at').all() - ) - cook_log = CookLogTable( - CookLog.objects.filter( - created_by=request.user - ).order_by('-created_at').all() - ) + view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user, space=request.space).order_by('-created_at').all()) + cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all()) return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log}) @@ -343,12 +313,10 @@ def system(request): database_message = _('Everything is fine!') elif postgres_ver < postgres_current - 2: database_status = 'danger' - database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % { - 'v': postgres_ver} + database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {'v': postgres_ver} else: database_status = 'info' - database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % { - 'v1': postgres_ver, 'v2': postgres_current} + database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {'v1': postgres_ver, 'v2': postgres_current} else: database_status = 'info' database_message = _( @@ -377,34 +345,26 @@ def system(request): pass else: current_app = row - migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [], - 'total': 0} + migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [], 'total': 0} for key in migration_info.keys(): - migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len( - migration_info[key]['applied_migrations']) + migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(migration_info[key]['applied_migrations']) - return render(request, 'system.html', { - 'gunicorn_media': settings.GUNICORN_MEDIA, - 'debug': settings.DEBUG, - 'postgres': postgres, - 'postgres_version': postgres_ver, - 'postgres_status': database_status, - 'postgres_message': database_message, - 'version_info': VERSION_INFO, - 'plugins': PLUGINS, - 'secret_key': secret_key, - 'orphans': orphans, - 'migration_info': migration_info, - 'missing_migration': missing_migration, - }) + return render( + request, 'system.html', { + 'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres, 'postgres_version': postgres_ver, 'postgres_status': database_status, + 'postgres_message': database_message, 'version_info': VERSION_INFO, 'plugins': PLUGINS, 'secret_key': secret_key, 'orphans': orphans, 'migration_info': migration_info, + 'missing_migration': missing_migration, + }) def setup(request): with scopes_disabled(): if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS: - messages.add_message(request, messages.ERROR, - _('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.')) + messages.add_message( + request, messages.ERROR, + _('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.' + )) return HttpResponseRedirect(reverse('account_login')) if request.method == 'POST': @@ -444,8 +404,7 @@ def invite_link(request, token): link.used_by = request.user link.save() - user_space = UserSpace.objects.create(user=request.user, space=link.space, - internal_note=link.internal_note, invite_link=link, active=False) + user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False) if request.user.userspace_set.count() == 1: user_space.active = True @@ -475,66 +434,36 @@ def space_manage(request, space_id): def report_share_abuse(request, token): if not settings.SHARING_ABUSE: - messages.add_message(request, messages.WARNING, - _('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.')) + messages.add_message(request, messages.WARNING, _('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.')) else: if link := ShareLink.objects.filter(uuid=token).first(): link.abuse_blocked = True link.save() - messages.add_message(request, messages.WARNING, - _('Recipe sharing link has been disabled! For additional information please contact the page administrator.')) + messages.add_message(request, messages.WARNING, _('Recipe sharing link has been disabled! For additional information please contact the page administrator.')) return HttpResponseRedirect(reverse('index')) def web_manifest(request): theme_values = get_theming_values(request) - icons = [ - {"src": theme_values['logo_color_svg'], "sizes": "any"}, - {"src": theme_values['logo_color_144'], "type": "image/png", "sizes": "144x144"}, - {"src": theme_values['logo_color_512'], "type": "image/png", "sizes": "512x512"} - ] + icons = [{"src": theme_values['logo_color_svg'], "sizes": "any"}, {"src": theme_values['logo_color_144'], "type": "image/png", "sizes": "144x144"}, + {"src": theme_values['logo_color_512'], "type": "image/png", "sizes": "512x512"}] manifest_info = { - "name": theme_values['app_name'], - "short_name": theme_values['app_name'], - "description": _("Manage recipes, shopping list, meal plans and more."), - "icons": icons, - "start_url": "./search", - "background_color": theme_values['nav_bg_color'], - "display": "standalone", - "scope": ".", - "theme_color": theme_values['nav_bg_color'], - "shortcuts": [ - { - "name": _("Plan"), - "short_name": _("Plan"), - "description": _("View your meal Plan"), - "url": "./plan" - }, - { - "name": _("Books"), - "short_name": _("Books"), - "description": _("View your cookbooks"), - "url": "./books" - }, - { - "name": _("Shopping"), - "short_name": _("Shopping"), - "description": _("View your shopping lists"), - "url": "./list/shopping-list/" - } - ], - "share_target": { - "action": "/data/import/url", - "method": "GET", - "params": { - "title": "title", - "url": "url", - "text": "text" - - } - } + "name": + theme_values['app_name'], "short_name": + theme_values['app_name'], "description": + _("Manage recipes, shopping list, meal plans and more."), "icons": + icons, "start_url": + "./search", "background_color": + theme_values['nav_bg_color'], "display": + "standalone", "scope": + ".", "theme_color": + theme_values['nav_bg_color'], "shortcuts": + [{"name": _("Plan"), "short_name": _("Plan"), "description": _("View your meal Plan"), "url": + "./plan"}, {"name": _("Books"), "short_name": _("Books"), "description": _("View your cookbooks"), "url": "./books"}, + {"name": _("Shopping"), "short_name": _("Shopping"), "description": _("View your shopping lists"), "url": + "./list/shopping-list/"}], "share_target": {"action": "/data/import/url", "method": "GET", "params": {"title": "title", "url": "url", "text": "text"}} } return JsonResponse(manifest_info, json_dumps_params={'indent': 4}) @@ -564,9 +493,7 @@ def test(request): from cookbook.helper.ingredient_parser import IngredientParser parser = IngredientParser(request, False) - data = { - 'original': '90g golden syrup' - } + data = {'original': '90g golden syrup'} data['parsed'] = parser.parse(data['original']) return render(request, 'test.html', {'data': data}) diff --git a/docs/features/authentication.md b/docs/features/authentication.md index 1b6f497d..84df9fca 100644 --- a/docs/features/authentication.md +++ b/docs/features/authentication.md @@ -65,8 +65,9 @@ At Keycloak, create a new client and assign a `Client-ID`, this client comes wit To enable Keycloak as a sign in option, set those variables to define the social provider and specify its configuration: ```ini -SOCIAL_PROVIDERS=allauth.socialaccount.providers.keycloak -SOCIALACCOUNT_PROVIDERS='{ "keycloak": { "KEYCLOAK_URL": "https://auth.example.com/", "KEYCLOAK_REALM": "master" } }' +SOCIAL_PROVIDERS=allauth.socialaccount.providers.openid_connect +SOCIALACCOUNT_PROVIDERS='{"openid_connect":{"APPS":[{"provider_id":"keycloak","name":"Keycloak","client_id":"KEYCLOAK_CLIENT_ID","secret":"KEYCLOAK_CLIENT_SECRET","settings":{"server_url":"https://auth.example.org/realms/KEYCLOAK_REALM/.well-known/openid-configuration"}}]}} +' ``` 1. Restart the service, login as superuser and open the `Admin` page. diff --git a/docs/install/docker/ipv6_plain/docker-compose.yml b/docs/install/docker/ipv6_plain/docker-compose.yml index 312b1400..e989fc09 100644 --- a/docs/install/docker/ipv6_plain/docker-compose.yml +++ b/docs/install/docker/ipv6_plain/docker-compose.yml @@ -2,7 +2,7 @@ version: "2.4" services: db_recipes: restart: always - image: postgres:15-alpine + image: postgres:16-alpine volumes: - ${POSTGRES_DATA_DIR:-./postgresql}:/var/lib/postgresql/data env_file: diff --git a/docs/install/docker/nginx-proxy/docker-compose.yml b/docs/install/docker/nginx-proxy/docker-compose.yml index 7040c566..2e408646 100644 --- a/docs/install/docker/nginx-proxy/docker-compose.yml +++ b/docs/install/docker/nginx-proxy/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: db_recipes: restart: always - image: postgres:15-alpine + image: postgres:16-alpine volumes: - ./postgresql:/var/lib/postgresql/data env_file: diff --git a/docs/install/docker/plain/docker-compose.yml b/docs/install/docker/plain/docker-compose.yml index 203217e4..089e72c5 100644 --- a/docs/install/docker/plain/docker-compose.yml +++ b/docs/install/docker/plain/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: db_recipes: restart: always - image: postgres:15-alpine + image: postgres:16-alpine volumes: - ./postgresql:/var/lib/postgresql/data env_file: diff --git a/docs/install/docker/traefik-nginx/docker-compose.yml b/docs/install/docker/traefik-nginx/docker-compose.yml index 9947c3a8..afe2fbfb 100644 --- a/docs/install/docker/traefik-nginx/docker-compose.yml +++ b/docs/install/docker/traefik-nginx/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: db_recipes: restart: always - image: postgres:15-alpine + image: postgres:16-alpine volumes: - ./postgresql:/var/lib/postgresql/data env_file: diff --git a/docs/install/swag.md b/docs/install/swag.md index 3f1eda6a..15b2b9b6 100644 --- a/docs/install/swag.md +++ b/docs/install/swag.md @@ -69,7 +69,7 @@ services: db_recipes: restart: always container_name: db_recipes - image: postgres:15-alpine + image: postgres:16-alpine volumes: - ./recipes/db:/var/lib/postgresql/data env_file: diff --git a/docs/install/truenas_portainer.md b/docs/install/truenas_portainer.md index 7be57bf2..436fc595 100644 --- a/docs/install/truenas_portainer.md +++ b/docs/install/truenas_portainer.md @@ -81,7 +81,7 @@ version: "3" services: db_recipes: restart: always - image: postgres:15-alpine + image: postgres:16-alpine volumes: - ./postgresql:/var/lib/postgresql/data env_file: diff --git a/docs/system/updating.md b/docs/system/updating.md index 404da607..caaa0417 100644 --- a/docs/system/updating.md +++ b/docs/system/updating.md @@ -53,7 +53,7 @@ docker stop {{database_container}} {{tandoor_container}} 4. Rename the tandoor volume ``` bash -sudo mv -R ~/.docker/compose/postgres ~/.docker/compose/postgres.old +sudo mv ~/.docker/compose/postgres ~/.docker/compose/postgres.old ``` 5. Update image tag on postgres container. diff --git a/recipes/settings.py b/recipes/settings.py index 36e68c4f..b8bf3557 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -98,8 +98,6 @@ FDC_API_KEY = os.getenv('FDC_API_KEY', 'DEMO_KEY') SHARING_ABUSE = bool(int(os.getenv('SHARING_ABUSE', False))) SHARING_LIMIT = int(os.getenv('SHARING_LIMIT', 0)) -ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm' - DRF_THROTTLE_RECIPE_URL_IMPORT = os.getenv('DRF_THROTTLE_RECIPE_URL_IMPORT', '60/hour') TERMS_URL = os.getenv('TERMS_URL', '') @@ -556,4 +554,19 @@ DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'webmaster@localhost') ACCOUNT_EMAIL_SUBJECT_PREFIX = os.getenv( 'ACCOUNT_EMAIL_SUBJECT_PREFIX', '[Tandoor Recipes] ') # allauth sender prefix +# ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm' +ACCOUNT_FORMS = { + 'signup': 'cookbook.forms.AllAuthSignupForm', + 'reset_password': 'cookbook.forms.CustomPasswordResetForm' +} + +ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False +ACCOUNT_RATE_LIMITS = { + "change_password": "1/m/user", + "reset_password": "1/m/ip,1/m/key", + "reset_password_from_key": "1/m/ip", + "signup": "5/m/ip", + "login": "5/m/ip", +} + mimetypes.add_type("text/javascript", ".js", True) diff --git a/requirements.txt b/requirements.txt index 99b2e99f..e799af7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ -Django==4.2.7 -cryptography===41.0.7 +Django==4.2.10 +cryptography===42.0.2 django-annoying==0.10.6 django-autocomplete-light==3.9.7 django-cleanup==8.0.0 django-crispy-forms==2.0 crispy-bootstrap4==2022.1 -django-tables2==2.5.3 +django-tables2==2.7.0 djangorestframework==3.14.0 drf-writable-nested==0.7.0 django-oauth-toolkit==2.3.0 @@ -29,19 +29,19 @@ microdata==0.8.0 Jinja2==3.1.3 django-webpack-loader==1.8.1 git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82 -django-allauth==0.58.1 +django-allauth==0.61.1 recipe-scrapers==14.52.0 django-scopes==2.0.0 -pytest==7.4.3 -pytest-django==4.6.0 +pytest==8.0.0 +pytest-django==4.8.0 django-treebeard==4.7 django-cors-headers==4.2.0 django-storages==1.14.2 boto3==1.28.75 django-prometheus==2.2.0 django-hCaptcha==0.2.0 -python-ldap==3.4.3 -django-auth-ldap==4.4.0 +python-ldap==3.4.4 +django-auth-ldap==4.6.0 pytest-factoryboy==2.6.0 pyppeteer==1.0.2 validators==0.20.0 diff --git a/vue/package.json b/vue/package.json index 4f56ee31..c4229831 100644 --- a/vue/package.json +++ b/vue/package.json @@ -13,21 +13,20 @@ "@codemirror/commands": "^6.3.2", "@codemirror/lang-markdown": "^6.2.3", "@codemirror/state": "^6.3.3", - "@codemirror/view": "^6.22.2", + "@codemirror/view": "^6.23.1", "@popperjs/core": "^2.11.7", "@vue/cli": "^5.0.8", "@vue/composition-api": "1.7.1", - "axios": "^1.6.0", + "axios": "^1.6.7", "babel": "^6.23.0", "babel-core": "^6.26.3", "babel-loader": "^9.1.0", "bootstrap-vue": "^2.23.1", "core-js": "^3.29.1", - "html2pdf.js": "^0.10.1", "lodash": "^4.17.21", "mavon-editor": "^2.10.4", "moment": "^2.29.4", - "pinia": "^2.0.30", + "pinia": "^2.1.7", "prismjs": "^1.29.0", "string-similarity": "^4.0.4", "vue": "^2.6.14", @@ -62,9 +61,9 @@ "babel-eslint": "^10.1.0", "eslint": "^8.46.0", "eslint-plugin-vue": "^8.7.1", - "typescript": "~5.1.6", + "typescript": "~5.3.3", "vue-cli-plugin-i18n": "^2.3.2", - "webpack-bundle-tracker": "1.8.1", + "webpack-bundle-tracker": "3.0.1", "workbox-background-sync": "^7.0.0", "workbox-expiration": "^6.5.4", "workbox-navigation-preload": "^7.0.0", @@ -86,7 +85,8 @@ "parser": "@typescript-eslint/parser" }, "rules": { - "no-unused-vars": "off" + "no-unused-vars": "off", + "vue/no-unused-components": "warn" } }, "browserslist": [ diff --git a/vue/src/apps/CookbookView/CookbookView.vue b/vue/src/apps/CookbookView/CookbookView.vue index 78e59eae..3695eaa5 100644 --- a/vue/src/apps/CookbookView/CookbookView.vue +++ b/vue/src/apps/CookbookView/CookbookView.vue @@ -11,50 +11,90 @@ + + oldest to newest + newest to oldest + alphabetical order + manually + + + {{submitText}} +
-
-
-
-
- - - - - -
- {{ book.name }} -
-
{{ book.description }}
-
-
-
-
-
-
+
+
+
+
+
+ + + + + + +
+ {{ book.name }} +
+
{{ book.description }}
+
+
+
+
+
+
+
+
+ + + + + +
- - - - - - +
+
+ + + +
+
+ +
+
+
+ + #{{ index + 1 }} + + {{ book.name }} +
+
+
+
+
+
-
- @@ -49,5 +89,46 @@ export default { diff --git a/vue/src/components/BottomNavigationBar.vue b/vue/src/components/BottomNavigationBar.vue index 23b0a244..71002710 100644 --- a/vue/src/components/BottomNavigationBar.vue +++ b/vue/src/components/BottomNavigationBar.vue @@ -11,14 +11,14 @@
-
{{ $t('Recipes') }}
+
{{ $t('Recipes') }}
-
{{ $t('Meal_Plan') }}
+
{{ $t('Meal_Plan') }}
@@ -53,14 +53,14 @@
-
{{ $t('Shopping_list') }}
+
{{ $t('Shopping_list') }}
-
{{ $t('Books') }}
+
{{ $t('Books') }}
diff --git a/vue/src/components/Buttons/DownloadPDF.vue b/vue/src/components/Buttons/DownloadPDF.vue index fd7560f9..ce9ea626 100644 --- a/vue/src/components/Buttons/DownloadPDF.vue +++ b/vue/src/components/Buttons/DownloadPDF.vue @@ -6,7 +6,6 @@ diff --git a/vue/src/components/NumberScalerComponent.vue b/vue/src/components/NumberScalerComponent.vue new file mode 100644 index 00000000..f9cb5eab --- /dev/null +++ b/vue/src/components/NumberScalerComponent.vue @@ -0,0 +1,74 @@ + + + + + + \ No newline at end of file diff --git a/vue/src/components/OpenDataImportComponent.vue b/vue/src/components/OpenDataImportComponent.vue index ebb3e8b2..a591ac44 100644 --- a/vue/src/components/OpenDataImportComponent.vue +++ b/vue/src/components/OpenDataImportComponent.vue @@ -4,7 +4,7 @@
{{ $t('Data_Import_Info') }} - {{$t('Learn_More')}} + {{ $t('Learn_More') }}