Merge remote-tracking branch 'origin/Auto-Planner' into Auto-Planner

This commit is contained in:
AquaticLava 2024-01-02 15:38:47 -07:00
commit 61b67cd37a
272 changed files with 40730 additions and 24191 deletions

View File

@ -3,7 +3,6 @@ npm-debug.log
Dockerfile* Dockerfile*
docker-compose* docker-compose*
.dockerignore .dockerignore
.git
.gitignore .gitignore
README.md README.md
LICENSE LICENSE

View File

@ -13,13 +13,22 @@ DEBUG_TOOLBAR=0
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,... # hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
ALLOWED_HOSTS=* ALLOWED_HOSTS=*
# Cross Site Request Forgery protection
# (https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS)
# CSRF_TRUSTED_ORIGINS = []
# Cross Origin Resource Sharing
# (https://github.com/adamchainz/django-cors-header)
# CORS_ALLOW_ALL_ORIGINS = True
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one # random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
# ---------------------------- REQUIRED ------------------------- # ---------------------------- AT LEAST ONE REQUIRED -------------------------
SECRET_KEY= SECRET_KEY=
SECRET_KEY_FILE=
# --------------------------------------------------------------- # ---------------------------------------------------------------
# your default timezone See https://timezonedb.com/time-zones for a list of timezones # 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 # add only a database password if you want to run with the default postgres, otherwise change settings accordingly
DB_ENGINE=django.db.backends.postgresql DB_ENGINE=django.db.backends.postgresql
@ -27,8 +36,9 @@ DB_ENGINE=django.db.backends.postgresql
POSTGRES_HOST=db_recipes POSTGRES_HOST=db_recipes
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_USER=djangouser POSTGRES_USER=djangouser
# ---------------------------- REQUIRED ------------------------- # ---------------------------- AT LEAST ONE REQUIRED -------------------------
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
POSTGRES_PASSWORD_FILE=
# --------------------------------------------------------------- # ---------------------------------------------------------------
POSTGRES_DB=djangodb POSTGRES_DB=djangodb
@ -100,10 +110,12 @@ GUNICORN_MEDIA=0
# prefix used for account related emails (default "[Tandoor Recipes] ") # prefix used for account related emails (default "[Tandoor Recipes] ")
# ACCOUNT_EMAIL_SUBJECT_PREFIX= # ACCOUNT_EMAIL_SUBJECT_PREFIX=
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing # allow authentication via the REMOTE-USER header (can be used for e.g. authelia).
# see docs for more information https://docs.tandoor.dev/features/authentication/ # ATTENTION: Leave off if you don't know what you are doing! Enabling this without proper configuration will enable anybody
# to login with any username!
# See docs for additional information: https://docs.tandoor.dev/features/authentication/#reverse-proxy-authentication
# when unset: 0 (false) # when unset: 0 (false)
REVERSE_PROXY_AUTH=0 REMOTE_USER_AUTH=0
# Default settings for spaces, apply per space and can be changed in the admin view # Default settings for spaces, apply per space and can be changed in the admin view
# SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes # SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes
@ -111,7 +123,8 @@ REVERSE_PROXY_AUTH=0
# SPACE_DEFAULT_MAX_FILES=0 # Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload. # SPACE_DEFAULT_MAX_FILES=0 # Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.
# SPACE_DEFAULT_ALLOW_SHARING=1 # Allow users to share recipes with public links # SPACE_DEFAULT_ALLOW_SHARING=1 # Allow users to share recipes with public links
# allow people to create accounts on your application instance (without an invite link) # allow people to create local accounts on your application instance (without an invite link)
# social accounts will always be able to sign up
# when unset: 0 (false) # when unset: 0 (false)
# ENABLE_SIGNUP=0 # ENABLE_SIGNUP=0
@ -170,3 +183,9 @@ REVERSE_PROXY_AUTH=0
# Recipe exports are cached for a certain time by default, adjust time if needed # Recipe exports are cached for a certain time by default, adjust time if needed
# EXPORT_FILE_CACHE_DURATION=600 # 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
# API throttle limits
# you may use X per second, minute, hour or day
# DRF_THROTTLE_RECIPE_URL_IMPORT=60/hour

View File

@ -34,16 +34,6 @@ jobs:
echo VERSION=develop >> $GITHUB_OUTPUT echo VERSION=develop >> $GITHUB_OUTPUT
fi fi
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.2
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}-open-data'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# clone open data plugin # clone open data plugin
- name: clone open data plugin repo - name: clone open data plugin repo
uses: actions/checkout@master uses: actions/checkout@master
@ -55,7 +45,7 @@ jobs:
# Build Vue frontend # Build Vue frontend
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: '14' node-version: '18'
cache: yarn cache: yarn
cache-dependency-path: vue/yarn.lock cache-dependency-path: vue/yarn.lock
- name: Install dependencies - name: Install dependencies
@ -74,17 +64,17 @@ jobs:
run: yarn build run: yarn build
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
if: github.secret_source == 'Actions' if: github.secret_source == 'Actions'
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
if: github.secret_source == 'Actions' if: github.secret_source == 'Actions'
with: with:
registry: ghcr.io registry: ghcr.io
@ -92,7 +82,7 @@ jobs:
password: ${{ github.token }} password: ${{ github.token }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: | images: |
vabene1111/recipes vabene1111/recipes
@ -107,7 +97,7 @@ jobs:
type=semver,suffix=-open-data-plugin,pattern={{major}} type=semver,suffix=-open-data-plugin,pattern={{major}}
type=ref,suffix=-open-data-plugin,event=branch type=ref,suffix=-open-data-plugin,event=branch
- name: Build and Push - name: Build and Push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}

View File

@ -17,15 +17,9 @@ jobs:
# Standard build config # Standard build config
- name: Standard - name: Standard
dockerfile: Dockerfile dockerfile: Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64,linux/arm/v7
suffix: "" suffix: ""
continue-on-error: false continue-on-error: false
# Raspi build config
- name: Raspi
dockerfile: Dockerfile-raspi
platforms: linux/arm/v7
suffix: "-raspi"
continue-on-error: true
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@ -40,20 +34,10 @@ jobs:
echo VERSION=develop >> $GITHUB_OUTPUT echo VERSION=develop >> $GITHUB_OUTPUT
fi fi
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.2
with:
path: recipes/version.py
contents: |
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend # Build Vue frontend
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: '14' node-version: '18'
cache: yarn cache: yarn
cache-dependency-path: vue/yarn.lock cache-dependency-path: vue/yarn.lock
- name: Install dependencies - name: Install dependencies
@ -64,17 +48,17 @@ jobs:
run: yarn build run: yarn build
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Buildx - name: Set up Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
if: github.secret_source == 'Actions' if: github.secret_source == 'Actions'
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
if: github.secret_source == 'Actions' if: github.secret_source == 'Actions'
with: with:
registry: ghcr.io registry: ghcr.io
@ -82,7 +66,7 @@ jobs:
password: ${{ github.token }} password: ${{ github.token }}
- name: Docker meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v5
with: with:
images: | images: |
vabene1111/recipes vabene1111/recipes
@ -97,7 +81,7 @@ jobs:
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=ref,event=branch type=ref,event=branch
- name: Build and Push - name: Build and Push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: ${{ matrix.dockerfile }} file: ${{ matrix.dockerfile }}

View File

@ -20,7 +20,7 @@ jobs:
# Build Vue frontend # Build Vue frontend
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: '16' node-version: '18'
- name: Install Vue dependencies - name: Install Vue dependencies
working-directory: ./vue working-directory: ./vue
run: yarn install run: yarn install

1
.gitignore vendored
View File

@ -74,6 +74,7 @@ mediafiles/
\.env \.env
staticfiles/ staticfiles/
postgresql/ postgresql/
data/
/docker-compose.override.yml /docker-compose.override.yml

View File

@ -3,6 +3,7 @@
<words> <words>
<w>pinia</w> <w>pinia</w>
<w>selfhosted</w> <w>selfhosted</w>
<w>unapplied</w>
</words> </words>
</dictionary> </dictionary>
</component> </component>

View File

@ -1,5 +1,6 @@
<component name="InspectionProjectProfileManager"> <component name="InspectionProjectProfileManager">
<settings> <settings>
<option name="PROJECT_PROFILE" value="Default" />
<option name="USE_PROJECT_PROFILE" value="false" /> <option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" /> <version value="1.0" />
</settings> </settings>

35
.idea/recipes.iml Normal file
View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="FacetManager">
<facet type="django" name="Django">
<configuration>
<option name="rootFolder" value="$MODULE_DIR$" />
<option name="settingsModule" value="recipes/settings.py" />
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
<option name="environment" value="&lt;map/&gt;" />
<option name="doNotUseTestRunner" value="false" />
<option name="trackFilePattern" value="migrations" />
</configuration>
</facet>
</component>
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/cookbook/tests/resources" />
<excludeFolder url="file://$MODULE_DIR$/staticfiles" />
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_CONFIGURATION" value="Django" />
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/cookbook/templates" />
</list>
</option>
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="pytest" />
</component>
</module>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
</project> </project>

View File

@ -1,7 +1,7 @@
FROM python:3.10-alpine3.15 FROM python:3.10-alpine3.18
#Install all dependencies. #Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev py-cryptography openldap RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git
#Print all logs without buffering it. #Print all logs without buffering it.
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
@ -15,7 +15,11 @@ WORKDIR /opt/recipes
COPY requirements.txt ./ COPY requirements.txt ./
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev git && \ RUN \
if [ `apk --print-arch` = "armv7" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \ echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
python -m venv venv && \ python -m venv venv && \
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \ /opt/recipes/venv/bin/python -m pip install --upgrade pip && \
@ -26,5 +30,11 @@ RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-de
#Copy project and execute it. #Copy project and execute it.
COPY . ./ COPY . ./
# collect information from git repositories
RUN /opt/recipes/venv/bin/python version.py
# delete git repositories to reduce image size
RUN find . -type d -name ".git" | xargs rm -rf
RUN chmod +x boot.sh RUN chmod +x boot.sh
ENTRYPOINT ["/opt/recipes/boot.sh"] ENTRYPOINT ["/opt/recipes/boot.sh"]

View File

@ -1,33 +0,0 @@
# builds of cryptography for raspberry pi (or better arm v7) fail for some
FROM python:3.9-alpine3.15
#Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev py-cryptography openldap gcompat
#Print all logs without buffering it.
ENV PYTHONUNBUFFERED 1
#This port will be used by gunicorn.
EXPOSE 8080
#Create app dir and install requirements.
RUN mkdir /opt/recipes
WORKDIR /opt/recipes
COPY requirements.txt ./
RUN \
if [ `apk --print-arch` = "armv7" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
RUN apk add --no-cache --virtual .build-deps gcc musl-dev zlib-dev jpeg-dev libwebp-dev python3-dev git && \
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
python -m venv venv && \
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
venv/bin/pip install wheel==0.37.1 && \
venv/bin/pip install -r requirements.txt --no-cache-dir --no-binary=Pillow && \
apk --purge del .build-deps
#Copy project and execute it.
COPY . ./
RUN chmod +x boot.sh
ENTRYPOINT ["/opt/recipes/boot.sh"]

20
boot.sh
View File

@ -19,9 +19,14 @@ if [ ! -f "$NGINX_CONF_FILE" ] && [ $GUNICORN_MEDIA -eq 0 ]; then
display_warning "Nginx configuration file could not be found at the default location!\nPath: ${NGINX_CONF_FILE}" display_warning "Nginx configuration file could not be found at the default location!\nPath: ${NGINX_CONF_FILE}"
fi fi
# SECRET_KEY must be set in .env file # SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
if [ -f "${SECRET_KEY_FILE}" ]; then
export SECRET_KEY=$(cat "$SECRET_KEY_FILE")
fi
if [ -z "${SECRET_KEY}" ]; then if [ -z "${SECRET_KEY}" ]; then
display_warning "The environment variable 'SECRET_KEY' is not set but REQUIRED for running Tandoor!" display_warning "The environment variable 'SECRET_KEY' (or 'SECRET_KEY_FILE' that points to an existing file) is not set but REQUIRED for running Tandoor!"
fi fi
@ -30,11 +35,16 @@ echo "Waiting for database to be ready..."
attempt=0 attempt=0
max_attempts=20 max_attempts=20
if [ "${DB_ENGINE}" != 'django.db.backends.sqlite3' ]; then if [ "${DB_ENGINE}" == 'django.db.backends.postgresql' ] || [ "${DATABASE_URL}" == 'postgres'* ]; then
# POSTGRES_PASSWORD (or a valid file at POSTGRES_PASSWORD_FILE) must be set in .env file
if [ -f "${POSTGRES_PASSWORD_FILE}" ]; then
export POSTGRES_PASSWORD=$(cat "$POSTGRES_PASSWORD_FILE")
fi
# POSTGRES_PASSWORD must be set in .env file
if [ -z "${POSTGRES_PASSWORD}" ]; then if [ -z "${POSTGRES_PASSWORD}" ]; then
display_warning "The environment variable 'POSTGRES_PASSWORD' is not set but REQUIRED for running Tandoor!" display_warning "The environment variable 'POSTGRES_PASSWORD' (or 'POSTGRES_PASSWORD_FILE' that points to an existing file) is not set but REQUIRED for running Tandoor!"
fi fi
while pg_isready --host=${POSTGRES_HOST} --port=${POSTGRES_PORT} --user=${POSTGRES_USER} -q; status=$?; attempt=$((attempt+1)); [ $status -ne 0 ] && [ $attempt -le $max_attempts ]; do while pg_isready --host=${POSTGRES_HOST} --port=${POSTGRES_PORT} --user=${POSTGRES_USER} -q; status=$?; attempt=$((attempt+1)); [ $status -ne 0 ] && [ $attempt -le $max_attempts ]; do

View File

@ -10,13 +10,13 @@ from treebeard.forms import movenodeform_factory
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from .models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField, from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink,
ImportLog, Ingredient, InviteLink, Keyword, MealPlan, MealType, Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType,
NutritionInformation, Property, PropertyType, Recipe, RecipeBook, Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, ShoppingList, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, TelegramBot, TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
Unit, UnitConversion, UserFile, UserPreference, UserSpace, ViewLog) ViewLog)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
@ -39,6 +39,8 @@ def delete_space_action(modeladmin, request, queryset):
class SpaceAdmin(admin.ModelAdmin): class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing') list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username') search_fields = ('name', 'created_by__username')
autocomplete_fields = ('created_by',)
filter_horizontal = ('food_inherit',)
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing') list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
actions = [delete_space_action] actions = [delete_space_action]
@ -50,16 +52,19 @@ admin.site.register(Space, SpaceAdmin)
class UserSpaceAdmin(admin.ModelAdmin): class UserSpaceAdmin(admin.ModelAdmin):
list_display = ('user', 'space',) list_display = ('user', 'space',)
search_fields = ('user__username', 'space__name',) search_fields = ('user__username', 'space__name',)
filter_horizontal = ('groups',)
autocomplete_fields = ('user', 'space',)
admin.site.register(UserSpace, UserSpaceAdmin) admin.site.register(UserSpace, UserSpaceAdmin)
class UserPreferenceAdmin(admin.ModelAdmin): class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'theme', 'nav_color', 'default_page',) list_display = ('name', 'theme', 'default_page')
search_fields = ('user__username',) search_fields = ('user__username',)
list_filter = ('theme', 'nav_color', 'default_page',) list_filter = ('theme', 'default_page',)
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
filter_horizontal = ('plan_share', 'shopping_share',)
@staticmethod @staticmethod
def name(obj): def name(obj):
@ -103,11 +108,16 @@ class SupermarketCategoryInline(admin.TabularInline):
class SupermarketAdmin(admin.ModelAdmin): class SupermarketAdmin(admin.ModelAdmin):
list_display = ('name', 'space',)
inlines = (SupermarketCategoryInline,) inlines = (SupermarketCategoryInline,)
class SupermarketCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'space',)
admin.site.register(Supermarket, SupermarketAdmin) admin.site.register(Supermarket, SupermarketAdmin)
admin.site.register(SupermarketCategory) admin.site.register(SupermarketCategory, SupermarketCategoryAdmin)
class SyncLogAdmin(admin.ModelAdmin): class SyncLogAdmin(admin.ModelAdmin):
@ -158,10 +168,18 @@ def delete_unattached_steps(modeladmin, request, queryset):
class StepAdmin(admin.ModelAdmin): class StepAdmin(admin.ModelAdmin):
list_display = ('name', 'order',) list_display = ('recipe_and_name', 'order', 'space')
search_fields = ('name',) ordering = ('recipe__name', 'name', 'space',)
search_fields = ('name', 'recipe__name')
actions = [delete_unattached_steps] actions = [delete_unattached_steps]
@staticmethod
@admin.display(description="Name")
def recipe_and_name(obj):
if not obj.recipe_set.exists():
return f"Orphaned Step{'':s if not obj.name else f': {obj.name}'}"
return f"{obj.recipe_set.first().name}: {obj.name}" if obj.name else obj.recipe_set.first().name
admin.site.register(Step, StepAdmin) admin.site.register(Step, StepAdmin)
@ -178,8 +196,9 @@ def rebuild_index(modeladmin, request, queryset):
class RecipeAdmin(admin.ModelAdmin): class RecipeAdmin(admin.ModelAdmin):
list_display = ('name', 'internal', 'created_by', 'storage') list_display = ('name', 'internal', 'created_by', 'storage', 'space')
search_fields = ('name', 'created_by__username') search_fields = ('name', 'created_by__username')
ordering = ('name', 'created_by__username',)
list_filter = ('internal',) list_filter = ('internal',)
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
@ -187,13 +206,20 @@ class RecipeAdmin(admin.ModelAdmin):
def created_by(obj): def created_by(obj):
return obj.created_by.get_user_display_name() return obj.created_by.get_user_display_name()
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']: if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
actions = [rebuild_index] actions = [rebuild_index]
admin.site.register(Recipe, RecipeAdmin) admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('name', 'space')
ordering = ('name', 'space',)
search_fields = ('name',)
admin.site.register(Unit, UnitAdmin)
# admin.site.register(FoodInheritField) # admin.site.register(FoodInheritField)
@ -224,10 +250,16 @@ def delete_unattached_ingredients(modeladmin, request, queryset):
class IngredientAdmin(admin.ModelAdmin): class IngredientAdmin(admin.ModelAdmin):
list_display = ('food', 'amount', 'unit') list_display = ('recipe_name', 'amount', 'unit', 'food', 'space')
search_fields = ('food__name', 'unit__name') search_fields = ('food__name', 'unit__name', 'step__recipe__name')
actions = [delete_unattached_ingredients] actions = [delete_unattached_ingredients]
@staticmethod
@admin.display(description="Recipe")
def recipe_name(obj):
recipes = obj.step_set.first().recipe_set.all() if obj.step_set.exists() else None
return recipes.first().name if recipes else 'Orphaned Ingredient'
admin.site.register(Ingredient, IngredientAdmin) admin.site.register(Ingredient, IngredientAdmin)
@ -253,7 +285,7 @@ admin.site.register(RecipeImport, RecipeImportAdmin)
class RecipeBookAdmin(admin.ModelAdmin): class RecipeBookAdmin(admin.ModelAdmin):
list_display = ('name', 'user_name') list_display = ('name', 'user_name', 'space')
search_fields = ('name', 'created_by__username') search_fields = ('name', 'created_by__username')
@staticmethod @staticmethod
@ -272,7 +304,7 @@ admin.site.register(RecipeBookEntry, RecipeBookEntryAdmin)
class MealPlanAdmin(admin.ModelAdmin): class MealPlanAdmin(admin.ModelAdmin):
list_display = ('user', 'recipe', 'meal_type', 'date') list_display = ('user', 'recipe', 'meal_type', 'from_date', 'to_date')
@staticmethod @staticmethod
def user(obj): def user(obj):
@ -309,6 +341,7 @@ admin.site.register(InviteLink, InviteLinkAdmin)
class CookLogAdmin(admin.ModelAdmin): class CookLogAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings') list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
search_fields = ('recipe__name', 'space__name',)
admin.site.register(CookLog, CookLogAdmin) admin.site.register(CookLog, CookLogAdmin)
@ -328,11 +361,11 @@ class ShoppingListEntryAdmin(admin.ModelAdmin):
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin) admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
class ShoppingListAdmin(admin.ModelAdmin): # class ShoppingListAdmin(admin.ModelAdmin):
list_display = ('id', 'created_by', 'created_at') # list_display = ('id', 'created_by', 'created_at')
admin.site.register(ShoppingList, ShoppingListAdmin) # admin.site.register(ShoppingList, ShoppingListAdmin)
class ShareLinkAdmin(admin.ModelAdmin): class ShareLinkAdmin(admin.ModelAdmin):
@ -343,7 +376,9 @@ admin.site.register(ShareLink, ShareLinkAdmin)
class PropertyTypeAdmin(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) admin.site.register(PropertyType, PropertyTypeAdmin)

View File

@ -9,8 +9,8 @@ from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook, from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry,
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference) SearchPreference, Space, Storage, Sync, User, UserPreference)
class SelectWidget(widgets.Select): class SelectWidget(widgets.Select):
@ -33,64 +33,6 @@ class DateWidget(forms.DateInput):
super().__init__(**kwargs) super().__init__(**kwargs)
class UserPreferenceForm(forms.ModelForm):
prefix = 'preference'
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['plan_share'].queryset = User.objects.filter(userspace__space=space).all()
class Meta:
model = UserPreference
fields = (
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed',
)
labels = {
'default_unit': _('Default unit'),
'use_fractions': _('Use fractions'),
'use_kj': _('Use KJ'),
'theme': _('Theme'),
'nav_color': _('Navbar color'),
'sticky_navbar': _('Sticky navbar'),
'default_page': _('Default page'),
'plan_share': _('Plan sharing'),
'ingredient_decimals': _('Ingredient decimal places'),
'shopping_auto_sync': _('Shopping list auto sync period'),
'comments': _('Comments'),
'left_handed': _('Left-handed mode')
}
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
'use_fractions': _(
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
'use_kj': _('Display nutritional energy amounts in joules instead of calories'),
'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
'shopping_share': _('Users with whom to share shopping lists.'),
'ingredient_decimals': _('Number of decimals to round ingredients.'),
'comments': _('If you want to be able to create and see comments underneath recipes.'),
'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.'
),
'sticky_navbar': _('Makes the navbar stick to the top of the page.'),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
'left_handed': _('Will optimize the UI for use with your left hand.')
}
widgets = {
'plan_share': MultiSelectWidget,
'shopping_share': MultiSelectWidget,
}
class UserNameForm(forms.ModelForm): class UserNameForm(forms.ModelForm):
prefix = 'name' prefix = 'name'
@ -184,6 +126,7 @@ class MultipleFileField(forms.FileField):
result = single_file_clean(data, initial) result = single_file_clean(data, initial)
return result return result
class ImportForm(ImportExportBase): class ImportForm(ImportExportBase):
files = MultipleFileField(required=True) files = MultipleFileField(required=True)
duplicates = forms.BooleanField(help_text=_( duplicates = forms.BooleanField(help_text=_(
@ -322,50 +265,6 @@ class ImportRecipeForm(forms.ModelForm):
} }
# TODO deprecate
class MealPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['recipe'].queryset = Recipe.objects.filter(space=space).all()
self.fields['meal_type'].queryset = MealType.objects.filter(space=space).all()
self.fields['shared'].queryset = User.objects.filter(userpreference__space=space).all()
def clean(self):
cleaned_data = super(MealPlanForm, self).clean()
if cleaned_data['title'] == '' and cleaned_data['recipe'] is None:
raise forms.ValidationError(
_('You must provide at least a recipe or a title.')
)
return cleaned_data
class Meta:
model = MealPlan
fields = (
'recipe', 'title', 'meal_type', 'note',
'servings', 'date', 'shared'
)
help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'),
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
}
widgets = {
'recipe': SelectWidget,
'date': DateWidget,
'shared': MultiSelectWidget
}
field_classes = {
'recipe': SafeModelChoiceField,
'meal_type': SafeModelChoiceField,
'shared': SafeModelMultipleChoiceField,
}
class InviteLinkForm(forms.ModelForm): class InviteLinkForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
user = kwargs.pop('user') user = kwargs.pop('user')
@ -506,8 +405,8 @@ class ShoppingPreferenceForm(forms.ModelForm):
help_texts = { 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_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': _( '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 ' '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.' '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_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_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
@ -551,11 +450,10 @@ class SpacePreferenceForm(forms.ModelForm):
class Meta: class Meta:
model = Space model = Space
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural') fields = ('food_inherit', 'reset_food_inherit', 'use_plural')
help_texts = { help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'), 'food_inherit': _('Fields on food that should be inherited by default.'),
'show_facet_count': _('Show recipe counts on search filters'),
'use_plural': _('Use the plural form for units and food inside this space.'), 'use_plural': _('Use the plural form for units and food inside this space.'),
} }

View File

@ -1,11 +1,10 @@
import datetime import datetime
from gettext import gettext as _
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter from allauth.account.adapter import DefaultAccountAdapter
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.cache import caches from django.core.cache import caches
from gettext import gettext as _
from cookbook.models import InviteLink from cookbook.models import InviteLink
@ -17,10 +16,13 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
Whether to allow sign-ups. Whether to allow sign-ups.
""" """
signup_token = False signup_token = False
if 'signup_token' in request.session and InviteLink.objects.filter(valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists(): if 'signup_token' in request.session and InviteLink.objects.filter(
valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists():
signup_token = True signup_token = True
if (request.resolver_match.view_name == 'account_signup' or request.resolver_match.view_name == 'socialaccount_signup') and not settings.ENABLE_SIGNUP and not signup_token: if request.resolver_match.view_name == 'account_signup' and not settings.ENABLE_SIGNUP and not signup_token:
return False
elif request.resolver_match.view_name == 'socialaccount_signup' and len(settings.SOCIAL_PROVIDERS) < 1:
return False return False
else: else:
return super(AllAuthCustomAdapter, self).is_open_for_signup(request) return super(AllAuthCustomAdapter, self).is_open_for_signup(request)
@ -33,7 +35,7 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
if c == default: if c == default:
try: try:
super(AllAuthCustomAdapter, self).send_mail(template_prefix, email, context) super(AllAuthCustomAdapter, self).send_mail(template_prefix, email, context)
except Exception: # dont fail signup just because confirmation mail could not be send except Exception: # dont fail signup just because confirmation mail could not be send
pass pass
else: else:
messages.add_message(self.request, messages.ERROR, _('In order to prevent spam, the requested email was not send. Please wait a few minutes and try again.')) messages.add_message(self.request, messages.ERROR, _('In order to prevent spam, the requested email was not send. Please wait a few minutes and try again.'))

View File

@ -7,7 +7,7 @@ class Round(Func):
def str2bool(v): def str2bool(v):
if type(v) == bool or v is None: if isinstance(v, bool) or v is None:
return v return v
else: else:
return v.lower() in ("yes", "true", "1") return v.lower() in ("yes", "true", "1")

View File

@ -0,0 +1,227 @@
import re
from django.core.cache import caches
from django.db.models.functions import Lower
from cookbook.models import Automation
class AutomationEngine:
request = None
source = None
use_cache = None
food_aliases = None
keyword_aliases = None
unit_aliases = None
never_unit = None
transpose_words = None
regex_replace = {
Automation.DESCRIPTION_REPLACE: None,
Automation.INSTRUCTION_REPLACE: None,
Automation.FOOD_REPLACE: None,
Automation.UNIT_REPLACE: None,
Automation.NAME_REPLACE: None,
}
def __init__(self, request, use_cache=True, source=None):
self.request = request
self.use_cache = use_cache
if not source:
self.source = "default_string_to_avoid_false_regex_match"
else:
self.source = source
def apply_keyword_automation(self, keyword):
keyword = keyword.strip()
if self.use_cache and self.keyword_aliases is None:
self.keyword_aliases = {}
KEYWORD_CACHE_KEY = f'automation_keyword_alias_{self.request.space.pk}'
if c := caches['default'].get(KEYWORD_CACHE_KEY, None):
self.keyword_aliases = c
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
self.keyword_aliases[a.param_1.lower()] = a.param_2
caches['default'].set(KEYWORD_CACHE_KEY, self.keyword_aliases, 30)
else:
self.keyword_aliases = {}
if self.keyword_aliases:
try:
keyword = self.keyword_aliases[keyword.lower()]
except KeyError:
pass
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.KEYWORD_ALIAS, param_1__iexact=keyword, disabled=False).order_by('order').first():
return automation.param_2
return keyword
def apply_unit_automation(self, unit):
unit = unit.strip()
if self.use_cache and self.unit_aliases is None:
self.unit_aliases = {}
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
if c := caches['default'].get(UNIT_CACHE_KEY, None):
self.unit_aliases = c
caches['default'].touch(UNIT_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
self.unit_aliases[a.param_1.lower()] = a.param_2
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
else:
self.unit_aliases = {}
if self.unit_aliases:
try:
unit = self.unit_aliases[unit.lower()]
except KeyError:
pass
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1__iexact=unit, disabled=False).order_by('order').first():
return automation.param_2
return self.apply_regex_replace_automation(unit, Automation.UNIT_REPLACE)
def apply_food_automation(self, food):
food = food.strip()
if self.use_cache and self.food_aliases is None:
self.food_aliases = {}
FOOD_CACHE_KEY = f'automation_food_alias_{self.request.space.pk}'
if c := caches['default'].get(FOOD_CACHE_KEY, None):
self.food_aliases = c
caches['default'].touch(FOOD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
self.food_aliases[a.param_1.lower()] = a.param_2
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
else:
self.food_aliases = {}
if self.food_aliases:
try:
return self.food_aliases[food.lower()]
except KeyError:
return food
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
return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE)
def apply_never_unit_automation(self, tokens):
"""
Moves a string that should never be treated as a unit to next token and optionally replaced with default unit
e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white']
or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk']
:param1 string: string that should never be considered a unit, will be moved to token[2]
:param2 (optional) unit as string: will insert unit string into token[1]
:return: unit as string (possibly changed by automation)
"""
if self.use_cache and self.never_unit is None:
self.never_unit = {}
NEVER_UNIT_CACHE_KEY = f'automation_never_unit_{self.request.space.pk}'
if c := caches['default'].get(NEVER_UNIT_CACHE_KEY, None):
self.never_unit = c
caches['default'].touch(NEVER_UNIT_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.NEVER_UNIT).only('param_1', 'param_2').order_by('order').all():
self.never_unit[a.param_1.lower()] = a.param_2
caches['default'].set(NEVER_UNIT_CACHE_KEY, self.never_unit, 30)
else:
self.never_unit = {}
new_unit = None
alt_unit = self.apply_unit_automation(tokens[1])
never_unit = False
if self.never_unit:
try:
new_unit = self.never_unit[tokens[1].lower()]
never_unit = True
except KeyError:
return tokens
else:
if a := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[
tokens[1].lower(), alt_unit.lower()], disabled=False).order_by('order').first():
new_unit = a.param_2
never_unit = True
if never_unit:
tokens.insert(1, new_unit)
return tokens
def apply_transpose_automation(self, string):
"""
If two words (param_1 & param_2) are detected in sequence, swap their position in the ingredient string
:param 1: first word to detect
:param 2: second word to detect
return: new ingredient string
"""
if self.use_cache and self.transpose_words is None:
self.transpose_words = {}
TRANSPOSE_WORDS_CACHE_KEY = f'automation_transpose_words_{self.request.space.pk}'
if c := caches['default'].get(TRANSPOSE_WORDS_CACHE_KEY, None):
self.transpose_words = c
caches['default'].touch(TRANSPOSE_WORDS_CACHE_KEY, 30)
else:
i = 0
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.TRANSPOSE_WORDS).only(
'param_1', 'param_2').order_by('order').all()[:512]:
self.transpose_words[i] = [a.param_1.lower(), a.param_2.lower()]
i += 1
caches['default'].set(TRANSPOSE_WORDS_CACHE_KEY, self.transpose_words, 30)
else:
self.transpose_words = {}
tokens = [x.lower() for x in string.replace(',', ' ').split()]
if self.transpose_words:
for key, value in self.transpose_words.items():
if value[0] in tokens and value[1] in tokens:
string = re.sub(rf"\b({value[0]})\W*({value[1]})\b", r"\2 \1", string, flags=re.IGNORECASE)
else:
for rule in Automation.objects.filter(space=self.request.space, type=Automation.TRANSPOSE_WORDS, disabled=False) \
.annotate(param_1_lower=Lower('param_1'), param_2_lower=Lower('param_2')) \
.filter(param_1_lower__in=tokens, param_2_lower__in=tokens).order_by('order')[:512]:
if rule.param_1 in tokens and rule.param_2 in tokens:
string = re.sub(rf"\b({rule.param_1})\W*({rule.param_2})\b", r"\2 \1", string, flags=re.IGNORECASE)
return string
def apply_regex_replace_automation(self, string, automation_type):
# TODO add warning - maybe on SPACE page? when a max of 512 automations of a specific type is exceeded (ALIAS types excluded?)
"""
Replaces strings in a recipe field that are from a matched source
field_type are Automation.type that apply regex replacements
Automation.DESCRIPTION_REPLACE
Automation.INSTRUCTION_REPLACE
Automation.FOOD_REPLACE
Automation.UNIT_REPLACE
Automation.NAME_REPLACE
regex replacment utilized the following fields from the Automation model
:param 1: source that should apply the automation in regex format ('.*' for all)
:param 2: regex pattern to match ()
:param 3: replacement string (leave blank to delete)
return: new string
"""
if self.use_cache and self.regex_replace[automation_type] is None:
self.regex_replace[automation_type] = {}
REGEX_REPLACE_CACHE_KEY = f'automation_regex_replace_{self.request.space.pk}'
if c := caches['default'].get(REGEX_REPLACE_CACHE_KEY, None):
self.regex_replace[automation_type] = c[automation_type]
caches['default'].touch(REGEX_REPLACE_CACHE_KEY, 30)
else:
i = 0
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=automation_type).only(
'param_1', 'param_2', 'param_3').order_by('order').all()[:512]:
self.regex_replace[automation_type][i] = [a.param_1, a.param_2, a.param_3]
i += 1
caches['default'].set(REGEX_REPLACE_CACHE_KEY, self.regex_replace, 30)
else:
self.regex_replace[automation_type] = {}
if self.regex_replace[automation_type]:
for rule in self.regex_replace[automation_type].values():
if re.match(rule[0], (self.source)[:512]):
string = re.sub(rule[1], rule[2], string, flags=re.IGNORECASE)
else:
for rule in Automation.objects.filter(space=self.request.space, disabled=False, type=automation_type).only(
'param_1', 'param_2', 'param_3').order_by('order').all()[:512]:
if re.match(rule.param_1, (self.source)[:512]):
string = re.sub(rule.param_2, rule.param_3, string, flags=re.IGNORECASE)
return string

