diff --git a/.env.template b/.env.template index c1ba71f7b5..1e57b4a12d 100644 --- a/.env.template +++ b/.env.template @@ -28,7 +28,7 @@ SECRET_KEY_FILE= # --------------------------------------------------------------- # your default timezone See https://timezonedb.com/time-zones for a list of timezones -TIMEZONE=Europe/Berlin +TZ=Europe/Berlin # add only a database password if you want to run with the default postgres, otherwise change settings accordingly DB_ENGINE=django.db.backends.postgresql @@ -183,3 +183,5 @@ REMOTE_USER_AUTH=0 # Recipe exports are cached for a certain time by default, adjust time if needed # EXPORT_FILE_CACHE_DURATION=600 +# if you want to do many requests to the FDC API you need to get a (free) API key. Demo key is limited to 30 requests / hour or 50 requests / day +#FDC_API_KEY=DEMO_KEY \ No newline at end of file diff --git a/cookbook/admin.py b/cookbook/admin.py index 9f7df90a63..d2b5c31aa3 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -349,7 +349,9 @@ class ShareLinkAdmin(admin.ModelAdmin): class PropertyTypeAdmin(admin.ModelAdmin): - list_display = ('id', 'name') + search_fields = ('space',) + + list_display = ('id', 'space', 'name', 'fdc_id') admin.site.register(PropertyType, PropertyTypeAdmin) diff --git a/cookbook/helper/fdc_helper.py b/cookbook/helper/fdc_helper.py new file mode 100644 index 0000000000..b09e7bfa16 --- /dev/null +++ b/cookbook/helper/fdc_helper.py @@ -0,0 +1,19 @@ +import json + + +def get_all_nutrient_types(): + f = open('') # <--- download the foundation food or any other dataset and retrieve all nutrition ID's from it https://fdc.nal.usda.gov/download-datasets.html + json_data = json.loads(f.read()) + + nutrients = {} + for food in json_data['FoundationFoods']: + for entry in food['foodNutrients']: + nutrients[entry['nutrient']['id']] = {'name': entry['nutrient']['name'], 'unit': entry['nutrient']['unitName']} + + nutrient_ids = list(nutrients.keys()) + nutrient_ids.sort() + for nid in nutrient_ids: + print('{', f'value: {nid}, text: "{nutrients[nid]["name"]} [{nutrients[nid]["unit"]}] ({nid})"', '},') + + +get_all_nutrient_types() diff --git a/cookbook/helper/recipe_url_import.py b/cookbook/helper/recipe_url_import.py index b863e8bdbf..fbbc23c9cd 100644 --- a/cookbook/helper/recipe_url_import.py +++ b/cookbook/helper/recipe_url_import.py @@ -163,10 +163,9 @@ def get_from_scraper(scrape, request): if len(recipe_json['steps']) == 0: recipe_json['steps'].append({'instruction': '', 'ingredients': [], }) + recipe_json['description'] = recipe_json['description'][:512] if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards recipe_json['steps'][0]['instruction'] = f"*{recipe_json['description']}* \n\n" + recipe_json['steps'][0]['instruction'] - else: - recipe_json['description'] = recipe_json['description'][:512] try: for x in scrape.ingredients(): @@ -259,13 +258,14 @@ def get_from_youtube_scraper(url, request): ] } - # TODO add automation here try: automation_engine = AutomationEngine(request, source=url) - video = YouTube(url=url) + video = YouTube(url) + video.streams.first() # this is required to execute some kind of generator/web request that fetches the description default_recipe_json['name'] = automation_engine.apply_regex_replace_automation(video.title, Automation.NAME_REPLACE) default_recipe_json['image'] = video.thumbnail_url - default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE) + if video.description: + default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE) except Exception: pass diff --git a/cookbook/migrations/0205_alter_food_fdc_id_alter_propertytype_fdc_id.py b/cookbook/migrations/0205_alter_food_fdc_id_alter_propertytype_fdc_id.py new file mode 100644 index 0000000000..9f57e4434a --- /dev/null +++ b/cookbook/migrations/0205_alter_food_fdc_id_alter_propertytype_fdc_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.7 on 2023-11-29 19:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cookbook', '0204_propertytype_fdc_id'), + ] + + operations = [ + migrations.AlterField( + model_name='food', + name='fdc_id', + field=models.IntegerField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='propertytype', + name='fdc_id', + field=models.IntegerField(blank=True, default=None, null=True), + ), + ] diff --git a/cookbook/models.py b/cookbook/models.py index 6ea39d2e4d..c40bb41f2e 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -591,7 +591,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin): preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit') preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit') - fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None) + fdc_id = models.IntegerField(null=True, default=None, blank=True) open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) space = models.ForeignKey(Space, on_delete=models.CASCADE) @@ -767,7 +767,7 @@ class PropertyType(models.Model, PermissionModelMixin): (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True) open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) - fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None) + fdc_id = models.IntegerField(null=True, default=None, blank=True) # TODO show if empty property? # TODO formatting property? @@ -809,7 +809,7 @@ class FoodProperty(models.Model): class Meta: constraints = [ - models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food') + models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'), ] diff --git a/cookbook/serializer.py b/cookbook/serializer.py index e012d330a0..ff4e2dfc8c 100644 --- a/cookbook/serializer.py +++ b/cookbook/serializer.py @@ -19,6 +19,7 @@ from PIL import Image from rest_framework import serializers from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.fields import IntegerField from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage from cookbook.helper.HelperFunctions import str2bool @@ -524,6 +525,7 @@ class Meta: class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, UniqueFieldsMixin): id = serializers.IntegerField(required=False) + order = IntegerField(default=0, required=False) def create(self, validated_data): validated_data['name'] = validated_data['name'].strip() @@ -985,6 +987,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer): shared = UserSerializer(many=True, required=False, allow_null=True) shopping = serializers.SerializerMethodField('in_shopping') + to_date = serializers.DateField(required=False) + def get_note_markdown(self, obj): return markdown(obj.note) @@ -993,6 +997,10 @@ def in_shopping(self, obj): def create(self, validated_data): validated_data['created_by'] = self.context['request'].user + + if 'to_date' not in validated_data or validated_data['to_date'] is None: + validated_data['to_date'] = validated_data['from_date'] + mealplan = super().create(validated_data) if self.context['request'].data.get('addshopping', False) and self.context['request'].data.get('recipe', None): SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space']) diff --git a/cookbook/templates/property_editor.html b/cookbook/templates/property_editor.html new file mode 100644 index 0000000000..f24a133e80 --- /dev/null +++ b/cookbook/templates/property_editor.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% load i18n %} +{% load l10n %} + +{% block title %}{% trans 'Property Editor' %}{% endblock %} + +{% block content_fluid %} + +
+ +
+ + +{% endblock %} + + +{% block script %} + {% if debug %} + + {% else %} + + {% endif %} + + + + {% render_bundle 'property_editor_view' %} +{% endblock %} \ No newline at end of file diff --git a/cookbook/urls.py b/cookbook/urls.py index e8be51607d..db8680e5ef 100644 --- a/cookbook/urls.py +++ b/cookbook/urls.py @@ -43,7 +43,7 @@ def extend(self, r): router.register(r'recipe-book', api.RecipeBookViewSet) router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet) router.register(r'unit-conversion', api.UnitConversionViewSet) -router.register(r'food-property-type', api.PropertyTypeViewSet) +router.register(r'food-property-type', api.PropertyTypeViewSet) # TODO rename + regenerate router.register(r'food-property', api.PropertyViewSet) router.register(r'shopping-list', api.ShoppingListViewSet) router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet) @@ -91,6 +91,7 @@ def extend(self, r): path('history/', views.history, name='view_history'), path('supermarket/', views.supermarket, name='view_supermarket'), path('ingredient-editor/', views.ingredient_editor, name='view_ingredient_editor'), + path('property-editor/', views.property_editor, name='view_property_editor'), path('abuse/', views.report_share_abuse, name='view_report_share_abuse'), path('api/import/', api.import_files, name='view_import'), diff --git a/cookbook/views/api.py b/cookbook/views/api.py index 159b6f8f5e..d97d5f739c 100644 --- a/cookbook/views/api.py +++ b/cookbook/views/api.py @@ -26,7 +26,7 @@ from django.db.models.fields.related import ForeignObjectRel from django.db.models.functions import Coalesce, Lower from django.db.models.signals import post_save -from django.http import FileResponse, HttpResponse, JsonResponse +from django.http import FileResponse, HttpResponse, JsonResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils import timezone @@ -75,7 +75,7 @@ ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, - UserFile, UserPreference, UserSpace, ViewLog) + UserFile, UserPreference, UserSpace, ViewLog, FoodProperty) from cookbook.provider.dropbox import Dropbox from cookbook.provider.local import Local from cookbook.provider.nextcloud import Nextcloud @@ -104,6 +104,7 @@ UserSerializer, UserSpaceSerializer, ViewLogSerializer) from cookbook.views.import_export import get_integration from recipes import settings +from recipes.settings import FDC_API_KEY class StandardFilterMixin(ViewSetMixin): @@ -595,6 +596,54 @@ def shopping(self, request, pk): created_by=request.user) return Response(content, status=status.HTTP_204_NO_CONTENT) + @decorators.action(detail=True, methods=['POST'], ) + def fdc(self, request, pk): + """ + updates the food with all possible data from the FDC Api + if properties with a fdc_id already exist they will be overridden, if existing properties don't have a fdc_id they won't be changed + """ + food = self.get_object() + + response = requests.get(f'https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key={FDC_API_KEY}') + if response.status_code == 429: + return JsonResponse({'msg', 'API Key Rate Limit reached/exceeded, see https://api.data.gov/docs/rate-limits/ for more information. Configure your key in Tandoor using environment FDC_API_KEY variable.'}, status=429, + json_dumps_params={'indent': 4}) + + try: + data = json.loads(response.content) + + food_property_list = [] + + # delete all properties where the property type has a fdc_id as these should be overridden + for fp in food.properties.all(): + if fp.property_type.fdc_id: + fp.delete() + + for pt in PropertyType.objects.filter(space=request.space, fdc_id__gte=0).all(): + if pt.fdc_id: + for fn in data['foodNutrients']: + if fn['nutrient']['id'] == pt.fdc_id: + food_property_list.append(Property( + property_type_id=pt.id, + property_amount=round(fn['amount'], 2), + import_food_id=food.id, + space=self.request.space, + )) + + Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',)) + + property_food_relation_list = [] + for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ): + property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1])) + + FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',)) + Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None) + + return self.retrieve(request, pk) + except Exception as e: + traceback.print_exc() + return JsonResponse({'msg': f'there was an error parsing the FDC data, please check the server logs'}, status=500, json_dumps_params={'indent': 4}) + def destroy(self, *args, **kwargs): try: return (super().destroy(self, *args, **kwargs)) @@ -1454,7 +1503,7 @@ def import_files(request): """ limit, msg = above_space_limit(request.space) if limit: - return Response({'error': msg}, status=status.HTTP_400_BAD_REQUEST) + return Response({'error': True, 'msg': _('File is above space limit')}, status=status.HTTP_400_BAD_REQUEST) form = ImportForm(request.POST, request.FILES) if form.is_valid() and request.FILES != {}: diff --git a/cookbook/views/views.py b/cookbook/views/views.py index 58a610a240..92c55789e7 100644 --- a/cookbook/views/views.py +++ b/cookbook/views/views.py @@ -204,6 +204,11 @@ def ingredient_editor(request): return render(request, 'ingredient_editor.html', template_vars) +@group_required('user') +def property_editor(request, pk): + return render(request, 'property_editor.html', {'recipe_id': pk}) + + @group_required('guest') def shopping_settings(request): if request.space.demo: @@ -220,10 +225,10 @@ def shopping_settings(request): 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']) + 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 diff --git a/docs/faq.md b/docs/faq.md index be40a286ca..6c8e5aa5e6 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,5 +1,5 @@ There are several questions and issues that come up from time to time, here are some answers: -please note that the existence of some questions is due the application not being perfect in some parts. +please note that the existence of some questions is due the application not being perfect in some parts. Many of those shortcomings are planned to be fixed in future release but simply could not be addressed yet due to time limits. ## Is there a Tandoor app? @@ -15,7 +15,7 @@ Open Tandoor, click the `add Tandoor to the home screen` message that pops up at ### Desktop browsers -#### Google Chrome +#### Google Chrome Open Tandoor, open the menu behind the three vertical dots at the top right, select `Install Tandoor Recipes...` #### Microsoft Edge @@ -32,6 +32,17 @@ If you just set up your Tandoor instance and you're having issues like; then make sure you have set [all required headers](install/docker.md#required-headers) in your reverse proxy correctly. If that doesn't fix it, you can also refer to the appropriate sub section in the [reverse proxy documentation](install/docker.md#reverse-proxy) and verify your general webserver configuration. +### Required Headers +Navigate to `/system` and review the headers listed in the DEBUG section. At a minimum, if you are using a reverse proxy the headers must match the below conditions. + +| Header | Requirement | +| :--- | :---- | +| HTTP_HOST:mydomain.tld | The host domain must match the url that you are using to open Tandoor. | +| HTTP_X_FORWARDED_HOST:mydomain.tld | The host domain must match the url that you are using to open Tandoor. | +| HTTP_X_FORWARDED_PROTO:http(s) | The protocol must match the url you are using to open Tandoor. There must be exactly one protocol listed. | +| HTTP_X_SCRIPT_NAME:/subfolder | If you are hosting Tandoor at a subfolder instead of a subdomain this header must exist. | + + ## Why am I getting CSRF Errors? If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers. @@ -41,19 +52,22 @@ If you are using a plain ngix you might need `proxy_set_header Host $http_host;` Further discussions can be found in this [Issue #518](https://github.com/vabene1111/recipes/issues/518) ## Why are images not loading? -If images are not loading this might be related to the same issue as the CSRF errors (see above). +If images are not loading this might be related to the same issue as the CSRF errors (see above). A discussion about that can be found at [Issue #452](https://github.com/vabene1111/recipes/issues/452) -The other common issue is that the recommended nginx container is removed from the deployment stack. -If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or +The other common issue is that the recommended nginx container is removed from the deployment stack. +If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or `GUNICORN_MEDIA` needs to be enabled to allow media serving by the application container itself. +## Why am I getting an error stating database files are incompatible with server? +Your version of Postgres has been upgraded. See [Updating PostgreSQL](https://docs.tandoor.dev/system/updating/#postgresql) + ## Why does the Text/Markdown preview look different than the final recipe? Tandoor has always rendered the recipe instructions markdown on the server. This also allows tandoor to implement things like ingredient templating and scaling in text. -To make editing easier a markdown editor was added to the frontend with integrated preview as a temporary solution. Since the markdown editor uses a different -specification than the server the preview is different to the final result. It is planned to improve this in the future. +To make editing easier a markdown editor was added to the frontend with integrated preview as a temporary solution. Since the markdown editor uses a different +specification than the server the preview is different to the final result. It is planned to improve this in the future. The markdown renderer follows this markdown specification https://daringfireball.net/projects/markdown/ @@ -66,18 +80,18 @@ To create a new user click on your name (top right corner) and select 'space set It is not possible to create users through the admin because users must be assigned a default group and space. -To change a user's space you need to go to the admin and select User Infos. +To change a user's space you need to go to the admin and select User Infos. -If you use an external auth provider or proxy authentication make sure to specify a default group and space in the +If you use an external auth provider or proxy authentication make sure to specify a default group and space in the environment configuration. ## What are spaces? -Spaces are is a type of feature used to separate one installation of Tandoor into several parts. +Spaces are is a type of feature used to separate one installation of Tandoor into several parts. In technical terms it is a multi-tenant system. -You can compare a space to something like google drive or dropbox. +You can compare a space to something like google drive or dropbox. There is only one installation of the Dropbox system, but it handles multiple users without them noticing each other. -For Tandoor that means all people that work together on one recipe collection can be in one space. +For Tandoor that means all people that work together on one recipe collection can be in one space. If you want to host the collection of your friends, family, or neighbor you can create a separate space for them (through the admin interface). Sharing between spaces is currently not possible but is planned for future releases. @@ -90,7 +104,7 @@ To reset a lost password if access to the container is lost you need to: 3. run `python manage.py changepassword ` and follow the steps shown. ## How can I add an admin user? -To create a superuser you need to +To create a superuser you need to 1. execute into the container using `docker-compose exec web_recipes sh` 2. activate the virtual environment `source venv/bin/activate` @@ -98,10 +112,10 @@ To create a superuser you need to ## Why cant I get support for my manual setup? -Even tough I would love to help everyone get tandoor up and running I have only so much time -that I can spend on this project besides work, family and other life things. -Due to the countless problems that can occur when manually installing I simply do not have -the time to help solving each one. +Even tough I would love to help everyone get tandoor up and running I have only so much time +that I can spend on this project besides work, family and other life things. +Due to the countless problems that can occur when manually installing I simply do not have +the time to help solving each one. You can install Tandoor manually but please do not expect me or anyone to help you with that. As a general advice: If you do it manually do NOT change anything at first and slowly work yourself @@ -120,4 +134,4 @@ Postgres requires manual intervention when updating from one major version to an If anything fails, go back to the old postgres version and data directory and try again. -There are many articles and tools online that might provide a good starting point to help you upgrade [1](https://thomasbandt.com/postgres-docker-major-version-upgrade), [2](https://github.com/tianon/docker-postgres-upgrade), [3](https://github.com/vabene1111/DockerPostgresBackups). \ No newline at end of file +There are many articles and tools online that might provide a good starting point to help you upgrade [1](https://thomasbandt.com/postgres-docker-major-version-upgrade), [2](https://github.com/tianon/docker-postgres-upgrade), [3](https://github.com/vabene1111/DockerPostgresBackups). diff --git a/docs/install/docker.md b/docs/install/docker.md index 244334fbcc..0a7eaba8ea 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -6,6 +6,12 @@ It is possible to install this application using many different Docker configura Please read the instructions on each example carefully and decide if this is the way for you. +## **DockSTARTer** + +The main goal of [DockSTARTer](https://dockstarter.com/) is to make it quick and easy to get up and running with Docker. +You may choose to rely on DockSTARTer for various changes to your Docker system or use DockSTARTer as a stepping stone and learn to do more advanced configurations. +Follow the guide for installing DockSTARTer and then run `ds` then select 'Configuration' and 'Select Apps' to get Tandoor up and running quickly and easily. + ## **Docker** The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `8080`. @@ -110,7 +116,7 @@ in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs Please refer to the appropriate documentation on how to setup the reverse proxy and networks. !!! warning "Adjust client_max_body_size" - By using jwilder's Nginx-proxy, uploads will be restricted to 1 MB file size. This can be resolved by adjusting the ```client_max_body_size``` variable in the jwilder nginx configuration. + By using jwilder's Nginx-proxy, uploads will be restricted to 1 MB file size. This can be resolved by adjusting the ```client_max_body_size``` variable in the jwilder nginx configuration. Remember to add the appropriate environment variables to the `.env` file: @@ -360,11 +366,11 @@ follow these instructions: ### Sub Path nginx config If hosting under a sub-path you might want to change the default nginx config (which gets mounted through the named volume from the application container into the nginx container) -with the following config. +with the following config. ```nginx location /my_app { # change to subfolder name - include /config/nginx/proxy.conf; + include /config/nginx/proxy.conf; proxy_pass https://mywebapp.com/; # change to your host name:port proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/docs/system/backup.md b/docs/system/backup.md index cf5a2854f7..308e3561bd 100644 --- a/docs/system/backup.md +++ b/docs/system/backup.md @@ -8,7 +8,7 @@ downloaded and restored through the web interface. When developing a new backup strategy, make sure to also test the restore process! ## Database -Please use any standard way of backing up your database. For most systems this can be achieved by using a dump +Please use any standard way of backing up your database. For most systems this can be achieved by using a dump command that will create an SQL file with all the required data. Please refer to your Database System documentation. @@ -18,7 +18,7 @@ It is **neither** well tested nor documented so use at your own risk. I would recommend using it only as a starting place for your own backup strategy. ## Mediafiles -The only Data this application stores apart from the database are the media files (e.g. images) used in your +The only Data this application stores apart from the database are the media files (e.g. images) used in your recipes. They can be found in the mediafiles mounted directory (depending on your installation). @@ -56,3 +56,23 @@ You can now export recipes from Tandoor using the export function. This method r Import: Go to Import > from app > tandoor and select the zip file you want to import from. +## Backing up using the pgbackup container +You can add [pgbackup](https://hub.docker.com/r/prodrigestivill/postgres-backup-local) to manage the scheduling and automatic backup of your postgres database. +Modify the below to match your environment and add it to your `docker-compose.yml` + +``` yaml + pgbackup: + container_name: pgbackup + environment: + BACKUP_KEEP_DAYS: "8" + BACKUP_KEEP_MONTHS: "6" + BACKUP_KEEP_WEEKS: "4" + POSTGRES_EXTRA_OPTS: -Z6 --schema=public --blobs + SCHEDULE: '@daily' + # Note: the tag must match the version of postgres you are using + image: prodrigestivill/postgres-backup-local:15 + restart: unless-stopped + volumes: + - backups/postgres:/backups +``` +You can manually initiate a backup by running `docker exec -it pgbackup ./backup.sh` diff --git a/docs/system/updating.md b/docs/system/updating.md index 820848649b..b6d885888e 100644 --- a/docs/system/updating.md +++ b/docs/system/updating.md @@ -1,6 +1,6 @@ The Updating process depends on your chosen method of [installation](/install/docker) -While intermediate updates can be skipped when updating please make sure to +While intermediate updates can be skipped when updating please make sure to **read the release notes** in case some special action is required to update. ## Docker @@ -16,7 +16,79 @@ For all setups using Docker the updating process look something like this For all setups using a manual installation updates usually involve downloading the latest source code from GitHub. After that make sure to run: -1. `manage.py collectstatic` -2. `manage.py migrate` +1. `pip install -r requirements.txt` +2. `manage.py collectstatic` +3. `manage.py migrate` +4. `cd ./vue` +5. `yarn install` +6. `yarn build` -To apply all new migrations and collect new static files. +To install latest libraries, apply all new migrations and collect new static files. + +## PostgreSQL + +Postgres does not automatically upgrade database files when you change versions and requires manual intervention. +One option is to manually [backup/restore](https://docs.tandoor.dev/system/updating/#postgresql) the database. + +A full list of options to upgrade a database provide in the [official PostgreSQL documentation](https://www.postgresql.org/docs/current/upgrading.html). + +1. Collect information about your environment. + +``` bash +grep -E 'POSTGRES|DATABASE' ~/.docker/compose/.env +docker ps -a --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' | awk 'NR == 1 || /postgres/ || /recipes/' +``` + +2. Export the tandoor database + +``` bash +docker exec -t {{database_container}} pg_dumpall -U {{djangouser}} > ~/tandoor.sql +``` + +3. Stop the postgres container +``` bash +docker stop {{database_container}} {{tandoor_container}} +``` + +4. Rename the tandoor volume + +``` bash +sudo mv -R ~/.docker/compose/postgres ~/.docker/compose/postgres.old +``` + +5. Update image tag on postgres container. + + ``` yaml + db_recipes: + restart: always + image: postgres:16-alpine + volumes: + - ./postgresql:/var/lib/postgresql/data + env_file: + - ./.env + ``` + +6. Pull and rebuild container. + + ``` bash + docker-compose pull && docker-compose up -d + ``` + +7. Import the database export + + ``` bash + cat ~/tandoor.sql | sudo docker exec -i {{database_container}} psql postgres -U {{djangouser}} + ``` + 8. Install postgres extensions + ``` bash + docker exec -it {{database_container}} psql + ``` + then + ``` psql + CREATE EXTENSION IF NOT EXISTS unaccent; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + ``` + +If anything fails, go back to the old postgres version and data directory and try again. + +There are many articles and tools online that might provide a good starting point to help you upgrade [1](https://thomasbandt.com/postgres-docker-major-version-upgrade), [2](https://github.com/tianon/docker-postgres-upgrade), [3](https://github.com/vabene1111/DockerPostgresBackups). diff --git a/recipes/settings.py b/recipes/settings.py index df2c2b1de8..86739628f3 100644 --- a/recipes/settings.py +++ b/recipes/settings.py @@ -89,7 +89,7 @@ HCAPTCHA_SITEKEY = os.getenv('HCAPTCHA_SITEKEY', '') HCAPTCHA_SECRET = os.getenv('HCAPTCHA_SECRET', '') -FDA_API_KEY = os.getenv('FDA_API_KEY', 'DEMO_KEY') +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)) @@ -350,7 +350,7 @@ # Load settings from env files if os.getenv('DATABASE_URL'): match = re.match( - r'(?P\w+):\/\/(?:(?P[\w\d_-]+)(?::(?P[^@]+))?@)?(?P[^:/]+)(?:(?P\d+))?(?:/(?P[\w\d/._-]+))?', + r'(?P\w+):\/\/(?:(?P[\w\d_-]+)(?::(?P[^@]+))?@)?(?P[^:/]+)(?::(?P\d+))?(?:/(?P[\w\d/._-]+))?', os.getenv('DATABASE_URL') ) settings = match.groupdict() @@ -450,7 +450,11 @@ LANGUAGE_CODE = 'en' -TIME_ZONE = os.getenv('TIMEZONE') if os.getenv('TIMEZONE') else 'Europe/Berlin' +if os.getenv('TIMEZONE') is not None: + print('DEPRECATION WARNING: Environment var "TIMEZONE" is deprecated. Please use "TZ" instead.') + TIME_ZONE = os.getenv('TIMEZONE') if os.getenv('TIMEZONE') else 'Europe/Berlin' +else: + TIME_ZONE = os.getenv('TZ') if os.getenv('TZ') else 'Europe/Berlin' USE_I18N = True diff --git a/requirements.txt b/requirements.txt index cec0394d1f..0ebb387529 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==4.2.7 -cryptography===41.0.4 +cryptography===41.0.6 django-annoying==0.10.6 django-autocomplete-light==3.9.4 django-cleanup==8.0.0 @@ -32,7 +32,7 @@ git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491 django-allauth==0.54.0 recipe-scrapers==14.52.0 django-scopes==2.0.0 -pytest==7.3.1 +pytest==7.4.3 pytest-django==4.6.0 django-treebeard==4.7 django-cors-headers==4.2.0 diff --git a/vue/src/apps/ImportView/ImportView.vue b/vue/src/apps/ImportView/ImportView.vue index 12a206f37a..b1743944ce 100644 --- a/vue/src/apps/ImportView/ImportView.vue +++ b/vue/src/apps/ImportView/ImportView.vue @@ -669,8 +669,7 @@ export default { if (url !== '') { this.failed_imports.push(url) } - StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err) - throw "Load Recipe Error" + StandardToasts.makeStandardToast(this, StandardToasts.FAIL_IMPORT, err) }) }, /** @@ -713,8 +712,7 @@ export default { axios.post(resolveDjangoUrl('view_import'), formData, {headers: {'Content-Type': 'multipart/form-data'}}).then((response) => { window.location.href = resolveDjangoUrl('view_import_response', response.data['import_id']) }).catch((err) => { - console.log(err) - StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE) + StandardToasts.makeStandardToast(this, StandardToasts.FAIL_IMPORT, err) }) }, /** diff --git a/vue/src/apps/MealPlanView/MealPlanView.vue b/vue/src/apps/MealPlanView/MealPlanView.vue index 8a5f426400..2f60ae01fc 100644 --- a/vue/src/apps/MealPlanView/MealPlanView.vue +++ b/vue/src/apps/MealPlanView/MealPlanView.vue @@ -744,8 +744,12 @@ having to override as much. .theme-default .cv-item.continued::before, .theme-default .cv-item.toBeContinued::after { + /* + removed because it breaks a line and would increase item size https://github.com/TandoorRecipes/recipes/issues/2678 + content: " \21e2 "; color: #999; + */ } .theme-default .cv-item.toBeContinued { diff --git a/vue/src/apps/ModelListView/ModelListView.vue b/vue/src/apps/ModelListView/ModelListView.vue index dd7e6f122b..cc896e77ba 100644 --- a/vue/src/apps/ModelListView/ModelListView.vue +++ b/vue/src/apps/ModelListView/ModelListView.vue @@ -186,10 +186,10 @@ export default { case "ingredient-editor": { let url = resolveDjangoUrl("view_ingredient_editor") if (this.this_model === this.Models.FOOD) { - window.location.href = url + '?food_id=' + e.source.id + window.open(url + '?food_id=' + e.source.id, "_blank"); } if (this.this_model === this.Models.UNIT) { - window.location.href = url + '?unit_id=' + e.source.id + window.open(url + '?unit_id=' + e.source.id, "_blank"); } break } diff --git a/vue/src/apps/PropertyEditorView/PropertyEditorView.vue b/vue/src/apps/PropertyEditorView/PropertyEditorView.vue new file mode 100644 index 0000000000..076628c3bc --- /dev/null +++ b/vue/src/apps/PropertyEditorView/PropertyEditorView.vue @@ -0,0 +1,227 @@ + + + + + + diff --git a/vue/src/apps/PropertyEditorView/main.js b/vue/src/apps/PropertyEditorView/main.js new file mode 100644 index 0000000000..40e6a30923 --- /dev/null +++ b/vue/src/apps/PropertyEditorView/main.js @@ -0,0 +1,22 @@ +import Vue from 'vue' +import App from './PropertyEditorView.vue' +import i18n from '@/i18n' +import {createPinia, PiniaVuePlugin} from "pinia"; + +Vue.config.productionTip = false + +// TODO move this and other default stuff to centralized JS file (verify nothing breaks) +let publicPath = localStorage.STATIC_URL + 'vue/' +if (process.env.NODE_ENV === 'development') { + publicPath = 'http://localhost:8080/' +} +export default __webpack_public_path__ = publicPath // eslint-disable-line + +Vue.use(PiniaVuePlugin) +const pinia = createPinia() + +new Vue({ + pinia, + i18n, + render: h => h(App), +}).$mount('#app') diff --git a/vue/src/apps/TestView/TestView.vue b/vue/src/apps/TestView/TestView.vue index 52e7311a7c..9ee2c9287e 100644 --- a/vue/src/apps/TestView/TestView.vue +++ b/vue/src/apps/TestView/TestView.vue @@ -2,34 +2,7 @@
-