View File

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

View File

@ -1,8 +1,7 @@
import os import os
import sys from io import BytesIO
from PIL import Image from PIL import Image
from io import BytesIO
def rescale_image_jpeg(image_object, base_width=1020): def rescale_image_jpeg(image_object, base_width=1020):
@ -11,7 +10,7 @@ def rescale_image_jpeg(image_object, base_width=1020):
width_percent = (base_width / float(img.size[0])) width_percent = (base_width / float(img.size[0]))
height = int((float(img.size[1]) * float(width_percent))) height = int((float(img.size[1]) * float(width_percent)))
img = img.resize((base_width, height), Image.ANTIALIAS) img = img.resize((base_width, height), Image.LANCZOS)
img_bytes = BytesIO() img_bytes = BytesIO()
img.save(img_bytes, 'JPEG', quality=90, optimize=True, icc_profile=icc_profile) img.save(img_bytes, 'JPEG', quality=90, optimize=True, icc_profile=icc_profile)
@ -22,7 +21,7 @@ def rescale_image_png(image_object, base_width=1020):
image_object = Image.open(image_object) image_object = Image.open(image_object)
wpercent = (base_width / float(image_object.size[0])) wpercent = (base_width / float(image_object.size[0]))
hsize = int((float(image_object.size[1]) * float(wpercent))) hsize = int((float(image_object.size[1]) * float(wpercent)))
img = image_object.resize((base_width, hsize), Image.ANTIALIAS) img = image_object.resize((base_width, hsize), Image.LANCZOS)
im_io = BytesIO() im_io = BytesIO()
img.save(im_io, 'PNG', quality=90) img.save(im_io, 'PNG', quality=90)

View File

@ -2,18 +2,16 @@ import re
import string import string
import unicodedata import unicodedata
from django.core.cache import caches from cookbook.helper.automation_helper import AutomationEngine
from cookbook.models import Food, Ingredient, Unit
from cookbook.models import Unit, Food, Automation, Ingredient
class IngredientParser: class IngredientParser:
request = None request = None
ignore_rules = False ignore_rules = False
food_aliases = {} automation = None
unit_aliases = {}
def __init__(self, request, cache_mode, ignore_automations=False): def __init__(self, request, cache_mode=True, ignore_automations=False):
""" """
Initialize ingredient parser Initialize ingredient parser
:param request: request context (to control caching, rule ownership, etc.) :param request: request context (to control caching, rule ownership, etc.)
@ -22,65 +20,8 @@ class IngredientParser:
""" """
self.request = request self.request = request
self.ignore_rules = ignore_automations self.ignore_rules = ignore_automations
if cache_mode: if not self.ignore_rules:
FOOD_CACHE_KEY = f'automation_food_alias_{self.request.space.pk}' self.automation = AutomationEngine(self.request, use_cache=cache_mode)
if c := caches['default'].get(FOOD_CACHE_KEY, None):
self.food_aliases = c
caches['default'].touch(FOOD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
self.food_aliases[a.param_1] = a.param_2
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
if c := caches['default'].get(UNIT_CACHE_KEY, None):
self.unit_aliases = c
caches['default'].touch(UNIT_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
self.unit_aliases[a.param_1] = a.param_2
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
else:
self.food_aliases = {}
self.unit_aliases = {}
def apply_food_automation(self, food):
"""
Apply food alias automations to passed food
:param food: unit as string
:return: food as string (possibly changed by automation)
"""
if self.ignore_rules:
return food
else:
if self.food_aliases:
try:
return self.food_aliases[food]
except KeyError:
return food
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first():
return automation.param_2
return food
def apply_unit_automation(self, unit):
"""
Apply unit alias automations to passed unit
:param unit: unit as string
:return: unit as string (possibly changed by automation)
"""
if self.ignore_rules:
return unit
else:
if self.unit_aliases:
try:
return self.unit_aliases[unit]
except KeyError:
return unit
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first():
return automation.param_2
return unit
def get_unit(self, unit): def get_unit(self, unit):
""" """
@ -91,7 +32,10 @@ class IngredientParser:
if not unit: if not unit:
return None return None
if len(unit) > 0: if len(unit) > 0:
u, created = Unit.objects.get_or_create(name=self.apply_unit_automation(unit), space=self.request.space) if self.ignore_rules:
u, created = Unit.objects.get_or_create(name=unit.strip(), space=self.request.space)
else:
u, created = Unit.objects.get_or_create(name=self.automation.apply_unit_automation(unit), space=self.request.space)
return u return u
return None return None
@ -104,7 +48,10 @@ class IngredientParser:
if not food: if not food:
return None return None
if len(food) > 0: if len(food) > 0:
f, created = Food.objects.get_or_create(name=self.apply_food_automation(food), space=self.request.space) if self.ignore_rules:
f, created = Food.objects.get_or_create(name=food.strip(), space=self.request.space)
else:
f, created = Food.objects.get_or_create(name=self.automation.apply_food_automation(food), space=self.request.space)
return f return f
return None return None
@ -133,10 +80,10 @@ class IngredientParser:
end = 0 end = 0
while (end < len(x) and (x[end] in string.digits while (end < len(x) and (x[end] in string.digits
or ( or (
(x[end] == '.' or x[end] == ',' or x[end] == '/') (x[end] == '.' or x[end] == ',' or x[end] == '/')
and end + 1 < len(x) and end + 1 < len(x)
and x[end + 1] in string.digits and x[end + 1] in string.digits
))): ))):
end += 1 end += 1
if end > 0: if end > 0:
if "/" in x[:end]: if "/" in x[:end]:
@ -160,7 +107,8 @@ class IngredientParser:
if unit is not None and unit.strip() == '': if unit is not None and unit.strip() == '':
unit = None unit = None
if unit is not None and (unit.startswith('(') or unit.startswith('-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3 if unit is not None and (unit.startswith('(') or unit.startswith(
'-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = None unit = None
note = x note = x
return amount, unit, note return amount, unit, note
@ -221,6 +169,9 @@ class IngredientParser:
if len(ingredient) == 0: if len(ingredient) == 0:
raise ValueError('string to parse cannot be empty') raise ValueError('string to parse cannot be empty')
if len(ingredient) > 512:
raise ValueError('cannot parse ingredients with more than 512 characters')
# some people/languages put amount and unit at the end of the ingredient string # some people/languages put amount and unit at the end of the ingredient string
# if something like this is detected move it to the beginning so the parser can handle it # if something like this is detected move it to the beginning so the parser can handle it
if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient): if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient):
@ -230,8 +181,8 @@ class IngredientParser:
# if the string contains parenthesis early on remove it and place it at the end # if the string contains parenthesis early on remove it and place it at the end
# because its likely some kind of note # because its likely some kind of note
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', ingredient): if re.match('(.){1,6}\\s\\((.[^\\(\\)])+\\)\\s', ingredient):
match = re.search('\((.[^\(])+\)', ingredient) match = re.search('\\((.[^\\(])+\\)', ingredient)
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()] ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
# leading spaces before commas result in extra tokens, clean them out # leading spaces before commas result in extra tokens, clean them out
@ -239,12 +190,15 @@ class IngredientParser:
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description # handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)" # "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
ingredient = re.sub("^(\d+|\d+[\\.,]\d+) - (\d+|\d+[\\.,]\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient) ingredient = re.sub("^(\\d+|\\d+[\\.,]\\d+) - (\\d+|\\d+[\\.,]\\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
# if amount and unit are connected add space in between # if amount and unit are connected add space in between
if re.match('([0-9])+([A-z])+\s', ingredient): if re.match('([0-9])+([A-z])+\\s', ingredient):
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient) ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
if not self.ignore_rules:
ingredient = self.automation.apply_transpose_automation(ingredient)
tokens = ingredient.split() # split at each space into tokens tokens = ingredient.split() # split at each space into tokens
if len(tokens) == 1: if len(tokens) == 1:
# there only is one argument, that must be the food # there only is one argument, that must be the food
@ -257,6 +211,8 @@ class IngredientParser:
# three arguments if it already has a unit there can't be # three arguments if it already has a unit there can't be
# a fraction for the amount # a fraction for the amount
if len(tokens) > 2: if len(tokens) > 2:
if not self.ignore_rules:
tokens = self.automation.apply_never_unit_automation(tokens)
try: try:
if unit is not None: if unit is not None:
# a unit is already found, no need to try the second argument for a fraction # a unit is already found, no need to try the second argument for a fraction
@ -303,10 +259,11 @@ class IngredientParser:
if unit_note not in note: if unit_note not in note:
note += ' ' + unit_note note += ' ' + unit_note
if unit: if unit and not self.ignore_rules:
unit = self.apply_unit_automation(unit.strip()) unit = self.automation.apply_unit_automation(unit)
food = self.apply_food_automation(food.strip()) if food and not self.ignore_rules:
food = self.automation.apply_food_automation(food)
if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long
# try splitting it at a space and taking only the first arg # try splitting it at a space and taking only the first arg
if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length: if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length:

View File

@ -1,6 +1,5 @@
from django.db.models import Q from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket,
SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion)
from cookbook.models import Unit, SupermarketCategory, Property, PropertyType, Supermarket, SupermarketCategoryRelation, Food, Automation, UnitConversion, FoodProperty
class OpenDataImporter: class OpenDataImporter:
@ -33,7 +32,8 @@ class OpenDataImporter:
)) ))
if self.update_existing: 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',)) 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: else:
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',)) return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
@ -116,27 +116,25 @@ class OpenDataImporter:
self._update_slug_cache(Unit, 'unit') self._update_slug_cache(Unit, 'unit')
self._update_slug_cache(PropertyType, 'property') self._update_slug_cache(PropertyType, 'property')
# pref_unit_key = 'preferred_unit_metric'
# pref_shopping_unit_key = 'preferred_packaging_unit_metric'
# if not self.use_metric:
# pref_unit_key = 'preferred_unit_imperial'
# pref_shopping_unit_key = 'preferred_packaging_unit_imperial'
insert_list = [] insert_list = []
insert_list_flat = []
update_list = [] update_list = []
update_field_list = [] update_field_list = []
for k in list(self.data[datatype].keys()): 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 existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
insert_list.append({'data': { if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_flat):
'name': self.data[datatype][k]['name'], insert_list.append({'data': {
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, 'name': self.data[datatype][k]['name'],
# 'preferred_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]], 'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
# 'preferred_shopping_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]], 'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
'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,
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None, 'open_data_slug': k,
'open_data_slug': k, 'space': self.request.space.id,
'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'])
else: else:
if self.data[datatype][k]['name'] in existing_objects: if self.data[datatype][k]['name'] in existing_objects:
existing_food_id = existing_objects[self.data[datatype][k]['name']][0] existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
@ -149,8 +147,6 @@ class OpenDataImporter:
id=existing_food_id, id=existing_food_id,
name=self.data[datatype][k]['name'], name=self.data[datatype][k]['name'],
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None, plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
# preferred_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
# preferred_shopping_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_category']], 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, fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
open_data_slug=k, open_data_slug=k,
@ -166,23 +162,20 @@ class OpenDataImporter:
self._update_slug_cache(Food, 'food') self._update_slug_cache(Food, 'food')
food_property_list = [] food_property_list = []
alias_list = [] # alias_list = []
for k in list(self.data[datatype].keys()): for k in list(self.data[datatype].keys()):
for fp in self.data[datatype][k]['properties']['type_values']: for fp in self.data[datatype][k]['properties']['type_values']:
food_property_list.append(Property( # try catch here because somettimes key "k" is not set for he food cache
property_type_id=self.slug_id_cache['property'][fp['property_type']], try:
property_amount=fp['property_value'], food_property_list.append(Property(
import_food_id=self.slug_id_cache['food'][k], property_type_id=self.slug_id_cache['property'][fp['property_type']],
space=self.request.space, property_amount=fp['property_value'],
)) import_food_id=self.slug_id_cache['food'][k],
space=self.request.space,
# for a in self.data[datatype][k]['alias']: ))
# alias_list.append(Automation( except KeyError:
# param_1=a, print(str(k) + ' is not in self.slug_id_cache["food"]')
# param_2=self.data[datatype][k]['name'],
# space=self.request.space,
# created_by=self.request.user,
# ))
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',)) Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
@ -192,7 +185,6 @@ class OpenDataImporter:
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',)) FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
# Automation.objects.bulk_create(alias_list, ignore_conflicts=True, unique_fields=('space', 'param_1', 'param_2',))
return insert_list + update_list return insert_list + update_list
def import_conversion(self): def import_conversion(self):
@ -200,15 +192,19 @@ class OpenDataImporter:
insert_list = [] insert_list = []
for k in list(self.data[datatype].keys()): for k in list(self.data[datatype].keys()):
insert_list.append(UnitConversion( # try catch here because sometimes key "k" is not set for he food cache
base_amount=self.data[datatype][k]['base_amount'], try:
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']], insert_list.append(UnitConversion(
converted_amount=self.data[datatype][k]['converted_amount'], base_amount=self.data[datatype][k]['base_amount'],
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']], base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']], converted_amount=self.data[datatype][k]['converted_amount'],
open_data_slug=k, converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
space=self.request.space, food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
created_by=self.request.user, open_data_slug=k,
)) space=self.request.space,
created_by=self.request.user,
))
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')) return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))

View File

@ -4,16 +4,16 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import user_passes_test
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from oauth2_provider.contrib.rest_framework import TokenHasScope, TokenHasReadWriteScope from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope
from oauth2_provider.models import AccessToken from oauth2_provider.models import AccessToken
from rest_framework import permissions from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink, Recipe, UserSpace from cookbook.models import Recipe, ShareLink, UserSpace
def get_allowed_groups(groups_required): def get_allowed_groups(groups_required):
@ -255,9 +255,6 @@ class CustomIsShared(permissions.BasePermission):
return request.user.is_authenticated return request.user.is_authenticated
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
# # temporary hack to make old shopping list work with new shopping list
# if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
# return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj) return is_object_shared(request.user, obj)
@ -322,7 +319,8 @@ class CustomRecipePermission(permissions.BasePermission):
def has_permission(self, request, view): # user is either at least a guest or a share link is given and the request is safe def has_permission(self, request, view): # user is either at least a guest or a share link is given and the request is safe
share = request.query_params.get('share', None) share = request.query_params.get('share', None)
return has_group_permission(request.user, ['guest']) or (share and request.method in SAFE_METHODS and 'pk' in view.kwargs) return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS) or has_group_permission(
request.user, ['user'])) or (share and request.method in SAFE_METHODS and 'pk' in view.kwargs)
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
share = request.query_params.get('share', None) share = request.query_params.get('share', None)
@ -332,7 +330,8 @@ class CustomRecipePermission(permissions.BasePermission):
if obj.private: if obj.private:
return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space
else: else:
return has_group_permission(request.user, ['guest']) and obj.space == request.space return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS)
or has_group_permission(request.user, ['user'])) and obj.space == request.space
class CustomUserPermission(permissions.BasePermission): class CustomUserPermission(permissions.BasePermission):
@ -361,7 +360,7 @@ class CustomTokenHasScope(TokenHasScope):
""" """
def has_permission(self, request, view): def has_permission(self, request, view):
if type(request.auth) == AccessToken: if isinstance(request.auth, AccessToken):
return super().has_permission(request, view) return super().has_permission(request, view)
else: else:
return request.user.is_authenticated return request.user.is_authenticated
@ -375,7 +374,7 @@ class CustomTokenHasReadWriteScope(TokenHasReadWriteScope):
""" """
def has_permission(self, request, view): def has_permission(self, request, view):
if type(request.auth) == AccessToken: if isinstance(request.auth, AccessToken):
return super().has_permission(request, view) return super().has_permission(request, view)
else: else:
return True return True
@ -434,3 +433,10 @@ def switch_user_active_space(user, space):
return us return us
except ObjectDoesNotExist: except ObjectDoesNotExist:
return None return None
class IsReadOnlyDRF(permissions.BasePermission):
message = 'You cannot interact with this object as it is not owned by you!'
def has_permission(self, request, view):
return request.method in SAFE_METHODS

View File

@ -2,7 +2,7 @@ from django.core.cache import caches
from cookbook.helper.cache_helper import CacheHelper from cookbook.helper.cache_helper import CacheHelper
from cookbook.helper.unit_conversion_helper import UnitConversionHelper from cookbook.helper.unit_conversion_helper import UnitConversionHelper
from cookbook.models import PropertyType, Unit, Food, Property, Recipe, Step from cookbook.models import PropertyType
class FoodPropertyHelper: class FoodPropertyHelper:
@ -31,10 +31,12 @@ class FoodPropertyHelper:
if not property_types: if not property_types:
property_types = PropertyType.objects.filter(space=self.space).all() property_types = PropertyType.objects.filter(space=self.space).all()
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60) # cache is cleared on property type save signal so long duration is fine # cache is cleared on property type save signal so long duration is fine
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60)
for fpt in property_types: for fpt in property_types:
computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'icon': fpt.icon, 'description': fpt.description, 'unit': fpt.unit, 'food_values': {}, 'total_value': 0, 'missing_value': False} computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'description': fpt.description,
'unit': fpt.unit, 'order': fpt.order, 'food_values': {}, 'total_value': 0, 'missing_value': False}
uch = UnitConversionHelper(self.space) uch = UnitConversionHelper(self.space)
@ -53,7 +55,8 @@ class FoodPropertyHelper:
if c.unit == i.food.properties_food_unit: if c.unit == i.food.properties_food_unit:
found_property = True found_property = True
computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount
computed_properties[pt.id]['food_values'] = self.add_or_create(computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food) computed_properties[pt.id]['food_values'] = self.add_or_create(
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
if not found_property: if not found_property:
computed_properties[pt.id]['missing_value'] = True computed_properties[pt.id]['missing_value'] = True
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0} computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}

View File

@ -1,191 +0,0 @@
# import json
# import re
# from json import JSONDecodeError
# from urllib.parse import unquote
# from bs4 import BeautifulSoup
# from bs4.element import Tag
# from recipe_scrapers import scrape_html, scrape_me
# from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
# from recipe_scrapers._utils import get_host_name, normalize_string
# from cookbook.helper import recipe_url_import as helper
# from cookbook.helper.scrapers.scrapers import text_scraper
# def get_recipe_from_source(text, url, request):
# def build_node(k, v):
# if isinstance(v, dict):
# node = {
# 'name': k,
# 'value': k,
# 'children': get_children_dict(v)
# }
# elif isinstance(v, list):
# node = {
# 'name': k,
# 'value': k,
# 'children': get_children_list(v)
# }
# else:
# node = {
# 'name': k + ": " + normalize_string(str(v)),
# 'value': normalize_string(str(v))
# }
# return node
# def get_children_dict(children):
# kid_list = []
# for k, v in children.items():
# kid_list.append(build_node(k, v))
# return kid_list
# def get_children_list(children):
# kid_list = []
# for kid in children:
# if type(kid) == list:
# node = {
# 'name': "unknown list",
# 'value': "unknown list",
# 'children': get_children_list(kid)
# }
# kid_list.append(node)
# elif type(kid) == dict:
# for k, v in kid.items():
# kid_list.append(build_node(k, v))
# else:
# kid_list.append({
# 'name': normalize_string(str(kid)),
# 'value': normalize_string(str(kid))
# })
# return kid_list
# recipe_tree = []
# parse_list = []
# soup = BeautifulSoup(text, "html.parser")
# html_data = get_from_html(soup)
# images = get_images_from_source(soup, url)
# text = unquote(text)
# scrape = None
# if url and not text:
# try:
# scrape = scrape_me(url_path=url, wild_mode=True)
# except(NoSchemaFoundInWildMode):
# pass
# if not scrape:
# try:
# parse_list.append(remove_graph(json.loads(text)))
# if not url and 'url' in parse_list[0]:
# url = parse_list[0]['url']
# scrape = text_scraper("<script type='application/ld+json'>" + text + "</script>", url=url)
# except JSONDecodeError:
# for el in soup.find_all('script', type='application/ld+json'):
# el = remove_graph(el)
# if not url and 'url' in el:
# url = el['url']
# if type(el) == list:
# for le in el:
# parse_list.append(le)
# elif type(el) == dict:
# parse_list.append(el)
# for el in soup.find_all(type='application/json'):
# el = remove_graph(el)
# if type(el) == list:
# for le in el:
# parse_list.append(le)
# elif type(el) == dict:
# parse_list.append(el)
# scrape = text_scraper(text, url=url)
# recipe_json = helper.get_from_scraper(scrape, request)
# # TODO: DEPRECATE recipe_tree & html_data. first validate it isn't used anywhere
# for el in parse_list:
# temp_tree = []
# if isinstance(el, Tag):
# try:
# el = json.loads(el.string)
# except TypeError:
# continue
# for k, v in el.items():
# if isinstance(v, dict):
# node = {
# 'name': k,
# 'value': k,
# 'children': get_children_dict(v)
# }
# elif isinstance(v, list):
# node = {
# 'name': k,
# 'value': k,
# 'children': get_children_list(v)
# }
# else:
# node = {
# 'name': k + ": " + normalize_string(str(v)),
# 'value': normalize_string(str(v))
# }
# temp_tree.append(node)
# if '@type' in el and el['@type'] == 'Recipe':
# recipe_tree += [{'name': 'ld+json', 'children': temp_tree}]
# else:
# recipe_tree += [{'name': 'json', 'children': temp_tree}]
# return recipe_json, recipe_tree, html_data, images
# def get_from_html(soup):
# INVISIBLE_ELEMS = ('style', 'script', 'head', 'title')
# html = []
# for s in soup.strings:
# if ((s.parent.name not in INVISIBLE_ELEMS) and (len(s.strip()) > 0)):
# html.append(s)
# return html
# def get_images_from_source(soup, url):
# sources = ['src', 'srcset', 'data-src']
# images = []
# img_tags = soup.find_all('img')
# if url:
# site = get_host_name(url)
# prot = url.split(':')[0]
# urls = []
# for img in img_tags:
# for src in sources:
# try:
# urls.append(img[src])
# except KeyError:
# pass
# for u in urls:
# u = u.split('?')[0]
# filename = re.search(r'/([\w_-]+[.](jpg|jpeg|gif|png))$', u)
# if filename:
# if (('http' not in u) and (url)):
# # sometimes an image source can be relative
# # if it is provide the base url
# u = '{}://{}{}'.format(prot, site, u)
# if 'http' in u:
# images.append(u)
# return images
# def remove_graph(el):
# # recipes type might be wrapped in @graph type
# if isinstance(el, Tag):
# try:
# el = json.loads(el.string)
# if '@graph' in el:
# for x in el['@graph']:
# if '@type' in x and x['@type'] == 'Recipe':
# el = x
# except (TypeError, JSONDecodeError):
# pass
# return el

View File

@ -1,14 +1,11 @@
import json import json
from collections import Counter
from datetime import date, timedelta from datetime import date, timedelta
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
from django.core.cache import cache, caches from django.core.cache import cache
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, from django.db.models import Avg, Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Value, When
When)
from django.db.models.functions import Coalesce, Lower, Substr from django.db.models.functions import Coalesce, Lower, Substr
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
@ -17,21 +14,25 @@ from cookbook.models import (CookLog, CustomFilter, Food, Keyword, Recipe, Searc
from recipes import settings from recipes import settings
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering # TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
class RecipeSearch(): class RecipeSearch():
_postgres = settings.DATABASES['default']['ENGINE'] in [ _postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
def __init__(self, request, **params): def __init__(self, request, **params):
self._request = request self._request = request
self._queryset = None self._queryset = None
if f := params.get('filter', None): if f := params.get('filter', None):
custom_filter = CustomFilter.objects.filter(id=f, space=self._request.space).filter(Q(created_by=self._request.user) | custom_filter = (
Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)).first() CustomFilter.objects.filter(id=f, space=self._request.space)
.filter(Q(created_by=self._request.user) | Q(shared=self._request.user) | Q(recipebook__shared=self._request.user))
.first()
)
if custom_filter: if custom_filter:
self._params = {**json.loads(custom_filter.search)} self._params = {**json.loads(custom_filter.search)}
self._original_params = {**(params or {})} self._original_params = {**(params or {})}
# json.loads casts rating as an integer, expecting string
if isinstance(self._params.get('rating', None), int):
self._params['rating'] = str(self._params['rating'])
else: else:
self._params = {**(params or {})} self._params = {**(params or {})}
else: else:
@ -85,9 +86,9 @@ class RecipeSearch():
self._viewedon = self._params.get('viewedon', None) self._viewedon = self._params.get('viewedon', None)
self._makenow = self._params.get('makenow', None) self._makenow = self._params.get('makenow', None)
# this supports hidden feature to find recipes missing X ingredients # this supports hidden feature to find recipes missing X ingredients
if type(self._makenow) == bool and self._makenow == True: if isinstance(self._makenow, bool) and self._makenow == True:
self._makenow = 0 self._makenow = 0
elif type(self._makenow) == str and self._makenow in ["yes", "true"]: elif isinstance(self._makenow, str) and self._makenow in ["yes", "true"]:
self._makenow = 0 self._makenow = 0
else: else:
try: try:
@ -98,24 +99,18 @@ class RecipeSearch():
self._search_type = self._search_prefs.search or 'plain' self._search_type = self._search_prefs.search or 'plain'
if self._string: if self._string:
if self._postgres: if self._postgres:
self._unaccent_include = self._search_prefs.unaccent.values_list( self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
'field', flat=True)
else: else:
self._unaccent_include = [] self._unaccent_include = []
self._icontains_include = [ self._icontains_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)] self._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
self._istartswith_include = [
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
self._trigram_include = None self._trigram_include = None
self._fulltext_include = None self._fulltext_include = None
self._trigram = False self._trigram = False
if self._postgres and self._string: if self._postgres and self._string:
self._language = DICTIONARY.get( self._language = DICTIONARY.get(translation.get_language(), 'simple')
translation.get_language(), 'simple') self._trigram_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
self._trigram_include = [ self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
self._fulltext_include = self._search_prefs.fulltext.values_list(
'field', flat=True) or None
if self._search_type not in ['websearch', 'raw'] and self._trigram_include: if self._search_type not in ['websearch', 'raw'] and self._trigram_include:
self._trigram = True self._trigram = True
@ -150,7 +145,7 @@ class RecipeSearch():
self.unit_filters(units=self._units) self.unit_filters(units=self._units)
self._makenow_filter(missing=self._makenow) self._makenow_filter(missing=self._makenow)
self.string_filters(string=self._string) self.string_filters(string=self._string)
return self._queryset.filter(space=self._request.space).distinct().order_by(*self.orderby) return self._queryset.filter(space=self._request.space).order_by(*self.orderby)
def _sort_includes(self, *args): def _sort_includes(self, *args):
for x in args: for x in args:
@ -166,7 +161,7 @@ class RecipeSearch():
else: else:
order = [] order = []
# TODO add userpreference for default sort order and replace '-favorite' # TODO add userpreference for default sort order and replace '-favorite'
default_order = ['-name'] default_order = ['name']
# recent and new_recipe are always first; they float a few recipes to the top # recent and new_recipe are always first; they float a few recipes to the top
if self._num_recent: if self._num_recent:
order += ['-recent'] order += ['-recent']
@ -175,7 +170,6 @@ class RecipeSearch():
# if a sort order is provided by user - use that order # if a sort order is provided by user - use that order
if self._sort_order: if self._sort_order:
if not isinstance(self._sort_order, list): if not isinstance(self._sort_order, list):
order += [self._sort_order] order += [self._sort_order]
else: else:
@ -215,24 +209,18 @@ class RecipeSearch():
self._queryset = self._queryset.filter(query_filter).distinct() self._queryset = self._queryset.filter(query_filter).distinct()
if self._fulltext_include: if self._fulltext_include:
if self._fuzzy_match is None: if self._fuzzy_match is None:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0))
score=Coalesce(Max(self.search_rank), 0.0))
else: else:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0))
rank=Coalesce(Max(self.search_rank), 0.0))
if self._fuzzy_match is not None: if self._fuzzy_match is not None:
simularity = self._fuzzy_match.filter( simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
pk=OuterRef('pk')).values('simularity')
if not self._fulltext_include: if not self._fulltext_include:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
score=Coalesce(Subquery(simularity), 0.0))
else: else:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
simularity=Coalesce(Subquery(simularity), 0.0))
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None: if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
self._queryset = self._queryset.annotate( self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
score=F('rank') + F('simularity'))
else: else:
query_filter = Q() query_filter = Q()
for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]: for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]:
@ -241,78 +229,69 @@ class RecipeSearch():
def _cooked_on_filter(self, cooked_date=None): def _cooked_on_filter(self, cooked_date=None):
if self._sort_includes('lastcooked') or cooked_date: if self._sort_includes('lastcooked') or cooked_date:
lessthan = self._sort_includes( lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1]
'-lastcooked') or '-' in (cooked_date or [])[:1]
if lessthan: if lessthan:
default = timezone.now() - timedelta(days=100000) default = timezone.now() - timedelta(days=100000)
else: else:
default = timezone.now() default = timezone.now()
self._queryset = self._queryset.annotate(lastcooked=Coalesce( self._queryset = self._queryset.annotate(
Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default))) lastcooked=Coalesce(Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default))
)
if cooked_date is None: if cooked_date is None:
return return
cooked_date = date(*[int(x) cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != ''])
for x in cooked_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
def _created_on_filter(self, created_date=None): def _created_on_filter(self, created_date=None):
if created_date is None: if created_date is None:
return return
lessthan = '-' in created_date[:1] lessthan = '-' in created_date[:1]
created_date = date(*[int(x) created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
for x in created_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(created_at__date__lte=created_date)
created_at__date__lte=created_date)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(created_at__date__gte=created_date)
created_at__date__gte=created_date)
def _updated_on_filter(self, updated_date=None): def _updated_on_filter(self, updated_date=None):
if updated_date is None: if updated_date is None:
return return
lessthan = '-' in updated_date[:1] lessthan = '-' in updated_date[:1]
updated_date = date(*[int(x) updated_date = date(*[int(x)for x in updated_date.split('-') if x != ''])
for x in updated_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
updated_at__date__lte=updated_date)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(updated_at__date__gte=updated_date)
updated_at__date__gte=updated_date)
def _viewed_on_filter(self, viewed_date=None): def _viewed_on_filter(self, viewed_date=None):
if self._sort_includes('lastviewed') or viewed_date: if self._sort_includes('lastviewed') or viewed_date:
longTimeAgo = timezone.now() - timedelta(days=100000) longTimeAgo = timezone.now() - timedelta(days=100000)
self._queryset = self._queryset.annotate(lastviewed=Coalesce( self._queryset = self._queryset.annotate(
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo))) lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo))
)
if viewed_date is None: if viewed_date is None:
return return
lessthan = '-' in viewed_date[:1] lessthan = '-' in viewed_date[:1]
viewed_date = date(*[int(x) viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != ''])
for x in viewed_date.split('-') if x != ''])
if lessthan: if lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
def _new_recipes(self, new_days=7): def _new_recipes(self, new_days=7):
# TODO make new days a user-setting # TODO make new days a user-setting
if not self._new: if not self._new:
return return
self._queryset = ( self._queryset = self._queryset.annotate(
self._queryset.annotate(new_recipe=Case( new_recipe=Case(
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), default=Value(0), )) When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')),
default=Value(0),
)
) )
def _recently_viewed(self, num_recent=None): def _recently_viewed(self, num_recent=None):
@ -322,34 +301,35 @@ class RecipeSearch():
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0))) Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0)))
return return
num_recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values( num_recent_recipes = (
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent] ViewLog.objects.filter(created_by=self._request.user, space=self._request.space)
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When( .values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0))) )
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
def _favorite_recipes(self, times_cooked=None): def _favorite_recipes(self, times_cooked=None):
if self._sort_includes('favorite') or times_cooked: if self._sort_includes('favorite') or times_cooked:
less_than = '-' in (times_cooked or [] less_than = '-' in (str(times_cooked) or []) and not self._sort_includes('-favorite')
) and not self._sort_includes('-favorite')
if less_than: if less_than:
default = 1000 default = 1000
else: else:
default = 0 default = 0
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk') favorite_recipes = (
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count') CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk'))
self._queryset = self._queryset.annotate( .values('recipe')
favorite=Coalesce(Subquery(favorite_recipes), default)) .annotate(count=Count('pk', distinct=True))
.values('count')
)
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
if times_cooked is None: if times_cooked is None:
return return
if times_cooked == '0': if times_cooked == '0':
self._queryset = self._queryset.filter(favorite=0) self._queryset = self._queryset.filter(favorite=0)
elif less_than: elif less_than:
self._queryset = self._queryset.filter(favorite__lte=int( self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0)
times_cooked.replace('-', ''))).exclude(favorite=0)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
favorite__gte=int(times_cooked))
def keyword_filters(self, **kwargs): def keyword_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]): if all([kwargs[x] is None for x in kwargs]):
@ -382,8 +362,7 @@ class RecipeSearch():
else: else:
self._queryset = self._queryset.filter(f_and) self._queryset = self._queryset.filter(f_and)
if 'not' in kw_filter: if 'not' in kw_filter:
self._queryset = self._queryset.exclude( self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
id__in=recipes.values('id'))
def food_filters(self, **kwargs): def food_filters(self, **kwargs):
if all([kwargs[x] is None for x in kwargs]): if all([kwargs[x] is None for x in kwargs]):
@ -397,8 +376,7 @@ class RecipeSearch():
foods = Food.objects.filter(pk__in=kwargs[fd_filter]) foods = Food.objects.filter(pk__in=kwargs[fd_filter])
if 'or' in fd_filter: if 'or' in fd_filter:
if self._include_children: if self._include_children:
f_or = Q( f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods))
steps__ingredients__food__in=Food.include_descendants(foods))
else: else:
f_or = Q(steps__ingredients__food__in=foods) f_or = Q(steps__ingredients__food__in=foods)
@ -410,8 +388,7 @@ class RecipeSearch():
recipes = Recipe.objects.all() recipes = Recipe.objects.all()
for food in foods: for food in foods:
if self._include_children: if self._include_children:
f_and = Q( f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self())
steps__ingredients__food__in=food.get_descendants_and_self())
else: else:
f_and = Q(steps__ingredients__food=food) f_and = Q(steps__ingredients__food=food)
if 'not' in fd_filter: if 'not' in fd_filter:
@ -419,8 +396,7 @@ class RecipeSearch():
else: else:
self._queryset = self._queryset.filter(f_and) self._queryset = self._queryset.filter(f_and)
if 'not' in fd_filter: if 'not' in fd_filter:
self._queryset = self._queryset.exclude( self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
id__in=recipes.values('id'))
def unit_filters(self, units=None, operator=True): def unit_filters(self, units=None, operator=True):
if operator != True: if operator != True:
@ -429,27 +405,25 @@ class RecipeSearch():
return return
if not isinstance(units, list): if not isinstance(units, list):
units = [units] units = [units]
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
steps__ingredients__unit__in=units)
def rating_filter(self, rating=None): def rating_filter(self, rating=None):
if rating or self._sort_includes('rating'): if rating or self._sort_includes('rating'):
lessthan = self._sort_includes('-rating') or '-' in (rating or []) lessthan = '-' in (rating or [])
if lessthan: reverse = 'rating' in (self._sort_order or []) and '-rating' not in (self._sort_order or [])
if lessthan or reverse:
default = 100 default = 100
else: else:
default = 0 default = 0
# TODO make ratings a settings user-only vs all-users # TODO make ratings a settings user-only vs all-users
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When( self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
if rating is None: if rating is None:
return return
if rating == '0': if rating == '0':
self._queryset = self._queryset.filter(rating=0) self._queryset = self._queryset.filter(rating=0)
elif lessthan: elif lessthan:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0)
rating__lte=int(rating[1:])).exclude(rating=0)
else: else:
self._queryset = self._queryset.filter(rating__gte=int(rating)) self._queryset = self._queryset.filter(rating__gte=int(rating))
@ -477,14 +451,11 @@ class RecipeSearch():
recipes = Recipe.objects.all() recipes = Recipe.objects.all()
for book in kwargs[bk_filter]: for book in kwargs[bk_filter]:
if 'not' in bk_filter: if 'not' in bk_filter:
recipes = recipes.filter( recipes = recipes.filter(recipebookentry__book__id=book)
recipebookentry__book__id=book)
else: else:
self._queryset = self._queryset.filter( self._queryset = self._queryset.filter(recipebookentry__book__id=book)
recipebookentry__book__id=book)
if 'not' in bk_filter: if 'not' in bk_filter:
self._queryset = self._queryset.exclude( self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
id__in=recipes.values('id'))
def step_filters(self, steps=None, operator=True): def step_filters(self, steps=None, operator=True):
if operator != True: if operator != True:
@ -503,25 +474,20 @@ class RecipeSearch():
rank = [] rank = []
if 'name' in self._fulltext_include: if 'name' in self._fulltext_include:
vectors.append('name_search_vector') vectors.append('name_search_vector')
rank.append(SearchRank('name_search_vector', rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True))
self.search_query, cover_density=True))
if 'description' in self._fulltext_include: if 'description' in self._fulltext_include:
vectors.append('desc_search_vector') vectors.append('desc_search_vector')
rank.append(SearchRank('desc_search_vector', rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True))
self.search_query, cover_density=True))
if 'steps__instruction' in self._fulltext_include: if 'steps__instruction' in self._fulltext_include:
vectors.append('steps__search_vector') vectors.append('steps__search_vector')
rank.append(SearchRank('steps__search_vector', rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True))
self.search_query, cover_density=True))
if 'keywords__name' in self._fulltext_include: if 'keywords__name' in self._fulltext_include:
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields # explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
vectors.append('keywords__name__unaccent') vectors.append('keywords__name__unaccent')
rank.append(SearchRank('keywords__name__unaccent', rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
self.search_query, cover_density=True))
if 'steps__ingredients__food__name' in self._fulltext_include: if 'steps__ingredients__food__name' in self._fulltext_include:
vectors.append('steps__ingredients__food__name__unaccent') vectors.append('steps__ingredients__food__name__unaccent')
rank.append(SearchRank('steps__ingredients__food__name', rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True))
self.search_query, cover_density=True))
for r in rank: for r in rank:
if self.search_rank is None: if self.search_rank is None:
@ -529,8 +495,7 @@ class RecipeSearch():
else: else:
self.search_rank += r self.search_rank += r
# modifying queryset will annotation creates duplicate results # modifying queryset will annotation creates duplicate results
self._filters.append(Q(id__in=Recipe.objects.annotate( self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
def build_text_filters(self, string=None): def build_text_filters(self, string=None):
if not string: if not string:
@ -555,15 +520,19 @@ class RecipeSearch():
trigram += TrigramSimilarity(f, self._string) trigram += TrigramSimilarity(f, self._string)
else: else:
trigram = TrigramSimilarity(f, self._string) trigram = TrigramSimilarity(f, self._string)
self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct( self._fuzzy_match = (
).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold) Recipe.objects.annotate(trigram=trigram)
.distinct()
.annotate(simularity=Max('trigram'))
.values('id', 'simularity')
.filter(simularity__gt=self._search_prefs.trigram_threshold)
)
self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))] self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))]
def _makenow_filter(self, missing=None): def _makenow_filter(self, missing=None):
if missing is None or (type(missing) == bool and missing == False): if missing is None or (isinstance(missing, bool) and missing == False):
return return
shopping_users = [ shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
*self._request.user.get_shopping_share(), self._request.user]
onhand_filter = ( onhand_filter = (
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
@ -573,264 +542,40 @@ class RecipeSearch():
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users)) | Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
) )
makenow_recipes = Recipe.objects.annotate( makenow_recipes = Recipe.objects.annotate(
count_food=Count('steps__ingredients__food__pk', filter=Q( count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
steps__ingredients__food__isnull=False), distinct=True), count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, distinct=True),
count_onhand=Count('steps__ingredients__food__pk', count_ignore_shopping=Count(
filter=onhand_filter, distinct=True), 'steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True, steps__ingredients__food__recipe__isnull=True), distinct=True
count_ignore_shopping=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True, ),
steps__ingredients__food__recipe__isnull=True), distinct=True), has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)),
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter( has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0))
shopping_users), then=Value(1)), default=Value(0)), ).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood__lte=missing)
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter( self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))
shopping_users), then=Value(1)), default=Value(0))
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing)
self._queryset = self._queryset.distinct().filter(
id__in=makenow_recipes.values('id'))
@staticmethod @staticmethod
def __children_substitute_filter(shopping_users=None): def __children_substitute_filter(shopping_users=None):
children_onhand_subquery = Food.objects.filter( children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users)
path__startswith=OuterRef('path'), return (
depth__gt=OuterRef('depth'), Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes
onhand_users__in=shopping_users Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
)
.exclude(depth=1, numchild=0)
.filter(substitute_children=True)
.annotate(child_onhand_count=Exists(children_onhand_subquery))
.filter(child_onhand_count=True)
) )
return Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes
Q(onhand_users__in=shopping_users)
| Q(ignore_shopping=True, recipe__isnull=True)
| Q(substitute__onhand_users__in=shopping_users)
).exclude(depth=1, numchild=0
).filter(substitute_children=True
).annotate(child_onhand_count=Exists(children_onhand_subquery)
).filter(child_onhand_count=True)
@staticmethod @staticmethod
def __sibling_substitute_filter(shopping_users=None): def __sibling_substitute_filter(shopping_users=None):
sibling_onhand_subquery = Food.objects.filter( sibling_onhand_subquery = Food.objects.filter(
path__startswith=Substr( path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), depth=OuterRef('depth'), onhand_users__in=shopping_users
OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
depth=OuterRef('depth'),
onhand_users__in=shopping_users
) )
return Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes return (
Q(onhand_users__in=shopping_users) Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes
| Q(ignore_shopping=True, recipe__isnull=True) Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
| Q(substitute__onhand_users__in=shopping_users) )
).exclude(depth=1, numchild=0 .exclude(depth=1, numchild=0)
).filter(substitute_siblings=True .filter(substitute_siblings=True)
).annotate(sibling_onhand=Exists(sibling_onhand_subquery) .annotate(sibling_onhand=Exists(sibling_onhand_subquery))
).filter(sibling_onhand=True) .filter(sibling_onhand=True)
class RecipeFacet():
class CacheEmpty(Exception):
pass
def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600):
if hash_key is None and queryset is None:
raise ValueError(_("One of queryset or hash_key must be provided"))
self._request = request
self._queryset = queryset
self.hash_key = hash_key or str(hash(self._queryset.query))
self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
self._cache_timeout = cache_timeout
self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
if self._cache is None and self._queryset is None:
raise self.CacheEmpty("No queryset provided and cache empty")
self.Keywords = self._cache.get('Keywords', None)
self.Foods = self._cache.get('Foods', None)
self.Books = self._cache.get('Books', None)
self.Ratings = self._cache.get('Ratings', None)
# TODO Move Recent to recipe annotation/serializer: requrires change in RecipeSearch(), RecipeSearchView.vue and serializer
self.Recent = self._cache.get('Recent', None)
if self._queryset is not None:
self._recipe_list = list(
self._queryset.values_list('id', flat=True))
self._search_params = {
'keyword_list': self._request.query_params.getlist('keywords', []),
'food_list': self._request.query_params.getlist('foods', []),
'book_list': self._request.query_params.getlist('book', []),
'search_keywords_or': str2bool(self._request.query_params.get('keywords_or', True)),
'search_foods_or': str2bool(self._request.query_params.get('foods_or', True)),
'search_books_or': str2bool(self._request.query_params.get('books_or', True)),
'space': self._request.space,
}
elif self.hash_key is not None:
self._recipe_list = self._cache.get('recipe_list', [])
self._search_params = {
'keyword_list': self._cache.get('keyword_list', None),
'food_list': self._cache.get('food_list', None),
'book_list': self._cache.get('book_list', None),
'search_keywords_or': self._cache.get('search_keywords_or', None),
'search_foods_or': self._cache.get('search_foods_or', None),
'search_books_or': self._cache.get('search_books_or', None),
'space': self._cache.get('space', None),
}
self._cache = {
**self._search_params,
'recipe_list': self._recipe_list,
'Ratings': self.Ratings,
'Recent': self.Recent,
'Keywords': self.Keywords,
'Foods': self.Foods,
'Books': self.Books
}
caches['default'].set(self._SEARCH_CACHE_KEY,
self._cache, self._cache_timeout)
def get_facets(self, from_cache=False):
if from_cache:
return {
'cache_key': self.hash_key or '',
'Ratings': self.Ratings or {},
'Recent': self.Recent or [],
'Keywords': self.Keywords or [],
'Foods': self.Foods or [],
'Books': self.Books or []
}
return {
'cache_key': self.hash_key,
'Ratings': self.get_ratings(),
'Recent': self.get_recent(),
'Keywords': self.get_keywords(),
'Foods': self.get_foods(),
'Books': self.get_books()
}
def set_cache(self, key, value):
self._cache = {**self._cache, key: value}
caches['default'].set(
self._SEARCH_CACHE_KEY,
self._cache,
self._cache_timeout
) )
def get_books(self):
if self.Books is None:
self.Books = []
return self.Books
def get_keywords(self):
if self.Keywords is None:
if self._search_params['search_keywords_or']:
keywords = Keyword.objects.filter(
space=self._request.space).distinct()
else:
keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only
keywords = self._keyword_queryset(keywords)
self.Keywords = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords)
return self.Keywords
def get_foods(self):
if self.Foods is None:
# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
if self._search_params['search_foods_or']:
foods = Food.objects.filter(
space=self._request.space).distinct()
else:
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(
depth=1)).filter(space=self._request.space).distinct()
# set keywords to root objects only
foods = self._food_queryset(foods)
self.Foods = [{**x, 'children': None}
if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods)
return self.Foods
def get_ratings(self):
if self.Ratings is None:
if not self._request.space.demo and self._request.space.show_facet_count:
if self._queryset is None:
self._queryset = Recipe.objects.filter(
id__in=self._recipe_list)
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When(
cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
self.Ratings = dict(Counter(r.rating for r in rating_qs))
else:
self.Rating = {}
self.set_cache('Ratings', self.Ratings)
return self.Ratings
def get_recent(self):
if self.Recent is None:
# TODO make days of recent recipe a setting
recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space, created_at__gte=timezone.now() - timedelta(days=14)
).values_list('recipe__pk', flat=True)
self.Recent = list(recent_recipes)
self.set_cache('Recent', self.Recent)
return self.Recent
def add_food_children(self, id):
try:
food = Food.objects.get(id=id)
nodes = food.get_ancestors()
except Food.DoesNotExist:
return self.get_facets()
foods = self._food_queryset(food.get_children(), food)
deep_search = self.Foods
for node in nodes:
index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(
deep_search) if x["id"] == food.id), None)
deep_search[index]['children'] = [
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)]
self.set_cache('Foods', self.Foods)
return self.get_facets()
def add_keyword_children(self, id):
try:
keyword = Keyword.objects.get(id=id)
nodes = keyword.get_ancestors()
except Keyword.DoesNotExist:
return self.get_facets()
keywords = self._keyword_queryset(keyword.get_children(), keyword)
deep_search = self.Keywords
for node in nodes:
index = next((i for i, x in enumerate(
deep_search) if x["id"] == node.id), None)
deep_search = deep_search[index]['children']
index = next((i for i, x in enumerate(deep_search)
if x["id"] == keyword.id), None)
deep_search[index]['children'] = [
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)]
self.set_cache('Keywords', self.Keywords)
return self.get_facets()
def _recipe_count_queryset(self, field, depth=1, steplen=4):
return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path'), f'{field}__depth__gte': depth}, id__in=self._recipe_list, space=self._request.space
).annotate(count=Coalesce(Func('pk', function='Count'), 0)).values('count')
def _keyword_queryset(self, queryset, keyword=None):
depth = getattr(keyword, 'depth', 0) + 1
steplen = depth * Keyword.steplen
if not self._request.space.demo and self._request.space.show_facet_count:
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0)
).filter(depth=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
else:
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
def _food_queryset(self, queryset, food=None):
depth = getattr(food, 'depth', 0) + 1
steplen = depth * Food.steplen
if not self._request.space.demo and self._request.space.show_facet_count:
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0)
).filter(depth__lte=depth, count__gt=0
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
else:
return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())

View File

@ -1,9 +1,7 @@
# import random
import re import re
import traceback import traceback
from html import unescape from html import unescape
from django.core.cache import caches
from django.utils.dateparse import parse_duration from django.utils.dateparse import parse_duration
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from isodate import parse_duration as iso_parse_duration from isodate import parse_duration as iso_parse_duration
@ -11,20 +9,37 @@ from isodate.isoerror import ISO8601Error
from pytube import YouTube from pytube import YouTube
from recipe_scrapers._utils import get_host_name, get_minutes from recipe_scrapers._utils import get_host_name, get_minutes
# from cookbook.helper import recipe_url_import as helper from cookbook.helper.automation_helper import AutomationEngine
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Automation, Keyword, PropertyType from cookbook.models import Automation, Keyword, PropertyType
# from unicodedata import decomposition
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
def get_from_scraper(scrape, request): def get_from_scraper(scrape, request):
# converting the scrape_me object to the existing json format based on ld+json # converting the scrape_me object to the existing json format based on ld+json
recipe_json = {}
recipe_json = {
'steps': [],
'internal': True
}
keywords = []
# assign source URL
try:
source_url = scrape.canonical_url()
except Exception:
try:
source_url = scrape.url
except Exception:
pass
if source_url:
recipe_json['source_url'] = source_url
try:
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
except Exception:
recipe_json['source_url'] = ''
automation_engine = AutomationEngine(request, source=recipe_json.get('source_url'))
# assign recipe name
try: try:
recipe_json['name'] = parse_name(scrape.title()[:128] or None) recipe_json['name'] = parse_name(scrape.title()[:128] or None)
except Exception: except Exception:
@ -38,6 +53,10 @@ def get_from_scraper(scrape, request):
if isinstance(recipe_json['name'], list) and len(recipe_json['name']) > 0: if isinstance(recipe_json['name'], list) and len(recipe_json['name']) > 0:
recipe_json['name'] = recipe_json['name'][0] recipe_json['name'] = recipe_json['name'][0]
recipe_json['name'] = automation_engine.apply_regex_replace_automation(recipe_json['name'], Automation.NAME_REPLACE)
# assign recipe description
# TODO notify user about limit if reached - >256 description will be truncated
try: try:
description = scrape.description() or None description = scrape.description() or None
except Exception: except Exception:
@ -48,16 +67,20 @@ def get_from_scraper(scrape, request):
except Exception: except Exception:
description = '' description = ''
recipe_json['internal'] = True recipe_json['description'] = parse_description(description)
recipe_json['description'] = automation_engine.apply_regex_replace_automation(recipe_json['description'], Automation.DESCRIPTION_REPLACE)
# assign servings attributes
try: try:
servings = scrape.schema.data.get('recipeYield') or 1 # dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly # dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
servings = scrape.schema.data.get('recipeYield') or 1
except Exception: except Exception:
servings = 1 servings = 1
recipe_json['servings'] = parse_servings(servings) recipe_json['servings'] = parse_servings(servings)
recipe_json['servings_text'] = parse_servings_text(servings) recipe_json['servings_text'] = parse_servings_text(servings)
# assign time attributes
try: try:
recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0 recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0
except Exception: except Exception:
@ -82,6 +105,7 @@ def get_from_scraper(scrape, request):
except Exception: except Exception:
pass pass
# assign image
try: try:
recipe_json['image'] = parse_image(scrape.image()) or None recipe_json['image'] = parse_image(scrape.image()) or None
except Exception: except Exception:
@ -92,7 +116,7 @@ def get_from_scraper(scrape, request):
except Exception: except Exception:
recipe_json['image'] = '' recipe_json['image'] = ''
keywords = [] # assign keywords
try: try:
if scrape.schema.data.get("keywords"): if scrape.schema.data.get("keywords"):
keywords += listify_keywords(scrape.schema.data.get("keywords")) keywords += listify_keywords(scrape.schema.data.get("keywords"))
@ -117,20 +141,6 @@ def get_from_scraper(scrape, request):
except Exception: except Exception:
pass pass
try:
source_url = scrape.canonical_url()
except Exception:
try:
source_url = scrape.url
except Exception:
pass
if source_url:
recipe_json['source_url'] = source_url
try:
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
except Exception:
recipe_json['source_url'] = ''
try: try:
if scrape.author(): if scrape.author():
keywords.append(scrape.author()) keywords.append(scrape.author())
@ -138,33 +148,24 @@ def get_from_scraper(scrape, request):
pass pass
try: try:
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space) recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request)
except AttributeError: except AttributeError:
recipe_json['keywords'] = keywords recipe_json['keywords'] = keywords
ingredient_parser = IngredientParser(request, True) ingredient_parser = IngredientParser(request, True)
recipe_json['steps'] = [] # assign steps
try: try:
for i in parse_instructions(scrape.instructions()): for i in parse_instructions(scrape.instructions()):
recipe_json['steps'].append({'instruction': i, 'ingredients': [], }) recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients, })
except Exception: except Exception:
pass pass
if len(recipe_json['steps']) == 0: if len(recipe_json['steps']) == 0:
recipe_json['steps'].append({'instruction': '', 'ingredients': [], }) recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
parsed_description = parse_description(description) recipe_json['description'] = recipe_json['description'][:512]
# TODO notify user about limit if reached if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards
# limits exist to limit the attack surface for dos style attacks recipe_json['steps'][0]['instruction'] = f"*{recipe_json['description']}* \n\n" + recipe_json['steps'][0]['instruction']
automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').all().order_by('order')[:512]
for a in automations:
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
if len(parsed_description) > 256: # split at 256 as long descriptions don't look good on recipe cards
recipe_json['steps'][0]['instruction'] = f'*{parsed_description}* \n\n' + recipe_json['steps'][0]['instruction']
else:
recipe_json['description'] = parsed_description[:512]
try: try:
for x in scrape.ingredients(): for x in scrape.ingredients():
@ -205,12 +206,9 @@ def get_from_scraper(scrape, request):
traceback.print_exc() traceback.print_exc()
pass pass
if 'source_url' in recipe_json and recipe_json['source_url']: for s in recipe_json['steps']:
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512] s['instruction'] = automation_engine.apply_regex_replace_automation(s['instruction'], Automation.INSTRUCTION_REPLACE)
for a in automations: # re.sub(a.param_2, a.param_3, s['instruction'])
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
for s in recipe_json['steps']:
s['instruction'] = re.sub(a.param_2, a.param_3, s['instruction'])
return recipe_json return recipe_json
@ -261,10 +259,14 @@ def get_from_youtube_scraper(url, request):
} }
try: try:
video = YouTube(url=url) automation_engine = AutomationEngine(request, source=url)
default_recipe_json['name'] = video.title 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['image'] = video.thumbnail_url
default_recipe_json['steps'][0]['instruction'] = video.description if video.description:
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
except Exception: except Exception:
pass pass
@ -272,7 +274,7 @@ def get_from_youtube_scraper(url, request):
def parse_name(name): def parse_name(name):
if type(name) == list: if isinstance(name, list):
try: try:
name = name[0] name = name[0]
except Exception: except Exception:
@ -316,16 +318,16 @@ def parse_instructions(instructions):
""" """
instruction_list = [] instruction_list = []
if type(instructions) == list: if isinstance(instructions, list):
for i in instructions: for i in instructions:
if type(i) == str: if isinstance(i, str):
instruction_list.append(clean_instruction_string(i)) instruction_list.append(clean_instruction_string(i))
else: else:
if 'text' in i: if 'text' in i:
instruction_list.append(clean_instruction_string(i['text'])) instruction_list.append(clean_instruction_string(i['text']))
elif 'itemListElement' in i: elif 'itemListElement' in i:
for ile in i['itemListElement']: for ile in i['itemListElement']:
if type(ile) == str: if isinstance(ile, str):
instruction_list.append(clean_instruction_string(ile)) instruction_list.append(clean_instruction_string(ile))
elif 'text' in ile: elif 'text' in ile:
instruction_list.append(clean_instruction_string(ile['text'])) instruction_list.append(clean_instruction_string(ile['text']))
@ -341,13 +343,13 @@ def parse_image(image):
# check if list of images is returned, take first if so # check if list of images is returned, take first if so
if not image: if not image:
return None return None
if type(image) == list: if isinstance(image, list):
for pic in image: for pic in image:
if (type(pic) == str) and (pic[:4] == 'http'): if (isinstance(pic, str)) and (pic[:4] == 'http'):
image = pic image = pic
elif 'url' in pic: elif 'url' in pic:
image = pic['url'] image = pic['url']
elif type(image) == dict: elif isinstance(image, dict):
if 'url' in image: if 'url' in image:
image = image['url'] image = image['url']
@ -358,12 +360,12 @@ def parse_image(image):
def parse_servings(servings): def parse_servings(servings):
if type(servings) == str: if isinstance(servings, str):
try: try:
servings = int(re.search(r'\d+', servings).group()) servings = int(re.search(r'\d+', servings).group())
except AttributeError: except AttributeError:
servings = 1 servings = 1
elif type(servings) == list: elif isinstance(servings, list):
try: try:
servings = int(re.findall(r'\b\d+\b', servings[0])[0]) servings = int(re.findall(r'\b\d+\b', servings[0])[0])
except KeyError: except KeyError:
@ -372,12 +374,12 @@ def parse_servings(servings):
def parse_servings_text(servings): def parse_servings_text(servings):
if type(servings) == str: if isinstance(servings, str):
try: try:
servings = re.sub("\d+", '', servings).strip() servings = re.sub("\\d+", '', servings).strip()
except Exception: except Exception:
servings = '' servings = ''
if type(servings) == list: if isinstance(servings, list):
try: try:
servings = parse_servings_text(servings[1]) servings = parse_servings_text(servings[1])
except Exception: except Exception:
@ -394,7 +396,7 @@ def parse_time(recipe_time):
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60) recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
except ISO8601Error: except ISO8601Error:
try: try:
if (type(recipe_time) == list and len(recipe_time) > 0): if (isinstance(recipe_time, list) and len(recipe_time) > 0):
recipe_time = recipe_time[0] recipe_time = recipe_time[0]
recipe_time = round(parse_duration(recipe_time).seconds / 60) recipe_time = round(parse_duration(recipe_time).seconds / 60)
except AttributeError: except AttributeError:
@ -403,18 +405,9 @@ def parse_time(recipe_time):
return recipe_time return recipe_time
def parse_keywords(keyword_json, space): def parse_keywords(keyword_json, request):
keywords = [] keywords = []
keyword_aliases = {} automation_engine = AutomationEngine(request)
# retrieve keyword automation cache if it exists, otherwise build from database
KEYWORD_CACHE_KEY = f'automation_keyword_alias_{space.pk}'
if c := caches['default'].get(KEYWORD_CACHE_KEY, None):
self.food_aliases = c
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
keyword_aliases[a.param_1] = a.param_2
caches['default'].set(KEYWORD_CACHE_KEY, keyword_aliases, 30)
# keywords as list # keywords as list
for kw in keyword_json: for kw in keyword_json:
@ -422,12 +415,8 @@ def parse_keywords(keyword_json, space):
# if alias exists use that instead # if alias exists use that instead
if len(kw) != 0: if len(kw) != 0:
if keyword_aliases: kw = automation_engine.apply_keyword_automation(kw)
try: if k := Keyword.objects.filter(name__iexact=kw, space=request.space).first():
kw = keyword_aliases[kw]
except KeyError:
pass
if k := Keyword.objects.filter(name=kw, space=space).first():
keywords.append({'label': str(k), 'name': k.name, 'id': k.id}) keywords.append({'label': str(k), 'name': k.name, 'id': k.id})
else: else:
keywords.append({'label': kw, 'name': kw}) keywords.append({'label': kw, 'name': kw})
@ -438,15 +427,15 @@ def parse_keywords(keyword_json, space):
def listify_keywords(keyword_list): def listify_keywords(keyword_list):
# keywords as string # keywords as string
try: try:
if type(keyword_list[0]) == dict: if isinstance(keyword_list[0], dict):
return keyword_list return keyword_list
except (KeyError, IndexError): except (KeyError, IndexError):
pass pass
if type(keyword_list) == str: if isinstance(keyword_list, str):
keyword_list = keyword_list.split(',') keyword_list = keyword_list.split(',')
# keywords as string in list # keywords as string in list
if (type(keyword_list) == list and len(keyword_list) == 1 and ',' in keyword_list[0]): if (isinstance(keyword_list, list) and len(keyword_list) == 1 and ',' in keyword_list[0]):
keyword_list = keyword_list[0].split(',') keyword_list = keyword_list[0].split(',')
return [x.strip() for x in keyword_list] return [x.strip() for x in keyword_list]
@ -500,13 +489,13 @@ def get_images_from_soup(soup, url):
def clean_dict(input_dict, key): def clean_dict(input_dict, key):
if type(input_dict) == dict: if isinstance(input_dict, dict):
for x in list(input_dict): for x in list(input_dict):
if x == key: if x == key:
del input_dict[x] del input_dict[x]
elif type(input_dict[x]) == dict: elif isinstance(input_dict[x], dict):
input_dict[x] = clean_dict(input_dict[x], key) input_dict[x] = clean_dict(input_dict[x], key)
elif type(input_dict[x]) == list: elif isinstance(input_dict[x], list):
temp_list = [] temp_list = []
for e in input_dict[x]: for e in input_dict[x]:
temp_list.append(clean_dict(e, key)) temp_list.append(clean_dict(e, key))

View File

@ -1,8 +1,6 @@
from django.urls import reverse from django.urls import reverse
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from oauth2_provider.contrib.rest_framework import OAuth2Authentication from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from cookbook.views import views from cookbook.views import views
@ -50,7 +48,6 @@ class ScopeMiddleware:
return views.no_groups(request) return views.no_groups(request)
request.space = user_space.space request.space = user_space.space
# with scopes_disabled():
with scope(space=request.space): with scope(space=request.space):
return self.get_response(request) return self.get_response(request)
else: else:

View File

@ -1,16 +1,13 @@
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import F, OuterRef, Q, Subquery, Value from django.db.models import F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe, from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
SupermarketCategoryRelation) SupermarketCategoryRelation)
from recipes import settings
def shopping_helper(qs, request): def shopping_helper(qs, request):
@ -47,7 +44,7 @@ class RecipeShoppingEditor():
self.mealplan = self._kwargs.get('mealplan', None) self.mealplan = self._kwargs.get('mealplan', None)
if type(self.mealplan) in [int, float]: if type(self.mealplan) in [int, float]:
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space) self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
if type(self.mealplan) == dict: if isinstance(self.mealplan, dict):
self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first() self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first()
self.id = self._kwargs.get('id', None) self.id = self._kwargs.get('id', None)
@ -69,11 +66,12 @@ class RecipeShoppingEditor():
@property @property
def _recipe_servings(self): def _recipe_servings(self):
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings', None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None) return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings',
None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
@property @property
def _servings_factor(self): def _servings_factor(self):
return Decimal(self.servings)/Decimal(self._recipe_servings) return Decimal(self.servings) / Decimal(self._recipe_servings)
@property @property
def _shared_users(self): def _shared_users(self):
@ -90,9 +88,10 @@ class RecipeShoppingEditor():
def get_recipe_ingredients(self, id, exclude_onhand=False): def get_recipe_ingredients(self, id, exclude_onhand=False):
if exclude_onhand: if exclude_onhand:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users]) return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(
food__onhand_users__id__in=[x.id for x in self._shared_users])
else: else:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space) return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
@property @property
def _include_related(self): def _include_related(self):
@ -109,7 +108,7 @@ class RecipeShoppingEditor():
self.servings = float(servings) self.servings = float(servings)
if mealplan := kwargs.get('mealplan', None): if mealplan := kwargs.get('mealplan', None):
if type(mealplan) == dict: if isinstance(mealplan, dict):
self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first() self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first()
else: else:
self.mealplan = mealplan self.mealplan = mealplan
@ -170,14 +169,14 @@ class RecipeShoppingEditor():
try: try:
self._shopping_list_recipe.delete() self._shopping_list_recipe.delete()
return True return True
except: except BaseException:
return False return False
def _add_ingredients(self, ingredients=None): def _add_ingredients(self, ingredients=None):
if not ingredients: if not ingredients:
return return
elif type(ingredients) == list: elif isinstance(ingredients, list):
ingredients = Ingredient.objects.filter(id__in=ingredients) ingredients = Ingredient.objects.filter(id__in=ingredients, food__ignore_shopping=False)
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True) existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
add_ingredients = ingredients.exclude(id__in=existing) add_ingredients = ingredients.exclude(id__in=existing)
@ -199,120 +198,3 @@ class RecipeShoppingEditor():
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients) to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
ShoppingListEntry.objects.filter(id__in=to_delete).delete() ShoppingListEntry.objects.filter(id__in=to_delete).delete()
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space) self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
# # TODO refactor as class
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
# """
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
# :param list_recipe: Modify an existing ShoppingListRecipe
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
# """
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
# if not r:
# raise ValueError(_("You must supply a recipe or mealplan"))
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
# if not created_by:
# raise ValueError(_("You must supply a created_by"))
# try:
# servings = float(servings)
# except (ValueError, TypeError):
# servings = getattr(mealplan, 'servings', 1.0)
# servings_factor = servings / r.servings
# shared_users = list(created_by.get_shopping_share())
# shared_users.append(created_by)
# if list_recipe:
# created = False
# else:
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
# created = True
# related_step_ing = []
# if servings == 0 and not created:
# list_recipe.delete()
# return []
# elif ingredients:
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
# else:
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# if related := created_by.userpreference.mealplan_autoinclude_related:
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
# related_recipes = r.get_related_recipes()
# for x in related_recipes:
# # related recipe is a Step serving size is driven by recipe serving size
# # TODO once/if Steps can have a serving size this needs to be refactored
# if exclude_onhand:
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
# else:
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
# x_ing = []
# if ingredients.filter(food__recipe=x).exists():
# for ing in ingredients.filter(food__recipe=x):
# if exclude_onhand:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# else:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
# for i in [x for x in x_ing]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
# ingredients = ingredients.exclude(food__recipe=x)
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
# if not append:
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# # delete shopping list entries not included in ingredients
# existing_list.exclude(ingredient__in=ingredients).delete()
# # add shopping list entries that did not previously exist
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# # if servings have changed, update the ShoppingListRecipe and existing Entries
# if servings <= 0:
# servings = 1
# if not created and list_recipe.servings != servings:
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
# list_recipe.servings = servings
# list_recipe.save()
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
# sle.save()
# # add any missing Entries
# for i in [x for x in add_ingredients if x.food]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # return all shopping list items
# return list_recipe