{{ recipe.name}}

- - - - - - - - - - - - - -
{{ $t('Name') }}{{ pt.name }} - -
- {{ f.food.name }} - {{ $t('Property') }} / - - - - {{ p.property_type.unit }}
@@ -45,6 +18,7 @@ import axios from "axios"; import BetaWarning from "@/components/BetaWarning.vue"; import {ApiApiFactory} from "@/utils/openapi/api"; import GenericMultiselect from "@/components/GenericMultiselect.vue"; +import GenericModalForm from "@/components/Modals/GenericModalForm.vue"; Vue.use(BootstrapVue) @@ -53,66 +27,16 @@ Vue.use(BootstrapVue) export default { name: "TestView", mixins: [ApiMixin], - components: {GenericMultiselect}, - computed: { - foods: function () { - let foods = [] - if (this.recipe !== null && this.property_types !== []) { - this.recipe.steps.forEach(s => { - s.ingredients.forEach(i => { - let food = {food: i.food, properties: {}} - - this.property_types.forEach(pt => { - food.properties[pt.id] = {changed: false, property_amount: 0, property_type: pt} - }) - i.food.properties.forEach(fp => { - food.properties[fp.property_type.id] = {changed: false, property_amount: fp.property_amount, property_type: fp.property_type} - }) - foods.push(food) - }) - }) - } - return foods - } - }, + components: {}, + computed: {}, data() { - return { - recipe: null, - property_types: [] - } + return {} }, mounted() { this.$i18n.locale = window.CUSTOM_LOCALE - - let apiClient = new ApiApiFactory() - - apiClient.retrieveRecipe("112").then(result => { - this.recipe = result.data - }) - - apiClient.listPropertyTypes().then(result => { - this.property_types = result.data - }) }, methods: { - updateFood: function (food) { - let apiClient = new ApiApiFactory() - apiClient.partialUpdateFood(food.id, food).then(result => { - //TODO handle properly - StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE) - }).catch((err) => { - StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err) - }) - }, - updatePropertyType: function (pt) { - let apiClient = new ApiApiFactory() - apiClient.partialUpdatePropertyType(pt.id, pt).then(result => { - //TODO handle properly - StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE) - }).catch((err) => { - StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err) - }) - } + }, } diff --git a/vue/src/components/FoodEditor.vue b/vue/src/components/FoodEditor.vue index 232ae4bc52..9b707c400e 100644 --- a/vue/src/components/FoodEditor.vue +++ b/vue/src/components/FoodEditor.vue @@ -33,11 +33,11 @@
{{ $t('Properties') }}
- + - + @@ -74,6 +75,7 @@ export default { allow_create: { type: Boolean, default: false }, create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" }, clear: { type: Number }, + disabled: {type: Boolean, default: false, }, }, watch: { initial_selection: function (newVal, oldVal) { diff --git a/vue/src/components/Modals/ChoiceInput.vue b/vue/src/components/Modals/ChoiceInput.vue index 73f9fa85af..5c1a24cbde 100644 --- a/vue/src/components/Modals/ChoiceInput.vue +++ b/vue/src/components/Modals/ChoiceInput.vue @@ -25,6 +25,12 @@ export default { }, mounted() { this.new_value = this.value + + if (this.new_value === "") { // if the selection is empty but the options are of type number, set to 0 instead of "" + if (typeof this.options[0]['value'] === 'number') { + this.new_value = 0 + } + } }, watch: { new_value: function () { diff --git a/vue/src/components/Modals/NumberInput.vue b/vue/src/components/Modals/NumberInput.vue index a86f0b0e75..1dad5e625a 100644 --- a/vue/src/components/Modals/NumberInput.vue +++ b/vue/src/components/Modals/NumberInput.vue @@ -14,7 +14,7 @@ export default { props: { field: { type: String, default: "You Forgot To Set Field Name" }, label: { type: String, default: "Text Field" }, - value: { type: String, default: "" }, + value: { type: Number, default: 0 }, placeholder: { type: Number, default: 0 }, help: { type: String, default: undefined }, subtitle: { type: String, default: undefined }, diff --git a/vue/src/components/PropertyViewComponent.vue b/vue/src/components/PropertyViewComponent.vue index 7aa8c218f0..2c566e522c 100644 --- a/vue/src/components/PropertyViewComponent.vue +++ b/vue/src/components/PropertyViewComponent.vue @@ -24,7 +24,7 @@ - + @@ -41,14 +41,18 @@ - -
{{ $t('per_serving') }} {{ $t('total') }} - - + + + +
+ +
+ {{ $t('Property_Editor') }} +
@@ -79,7 +83,7 @@