View File

@ -2,7 +2,6 @@ from gettext import gettext as _
import bleach import bleach
import markdown as md import markdown as md
from bleach_allowlist import markdown_attrs, markdown_tags
from jinja2 import Template, TemplateSyntaxError, UndefinedError from jinja2 import Template, TemplateSyntaxError, UndefinedError
from markdown.extensions.tables import TableExtension from markdown.extensions.tables import TableExtension
@ -53,9 +52,17 @@ class IngredientObject(object):
def render_instructions(step): # TODO deduplicate markdown cleanup code def render_instructions(step): # TODO deduplicate markdown cleanup code
instructions = step.instruction instructions = step.instruction
tags = markdown_tags + [ tags = {
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead', 'img' "h1", "h2", "h3", "h4", "h5", "h6",
] "b", "i", "strong", "em", "tt",
"p", "br",
"span", "div", "blockquote", "code", "pre", "hr",
"ul", "ol", "li", "dd", "dt",
"img",
"a",
"sub", "sup",
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
}
parsed_md = md.markdown( parsed_md = md.markdown(
instructions, instructions,
extensions=[ extensions=[
@ -63,7 +70,11 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
UrlizeExtension(), MarkdownFormatExtension() UrlizeExtension(), MarkdownFormatExtension()
] ]
) )
markdown_attrs['*'] = markdown_attrs['*'] + ['class', 'width', 'height'] markdown_attrs = {
"*": ["id", "class", 'width', 'height'],
"img": ["src", "alt", "title"],
"a": ["href", "alt", "title"],
}
instructions = bleach.clean(parsed_md, tags, markdown_attrs) instructions = bleach.clean(parsed_md, tags, markdown_attrs)

View File

@ -36,7 +36,7 @@ class ChefTap(Integration):
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space, ) recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space, )
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space,) step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,)
if source_url != '': if source_url != '':
step.instruction += '\n' + source_url step.instruction += '\n' + source_url

View File

@ -4,6 +4,7 @@ from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step from cookbook.models import Ingredient, Keyword, Recipe, Step
@ -19,6 +20,10 @@ class Chowdown(Integration):
direction_mode = False direction_mode = False
description_mode = False description_mode = False
description = None
prep_time = None
serving = None
ingredients = [] ingredients = []
directions = [] directions = []
descriptions = [] descriptions = []
@ -26,6 +31,12 @@ class Chowdown(Integration):
line = fl.decode("utf-8") line = fl.decode("utf-8")
if 'title:' in line: if 'title:' in line:
title = line.replace('title:', '').replace('"', '').strip() title = line.replace('title:', '').replace('"', '').strip()
if 'description:' in line:
description = line.replace('description:', '').replace('"', '').strip()
if 'prep_time:' in line:
prep_time = line.replace('prep_time:', '').replace('"', '').strip()
if 'yield:' in line:
serving = line.replace('yield:', '').replace('"', '').strip()
if 'image:' in line: if 'image:' in line:
image = line.replace('image:', '').strip() image = line.replace('image:', '').strip()
if 'tags:' in line: if 'tags:' in line:
@ -48,15 +59,43 @@ class Chowdown(Integration):
descriptions.append(line) descriptions.append(line)
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space) recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space)
if description:
recipe.description = description
for k in tags.split(','): for k in tags.split(','):
print(f'adding keyword {k.strip()}')
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space) keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
recipe.keywords.add(keyword) recipe.keywords.add(keyword)
step = Step.objects.create( ingredients_added = False
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions), space=self.request.space, for direction in directions:
) if len(direction.strip()) > 0:
step = Step.objects.create(
instruction=direction, name='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
)
else:
step = Step.objects.create(
instruction=direction, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
)
if not ingredients_added:
ingredients_added = True
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
if len(ingredient.strip()) > 0:
amount, unit, food, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(food)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,
))
recipe.steps.add(step)
if serving:
recipe.servings = parse_servings(serving)
recipe.servings_text = 'servings'
if prep_time:
recipe.working_time = parse_time(prep_time)
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients: for ingredient in ingredients:
@ -76,6 +115,7 @@ class Chowdown(Integration):
if re.match(f'^images/{image}$', z.filename): if re.match(f'^images/{image}$', z.filename):
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename)) self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
recipe.save()
return recipe return recipe
def get_file_from_recipe(self, recipe): def get_file_from_recipe(self, recipe):

View File

@ -1,20 +1,15 @@
import base64
import gzip
import json
import re import re
from gettext import gettext as _
from io import BytesIO from io import BytesIO
import requests import requests
import validators import validators
import yaml
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup, from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
iso_duration_to_minutes) iso_duration_to_minutes)
from cookbook.helper.scrapers.scrapers import text_scraper from cookbook.helper.scrapers.scrapers import text_scraper
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step from cookbook.models import Ingredient, Recipe, Step
class CookBookApp(Integration): class CookBookApp(Integration):
@ -25,7 +20,6 @@ class CookBookApp(Integration):
def get_recipe_from_file(self, file): def get_recipe_from_file(self, file):
recipe_html = file.getvalue().decode("utf-8") recipe_html = file.getvalue().decode("utf-8")
# recipe_json, recipe_tree, html_data, images = get_recipe_from_source(recipe_html, 'CookBookApp', self.request)
scrape = text_scraper(text=recipe_html) scrape = text_scraper(text=recipe_html)
recipe_json = get_from_scraper(scrape, self.request) recipe_json = get_from_scraper(scrape, self.request)
images = list(dict.fromkeys(get_images_from_soup(scrape.soup, None))) images = list(dict.fromkeys(get_images_from_soup(scrape.soup, None)))
@ -37,7 +31,7 @@ class CookBookApp(Integration):
try: try:
recipe.servings = re.findall('([0-9])+', recipe_json['recipeYield'])[0] recipe.servings = re.findall('([0-9])+', recipe_json['recipeYield'])[0]
except Exception as e: except Exception:
pass pass
try: try:
@ -47,7 +41,8 @@ class CookBookApp(Integration):
pass pass
# assuming import files only contain single step # assuming import files only contain single step
step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space, ) step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space,
show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
if 'nutrition' in recipe_json: if 'nutrition' in recipe_json:
step.instruction = step.instruction + '\n\n' + recipe_json['nutrition'] step.instruction = step.instruction + '\n\n' + recipe_json['nutrition']
@ -62,7 +57,7 @@ class CookBookApp(Integration):
if unit := ingredient.get('unit', None): if unit := ingredient.get('unit', None):
u = ingredient_parser.get_unit(unit.get('name', None)) u = ingredient_parser.get_unit(unit.get('name', None))
step.ingredients.add(Ingredient.objects.create( step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space, food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space,
)) ))
if len(images) > 0: if len(images) > 0:

View File

@ -1,17 +1,12 @@
import base64
import json
from io import BytesIO from io import BytesIO
from gettext import gettext as _
import requests import requests
import validators import validators
from lxml import etree
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_time, parse_servings_text from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step from cookbook.models import Ingredient, Recipe, Step
class Cookmate(Integration): class Cookmate(Integration):
@ -50,7 +45,7 @@ class Cookmate(Integration):
for step in recipe_text.getchildren(): for step in recipe_text.getchildren():
if step.text: if step.text:
step = Step.objects.create( step = Step.objects.create(
instruction=step.text.strip(), space=self.request.space, instruction=step.text.strip(), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
recipe.steps.add(step) recipe.steps.add(step)

View File

@ -1,4 +1,3 @@
import re
from io import BytesIO from io import BytesIO
from zipfile import ZipFile from zipfile import ZipFile
@ -26,12 +25,13 @@ class CopyMeThat(Integration):
except AttributeError: except AttributeError:
source = None source = None
recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip()[:128], source_url=source, created_by=self.request.user, internal=True, space=self.request.space, ) recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip(
)[:128], source_url=source, created_by=self.request.user, internal=True, space=self.request.space, )
for category in file.find_all("span", {"class": "recipeCategory"}): for category in file.find_all("span", {"class": "recipeCategory"}):
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space) keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
recipe.keywords.add(keyword) recipe.keywords.add(keyword)
try: try:
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip()) recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip()) recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
@ -51,7 +51,7 @@ class CopyMeThat(Integration):
except AttributeError: except AttributeError:
pass pass
step = Step.objects.create(instruction='', space=self.request.space, ) step = Step.objects.create(instruction='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)
@ -61,7 +61,14 @@ class CopyMeThat(Integration):
if not isinstance(ingredient, Tag) or not ingredient.text.strip() or "recipeIngredient_spacer" in ingredient['class']: if not isinstance(ingredient, Tag) or not ingredient.text.strip() or "recipeIngredient_spacer" in ingredient['class']:
continue continue
if any(x in ingredient['class'] for x in ["recipeIngredient_subheader", "recipeIngredient_note"]): if any(x in ingredient['class'] for x in ["recipeIngredient_subheader", "recipeIngredient_note"]):
step.ingredients.add(Ingredient.objects.create(is_header=True, note=ingredient.text.strip()[:256], original_text=ingredient.text.strip(), space=self.request.space, )) step.ingredients.add(
Ingredient.objects.create(
is_header=True,
note=ingredient.text.strip()[
:256],
original_text=ingredient.text.strip(),
space=self.request.space,
))
else: else:
amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip()) amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip())
f = ingredient_parser.get_food(food) f = ingredient_parser.get_food(food)
@ -78,7 +85,7 @@ class CopyMeThat(Integration):
step.save() step.save()
recipe.steps.add(step) recipe.steps.add(step)
step = Step.objects.create(instruction='', space=self.request.space, ) step = Step.objects.create(instruction='', space=self.request.space, )
step.name = instruction.text.strip()[:128] step.name = instruction.text.strip()[:128]
else: else:
step.instruction += instruction.text.strip() + ' \n\n' step.instruction += instruction.text.strip() + ' \n\n'

View File

@ -1,4 +1,5 @@
import json import json
import traceback
from io import BytesIO, StringIO from io import BytesIO, StringIO
from re import match from re import match
from zipfile import ZipFile from zipfile import ZipFile
@ -19,7 +20,10 @@ class Default(Integration):
recipe = self.decode_recipe(recipe_string) recipe = self.decode_recipe(recipe_string)
images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist())) images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist()))
if images: if images:
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0])) try:
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
except AttributeError:
traceback.print_exc()
return recipe return recipe
def decode_recipe(self, string): def decode_recipe(self, string):
@ -54,7 +58,7 @@ class Default(Integration):
try: try:
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read()) recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
except ValueError: except (ValueError, FileNotFoundError):
pass pass
recipe_zip_obj.close() recipe_zip_obj.close()
@ -67,4 +71,4 @@ class Default(Integration):
export_zip_obj.close() export_zip_obj.close()
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]] return [[self.get_export_file_name(), export_zip_stream.getvalue()]]

View File

@ -28,7 +28,7 @@ class Domestica(Integration):
recipe.save() recipe.save()
step = Step.objects.create( step = Step.objects.create(
instruction=file['directions'], space=self.request.space, instruction=file['directions'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
if file['source'] != '': if file['source'] != '':

View File

@ -1,4 +1,3 @@
import traceback
import datetime import datetime
import traceback import traceback
import uuid import uuid
@ -18,8 +17,7 @@ from lxml import etree
from cookbook.helper.image_processing import handle_image from cookbook.helper.image_processing import handle_image
from cookbook.models import Keyword, Recipe from cookbook.models import Keyword, Recipe
from recipes.settings import DEBUG from recipes.settings import DEBUG, EXPORT_FILE_CACHE_DURATION
from recipes.settings import EXPORT_FILE_CACHE_DURATION
class Integration: class Integration:
@ -39,7 +37,6 @@ class Integration:
self.ignored_recipes = [] self.ignored_recipes = []
description = f'Imported by {request.user.get_user_display_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}' description = f'Imported by {request.user.get_user_display_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}'
icon = '📥'
try: try:
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at') last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
@ -52,23 +49,19 @@ class Integration:
self.keyword = parent.add_child( self.keyword = parent.add_child(
name=name, name=name,
description=description, description=description,
icon=icon,
space=request.space space=request.space
) )
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now. except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
self.keyword = parent.add_child( self.keyword = parent.add_child(
name=f'{name} {str(uuid.uuid4())[0:8]}', name=f'{name} {str(uuid.uuid4())[0:8]}',
description=description, description=description,
icon=icon,
space=request.space space=request.space
) )
def do_export(self, recipes, el): def do_export(self, recipes, el):
with scope(space=self.request.space): with scope(space=self.request.space):
el.total_recipes = len(recipes) el.total_recipes = len(recipes)
el.cache_duration = EXPORT_FILE_CACHE_DURATION el.cache_duration = EXPORT_FILE_CACHE_DURATION
el.save() el.save()
@ -80,7 +73,7 @@ class Integration:
export_file = file export_file = file
else: else:
#zip the files if there is more then one file # zip the files if there is more then one file
export_filename = self.get_export_file_name() export_filename = self.get_export_file_name()
export_stream = BytesIO() export_stream = BytesIO()
export_obj = ZipFile(export_stream, 'w') export_obj = ZipFile(export_stream, 'w')
@ -91,8 +84,7 @@ class Integration:
export_obj.close() export_obj.close()
export_file = export_stream.getvalue() export_file = export_stream.getvalue()
cache.set('export_file_' + str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
cache.set('export_file_'+str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
el.running = False el.running = False
el.save() el.save()
@ -100,7 +92,6 @@ class Integration:
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"' response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response return response
def import_file_name_filter(self, zip_info_object): def import_file_name_filter(self, zip_info_object):
""" """
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
@ -164,7 +155,7 @@ class Integration:
for z in file_list: for z in file_list:
try: try:
if not hasattr(z, 'filename') or type(z) == Tag: if not hasattr(z, 'filename') or isinstance(z, Tag):
recipe = self.get_recipe_from_file(z) recipe = self.get_recipe_from_file(z)
else: else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename))) recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
@ -298,7 +289,6 @@ class Integration:
if DEBUG: if DEBUG:
traceback.print_exc() traceback.print_exc()
def get_export_file_name(self, format='zip'): def get_export_file_name(self, format='zip'):
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format) return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)

View File

@ -7,7 +7,7 @@ from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step from cookbook.models import Ingredient, Keyword, Recipe, Step
class Mealie(Integration): class Mealie(Integration):
@ -25,7 +25,7 @@ class Mealie(Integration):
created_by=self.request.user, internal=True, space=self.request.space) created_by=self.request.user, internal=True, space=self.request.space)
for s in recipe_json['recipe_instructions']: for s in recipe_json['recipe_instructions']:
step = Step.objects.create(instruction=s['text'], space=self.request.space, ) step = Step.objects.create(instruction=s['text'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
recipe.steps.add(step) recipe.steps.add(step)
step = recipe.steps.first() step = recipe.steps.first()
@ -56,6 +56,12 @@ class Mealie(Integration):
except Exception: except Exception:
pass pass
if 'tags' in recipe_json and len(recipe_json['tags']) > 0:
for k in recipe_json['tags']:
if 'name' in k:
keyword, created = Keyword.objects.get_or_create(name=k['name'].strip(), space=self.request.space)
recipe.keywords.add(keyword)
if 'notes' in recipe_json and len(recipe_json['notes']) > 0: if 'notes' in recipe_json and len(recipe_json['notes']) > 0:
notes_text = "#### Notes \n\n" notes_text = "#### Notes \n\n"
for n in recipe_json['notes']: for n in recipe_json['notes']:

View File

@ -39,7 +39,7 @@ class MealMaster(Integration):
recipe.keywords.add(keyword) recipe.keywords.add(keyword)
step = Step.objects.create( step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n', space=self.request.space, instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)

View File

@ -57,7 +57,7 @@ class MelaRecipes(Integration):
recipe.source_url = recipe_json['link'] recipe.source_url = recipe_json['link']
step = Step.objects.create( step = Step.objects.create(
instruction=instruction, space=self.request.space, instruction=instruction, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients
) )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)

View File

@ -2,13 +2,14 @@ import json
import re import re
from io import BytesIO, StringIO from io import BytesIO, StringIO
from zipfile import ZipFile from zipfile import ZipFile
from PIL import Image from PIL import Image
from cookbook.helper.image_processing import get_filetype from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import iso_duration_to_minutes from cookbook.helper.recipe_url_import import iso_duration_to_minutes
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step, NutritionInformation from cookbook.models import Ingredient, Keyword, NutritionInformation, Recipe, Step
class NextcloudCookbook(Integration): class NextcloudCookbook(Integration):
@ -51,14 +52,13 @@ class NextcloudCookbook(Integration):
ingredients_added = False ingredients_added = False
for s in recipe_json['recipeInstructions']: for s in recipe_json['recipeInstructions']:
instruction_text = ''
if 'text' in s: if 'text' in s:
step = Step.objects.create( step = Step.objects.create(
instruction=s['text'], name=s['name'], space=self.request.space, instruction=s['text'], name=s['name'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
else: else:
step = Step.objects.create( step = Step.objects.create(
instruction=s, space=self.request.space, instruction=s, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
if not ingredients_added: if not ingredients_added:
if len(recipe_json['description'].strip()) > 500: if len(recipe_json['description'].strip()) > 500:
@ -91,7 +91,7 @@ class NextcloudCookbook(Integration):
if nutrition != {}: if nutrition != {}:
recipe.nutrition = NutritionInformation.objects.create(**nutrition, space=self.request.space) recipe.nutrition = NutritionInformation.objects.create(**nutrition, space=self.request.space)
recipe.save() recipe.save()
except Exception as e: except Exception:
pass pass
for f in self.files: for f in self.files:

View File

@ -1,9 +1,11 @@
import json import json
from django.utils.translation import gettext as _
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step, Keyword, Comment, CookLog from cookbook.models import Comment, CookLog, Ingredient, Keyword, Recipe, Step
from django.utils.translation import gettext as _
class OpenEats(Integration): class OpenEats(Integration):
@ -25,16 +27,16 @@ class OpenEats(Integration):
if file["source"] != '': if file["source"] != '':
instructions += '\n' + _('Recipe source:') + f'[{file["source"]}]({file["source"]})' instructions += '\n' + _('Recipe source:') + f'[{file["source"]}]({file["source"]})'
cuisine_keyword, created = Keyword.objects.get_or_create(name="Cuisine", space=self.request.space) cuisine_keyword, created = Keyword.objects.get_or_create(name="Cuisine", space=self.request.space)
if file["cuisine"] != '': if file["cuisine"] != '':
keyword, created = Keyword.objects.get_or_create(name=file["cuisine"].strip(), space=self.request.space) keyword, created = Keyword.objects.get_or_create(name=file["cuisine"].strip(), space=self.request.space)
if created: if created:
keyword.move(cuisine_keyword, pos="last-child") keyword.move(cuisine_keyword, pos="last-child")
recipe.keywords.add(keyword) recipe.keywords.add(keyword)
course_keyword, created = Keyword.objects.get_or_create(name="Course", space=self.request.space) course_keyword, created = Keyword.objects.get_or_create(name="Course", space=self.request.space)
if file["course"] != '': if file["course"] != '':
keyword, created = Keyword.objects.get_or_create(name=file["course"].strip(), space=self.request.space) keyword, created = Keyword.objects.get_or_create(name=file["course"].strip(), space=self.request.space)
if created: if created:
keyword.move(course_keyword, pos="last-child") keyword.move(course_keyword, pos="last-child")
recipe.keywords.add(keyword) recipe.keywords.add(keyword)
@ -51,7 +53,7 @@ class OpenEats(Integration):
recipe.image = f'recipes/openeats-import/{file["photo"]}' recipe.image = f'recipes/openeats-import/{file["photo"]}'
recipe.save() recipe.save()
step = Step.objects.create(instruction=instructions, space=self.request.space,) step = Step.objects.create(instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,)
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)
for ingredient in file['ingredients']: for ingredient in file['ingredients']:

View File

@ -58,7 +58,7 @@ class Paprika(Integration):
pass pass
step = Step.objects.create( step = Step.objects.create(
instruction=instructions, space=self.request.space, instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
if 'description' in recipe_json and len(recipe_json['description'].strip()) > 500: if 'description' in recipe_json and len(recipe_json['description'].strip()) > 500:
@ -90,7 +90,7 @@ class Paprika(Integration):
if validators.url(url, public=True): if validators.url(url, public=True):
response = requests.get(url) response = requests.get(url)
self.import_recipe_image(recipe, BytesIO(response.content)) self.import_recipe_image(recipe, BytesIO(response.content))
except: except Exception:
if recipe_json.get("photo_data", None): if recipe_json.get("photo_data", None):
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg') self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')

View File

@ -1,21 +1,11 @@
import json
from io import BytesIO
from re import match
from zipfile import ZipFile
import asyncio import asyncio
from pyppeteer import launch
from rest_framework.renderers import JSONRenderer
from cookbook.helper.image_processing import get_filetype
from cookbook.integration.integration import Integration
from cookbook.serializer import RecipeExportSerializer
from cookbook.models import ExportLog
from asgiref.sync import sync_to_async
import django.core.management.commands.runserver as runserver import django.core.management.commands.runserver as runserver
import logging from asgiref.sync import sync_to_async
from pyppeteer import launch
from cookbook.integration.integration import Integration
class PDFexport(Integration): class PDFexport(Integration):
@ -42,7 +32,6 @@ class PDFexport(Integration):
} }
} }
files = [] files = []
for recipe in recipes: for recipe in recipes:
@ -50,20 +39,18 @@ class PDFexport(Integration):
await page.emulateMedia('print') await page.emulateMedia('print')
await page.setCookie(cookies) await page.setCookie(cookies)
await page.goto('http://'+cmd.default_addr+':'+cmd.default_port+'/view/recipe/'+str(recipe.id), {'waitUntil': 'domcontentloaded'}) await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'domcontentloaded'})
await page.waitForSelector('#printReady'); await page.waitForSelector('#printReady')
files.append([recipe.name + '.pdf', await page.pdf(options)]) files.append([recipe.name + '.pdf', await page.pdf(options)])
await page.close(); await page.close()
el.exported_recipes += 1 el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(recipe) el.msg += self.get_recipe_processed_msg(recipe)
await sync_to_async(el.save, thread_sensitive=True)() await sync_to_async(el.save, thread_sensitive=True)()
await browser.close() await browser.close()
return files return files
def get_files_from_recipes(self, recipes, el, cookie): def get_files_from_recipes(self, recipes, el, cookie):
return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie)) return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie))

View File

@ -35,7 +35,7 @@ class Pepperplate(Integration):
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space) recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
step = Step.objects.create( step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n', space=self.request.space, instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)

View File

@ -46,7 +46,7 @@ class Plantoeat(Integration):
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space) recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
step = Step.objects.create( step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n', space=self.request.space, instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
if tags: if tags:

View File

@ -46,7 +46,7 @@ class RecetteTek(Integration):
if not instructions: if not instructions:
instructions = '' instructions = ''
step = Step.objects.create(instruction=instructions, space=self.request.space,) step = Step.objects.create(instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,)
# Append the original import url to the step (if it exists) # Append the original import url to the step (if it exists)
try: try:

View File

@ -41,7 +41,7 @@ class RecipeKeeper(Integration):
except AttributeError: except AttributeError:
pass pass
step = Step.objects.create(instruction='', space=self.request.space, ) step = Step.objects.create(instruction='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"): for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):

View File

@ -39,7 +39,7 @@ class RecipeSage(Integration):
ingredients_added = False ingredients_added = False
for s in file['recipeInstructions']: for s in file['recipeInstructions']:
step = Step.objects.create( step = Step.objects.create(
instruction=s['text'], space=self.request.space, instruction=s['text'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
if not ingredients_added: if not ingredients_added:
ingredients_added = True ingredients_added = True

View File

@ -2,12 +2,10 @@ import base64
from io import BytesIO from io import BytesIO
from xml import etree from xml import etree
from lxml import etree
from cookbook.helper.ingredient_parser import IngredientParser from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_time, parse_servings, parse_servings_text from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
from cookbook.integration.integration import Integration from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Recipe, Step, Keyword from cookbook.models import Ingredient, Keyword, Recipe, Step
class Rezeptsuitede(Integration): class Rezeptsuitede(Integration):
@ -37,7 +35,7 @@ class Rezeptsuitede(Integration):
try: try:
if prep.find('step').text: if prep.find('step').text:
step = Step.objects.create( step = Step.objects.create(
instruction=prep.find('step').text.strip(), space=self.request.space, instruction=prep.find('step').text.strip(), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
recipe.steps.add(step) recipe.steps.add(step)
except Exception: except Exception:
@ -61,14 +59,14 @@ class Rezeptsuitede(Integration):
try: try:
k, created = Keyword.objects.get_or_create(name=recipe_xml.find('head').find('cat').text.strip(), space=self.request.space) k, created = Keyword.objects.get_or_create(name=recipe_xml.find('head').find('cat').text.strip(), space=self.request.space)
recipe.keywords.add(k) recipe.keywords.add(k)
except Exception as e: except Exception:
pass pass
recipe.save() recipe.save()
try: try:
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_xml.find('head').find('picbin').text)), filetype='.jpeg') self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_xml.find('head').find('picbin').text)), filetype='.jpeg')
except: except BaseException:
pass pass
return recipe return recipe

View File

@ -38,7 +38,7 @@ class RezKonv(Integration):
recipe.keywords.add(keyword) recipe.keywords.add(keyword)
step = Step.objects.create( step = Step.objects.create(
instruction=' \n'.join(directions) + '\n\n', space=self.request.space, instruction=' \n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
) )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)
@ -60,8 +60,8 @@ class RezKonv(Integration):
def split_recipe_file(self, file): def split_recipe_file(self, file):
recipe_list = [] recipe_list = []
current_recipe = '' current_recipe = ''
encoding_list = ['windows-1250', # TODO build algorithm to try trough encodings and fail if none work, use for all importers
'latin-1'] # TODO build algorithm to try trough encodings and fail if none work, use for all importers # encoding_list = ['windows-1250', 'latin-1']
encoding = 'windows-1250' encoding = 'windows-1250'
for fl in file.readlines(): for fl in file.readlines():
try: try:

View File

@ -43,7 +43,7 @@ class Saffron(Integration):
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, ) recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, )
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, ) step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
ingredient_parser = IngredientParser(self.request, True) ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients: for ingredient in ingredients:
@ -59,11 +59,11 @@ class Saffron(Integration):
def get_file_from_recipe(self, recipe): def get_file_from_recipe(self, recipe):
data = "Title: "+recipe.name if recipe.name else ""+"\n" data = "Title: " + recipe.name if recipe.name else "" + "\n"
data += "Description: "+recipe.description if recipe.description else ""+"\n" data += "Description: " + recipe.description if recipe.description else "" + "\n"
data += "Source: \n" data += "Source: \n"
data += "Original URL: \n" data += "Original URL: \n"
data += "Yield: "+str(recipe.servings)+"\n" data += "Yield: " + str(recipe.servings) + "\n"
data += "Cookbook: \n" data += "Cookbook: \n"
data += "Section: \n" data += "Section: \n"
data += "Image: \n" data += "Image: \n"
@ -78,13 +78,13 @@ class Saffron(Integration):
data += "Ingredients: \n" data += "Ingredients: \n"
for ingredient in recipeIngredient: for ingredient in recipeIngredient:
data += ingredient+"\n" data += ingredient + "\n"
data += "Instructions: \n" data += "Instructions: \n"
for instruction in recipeInstructions: for instruction in recipeInstructions:
data += instruction+"\n" data += instruction + "\n"
return recipe.name+'.txt', data return recipe.name + '.txt', data
def get_files_from_recipes(self, recipes, el, cookie): def get_files_from_recipes(self, recipes, el, cookie):
files = [] files = []

View File

@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-29 18:42+0200\n" "POT-Creation-Date: 2022-04-29 18:42+0200\n"
"PO-Revision-Date: 2022-07-06 14:32+0000\n" "PO-Revision-Date: 2023-11-28 11:03+0000\n"
"Last-Translator: Nidhal Brniyah <n1a1b1@gmail.com>\n" "Last-Translator: Mahmoud Aljouhari <mapgohary@gmail.com>\n"
"Language-Team: Arabic <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Arabic <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/ar/>\n" "recipes-backend/ar/>\n"
"Language: ar\n" "Language: ar\n"
@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n" "&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
"X-Generator: Weblate 4.10.1\n" "X-Generator: Weblate 4.15\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34 #: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28 #: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28
@ -2578,7 +2578,7 @@ msgstr ""
#: .\cookbook\views\views.py:262 #: .\cookbook\views\views.py:262
msgid "This feature is not available in the demo version!" msgid "This feature is not available in the demo version!"
msgstr "" msgstr "هذه الميزة غير موجودة في النسخة التجريبية!"
#: .\cookbook\views\views.py:322 #: .\cookbook\views\views.py:322
msgid "You must select at least one field to search!" msgid "You must select at least one field to search!"

View File

@ -13,8 +13,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n" "PO-Revision-Date: 2023-07-06 21:19+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n" "Last-Translator: Rubens <rubenixnagios@gmail.com>\n"
"Language-Team: Catalan <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Catalan <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/ca/>\n" "recipes-backend/ca/>\n"
"Language: ca\n" "Language: ca\n"
@ -421,7 +421,7 @@ msgstr "Compartir Llista de la Compra"
#: .\cookbook\forms.py:525 #: .\cookbook\forms.py:525
msgid "Autosync" msgid "Autosync"
msgstr "Autosync" msgstr "Autosinc"
#: .\cookbook\forms.py:526 #: .\cookbook\forms.py:526
msgid "Auto Add Meal Plan" msgid "Auto Add Meal Plan"
@ -477,7 +477,7 @@ msgstr "Mostra el recompte de receptes als filtres de cerca"
#: .\cookbook\forms.py:559 #: .\cookbook\forms.py:559
msgid "Use the plural form for units and food inside this space." msgid "Use the plural form for units and food inside this space."
msgstr "" msgstr "Empra el plural d'aquestes unitats i menjars dins de l'espai."
#: .\cookbook\helper\AllAuthCustomAdapter.py:39 #: .\cookbook\helper\AllAuthCustomAdapter.py:39
msgid "" msgid ""

View File

@ -11,8 +11,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-02-09 18:01+0100\n" "POT-Creation-Date: 2021-02-09 18:01+0100\n"
"PO-Revision-Date: 2023-03-25 11:32+0000\n" "PO-Revision-Date: 2023-07-31 14:19+0000\n"
"Last-Translator: Matěj Kubla <matykubla@gmail.com>\n" "Last-Translator: Mára Štěpánek <stepanekm7@gmail.com>\n"
"Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/cs/>\n" "recipes-backend/cs/>\n"
"Language: cs\n" "Language: cs\n"
@ -36,7 +36,7 @@ msgid ""
"try them out!" "try them out!"
msgstr "" msgstr ""
"Barva horního navigačního menu. Některé barvy neladí se všemi tématy a je " "Barva horního navigačního menu. Některé barvy neladí se všemi tématy a je "
"třeba je vyzkoušet." "třeba je vyzkoušet!"
#: .\cookbook\forms.py:45 #: .\cookbook\forms.py:45
msgid "Default Unit to be used when inserting a new ingredient into a recipe." msgid "Default Unit to be used when inserting a new ingredient into a recipe."
@ -50,7 +50,7 @@ msgid ""
"to fractions automatically)" "to fractions automatically)"
msgstr "" msgstr ""
"Povolit podporu zlomků u množství ingrediencí (desetinná čísla budou " "Povolit podporu zlomků u množství ingrediencí (desetinná čísla budou "
"automaticky převedena na zlomky)." "automaticky převedena na zlomky)"
#: .\cookbook\forms.py:47 #: .\cookbook\forms.py:47
msgid "" msgid ""

View File

@ -15,8 +15,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-06-21 14:19+0000\n" "PO-Revision-Date: 2023-11-22 18:19+0000\n"
"Last-Translator: Tobias Huppertz <tobias.huppertz@mail.de>\n" "Last-Translator: Spreez <tandoor@larsdev.de>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/de/>\n" "recipes-backend/de/>\n"
"Language: de\n" "Language: de\n"
@ -161,7 +161,7 @@ msgstr "Name"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88 #: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
msgid "Keywords" msgid "Keywords"
msgstr "Stichwörter" msgstr "Schlüsselwörter"
#: .\cookbook\forms.py:125 #: .\cookbook\forms.py:125
msgid "Preparation time in minutes" msgid "Preparation time in minutes"
@ -1436,11 +1436,11 @@ msgid ""
" " " "
msgstr "" msgstr ""
"\n" "\n"
" <b>Password und Token</b> werden im <b>Klartext</b> in der Datenbank " " <b>Passwort und Token</b> werden im <b>Klartext</b> in der Datenbank "
"gespeichert.\n" "gespeichert.\n"
" Dies ist notwendig da Passwort oder Token benötigt werden, um API-" " Dies ist notwendig da Passwort oder Token benötigt werden, um API-"
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/" "Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/>"
">\n" "\n"
" Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder " " Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder "
"Accounts mit limitiertem Zugriff verwendet werden.\n" "Accounts mit limitiertem Zugriff verwendet werden.\n"
" " " "
@ -2600,7 +2600,7 @@ msgstr "Ungültiges URL Schema."
#: .\cookbook\views\api.py:1233 #: .\cookbook\views\api.py:1233
msgid "No usable data could be found." msgid "No usable data could be found."
msgstr "Es konnten keine nutzbaren Daten gefunden werden." msgstr "Es konnten keine passenden Daten gefunden werden."
#: .\cookbook\views\api.py:1326 .\cookbook\views\import_export.py:117 #: .\cookbook\views\api.py:1326 .\cookbook\views\import_export.py:117
msgid "Importing is not implemented for this provider" msgid "Importing is not implemented for this provider"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -14,8 +14,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-05-26 16:19+0000\n" "PO-Revision-Date: 2023-09-25 09:59+0000\n"
"Last-Translator: Luis Cacho <luiscachog@gmail.com>\n" "Last-Translator: Matias Laporte <laportematias+weblate@gmail.com>\n"
"Language-Team: Spanish <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Spanish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/es/>\n" "recipes-backend/es/>\n"
"Language: es\n" "Language: es\n"
@ -543,19 +543,19 @@ msgstr ""
#: .\cookbook\helper\recipe_url_import.py:268 #: .\cookbook\helper\recipe_url_import.py:268
msgid "knead" msgid "knead"
msgstr "" msgstr "amasar"
#: .\cookbook\helper\recipe_url_import.py:269 #: .\cookbook\helper\recipe_url_import.py:269
msgid "thicken" msgid "thicken"
msgstr "" msgstr "espesar"
#: .\cookbook\helper\recipe_url_import.py:270 #: .\cookbook\helper\recipe_url_import.py:270
msgid "warm up" msgid "warm up"
msgstr "" msgstr "precalentar"
#: .\cookbook\helper\recipe_url_import.py:271 #: .\cookbook\helper\recipe_url_import.py:271
msgid "ferment" msgid "ferment"
msgstr "" msgstr "fermentar"
#: .\cookbook\helper\recipe_url_import.py:272 #: .\cookbook\helper\recipe_url_import.py:272
msgid "sous-vide" msgid "sous-vide"
@ -573,11 +573,11 @@ msgstr ""
#: .\cookbook\integration\copymethat.py:44 #: .\cookbook\integration\copymethat.py:44
#: .\cookbook\integration\melarecipes.py:37 #: .\cookbook\integration\melarecipes.py:37
msgid "Favorite" msgid "Favorite"
msgstr "" msgstr "Favorito"
#: .\cookbook\integration\copymethat.py:50 #: .\cookbook\integration\copymethat.py:50
msgid "I made this" msgid "I made this"
msgstr "" msgstr "Lo he preparado"
#: .\cookbook\integration\integration.py:218 #: .\cookbook\integration\integration.py:218
msgid "" msgid ""
@ -604,7 +604,7 @@ msgstr "Se importaron %s recetas."
#: .\cookbook\integration\openeats.py:26 #: .\cookbook\integration\openeats.py:26
msgid "Recipe source:" msgid "Recipe source:"
msgstr "Recipe source:" msgstr "Fuente de la receta:"
#: .\cookbook\integration\paprika.py:49 #: .\cookbook\integration\paprika.py:49
msgid "Notes" msgid "Notes"
@ -645,19 +645,21 @@ msgstr "Sección"
#: .\cookbook\management\commands\rebuildindex.py:14 #: .\cookbook\management\commands\rebuildindex.py:14
msgid "Rebuilds full text search index on Recipe" msgid "Rebuilds full text search index on Recipe"
msgstr "" msgstr "Reconstruye el índice de búsqueda por texto completo de la receta"
#: .\cookbook\management\commands\rebuildindex.py:18 #: .\cookbook\management\commands\rebuildindex.py:18
msgid "Only Postgresql databases use full text search, no index to rebuild" msgid "Only Postgresql databases use full text search, no index to rebuild"
msgstr "" msgstr ""
"Solo las bases de datos Postgresql utilizan la búsqueda por texto completo, "
"no hay índice para reconstruir"
#: .\cookbook\management\commands\rebuildindex.py:29 #: .\cookbook\management\commands\rebuildindex.py:29
msgid "Recipe index rebuild complete." msgid "Recipe index rebuild complete."
msgstr "" msgstr "Se reconstruyó el índice de la receta."
#: .\cookbook\management\commands\rebuildindex.py:31 #: .\cookbook\management\commands\rebuildindex.py:31
msgid "Recipe index rebuild failed." msgid "Recipe index rebuild failed."
msgstr "" msgstr "No fue posible reconstruir el índice de la receta."
#: .\cookbook\migrations\0047_auto_20200602_1133.py:14 #: .\cookbook\migrations\0047_auto_20200602_1133.py:14
msgid "Breakfast" msgid "Breakfast"
@ -699,23 +701,23 @@ msgstr "Libros"
#: .\cookbook\models.py:580 #: .\cookbook\models.py:580
msgid " is part of a recipe step and cannot be deleted" msgid " is part of a recipe step and cannot be deleted"
msgstr "" msgstr " es parte del paso de una receta y no puede ser eliminado"
#: .\cookbook\models.py:1181 .\cookbook\templates\search_info.html:28 #: .\cookbook\models.py:1181 .\cookbook\templates\search_info.html:28
msgid "Simple" msgid "Simple"
msgstr "" msgstr "Simple"
#: .\cookbook\models.py:1182 .\cookbook\templates\search_info.html:33 #: .\cookbook\models.py:1182 .\cookbook\templates\search_info.html:33
msgid "Phrase" msgid "Phrase"
msgstr "" msgstr "Frase"
#: .\cookbook\models.py:1183 .\cookbook\templates\search_info.html:38 #: .\cookbook\models.py:1183 .\cookbook\templates\search_info.html:38
msgid "Web" msgid "Web"
msgstr "" msgstr "Web"
#: .\cookbook\models.py:1184 .\cookbook\templates\search_info.html:47 #: .\cookbook\models.py:1184 .\cookbook\templates\search_info.html:47
msgid "Raw" msgid "Raw"
msgstr "" msgstr "Crudo"
#: .\cookbook\models.py:1231 #: .\cookbook\models.py:1231
msgid "Food Alias" msgid "Food Alias"
@ -762,49 +764,53 @@ msgstr "Palabra clave"
#: .\cookbook\serializer.py:198 #: .\cookbook\serializer.py:198
msgid "File uploads are not enabled for this Space." msgid "File uploads are not enabled for this Space."
msgstr "" msgstr "Las cargas de archivo no están habilitadas para esta Instancia."
#: .\cookbook\serializer.py:209 #: .\cookbook\serializer.py:209
msgid "You have reached your file upload limit." msgid "You have reached your file upload limit."
msgstr "" msgstr "Has alcanzado el límite de cargas de archivo."
#: .\cookbook\serializer.py:291 #: .\cookbook\serializer.py:291
msgid "Cannot modify Space owner permission." msgid "Cannot modify Space owner permission."
msgstr "" msgstr "No puedes modificar los permisos del propietario de la Instancia."
#: .\cookbook\serializer.py:1093 #: .\cookbook\serializer.py:1093
msgid "Hello" msgid "Hello"
msgstr "" msgstr "Hola"
#: .\cookbook\serializer.py:1093 #: .\cookbook\serializer.py:1093
msgid "You have been invited by " msgid "You have been invited by "
msgstr "" msgstr "Has sido invitado por: "
#: .\cookbook\serializer.py:1094 #: .\cookbook\serializer.py:1094
msgid " to join their Tandoor Recipes space " msgid " to join their Tandoor Recipes space "
msgstr "" msgstr " para unirte a su instancia de Tandoor Recipes "
#: .\cookbook\serializer.py:1095 #: .\cookbook\serializer.py:1095
msgid "Click the following link to activate your account: " msgid "Click the following link to activate your account: "
msgstr "" msgstr "Haz click en el siguiente enlace para activar tu cuenta: "
#: .\cookbook\serializer.py:1096 #: .\cookbook\serializer.py:1096
msgid "" msgid ""
"If the link does not work use the following code to manually join the space: " "If the link does not work use the following code to manually join the space: "
msgstr "" msgstr ""
"Si el enlace no funciona, utiliza el siguiente código para unirte "
"manualmente a la instancia: "
#: .\cookbook\serializer.py:1097 #: .\cookbook\serializer.py:1097
msgid "The invitation is valid until " msgid "The invitation is valid until "
msgstr "" msgstr "La invitación es válida hasta "
#: .\cookbook\serializer.py:1098 #: .\cookbook\serializer.py:1098
msgid "" msgid ""
"Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub " "Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub "
msgstr "" msgstr ""
"Tandoor Recipes es un administrador de recetas Open Source. Dale una ojeada "
"en GitHub "
#: .\cookbook\serializer.py:1101 #: .\cookbook\serializer.py:1101
msgid "Tandoor Recipes Invite" msgid "Tandoor Recipes Invite"
msgstr "" msgstr "Invitación para Tandoor Recipes"
#: .\cookbook\serializer.py:1242 #: .\cookbook\serializer.py:1242
msgid "Existing shopping list to update" msgid "Existing shopping list to update"

View File

@ -14,10 +14,10 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n" "PO-Revision-Date: 2023-12-10 14:19+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n" "Last-Translator: Robin Wilmet <wilmetrobin@hotmail.com>\n"
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/recipes-" "Language-Team: French <http://translate.tandoor.dev/projects/tandoor/"
"backend/fr/>\n" "recipes-backend/fr/>\n"
"Language: fr\n" "Language: fr\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -310,7 +310,7 @@ msgid ""
msgstr "" msgstr ""
"Champs à rechercher en ignorant les accents. La sélection de cette option " "Champs à rechercher en ignorant les accents. La sélection de cette option "
"peut améliorer ou dégrader la qualité de la recherche en fonction de la " "peut améliorer ou dégrader la qualité de la recherche en fonction de la "
"langue." "langue"
#: .\cookbook\forms.py:466 #: .\cookbook\forms.py:466
msgid "" msgid ""
@ -326,8 +326,8 @@ msgid ""
"will return 'salad' and 'sandwich')" "will return 'salad' and 'sandwich')"
msgstr "" msgstr ""
"Champs permettant de rechercher les correspondances de début de mot (par " "Champs permettant de rechercher les correspondances de début de mot (par "
"exemple, si vous recherchez « sa », vous obtiendrez « salade » et " "exemple, si vous recherchez « sa », vous obtiendrez « salade » et « "
"« sandwich»)." "sandwich»)"
#: .\cookbook\forms.py:470 #: .\cookbook\forms.py:470
msgid "" msgid ""
@ -546,38 +546,36 @@ msgid "One of queryset or hash_key must be provided"
msgstr "Il est nécessaire de fournir soit le queryset, soit la clé de hachage" msgstr "Il est nécessaire de fournir soit le queryset, soit la clé de hachage"
#: .\cookbook\helper\recipe_url_import.py:266 #: .\cookbook\helper\recipe_url_import.py:266
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation" msgid "reverse rotation"
msgstr "Utiliser les fractions" msgstr "sens inverse"
#: .\cookbook\helper\recipe_url_import.py:267 #: .\cookbook\helper\recipe_url_import.py:267
msgid "careful rotation" msgid "careful rotation"
msgstr "" msgstr "sens horloger"
#: .\cookbook\helper\recipe_url_import.py:268 #: .\cookbook\helper\recipe_url_import.py:268
msgid "knead" msgid "knead"
msgstr "" msgstr "pétrir"
#: .\cookbook\helper\recipe_url_import.py:269 #: .\cookbook\helper\recipe_url_import.py:269
msgid "thicken" msgid "thicken"
msgstr "" msgstr "épaissir"
#: .\cookbook\helper\recipe_url_import.py:270 #: .\cookbook\helper\recipe_url_import.py:270
msgid "warm up" msgid "warm up"
msgstr "" msgstr "réchauffer"
#: .\cookbook\helper\recipe_url_import.py:271 #: .\cookbook\helper\recipe_url_import.py:271
msgid "ferment" msgid "ferment"
msgstr "" msgstr "fermenter"
#: .\cookbook\helper\recipe_url_import.py:272 #: .\cookbook\helper\recipe_url_import.py:272
msgid "sous-vide" msgid "sous-vide"
msgstr "" msgstr "sous-vide"
#: .\cookbook\helper\shopping_helper.py:157 #: .\cookbook\helper\shopping_helper.py:157
msgid "You must supply a servings size" msgid "You must supply a servings size"
msgstr "Vous devez fournir une information de portion" msgstr "Vous devez fournir un nombre de portions"
#: .\cookbook\helper\template_helper.py:79 #: .\cookbook\helper\template_helper.py:79
#: .\cookbook\helper\template_helper.py:81 #: .\cookbook\helper\template_helper.py:81
@ -590,7 +588,6 @@ msgid "Favorite"
msgstr "Favori" msgstr "Favori"
#: .\cookbook\integration\copymethat.py:50 #: .\cookbook\integration\copymethat.py:50
#, fuzzy
msgid "I made this" msgid "I made this"
msgstr "J'ai fait ça" msgstr "J'ai fait ça"
@ -620,10 +617,8 @@ msgid "Imported %s recipes."
msgstr "%s recettes importées." msgstr "%s recettes importées."
#: .\cookbook\integration\openeats.py:26 #: .\cookbook\integration\openeats.py:26
#, fuzzy
#| msgid "Recipe Home"
msgid "Recipe source:" msgid "Recipe source:"
msgstr "Page daccueil" msgstr "Source de la recette :"
#: .\cookbook\integration\paprika.py:49 #: .\cookbook\integration\paprika.py:49
msgid "Notes" msgid "Notes"
@ -648,7 +643,7 @@ msgstr "Portions"
#: .\cookbook\integration\saffron.py:25 #: .\cookbook\integration\saffron.py:25
msgid "Waiting time" msgid "Waiting time"
msgstr "temps dattente" msgstr "Temps dattente"
#: .\cookbook\integration\saffron.py:27 #: .\cookbook\integration\saffron.py:27
msgid "Preparation Time" msgid "Preparation Time"
@ -851,7 +846,6 @@ msgid "ID of unit to use for the shopping list"
msgstr "ID de lunité à utiliser pour la liste de courses" msgstr "ID de lunité à utiliser pour la liste de courses"
#: .\cookbook\serializer.py:1259 #: .\cookbook\serializer.py:1259
#, fuzzy
msgid "When set to true will delete all food from active shopping lists." msgid "When set to true will delete all food from active shopping lists."
msgstr "" msgstr ""
"Lorsqu'il est défini sur \"true\", tous les aliments des listes de courses " "Lorsqu'il est défini sur \"true\", tous les aliments des listes de courses "
@ -967,8 +961,9 @@ msgid ""
" ." " ."
msgstr "" msgstr ""
"Confirmez SVP que\n" "Confirmez SVP que\n"
" <a href=\"mailto:%(email)s\"></a> est une adresse mail de " " <a href=\"mailto:%(email)s\"></a> est une adresse mail de l"
"lutilisateur %(user_display)s." "utilisateur %(user_display)s\n"
" ."
#: .\cookbook\templates\account\email_confirm.html:22 #: .\cookbook\templates\account\email_confirm.html:22
#: .\cookbook\templates\generic\delete_template.html:72 #: .\cookbook\templates\generic\delete_template.html:72
@ -1371,9 +1366,8 @@ msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
msgstr "Êtes-vous sûr(e) de vouloir supprimer %(title)s : <b>%(object)s</b> " msgstr "Êtes-vous sûr(e) de vouloir supprimer %(title)s : <b>%(object)s</b> "
#: .\cookbook\templates\generic\delete_template.html:22 #: .\cookbook\templates\generic\delete_template.html:22
#, fuzzy
msgid "This cannot be undone!" msgid "This cannot be undone!"
msgstr "Cela ne peut pas être annulé !" msgstr "L'opération ne peut pas être annulée !"
#: .\cookbook\templates\generic\delete_template.html:27 #: .\cookbook\templates\generic\delete_template.html:27
msgid "Protected" msgid "Protected"
@ -1456,12 +1450,12 @@ msgid ""
" " " "
msgstr "" msgstr ""
"\n" "\n"
" Les champs <b>Mot de passe et Token</b> sont stockés <b>en texte " " Les champs <b>Mot de passe et Token</b> sont stockés <b>en clair</"
"brut</b>dans la base de données.\n" "b>dans la base de données.\n"
" C'est nécessaire car ils sont utilisés pour faire des requêtes API, " " C'est nécessaire car ils sont utilisés pour faire des requêtes API, "
"mais cela accroît le risque que quelqu'un les vole.<br/>\n" "mais cela accroît le risque que quelqu'un les vole.<br/>\n"
" Pour limiter les risques, des tokens ou comptes avec un accès limité " " Pour limiter les risques, il est possible d'utiliser des tokens ou "
"devraient être utilisés.\n" "des comptes avec un accès limité.\n"
" " " "
#: .\cookbook\templates\index.html:29 #: .\cookbook\templates\index.html:29
@ -1771,15 +1765,6 @@ msgstr ""
" " " "
#: .\cookbook\templates\search_info.html:29 #: .\cookbook\templates\search_info.html:29
#, fuzzy
#| msgid ""
#| " \n"
#| " Simple searches ignore punctuation and common words such as "
#| "'the', 'a', 'and'. And will treat seperate words as required.\n"
#| " Searching for 'apple or flour' will return any recipe that "
#| "includes both 'apple' and 'flour' anywhere in the fields that have been "
#| "selected for a full text search.\n"
#| " "
msgid "" msgid ""
" \n" " \n"
" Simple searches ignore punctuation and common words such as " " Simple searches ignore punctuation and common words such as "
@ -1791,7 +1776,7 @@ msgid ""
msgstr "" msgstr ""
" \n" " \n"
" Les recherches simples ignorent la ponctuation et les mots " " Les recherches simples ignorent la ponctuation et les mots "
"courants tels que \"le\", \"a\", \"et\", et traiteront les mots séparés " "courants tels que \"le\", \"et\", \"a\", et traiteront les mots séparés "
"comme il se doit.\n" "comme il se doit.\n"
" Si vous recherchez \"pomme ou farine\", vous obtiendrez toutes " " Si vous recherchez \"pomme ou farine\", vous obtiendrez toutes "
"les recettes qui contiennent à la fois \"pomme\" et \"farine\" dans les " "les recettes qui contiennent à la fois \"pomme\" et \"farine\" dans les "
@ -2219,7 +2204,7 @@ msgstr "Gérer labonnement"
#: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216 #: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216
msgid "Space" msgid "Space"
msgstr "Groupe :" msgstr "Groupe"
#: .\cookbook\templates\space_overview.html:17 #: .\cookbook\templates\space_overview.html:17
msgid "" msgid ""
@ -2659,7 +2644,7 @@ msgstr ""
#: .\cookbook\views\api.py:1394 #: .\cookbook\views\api.py:1394
msgid "Sync successful!" msgid "Sync successful!"
msgstr "Synchro réussie !" msgstr "Synchronisation réussie !"
#: .\cookbook\views\api.py:1399 #: .\cookbook\views\api.py:1399
msgid "Error synchronizing with Storage" msgid "Error synchronizing with Storage"
@ -2732,6 +2717,8 @@ msgid ""
"The PDF Exporter is not enabled on this instance as it is still in an " "The PDF Exporter is not enabled on this instance as it is still in an "
"experimental state." "experimental state."
msgstr "" msgstr ""
"L'export PDF n'est pas activé sur cette instance car il est toujours au "
"statut expérimental."
#: .\cookbook\views\lists.py:24 #: .\cookbook\views\lists.py:24
msgid "Import Log" msgid "Import Log"

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -11,8 +11,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n" "PO-Revision-Date: 2023-12-05 09:15+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n" "Last-Translator: Ferenc <ugyes@freemail.hu>\n"
"Language-Team: Hungarian <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Hungarian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/hu/>\n" "recipes-backend/hu/>\n"
"Language: hu_HU\n" "Language: hu_HU\n"
@ -99,7 +99,7 @@ msgstr ""
#: .\cookbook\forms.py:74 #: .\cookbook\forms.py:74
msgid "Users with whom newly created meal plans should be shared by default." msgid "Users with whom newly created meal plans should be shared by default."
msgstr "" msgstr ""
"Azok a felhasználók, akikkel az újonnan létrehozott étkezési terveket " "Azok a felhasználók, akikkel az újonnan létrehozott menüterveket "
"alapértelmezés szerint meg kell osztani." "alapértelmezés szerint meg kell osztani."
#: .\cookbook\forms.py:75 #: .\cookbook\forms.py:75
@ -135,8 +135,7 @@ msgstr "A navigációs sávot az oldal tetejére rögzíti."
#: .\cookbook\forms.py:83 .\cookbook\forms.py:512 #: .\cookbook\forms.py:83 .\cookbook\forms.py:512
msgid "Automatically add meal plan ingredients to shopping list." msgid "Automatically add meal plan ingredients to shopping list."
msgstr "" msgstr "Automatikusan hozzáadja a menüterv hozzávalóit a bevásárlólistához."
"Automatikusan hozzáadja az étkezési terv hozzávalóit a bevásárlólistához."
#: .\cookbook\forms.py:84 #: .\cookbook\forms.py:84
msgid "Exclude ingredients that are on hand." msgid "Exclude ingredients that are on hand."
@ -283,16 +282,12 @@ msgstr ""
"hibát figyelmen kívül hagynak)." "hibát figyelmen kívül hagynak)."
#: .\cookbook\forms.py:461 #: .\cookbook\forms.py:461
#, fuzzy
#| msgid ""
#| "Select type method of search. Click <a href=\"/docs/search/\">here</a> "
#| "for full desciption of choices."
msgid "" msgid ""
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for " "Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
"full description of choices." "full description of choices."
msgstr "" msgstr ""
"Válassza ki a keresés típusát. Kattintson <a href=\"/docs/search/\">ide</a> " "Válassza ki a keresés típusát. Kattintson <a href=\"/docs/search/\">ide</"
"a lehetőségek teljes leírásáért." "a> a lehetőségek teljes leírásáért."
#: .\cookbook\forms.py:462 #: .\cookbook\forms.py:462
msgid "" msgid ""
@ -360,10 +355,8 @@ msgid "Partial Match"
msgstr "Részleges találat" msgstr "Részleges találat"
#: .\cookbook\forms.py:480 #: .\cookbook\forms.py:480
#, fuzzy
#| msgid "Starts Wtih"
msgid "Starts With" msgid "Starts With"
msgstr "Kezdődik a következővel" msgstr "Ezzel kezdődik"
#: .\cookbook\forms.py:481 #: .\cookbook\forms.py:481
msgid "Fuzzy Search" msgid "Fuzzy Search"
@ -387,16 +380,16 @@ msgid ""
"When adding a meal plan to the shopping list (manually or automatically), " "When adding a meal plan to the shopping list (manually or automatically), "
"include all related recipes." "include all related recipes."
msgstr "" msgstr ""
"Amikor étkezési tervet ad hozzá a bevásárlólistához (kézzel vagy " "Amikor menütervet ad hozzá a bevásárlólistához (kézzel vagy automatikusan), "
"automatikusan), vegye fel az összes kapcsolódó receptet." "vegye fel az összes kapcsolódó receptet."
#: .\cookbook\forms.py:514 #: .\cookbook\forms.py:514
msgid "" msgid ""
"When adding a meal plan to the shopping list (manually or automatically), " "When adding a meal plan to the shopping list (manually or automatically), "
"exclude ingredients that are on hand." "exclude ingredients that are on hand."
msgstr "" msgstr ""
"Amikor étkezési tervet ad hozzá a bevásárlólistához (kézzel vagy " "Amikor menütervet ad hozzá a bevásárlólistához (kézzel vagy automatikusan), "
"automatikusan), zárja ki a kéznél lévő összetevőket." "zárja ki a kéznél lévő összetevőket."
#: .\cookbook\forms.py:515 #: .\cookbook\forms.py:515
msgid "Default number of hours to delay a shopping list entry." msgid "Default number of hours to delay a shopping list entry."
@ -436,7 +429,7 @@ msgstr "Automatikus szinkronizálás"
#: .\cookbook\forms.py:526 #: .\cookbook\forms.py:526
msgid "Auto Add Meal Plan" msgid "Auto Add Meal Plan"
msgstr "Automatikus étkezési terv hozzáadása" msgstr "Menüterv automatikus hozzáadása"
#: .\cookbook\forms.py:527 #: .\cookbook\forms.py:527
msgid "Exclude On Hand" msgid "Exclude On Hand"
@ -490,6 +483,7 @@ msgstr "A receptek számának megjelenítése a keresési szűrőkön"
#: .\cookbook\forms.py:559 #: .\cookbook\forms.py:559
msgid "Use the plural form for units and food inside this space." msgid "Use the plural form for units and food inside this space."
msgstr "" msgstr ""
"Használja a többes számot az egységek és az ételek esetében ezen a helyen."
#: .\cookbook\helper\AllAuthCustomAdapter.py:39 #: .\cookbook\helper\AllAuthCustomAdapter.py:39
msgid "" msgid ""
@ -538,10 +532,8 @@ msgid "One of queryset or hash_key must be provided"
msgstr "A queryset vagy a hash_key valamelyikét meg kell adni" msgstr "A queryset vagy a hash_key valamelyikét meg kell adni"
#: .\cookbook\helper\recipe_url_import.py:266 #: .\cookbook\helper\recipe_url_import.py:266
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation" msgid "reverse rotation"
msgstr "Törtek használata" msgstr "Ellentétes irány"
#: .\cookbook\helper\recipe_url_import.py:267 #: .\cookbook\helper\recipe_url_import.py:267
msgid "careful rotation" msgid "careful rotation"
@ -549,29 +541,27 @@ msgstr ""
#: .\cookbook\helper\recipe_url_import.py:268 #: .\cookbook\helper\recipe_url_import.py:268
msgid "knead" msgid "knead"
msgstr "" msgstr "dagasztás"
#: .\cookbook\helper\recipe_url_import.py:269 #: .\cookbook\helper\recipe_url_import.py:269
msgid "thicken" msgid "thicken"
msgstr "" msgstr "sűrítés"
#: .\cookbook\helper\recipe_url_import.py:270 #: .\cookbook\helper\recipe_url_import.py:270
msgid "warm up" msgid "warm up"
msgstr "" msgstr "melegítés"
#: .\cookbook\helper\recipe_url_import.py:271 #: .\cookbook\helper\recipe_url_import.py:271
msgid "ferment" msgid "ferment"
msgstr "" msgstr "fermentálás"
#: .\cookbook\helper\recipe_url_import.py:272 #: .\cookbook\helper\recipe_url_import.py:272
msgid "sous-vide" msgid "sous-vide"
msgstr "" msgstr "sous-vide"
#: .\cookbook\helper\shopping_helper.py:157 #: .\cookbook\helper\shopping_helper.py:157
#, fuzzy
#| msgid "You must supply a created_by"
msgid "You must supply a servings size" msgid "You must supply a servings size"
msgstr "Meg kell adnia egy created_by" msgstr "Meg kell adnia az adagok nagyságát"
#: .\cookbook\helper\template_helper.py:79 #: .\cookbook\helper\template_helper.py:79
#: .\cookbook\helper\template_helper.py:81 #: .\cookbook\helper\template_helper.py:81
@ -581,11 +571,11 @@ msgstr "Nem sikerült elemezni a sablon kódját."
#: .\cookbook\integration\copymethat.py:44 #: .\cookbook\integration\copymethat.py:44
#: .\cookbook\integration\melarecipes.py:37 #: .\cookbook\integration\melarecipes.py:37
msgid "Favorite" msgid "Favorite"
msgstr "" msgstr "Kedvenc"
#: .\cookbook\integration\copymethat.py:50 #: .\cookbook\integration\copymethat.py:50
msgid "I made this" msgid "I made this"
msgstr "" msgstr "Elkészítettem"
#: .\cookbook\integration\integration.py:218 #: .\cookbook\integration\integration.py:218
msgid "" msgid ""
@ -613,10 +603,8 @@ msgid "Imported %s recipes."
msgstr "Importálva %s recept." msgstr "Importálva %s recept."
#: .\cookbook\integration\openeats.py:26 #: .\cookbook\integration\openeats.py:26
#, fuzzy
#| msgid "Recipe Home"
msgid "Recipe source:" msgid "Recipe source:"
msgstr "Recipe Home" msgstr "Recept forrása:"
#: .\cookbook\integration\paprika.py:49 #: .\cookbook\integration\paprika.py:49
msgid "Notes" msgid "Notes"
@ -632,10 +620,8 @@ msgstr "Forrás"
#: .\cookbook\integration\recettetek.py:54 #: .\cookbook\integration\recettetek.py:54
#: .\cookbook\integration\recipekeeper.py:70 #: .\cookbook\integration\recipekeeper.py:70
#, fuzzy
#| msgid "Import Log"
msgid "Imported from" msgid "Imported from"
msgstr "Import napló" msgstr "Importálva a"
#: .\cookbook\integration\saffron.py:23 #: .\cookbook\integration\saffron.py:23
msgid "Servings" msgid "Servings"
@ -662,12 +648,10 @@ msgid "Rebuilds full text search index on Recipe"
msgstr "Újraépíti a teljes szöveges keresési indexet a Recept oldalon" msgstr "Újraépíti a teljes szöveges keresési indexet a Recept oldalon"
#: .\cookbook\management\commands\rebuildindex.py:18 #: .\cookbook\management\commands\rebuildindex.py:18
#, fuzzy
#| msgid "Only Postgress databases use full text search, no index to rebuild"
msgid "Only Postgresql databases use full text search, no index to rebuild" msgid "Only Postgresql databases use full text search, no index to rebuild"
msgstr "" msgstr ""
"Csak a Postgress adatbázisok használnak teljes szöveges keresést, nincs " "Csak a Postgresql adatbázisok használják a teljes szöveges keresést, nem "
"újjáépítenindex" "kell indexet újjáépíteni"
#: .\cookbook\management\commands\rebuildindex.py:29 #: .\cookbook\management\commands\rebuildindex.py:29
msgid "Recipe index rebuild complete." msgid "Recipe index rebuild complete."
@ -711,7 +695,7 @@ msgstr "Keresés"
#: .\cookbook\templates\meal_plan.html:7 .\cookbook\views\delete.py:178 #: .\cookbook\templates\meal_plan.html:7 .\cookbook\views\delete.py:178
#: .\cookbook\views\edit.py:211 .\cookbook\views\new.py:179 #: .\cookbook\views\edit.py:211 .\cookbook\views\new.py:179
msgid "Meal-Plan" msgid "Meal-Plan"
msgstr "Étkezési terv" msgstr "Menüterv"
#: .\cookbook\models.py:367 .\cookbook\templates\base.html:118 #: .\cookbook\models.py:367 .\cookbook\templates\base.html:118
msgid "Books" msgid "Books"
@ -750,16 +734,12 @@ msgid "Keyword Alias"
msgstr "Kulcsszó álneve" msgstr "Kulcsszó álneve"
#: .\cookbook\models.py:1232 #: .\cookbook\models.py:1232
#, fuzzy
#| msgid "Description"
msgid "Description Replace" msgid "Description Replace"
msgstr "Leírás" msgstr "Leírás csere"
#: .\cookbook\models.py:1232 #: .\cookbook\models.py:1232
#, fuzzy
#| msgid "Instructions"
msgid "Instruction Replace" msgid "Instruction Replace"
msgstr "Elkészítés" msgstr "Leírás cseréje"
#: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36 #: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36
#: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48 #: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48
@ -767,10 +747,8 @@ msgid "Recipe"
msgstr "Recept" msgstr "Recept"
#: .\cookbook\models.py:1259 #: .\cookbook\models.py:1259
#, fuzzy
#| msgid "Foods"
msgid "Food" msgid "Food"
msgstr "Ételek" msgstr "Étel"
#: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141 #: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141
msgid "Keyword" msgid "Keyword"
@ -786,7 +764,7 @@ msgstr "Elérte a fájlfeltöltési limitet."
#: .\cookbook\serializer.py:291 #: .\cookbook\serializer.py:291
msgid "Cannot modify Space owner permission." msgid "Cannot modify Space owner permission."
msgstr "" msgstr "A Hely tulajdonosi engedélye nem módosítható."
#: .\cookbook\serializer.py:1093 #: .\cookbook\serializer.py:1093
msgid "Hello" msgid "Hello"
@ -1176,7 +1154,7 @@ msgstr "Ételek"
#: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122 #: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122
msgid "Units" msgid "Units"
msgstr "Egységek" msgstr "Mértékegységek"
#: .\cookbook\templates\base.html:179 .\cookbook\templates\supermarket.html:7 #: .\cookbook\templates\base.html:179 .\cookbook\templates\supermarket.html:7
msgid "Supermarket" msgid "Supermarket"
@ -1206,10 +1184,8 @@ msgstr "Előzmények"
#: .\cookbook\templates\base.html:255 #: .\cookbook\templates\base.html:255
#: .\cookbook\templates\ingredient_editor.html:7 #: .\cookbook\templates\ingredient_editor.html:7
#: .\cookbook\templates\ingredient_editor.html:13 #: .\cookbook\templates\ingredient_editor.html:13
#, fuzzy
#| msgid "Ingredients"
msgid "Ingredient Editor" msgid "Ingredient Editor"
msgstr "Hozzávalók" msgstr "Hozzávaló szerkesztő"
#: .\cookbook\templates\base.html:267 #: .\cookbook\templates\base.html:267
#: .\cookbook\templates\export_response.html:7 #: .\cookbook\templates\export_response.html:7
@ -1244,15 +1220,13 @@ msgstr "Admin"
#: .\cookbook\templates\base.html:312 #: .\cookbook\templates\base.html:312
#: .\cookbook\templates\space_overview.html:25 #: .\cookbook\templates\space_overview.html:25
#, fuzzy
#| msgid "No Space"
msgid "Your Spaces" msgid "Your Spaces"
msgstr "Nincs hely" msgstr "Ön Helye"
#: .\cookbook\templates\base.html:323 #: .\cookbook\templates\base.html:323
#: .\cookbook\templates\space_overview.html:6 #: .\cookbook\templates\space_overview.html:6
msgid "Overview" msgid "Overview"
msgstr "" msgstr "Áttekintés"
#: .\cookbook\templates\base.html:327 #: .\cookbook\templates\base.html:327
msgid "Markdown Guide" msgid "Markdown Guide"
@ -1276,11 +1250,11 @@ msgstr "Kijelentkezés"
#: .\cookbook\templates\base.html:360 #: .\cookbook\templates\base.html:360
msgid "You are using the free version of Tandor" msgid "You are using the free version of Tandor"
msgstr "" msgstr "Ön a Tandoor ingyenes verzióját használja"
#: .\cookbook\templates\base.html:361 #: .\cookbook\templates\base.html:361
msgid "Upgrade Now" msgid "Upgrade Now"
msgstr "" msgstr "Frissítés most"
#: .\cookbook\templates\batch\edit.html:6 #: .\cookbook\templates\batch\edit.html:6
msgid "Batch edit Category" msgid "Batch edit Category"
@ -1377,7 +1351,7 @@ msgstr "Biztos, hogy törölni akarod a %(title)s: <b>%(object)s</b> "
#: .\cookbook\templates\generic\delete_template.html:22 #: .\cookbook\templates\generic\delete_template.html:22
msgid "This cannot be undone!" msgid "This cannot be undone!"
msgstr "" msgstr "Ezt nem lehet visszafordítani!"
#: .\cookbook\templates\generic\delete_template.html:27 #: .\cookbook\templates\generic\delete_template.html:27
msgid "Protected" msgid "Protected"
@ -1541,8 +1515,6 @@ msgstr "A sortörés a sor vége után két szóköz hozzáadásával történik
#: .\cookbook\templates\markdown_info.html:57 #: .\cookbook\templates\markdown_info.html:57
#: .\cookbook\templates\markdown_info.html:73 #: .\cookbook\templates\markdown_info.html:73
#, fuzzy
#| msgid "or by leaving a blank line inbetween."
msgid "or by leaving a blank line in between." msgid "or by leaving a blank line in between."
msgstr "vagy egy üres sort hagyva közöttük." msgstr "vagy egy üres sort hagyva közöttük."
@ -1566,10 +1538,6 @@ msgid "Lists"
msgstr "Listák" msgstr "Listák"
#: .\cookbook\templates\markdown_info.html:85 #: .\cookbook\templates\markdown_info.html:85
#, fuzzy
#| msgid ""
#| "Lists can ordered or unorderd. It is <b>important to leave a blank line "
#| "before the list!</b>"
msgid "" msgid ""
"Lists can ordered or unordered. It is <b>important to leave a blank line " "Lists can ordered or unordered. It is <b>important to leave a blank line "
"before the list!</b>" "before the list!</b>"
@ -1701,11 +1669,11 @@ msgstr ""
#: .\cookbook\templates\openid\login.html:27 #: .\cookbook\templates\openid\login.html:27
#: .\cookbook\templates\socialaccount\authentication_error.html:27 #: .\cookbook\templates\socialaccount\authentication_error.html:27
msgid "Back" msgid "Back"
msgstr "" msgstr "Vissza"
#: .\cookbook\templates\profile.html:7 #: .\cookbook\templates\profile.html:7
msgid "Profile" msgid "Profile"
msgstr "" msgstr "Profil"
#: .\cookbook\templates\recipe_view.html:41 #: .\cookbook\templates\recipe_view.html:41
msgid "by" msgid "by"
@ -1718,7 +1686,7 @@ msgstr "Megjegyzés"
#: .\cookbook\templates\rest_framework\api.html:5 #: .\cookbook\templates\rest_framework\api.html:5
msgid "Recipe Home" msgid "Recipe Home"
msgstr "Recipe Home" msgstr "Recept főoldal"
#: .\cookbook\templates\search_info.html:5 #: .\cookbook\templates\search_info.html:5
#: .\cookbook\templates\search_info.html:9 #: .\cookbook\templates\search_info.html:9
@ -2104,17 +2072,15 @@ msgstr "Szuperfelhasználói fiók létrehozása"
#: .\cookbook\templates\socialaccount\authentication_error.html:7 #: .\cookbook\templates\socialaccount\authentication_error.html:7
#: .\cookbook\templates\socialaccount\authentication_error.html:23 #: .\cookbook\templates\socialaccount\authentication_error.html:23
#, fuzzy
#| msgid "Social Login"
msgid "Social Network Login Failure" msgid "Social Network Login Failure"
msgstr "Közösségi bejelentkezés" msgstr "Közösségi hálózat bejelentkezési hiba"
#: .\cookbook\templates\socialaccount\authentication_error.html:25 #: .\cookbook\templates\socialaccount\authentication_error.html:25
#, fuzzy
#| msgid "An error occurred attempting to move "
msgid "" msgid ""
"An error occurred while attempting to login via your social network account." "An error occurred while attempting to login via your social network account."
msgstr "Hiba történt az áthelyezés közben " msgstr ""
"Hiba történt, miközben megpróbált bejelentkezni a közösségi hálózati fiókján "
"keresztül."
#: .\cookbook\templates\socialaccount\connections.html:4 #: .\cookbook\templates\socialaccount\connections.html:4
#: .\cookbook\templates\socialaccount\connections.html:15 #: .\cookbook\templates\socialaccount\connections.html:15
@ -2152,17 +2118,19 @@ msgstr "Regisztráció"
#: .\cookbook\templates\socialaccount\login.html:9 #: .\cookbook\templates\socialaccount\login.html:9
#, python-format #, python-format
msgid "Connect %(provider)s" msgid "Connect %(provider)s"
msgstr "" msgstr "Csatlakozás %(provider)s"
#: .\cookbook\templates\socialaccount\login.html:11 #: .\cookbook\templates\socialaccount\login.html:11
#, python-format #, python-format
msgid "You are about to connect a new third party account from %(provider)s." msgid "You are about to connect a new third party account from %(provider)s."
msgstr "" msgstr ""
"Ön egy új, harmadik féltől származó fiókot készül csatlakoztatni "
"a%(provider)-tól/től."
#: .\cookbook\templates\socialaccount\login.html:13 #: .\cookbook\templates\socialaccount\login.html:13
#, python-format #, python-format
msgid "Sign In Via %(provider)s" msgid "Sign In Via %(provider)s"
msgstr "" msgstr "Bejelentkezve %(provider)s keresztül"
#: .\cookbook\templates\socialaccount\login.html:15 #: .\cookbook\templates\socialaccount\login.html:15
#, python-format #, python-format
@ -2171,7 +2139,7 @@ msgstr ""
#: .\cookbook\templates\socialaccount\login.html:20 #: .\cookbook\templates\socialaccount\login.html:20
msgid "Continue" msgid "Continue"
msgstr "" msgstr "Folytatás"
#: .\cookbook\templates\socialaccount\signup.html:10 #: .\cookbook\templates\socialaccount\signup.html:10
#, python-format #, python-format
@ -2210,10 +2178,8 @@ msgid "Manage Subscription"
msgstr "Feliratkozás kezelése" msgstr "Feliratkozás kezelése"
#: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216 #: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216
#, fuzzy
#| msgid "Space:"
msgid "Space" msgid "Space"
msgstr "Tér:" msgstr "Tér"
#: .\cookbook\templates\space_overview.html:17 #: .\cookbook\templates\space_overview.html:17
msgid "" msgid ""
@ -2230,13 +2196,11 @@ msgstr "Meghívást kaphatsz egy meglévő térbe, vagy létrehozhatod a sajáto
#: .\cookbook\templates\space_overview.html:53 #: .\cookbook\templates\space_overview.html:53
msgid "Owner" msgid "Owner"
msgstr "" msgstr "Tulajdonos"
#: .\cookbook\templates\space_overview.html:57 #: .\cookbook\templates\space_overview.html:57
#, fuzzy
#| msgid "Create Space"
msgid "Leave Space" msgid "Leave Space"
msgstr "Tér létrehozása" msgstr "Kilépés a Térből"
#: .\cookbook\templates\space_overview.html:78 #: .\cookbook\templates\space_overview.html:78
#: .\cookbook\templates\space_overview.html:88 #: .\cookbook\templates\space_overview.html:88
@ -2485,87 +2449,111 @@ msgstr ""
"teljes szöveges keresés is." "teljes szöveges keresés is."
#: .\cookbook\views\api.py:733 #: .\cookbook\views\api.py:733
#, fuzzy
#| msgid "ID of keyword a recipe should have. For multiple repeat parameter."
msgid "" msgid ""
"ID of keyword a recipe should have. For multiple repeat parameter. " "ID of keyword a recipe should have. For multiple repeat parameter. "
"Equivalent to keywords_or" "Equivalent to keywords_or"
msgstr "" msgstr ""
"A recept kulcsszavának azonosítója. Többszörös ismétlődő paraméter esetén." "A recept kulcsszavának azonosítója. Többszörös ismétlődő paraméter esetén. "
"Egyenértékű a keywords_or kulcsszavakkal"
#: .\cookbook\views\api.py:736 #: .\cookbook\views\api.py:736
msgid "" msgid ""
"Keyword IDs, repeat for multiple. Return recipes with any of the keywords" "Keyword IDs, repeat for multiple. Return recipes with any of the keywords"
msgstr "" msgstr ""
"Kulcsszó azonosítók. Többször is megadható. A megadott kulcsszavak "
"mindegyikéhez tartozó receptek listázza"
#: .\cookbook\views\api.py:739 #: .\cookbook\views\api.py:739
msgid "" msgid ""
"Keyword IDs, repeat for multiple. Return recipes with all of the keywords." "Keyword IDs, repeat for multiple. Return recipes with all of the keywords."
msgstr "" msgstr ""
"Kulcsszó azonosítók. Többször is megadható. Az összes megadott kulcsszót "
"tartalmazó receptek listázása."
#: .\cookbook\views\api.py:742 #: .\cookbook\views\api.py:742
msgid "" msgid ""
"Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords." "Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords."
msgstr "" msgstr ""
"Kulcsszó azonosító. Többször is megadható. Kizárja a recepteket a megadott "
"kulcsszavak egyikéből."
#: .\cookbook\views\api.py:745 #: .\cookbook\views\api.py:745
msgid "" msgid ""
"Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords." "Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords."
msgstr "" msgstr ""
"Kulcsszó azonosítók. Többször is megadható. Kizárja az összes megadott "
"kulcsszóval rendelkező receptet."
#: .\cookbook\views\api.py:747 #: .\cookbook\views\api.py:747
msgid "ID of food a recipe should have. For multiple repeat parameter." msgid "ID of food a recipe should have. For multiple repeat parameter."
msgstr "" msgstr ""
"Az ételek azonosítója egy receptnek tartalmaznia kell. Többszörös ismétlődő " "Annak az összetevőnek az azonosítója, amelynek receptjeit fel kell sorolni. "
"paraméter esetén." "Többször is megadható."
#: .\cookbook\views\api.py:750 #: .\cookbook\views\api.py:750
msgid "Food IDs, repeat for multiple. Return recipes with any of the foods" msgid "Food IDs, repeat for multiple. Return recipes with any of the foods"
msgstr "" msgstr ""
"Összetevő azonosító. Többször is megadható. Legalább egy összetevő "
"receptjeinek listája"
#: .\cookbook\views\api.py:752 #: .\cookbook\views\api.py:752
msgid "Food IDs, repeat for multiple. Return recipes with all of the foods." msgid "Food IDs, repeat for multiple. Return recipes with all of the foods."
msgstr "" msgstr ""
"Összetevő azonosító. Többször is megadható. Az összes megadott összetevőt "
"tartalmazó receptek listája."
#: .\cookbook\views\api.py:754 #: .\cookbook\views\api.py:754
msgid "Food IDs, repeat for multiple. Exclude recipes with any of the foods." msgid "Food IDs, repeat for multiple. Exclude recipes with any of the foods."
msgstr "" msgstr ""
"Összetevő azonosító. Többször is megadható. Kizárja azokat a recepteket, "
"amelyek a megadott összetevők bármelyikét tartalmazzák."
#: .\cookbook\views\api.py:756 #: .\cookbook\views\api.py:756
msgid "Food IDs, repeat for multiple. Exclude recipes with all of the foods." msgid "Food IDs, repeat for multiple. Exclude recipes with all of the foods."
msgstr "" msgstr ""
"Összetevő azonosító. Többször is megadható. Kizárja az összes megadott "
"összetevőt tartalmazó recepteket."
#: .\cookbook\views\api.py:757 #: .\cookbook\views\api.py:757
msgid "ID of unit a recipe should have." msgid "ID of unit a recipe should have."
msgstr "Az egység azonosítója, amellyel a receptnek rendelkeznie kell." msgstr "A recepthez tartozó mértékegység azonosítója."
#: .\cookbook\views\api.py:759 #: .\cookbook\views\api.py:759
msgid "" msgid ""
"Rating a recipe should have or greater. [0 - 5] Negative value filters " "Rating a recipe should have or greater. [0 - 5] Negative value filters "
"rating less than." "rating less than."
msgstr "" msgstr ""
"Egy recept minimális értékelése (0-5). A negatív értékek a maximális "
"értékelés szerint szűrnek."
#: .\cookbook\views\api.py:760 #: .\cookbook\views\api.py:760
msgid "ID of book a recipe should be in. For multiple repeat parameter." msgid "ID of book a recipe should be in. For multiple repeat parameter."
msgstr "" msgstr ""
"A könyv azonosítója, amelyben a receptnek szerepelnie kell. Többszörös " "A könyv azonosítója, amelyben a recept található. Többször is megadható."
"ismétlés esetén paraméter."
#: .\cookbook\views\api.py:762 #: .\cookbook\views\api.py:762
msgid "Book IDs, repeat for multiple. Return recipes with any of the books" msgid "Book IDs, repeat for multiple. Return recipes with any of the books"
msgstr "" msgstr ""
"A könyv azonosítója. Többször is megadható. A megadott könyvek összes "
"receptjének listája"
#: .\cookbook\views\api.py:764 #: .\cookbook\views\api.py:764
msgid "Book IDs, repeat for multiple. Return recipes with all of the books." msgid "Book IDs, repeat for multiple. Return recipes with all of the books."
msgstr "" msgstr ""
"A könyv azonosítója. Többször is megadható. Az összes könyvben szereplő "
"recept listája."
#: .\cookbook\views\api.py:766 #: .\cookbook\views\api.py:766
msgid "Book IDs, repeat for multiple. Exclude recipes with any of the books." msgid "Book IDs, repeat for multiple. Exclude recipes with any of the books."
msgstr "" msgstr ""
"A könyv azonosítói. Többször is megadható. Kizárja a megadott könyvek "
"receptjeit."
#: .\cookbook\views\api.py:768 #: .\cookbook\views\api.py:768
msgid "Book IDs, repeat for multiple. Exclude recipes with all of the books." msgid "Book IDs, repeat for multiple. Exclude recipes with all of the books."
msgstr "" msgstr ""
"A könyv azonosítói. Többször is megadható. Kizárja az összes megadott "
"könyvben szereplő receptet."
#: .\cookbook\views\api.py:770 #: .\cookbook\views\api.py:770
msgid "If only internal recipes should be returned. [true/<b>false</b>]" msgid "If only internal recipes should be returned. [true/<b>false</b>]"
@ -2587,36 +2575,50 @@ msgid ""
"Filter recipes cooked X times or more. Negative values returns cooked less " "Filter recipes cooked X times or more. Negative values returns cooked less "
"than X times" "than X times"
msgstr "" msgstr ""
"X-szer vagy többször főzött receptek szűrése. A negatív értékek X "
"alkalomnál kevesebbet főzött recepteket jelenítik meg"
#: .\cookbook\views\api.py:778 #: .\cookbook\views\api.py:778
msgid "" msgid ""
"Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on " "Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on "
"or before date." "or before date."
msgstr "" msgstr ""
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
"vagy később főztek meg utoljára. A - jelölve az adott dátumon vagy azt "
"megelőzően elkészítettek kerülnek be a receptek listájába."
#: .\cookbook\views\api.py:780 #: .\cookbook\views\api.py:780
msgid "" msgid ""
"Filter recipes created on or after YYYY-MM-DD. Prepending - filters on or " "Filter recipes created on or after YYYY-MM-DD. Prepending - filters on or "
"before date." "before date."
msgstr "" msgstr ""
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
"vagy később hoztak létre. A - jelölve az adott dátumon vagy azt megelőzően "
"hozták létre."
#: .\cookbook\views\api.py:782 #: .\cookbook\views\api.py:782
msgid "" msgid ""
"Filter recipes updated on or after YYYY-MM-DD. Prepending - filters on or " "Filter recipes updated on or after YYYY-MM-DD. Prepending - filters on or "
"before date." "before date."
msgstr "" msgstr ""
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
"vagy később frissültek. A - jelölve az adott dátumon vagy azt megelőzően "
"frissültek."
#: .\cookbook\views\api.py:784 #: .\cookbook\views\api.py:784
msgid "" msgid ""
"Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending - filters on " "Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending - filters on "
"or before date." "or before date."
msgstr "" msgstr ""
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
"vagy később néztek meg utoljára. A - jelölve az adott dátumon vagy azt "
"megelőzően néztek meg utoljára."
#: .\cookbook\views\api.py:786 #: .\cookbook\views\api.py:786
#, fuzzy
#| msgid "If only internal recipes should be returned. [true/<b>false</b>]"
msgid "Filter recipes that can be made with OnHand food. [true/<b>false</b>]" msgid "Filter recipes that can be made with OnHand food. [true/<b>false</b>]"
msgstr "Ha csak a belső recepteket kell visszaadni. [true/<b>false</b>]" msgstr ""
"Felsorolja azokat a recepteket, amelyeket a rendelkezésre álló összetevőkből "
"el lehet készíteni. [true/<b>false</b>]"
#: .\cookbook\views\api.py:946 #: .\cookbook\views\api.py:946
msgid "" msgid ""
@ -2647,7 +2649,7 @@ msgstr "Semmi feladat."
#: .\cookbook\views\api.py:1198 #: .\cookbook\views\api.py:1198
msgid "Invalid Url" msgid "Invalid Url"
msgstr "" msgstr "Érvénytelen URL"
#: .\cookbook\views\api.py:1205 #: .\cookbook\views\api.py:1205
msgid "Connection Refused." msgid "Connection Refused."
@ -2655,13 +2657,11 @@ msgstr "Kapcsolat megtagadva."
#: .\cookbook\views\api.py:1210 #: .\cookbook\views\api.py:1210
msgid "Bad URL Schema." msgid "Bad URL Schema."
msgstr "" msgstr "Rossz URL séma."
#: .\cookbook\views\api.py:1233 #: .\cookbook\views\api.py:1233
#, fuzzy
#| msgid "No useable data could be found."
msgid "No usable data could be found." msgid "No usable data could be found."
msgstr "Nem találtam használható adatokat." msgstr "Nem sikerült használható adatokat találni."
#: .\cookbook\views\api.py:1326 .\cookbook\views\import_export.py:117 #: .\cookbook\views\api.py:1326 .\cookbook\views\import_export.py:117
msgid "Importing is not implemented for this provider" msgid "Importing is not implemented for this provider"
@ -2774,10 +2774,8 @@ msgid "Shopping Categories"
msgstr "Bevásárlási kategóriák" msgstr "Bevásárlási kategóriák"
#: .\cookbook\views\lists.py:187 #: .\cookbook\views\lists.py:187
#, fuzzy
#| msgid "Filter"
msgid "Custom Filters" msgid "Custom Filters"
msgstr "Szűrő" msgstr "Egyedi szűrők"
#: .\cookbook\views\lists.py:224 #: .\cookbook\views\lists.py:224
msgid "Steps" msgid "Steps"

View File

@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-11 15:09+0200\n" "POT-Creation-Date: 2021-04-11 15:09+0200\n"
"PO-Revision-Date: 2023-04-17 20:55+0000\n" "PO-Revision-Date: 2023-08-19 21:36+0000\n"
"Last-Translator: Espen Sellevåg <buskmenn.drammer03@icloud.com>\n" "Last-Translator: NeoID <neoid@animenord.com>\n"
"Language-Team: Norwegian Bokmål <http://translate.tandoor.dev/projects/" "Language-Team: Norwegian Bokmål <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/nb_NO/>\n" "tandoor/recipes-backend/nb_NO/>\n"
"Language: nb_NO\n" "Language: nb_NO\n"
@ -31,6 +31,8 @@ msgid ""
"Color of the top navigation bar. Not all colors work with all themes, just " "Color of the top navigation bar. Not all colors work with all themes, just "
"try them out!" "try them out!"
msgstr "" msgstr ""
"Farge på toppnavigasjonslinjen. Ikke alle farger fungerer med alle temaer, "
"så bare prøv dem ut!"
#: .\cookbook\forms.py:46 #: .\cookbook\forms.py:46
msgid "Default Unit to be used when inserting a new ingredient into a recipe." msgid "Default Unit to be used when inserting a new ingredient into a recipe."
@ -79,13 +81,15 @@ msgstr ""
#: .\cookbook\forms.py:56 #: .\cookbook\forms.py:56
msgid "Makes the navbar stick to the top of the page." msgid "Makes the navbar stick to the top of the page."
msgstr "" msgstr "Fest navigasjonslinjen til toppen av siden."
#: .\cookbook\forms.py:72 #: .\cookbook\forms.py:72
msgid "" msgid ""
"Both fields are optional. If none are given the username will be displayed " "Both fields are optional. If none are given the username will be displayed "
"instead" "instead"
msgstr "" msgstr ""
"Begge feltene er valgfrie. Hvis ingen blir oppgitt, vil brukernavnet vises i "
"stedet"
#: .\cookbook\forms.py:93 .\cookbook\forms.py:315 #: .\cookbook\forms.py:93 .\cookbook\forms.py:315
#: .\cookbook\templates\forms\edit_internal_recipe.html:45 #: .\cookbook\templates\forms\edit_internal_recipe.html:45
@ -97,15 +101,15 @@ msgstr "Navn"
#: .\cookbook\templates\forms\edit_internal_recipe.html:81 #: .\cookbook\templates\forms\edit_internal_recipe.html:81
#: .\cookbook\templates\stats.html:24 .\cookbook\templates\url_import.html:202 #: .\cookbook\templates\stats.html:24 .\cookbook\templates\url_import.html:202
msgid "Keywords" msgid "Keywords"
msgstr "" msgstr "Nøkkelord"
#: .\cookbook\forms.py:95 #: .\cookbook\forms.py:95
msgid "Preparation time in minutes" msgid "Preparation time in minutes"
msgstr "" msgstr "Forberedelsestid i minutter"
#: .\cookbook\forms.py:96 #: .\cookbook\forms.py:96
msgid "Waiting time (cooking/baking) in minutes" msgid "Waiting time (cooking/baking) in minutes"
msgstr "" msgstr "Ventetid (til matlaging/baking) i minutter"
#: .\cookbook\forms.py:97 .\cookbook\forms.py:317 #: .\cookbook\forms.py:97 .\cookbook\forms.py:317
msgid "Path" msgid "Path"
@ -124,6 +128,8 @@ msgid ""
"To prevent duplicates recipes with the same name as existing ones are " "To prevent duplicates recipes with the same name as existing ones are "
"ignored. Check this box to import everything." "ignored. Check this box to import everything."
msgstr "" msgstr ""
"For å unngå duplikater, blir oppskrifter med samme navn som eksisterende "
"ignorert. Merk av denne boksen for å importere alt."
#: .\cookbook\forms.py:149 #: .\cookbook\forms.py:149
msgid "New Unit" msgid "New Unit"
@ -131,7 +137,7 @@ msgstr "Ny enhet"
#: .\cookbook\forms.py:150 #: .\cookbook\forms.py:150
msgid "New unit that other gets replaced by." msgid "New unit that other gets replaced by."
msgstr "" msgstr "Ny enhet som erstatter den gamle."
#: .\cookbook\forms.py:155 #: .\cookbook\forms.py:155
msgid "Old Unit" msgid "Old Unit"
@ -143,19 +149,19 @@ msgstr "Enhet som skal erstattes."
#: .\cookbook\forms.py:172 #: .\cookbook\forms.py:172
msgid "New Food" msgid "New Food"
msgstr "" msgstr "Ny matvare"
#: .\cookbook\forms.py:173 #: .\cookbook\forms.py:173
msgid "New food that other gets replaced by." msgid "New food that other gets replaced by."
msgstr "" msgstr "Ny matvare som erstatter den gamle."
#: .\cookbook\forms.py:178 #: .\cookbook\forms.py:178
msgid "Old Food" msgid "Old Food"
msgstr "" msgstr "Gammel matvare"
#: .\cookbook\forms.py:179 #: .\cookbook\forms.py:179
msgid "Food that should be replaced." msgid "Food that should be replaced."
msgstr "" msgstr "Matvare som bør erstattes."
#: .\cookbook\forms.py:197 #: .\cookbook\forms.py:197
msgid "Add your comment: " msgid "Add your comment: "
@ -163,17 +169,19 @@ msgstr "Legg til din kommentar: "
#: .\cookbook\forms.py:238 #: .\cookbook\forms.py:238
msgid "Leave empty for dropbox and enter app password for nextcloud." msgid "Leave empty for dropbox and enter app password for nextcloud."
msgstr "" msgstr "La det stå tomt for Dropbox og skriv inn app-passordet for Nextcloud."
#: .\cookbook\forms.py:245 #: .\cookbook\forms.py:245
msgid "Leave empty for nextcloud and enter api token for dropbox." msgid "Leave empty for nextcloud and enter api token for dropbox."
msgstr "" msgstr "La det stå tomt for Nextcloud og skriv inn API-tokenet for Dropbox."
#: .\cookbook\forms.py:253 #: .\cookbook\forms.py:253
msgid "" msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote." "Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)" "php/webdav/</code> is added automatically)"
msgstr "" msgstr ""
"La det stå tomt for Dropbox, og skriv bare inn grunn-URLen for Nextcloud "
"(<code>/remote.php/webdav/</code> blir lagt til automatisk)"
#: .\cookbook\forms.py:291 #: .\cookbook\forms.py:291
msgid "Search String" msgid "Search String"
@ -185,11 +193,12 @@ msgstr "Fil-ID"
#: .\cookbook\forms.py:354 #: .\cookbook\forms.py:354
msgid "You must provide at least a recipe or a title." msgid "You must provide at least a recipe or a title."
msgstr "" msgstr "Du må oppgi minst en oppskrift eller en tittel."
#: .\cookbook\forms.py:367 #: .\cookbook\forms.py:367
msgid "You can list default users to share recipes with in the settings." msgid "You can list default users to share recipes with in the settings."
msgstr "" msgstr ""
"Du kan liste opp standardbrukere for å dele oppskrifter innen innstillingene."
#: .\cookbook\forms.py:368 #: .\cookbook\forms.py:368
#: .\cookbook\templates\forms\edit_internal_recipe.html:377 #: .\cookbook\templates\forms\edit_internal_recipe.html:377
@ -197,10 +206,14 @@ msgid ""
"You can use markdown to format this field. See the <a href=\"/docs/markdown/" "You can use markdown to format this field. See the <a href=\"/docs/markdown/"
"\">docs here</a>" "\">docs here</a>"
msgstr "" msgstr ""
"Du kan bruke Markdown for å formatere dette feltet. Se <a href=\"/docs/"
"markdown/\">dokumentasjonen her</a>"
#: .\cookbook\forms.py:393 #: .\cookbook\forms.py:393
msgid "A username is not required, if left blank the new user can choose one." msgid "A username is not required, if left blank the new user can choose one."
msgstr "" msgstr ""
"Et brukernavn er ikke påkrevd. Hvis det blir stående tomt, kan den nye "
"brukeren velge ett selv."
#: .\cookbook\helper\permission_helper.py:123 #: .\cookbook\helper\permission_helper.py:123
#: .\cookbook\helper\permission_helper.py:129 #: .\cookbook\helper\permission_helper.py:129
@ -222,26 +235,30 @@ msgstr "Du er ikke innlogget og kan derfor ikke vise siden!"
#: .\cookbook\helper\permission_helper.py:167 #: .\cookbook\helper\permission_helper.py:167
#: .\cookbook\helper\permission_helper.py:182 #: .\cookbook\helper\permission_helper.py:182
msgid "You cannot interact with this object as it is not owned by you!" msgid "You cannot interact with this object as it is not owned by you!"
msgstr "" msgstr "Du kan ikke samhandle med dette objektet, da det ikke tilhører deg!"
#: .\cookbook\helper\recipe_url_import.py:40 .\cookbook\views\api.py:549 #: .\cookbook\helper\recipe_url_import.py:40 .\cookbook\views\api.py:549
msgid "The requested site provided malformed data and cannot be read." msgid "The requested site provided malformed data and cannot be read."
msgstr "" msgstr ""
"Nettstedet du har forespurt, har levert feilformatert data som ikke kan "
"leses."
#: .\cookbook\helper\recipe_url_import.py:54 #: .\cookbook\helper\recipe_url_import.py:54
msgid "" msgid ""
"The requested site does not provide any recognized data format to import the " "The requested site does not provide any recognized data format to import the "
"recipe from." "recipe from."
msgstr "" msgstr ""
"Det forespurte nettstedet gir ingen gjenkjennelig dataformat som kan "
"importeres oppskriften fra."
#: .\cookbook\helper\recipe_url_import.py:160 #: .\cookbook\helper\recipe_url_import.py:160
msgid "Imported from" msgid "Imported from"
msgstr "" msgstr "Importert fra"
#: .\cookbook\helper\template_helper.py:60 #: .\cookbook\helper\template_helper.py:60
#: .\cookbook\helper\template_helper.py:62 #: .\cookbook\helper\template_helper.py:62
msgid "Could not parse template code." msgid "Could not parse template code."
msgstr "" msgstr "Kunne ikke analysere mal-koden."
#: .\cookbook\integration\integration.py:102 #: .\cookbook\integration\integration.py:102
#: .\cookbook\templates\import.html:14 .\cookbook\templates\import.html:20 #: .\cookbook\templates\import.html:14 .\cookbook\templates\import.html:20
@ -250,50 +267,52 @@ msgstr ""
#: .\cookbook\templates\url_import.html:233 .\cookbook\views\delete.py:60 #: .\cookbook\templates\url_import.html:233 .\cookbook\views\delete.py:60
#: .\cookbook\views\edit.py:190 #: .\cookbook\views\edit.py:190
msgid "Import" msgid "Import"
msgstr "" msgstr "Importér"
#: .\cookbook\integration\integration.py:131 #: .\cookbook\integration\integration.py:131
msgid "" msgid ""
"Importer expected a .zip file. Did you choose the correct importer type for " "Importer expected a .zip file. Did you choose the correct importer type for "
"your data ?" "your data ?"
msgstr "" msgstr ""
"Importøren forventet en .zip-fil. Har du valgt riktig type importør for "
"dataene dine?"
#: .\cookbook\integration\integration.py:134 #: .\cookbook\integration\integration.py:134
msgid "The following recipes were ignored because they already existed:" msgid "The following recipes were ignored because they already existed:"
msgstr "" msgstr "Følgende oppskrifter ble ignorert fordi de allerede eksisterte:"
#: .\cookbook\integration\integration.py:137 #: .\cookbook\integration\integration.py:137
#, python-format #, python-format
msgid "Imported %s recipes." msgid "Imported %s recipes."
msgstr "" msgstr "Importerte %s oppskrifter."
#: .\cookbook\integration\paprika.py:44 #: .\cookbook\integration\paprika.py:44
msgid "Notes" msgid "Notes"
msgstr "" msgstr "Notater"
#: .\cookbook\integration\paprika.py:47 #: .\cookbook\integration\paprika.py:47
msgid "Nutritional Information" msgid "Nutritional Information"
msgstr "" msgstr "Næringsinformasjon"
#: .\cookbook\integration\paprika.py:50 #: .\cookbook\integration\paprika.py:50
msgid "Source" msgid "Source"
msgstr "" msgstr "Kilde"
#: .\cookbook\integration\safron.py:23 #: .\cookbook\integration\safron.py:23
#: .\cookbook\templates\forms\edit_internal_recipe.html:75 #: .\cookbook\templates\forms\edit_internal_recipe.html:75
#: .\cookbook\templates\include\log_cooking.html:16 #: .\cookbook\templates\include\log_cooking.html:16
#: .\cookbook\templates\url_import.html:84 #: .\cookbook\templates\url_import.html:84
msgid "Servings" msgid "Servings"
msgstr "" msgstr "Porsjoner"
#: .\cookbook\integration\safron.py:25 #: .\cookbook\integration\safron.py:25
msgid "Waiting time" msgid "Waiting time"
msgstr "" msgstr "Ventetid"
#: .\cookbook\integration\safron.py:27 #: .\cookbook\integration\safron.py:27
#: .\cookbook\templates\forms\edit_internal_recipe.html:69 #: .\cookbook\templates\forms\edit_internal_recipe.html:69
msgid "Preparation Time" msgid "Preparation Time"
msgstr "" msgstr "Forberedelsestid"
#: .\cookbook\integration\safron.py:29 .\cookbook\templates\base.html:71 #: .\cookbook\integration\safron.py:29 .\cookbook\templates\base.html:71
#: .\cookbook\templates\forms\ingredients.html:7 #: .\cookbook\templates\forms\ingredients.html:7
@ -329,7 +348,7 @@ msgstr "Søk"
#: .\cookbook\templates\meal_plan.html:5 .\cookbook\views\delete.py:152 #: .\cookbook\templates\meal_plan.html:5 .\cookbook\views\delete.py:152
#: .\cookbook\views\edit.py:224 .\cookbook\views\new.py:188 #: .\cookbook\views\edit.py:224 .\cookbook\views\new.py:188
msgid "Meal-Plan" msgid "Meal-Plan"
msgstr "" msgstr "Måltidsplan"
#: .\cookbook\models.py:112 .\cookbook\templates\base.html:82 #: .\cookbook\models.py:112 .\cookbook\templates\base.html:82
msgid "Books" msgid "Books"
@ -337,11 +356,11 @@ msgstr "Bøker"
#: .\cookbook\models.py:119 #: .\cookbook\models.py:119
msgid "Small" msgid "Small"
msgstr "" msgstr "Liten"
#: .\cookbook\models.py:119 #: .\cookbook\models.py:119
msgid "Large" msgid "Large"
msgstr "" msgstr "Stor"
#: .\cookbook\models.py:327 #: .\cookbook\models.py:327
#: .\cookbook\templates\forms\edit_internal_recipe.html:198 #: .\cookbook\templates\forms\edit_internal_recipe.html:198
@ -1109,22 +1128,24 @@ msgstr ""
#: .\cookbook\templates\markdown_info.html:125 #: .\cookbook\templates\markdown_info.html:125
msgid "Images & Links" msgid "Images & Links"
msgstr "" msgstr "Bilder og lenker"
#: .\cookbook\templates\markdown_info.html:126 #: .\cookbook\templates\markdown_info.html:126
msgid "" msgid ""
"Links can be formatted with Markdown. This application also allows to paste " "Links can be formatted with Markdown. This application also allows to paste "
"links directly into markdown fields without any formatting." "links directly into markdown fields without any formatting."
msgstr "" msgstr ""
"Lenker kan formateres med Markdown. Denne applikasjonen lar deg også lime "
"inn lenker direkte i Markdown-felt uten noen formatering."
#: .\cookbook\templates\markdown_info.html:132 #: .\cookbook\templates\markdown_info.html:132
#: .\cookbook\templates\markdown_info.html:145 #: .\cookbook\templates\markdown_info.html:145
msgid "This will become an image" msgid "This will become an image"
msgstr "" msgstr "Dette vil bli til et bilde"
#: .\cookbook\templates\markdown_info.html:152 #: .\cookbook\templates\markdown_info.html:152
msgid "Tables" msgid "Tables"
msgstr "" msgstr "Tabeller"
#: .\cookbook\templates\markdown_info.html:153 #: .\cookbook\templates\markdown_info.html:153
msgid "" msgid ""
@ -1132,124 +1153,130 @@ msgid ""
"editor like <a href=\"https://www.tablesgenerator.com/markdown_tables\" rel=" "editor like <a href=\"https://www.tablesgenerator.com/markdown_tables\" rel="
"\"noreferrer noopener\" target=\"_blank\">this one.</a>" "\"noreferrer noopener\" target=\"_blank\">this one.</a>"
msgstr "" msgstr ""
"Markdown-tabeller er vanskelige å lage for hånd. Det anbefales å bruke en "
"tabellredigerer som <a href=\"https://www.tablesgenerator.com/"
"markdown_tables\" rel=\"noreferrer noopener\" target=\"_blank\">denne.</a>"
#: .\cookbook\templates\markdown_info.html:155 #: .\cookbook\templates\markdown_info.html:155
#: .\cookbook\templates\markdown_info.html:157 #: .\cookbook\templates\markdown_info.html:157
#: .\cookbook\templates\markdown_info.html:171 #: .\cookbook\templates\markdown_info.html:171
#: .\cookbook\templates\markdown_info.html:177 #: .\cookbook\templates\markdown_info.html:177
msgid "Table" msgid "Table"
msgstr "" msgstr "Tabell"
#: .\cookbook\templates\markdown_info.html:155 #: .\cookbook\templates\markdown_info.html:155
#: .\cookbook\templates\markdown_info.html:172 #: .\cookbook\templates\markdown_info.html:172
msgid "Header" msgid "Header"
msgstr "" msgstr "Overskrift"
#: .\cookbook\templates\markdown_info.html:157 #: .\cookbook\templates\markdown_info.html:157
#: .\cookbook\templates\markdown_info.html:178 #: .\cookbook\templates\markdown_info.html:178
msgid "Cell" msgid "Cell"
msgstr "" msgstr "Celle"
#: .\cookbook\templates\meal_plan.html:101 #: .\cookbook\templates\meal_plan.html:101
msgid "New Entry" msgid "New Entry"
msgstr "" msgstr "Ny oppføring"
#: .\cookbook\templates\meal_plan.html:113 #: .\cookbook\templates\meal_plan.html:113
#: .\cookbook\templates\shopping_list.html:52 #: .\cookbook\templates\shopping_list.html:52
msgid "Search Recipe" msgid "Search Recipe"
msgstr "" msgstr "Søk oppskrift"
#: .\cookbook\templates\meal_plan.html:139 #: .\cookbook\templates\meal_plan.html:139
msgid "Title" msgid "Title"
msgstr "" msgstr "Tittel"
#: .\cookbook\templates\meal_plan.html:141 #: .\cookbook\templates\meal_plan.html:141
msgid "Note (optional)" msgid "Note (optional)"
msgstr "" msgstr "Merknad (valgfritt)"
#: .\cookbook\templates\meal_plan.html:143 #: .\cookbook\templates\meal_plan.html:143
msgid "" msgid ""
"You can use markdown to format this field. See the <a href=\"/docs/markdown/" "You can use markdown to format this field. See the <a href=\"/docs/markdown/"
"\" target=\"_blank\" rel=\"noopener noreferrer\">docs here</a>" "\" target=\"_blank\" rel=\"noopener noreferrer\">docs here</a>"
msgstr "" msgstr ""
"Du kan bruke Markdown for å formatere dette feltet. Se <a href=\"/docs/"
"markdown/\" target=\"_blank\" rel=\"noopener noreferrer\">dokumentasjonen "
"her</a>"
#: .\cookbook\templates\meal_plan.html:147 #: .\cookbook\templates\meal_plan.html:147
#: .\cookbook\templates\meal_plan.html:251 #: .\cookbook\templates\meal_plan.html:251
msgid "Serving Count" msgid "Serving Count"
msgstr "" msgstr "Antall porsjoner"
#: .\cookbook\templates\meal_plan.html:153 #: .\cookbook\templates\meal_plan.html:153
msgid "Create only note" msgid "Create only note"
msgstr "" msgstr "Opprett kun en merknad"
#: .\cookbook\templates\meal_plan.html:168 #: .\cookbook\templates\meal_plan.html:168
#: .\cookbook\templates\shopping_list.html:7 #: .\cookbook\templates\shopping_list.html:7
#: .\cookbook\templates\shopping_list.html:29 #: .\cookbook\templates\shopping_list.html:29
#: .\cookbook\templates\shopping_list.html:705 #: .\cookbook\templates\shopping_list.html:705
msgid "Shopping List" msgid "Shopping List"
msgstr "" msgstr "Handleliste"
#: .\cookbook\templates\meal_plan.html:172 #: .\cookbook\templates\meal_plan.html:172
msgid "Shopping list currently empty" msgid "Shopping list currently empty"
msgstr "" msgstr "Handlelisten er for øyeblikket tom"
#: .\cookbook\templates\meal_plan.html:175 #: .\cookbook\templates\meal_plan.html:175
msgid "Open Shopping List" msgid "Open Shopping List"
msgstr "" msgstr "Åpne handlelisten"
#: .\cookbook\templates\meal_plan.html:189 #: .\cookbook\templates\meal_plan.html:189
msgid "Plan" msgid "Plan"
msgstr "" msgstr "Plan"
#: .\cookbook\templates\meal_plan.html:196 #: .\cookbook\templates\meal_plan.html:196
msgid "Number of Days" msgid "Number of Days"
msgstr "" msgstr "Antall dager"
#: .\cookbook\templates\meal_plan.html:206 #: .\cookbook\templates\meal_plan.html:206
msgid "Weekday offset" msgid "Weekday offset"
msgstr "" msgstr "Ukedagsforskyvning"
#: .\cookbook\templates\meal_plan.html:209 #: .\cookbook\templates\meal_plan.html:209
msgid "" msgid ""
"Number of days starting from the first day of the week to offset the default " "Number of days starting from the first day of the week to offset the default "
"view." "view."
msgstr "" msgstr "Antall dager fra den første dagen i uken for å endre standardvisningen."
#: .\cookbook\templates\meal_plan.html:217 #: .\cookbook\templates\meal_plan.html:217
#: .\cookbook\templates\meal_plan.html:294 #: .\cookbook\templates\meal_plan.html:294
msgid "Edit plan types" msgid "Edit plan types"
msgstr "" msgstr "Rediger plantyper"
#: .\cookbook\templates\meal_plan.html:219 #: .\cookbook\templates\meal_plan.html:219
msgid "Show help" msgid "Show help"
msgstr "" msgstr "Vis hjelp"
#: .\cookbook\templates\meal_plan.html:220 #: .\cookbook\templates\meal_plan.html:220
msgid "Week iCal export" msgid "Week iCal export"
msgstr "" msgstr "Uke iCal-eksport"
#: .\cookbook\templates\meal_plan.html:264 #: .\cookbook\templates\meal_plan.html:264
#: .\cookbook\templates\meal_plan_entry.html:18 #: .\cookbook\templates\meal_plan_entry.html:18
msgid "Created by" msgid "Created by"
msgstr "" msgstr "Opprettet av"
#: .\cookbook\templates\meal_plan.html:270 #: .\cookbook\templates\meal_plan.html:270
#: .\cookbook\templates\meal_plan_entry.html:20 #: .\cookbook\templates\meal_plan_entry.html:20
#: .\cookbook\templates\shopping_list.html:250 #: .\cookbook\templates\shopping_list.html:250
msgid "Shared with" msgid "Shared with"
msgstr "" msgstr "Delt med"
#: .\cookbook\templates\meal_plan.html:280 #: .\cookbook\templates\meal_plan.html:280
msgid "Add to Shopping" msgid "Add to Shopping"
msgstr "" msgstr "Legg til i handlelisten"
#: .\cookbook\templates\meal_plan.html:323 #: .\cookbook\templates\meal_plan.html:323
msgid "New meal type" msgid "New meal type"
msgstr "" msgstr "Ny måltidstype"
#: .\cookbook\templates\meal_plan.html:338 #: .\cookbook\templates\meal_plan.html:338
msgid "Meal Plan Help" msgid "Meal Plan Help"
msgstr "" msgstr "Hjelp for måltidsplanen"
#: .\cookbook\templates\meal_plan.html:344 #: .\cookbook\templates\meal_plan.html:344
msgid "" msgid ""
@ -1289,7 +1316,7 @@ msgstr ""
#: .\cookbook\templates\meal_plan_entry.html:6 #: .\cookbook\templates\meal_plan_entry.html:6
msgid "Meal Plan View" msgid "Meal Plan View"
msgstr "" msgstr "Visning av måltidsplanen"
#: .\cookbook\templates\meal_plan_entry.html:50 #: .\cookbook\templates\meal_plan_entry.html:50
msgid "Never cooked before." msgid "Never cooked before."
@ -1297,7 +1324,7 @@ msgstr ""
#: .\cookbook\templates\meal_plan_entry.html:76 #: .\cookbook\templates\meal_plan_entry.html:76
msgid "Other meals on this day" msgid "Other meals on this day"
msgstr "" msgstr "Andre måltider denne dagen"
#: .\cookbook\templates\no_groups_info.html:5 #: .\cookbook\templates\no_groups_info.html:5
#: .\cookbook\templates\no_groups_info.html:12 #: .\cookbook\templates\no_groups_info.html:12

View File

@ -13,10 +13,10 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-02-27 13:55+0000\n" "PO-Revision-Date: 2023-08-15 19:19+0000\n"
"Last-Translator: Jesse <jesse.kamps@pm.me>\n" "Last-Translator: Jochum van der Heide <jochum@famvanderheide.com>\n"
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/recipes-" "Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
"backend/nl/>\n" "recipes-backend/nl/>\n"
"Language: nl\n" "Language: nl\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
@ -522,34 +522,32 @@ msgid "One of queryset or hash_key must be provided"
msgstr "Er moet een queryset of hash_key opgegeven worden" msgstr "Er moet een queryset of hash_key opgegeven worden"
#: .\cookbook\helper\recipe_url_import.py:266 #: .\cookbook\helper\recipe_url_import.py:266
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation" msgid "reverse rotation"
msgstr "Gebruik fracties" msgstr "omgekeerde rotatie"
#: .\cookbook\helper\recipe_url_import.py:267 #: .\cookbook\helper\recipe_url_import.py:267
msgid "careful rotation" msgid "careful rotation"
msgstr "" msgstr "voorzichtige rotatie"
#: .\cookbook\helper\recipe_url_import.py:268 #: .\cookbook\helper\recipe_url_import.py:268
msgid "knead" msgid "knead"
msgstr "" msgstr "kneden"
#: .\cookbook\helper\recipe_url_import.py:269 #: .\cookbook\helper\recipe_url_import.py:269
msgid "thicken" msgid "thicken"
msgstr "" msgstr "verdikken"
#: .\cookbook\helper\recipe_url_import.py:270 #: .\cookbook\helper\recipe_url_import.py:270
msgid "warm up" msgid "warm up"
msgstr "" msgstr "opwarmen"
#: .\cookbook\helper\recipe_url_import.py:271 #: .\cookbook\helper\recipe_url_import.py:271
msgid "ferment" msgid "ferment"
msgstr "" msgstr "gisten"
#: .\cookbook\helper\recipe_url_import.py:272 #: .\cookbook\helper\recipe_url_import.py:272
msgid "sous-vide" msgid "sous-vide"
msgstr "" msgstr "sous-vide"
#: .\cookbook\helper\shopping_helper.py:157 #: .\cookbook\helper\shopping_helper.py:157
msgid "You must supply a servings size" msgid "You must supply a servings size"
@ -594,10 +592,8 @@ msgid "Imported %s recipes."
msgstr "%s recepten geïmporteerd." msgstr "%s recepten geïmporteerd."
#: .\cookbook\integration\openeats.py:26 #: .\cookbook\integration\openeats.py:26
#, fuzzy
#| msgid "Recipe Home"
msgid "Recipe source:" msgid "Recipe source:"
msgstr "Recept thuis" msgstr "Bron van het recept:"
#: .\cookbook\integration\paprika.py:49 #: .\cookbook\integration\paprika.py:49
msgid "Notes" msgid "Notes"

View File

@ -12,8 +12,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n" "POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-01-08 17:55+0000\n" "PO-Revision-Date: 2023-10-07 18:02+0000\n"
"Last-Translator: Joachim Weber <joachim.weber@gmx.de>\n" "Last-Translator: Guilherme Roda <glealroda@gmail.com>\n"
"Language-Team: Portuguese <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Portuguese <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/pt/>\n" "recipes-backend/pt/>\n"
"Language: pt\n" "Language: pt\n"
@ -206,8 +206,8 @@ msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote." "Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)" "php/webdav/</code> is added automatically)"
msgstr "" msgstr ""
"Deixar vazio para Dropbox e inserir apenas url base para Nextcloud (<code>/" "Deixar vazio para Dropbox e inserir apenas url base para Nextcloud "
"remote.php/webdav/</code>é adicionado automaticamente). " "(<code>/remote.php/webdav/</code>é adicionado automaticamente)"
#: .\cookbook\forms.py:282 .\cookbook\views\edit.py:157 #: .\cookbook\forms.py:282 .\cookbook\views\edit.py:157
msgid "Storage" msgid "Storage"
@ -277,16 +277,12 @@ msgstr ""
"ignorados)." "ignorados)."
#: .\cookbook\forms.py:461 #: .\cookbook\forms.py:461
#, fuzzy
#| msgid ""
#| "Select type method of search. Click <a href=\"/docs/search/\">here</a> "
#| "for full desciption of choices."
msgid "" msgid ""
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for " "Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
"full description of choices." "full description of choices."
msgstr "" msgstr ""
"Selecionar o método de pesquisa. Uma descrição completa das opções pode ser " "Selecionar o método de pesquisa. Uma descrição completa das opções pode "
"encontrada <a href=\"/docs/search/\">aqui</a>." "ser encontrada <a href=\"/docs/search/\">aqui</a>."
#: .\cookbook\forms.py:462 #: .\cookbook\forms.py:462
msgid "" msgid ""
@ -329,10 +325,8 @@ msgid ""
msgstr "" msgstr ""
#: .\cookbook\forms.py:476 #: .\cookbook\forms.py:476
#, fuzzy
#| msgid "Search"
msgid "Search Method" msgid "Search Method"
msgstr "Procurar" msgstr "Método de Pesquisa"
#: .\cookbook\forms.py:477 #: .\cookbook\forms.py:477
msgid "Fuzzy Lookups" msgid "Fuzzy Lookups"
@ -351,16 +345,12 @@ msgid "Starts With"
msgstr "" msgstr ""
#: .\cookbook\forms.py:481 #: .\cookbook\forms.py:481
#, fuzzy
#| msgid "Search"
msgid "Fuzzy Search" msgid "Fuzzy Search"
msgstr "Procurar" msgstr "Pesquisa Fuzzy"
#: .\cookbook\forms.py:482 #: .\cookbook\forms.py:482
#, fuzzy
#| msgid "Text"
msgid "Full Text" msgid "Full Text"
msgstr "Texto" msgstr "Texto Completo"
#: .\cookbook\forms.py:507 #: .\cookbook\forms.py:507
msgid "" msgid ""
@ -405,10 +395,8 @@ msgid "Prefix to add when copying list to the clipboard."
msgstr "" msgstr ""
#: .\cookbook\forms.py:524 #: .\cookbook\forms.py:524
#, fuzzy
#| msgid "Shopping"
msgid "Share Shopping List" msgid "Share Shopping List"
msgstr "Compras" msgstr "Compartilhar Lista de Compras"
#: .\cookbook\forms.py:525 #: .\cookbook\forms.py:525
msgid "Autosync" msgid "Autosync"
@ -459,10 +447,8 @@ msgid "Reset all food to inherit the fields configured."
msgstr "" msgstr ""
#: .\cookbook\forms.py:557 #: .\cookbook\forms.py:557
#, fuzzy
#| msgid "Food that should be replaced."
msgid "Fields on food that should be inherited by default." msgid "Fields on food that should be inherited by default."
msgstr "Prato a ser alterado." msgstr "Campos do alimento que devem ser herdados por padrão."
#: .\cookbook\forms.py:558 #: .\cookbook\forms.py:558
msgid "Show recipe counts on search filters" msgid "Show recipe counts on search filters"
@ -516,10 +502,8 @@ msgid "One of queryset or hash_key must be provided"
msgstr "" msgstr ""
#: .\cookbook\helper\recipe_url_import.py:266 #: .\cookbook\helper\recipe_url_import.py:266
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation" msgid "reverse rotation"
msgstr "Usar frações" msgstr "rotação reversa"
#: .\cookbook\helper\recipe_url_import.py:267 #: .\cookbook\helper\recipe_url_import.py:267
msgid "careful rotation" msgid "careful rotation"
@ -585,16 +569,12 @@ msgid "Imported %s recipes."
msgstr "%s receitas importadas." msgstr "%s receitas importadas."
#: .\cookbook\integration\openeats.py:26 #: .\cookbook\integration\openeats.py:26
#, fuzzy
#| msgid "Recipes"
msgid "Recipe source:" msgid "Recipe source:"
msgstr "Receitas" msgstr "Fonte da Receita:"
#: .\cookbook\integration\paprika.py:49 #: .\cookbook\integration\paprika.py:49
#, fuzzy
#| msgid "Note"
msgid "Notes" msgid "Notes"
msgstr "Nota" msgstr "Notas"
#: .\cookbook\integration\paprika.py:52 #: .\cookbook\integration\paprika.py:52
msgid "Nutritional Information" msgid "Nutritional Information"
@ -606,10 +586,8 @@ msgstr ""
#: .\cookbook\integration\recettetek.py:54 #: .\cookbook\integration\recettetek.py:54
#: .\cookbook\integration\recipekeeper.py:70 #: .\cookbook\integration\recipekeeper.py:70
#, fuzzy
#| msgid "Import"
msgid "Imported from" msgid "Imported from"
msgstr "Importar" msgstr "Importado de"
#: .\cookbook\integration\saffron.py:23 #: .\cookbook\integration\saffron.py:23
msgid "Servings" msgid "Servings"
@ -706,32 +684,24 @@ msgid "Raw"
msgstr "" msgstr ""
#: .\cookbook\models.py:1231 #: .\cookbook\models.py:1231
#, fuzzy
#| msgid "New Food"
msgid "Food Alias" msgid "Food Alias"
msgstr "Novo Prato" msgstr "Apelido do Alimento"
#: .\cookbook\models.py:1231 #: .\cookbook\models.py:1231
#, fuzzy
#| msgid "Units"
msgid "Unit Alias" msgid "Unit Alias"
msgstr "Unidades" msgstr "Apelido da Unidade"
#: .\cookbook\models.py:1231 #: .\cookbook\models.py:1231
#, fuzzy
#| msgid "Keywords"
msgid "Keyword Alias" msgid "Keyword Alias"
msgstr "Palavras-chave" msgstr "Apelido de Palavra-chave"
#: .\cookbook\models.py:1232 #: .\cookbook\models.py:1232
msgid "Description Replace" msgid "Description Replace"
msgstr "" msgstr ""
#: .\cookbook\models.py:1232 #: .\cookbook\models.py:1232
#, fuzzy
#| msgid "Instructions"
msgid "Instruction Replace" msgid "Instruction Replace"
msgstr "Instruções" msgstr "Substituir Instruções"
#: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36 #: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36
#: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48 #: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48
@ -739,10 +709,8 @@ msgid "Recipe"
msgstr "Receita" msgstr "Receita"
#: .\cookbook\models.py:1259 #: .\cookbook\models.py:1259
#, fuzzy
#| msgid "New Food"
msgid "Food" msgid "Food"
msgstr "Novo Prato" msgstr "Alimento"
#: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141 #: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141
msgid "Keyword" msgid "Keyword"
@ -880,10 +848,8 @@ msgid "Primary"
msgstr "" msgstr ""
#: .\cookbook\templates\account\email.html:47 #: .\cookbook\templates\account\email.html:47
#, fuzzy
#| msgid "Make Header"
msgid "Make Primary" msgid "Make Primary"
msgstr "Adicionar Cabeçalho" msgstr "Tornar Primeiro"
#: .\cookbook\templates\account\email.html:49 #: .\cookbook\templates\account\email.html:49
msgid "Re-send Verification" msgid "Re-send Verification"
@ -1004,10 +970,8 @@ msgstr ""
#: .\cookbook\templates\account\password_change.html:12 #: .\cookbook\templates\account\password_change.html:12
#: .\cookbook\templates\account\password_set.html:12 #: .\cookbook\templates\account\password_set.html:12
#, fuzzy
#| msgid "Settings"
msgid "Password" msgid "Password"
msgstr "Definições" msgstr "Senha"
#: .\cookbook\templates\account\password_change.html:22 #: .\cookbook\templates\account\password_change.html:22
msgid "Forgot Password?" msgid "Forgot Password?"
@ -1050,10 +1014,8 @@ msgid ""
msgstr "" msgstr ""
#: .\cookbook\templates\account\password_reset_from_key.html:33 #: .\cookbook\templates\account\password_reset_from_key.html:33
#, fuzzy
#| msgid "Settings"
msgid "change password" msgid "change password"
msgstr "Definições" msgstr "alterar senha"
#: .\cookbook\templates\account\password_reset_from_key.html:36 #: .\cookbook\templates\account\password_reset_from_key.html:36
#: .\cookbook\templates\account\password_reset_from_key_done.html:19 #: .\cookbook\templates\account\password_reset_from_key_done.html:19
@ -1125,10 +1087,8 @@ msgid "Shopping"
msgstr "Compras" msgstr "Compras"
#: .\cookbook\templates\base.html:153 .\cookbook\views\lists.py:105 #: .\cookbook\templates\base.html:153 .\cookbook\views\lists.py:105
#, fuzzy
#| msgid "New Food"
msgid "Foods" msgid "Foods"
msgstr "Novo Prato" msgstr "Alimentos"
#: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122 #: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122
msgid "Units" msgid "Units"
@ -1139,20 +1099,16 @@ msgid "Supermarket"
msgstr "" msgstr ""
#: .\cookbook\templates\base.html:191 #: .\cookbook\templates\base.html:191
#, fuzzy
#| msgid "Batch edit Category"
msgid "Supermarket Category" msgid "Supermarket Category"
msgstr "Editar Categorias em massa" msgstr "Categoria de Supermercado"
#: .\cookbook\templates\base.html:203 .\cookbook\views\lists.py:171 #: .\cookbook\templates\base.html:203 .\cookbook\views\lists.py:171
msgid "Automations" msgid "Automations"
msgstr "" msgstr ""
#: .\cookbook\templates\base.html:217 .\cookbook\views\lists.py:207 #: .\cookbook\templates\base.html:217 .\cookbook\views\lists.py:207
#, fuzzy
#| msgid "File ID"
msgid "Files" msgid "Files"
msgstr "ID the ficheiro" msgstr "Arquivos"
#: .\cookbook\templates\base.html:229 #: .\cookbook\templates\base.html:229
msgid "Batch Edit" msgid "Batch Edit"
@ -1166,10 +1122,8 @@ msgstr "Histórico"
#: .\cookbook\templates\base.html:255 #: .\cookbook\templates\base.html:255
#: .\cookbook\templates\ingredient_editor.html:7 #: .\cookbook\templates\ingredient_editor.html:7
#: .\cookbook\templates\ingredient_editor.html:13 #: .\cookbook\templates\ingredient_editor.html:13
#, fuzzy
#| msgid "Ingredients"
msgid "Ingredient Editor" msgid "Ingredient Editor"
msgstr "Ingredientes" msgstr "Editor de Ingrediente"
#: .\cookbook\templates\base.html:267 #: .\cookbook\templates\base.html:267
#: .\cookbook\templates\export_response.html:7 #: .\cookbook\templates\export_response.html:7
@ -1191,10 +1145,8 @@ msgid "External Recipes"
msgstr "" msgstr ""
#: .\cookbook\templates\base.html:301 .\cookbook\templates\space_manage.html:15 #: .\cookbook\templates\base.html:301 .\cookbook\templates\space_manage.html:15
#, fuzzy
#| msgid "Settings"
msgid "Space Settings" msgid "Space Settings"
msgstr "Definições" msgstr "Configurar Espaço"
#: .\cookbook\templates\base.html:306 .\cookbook\templates\system.html:13 #: .\cookbook\templates\base.html:306 .\cookbook\templates\system.html:13
msgid "System" msgid "System"
@ -1206,10 +1158,8 @@ msgstr "Administração"
#: .\cookbook\templates\base.html:312 #: .\cookbook\templates\base.html:312
#: .\cookbook\templates\space_overview.html:25 #: .\cookbook\templates\space_overview.html:25
#, fuzzy
#| msgid "Create"
msgid "Your Spaces" msgid "Your Spaces"
msgstr "Criar" msgstr "Seus Espaços"
#: .\cookbook\templates\base.html:323 #: .\cookbook\templates\base.html:323
#: .\cookbook\templates\space_overview.html:6 #: .\cookbook\templates\space_overview.html:6
@ -1288,19 +1238,15 @@ msgstr ""
#: .\cookbook\templates\batch\monitor.html:28 #: .\cookbook\templates\batch\monitor.html:28
msgid "Sync Now!" msgid "Sync Now!"
msgstr "Sincronizar" msgstr "Sincronizar Agora!"
#: .\cookbook\templates\batch\monitor.html:29 #: .\cookbook\templates\batch\monitor.html:29
#, fuzzy
#| msgid "Recipes"
msgid "Show Recipes" msgid "Show Recipes"
msgstr "Receitas" msgstr "Mostrar Receitas"
#: .\cookbook\templates\batch\monitor.html:30 #: .\cookbook\templates\batch\monitor.html:30
#, fuzzy
#| msgid "View Log"
msgid "Show Log" msgid "Show Log"
msgstr "Ver Registro" msgstr "Mostrar Log"
#: .\cookbook\templates\batch\waiting.html:4 #: .\cookbook\templates\batch\waiting.html:4
#: .\cookbook\templates\batch\waiting.html:10 #: .\cookbook\templates\batch\waiting.html:10
@ -1335,7 +1281,7 @@ msgstr "Editar Receita"
#: .\cookbook\templates\generic\delete_template.html:21 #: .\cookbook\templates\generic\delete_template.html:21
#, python-format #, python-format
msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> " msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
msgstr "Tem a certeza que quer apagar %(title)s: <b>%(object)s</b>" msgstr "Tem certeza que deseja apagar %(title)s: <b>%(object)s</b> "
#: .\cookbook\templates\generic\delete_template.html:22 #: .\cookbook\templates\generic\delete_template.html:22
msgid "This cannot be undone!" msgid "This cannot be undone!"
@ -1369,7 +1315,7 @@ msgstr "Apagar ficheiro original"
#: .\cookbook\templates\generic\list_template.html:6 #: .\cookbook\templates\generic\list_template.html:6
#: .\cookbook\templates\generic\list_template.html:22 #: .\cookbook\templates\generic\list_template.html:22
msgid "List" msgid "List"
msgstr "Listar " msgstr "Listar"
#: .\cookbook\templates\generic\list_template.html:36 #: .\cookbook\templates\generic\list_template.html:36
msgid "Filter" msgid "Filter"
@ -1422,13 +1368,13 @@ msgid ""
" " " "
msgstr "" msgstr ""
"\n" "\n"
" Os </b>campos da senha e Token</b> são guardados dentro da base de " " Os campos de <b>senha e Token</b> são armazenados na base de dados "
"dados como <b>texto simples.</b>\n" "como <b>texto simples</b>.\n"
"Isto é necessário porque eles são usados para fazer pedidos á API, mas " " Isto é necessário porque eles são usados para fazer pedidos à API, "
"também aumenta o risco de\n" "mas também aumenta o risco\n"
"de alguém os roubar. <br/>\n" " de alguém os roubar.<br/>\n"
"Para limitar os possíveis danos, tokens e contas com acesso limitado podem " " Para limitar os possíveis danos, tokens e contas com acesso limitado "
"ser usadas.\n" "podem ser usadas.\n"
" " " "
#: .\cookbook\templates\index.html:29 #: .\cookbook\templates\index.html:29
@ -1441,7 +1387,7 @@ msgstr "Nova Receita"
#: .\cookbook\templates\index.html:53 #: .\cookbook\templates\index.html:53
msgid "Advanced Search" msgid "Advanced Search"
msgstr "Procura avançada " msgstr "Pesquisa avançada"
#: .\cookbook\templates\index.html:57 #: .\cookbook\templates\index.html:57
msgid "Reset Search" msgid "Reset Search"
@ -1493,8 +1439,6 @@ msgstr ""
#: .\cookbook\templates\markdown_info.html:57 #: .\cookbook\templates\markdown_info.html:57
#: .\cookbook\templates\markdown_info.html:73 #: .\cookbook\templates\markdown_info.html:73
#, fuzzy
#| msgid "or by leaving a blank line inbetween."
msgid "or by leaving a blank line in between." msgid "or by leaving a blank line in between."
msgstr "ou deixando uma linha em branco no meio." msgstr "ou deixando uma linha em branco no meio."
@ -1518,10 +1462,6 @@ msgid "Lists"
msgstr "Listas" msgstr "Listas"
#: .\cookbook\templates\markdown_info.html:85 #: .\cookbook\templates\markdown_info.html:85
#, fuzzy
#| msgid ""
#| "Lists can ordered or unorderd. It is <b>important to leave a blank line "
#| "before the list!</b>"
msgid "" msgid ""
"Lists can ordered or unordered. It is <b>important to leave a blank line " "Lists can ordered or unordered. It is <b>important to leave a blank line "
"before the list!</b>" "before the list!</b>"
@ -1598,7 +1538,7 @@ msgstr "Cabeçalho"
#: .\cookbook\templates\markdown_info.html:157 #: .\cookbook\templates\markdown_info.html:157
#: .\cookbook\templates\markdown_info.html:178 #: .\cookbook\templates\markdown_info.html:178
msgid "Cell" msgid "Cell"
msgstr "Célula " msgstr "Célula"
#: .\cookbook\templates\no_groups_info.html:5 #: .\cookbook\templates\no_groups_info.html:5
#: .\cookbook\templates\no_groups_info.html:12 #: .\cookbook\templates\no_groups_info.html:12
@ -1666,10 +1606,8 @@ msgstr ""
#: .\cookbook\templates\search_info.html:5 #: .\cookbook\templates\search_info.html:5
#: .\cookbook\templates\search_info.html:9 #: .\cookbook\templates\search_info.html:9
#: .\cookbook\templates\settings.html:24 #: .\cookbook\templates\settings.html:24
#, fuzzy
#| msgid "Search String"
msgid "Search Settings" msgid "Search Settings"
msgstr "Procurar" msgstr "Configurações de Pesquisa"
#: .\cookbook\templates\search_info.html:10 #: .\cookbook\templates\search_info.html:10
msgid "" msgid ""
@ -1684,10 +1622,8 @@ msgid ""
msgstr "" msgstr ""
#: .\cookbook\templates\search_info.html:19 #: .\cookbook\templates\search_info.html:19
#, fuzzy
#| msgid "Search"
msgid "Search Methods" msgid "Search Methods"
msgstr "Procurar" msgstr "Métodos de Pesquisa"
#: .\cookbook\templates\search_info.html:23 #: .\cookbook\templates\search_info.html:23
msgid "" msgid ""
@ -1769,10 +1705,8 @@ msgid ""
msgstr "" msgstr ""
#: .\cookbook\templates\search_info.html:69 #: .\cookbook\templates\search_info.html:69
#, fuzzy
#| msgid "Search Recipe"
msgid "Search Fields" msgid "Search Fields"
msgstr "Procure Receita" msgstr "Campos de Pesquisa"
#: .\cookbook\templates\search_info.html:73 #: .\cookbook\templates\search_info.html:73
msgid "" msgid ""
@ -1810,10 +1744,8 @@ msgid ""
msgstr "" msgstr ""
#: .\cookbook\templates\search_info.html:95 #: .\cookbook\templates\search_info.html:95
#, fuzzy
#| msgid "Search"
msgid "Search Index" msgid "Search Index"
msgstr "Procurar" msgstr "Índice de Pesquisa"
#: .\cookbook\templates\search_info.html:99 #: .\cookbook\templates\search_info.html:99
msgid "" msgid ""
@ -2012,10 +1944,8 @@ msgid "Owner"
msgstr "" msgstr ""
#: .\cookbook\templates\space_overview.html:57 #: .\cookbook\templates\space_overview.html:57
#, fuzzy
#| msgid "Create"
msgid "Leave Space" msgid "Leave Space"
msgstr "Criar" msgstr "Sair do Espaço"
#: .\cookbook\templates\space_overview.html:78 #: .\cookbook\templates\space_overview.html:78
#: .\cookbook\templates\space_overview.html:88 #: .\cookbook\templates\space_overview.html:88
@ -2034,10 +1964,8 @@ msgstr ""
#: .\cookbook\templates\space_overview.html:96 #: .\cookbook\templates\space_overview.html:96
#: .\cookbook\templates\space_overview.html:105 #: .\cookbook\templates\space_overview.html:105
#, fuzzy
#| msgid "Create"
msgid "Create Space" msgid "Create Space"
msgstr "Criar" msgstr "Criar Espaço"
#: .\cookbook\templates\space_overview.html:99 #: .\cookbook\templates\space_overview.html:99
msgid "Create your own recipe space." msgid "Create your own recipe space."
@ -2487,10 +2415,8 @@ msgid "Shopping Categories"
msgstr "" msgstr ""
#: .\cookbook\views\lists.py:187 #: .\cookbook\views\lists.py:187
#, fuzzy
#| msgid "Filter"
msgid "Custom Filters" msgid "Custom Filters"
msgstr "Filtrar" msgstr "Filtros Customizados"
#: .\cookbook\views\lists.py:224 #: .\cookbook\views\lists.py:224
msgid "Steps" msgid "Steps"

File diff suppressed because it is too large Load Diff

View File

@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-08 16:27+0100\n" "POT-Creation-Date: 2021-11-08 16:27+0100\n"
"PO-Revision-Date: 2023-04-12 11:55+0000\n" "PO-Revision-Date: 2023-08-13 08:19+0000\n"
"Last-Translator: noxonad <noxonad@proton.me>\n" "Last-Translator: Miha Perpar <miha.perpar2@gmail.com>\n"
"Language-Team: Slovenian <http://translate.tandoor.dev/projects/tandoor/" "Language-Team: Slovenian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/sl/>\n" "recipes-backend/sl/>\n"
"Language: sl\n" "Language: sl\n"
@ -964,7 +964,7 @@ msgstr ""
#: .\cookbook\templates\base.html:275 #: .\cookbook\templates\base.html:275
msgid "GitHub" msgid "GitHub"
msgstr "" msgstr "GitHub"
#: .\cookbook\templates\base.html:277 #: .\cookbook\templates\base.html:277
msgid "Translate Tandoor" msgid "Translate Tandoor"
@ -1961,7 +1961,7 @@ msgstr ""
#: .\cookbook\templates\space.html:106 #: .\cookbook\templates\space.html:106
msgid "user" msgid "user"
msgstr "" msgstr "uporabnik"
#: .\cookbook\templates\space.html:107 #: .\cookbook\templates\space.html:107
msgid "guest" msgid "guest"

View File

@ -1,9 +1,9 @@
from django.conf import settings from django.conf import settings
from django.contrib.postgres.search import SearchVector from django.contrib.postgres.search import SearchVector
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django_scopes import scopes_disabled
from django.utils import translation from django.utils import translation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step from cookbook.models import Recipe, Step
@ -14,7 +14,7 @@ class Command(BaseCommand):
help = _('Rebuilds full text search index on Recipe') help = _('Rebuilds full text search index on Recipe')
def handle(self, *args, **options): def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']: if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.postgresql':
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild'))) self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
try: try:

View File

@ -34,35 +34,14 @@ class RecipeSearchManager(models.Manager):
+ SearchVector(StringAgg('steps__ingredients__food__name__unaccent', delimiter=' '), weight='B', config=language) + SearchVector(StringAgg('steps__ingredients__food__name__unaccent', delimiter=' '), weight='B', config=language)
+ SearchVector(StringAgg('keywords__name__unaccent', delimiter=' '), weight='B', config=language)) + SearchVector(StringAgg('keywords__name__unaccent', delimiter=' '), weight='B', config=language))
search_rank = SearchRank(search_vectors, search_query) search_rank = SearchRank(search_vectors, search_query)
# USING TRIGRAM BREAKS WEB SEARCH
# ADDING MULTIPLE TRIGRAMS CREATES DUPLICATE RESULTS
# DISTINCT NOT COMPAITBLE WITH ANNOTATE
# trigram_name = (TrigramSimilarity('name', search_text))
# trigram_description = (TrigramSimilarity('description', search_text))
# trigram_food = (TrigramSimilarity('steps__ingredients__food__name', search_text))
# trigram_keyword = (TrigramSimilarity('keywords__name', search_text))
# adding additional trigrams created duplicates
# + TrigramSimilarity('description', search_text)
# + TrigramSimilarity('steps__ingredients__food__name', search_text)
# + TrigramSimilarity('keywords__name', search_text)
return ( return (
self.get_queryset() self.get_queryset()
.annotate( .annotate(
search=search_vectors, search=search_vectors,
rank=search_rank, rank=search_rank,
# trigram=trigram_name+trigram_description+trigram_food+trigram_keyword
# trigram_name=trigram_name,
# trigram_description=trigram_description,
# trigram_food=trigram_food,
# trigram_keyword=trigram_keyword
) )
.filter( .filter(
Q(search=search_query) Q(search=search_query)
# | Q(trigram_name__gt=0.1)
# | Q(name__icontains=search_text)
# | Q(trigram_name__gt=0.2)
# | Q(trigram_description__gt=0.2)
# | Q(trigram_food__gt=0.2)
# | Q(trigram_keyword__gt=0.2)
) )
.order_by('-rank')) .order_by('-rank'))

View File

@ -9,7 +9,7 @@ from django.utils import translation
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY from cookbook.managers import DICTIONARY
from cookbook.models import (Index, PermissionModelMixin, Recipe, Step, SearchFields) from cookbook.models import Index, PermissionModelMixin, Recipe, SearchFields, Step
def allSearchFields(): def allSearchFields():
@ -21,7 +21,7 @@ def nameSearchField():
def set_default_search_vector(apps, schema_editor): def set_default_search_vector(apps, schema_editor):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']: if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.postgresql':
return return
language = DICTIONARY.get(translation.get_language(), 'simple') language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled(): with scopes_disabled():

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.9 on 2023-06-26 13:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0193_space_internal_note'),
]
operations = [
migrations.AlterField(
model_name='food',
name='properties_food_amount',
field=models.DecimalField(blank=True, decimal_places=2, default=100, max_digits=16),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.9 on 2023-06-30 20:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0194_alter_food_properties_food_amount'),
]
operations = [
migrations.AddField(
model_name='invitelink',
name='internal_note',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='userspace',
name='internal_note',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='userspace',
name='invite_link',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.invitelink'),
),
migrations.AlterField(
model_name='userpreference',
name='theme',
field=models.CharField(choices=[('TANDOOR', 'Tandoor'), ('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero'), ('TANDOOR_DARK', 'Tandoor Dark (INCOMPLETE)')], default='TANDOOR', max_length=128),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.10 on 2023-07-22 06:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0195_invitelink_internal_note_userspace_internal_note_and_more'),
]
operations = [
migrations.AddField(
model_name='food',
name='url',
field=models.CharField(blank=True, default='', max_length=1024, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.10 on 2023-08-24 08:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0196_food_url'),
]
operations = [
migrations.AddField(
model_name='step',
name='show_ingredients_table',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='userpreference',
name='show_step_ingredients',
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.10 on 2023-08-24 09:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0197_step_show_ingredients_table_and_more'),
]
operations = [
migrations.AddField(
model_name='propertytype',
name='order',
field=models.IntegerField(default=0),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 4.1.10 on 2023-09-01 17:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0198_propertytype_order'),
]
operations = [
migrations.AlterField(
model_name='automation',
name='type',
field=models.CharField(
choices=[
('FOOD_ALIAS', 'Food Alias'),
('UNIT_ALIAS', 'Unit Alias'),
('KEYWORD_ALIAS', 'Keyword Alias'),
('DESCRIPTION_REPLACE', 'Description Replace'),
('INSTRUCTION_REPLACE', 'Instruction Replace'),
('NEVER_UNIT', 'Never Unit'),
('TRANSPOSE_WORDS', 'Transpose Words'),
('FOOD_REPLACE', 'Food Replace'),
('UNIT_REPLACE', 'Unit Replace'),
('NAME_REPLACE', 'Name Replace')],
max_length=128),
),
]

View File

@ -0,0 +1,64 @@
# Generated by Django 4.1.10 on 2023-08-29 11:59
from django.db import migrations
from django.db.models import F, Value, Count
from django.db.models.functions import Concat
from django_scopes import scopes_disabled
def migrate_icons(apps, schema_editor):
with scopes_disabled():
MealType = apps.get_model('cookbook', 'MealType')
Keyword = apps.get_model('cookbook', 'Keyword')
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()
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()
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()
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()
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')))
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0199_alter_propertytype_options_alter_automation_type_and_more'),
]
operations = [
migrations.RunPython( migrate_icons),
migrations.AlterModelOptions(
name='propertytype',
options={'ordering': ('order',)},
),
migrations.RemoveField(
model_name='keyword',
name='icon',
),
migrations.RemoveField(
model_name='mealtype',
name='icon',
),
migrations.RemoveField(
model_name='propertytype',
name='icon',
),
migrations.RemoveField(
model_name='recipebook',
name='icon',
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.1.10 on 2023-09-08 12:20
from django.db import migrations, models
from django.db.models import F
from django_scopes import scopes_disabled
def apply_migration(apps, schema_editor):
with scopes_disabled():
MealPlan = apps.get_model('cookbook', 'MealPlan')
MealPlan.objects.update(to_date=F('from_date'))
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0200_alter_propertytype_options_remove_keyword_icon_and_more'),
]
operations = [
migrations.RenameField(
model_name='mealplan',
old_name='date',
new_name='from_date',
),
migrations.AddField(
model_name='mealplan',
name='to_date',
field=models.DateField(blank=True, null=True),
),
migrations.RunPython(apply_migration),
migrations.AlterField(
model_name='mealplan',
name='to_date',
field=models.DateField(),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.10 on 2023-09-12 13:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0201_rename_date_mealplan_from_date_mealplan_to_date'),
]
operations = [
migrations.RemoveField(
model_name='space',
name='show_facet_count',
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2023-09-14 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0202_remove_space_show_facet_count'),
]
operations = [
migrations.AddConstraint(
model_name='mealtype',
constraint=models.UniqueConstraint(fields=('space', 'name', 'created_by'), name='mt_unique_name_per_space'),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.7 on 2023-11-27 21:09
from django.db import migrations, models
from django_scopes import scopes_disabled
def fix_fdc_ids(apps, schema_editor):
with scopes_disabled():
# in case any food had a non digit fdc ID before this migration, remove it
Food = apps.get_model('cookbook', 'Food')
Food.objects.exclude(fdc_id__regex=r'^\d+$').exclude(fdc_id=None).update(fdc_id=None)
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0203_alter_unique_contstraints'),
]
operations = [
migrations.RunPython(fix_fdc_ids),
migrations.AddField(
model_name='propertytype',
name='fdc_id',
field=models.CharField(blank=True, default=None, max_length=128, null=True),
),
]

View File

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

View File

@ -0,0 +1,128 @@
# Generated by Django 4.2.7 on 2024-01-01 18:44
import django
from django.db import migrations, models
from django_scopes import scopes_disabled
TANDOOR = 'TANDOOR'
TANDOOR_DARK = 'TANDOOR_DARK'
BOOTSTRAP = 'BOOTSTRAP'
DARKLY = 'DARKLY'
FLATLY = 'FLATLY'
SUPERHERO = 'SUPERHERO'
PRIMARY = 'PRIMARY'
SECONDARY = 'SECONDARY'
SUCCESS = 'SUCCESS'
INFO = 'INFO'
WARNING = 'WARNING'
DANGER = 'DANGER'
LIGHT = 'LIGHT'
DARK = 'DARK'
# ['light', 'warning', 'info', 'success'] --> light (theming_tags L45)
def get_nav_bg_color(theme, nav_color):
if theme == TANDOOR: # primary not actually primary color but override existed before update, same for dark
return {PRIMARY: '#ddbf86', SECONDARY: '#b55e4f', SUCCESS: '#82aa8b', INFO: '#385f84', WARNING: '#eaaa21', DANGER: '#a7240e', LIGHT: '#cfd5cd', DARK: '#221e1e'}[nav_color]
if theme == TANDOOR_DARK:
return {PRIMARY: '#ddbf86', SECONDARY: '#b55e4f', SUCCESS: '#82aa8b', INFO: '#385f84', WARNING: '#eaaa21', DANGER: '#a7240e', LIGHT: '#cfd5cd', DARK: '#221e1e'}[nav_color]
if theme == BOOTSTRAP:
return {PRIMARY: '#007bff', SECONDARY: '#6c757d', SUCCESS: '#28a745', INFO: '#17a2b8', WARNING: '#ffc107', DANGER: '#dc3545', LIGHT: '#f8f9fa', DARK: '#343a40'}[nav_color]
if theme == DARKLY:
return {PRIMARY: '#375a7f', SECONDARY: '#444', SUCCESS: '#00bc8c', INFO: '#3498DB', WARNING: '#F39C12', DANGER: '#E74C3C', LIGHT: '#999', DARK: '#303030'}[nav_color]
if theme == FLATLY:
return {PRIMARY: '#2C3E50', SECONDARY: '#95a5a6', SUCCESS: '#18BC9C', INFO: '#3498DB', WARNING: '#F39C12', DANGER: '#E74C3C', LIGHT: '#ecf0f1', DARK: '#7b8a8b'}[nav_color]
if theme == SUPERHERO:
return {PRIMARY: '#DF691A', SECONDARY: '#4E5D6C', SUCCESS: '#5cb85c', INFO: '#5bc0de', WARNING: '#f0ad4e', DANGER: '#d9534f', LIGHT: '#abb6c2', DARK: '#4E5D6C'}[nav_color]
def get_nav_text_color(theme, nav_color):
if theme == TANDOOR:
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
if theme == TANDOOR_DARK:
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
if theme == BOOTSTRAP:
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
if theme == DARKLY:
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
if theme == FLATLY:
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: LIGHT, INFO: LIGHT, WARNING: LIGHT, DANGER: DARK, LIGHT: LIGHT, DARK: DARK}[nav_color]
if theme == SUPERHERO:
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: LIGHT, INFO: LIGHT, WARNING: LIGHT, DANGER: DARK, LIGHT: LIGHT, DARK: DARK}[nav_color]
def get_current_colors(apps, schema_editor):
with scopes_disabled():
# in case any food had a non digit fdc ID before this migration, remove it
UserPreference = apps.get_model('cookbook', 'UserPreference')
update_ups = []
for up in UserPreference.objects.all():
if up.theme != TANDOOR or up.nav_color != PRIMARY:
up.nav_bg_color = get_nav_bg_color(up.theme, up.nav_color)
up.nav_text_color = get_nav_text_color(up.theme, up.nav_color)
up.nav_show_logo = (up.theme == TANDOOR or up.theme == TANDOOR_DARK)
update_ups.append(up)
UserPreference.objects.bulk_update(update_ups, ['nav_bg_color', 'nav_text_color', 'nav_show_logo'])
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0205_alter_food_fdc_id_alter_propertytype_fdc_id'),
]
operations = [
migrations.RenameField(
model_name='userpreference',
old_name='sticky_navbar',
new_name='nav_sticky',
),
migrations.AddField(
model_name='userpreference',
name='nav_bg_color',
field=models.CharField(default='#ddbf86', max_length=8),
),
migrations.AddField(
model_name='userpreference',
name='nav_text_color',
field=models.CharField(choices=[('LIGHT', 'Light'), ('DARK', 'Dark')], default='DARK', max_length=16),
),
migrations.AddField(
model_name='userpreference',
name='nav_show_logo',
field=models.BooleanField(default=True),
),
migrations.RunPython(get_current_colors),
migrations.RemoveField(
model_name='userpreference',
name='nav_color',
),
migrations.AddField(
model_name='space',
name='nav_bg_color',
field=models.CharField(blank=True, default='', max_length=8),
),
migrations.AddField(
model_name='space',
name='nav_logo',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_nav_logo', to='cookbook.userfile'),
),
migrations.AddField(
model_name='space',
name='nav_text_color',
field=models.CharField(choices=[('BLANK', '-------'), ('LIGHT', 'Light'), ('DARK', 'Dark')], default='BLANK', max_length=16),
),
migrations.AddField(
model_name='space',
name='space_theme',
field=models.CharField(choices=[('BLANK', '-------'), ('TANDOOR', 'Tandoor'), ('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero'), ('TANDOOR_DARK', 'Tandoor Dark (INCOMPLETE)')],
default='BLANK',
max_length=128),
),
migrations.AddField(
model_name='space',
name='custom_space_theme',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_theme', to='cookbook.userfile'),
),
]

View File

@ -5,7 +5,6 @@ import uuid
from datetime import date, timedelta from datetime import date, timedelta
import oauth2_provider.models import oauth2_provider.models
from PIL import Image
from annoying.fields import AutoOneToOneField from annoying.fields import AutoOneToOneField
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Group, User from django.contrib.auth.models import Group, User
@ -14,13 +13,14 @@ from django.contrib.postgres.search import SearchVectorField
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import IntegrityError, models from django.db import IntegrityError, models
from django.db.models import Index, ProtectedError, Q, Avg, Max from django.db.models import Avg, Index, Max, ProtectedError, Q
from django.db.models.fields.related import ManyToManyField from django.db.models.fields.related import ManyToManyField
from django.db.models.functions import Substr from django.db.models.functions import Substr
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_prometheus.models import ExportModelOperationsMixin from django_prometheus.models import ExportModelOperationsMixin
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from PIL import Image
from treebeard.mp_tree import MP_Node, MP_NodeManager from treebeard.mp_tree import MP_Node, MP_NodeManager
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT, from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
@ -116,10 +116,7 @@ class TreeModel(MP_Node):
_full_name_separator = ' > ' _full_name_separator = ' > '
def __str__(self): def __str__(self):
if self.icon: return f"{self.name}"
return f"{self.icon} {self.name}"
else:
return f"{self.name}"
@property @property
def parent(self): def parent(self):
@ -188,7 +185,6 @@ class TreeModel(MP_Node):
:param filter: Filter (include) the descendants nodes with the provided Q filter :param filter: Filter (include) the descendants nodes with the provided Q filter
""" """
descendants = Q() descendants = Q()
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
nodes = queryset.values('path', 'depth') nodes = queryset.values('path', 'depth')
for node in nodes: for node in nodes:
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth']) descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
@ -255,8 +251,44 @@ class FoodInheritField(models.Model, PermissionModelMixin):
class Space(ExportModelOperationsMixin('space'), models.Model): class Space(ExportModelOperationsMixin('space'), models.Model):
# TODO remove redundant theming constants
# Themes
BLANK = 'BLANK'
TANDOOR = 'TANDOOR'
TANDOOR_DARK = 'TANDOOR_DARK'
BOOTSTRAP = 'BOOTSTRAP'
DARKLY = 'DARKLY'
FLATLY = 'FLATLY'
SUPERHERO = 'SUPERHERO'
THEMES = (
(BLANK, '-------'),
(TANDOOR, 'Tandoor'),
(BOOTSTRAP, 'Bootstrap'),
(DARKLY, 'Darkly'),
(FLATLY, 'Flatly'),
(SUPERHERO, 'Superhero'),
(TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'),
)
LIGHT = 'LIGHT'
DARK = 'DARK'
NAV_TEXT_COLORS = (
(BLANK, '-------'),
(LIGHT, 'Light'),
(DARK, 'Dark')
)
name = models.CharField(max_length=128, default='Default') name = models.CharField(max_length=128, default='Default')
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image') image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image')
space_theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
custom_space_theme = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_theme')
nav_logo = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_nav_logo')
nav_bg_color = models.CharField(max_length=8, default='', blank=True, )
nav_text_color = models.CharField(max_length=16, choices=NAV_TEXT_COLORS, default=DARK)
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True) created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
message = models.CharField(max_length=512, default='', blank=True) message = models.CharField(max_length=512, default='', blank=True)
@ -268,7 +300,6 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
no_sharing_limit = models.BooleanField(default=False) no_sharing_limit = models.BooleanField(default=False)
demo = models.BooleanField(default=False) demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True) food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
show_facet_count = models.BooleanField(default=False)
internal_note = models.TextField(blank=True, null=True) internal_note = models.TextField(blank=True, null=True)
@ -331,6 +362,7 @@ class UserPreference(models.Model, PermissionModelMixin):
FLATLY = 'FLATLY' FLATLY = 'FLATLY'
SUPERHERO = 'SUPERHERO' SUPERHERO = 'SUPERHERO'
TANDOOR = 'TANDOOR' TANDOOR = 'TANDOOR'
TANDOOR_DARK = 'TANDOOR_DARK'
THEMES = ( THEMES = (
(TANDOOR, 'Tandoor'), (TANDOOR, 'Tandoor'),
@ -338,25 +370,14 @@ class UserPreference(models.Model, PermissionModelMixin):
(DARKLY, 'Darkly'), (DARKLY, 'Darkly'),
(FLATLY, 'Flatly'), (FLATLY, 'Flatly'),
(SUPERHERO, 'Superhero'), (SUPERHERO, 'Superhero'),
(TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'),
) )
# Nav colors # Nav colors
PRIMARY = 'PRIMARY'
SECONDARY = 'SECONDARY'
SUCCESS = 'SUCCESS'
INFO = 'INFO'
WARNING = 'WARNING'
DANGER = 'DANGER'
LIGHT = 'LIGHT' LIGHT = 'LIGHT'
DARK = 'DARK' DARK = 'DARK'
COLORS = ( NAV_TEXT_COLORS = (
(PRIMARY, 'Primary'),
(SECONDARY, 'Secondary'),
(SUCCESS, 'Success'),
(INFO, 'Info'),
(WARNING, 'Warning'),
(DANGER, 'Danger'),
(LIGHT, 'Light'), (LIGHT, 'Light'),
(DARK, 'Dark') (DARK, 'Dark')
) )
@ -374,8 +395,13 @@ class UserPreference(models.Model, PermissionModelMixin):
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True) user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image') image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image')
theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR) theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY) nav_bg_color = models.CharField(max_length=8, default='#ddbf86')
nav_text_color = models.CharField(max_length=16, choices=NAV_TEXT_COLORS, default=DARK)
nav_show_logo = models.BooleanField(default=True)
nav_sticky = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
default_unit = models.CharField(max_length=32, default='g') default_unit = models.CharField(max_length=32, default='g')
use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT) use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
use_kj = models.BooleanField(default=KJ_PREF_DEFAULT) use_kj = models.BooleanField(default=KJ_PREF_DEFAULT)
@ -385,13 +411,13 @@ class UserPreference(models.Model, PermissionModelMixin):
ingredient_decimals = models.IntegerField(default=2) ingredient_decimals = models.IntegerField(default=2)
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT) comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
shopping_auto_sync = models.IntegerField(default=5) shopping_auto_sync = models.IntegerField(default=5)
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
mealplan_autoadd_shopping = models.BooleanField(default=False) mealplan_autoadd_shopping = models.BooleanField(default=False)
mealplan_autoexclude_onhand = models.BooleanField(default=True) mealplan_autoexclude_onhand = models.BooleanField(default=True)
mealplan_autoinclude_related = models.BooleanField(default=True) mealplan_autoinclude_related = models.BooleanField(default=True)
shopping_add_onhand = models.BooleanField(default=False) shopping_add_onhand = models.BooleanField(default=False)
filter_to_supermarket = models.BooleanField(default=False) filter_to_supermarket = models.BooleanField(default=False)
left_handed = models.BooleanField(default=False) left_handed = models.BooleanField(default=False)
show_step_ingredients = models.BooleanField(default=True)
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4) default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7) shopping_recent_days = models.PositiveIntegerField(default=7)
csv_delim = models.CharField(max_length=2, default=",") csv_delim = models.CharField(max_length=2, default=",")
@ -413,6 +439,9 @@ class UserSpace(models.Model, PermissionModelMixin):
# that having more than one active space should just break certain parts of the application and not leak any data # that having more than one active space should just break certain parts of the application and not leak any data
active = models.BooleanField(default=False) active = models.BooleanField(default=False)
invite_link = models.ForeignKey("InviteLink", on_delete=models.PROTECT, null=True, blank=True)
internal_note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -527,7 +556,6 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
if SORT_TREE_BY_NAME: if SORT_TREE_BY_NAME:
node_order_by = ['name'] node_order_by = ['name']
name = models.CharField(max_length=64) name = models.CharField(max_length=64)
icon = models.CharField(max_length=16, blank=True, null=True)
description = models.TextField(default="", blank=True) description = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
@ -574,6 +602,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)]) name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None) plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL) recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
url = models.CharField(max_length=1024, blank=True, null=True, default='')
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field
ignore_shopping = models.BooleanField(default=False) # inherited field ignore_shopping = models.BooleanField(default=False) # inherited field
onhand_users = models.ManyToManyField(User, blank=True) onhand_users = models.ManyToManyField(User, blank=True)
@ -585,12 +614,12 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit') child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
properties = models.ManyToManyField("Property", blank=True, through='FoodProperty') properties = models.ManyToManyField("Property", blank=True, through='FoodProperty')
properties_food_amount = models.IntegerField(default=100, blank=True) properties_food_amount = models.DecimalField(default=100, max_digits=16, decimal_places=2, blank=True)
properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True) properties_food_unit = models.ForeignKey(Unit, on_delete=models.PROTECT, blank=True, null=True)
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit') 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') 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) open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
@ -718,23 +747,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
objects = ScopedManager(space='space') objects = ScopedManager(space='space')
def __str__(self): def __str__(self):
food = "" return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
unit = ""
if self.always_use_plural_food and self.food.plural_name not in (None, "") and not self.no_amount:
food = self.food.plural_name
else:
if self.amount > 1 and self.food.plural_name not in (None, "") and not self.no_amount:
food = self.food.plural_name
else:
food = str(self.food)
if self.always_use_plural_unit and self.unit.plural_name not in (None, "") and not self.no_amount:
unit = self.unit.plural_name
else:
if self.amount > 1 and self.unit is not None and self.unit.plural_name not in (None, "") and not self.no_amount:
unit = self.unit.plural_name
else:
unit = str(self.unit)
return str(self.amount) + ' ' + str(unit) + ' ' + str(food)
class Meta: class Meta:
ordering = ['order', 'pk'] ordering = ['order', 'pk']
@ -751,6 +764,7 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
order = models.IntegerField(default=0) order = models.IntegerField(default=0)
file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True) file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
show_as_header = models.BooleanField(default=True) show_as_header = models.BooleanField(default=True)
show_ingredients_table = models.BooleanField(default=True)
search_vector = SearchVectorField(null=True) search_vector = SearchVectorField(null=True)
step_recipe = models.ForeignKey('Recipe', default=None, blank=True, null=True, on_delete=models.PROTECT) step_recipe = models.ForeignKey('Recipe', default=None, blank=True, null=True, on_delete=models.PROTECT)
@ -762,7 +776,9 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
return render_instructions(self) return render_instructions(self)
def __str__(self): def __str__(self):
return f'{self.pk} {self.name}' if not self.recipe_set.exists():
return f"{self.pk}: {_('Orphaned Step')}"
return f"{self.pk}: {self.name}" if self.name else f"Step: {self.pk}"
class Meta: class Meta:
ordering = ['order', 'pk'] ordering = ['order', 'pk']
@ -778,11 +794,13 @@ class PropertyType(models.Model, PermissionModelMixin):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
unit = models.CharField(max_length=64, blank=True, null=True) unit = models.CharField(max_length=64, blank=True, null=True)
icon = models.CharField(max_length=16, blank=True, null=True) order = models.IntegerField(default=0)
description = models.CharField(max_length=512, blank=True, null=True) description = models.CharField(max_length=512, blank=True, null=True)
category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')), (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True) category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')),
(PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None) open_data_slug = 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 show if empty property?
# TODO formatting property? # TODO formatting property?
@ -797,6 +815,7 @@ class PropertyType(models.Model, PermissionModelMixin):
models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'), models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'),
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='property_type_unique_open_data_slug_per_space') models.UniqueConstraint(fields=['space', 'open_data_slug'], name='property_type_unique_open_data_slug_per_space')
] ]
ordering = ('order',)
class Property(models.Model, PermissionModelMixin): class Property(models.Model, PermissionModelMixin):
@ -823,7 +842,7 @@ class FoodProperty(models.Model):
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food') models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'),
] ]
@ -945,7 +964,6 @@ class RecipeImport(models.Model, PermissionModelMixin):
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin): class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
description = models.TextField(blank=True) description = models.TextField(blank=True)
icon = models.CharField(max_length=16, blank=True, null=True)
shared = models.ManyToManyField(User, blank=True, related_name='shared_with') shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL) filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
@ -988,7 +1006,6 @@ class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, Pe
class MealType(models.Model, PermissionModelMixin): class MealType(models.Model, PermissionModelMixin):
name = models.CharField(max_length=128) name = models.CharField(max_length=128)
order = models.IntegerField(default=0) order = models.IntegerField(default=0)
icon = models.CharField(max_length=16, blank=True, null=True)
color = models.CharField(max_length=7, blank=True, null=True) color = models.CharField(max_length=7, blank=True, null=True)
default = models.BooleanField(default=False, blank=True) default = models.BooleanField(default=False, blank=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
@ -999,6 +1016,11 @@ class MealType(models.Model, PermissionModelMixin):
def __str__(self): def __str__(self):
return self.name return self.name
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
]
class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin): class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True) recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
@ -1008,7 +1030,8 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
shared = models.ManyToManyField(User, blank=True, related_name='plan_share') shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE) meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
note = models.TextField(blank=True) note = models.TextField(blank=True)
date = models.DateField() from_date = models.DateField()
to_date = models.DateField()
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') objects = ScopedManager(space='space')
@ -1022,7 +1045,7 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
return self.meal_type.name return self.meal_type.name
def __str__(self): def __str__(self):
return f'{self.get_label()} - {self.date} - {self.meal_type.name}' return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}'
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin): class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
@ -1142,6 +1165,8 @@ class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, Permis
created_by = models.ForeignKey(User, on_delete=models.CASCADE) created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
internal_note = models.TextField(blank=True, null=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE) space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space') objects = ScopedManager(space='space')
@ -1308,7 +1333,7 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
def is_image(self): def is_image(self):
try: try:
img = Image.open(self.file.file.file) Image.open(self.file.file.file)
return True return True
except Exception: except Exception:
return False return False
@ -1326,10 +1351,25 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
KEYWORD_ALIAS = 'KEYWORD_ALIAS' KEYWORD_ALIAS = 'KEYWORD_ALIAS'
DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE' DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE'
INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE' INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE'
NEVER_UNIT = 'NEVER_UNIT'
TRANSPOSE_WORDS = 'TRANSPOSE_WORDS'
FOOD_REPLACE = 'FOOD_REPLACE'
UNIT_REPLACE = 'UNIT_REPLACE'
NAME_REPLACE = 'NAME_REPLACE'
type = models.CharField(max_length=128, type = models.CharField(max_length=128,
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')), choices=(
(DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')),)) (FOOD_ALIAS, _('Food Alias')),
(UNIT_ALIAS, _('Unit Alias')),
(KEYWORD_ALIAS, _('Keyword Alias')),
(DESCRIPTION_REPLACE, _('Description Replace')),
(INSTRUCTION_REPLACE, _('Instruction Replace')),
(NEVER_UNIT, _('Never Unit')),
(TRANSPOSE_WORDS, _('Transpose Words')),
(FOOD_REPLACE, _('Food Replace')),
(UNIT_REPLACE, _('Unit Replace')),
(NAME_REPLACE, _('Name Replace')),
))
name = models.CharField(max_length=128, default='') name = models.CharField(max_length=128, default='')
description = models.TextField(blank=True, null=True) description = models.TextField(blank=True, null=True)

View File

@ -67,17 +67,3 @@ class FilterSchema(AutoSchema):
'schema': {'type': 'string', }, 'schema': {'type': 'string', },
}) })
return parameters return parameters
# class QueryOnlySchema(AutoSchema):
# def get_path_parameters(self, path, method):
# if not is_list_view(path, method, self.view):
# return super(QueryOnlySchema, self).get_path_parameters(path, method)
# parameters = super().get_path_parameters(path, method)
# parameters.append({
# "name": 'query', "in": "query", "required": False,
# "description": 'Query string matched (fuzzy) against object name.',
# 'schema': {'type': 'string', },
# })
# return parameters

Some files were not shown because too many files have changed in this diff Show More