Merge remote-tracking branch 'origin/Auto-Planner' into Auto-Planner
This commit is contained in:
commit
61b67cd37a
@ -3,7 +3,6 @@ npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
|
@ -13,13 +13,22 @@ DEBUG_TOOLBAR=0
|
||||
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
|
||||
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
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
# ---------------------------- AT LEAST ONE REQUIRED -------------------------
|
||||
SECRET_KEY=
|
||||
SECRET_KEY_FILE=
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
@ -27,8 +36,9 @@ DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangouser
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
# ---------------------------- AT LEAST ONE REQUIRED -------------------------
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_PASSWORD_FILE=
|
||||
# ---------------------------------------------------------------
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
@ -100,10 +110,12 @@ GUNICORN_MEDIA=0
|
||||
# prefix used for account related emails (default "[Tandoor Recipes] ")
|
||||
# ACCOUNT_EMAIL_SUBJECT_PREFIX=
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://docs.tandoor.dev/features/authentication/
|
||||
# allow authentication via the REMOTE-USER header (can be used for e.g. authelia).
|
||||
# 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)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
REMOTE_USER_AUTH=0
|
||||
|
||||
# Default settings for spaces, apply per space and can be changed in the admin view
|
||||
# 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_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)
|
||||
# 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
|
||||
# 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
|
24
.github/workflows/build-docker-open-data.yml
vendored
24
.github/workflows/build-docker-open-data.yml
vendored
@ -34,16 +34,6 @@ jobs:
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
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
|
||||
- name: clone open data plugin repo
|
||||
uses: actions/checkout@master
|
||||
@ -55,7 +45,7 @@ jobs:
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
@ -74,17 +64,17 @@ jobs:
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@ -92,7 +82,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
vabene1111/recipes
|
||||
@ -107,7 +97,7 @@ jobs:
|
||||
type=semver,suffix=-open-data-plugin,pattern={{major}}
|
||||
type=ref,suffix=-open-data-plugin,event=branch
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
|
32
.github/workflows/build-docker.yml
vendored
32
.github/workflows/build-docker.yml
vendored
@ -17,15 +17,9 @@ jobs:
|
||||
# Standard build config
|
||||
- name: Standard
|
||||
dockerfile: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
# Raspi build config
|
||||
- name: Raspi
|
||||
dockerfile: Dockerfile-raspi
|
||||
platforms: linux/arm/v7
|
||||
suffix: "-raspi"
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@ -40,20 +34,10 @@ jobs:
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
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
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
@ -64,17 +48,17 @@ jobs:
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@ -82,7 +66,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
vabene1111/recipes
|
||||
@ -97,7 +81,7 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=branch
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: '18'
|
||||
- name: Install Vue dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -74,6 +74,7 @@ mediafiles/
|
||||
\.env
|
||||
staticfiles/
|
||||
postgresql/
|
||||
data/
|
||||
|
||||
|
||||
/docker-compose.override.yml
|
||||
|
@ -3,6 +3,7 @@
|
||||
<words>
|
||||
<w>pinia</w>
|
||||
<w>selfhosted</w>
|
||||
<w>unapplied</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
@ -1,5 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
|
35
.idea/recipes.iml
Normal file
35
.idea/recipes.iml
Normal 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="<map/>" />
|
||||
<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>
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
16
Dockerfile
16
Dockerfile
@ -1,7 +1,7 @@
|
||||
FROM python:3.10-alpine3.15
|
||||
FROM python:3.10-alpine3.18
|
||||
|
||||
#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.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
@ -15,7 +15,11 @@ WORKDIR /opt/recipes
|
||||
|
||||
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 && \
|
||||
python -m venv venv && \
|
||||
/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 . ./
|
||||
|
||||
# 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
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
|
@ -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
20
boot.sh
@ -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}"
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@ -30,11 +35,16 @@ echo "Waiting for database to be ready..."
|
||||
attempt=0
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
@ -10,13 +10,13 @@ from treebeard.forms import movenodeform_factory
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
|
||||
from .models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField,
|
||||
ImportLog, Ingredient, InviteLink, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Property, PropertyType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, TelegramBot,
|
||||
Unit, UnitConversion, UserFile, UserPreference, UserSpace, ViewLog)
|
||||
from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@ -39,6 +39,8 @@ def delete_space_action(modeladmin, request, queryset):
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
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')
|
||||
date_hierarchy = 'created_at'
|
||||
actions = [delete_space_action]
|
||||
@ -50,16 +52,19 @@ admin.site.register(Space, SpaceAdmin)
|
||||
class UserSpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'space',)
|
||||
search_fields = ('user__username', 'space__name',)
|
||||
filter_horizontal = ('groups',)
|
||||
autocomplete_fields = ('user', 'space',)
|
||||
|
||||
|
||||
admin.site.register(UserSpace, UserSpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'theme', 'nav_color', 'default_page',)
|
||||
list_display = ('name', 'theme', 'default_page')
|
||||
search_fields = ('user__username',)
|
||||
list_filter = ('theme', 'nav_color', 'default_page',)
|
||||
list_filter = ('theme', 'default_page',)
|
||||
date_hierarchy = 'created_at'
|
||||
filter_horizontal = ('plan_share', 'shopping_share',)
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@ -103,11 +108,16 @@ class SupermarketCategoryInline(admin.TabularInline):
|
||||
|
||||
|
||||
class SupermarketAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space',)
|
||||
inlines = (SupermarketCategoryInline,)
|
||||
|
||||
|
||||
class SupermarketCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space',)
|
||||
|
||||
|
||||
admin.site.register(Supermarket, SupermarketAdmin)
|
||||
admin.site.register(SupermarketCategory)
|
||||
admin.site.register(SupermarketCategory, SupermarketCategoryAdmin)
|
||||
|
||||
|
||||
class SyncLogAdmin(admin.ModelAdmin):
|
||||
@ -158,10 +168,18 @@ def delete_unattached_steps(modeladmin, request, queryset):
|
||||
|
||||
|
||||
class StepAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'order',)
|
||||
search_fields = ('name',)
|
||||
list_display = ('recipe_and_name', 'order', 'space')
|
||||
ordering = ('recipe__name', 'name', 'space',)
|
||||
search_fields = ('name', 'recipe__name')
|
||||
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)
|
||||
|
||||
@ -178,8 +196,9 @@ def rebuild_index(modeladmin, request, queryset):
|
||||
|
||||
|
||||
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')
|
||||
ordering = ('name', 'created_by__username',)
|
||||
list_filter = ('internal',)
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@ -187,13 +206,20 @@ class RecipeAdmin(admin.ModelAdmin):
|
||||
def created_by(obj):
|
||||
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]
|
||||
|
||||
|
||||
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)
|
||||
@ -224,10 +250,16 @@ def delete_unattached_ingredients(modeladmin, request, queryset):
|
||||
|
||||
|
||||
class IngredientAdmin(admin.ModelAdmin):
|
||||
list_display = ('food', 'amount', 'unit')
|
||||
search_fields = ('food__name', 'unit__name')
|
||||
list_display = ('recipe_name', 'amount', 'unit', 'food', 'space')
|
||||
search_fields = ('food__name', 'unit__name', 'step__recipe__name')
|
||||
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)
|
||||
|
||||
@ -253,7 +285,7 @@ admin.site.register(RecipeImport, RecipeImportAdmin)
|
||||
|
||||
|
||||
class RecipeBookAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user_name')
|
||||
list_display = ('name', 'user_name', 'space')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
|
||||
@staticmethod
|
||||
@ -272,7 +304,7 @@ admin.site.register(RecipeBookEntry, RecipeBookEntryAdmin)
|
||||
|
||||
|
||||
class MealPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'recipe', 'meal_type', 'date')
|
||||
list_display = ('user', 'recipe', 'meal_type', 'from_date', 'to_date')
|
||||
|
||||
@staticmethod
|
||||
def user(obj):
|
||||
@ -309,6 +341,7 @@ admin.site.register(InviteLink, InviteLinkAdmin)
|
||||
|
||||
class CookLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
|
||||
search_fields = ('recipe__name', 'space__name',)
|
||||
|
||||
|
||||
admin.site.register(CookLog, CookLogAdmin)
|
||||
@ -328,11 +361,11 @@ class ShoppingListEntryAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
|
||||
|
||||
|
||||
class ShoppingListAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'created_by', 'created_at')
|
||||
# class ShoppingListAdmin(admin.ModelAdmin):
|
||||
# list_display = ('id', 'created_by', 'created_at')
|
||||
|
||||
|
||||
admin.site.register(ShoppingList, ShoppingListAdmin)
|
||||
# admin.site.register(ShoppingList, ShoppingListAdmin)
|
||||
|
||||
|
||||
class ShareLinkAdmin(admin.ModelAdmin):
|
||||
@ -343,7 +376,9 @@ admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
class PropertyTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name')
|
||||
search_fields = ('space',)
|
||||
|
||||
list_display = ('id', 'space', 'name', 'fdc_id')
|
||||
|
||||
|
||||
admin.site.register(PropertyType, PropertyTypeAdmin)
|
||||
|
@ -9,8 +9,8 @@ from django_scopes import scopes_disabled
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from hcaptcha.fields import hCaptchaField
|
||||
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
|
||||
from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry,
|
||||
SearchPreference, Space, Storage, Sync, User, UserPreference)
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
@ -33,64 +33,6 @@ class DateWidget(forms.DateInput):
|
||||
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):
|
||||
prefix = 'name'
|
||||
|
||||
@ -184,6 +126,7 @@ class MultipleFileField(forms.FileField):
|
||||
result = single_file_clean(data, initial)
|
||||
return result
|
||||
|
||||
|
||||
class ImportForm(ImportExportBase):
|
||||
files = MultipleFileField(required=True)
|
||||
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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
@ -506,8 +405,8 @@ class ShoppingPreferenceForm(forms.ModelForm):
|
||||
help_texts = {
|
||||
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
|
||||
'of mobile data. If lower than instance limit it is reset when saving.'
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
|
||||
'of mobile data. If lower than instance limit it is reset when saving.'
|
||||
),
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
|
||||
@ -551,11 +450,10 @@ class SpacePreferenceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Space
|
||||
|
||||
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural')
|
||||
fields = ('food_inherit', 'reset_food_inherit', 'use_plural')
|
||||
|
||||
help_texts = {
|
||||
'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.'),
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,10 @@
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from gettext import gettext as _
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import caches
|
||||
from gettext import gettext as _
|
||||
|
||||
from cookbook.models import InviteLink
|
||||
|
||||
@ -17,10 +16,13 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
Whether to allow sign-ups.
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
else:
|
||||
return super(AllAuthCustomAdapter, self).is_open_for_signup(request)
|
||||
@ -33,7 +35,7 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
if c == default:
|
||||
try:
|
||||
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
|
||||
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.'))
|
||||
|
@ -7,7 +7,7 @@ class Round(Func):
|
||||
|
||||
|
||||
def str2bool(v):
|
||||
if type(v) == bool or v is None:
|
||||
if isinstance(v, bool) or v is None:
|
||||
return v
|
||||
else:
|
||||
return v.lower() in ("yes", "true", "1")
|
||||
|
227
cookbook/helper/automation_helper.py
Normal file
227
cookbook/helper/automation_helper.py
Normal 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
|
19
cookbook/helper/fdc_helper.py
Normal file
19
cookbook/helper/fdc_helper.py
Normal 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()
|
@ -1,8 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
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]))
|
||||
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.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)
|
||||
wpercent = (base_width / float(image_object.size[0]))
|
||||
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()
|
||||
img.save(im_io, 'PNG', quality=90)
|
||||
|
@ -2,18 +2,16 @@ import re
|
||||
import string
|
||||
import unicodedata
|
||||
|
||||
from django.core.cache import caches
|
||||
|
||||
from cookbook.models import Unit, Food, Automation, Ingredient
|
||||
from cookbook.helper.automation_helper import AutomationEngine
|
||||
from cookbook.models import Food, Ingredient, Unit
|
||||
|
||||
|
||||
class IngredientParser:
|
||||
request = None
|
||||
ignore_rules = False
|
||||
food_aliases = {}
|
||||
unit_aliases = {}
|
||||
automation = None
|
||||
|
||||
def __init__(self, request, cache_mode, ignore_automations=False):
|
||||
def __init__(self, request, cache_mode=True, ignore_automations=False):
|
||||
"""
|
||||
Initialize ingredient parser
|
||||
:param request: request context (to control caching, rule ownership, etc.)
|
||||
@ -22,65 +20,8 @@ class IngredientParser:
|
||||
"""
|
||||
self.request = request
|
||||
self.ignore_rules = ignore_automations
|
||||
if cache_mode:
|
||||
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] = 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
|
||||
if not self.ignore_rules:
|
||||
self.automation = AutomationEngine(self.request, use_cache=cache_mode)
|
||||
|
||||
def get_unit(self, unit):
|
||||
"""
|
||||
@ -91,7 +32,10 @@ class IngredientParser:
|
||||
if not unit:
|
||||
return None
|
||||
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 None
|
||||
|
||||
@ -104,7 +48,10 @@ class IngredientParser:
|
||||
if not food:
|
||||
return None
|
||||
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 None
|
||||
|
||||
@ -133,10 +80,10 @@ class IngredientParser:
|
||||
end = 0
|
||||
while (end < len(x) and (x[end] in string.digits
|
||||
or (
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
))):
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
))):
|
||||
end += 1
|
||||
if end > 0:
|
||||
if "/" in x[:end]:
|
||||
@ -160,7 +107,8 @@ class IngredientParser:
|
||||
if unit is not None and unit.strip() == '':
|
||||
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
|
||||
note = x
|
||||
return amount, unit, note
|
||||
@ -221,6 +169,9 @@ class IngredientParser:
|
||||
if len(ingredient) == 0:
|
||||
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
|
||||
# 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):
|
||||
@ -230,8 +181,8 @@ class IngredientParser:
|
||||
|
||||
# if the string contains parenthesis early on remove it and place it at the end
|
||||
# because its likely some kind of note
|
||||
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', ingredient):
|
||||
match = re.search('\((.[^\(])+\)', ingredient)
|
||||
if re.match('(.){1,6}\\s\\((.[^\\(\\)])+\\)\\s', ingredient):
|
||||
match = re.search('\\((.[^\\(])+\\)', ingredient)
|
||||
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
|
||||
|
||||
# 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
|
||||
# "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 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)
|
||||
|
||||
if not self.ignore_rules:
|
||||
ingredient = self.automation.apply_transpose_automation(ingredient)
|
||||
|
||||
tokens = ingredient.split() # split at each space into tokens
|
||||
if len(tokens) == 1:
|
||||
# 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
|
||||
# a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
if not self.ignore_rules:
|
||||
tokens = self.automation.apply_never_unit_automation(tokens)
|
||||
try:
|
||||
if unit is not None:
|
||||
# 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:
|
||||
note += ' ' + unit_note
|
||||
|
||||
if unit:
|
||||
unit = self.apply_unit_automation(unit.strip())
|
||||
if unit and not self.ignore_rules:
|
||||
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
|
||||
# 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:
|
||||
|
@ -1,6 +1,5 @@
|
||||
from django.db.models import Q
|
||||
|
||||
from cookbook.models import Unit, SupermarketCategory, Property, PropertyType, Supermarket, SupermarketCategoryRelation, Food, Automation, UnitConversion, FoodProperty
|
||||
from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion)
|
||||
|
||||
|
||||
class OpenDataImporter:
|
||||
@ -33,7 +32,8 @@ class OpenDataImporter:
|
||||
))
|
||||
|
||||
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:
|
||||
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(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_flat = []
|
||||
update_list = []
|
||||
update_field_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
|
||||
insert_list.append({'data': {
|
||||
'name': self.data[datatype][k]['name'],
|
||||
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
# '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']],
|
||||
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
'open_data_slug': k,
|
||||
'space': self.request.space.id,
|
||||
}})
|
||||
if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_flat):
|
||||
insert_list.append({'data': {
|
||||
'name': self.data[datatype][k]['name'],
|
||||
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
|
||||
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
'open_data_slug': k,
|
||||
'space': self.request.space.id,
|
||||
}})
|
||||
# build a fake second flat array to prevent duplicate foods from being inserted.
|
||||
# trying to insert a duplicate would throw a db error :(
|
||||
insert_list_flat.append(self.data[datatype][k]['name'])
|
||||
insert_list_flat.append(self.data[datatype][k]['plural_name'])
|
||||
else:
|
||||
if self.data[datatype][k]['name'] in existing_objects:
|
||||
existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
|
||||
@ -149,8 +147,6 @@ class OpenDataImporter:
|
||||
id=existing_food_id,
|
||||
name=self.data[datatype][k]['name'],
|
||||
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
# 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']],
|
||||
fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
open_data_slug=k,
|
||||
@ -166,23 +162,20 @@ class OpenDataImporter:
|
||||
self._update_slug_cache(Food, 'food')
|
||||
|
||||
food_property_list = []
|
||||
alias_list = []
|
||||
# alias_list = []
|
||||
|
||||
for k in list(self.data[datatype].keys()):
|
||||
for fp in self.data[datatype][k]['properties']['type_values']:
|
||||
food_property_list.append(Property(
|
||||
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||
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(
|
||||
# param_1=a,
|
||||
# param_2=self.data[datatype][k]['name'],
|
||||
# space=self.request.space,
|
||||
# created_by=self.request.user,
|
||||
# ))
|
||||
# try catch here because somettimes key "k" is not set for he food cache
|
||||
try:
|
||||
food_property_list.append(Property(
|
||||
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||
property_amount=fp['property_value'],
|
||||
import_food_id=self.slug_id_cache['food'][k],
|
||||
space=self.request.space,
|
||||
))
|
||||
except KeyError:
|
||||
print(str(k) + ' is not in self.slug_id_cache["food"]')
|
||||
|
||||
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',))
|
||||
|
||||
# Automation.objects.bulk_create(alias_list, ignore_conflicts=True, unique_fields=('space', 'param_1', 'param_2',))
|
||||
return insert_list + update_list
|
||||
|
||||
def import_conversion(self):
|
||||
@ -200,15 +192,19 @@ class OpenDataImporter:
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(UnitConversion(
|
||||
base_amount=self.data[datatype][k]['base_amount'],
|
||||
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
|
||||
converted_amount=self.data[datatype][k]['converted_amount'],
|
||||
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
|
||||
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
|
||||
open_data_slug=k,
|
||||
space=self.request.space,
|
||||
created_by=self.request.user,
|
||||
))
|
||||
# try catch here because sometimes key "k" is not set for he food cache
|
||||
try:
|
||||
insert_list.append(UnitConversion(
|
||||
base_amount=self.data[datatype][k]['base_amount'],
|
||||
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
|
||||
converted_amount=self.data[datatype][k]['converted_amount'],
|
||||
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
|
||||
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
|
||||
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'))
|
||||
|
@ -4,16 +4,16 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
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.urls import reverse, reverse_lazy
|
||||
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 rest_framework import permissions
|
||||
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):
|
||||
@ -255,9 +255,6 @@ class CustomIsShared(permissions.BasePermission):
|
||||
return request.user.is_authenticated
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@ -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
|
||||
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):
|
||||
share = request.query_params.get('share', None)
|
||||
@ -332,7 +330,8 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
if obj.private:
|
||||
return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space
|
||||
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):
|
||||
@ -361,7 +360,7 @@ class CustomTokenHasScope(TokenHasScope):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if type(request.auth) == AccessToken:
|
||||
if isinstance(request.auth, AccessToken):
|
||||
return super().has_permission(request, view)
|
||||
else:
|
||||
return request.user.is_authenticated
|
||||
@ -375,7 +374,7 @@ class CustomTokenHasReadWriteScope(TokenHasReadWriteScope):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if type(request.auth) == AccessToken:
|
||||
if isinstance(request.auth, AccessToken):
|
||||
return super().has_permission(request, view)
|
||||
else:
|
||||
return True
|
||||
@ -434,3 +433,10 @@ def switch_user_active_space(user, space):
|
||||
return us
|
||||
except ObjectDoesNotExist:
|
||||
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
|
||||
|
@ -2,7 +2,7 @@ from django.core.cache import caches
|
||||
|
||||
from cookbook.helper.cache_helper import CacheHelper
|
||||
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:
|
||||
@ -31,10 +31,12 @@ class FoodPropertyHelper:
|
||||
|
||||
if not property_types:
|
||||
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:
|
||||
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)
|
||||
|
||||
@ -53,7 +55,8 @@ class FoodPropertyHelper:
|
||||
if c.unit == i.food.properties_food_unit:
|
||||
found_property = True
|
||||
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:
|
||||
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}
|
||||
|
@ -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
|
@ -1,14 +1,11 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
|
||||
from django.core.cache import cache, caches
|
||||
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value,
|
||||
When)
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Avg, Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Value, When
|
||||
from django.db.models.functions import Coalesce, Lower, Substr
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.managers import DICTIONARY
|
||||
@ -17,21 +14,25 @@ from cookbook.models import (CookLog, CustomFilter, Food, Keyword, Recipe, Searc
|
||||
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
|
||||
class RecipeSearch():
|
||||
_postgres = settings.DATABASES['default']['ENGINE'] in [
|
||||
'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
|
||||
_postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
|
||||
|
||||
def __init__(self, request, **params):
|
||||
self._request = request
|
||||
self._queryset = 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) |
|
||||
Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)).first()
|
||||
custom_filter = (
|
||||
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:
|
||||
self._params = {**json.loads(custom_filter.search)}
|
||||
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:
|
||||
self._params = {**(params or {})}
|
||||
else:
|
||||
@ -85,9 +86,9 @@ class RecipeSearch():
|
||||
self._viewedon = self._params.get('viewedon', None)
|
||||
self._makenow = self._params.get('makenow', None)
|
||||
# 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
|
||||
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
|
||||
else:
|
||||
try:
|
||||
@ -98,24 +99,18 @@ class RecipeSearch():
|
||||
self._search_type = self._search_prefs.search or 'plain'
|
||||
if self._string:
|
||||
if self._postgres:
|
||||
self._unaccent_include = self._search_prefs.unaccent.values_list(
|
||||
'field', flat=True)
|
||||
self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
|
||||
else:
|
||||
self._unaccent_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)]
|
||||
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._icontains_include = [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._trigram_include = None
|
||||
self._fulltext_include = None
|
||||
self._trigram = False
|
||||
if self._postgres and self._string:
|
||||
self._language = DICTIONARY.get(
|
||||
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._fulltext_include = self._search_prefs.fulltext.values_list(
|
||||
'field', flat=True) or None
|
||||
self._language = DICTIONARY.get(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._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:
|
||||
self._trigram = True
|
||||
@ -150,7 +145,7 @@ class RecipeSearch():
|
||||
self.unit_filters(units=self._units)
|
||||
self._makenow_filter(missing=self._makenow)
|
||||
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):
|
||||
for x in args:
|
||||
@ -166,7 +161,7 @@ class RecipeSearch():
|
||||
else:
|
||||
order = []
|
||||
# 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
|
||||
if self._num_recent:
|
||||
order += ['-recent']
|
||||
@ -175,7 +170,6 @@ class RecipeSearch():
|
||||
|
||||
# if a sort order is provided by user - use that order
|
||||
if self._sort_order:
|
||||
|
||||
if not isinstance(self._sort_order, list):
|
||||
order += [self._sort_order]
|
||||
else:
|
||||
@ -215,24 +209,18 @@ class RecipeSearch():
|
||||
self._queryset = self._queryset.filter(query_filter).distinct()
|
||||
if self._fulltext_include:
|
||||
if self._fuzzy_match is None:
|
||||
self._queryset = self._queryset.annotate(
|
||||
score=Coalesce(Max(self.search_rank), 0.0))
|
||||
self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0))
|
||||
else:
|
||||
self._queryset = self._queryset.annotate(
|
||||
rank=Coalesce(Max(self.search_rank), 0.0))
|
||||
self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0))
|
||||
|
||||
if self._fuzzy_match is not None:
|
||||
simularity = self._fuzzy_match.filter(
|
||||
pk=OuterRef('pk')).values('simularity')
|
||||
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
|
||||
if not self._fulltext_include:
|
||||
self._queryset = self._queryset.annotate(
|
||||
score=Coalesce(Subquery(simularity), 0.0))
|
||||
self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
|
||||
else:
|
||||
self._queryset = self._queryset.annotate(
|
||||
simularity=Coalesce(Subquery(simularity), 0.0))
|
||||
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
||||
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
|
||||
self._queryset = self._queryset.annotate(
|
||||
score=F('rank') + F('simularity'))
|
||||
self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
|
||||
else:
|
||||
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)]:
|
||||
@ -241,78 +229,69 @@ class RecipeSearch():
|
||||
|
||||
def _cooked_on_filter(self, cooked_date=None):
|
||||
if self._sort_includes('lastcooked') or cooked_date:
|
||||
lessthan = self._sort_includes(
|
||||
'-lastcooked') or '-' in (cooked_date or [])[:1]
|
||||
lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1]
|
||||
if lessthan:
|
||||
default = timezone.now() - timedelta(days=100000)
|
||||
else:
|
||||
default = timezone.now()
|
||||
self._queryset = self._queryset.annotate(lastcooked=Coalesce(
|
||||
Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default)))
|
||||
self._queryset = self._queryset.annotate(
|
||||
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:
|
||||
return
|
||||
|
||||
cooked_date = date(*[int(x)
|
||||
for x in cooked_date.split('-') if x != ''])
|
||||
cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != ''])
|
||||
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
|
||||
self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
|
||||
self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
|
||||
|
||||
def _created_on_filter(self, created_date=None):
|
||||
if created_date is None:
|
||||
return
|
||||
lessthan = '-' in created_date[:1]
|
||||
created_date = date(*[int(x)
|
||||
for x in created_date.split('-') if x != ''])
|
||||
created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
created_at__date__lte=created_date)
|
||||
self._queryset = self._queryset.filter(created_at__date__lte=created_date)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
created_at__date__gte=created_date)
|
||||
self._queryset = self._queryset.filter(created_at__date__gte=created_date)
|
||||
|
||||
def _updated_on_filter(self, updated_date=None):
|
||||
if updated_date is None:
|
||||
return
|
||||
lessthan = '-' in updated_date[:1]
|
||||
updated_date = date(*[int(x)
|
||||
for x in updated_date.split('-') if x != ''])
|
||||
updated_date = date(*[int(x)for x in updated_date.split('-') if x != ''])
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
updated_at__date__lte=updated_date)
|
||||
self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
updated_at__date__gte=updated_date)
|
||||
self._queryset = self._queryset.filter(updated_at__date__gte=updated_date)
|
||||
|
||||
def _viewed_on_filter(self, viewed_date=None):
|
||||
if self._sort_includes('lastviewed') or viewed_date:
|
||||
longTimeAgo = timezone.now() - timedelta(days=100000)
|
||||
self._queryset = self._queryset.annotate(lastviewed=Coalesce(
|
||||
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo)))
|
||||
self._queryset = self._queryset.annotate(
|
||||
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:
|
||||
return
|
||||
lessthan = '-' in viewed_date[:1]
|
||||
viewed_date = date(*[int(x)
|
||||
for x in viewed_date.split('-') if x != ''])
|
||||
viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != ''])
|
||||
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
|
||||
def _new_recipes(self, new_days=7):
|
||||
# TODO make new days a user-setting
|
||||
if not self._new:
|
||||
return
|
||||
self._queryset = (
|
||||
self._queryset.annotate(new_recipe=Case(
|
||||
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), default=Value(0), ))
|
||||
self._queryset = self._queryset.annotate(
|
||||
new_recipe=Case(
|
||||
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')),
|
||||
default=Value(0),
|
||||
)
|
||||
)
|
||||
|
||||
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)))
|
||||
return
|
||||
|
||||
num_recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values(
|
||||
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
||||
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(
|
||||
pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||
num_recent_recipes = (
|
||||
ViewLog.objects.filter(created_by=self._request.user, space=self._request.space)
|
||||
.values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
||||
)
|
||||
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):
|
||||
if self._sort_includes('favorite') or times_cooked:
|
||||
less_than = '-' in (times_cooked or []
|
||||
) and not self._sort_includes('-favorite')
|
||||
less_than = '-' in (str(times_cooked) or []) and not self._sort_includes('-favorite')
|
||||
if less_than:
|
||||
default = 1000
|
||||
else:
|
||||
default = 0
|
||||
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
|
||||
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
|
||||
self._queryset = self._queryset.annotate(
|
||||
favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
favorite_recipes = (
|
||||
CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk'))
|
||||
.values('recipe')
|
||||
.annotate(count=Count('pk', distinct=True))
|
||||
.values('count')
|
||||
)
|
||||
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
if times_cooked is None:
|
||||
return
|
||||
|
||||
if times_cooked == '0':
|
||||
self._queryset = self._queryset.filter(favorite=0)
|
||||
elif less_than:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(
|
||||
times_cooked.replace('-', ''))).exclude(favorite=0)
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
favorite__gte=int(times_cooked))
|
||||
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
|
||||
|
||||
def keyword_filters(self, **kwargs):
|
||||
if all([kwargs[x] is None for x in kwargs]):
|
||||
@ -382,8 +362,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self._queryset = self._queryset.filter(f_and)
|
||||
if 'not' in kw_filter:
|
||||
self._queryset = self._queryset.exclude(
|
||||
id__in=recipes.values('id'))
|
||||
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
|
||||
|
||||
def food_filters(self, **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])
|
||||
if 'or' in fd_filter:
|
||||
if self._include_children:
|
||||
f_or = Q(
|
||||
steps__ingredients__food__in=Food.include_descendants(foods))
|
||||
f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods))
|
||||
else:
|
||||
f_or = Q(steps__ingredients__food__in=foods)
|
||||
|
||||
@ -410,8 +388,7 @@ class RecipeSearch():
|
||||
recipes = Recipe.objects.all()
|
||||
for food in foods:
|
||||
if self._include_children:
|
||||
f_and = Q(
|
||||
steps__ingredients__food__in=food.get_descendants_and_self())
|
||||
f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self())
|
||||
else:
|
||||
f_and = Q(steps__ingredients__food=food)
|
||||
if 'not' in fd_filter:
|
||||
@ -419,8 +396,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self._queryset = self._queryset.filter(f_and)
|
||||
if 'not' in fd_filter:
|
||||
self._queryset = self._queryset.exclude(
|
||||
id__in=recipes.values('id'))
|
||||
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
|
||||
|
||||
def unit_filters(self, units=None, operator=True):
|
||||
if operator != True:
|
||||
@ -429,27 +405,25 @@ class RecipeSearch():
|
||||
return
|
||||
if not isinstance(units, list):
|
||||
units = [units]
|
||||
self._queryset = self._queryset.filter(
|
||||
steps__ingredients__unit__in=units)
|
||||
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
|
||||
|
||||
def rating_filter(self, rating=None):
|
||||
if rating or self._sort_includes('rating'):
|
||||
lessthan = self._sort_includes('-rating') or '-' in (rating or [])
|
||||
if lessthan:
|
||||
lessthan = '-' in (rating or [])
|
||||
reverse = 'rating' in (self._sort_order or []) and '-rating' not in (self._sort_order or [])
|
||||
if lessthan or reverse:
|
||||
default = 100
|
||||
else:
|
||||
default = 0
|
||||
# TODO make ratings a settings user-only vs all-users
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(
|
||||
cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
|
||||
if rating is None:
|
||||
return
|
||||
|
||||
if rating == '0':
|
||||
self._queryset = self._queryset.filter(rating=0)
|
||||
elif lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
rating__lte=int(rating[1:])).exclude(rating=0)
|
||||
self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(rating__gte=int(rating))
|
||||
|
||||
@ -477,14 +451,11 @@ class RecipeSearch():
|
||||
recipes = Recipe.objects.all()
|
||||
for book in kwargs[bk_filter]:
|
||||
if 'not' in bk_filter:
|
||||
recipes = recipes.filter(
|
||||
recipebookentry__book__id=book)
|
||||
recipes = recipes.filter(recipebookentry__book__id=book)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
recipebookentry__book__id=book)
|
||||
self._queryset = self._queryset.filter(recipebookentry__book__id=book)
|
||||
if 'not' in bk_filter:
|
||||
self._queryset = self._queryset.exclude(
|
||||
id__in=recipes.values('id'))
|
||||
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
|
||||
|
||||
def step_filters(self, steps=None, operator=True):
|
||||
if operator != True:
|
||||
@ -503,25 +474,20 @@ class RecipeSearch():
|
||||
rank = []
|
||||
if 'name' in self._fulltext_include:
|
||||
vectors.append('name_search_vector')
|
||||
rank.append(SearchRank('name_search_vector',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True))
|
||||
if 'description' in self._fulltext_include:
|
||||
vectors.append('desc_search_vector')
|
||||
rank.append(SearchRank('desc_search_vector',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True))
|
||||
if 'steps__instruction' in self._fulltext_include:
|
||||
vectors.append('steps__search_vector')
|
||||
rank.append(SearchRank('steps__search_vector',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True))
|
||||
if 'keywords__name' in self._fulltext_include:
|
||||
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
|
||||
vectors.append('keywords__name__unaccent')
|
||||
rank.append(SearchRank('keywords__name__unaccent',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
|
||||
if 'steps__ingredients__food__name' in self._fulltext_include:
|
||||
vectors.append('steps__ingredients__food__name__unaccent')
|
||||
rank.append(SearchRank('steps__ingredients__food__name',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True))
|
||||
|
||||
for r in rank:
|
||||
if self.search_rank is None:
|
||||
@ -529,8 +495,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self.search_rank += r
|
||||
# modifying queryset will annotation creates duplicate results
|
||||
self._filters.append(Q(id__in=Recipe.objects.annotate(
|
||||
vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
|
||||
self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
|
||||
|
||||
def build_text_filters(self, string=None):
|
||||
if not string:
|
||||
@ -555,15 +520,19 @@ class RecipeSearch():
|
||||
trigram += TrigramSimilarity(f, self._string)
|
||||
else:
|
||||
trigram = TrigramSimilarity(f, self._string)
|
||||
self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct(
|
||||
).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold)
|
||||
self._fuzzy_match = (
|
||||
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'))]
|
||||
|
||||
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
|
||||
shopping_users = [
|
||||
*self._request.user.get_shopping_share(), self._request.user]
|
||||
shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
|
||||
|
||||
onhand_filter = (
|
||||
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))
|
||||
)
|
||||
makenow_recipes = Recipe.objects.annotate(
|
||||
count_food=Count('steps__ingredients__food__pk', filter=Q(
|
||||
steps__ingredients__food__isnull=False), distinct=True),
|
||||
count_onhand=Count('steps__ingredients__food__pk',
|
||||
filter=onhand_filter, 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_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(
|
||||
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'))
|
||||
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
|
||||
count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, 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_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0))
|
||||
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood__lte=missing)
|
||||
self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))
|
||||
|
||||
@staticmethod
|
||||
def __children_substitute_filter(shopping_users=None):
|
||||
children_onhand_subquery = Food.objects.filter(
|
||||
path__startswith=OuterRef('path'),
|
||||
depth__gt=OuterRef('depth'),
|
||||
onhand_users__in=shopping_users
|
||||
children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users)
|
||||
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)
|
||||
)
|
||||
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
|
||||
def __sibling_substitute_filter(shopping_users=None):
|
||||
sibling_onhand_subquery = Food.objects.filter(
|
||||
path__startswith=Substr(
|
||||
OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
|
||||
depth=OuterRef('depth'),
|
||||
onhand_users__in=shopping_users
|
||||
path__startswith=Substr(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
|
||||
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_siblings=True
|
||||
).annotate(sibling_onhand=Exists(sibling_onhand_subquery)
|
||||
).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
|
||||
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
|
||||
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_siblings=True)
|
||||
.annotate(sibling_onhand=Exists(sibling_onhand_subquery))
|
||||
.filter(sibling_onhand=True)
|
||||
)
|
||||
|
||||
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())
|
||||
|
@ -1,9 +1,7 @@
|
||||
# import random
|
||||
import re
|
||||
import traceback
|
||||
from html import unescape
|
||||
|
||||
from django.core.cache import caches
|
||||
from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
from isodate import parse_duration as iso_parse_duration
|
||||
@ -11,20 +9,37 @@ from isodate.isoerror import ISO8601Error
|
||||
from pytube import YouTube
|
||||
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.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):
|
||||
# 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:
|
||||
recipe_json['name'] = parse_name(scrape.title()[:128] or None)
|
||||
except Exception:
|
||||
@ -38,6 +53,10 @@ def get_from_scraper(scrape, request):
|
||||
if isinstance(recipe_json['name'], list) and len(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:
|
||||
description = scrape.description() or None
|
||||
except Exception:
|
||||
@ -48,16 +67,20 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
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:
|
||||
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:
|
||||
servings = 1
|
||||
|
||||
recipe_json['servings'] = parse_servings(servings)
|
||||
recipe_json['servings_text'] = parse_servings_text(servings)
|
||||
|
||||
# assign time attributes
|
||||
try:
|
||||
recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0
|
||||
except Exception:
|
||||
@ -82,6 +105,7 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# assign image
|
||||
try:
|
||||
recipe_json['image'] = parse_image(scrape.image()) or None
|
||||
except Exception:
|
||||
@ -92,7 +116,7 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
recipe_json['image'] = ''
|
||||
|
||||
keywords = []
|
||||
# assign keywords
|
||||
try:
|
||||
if 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:
|
||||
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:
|
||||
if scrape.author():
|
||||
keywords.append(scrape.author())
|
||||
@ -138,33 +148,24 @@ def get_from_scraper(scrape, request):
|
||||
pass
|
||||
|
||||
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:
|
||||
recipe_json['keywords'] = keywords
|
||||
|
||||
ingredient_parser = IngredientParser(request, True)
|
||||
|
||||
recipe_json['steps'] = []
|
||||
# assign steps
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
|
||||
parsed_description = parse_description(description)
|
||||
# TODO notify user about limit if reached
|
||||
# limits exist to limit the attack surface for dos style attacks
|
||||
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]
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
recipe_json['steps'][0]['instruction'] = f"*{recipe_json['description']}* \n\n" + recipe_json['steps'][0]['instruction']
|
||||
|
||||
try:
|
||||
for x in scrape.ingredients():
|
||||
@ -205,12 +206,9 @@ def get_from_scraper(scrape, request):
|
||||
traceback.print_exc()
|
||||
pass
|
||||
|
||||
if 'source_url' in recipe_json and recipe_json['source_url']:
|
||||
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]
|
||||
for a in automations:
|
||||
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'])
|
||||
for s in recipe_json['steps']:
|
||||
s['instruction'] = automation_engine.apply_regex_replace_automation(s['instruction'], Automation.INSTRUCTION_REPLACE)
|
||||
# re.sub(a.param_2, a.param_3, s['instruction'])
|
||||
|
||||
return recipe_json
|
||||
|
||||
@ -261,10 +259,14 @@ def get_from_youtube_scraper(url, request):
|
||||
}
|
||||
|
||||
try:
|
||||
video = YouTube(url=url)
|
||||
default_recipe_json['name'] = video.title
|
||||
automation_engine = AutomationEngine(request, source=url)
|
||||
video = YouTube(url)
|
||||
video.streams.first() # this is required to execute some kind of generator/web request that fetches the description
|
||||
default_recipe_json['name'] = automation_engine.apply_regex_replace_automation(video.title, Automation.NAME_REPLACE)
|
||||
default_recipe_json['image'] = video.thumbnail_url
|
||||
default_recipe_json['steps'][0]['instruction'] = video.description
|
||||
if video.description:
|
||||
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -272,7 +274,7 @@ def get_from_youtube_scraper(url, request):
|
||||
|
||||
|
||||
def parse_name(name):
|
||||
if type(name) == list:
|
||||
if isinstance(name, list):
|
||||
try:
|
||||
name = name[0]
|
||||
except Exception:
|
||||
@ -316,16 +318,16 @@ def parse_instructions(instructions):
|
||||
"""
|
||||
instruction_list = []
|
||||
|
||||
if type(instructions) == list:
|
||||
if isinstance(instructions, list):
|
||||
for i in instructions:
|
||||
if type(i) == str:
|
||||
if isinstance(i, str):
|
||||
instruction_list.append(clean_instruction_string(i))
|
||||
else:
|
||||
if 'text' in i:
|
||||
instruction_list.append(clean_instruction_string(i['text']))
|
||||
elif 'itemListElement' in i:
|
||||
for ile in i['itemListElement']:
|
||||
if type(ile) == str:
|
||||
if isinstance(ile, str):
|
||||
instruction_list.append(clean_instruction_string(ile))
|
||||
elif 'text' in ile:
|
||||
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
|
||||
if not image:
|
||||
return None
|
||||
if type(image) == list:
|
||||
if isinstance(image, list):
|
||||
for pic in image:
|
||||
if (type(pic) == str) and (pic[:4] == 'http'):
|
||||
if (isinstance(pic, str)) and (pic[:4] == 'http'):
|
||||
image = pic
|
||||
elif 'url' in pic:
|
||||
image = pic['url']
|
||||
elif type(image) == dict:
|
||||
elif isinstance(image, dict):
|
||||
if 'url' in image:
|
||||
image = image['url']
|
||||
|
||||
@ -358,12 +360,12 @@ def parse_image(image):
|
||||
|
||||
|
||||
def parse_servings(servings):
|
||||
if type(servings) == str:
|
||||
if isinstance(servings, str):
|
||||
try:
|
||||
servings = int(re.search(r'\d+', servings).group())
|
||||
except AttributeError:
|
||||
servings = 1
|
||||
elif type(servings) == list:
|
||||
elif isinstance(servings, list):
|
||||
try:
|
||||
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
||||
except KeyError:
|
||||
@ -372,12 +374,12 @@ def parse_servings(servings):
|
||||
|
||||
|
||||
def parse_servings_text(servings):
|
||||
if type(servings) == str:
|
||||
if isinstance(servings, str):
|
||||
try:
|
||||
servings = re.sub("\d+", '', servings).strip()
|
||||
servings = re.sub("\\d+", '', servings).strip()
|
||||
except Exception:
|
||||
servings = ''
|
||||
if type(servings) == list:
|
||||
if isinstance(servings, list):
|
||||
try:
|
||||
servings = parse_servings_text(servings[1])
|
||||
except Exception:
|
||||
@ -394,7 +396,7 @@ def parse_time(recipe_time):
|
||||
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
|
||||
except ISO8601Error:
|
||||
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 = round(parse_duration(recipe_time).seconds / 60)
|
||||
except AttributeError:
|
||||
@ -403,18 +405,9 @@ def parse_time(recipe_time):
|
||||
return recipe_time
|
||||
|
||||
|
||||
def parse_keywords(keyword_json, space):
|
||||
def parse_keywords(keyword_json, request):
|
||||
keywords = []
|
||||
keyword_aliases = {}
|
||||
# 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)
|
||||
automation_engine = AutomationEngine(request)
|
||||
|
||||
# keywords as list
|
||||
for kw in keyword_json:
|
||||
@ -422,12 +415,8 @@ def parse_keywords(keyword_json, space):
|
||||
# if alias exists use that instead
|
||||
|
||||
if len(kw) != 0:
|
||||
if keyword_aliases:
|
||||
try:
|
||||
kw = keyword_aliases[kw]
|
||||
except KeyError:
|
||||
pass
|
||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||
kw = automation_engine.apply_keyword_automation(kw)
|
||||
if k := Keyword.objects.filter(name__iexact=kw, space=request.space).first():
|
||||
keywords.append({'label': str(k), 'name': k.name, 'id': k.id})
|
||||
else:
|
||||
keywords.append({'label': kw, 'name': kw})
|
||||
@ -438,15 +427,15 @@ def parse_keywords(keyword_json, space):
|
||||
def listify_keywords(keyword_list):
|
||||
# keywords as string
|
||||
try:
|
||||
if type(keyword_list[0]) == dict:
|
||||
if isinstance(keyword_list[0], dict):
|
||||
return keyword_list
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
if type(keyword_list) == str:
|
||||
if isinstance(keyword_list, str):
|
||||
keyword_list = keyword_list.split(',')
|
||||
|
||||
# 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(',')
|
||||
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):
|
||||
if type(input_dict) == dict:
|
||||
if isinstance(input_dict, dict):
|
||||
for x in list(input_dict):
|
||||
if x == key:
|
||||
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)
|
||||
elif type(input_dict[x]) == list:
|
||||
elif isinstance(input_dict[x], list):
|
||||
temp_list = []
|
||||
for e in input_dict[x]:
|
||||
temp_list.append(clean_dict(e, key))
|
||||
|
@ -1,8 +1,6 @@
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
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 cookbook.views import views
|
||||
@ -50,7 +48,6 @@ class ScopeMiddleware:
|
||||
return views.no_groups(request)
|
||||
|
||||
request.space = user_space.space
|
||||
# with scopes_disabled():
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
else:
|
||||
|
@ -1,16 +1,13 @@
|
||||
from datetime import timedelta
|
||||
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.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
|
||||
SupermarketCategoryRelation)
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def shopping_helper(qs, request):
|
||||
@ -47,7 +44,7 @@ class RecipeShoppingEditor():
|
||||
self.mealplan = self._kwargs.get('mealplan', None)
|
||||
if type(self.mealplan) in [int, float]:
|
||||
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.id = self._kwargs.get('id', None)
|
||||
|
||||
@ -69,11 +66,12 @@ class RecipeShoppingEditor():
|
||||
|
||||
@property
|
||||
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
|
||||
def _servings_factor(self):
|
||||
return Decimal(self.servings)/Decimal(self._recipe_servings)
|
||||
return Decimal(self.servings) / Decimal(self._recipe_servings)
|
||||
|
||||
@property
|
||||
def _shared_users(self):
|
||||
@ -90,9 +88,10 @@ class RecipeShoppingEditor():
|
||||
|
||||
def get_recipe_ingredients(self, id, exclude_onhand=False):
|
||||
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:
|
||||
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
|
||||
def _include_related(self):
|
||||
@ -109,7 +108,7 @@ class RecipeShoppingEditor():
|
||||
self.servings = float(servings)
|
||||
|
||||
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()
|
||||
else:
|
||||
self.mealplan = mealplan
|
||||
@ -170,14 +169,14 @@ class RecipeShoppingEditor():
|
||||
try:
|
||||
self._shopping_list_recipe.delete()
|
||||
return True
|
||||
except:
|
||||
except BaseException:
|
||||
return False
|
||||
|
||||
def _add_ingredients(self, ingredients=None):
|
||||
if not ingredients:
|
||||
return
|
||||
elif type(ingredients) == list:
|
||||
ingredients = Ingredient.objects.filter(id__in=ingredients)
|
||||
elif isinstance(ingredients, list):
|
||||
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)
|
||||
add_ingredients = ingredients.exclude(id__in=existing)
|
||||
|
||||
@ -199,120 +198,3 @@ class RecipeShoppingEditor():
|
||||
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
|
||||
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
|
||||
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
|
@ -2,7 +2,6 @@ from gettext import gettext as _
|
||||
|
||||
import bleach
|
||||
import markdown as md
|
||||
from bleach_allowlist import markdown_attrs, markdown_tags
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from markdown.extensions.tables import TableExtension
|
||||
|
||||
@ -53,9 +52,17 @@ class IngredientObject(object):
|
||||
def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
instructions = step.instruction
|
||||
|
||||
tags = markdown_tags + [
|
||||
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead', 'img'
|
||||
]
|
||||
tags = {
|
||||
"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(
|
||||
instructions,
|
||||
extensions=[
|
||||
@ -63,7 +70,11 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
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)
|
||||
|
||||
|
@ -36,7 +36,7 @@ class ChefTap(Integration):
|
||||
|
||||
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 != '':
|
||||
step.instruction += '\n' + source_url
|
||||
|
@ -4,6 +4,7 @@ from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
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.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
@ -19,6 +20,10 @@ class Chowdown(Integration):
|
||||
direction_mode = False
|
||||
description_mode = False
|
||||
|
||||
description = None
|
||||
prep_time = None
|
||||
serving = None
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
descriptions = []
|
||||
@ -26,6 +31,12 @@ class Chowdown(Integration):
|
||||
line = fl.decode("utf-8")
|
||||
if 'title:' in line:
|
||||
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:
|
||||
image = line.replace('image:', '').strip()
|
||||
if 'tags:' in line:
|
||||
@ -48,15 +59,43 @@ class Chowdown(Integration):
|
||||
descriptions.append(line)
|
||||
|
||||
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(','):
|
||||
print(f'adding keyword {k.strip()}')
|
||||
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions), space=self.request.space,
|
||||
)
|
||||
ingredients_added = False
|
||||
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)
|
||||
for ingredient in ingredients:
|
||||
@ -76,6 +115,7 @@ class Chowdown(Integration):
|
||||
if re.match(f'^images/{image}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
|
@ -1,20 +1,15 @@
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
from gettext import gettext as _
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
import yaml
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
|
||||
iso_duration_to_minutes)
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
|
||||
class CookBookApp(Integration):
|
||||
@ -25,7 +20,6 @@ class CookBookApp(Integration):
|
||||
def get_recipe_from_file(self, file):
|
||||
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)
|
||||
recipe_json = get_from_scraper(scrape, self.request)
|
||||
images = list(dict.fromkeys(get_images_from_soup(scrape.soup, None)))
|
||||
@ -37,7 +31,7 @@ class CookBookApp(Integration):
|
||||
|
||||
try:
|
||||
recipe.servings = re.findall('([0-9])+', recipe_json['recipeYield'])[0]
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
@ -47,7 +41,8 @@ class CookBookApp(Integration):
|
||||
pass
|
||||
|
||||
# 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:
|
||||
step.instruction = step.instruction + '\n\n' + recipe_json['nutrition']
|
||||
@ -62,7 +57,7 @@ class CookBookApp(Integration):
|
||||
if unit := ingredient.get('unit', None):
|
||||
u = ingredient_parser.get_unit(unit.get('name', None))
|
||||
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:
|
||||
|
@ -1,17 +1,12 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from gettext import gettext as _
|
||||
|
||||
import requests
|
||||
import validators
|
||||
from lxml import etree
|
||||
|
||||
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.models import Ingredient, Keyword, Recipe, Step
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
|
||||
class Cookmate(Integration):
|
||||
@ -50,7 +45,7 @@ class Cookmate(Integration):
|
||||
for step in recipe_text.getchildren():
|
||||
if step.text:
|
||||
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)
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
@ -26,12 +25,13 @@ class CopyMeThat(Integration):
|
||||
except AttributeError:
|
||||
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"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
|
||||
try:
|
||||
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())
|
||||
@ -51,7 +51,7 @@ class CopyMeThat(Integration):
|
||||
except AttributeError:
|
||||
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)
|
||||
|
||||
@ -61,7 +61,14 @@ class CopyMeThat(Integration):
|
||||
if not isinstance(ingredient, Tag) or not ingredient.text.strip() or "recipeIngredient_spacer" in ingredient['class']:
|
||||
continue
|
||||
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:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
@ -78,7 +85,7 @@ class CopyMeThat(Integration):
|
||||
step.save()
|
||||
recipe.steps.add(step)
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
|
||||
|
||||
step.name = instruction.text.strip()[:128]
|
||||
else:
|
||||
step.instruction += instruction.text.strip() + ' \n\n'
|
||||
|
@ -1,4 +1,5 @@
|
||||
import json
|
||||
import traceback
|
||||
from io import BytesIO, StringIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
@ -19,7 +20,10 @@ class Default(Integration):
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist()))
|
||||
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
|
||||
|
||||
def decode_recipe(self, string):
|
||||
@ -54,7 +58,7 @@ class Default(Integration):
|
||||
|
||||
try:
|
||||
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
|
||||
except ValueError:
|
||||
except (ValueError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
@ -67,4 +71,4 @@ class Default(Integration):
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]
|
||||
return [[self.get_export_file_name(), export_zip_stream.getvalue()]]
|
||||
|
@ -28,7 +28,7 @@ class Domestica(Integration):
|
||||
recipe.save()
|
||||
|
||||
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'] != '':
|
||||
|
@ -1,4 +1,3 @@
|
||||
import traceback
|
||||
import datetime
|
||||
import traceback
|
||||
import uuid
|
||||
@ -18,8 +17,7 @@ from lxml import etree
|
||||
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.models import Keyword, Recipe
|
||||
from recipes.settings import DEBUG
|
||||
from recipes.settings import EXPORT_FILE_CACHE_DURATION
|
||||
from recipes.settings import DEBUG, EXPORT_FILE_CACHE_DURATION
|
||||
|
||||
|
||||
class Integration:
|
||||
@ -39,7 +37,6 @@ class Integration:
|
||||
self.ignored_recipes = []
|
||||
|
||||
description = f'Imported by {request.user.get_user_display_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}'
|
||||
icon = '📥'
|
||||
|
||||
try:
|
||||
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(
|
||||
name=name,
|
||||
description=description,
|
||||
icon=icon,
|
||||
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.
|
||||
self.keyword = parent.add_child(
|
||||
name=f'{name} {str(uuid.uuid4())[0:8]}',
|
||||
description=description,
|
||||
icon=icon,
|
||||
space=request.space
|
||||
)
|
||||
|
||||
|
||||
|
||||
def do_export(self, recipes, el):
|
||||
|
||||
with scope(space=self.request.space):
|
||||
el.total_recipes = len(recipes)
|
||||
el.total_recipes = len(recipes)
|
||||
el.cache_duration = EXPORT_FILE_CACHE_DURATION
|
||||
el.save()
|
||||
|
||||
@ -80,7 +73,7 @@ class Integration:
|
||||
export_file = file
|
||||
|
||||
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_stream = BytesIO()
|
||||
export_obj = ZipFile(export_stream, 'w')
|
||||
@ -91,8 +84,7 @@ class Integration:
|
||||
export_obj.close()
|
||||
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.save()
|
||||
|
||||
@ -100,7 +92,6 @@ class Integration:
|
||||
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
|
||||
return response
|
||||
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
"""
|
||||
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:
|
||||
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)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
@ -298,7 +289,6 @@ class Integration:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def get_export_file_name(self, format='zip'):
|
||||
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)
|
||||
|
||||
|
@ -7,7 +7,7 @@ from cookbook.helper.image_processing import get_filetype
|
||||
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.models import Ingredient, Recipe, Step
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
|
||||
class Mealie(Integration):
|
||||
@ -25,7 +25,7 @@ class Mealie(Integration):
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
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)
|
||||
|
||||
step = recipe.steps.first()
|
||||
@ -56,6 +56,12 @@ class Mealie(Integration):
|
||||
except Exception:
|
||||
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:
|
||||
notes_text = "#### Notes \n\n"
|
||||
for n in recipe_json['notes']:
|
||||
|
@ -39,7 +39,7 @@ class MealMaster(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
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)
|
||||
|
@ -57,7 +57,7 @@ class MelaRecipes(Integration):
|
||||
recipe.source_url = recipe_json['link']
|
||||
|
||||
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)
|
||||
|
@ -2,13 +2,14 @@ import json
|
||||
import re
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import iso_duration_to_minutes
|
||||
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):
|
||||
@ -51,14 +52,13 @@ class NextcloudCookbook(Integration):
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
instruction_text = ''
|
||||
if 'text' in s:
|
||||
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:
|
||||
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 len(recipe_json['description'].strip()) > 500:
|
||||
@ -91,7 +91,7 @@ class NextcloudCookbook(Integration):
|
||||
if nutrition != {}:
|
||||
recipe.nutrition = NutritionInformation.objects.create(**nutrition, space=self.request.space)
|
||||
recipe.save()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for f in self.files:
|
||||
|
@ -1,9 +1,11 @@
|
||||
import json
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step, Keyword, Comment, CookLog
|
||||
from django.utils.translation import gettext as _
|
||||
from cookbook.models import Comment, CookLog, Ingredient, Keyword, Recipe, Step
|
||||
|
||||
|
||||
class OpenEats(Integration):
|
||||
|
||||
@ -25,16 +27,16 @@ class OpenEats(Integration):
|
||||
if 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"] != '':
|
||||
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:
|
||||
keyword.move(cuisine_keyword, pos="last-child")
|
||||
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"] != '':
|
||||
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:
|
||||
keyword.move(course_keyword, pos="last-child")
|
||||
recipe.keywords.add(keyword)
|
||||
@ -51,7 +53,7 @@ class OpenEats(Integration):
|
||||
recipe.image = f'recipes/openeats-import/{file["photo"]}'
|
||||
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)
|
||||
for ingredient in file['ingredients']:
|
||||
|
@ -58,7 +58,7 @@ class Paprika(Integration):
|
||||
pass
|
||||
|
||||
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:
|
||||
@ -90,7 +90,7 @@ class Paprika(Integration):
|
||||
if validators.url(url, public=True):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except:
|
||||
except Exception:
|
||||
if recipe_json.get("photo_data", None):
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
|
||||
|
||||
|
@ -1,21 +1,11 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
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 logging
|
||||
from asgiref.sync import sync_to_async
|
||||
from pyppeteer import launch
|
||||
|
||||
from cookbook.integration.integration import Integration
|
||||
|
||||
|
||||
class PDFexport(Integration):
|
||||
|
||||
@ -42,7 +32,6 @@ class PDFexport(Integration):
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
files = []
|
||||
for recipe in recipes:
|
||||
|
||||
@ -50,20 +39,18 @@ class PDFexport(Integration):
|
||||
await page.emulateMedia('print')
|
||||
await page.setCookie(cookies)
|
||||
|
||||
await page.goto('http://'+cmd.default_addr+':'+cmd.default_port+'/view/recipe/'+str(recipe.id), {'waitUntil': 'domcontentloaded'})
|
||||
await page.waitForSelector('#printReady');
|
||||
await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'domcontentloaded'})
|
||||
await page.waitForSelector('#printReady')
|
||||
|
||||
files.append([recipe.name + '.pdf', await page.pdf(options)])
|
||||
await page.close();
|
||||
await page.close()
|
||||
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(recipe)
|
||||
await sync_to_async(el.save, thread_sensitive=True)()
|
||||
|
||||
|
||||
await browser.close()
|
||||
return files
|
||||
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie))
|
||||
|
@ -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)
|
||||
|
||||
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)
|
||||
|
@ -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)
|
||||
|
||||
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:
|
||||
|
@ -46,7 +46,7 @@ class RecetteTek(Integration):
|
||||
if not 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)
|
||||
try:
|
||||
|
@ -41,7 +41,7 @@ class RecipeKeeper(Integration):
|
||||
except AttributeError:
|
||||
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)
|
||||
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):
|
||||
|
@ -39,7 +39,7 @@ class RecipeSage(Integration):
|
||||
ingredients_added = False
|
||||
for s in file['recipeInstructions']:
|
||||
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:
|
||||
ingredients_added = True
|
||||
|
@ -2,12 +2,10 @@ import base64
|
||||
from io import BytesIO
|
||||
from xml import etree
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_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.models import Ingredient, Recipe, Step, Keyword
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
|
||||
class Rezeptsuitede(Integration):
|
||||
@ -37,7 +35,7 @@ class Rezeptsuitede(Integration):
|
||||
try:
|
||||
if prep.find('step').text:
|
||||
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)
|
||||
except Exception:
|
||||
@ -61,14 +59,14 @@ class Rezeptsuitede(Integration):
|
||||
try:
|
||||
k, created = Keyword.objects.get_or_create(name=recipe_xml.find('head').find('cat').text.strip(), space=self.request.space)
|
||||
recipe.keywords.add(k)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recipe.save()
|
||||
|
||||
try:
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_xml.find('head').find('picbin').text)), filetype='.jpeg')
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
return recipe
|
||||
|
@ -38,7 +38,7 @@ class RezKonv(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
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)
|
||||
@ -60,8 +60,8 @@ class RezKonv(Integration):
|
||||
def split_recipe_file(self, file):
|
||||
recipe_list = []
|
||||
current_recipe = ''
|
||||
encoding_list = ['windows-1250',
|
||||
'latin-1'] # TODO build algorithm to try trough encodings and fail if none work, use for all importers
|
||||
# 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'
|
||||
for fl in file.readlines():
|
||||
try:
|
||||
|
@ -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, )
|
||||
|
||||
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)
|
||||
for ingredient in ingredients:
|
||||
@ -59,11 +59,11 @@ class Saffron(Integration):
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
|
||||
data = "Title: "+recipe.name if recipe.name else ""+"\n"
|
||||
data += "Description: "+recipe.description if recipe.description else ""+"\n"
|
||||
data = "Title: " + recipe.name if recipe.name else "" + "\n"
|
||||
data += "Description: " + recipe.description if recipe.description else "" + "\n"
|
||||
data += "Source: \n"
|
||||
data += "Original URL: \n"
|
||||
data += "Yield: "+str(recipe.servings)+"\n"
|
||||
data += "Yield: " + str(recipe.servings) + "\n"
|
||||
data += "Cookbook: \n"
|
||||
data += "Section: \n"
|
||||
data += "Image: \n"
|
||||
@ -78,13 +78,13 @@ class Saffron(Integration):
|
||||
|
||||
data += "Ingredients: \n"
|
||||
for ingredient in recipeIngredient:
|
||||
data += ingredient+"\n"
|
||||
data += ingredient + "\n"
|
||||
|
||||
data += "Instructions: \n"
|
||||
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):
|
||||
files = []
|
||||
|
Binary file not shown.
@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-04-29 18:42+0200\n"
|
||||
"PO-Revision-Date: 2022-07-06 14:32+0000\n"
|
||||
"Last-Translator: Nidhal Brniyah <n1a1b1@gmail.com>\n"
|
||||
"PO-Revision-Date: 2023-11-28 11:03+0000\n"
|
||||
"Last-Translator: Mahmoud Aljouhari <mapgohary@gmail.com>\n"
|
||||
"Language-Team: Arabic <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/ar/>\n"
|
||||
"Language: ar\n"
|
||||
@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"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"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
|
||||
#: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28
|
||||
@ -2578,7 +2578,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\views.py:262
|
||||
msgid "This feature is not available in the demo version!"
|
||||
msgstr ""
|
||||
msgstr "هذه الميزة غير موجودة في النسخة التجريبية!"
|
||||
|
||||
#: .\cookbook\views\views.py:322
|
||||
msgid "You must select at least one field to search!"
|
||||
|
Binary file not shown.
@ -13,8 +13,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"PO-Revision-Date: 2023-07-06 21:19+0000\n"
|
||||
"Last-Translator: Rubens <rubenixnagios@gmail.com>\n"
|
||||
"Language-Team: Catalan <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/ca/>\n"
|
||||
"Language: ca\n"
|
||||
@ -421,7 +421,7 @@ msgstr "Compartir Llista de la Compra"
|
||||
|
||||
#: .\cookbook\forms.py:525
|
||||
msgid "Autosync"
|
||||
msgstr "Autosync"
|
||||
msgstr "Autosinc"
|
||||
|
||||
#: .\cookbook\forms.py:526
|
||||
msgid "Auto Add Meal Plan"
|
||||
@ -477,7 +477,7 @@ msgstr "Mostra el recompte de receptes als filtres de cerca"
|
||||
|
||||
#: .\cookbook\forms.py:559
|
||||
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
|
||||
msgid ""
|
||||
|
Binary file not shown.
@ -11,8 +11,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
|
||||
"PO-Revision-Date: 2023-03-25 11:32+0000\n"
|
||||
"Last-Translator: Matěj Kubla <matykubla@gmail.com>\n"
|
||||
"PO-Revision-Date: 2023-07-31 14:19+0000\n"
|
||||
"Last-Translator: Mára Štěpánek <stepanekm7@gmail.com>\n"
|
||||
"Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/cs/>\n"
|
||||
"Language: cs\n"
|
||||
@ -36,7 +36,7 @@ msgid ""
|
||||
"try them out!"
|
||||
msgstr ""
|
||||
"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
|
||||
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
|
||||
@ -50,7 +50,7 @@ msgid ""
|
||||
"to fractions automatically)"
|
||||
msgstr ""
|
||||
"Povolit podporu zlomků u množství ingrediencí (desetinná čísla budou "
|
||||
"automaticky převedena na zlomky)."
|
||||
"automaticky převedena na zlomky)"
|
||||
|
||||
#: .\cookbook\forms.py:47
|
||||
msgid ""
|
||||
|
Binary file not shown.
@ -15,8 +15,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-06-21 14:19+0000\n"
|
||||
"Last-Translator: Tobias Huppertz <tobias.huppertz@mail.de>\n"
|
||||
"PO-Revision-Date: 2023-11-22 18:19+0000\n"
|
||||
"Last-Translator: Spreez <tandoor@larsdev.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/de/>\n"
|
||||
"Language: de\n"
|
||||
@ -161,7 +161,7 @@ msgstr "Name"
|
||||
|
||||
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
|
||||
msgid "Keywords"
|
||||
msgstr "Stichwörter"
|
||||
msgstr "Schlüsselwörter"
|
||||
|
||||
#: .\cookbook\forms.py:125
|
||||
msgid "Preparation time in minutes"
|
||||
@ -1436,11 +1436,11 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\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"
|
||||
" Dies ist notwendig da Passwort oder Token benötigt werden, um API-"
|
||||
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/"
|
||||
">\n"
|
||||
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/>"
|
||||
"\n"
|
||||
" Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder "
|
||||
"Accounts mit limitiertem Zugriff verwendet werden.\n"
|
||||
" "
|
||||
@ -2600,7 +2600,7 @@ msgstr "Ungültiges URL Schema."
|
||||
|
||||
#: .\cookbook\views\api.py:1233
|
||||
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
|
||||
msgid "Importing is not implemented for this provider"
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -14,8 +14,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-05-26 16:19+0000\n"
|
||||
"Last-Translator: Luis Cacho <luiscachog@gmail.com>\n"
|
||||
"PO-Revision-Date: 2023-09-25 09:59+0000\n"
|
||||
"Last-Translator: Matias Laporte <laportematias+weblate@gmail.com>\n"
|
||||
"Language-Team: Spanish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/es/>\n"
|
||||
"Language: es\n"
|
||||
@ -543,19 +543,19 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "amasar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "espesar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "precalentar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermentar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
@ -573,11 +573,11 @@ msgstr ""
|
||||
#: .\cookbook\integration\copymethat.py:44
|
||||
#: .\cookbook\integration\melarecipes.py:37
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
msgstr "Favorito"
|
||||
|
||||
#: .\cookbook\integration\copymethat.py:50
|
||||
msgid "I made this"
|
||||
msgstr ""
|
||||
msgstr "Lo he preparado"
|
||||
|
||||
#: .\cookbook\integration\integration.py:218
|
||||
msgid ""
|
||||
@ -604,7 +604,7 @@ msgstr "Se importaron %s recetas."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
msgid "Recipe source:"
|
||||
msgstr "Recipe source:"
|
||||
msgstr "Fuente de la receta:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@ -645,19 +645,21 @@ msgstr "Sección"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:14
|
||||
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
|
||||
msgid "Only Postgresql databases use full text search, no index to rebuild"
|
||||
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
|
||||
msgid "Recipe index rebuild complete."
|
||||
msgstr ""
|
||||
msgstr "Se reconstruyó el índice de la receta."
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:31
|
||||
msgid "Recipe index rebuild failed."
|
||||
msgstr ""
|
||||
msgstr "No fue posible reconstruir el índice de la receta."
|
||||
|
||||
#: .\cookbook\migrations\0047_auto_20200602_1133.py:14
|
||||
msgid "Breakfast"
|
||||
@ -699,23 +701,23 @@ msgstr "Libros"
|
||||
|
||||
#: .\cookbook\models.py:580
|
||||
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
|
||||
msgid "Simple"
|
||||
msgstr ""
|
||||
msgstr "Simple"
|
||||
|
||||
#: .\cookbook\models.py:1182 .\cookbook\templates\search_info.html:33
|
||||
msgid "Phrase"
|
||||
msgstr ""
|
||||
msgstr "Frase"
|
||||
|
||||
#: .\cookbook\models.py:1183 .\cookbook\templates\search_info.html:38
|
||||
msgid "Web"
|
||||
msgstr ""
|
||||
msgstr "Web"
|
||||
|
||||
#: .\cookbook\models.py:1184 .\cookbook\templates\search_info.html:47
|
||||
msgid "Raw"
|
||||
msgstr ""
|
||||
msgstr "Crudo"
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
msgid "Food Alias"
|
||||
@ -762,49 +764,53 @@ msgstr "Palabra clave"
|
||||
|
||||
#: .\cookbook\serializer.py:198
|
||||
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
|
||||
msgid "You have reached your file upload limit."
|
||||
msgstr ""
|
||||
msgstr "Has alcanzado el límite de cargas de archivo."
|
||||
|
||||
#: .\cookbook\serializer.py:291
|
||||
msgid "Cannot modify Space owner permission."
|
||||
msgstr ""
|
||||
msgstr "No puedes modificar los permisos del propietario de la Instancia."
|
||||
|
||||
#: .\cookbook\serializer.py:1093
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
msgstr "Hola"
|
||||
|
||||
#: .\cookbook\serializer.py:1093
|
||||
msgid "You have been invited by "
|
||||
msgstr ""
|
||||
msgstr "Has sido invitado por: "
|
||||
|
||||
#: .\cookbook\serializer.py:1094
|
||||
msgid " to join their Tandoor Recipes space "
|
||||
msgstr ""
|
||||
msgstr " para unirte a su instancia de Tandoor Recipes "
|
||||
|
||||
#: .\cookbook\serializer.py:1095
|
||||
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
|
||||
msgid ""
|
||||
"If the link does not work use the following code to manually join the space: "
|
||||
msgstr ""
|
||||
"Si el enlace no funciona, utiliza el siguiente código para unirte "
|
||||
"manualmente a la instancia: "
|
||||
|
||||
#: .\cookbook\serializer.py:1097
|
||||
msgid "The invitation is valid until "
|
||||
msgstr ""
|
||||
msgstr "La invitación es válida hasta "
|
||||
|
||||
#: .\cookbook\serializer.py:1098
|
||||
msgid ""
|
||||
"Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub "
|
||||
msgstr ""
|
||||
"Tandoor Recipes es un administrador de recetas Open Source. Dale una ojeada "
|
||||
"en GitHub "
|
||||
|
||||
#: .\cookbook\serializer.py:1101
|
||||
msgid "Tandoor Recipes Invite"
|
||||
msgstr ""
|
||||
msgstr "Invitación para Tandoor Recipes"
|
||||
|
||||
#: .\cookbook\serializer.py:1242
|
||||
msgid "Existing shopping list to update"
|
||||
|
Binary file not shown.
@ -14,10 +14,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/fr/>\n"
|
||||
"PO-Revision-Date: 2023-12-10 14:19+0000\n"
|
||||
"Last-Translator: Robin Wilmet <wilmetrobin@hotmail.com>\n"
|
||||
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/fr/>\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@ -310,7 +310,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"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 "
|
||||
"langue."
|
||||
"langue"
|
||||
|
||||
#: .\cookbook\forms.py:466
|
||||
msgid ""
|
||||
@ -326,8 +326,8 @@ msgid ""
|
||||
"will return 'salad' and 'sandwich')"
|
||||
msgstr ""
|
||||
"Champs permettant de rechercher les correspondances de début de mot (par "
|
||||
"exemple, si vous recherchez « sa », vous obtiendrez « salade » et "
|
||||
"« sandwich»)."
|
||||
"exemple, si vous recherchez « sa », vous obtiendrez « salade » et « "
|
||||
"sandwich»)"
|
||||
|
||||
#: .\cookbook\forms.py:470
|
||||
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"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Utiliser les fractions"
|
||||
msgstr "sens inverse"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
msgstr ""
|
||||
msgstr "sens horloger"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "pétrir"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "épaissir"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "réchauffer"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermenter"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sous-vide"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
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:81
|
||||
@ -590,7 +588,6 @@ msgid "Favorite"
|
||||
msgstr "Favori"
|
||||
|
||||
#: .\cookbook\integration\copymethat.py:50
|
||||
#, fuzzy
|
||||
msgid "I made this"
|
||||
msgstr "J'ai fait ça"
|
||||
|
||||
@ -620,10 +617,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s recettes importées."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Page d’accueil"
|
||||
msgstr "Source de la recette :"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@ -648,7 +643,7 @@ msgstr "Portions"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:25
|
||||
msgid "Waiting time"
|
||||
msgstr "temps d’attente"
|
||||
msgstr "Temps d’attente"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:27
|
||||
msgid "Preparation Time"
|
||||
@ -851,7 +846,6 @@ msgid "ID of unit to use for the shopping list"
|
||||
msgstr "ID de l’unité à utiliser pour la liste de courses"
|
||||
|
||||
#: .\cookbook\serializer.py:1259
|
||||
#, fuzzy
|
||||
msgid "When set to true will delete all food from active shopping lists."
|
||||
msgstr ""
|
||||
"Lorsqu'il est défini sur \"true\", tous les aliments des listes de courses "
|
||||
@ -967,8 +961,9 @@ msgid ""
|
||||
" ."
|
||||
msgstr ""
|
||||
"Confirmez SVP que\n"
|
||||
" <a href=\"mailto:%(email)s\"></a> est une adresse mail de "
|
||||
"l’utilisateur %(user_display)s."
|
||||
" <a href=\"mailto:%(email)s\"></a> est une adresse mail de l’"
|
||||
"utilisateur %(user_display)s\n"
|
||||
" ."
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:22
|
||||
#: .\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> "
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:22
|
||||
#, fuzzy
|
||||
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
|
||||
msgid "Protected"
|
||||
@ -1456,12 +1450,12 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Les champs <b>Mot de passe et Token</b> sont stockés <b>en texte "
|
||||
"brut</b>dans la base de données.\n"
|
||||
" Les champs <b>Mot de passe et Token</b> sont stockés <b>en clair</"
|
||||
"b>dans la base de données.\n"
|
||||
" 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"
|
||||
" Pour limiter les risques, des tokens ou comptes avec un accès limité "
|
||||
"devraient être utilisés.\n"
|
||||
" Pour limiter les risques, il est possible d'utiliser des tokens ou "
|
||||
"des comptes avec un accès limité.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\index.html:29
|
||||
@ -1771,15 +1765,6 @@ msgstr ""
|
||||
" "
|
||||
|
||||
#: .\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 ""
|
||||
" \n"
|
||||
" Simple searches ignore punctuation and common words such as "
|
||||
@ -1791,7 +1776,7 @@ msgid ""
|
||||
msgstr ""
|
||||
" \n"
|
||||
" 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"
|
||||
" Si vous recherchez \"pomme ou farine\", vous obtiendrez toutes "
|
||||
"les recettes qui contiennent à la fois \"pomme\" et \"farine\" dans les "
|
||||
@ -2219,7 +2204,7 @@ msgstr "Gérer l’abonnement"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216
|
||||
msgid "Space"
|
||||
msgstr "Groupe :"
|
||||
msgstr "Groupe"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:17
|
||||
msgid ""
|
||||
@ -2659,7 +2644,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\api.py:1394
|
||||
msgid "Sync successful!"
|
||||
msgstr "Synchro réussie !"
|
||||
msgstr "Synchronisation réussie !"
|
||||
|
||||
#: .\cookbook\views\api.py:1399
|
||||
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 "
|
||||
"experimental state."
|
||||
msgstr ""
|
||||
"L'export PDF n'est pas activé sur cette instance car il est toujours au "
|
||||
"statut expérimental."
|
||||
|
||||
#: .\cookbook\views\lists.py:24
|
||||
msgid "Import Log"
|
||||
|
BIN
cookbook/locale/he/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/he/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2529
cookbook/locale/he/LC_MESSAGES/django.po
Normal file
2529
cookbook/locale/he/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -11,8 +11,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"PO-Revision-Date: 2023-12-05 09:15+0000\n"
|
||||
"Last-Translator: Ferenc <ugyes@freemail.hu>\n"
|
||||
"Language-Team: Hungarian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/hu/>\n"
|
||||
"Language: hu_HU\n"
|
||||
@ -99,7 +99,7 @@ msgstr ""
|
||||
#: .\cookbook\forms.py:74
|
||||
msgid "Users with whom newly created meal plans should be shared by default."
|
||||
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."
|
||||
|
||||
#: .\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
|
||||
msgid "Automatically add meal plan ingredients to shopping list."
|
||||
msgstr ""
|
||||
"Automatikusan hozzáadja az étkezési terv hozzávalóit a bevásárlólistához."
|
||||
msgstr "Automatikusan hozzáadja a menüterv hozzávalóit a bevásárlólistához."
|
||||
|
||||
#: .\cookbook\forms.py:84
|
||||
msgid "Exclude ingredients that are on hand."
|
||||
@ -283,16 +282,12 @@ msgstr ""
|
||||
"hibát figyelmen kívül hagynak)."
|
||||
|
||||
#: .\cookbook\forms.py:461
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Select type method of search. Click <a href=\"/docs/search/\">here</a> "
|
||||
#| "for full desciption of choices."
|
||||
msgid ""
|
||||
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
|
||||
"full description of choices."
|
||||
msgstr ""
|
||||
"Válassza ki a keresés típusát. Kattintson <a href=\"/docs/search/\">ide</a> "
|
||||
"a lehetőségek teljes leírásáért."
|
||||
"Válassza ki a keresés típusát. Kattintson <a href=\"/docs/search/\">ide</"
|
||||
"a> a lehetőségek teljes leírásáért."
|
||||
|
||||
#: .\cookbook\forms.py:462
|
||||
msgid ""
|
||||
@ -360,10 +355,8 @@ msgid "Partial Match"
|
||||
msgstr "Részleges találat"
|
||||
|
||||
#: .\cookbook\forms.py:480
|
||||
#, fuzzy
|
||||
#| msgid "Starts Wtih"
|
||||
msgid "Starts With"
|
||||
msgstr "Kezdődik a következővel"
|
||||
msgstr "Ezzel kezdődik"
|
||||
|
||||
#: .\cookbook\forms.py:481
|
||||
msgid "Fuzzy Search"
|
||||
@ -387,16 +380,16 @@ msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"include all related recipes."
|
||||
msgstr ""
|
||||
"Amikor étkezési tervet ad hozzá a bevásárlólistához (kézzel vagy "
|
||||
"automatikusan), vegye fel az összes kapcsolódó receptet."
|
||||
"Amikor menütervet ad hozzá a bevásárlólistához (kézzel vagy automatikusan), "
|
||||
"vegye fel az összes kapcsolódó receptet."
|
||||
|
||||
#: .\cookbook\forms.py:514
|
||||
msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"exclude ingredients that are on hand."
|
||||
msgstr ""
|
||||
"Amikor étkezési tervet ad hozzá a bevásárlólistához (kézzel vagy "
|
||||
"automatikusan), zárja ki a kéznél lévő összetevőket."
|
||||
"Amikor menütervet ad hozzá a bevásárlólistához (kézzel vagy automatikusan), "
|
||||
"zárja ki a kéznél lévő összetevőket."
|
||||
|
||||
#: .\cookbook\forms.py:515
|
||||
msgid "Default number of hours to delay a shopping list entry."
|
||||
@ -436,7 +429,7 @@ msgstr "Automatikus szinkronizálás"
|
||||
|
||||
#: .\cookbook\forms.py:526
|
||||
msgid "Auto Add Meal Plan"
|
||||
msgstr "Automatikus étkezési terv hozzáadása"
|
||||
msgstr "Menüterv automatikus hozzáadása"
|
||||
|
||||
#: .\cookbook\forms.py:527
|
||||
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
|
||||
msgid "Use the plural form for units and food inside this space."
|
||||
msgstr ""
|
||||
"Használja a többes számot az egységek és az ételek esetében ezen a helyen."
|
||||
|
||||
#: .\cookbook\helper\AllAuthCustomAdapter.py:39
|
||||
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"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Törtek használata"
|
||||
msgstr "Ellentétes irány"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
@ -549,29 +541,27 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "dagasztás"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "sűrítés"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "melegítés"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermentálás"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sous-vide"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
#, fuzzy
|
||||
#| msgid "You must supply a created_by"
|
||||
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:81
|
||||
@ -581,11 +571,11 @@ msgstr "Nem sikerült elemezni a sablon kódját."
|
||||
#: .\cookbook\integration\copymethat.py:44
|
||||
#: .\cookbook\integration\melarecipes.py:37
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
msgstr "Kedvenc"
|
||||
|
||||
#: .\cookbook\integration\copymethat.py:50
|
||||
msgid "I made this"
|
||||
msgstr ""
|
||||
msgstr "Elkészítettem"
|
||||
|
||||
#: .\cookbook\integration\integration.py:218
|
||||
msgid ""
|
||||
@ -613,10 +603,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "Importálva %s recept."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Recipe Home"
|
||||
msgstr "Recept forrása:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@ -632,10 +620,8 @@ msgstr "Forrás"
|
||||
|
||||
#: .\cookbook\integration\recettetek.py:54
|
||||
#: .\cookbook\integration\recipekeeper.py:70
|
||||
#, fuzzy
|
||||
#| msgid "Import Log"
|
||||
msgid "Imported from"
|
||||
msgstr "Import napló"
|
||||
msgstr "Importálva a"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:23
|
||||
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"
|
||||
|
||||
#: .\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"
|
||||
msgstr ""
|
||||
"Csak a Postgress adatbázisok használnak teljes szöveges keresést, nincs "
|
||||
"újjáépítendő index"
|
||||
"Csak a Postgresql adatbázisok használják a teljes szöveges keresést, nem "
|
||||
"kell indexet újjáépíteni"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:29
|
||||
msgid "Recipe index rebuild complete."
|
||||
@ -711,7 +695,7 @@ msgstr "Keresés"
|
||||
#: .\cookbook\templates\meal_plan.html:7 .\cookbook\views\delete.py:178
|
||||
#: .\cookbook\views\edit.py:211 .\cookbook\views\new.py:179
|
||||
msgid "Meal-Plan"
|
||||
msgstr "Étkezési terv"
|
||||
msgstr "Menüterv"
|
||||
|
||||
#: .\cookbook\models.py:367 .\cookbook\templates\base.html:118
|
||||
msgid "Books"
|
||||
@ -750,16 +734,12 @@ msgid "Keyword Alias"
|
||||
msgstr "Kulcsszó álneve"
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
#, fuzzy
|
||||
#| msgid "Description"
|
||||
msgid "Description Replace"
|
||||
msgstr "Leírás"
|
||||
msgstr "Leírás csere"
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
#, fuzzy
|
||||
#| msgid "Instructions"
|
||||
msgid "Instruction Replace"
|
||||
msgstr "Elkészítés"
|
||||
msgstr "Leírás cseréje"
|
||||
|
||||
#: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36
|
||||
#: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48
|
||||
@ -767,10 +747,8 @@ msgid "Recipe"
|
||||
msgstr "Recept"
|
||||
|
||||
#: .\cookbook\models.py:1259
|
||||
#, fuzzy
|
||||
#| msgid "Foods"
|
||||
msgid "Food"
|
||||
msgstr "Ételek"
|
||||
msgstr "Étel"
|
||||
|
||||
#: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141
|
||||
msgid "Keyword"
|
||||
@ -786,7 +764,7 @@ msgstr "Elérte a fájlfeltöltési limitet."
|
||||
|
||||
#: .\cookbook\serializer.py:291
|
||||
msgid "Cannot modify Space owner permission."
|
||||
msgstr ""
|
||||
msgstr "A Hely tulajdonosi engedélye nem módosítható."
|
||||
|
||||
#: .\cookbook\serializer.py:1093
|
||||
msgid "Hello"
|
||||
@ -1176,7 +1154,7 @@ msgstr "Ételek"
|
||||
|
||||
#: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122
|
||||
msgid "Units"
|
||||
msgstr "Egységek"
|
||||
msgstr "Mértékegységek"
|
||||
|
||||
#: .\cookbook\templates\base.html:179 .\cookbook\templates\supermarket.html:7
|
||||
msgid "Supermarket"
|
||||
@ -1206,10 +1184,8 @@ msgstr "Előzmények"
|
||||
#: .\cookbook\templates\base.html:255
|
||||
#: .\cookbook\templates\ingredient_editor.html:7
|
||||
#: .\cookbook\templates\ingredient_editor.html:13
|
||||
#, fuzzy
|
||||
#| msgid "Ingredients"
|
||||
msgid "Ingredient Editor"
|
||||
msgstr "Hozzávalók"
|
||||
msgstr "Hozzávaló szerkesztő"
|
||||
|
||||
#: .\cookbook\templates\base.html:267
|
||||
#: .\cookbook\templates\export_response.html:7
|
||||
@ -1244,15 +1220,13 @@ msgstr "Admin"
|
||||
|
||||
#: .\cookbook\templates\base.html:312
|
||||
#: .\cookbook\templates\space_overview.html:25
|
||||
#, fuzzy
|
||||
#| msgid "No Space"
|
||||
msgid "Your Spaces"
|
||||
msgstr "Nincs hely"
|
||||
msgstr "Ön Helye"
|
||||
|
||||
#: .\cookbook\templates\base.html:323
|
||||
#: .\cookbook\templates\space_overview.html:6
|
||||
msgid "Overview"
|
||||
msgstr ""
|
||||
msgstr "Áttekintés"
|
||||
|
||||
#: .\cookbook\templates\base.html:327
|
||||
msgid "Markdown Guide"
|
||||
@ -1276,11 +1250,11 @@ msgstr "Kijelentkezés"
|
||||
|
||||
#: .\cookbook\templates\base.html:360
|
||||
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
|
||||
msgid "Upgrade Now"
|
||||
msgstr ""
|
||||
msgstr "Frissítés most"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:6
|
||||
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
|
||||
msgid "This cannot be undone!"
|
||||
msgstr ""
|
||||
msgstr "Ezt nem lehet visszafordítani!"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:27
|
||||
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:73
|
||||
#, fuzzy
|
||||
#| msgid "or by leaving a blank line inbetween."
|
||||
msgid "or by leaving a blank line in between."
|
||||
msgstr "vagy egy üres sort hagyva közöttük."
|
||||
|
||||
@ -1566,10 +1538,6 @@ msgid "Lists"
|
||||
msgstr "Listák"
|
||||
|
||||
#: .\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 ""
|
||||
"Lists can ordered or unordered. It is <b>important to leave a blank line "
|
||||
"before the list!</b>"
|
||||
@ -1701,11 +1669,11 @@ msgstr ""
|
||||
#: .\cookbook\templates\openid\login.html:27
|
||||
#: .\cookbook\templates\socialaccount\authentication_error.html:27
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
msgstr "Vissza"
|
||||
|
||||
#: .\cookbook\templates\profile.html:7
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
msgstr "Profil"
|
||||
|
||||
#: .\cookbook\templates\recipe_view.html:41
|
||||
msgid "by"
|
||||
@ -1718,7 +1686,7 @@ msgstr "Megjegyzés"
|
||||
|
||||
#: .\cookbook\templates\rest_framework\api.html:5
|
||||
msgid "Recipe Home"
|
||||
msgstr "Recipe Home"
|
||||
msgstr "Recept főoldal"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:5
|
||||
#: .\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:23
|
||||
#, fuzzy
|
||||
#| msgid "Social Login"
|
||||
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
|
||||
#, fuzzy
|
||||
#| msgid "An error occurred attempting to move "
|
||||
msgid ""
|
||||
"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:15
|
||||
@ -2152,17 +2118,19 @@ msgstr "Regisztráció"
|
||||
#: .\cookbook\templates\socialaccount\login.html:9
|
||||
#, python-format
|
||||
msgid "Connect %(provider)s"
|
||||
msgstr ""
|
||||
msgstr "Csatlakozás %(provider)s"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:11
|
||||
#, python-format
|
||||
msgid "You are about to connect a new third party account from %(provider)s."
|
||||
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
|
||||
#, python-format
|
||||
msgid "Sign In Via %(provider)s"
|
||||
msgstr ""
|
||||
msgstr "Bejelentkezve %(provider)s keresztül"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:15
|
||||
#, python-format
|
||||
@ -2171,7 +2139,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:20
|
||||
msgid "Continue"
|
||||
msgstr ""
|
||||
msgstr "Folytatás"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\signup.html:10
|
||||
#, python-format
|
||||
@ -2210,10 +2178,8 @@ msgid "Manage Subscription"
|
||||
msgstr "Feliratkozás kezelése"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216
|
||||
#, fuzzy
|
||||
#| msgid "Space:"
|
||||
msgid "Space"
|
||||
msgstr "Tér:"
|
||||
msgstr "Tér"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:17
|
||||
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
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
msgstr "Tulajdonos"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:57
|
||||
#, fuzzy
|
||||
#| msgid "Create 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:88
|
||||
@ -2485,87 +2449,111 @@ msgstr ""
|
||||
"teljes szöveges keresés is."
|
||||
|
||||
#: .\cookbook\views\api.py:733
|
||||
#, fuzzy
|
||||
#| msgid "ID of keyword a recipe should have. For multiple repeat parameter."
|
||||
msgid ""
|
||||
"ID of keyword a recipe should have. For multiple repeat parameter. "
|
||||
"Equivalent to keywords_or"
|
||||
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
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Return recipes with any of the keywords"
|
||||
msgstr ""
|
||||
"Kulcsszó azonosítók. Többször is megadható. A megadott kulcsszavak "
|
||||
"mindegyikéhez tartozó receptek listázza"
|
||||
|
||||
#: .\cookbook\views\api.py:739
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Return recipes with all of the keywords."
|
||||
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
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords."
|
||||
msgstr ""
|
||||
"Kulcsszó azonosító. Többször is megadható. Kizárja a recepteket a megadott "
|
||||
"kulcsszavak egyikéből."
|
||||
|
||||
#: .\cookbook\views\api.py:745
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords."
|
||||
msgstr ""
|
||||
"Kulcsszó azonosítók. Többször is megadható. Kizárja az összes megadott "
|
||||
"kulcsszóval rendelkező receptet."
|
||||
|
||||
#: .\cookbook\views\api.py:747
|
||||
msgid "ID of food a recipe should have. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"Az ételek azonosítója egy receptnek tartalmaznia kell. Többszörös ismétlődő "
|
||||
"paraméter esetén."
|
||||
"Annak az összetevőnek az azonosítója, amelynek receptjeit fel kell sorolni. "
|
||||
"Többször is megadható."
|
||||
|
||||
#: .\cookbook\views\api.py:750
|
||||
msgid "Food IDs, repeat for multiple. Return recipes with any of the foods"
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Legalább egy összetevő "
|
||||
"receptjeinek listája"
|
||||
|
||||
#: .\cookbook\views\api.py:752
|
||||
msgid "Food IDs, repeat for multiple. Return recipes with all of the foods."
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Az összes megadott összetevőt "
|
||||
"tartalmazó receptek listája."
|
||||
|
||||
#: .\cookbook\views\api.py:754
|
||||
msgid "Food IDs, repeat for multiple. Exclude recipes with any of the foods."
|
||||
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
|
||||
msgid "Food IDs, repeat for multiple. Exclude recipes with all of the foods."
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Kizárja az összes megadott "
|
||||
"összetevőt tartalmazó recepteket."
|
||||
|
||||
#: .\cookbook\views\api.py:757
|
||||
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
|
||||
msgid ""
|
||||
"Rating a recipe should have or greater. [0 - 5] Negative value filters "
|
||||
"rating less than."
|
||||
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
|
||||
msgid "ID of book a recipe should be in. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"A könyv azonosítója, amelyben a receptnek szerepelnie kell. Többszörös "
|
||||
"ismétlés esetén paraméter."
|
||||
"A könyv azonosítója, amelyben a recept található. Többször is megadható."
|
||||
|
||||
#: .\cookbook\views\api.py:762
|
||||
msgid "Book IDs, repeat for multiple. Return recipes with any of the books"
|
||||
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
|
||||
msgid "Book IDs, repeat for multiple. Return recipes with all of the books."
|
||||
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
|
||||
msgid "Book IDs, repeat for multiple. Exclude recipes with any of the books."
|
||||
msgstr ""
|
||||
"A könyv azonosítói. Többször is megadható. Kizárja a megadott könyvek "
|
||||
"receptjeit."
|
||||
|
||||
#: .\cookbook\views\api.py:768
|
||||
msgid "Book IDs, repeat for multiple. Exclude recipes with all of the books."
|
||||
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
|
||||
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 "
|
||||
"than X times"
|
||||
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
|
||||
msgid ""
|
||||
"Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on "
|
||||
"or before date."
|
||||
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
|
||||
msgid ""
|
||||
"Filter recipes created on or after YYYY-MM-DD. Prepending - filters on or "
|
||||
"before date."
|
||||
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
|
||||
msgid ""
|
||||
"Filter recipes updated on or after YYYY-MM-DD. Prepending - filters on or "
|
||||
"before date."
|
||||
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
|
||||
msgid ""
|
||||
"Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending - filters on "
|
||||
"or before date."
|
||||
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
|
||||
#, 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>]"
|
||||
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
|
||||
msgid ""
|
||||
@ -2647,7 +2649,7 @@ msgstr "Semmi feladat."
|
||||
|
||||
#: .\cookbook\views\api.py:1198
|
||||
msgid "Invalid Url"
|
||||
msgstr ""
|
||||
msgstr "Érvénytelen URL"
|
||||
|
||||
#: .\cookbook\views\api.py:1205
|
||||
msgid "Connection Refused."
|
||||
@ -2655,13 +2657,11 @@ msgstr "Kapcsolat megtagadva."
|
||||
|
||||
#: .\cookbook\views\api.py:1210
|
||||
msgid "Bad URL Schema."
|
||||
msgstr ""
|
||||
msgstr "Rossz URL séma."
|
||||
|
||||
#: .\cookbook\views\api.py:1233
|
||||
#, fuzzy
|
||||
#| msgid "No useable 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
|
||||
msgid "Importing is not implemented for this provider"
|
||||
@ -2774,10 +2774,8 @@ msgid "Shopping Categories"
|
||||
msgstr "Bevásárlási kategóriák"
|
||||
|
||||
#: .\cookbook\views\lists.py:187
|
||||
#, fuzzy
|
||||
#| msgid "Filter"
|
||||
msgid "Custom Filters"
|
||||
msgstr "Szűrő"
|
||||
msgstr "Egyedi szűrők"
|
||||
|
||||
#: .\cookbook\views\lists.py:224
|
||||
msgid "Steps"
|
||||
|
Binary file not shown.
@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-04-11 15:09+0200\n"
|
||||
"PO-Revision-Date: 2023-04-17 20:55+0000\n"
|
||||
"Last-Translator: Espen Sellevåg <buskmenn.drammer03@icloud.com>\n"
|
||||
"PO-Revision-Date: 2023-08-19 21:36+0000\n"
|
||||
"Last-Translator: NeoID <neoid@animenord.com>\n"
|
||||
"Language-Team: Norwegian Bokmål <http://translate.tandoor.dev/projects/"
|
||||
"tandoor/recipes-backend/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 "
|
||||
"try them out!"
|
||||
msgstr ""
|
||||
"Farge på toppnavigasjonslinjen. Ikke alle farger fungerer med alle temaer, "
|
||||
"så bare prøv dem ut!"
|
||||
|
||||
#: .\cookbook\forms.py:46
|
||||
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
|
||||
@ -79,13 +81,15 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:56
|
||||
msgid "Makes the navbar stick to the top of the page."
|
||||
msgstr ""
|
||||
msgstr "Fest navigasjonslinjen til toppen av siden."
|
||||
|
||||
#: .\cookbook\forms.py:72
|
||||
msgid ""
|
||||
"Both fields are optional. If none are given the username will be displayed "
|
||||
"instead"
|
||||
msgstr ""
|
||||
"Begge feltene er valgfrie. Hvis ingen blir oppgitt, vil brukernavnet vises i "
|
||||
"stedet"
|
||||
|
||||
#: .\cookbook\forms.py:93 .\cookbook\forms.py:315
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:45
|
||||
@ -97,15 +101,15 @@ msgstr "Navn"
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:81
|
||||
#: .\cookbook\templates\stats.html:24 .\cookbook\templates\url_import.html:202
|
||||
msgid "Keywords"
|
||||
msgstr ""
|
||||
msgstr "Nøkkelord"
|
||||
|
||||
#: .\cookbook\forms.py:95
|
||||
msgid "Preparation time in minutes"
|
||||
msgstr ""
|
||||
msgstr "Forberedelsestid i minutter"
|
||||
|
||||
#: .\cookbook\forms.py:96
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr ""
|
||||
msgstr "Ventetid (til matlaging/baking) i minutter"
|
||||
|
||||
#: .\cookbook\forms.py:97 .\cookbook\forms.py:317
|
||||
msgid "Path"
|
||||
@ -124,6 +128,8 @@ msgid ""
|
||||
"To prevent duplicates recipes with the same name as existing ones are "
|
||||
"ignored. Check this box to import everything."
|
||||
msgstr ""
|
||||
"For å unngå duplikater, blir oppskrifter med samme navn som eksisterende "
|
||||
"ignorert. Merk av denne boksen for å importere alt."
|
||||
|
||||
#: .\cookbook\forms.py:149
|
||||
msgid "New Unit"
|
||||
@ -131,7 +137,7 @@ msgstr "Ny enhet"
|
||||
|
||||
#: .\cookbook\forms.py:150
|
||||
msgid "New unit that other gets replaced by."
|
||||
msgstr ""
|
||||
msgstr "Ny enhet som erstatter den gamle."
|
||||
|
||||
#: .\cookbook\forms.py:155
|
||||
msgid "Old Unit"
|
||||
@ -143,19 +149,19 @@ msgstr "Enhet som skal erstattes."
|
||||
|
||||
#: .\cookbook\forms.py:172
|
||||
msgid "New Food"
|
||||
msgstr ""
|
||||
msgstr "Ny matvare"
|
||||
|
||||
#: .\cookbook\forms.py:173
|
||||
msgid "New food that other gets replaced by."
|
||||
msgstr ""
|
||||
msgstr "Ny matvare som erstatter den gamle."
|
||||
|
||||
#: .\cookbook\forms.py:178
|
||||
msgid "Old Food"
|
||||
msgstr ""
|
||||
msgstr "Gammel matvare"
|
||||
|
||||
#: .\cookbook\forms.py:179
|
||||
msgid "Food that should be replaced."
|
||||
msgstr ""
|
||||
msgstr "Matvare som bør erstattes."
|
||||
|
||||
#: .\cookbook\forms.py:197
|
||||
msgid "Add your comment: "
|
||||
@ -163,17 +169,19 @@ msgstr "Legg til din kommentar: "
|
||||
|
||||
#: .\cookbook\forms.py:238
|
||||
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
|
||||
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
|
||||
msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
|
||||
"php/webdav/</code> is added automatically)"
|
||||
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
|
||||
msgid "Search String"
|
||||
@ -185,11 +193,12 @@ msgstr "Fil-ID"
|
||||
|
||||
#: .\cookbook\forms.py:354
|
||||
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
|
||||
msgid "You can list default users to share recipes with in the settings."
|
||||
msgstr ""
|
||||
"Du kan liste opp standardbrukere for å dele oppskrifter innen innstillingene."
|
||||
|
||||
#: .\cookbook\forms.py:368
|
||||
#: .\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/"
|
||||
"\">docs here</a>"
|
||||
msgstr ""
|
||||
"Du kan bruke Markdown for å formatere dette feltet. Se <a href=\"/docs/"
|
||||
"markdown/\">dokumentasjonen her</a>"
|
||||
|
||||
#: .\cookbook\forms.py:393
|
||||
msgid "A username is not required, if left blank the new user can choose one."
|
||||
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: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:182
|
||||
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
|
||||
msgid "The requested site provided malformed data and cannot be read."
|
||||
msgstr ""
|
||||
"Nettstedet du har forespurt, har levert feilformatert data som ikke kan "
|
||||
"leses."
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:54
|
||||
msgid ""
|
||||
"The requested site does not provide any recognized data format to import the "
|
||||
"recipe from."
|
||||
msgstr ""
|
||||
"Det forespurte nettstedet gir ingen gjenkjennelig dataformat som kan "
|
||||
"importeres oppskriften fra."
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:160
|
||||
msgid "Imported from"
|
||||
msgstr ""
|
||||
msgstr "Importert fra"
|
||||
|
||||
#: .\cookbook\helper\template_helper.py:60
|
||||
#: .\cookbook\helper\template_helper.py:62
|
||||
msgid "Could not parse template code."
|
||||
msgstr ""
|
||||
msgstr "Kunne ikke analysere mal-koden."
|
||||
|
||||
#: .\cookbook\integration\integration.py:102
|
||||
#: .\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\views\edit.py:190
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "Importér"
|
||||
|
||||
#: .\cookbook\integration\integration.py:131
|
||||
msgid ""
|
||||
"Importer expected a .zip file. Did you choose the correct importer type for "
|
||||
"your data ?"
|
||||
msgstr ""
|
||||
"Importøren forventet en .zip-fil. Har du valgt riktig type importør for "
|
||||
"dataene dine?"
|
||||
|
||||
#: .\cookbook\integration\integration.py:134
|
||||
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
|
||||
#, python-format
|
||||
msgid "Imported %s recipes."
|
||||
msgstr ""
|
||||
msgstr "Importerte %s oppskrifter."
|
||||
|
||||
#: .\cookbook\integration\paprika.py:44
|
||||
msgid "Notes"
|
||||
msgstr ""
|
||||
msgstr "Notater"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:47
|
||||
msgid "Nutritional Information"
|
||||
msgstr ""
|
||||
msgstr "Næringsinformasjon"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:50
|
||||
msgid "Source"
|
||||
msgstr ""
|
||||
msgstr "Kilde"
|
||||
|
||||
#: .\cookbook\integration\safron.py:23
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:75
|
||||
#: .\cookbook\templates\include\log_cooking.html:16
|
||||
#: .\cookbook\templates\url_import.html:84
|
||||
msgid "Servings"
|
||||
msgstr ""
|
||||
msgstr "Porsjoner"
|
||||
|
||||
#: .\cookbook\integration\safron.py:25
|
||||
msgid "Waiting time"
|
||||
msgstr ""
|
||||
msgstr "Ventetid"
|
||||
|
||||
#: .\cookbook\integration\safron.py:27
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:69
|
||||
msgid "Preparation Time"
|
||||
msgstr ""
|
||||
msgstr "Forberedelsestid"
|
||||
|
||||
#: .\cookbook\integration\safron.py:29 .\cookbook\templates\base.html:71
|
||||
#: .\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\views\edit.py:224 .\cookbook\views\new.py:188
|
||||
msgid "Meal-Plan"
|
||||
msgstr ""
|
||||
msgstr "Måltidsplan"
|
||||
|
||||
#: .\cookbook\models.py:112 .\cookbook\templates\base.html:82
|
||||
msgid "Books"
|
||||
@ -337,11 +356,11 @@ msgstr "Bøker"
|
||||
|
||||
#: .\cookbook\models.py:119
|
||||
msgid "Small"
|
||||
msgstr ""
|
||||
msgstr "Liten"
|
||||
|
||||
#: .\cookbook\models.py:119
|
||||
msgid "Large"
|
||||
msgstr ""
|
||||
msgstr "Stor"
|
||||
|
||||
#: .\cookbook\models.py:327
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:198
|
||||
@ -1109,22 +1128,24 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:125
|
||||
msgid "Images & Links"
|
||||
msgstr ""
|
||||
msgstr "Bilder og lenker"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:126
|
||||
msgid ""
|
||||
"Links can be formatted with Markdown. This application also allows to paste "
|
||||
"links directly into markdown fields without any formatting."
|
||||
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:145
|
||||
msgid "This will become an image"
|
||||
msgstr ""
|
||||
msgstr "Dette vil bli til et bilde"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:152
|
||||
msgid "Tables"
|
||||
msgstr ""
|
||||
msgstr "Tabeller"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:153
|
||||
msgid ""
|
||||
@ -1132,124 +1153,130 @@ msgid ""
|
||||
"editor like <a href=\"https://www.tablesgenerator.com/markdown_tables\" rel="
|
||||
"\"noreferrer noopener\" target=\"_blank\">this one.</a>"
|
||||
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:157
|
||||
#: .\cookbook\templates\markdown_info.html:171
|
||||
#: .\cookbook\templates\markdown_info.html:177
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
msgstr "Tabell"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:155
|
||||
#: .\cookbook\templates\markdown_info.html:172
|
||||
msgid "Header"
|
||||
msgstr ""
|
||||
msgstr "Overskrift"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:157
|
||||
#: .\cookbook\templates\markdown_info.html:178
|
||||
msgid "Cell"
|
||||
msgstr ""
|
||||
msgstr "Celle"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:101
|
||||
msgid "New Entry"
|
||||
msgstr ""
|
||||
msgstr "Ny oppføring"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:113
|
||||
#: .\cookbook\templates\shopping_list.html:52
|
||||
msgid "Search Recipe"
|
||||
msgstr ""
|
||||
msgstr "Søk oppskrift"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:139
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
msgstr "Tittel"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:141
|
||||
msgid "Note (optional)"
|
||||
msgstr ""
|
||||
msgstr "Merknad (valgfritt)"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:143
|
||||
msgid ""
|
||||
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
|
||||
"\" target=\"_blank\" rel=\"noopener noreferrer\">docs here</a>"
|
||||
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:251
|
||||
msgid "Serving Count"
|
||||
msgstr ""
|
||||
msgstr "Antall porsjoner"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:153
|
||||
msgid "Create only note"
|
||||
msgstr ""
|
||||
msgstr "Opprett kun en merknad"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:168
|
||||
#: .\cookbook\templates\shopping_list.html:7
|
||||
#: .\cookbook\templates\shopping_list.html:29
|
||||
#: .\cookbook\templates\shopping_list.html:705
|
||||
msgid "Shopping List"
|
||||
msgstr ""
|
||||
msgstr "Handleliste"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:172
|
||||
msgid "Shopping list currently empty"
|
||||
msgstr ""
|
||||
msgstr "Handlelisten er for øyeblikket tom"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:175
|
||||
msgid "Open Shopping List"
|
||||
msgstr ""
|
||||
msgstr "Åpne handlelisten"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:189
|
||||
msgid "Plan"
|
||||
msgstr ""
|
||||
msgstr "Plan"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:196
|
||||
msgid "Number of Days"
|
||||
msgstr ""
|
||||
msgstr "Antall dager"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:206
|
||||
msgid "Weekday offset"
|
||||
msgstr ""
|
||||
msgstr "Ukedagsforskyvning"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:209
|
||||
msgid ""
|
||||
"Number of days starting from the first day of the week to offset the default "
|
||||
"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:294
|
||||
msgid "Edit plan types"
|
||||
msgstr ""
|
||||
msgstr "Rediger plantyper"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:219
|
||||
msgid "Show help"
|
||||
msgstr ""
|
||||
msgstr "Vis hjelp"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:220
|
||||
msgid "Week iCal export"
|
||||
msgstr ""
|
||||
msgstr "Uke iCal-eksport"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:264
|
||||
#: .\cookbook\templates\meal_plan_entry.html:18
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
msgstr "Opprettet av"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:270
|
||||
#: .\cookbook\templates\meal_plan_entry.html:20
|
||||
#: .\cookbook\templates\shopping_list.html:250
|
||||
msgid "Shared with"
|
||||
msgstr ""
|
||||
msgstr "Delt med"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:280
|
||||
msgid "Add to Shopping"
|
||||
msgstr ""
|
||||
msgstr "Legg til i handlelisten"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:323
|
||||
msgid "New meal type"
|
||||
msgstr ""
|
||||
msgstr "Ny måltidstype"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:338
|
||||
msgid "Meal Plan Help"
|
||||
msgstr ""
|
||||
msgstr "Hjelp for måltidsplanen"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:344
|
||||
msgid ""
|
||||
@ -1289,7 +1316,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:6
|
||||
msgid "Meal Plan View"
|
||||
msgstr ""
|
||||
msgstr "Visning av måltidsplanen"
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:50
|
||||
msgid "Never cooked before."
|
||||
@ -1297,7 +1324,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:76
|
||||
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:12
|
||||
|
Binary file not shown.
@ -13,10 +13,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-02-27 13:55+0000\n"
|
||||
"Last-Translator: Jesse <jesse.kamps@pm.me>\n"
|
||||
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/nl/>\n"
|
||||
"PO-Revision-Date: 2023-08-15 19:19+0000\n"
|
||||
"Last-Translator: Jochum van der Heide <jochum@famvanderheide.com>\n"
|
||||
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/nl/>\n"
|
||||
"Language: nl\n"
|
||||
"MIME-Version: 1.0\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"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Gebruik fracties"
|
||||
msgstr "omgekeerde rotatie"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
msgstr ""
|
||||
msgstr "voorzichtige rotatie"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "kneden"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "verdikken"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "opwarmen"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "gisten"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sous-vide"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
msgid "You must supply a servings size"
|
||||
@ -594,10 +592,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s recepten geïmporteerd."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Recept thuis"
|
||||
msgstr "Bron van het recept:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
|
Binary file not shown.
@ -12,8 +12,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-01-08 17:55+0000\n"
|
||||
"Last-Translator: Joachim Weber <joachim.weber@gmx.de>\n"
|
||||
"PO-Revision-Date: 2023-10-07 18:02+0000\n"
|
||||
"Last-Translator: Guilherme Roda <glealroda@gmail.com>\n"
|
||||
"Language-Team: Portuguese <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/pt/>\n"
|
||||
"Language: pt\n"
|
||||
@ -206,8 +206,8 @@ msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
|
||||
"php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"Deixar vazio para Dropbox e inserir apenas url base para Nextcloud (<code>/"
|
||||
"remote.php/webdav/</code>é adicionado automaticamente). "
|
||||
"Deixar vazio para Dropbox e inserir apenas url base para Nextcloud "
|
||||
"(<code>/remote.php/webdav/</code>é adicionado automaticamente)"
|
||||
|
||||
#: .\cookbook\forms.py:282 .\cookbook\views\edit.py:157
|
||||
msgid "Storage"
|
||||
@ -277,16 +277,12 @@ msgstr ""
|
||||
"ignorados)."
|
||||
|
||||
#: .\cookbook\forms.py:461
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Select type method of search. Click <a href=\"/docs/search/\">here</a> "
|
||||
#| "for full desciption of choices."
|
||||
msgid ""
|
||||
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
|
||||
"full description of choices."
|
||||
msgstr ""
|
||||
"Selecionar o método de pesquisa. Uma descrição completa das opções pode ser "
|
||||
"encontrada <a href=\"/docs/search/\">aqui</a>."
|
||||
"Selecionar o método de pesquisa. Uma descrição completa das opções pode "
|
||||
"ser encontrada <a href=\"/docs/search/\">aqui</a>."
|
||||
|
||||
#: .\cookbook\forms.py:462
|
||||
msgid ""
|
||||
@ -329,10 +325,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:476
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Search Method"
|
||||
msgstr "Procurar"
|
||||
msgstr "Método de Pesquisa"
|
||||
|
||||
#: .\cookbook\forms.py:477
|
||||
msgid "Fuzzy Lookups"
|
||||
@ -351,16 +345,12 @@ msgid "Starts With"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:481
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Fuzzy Search"
|
||||
msgstr "Procurar"
|
||||
msgstr "Pesquisa Fuzzy"
|
||||
|
||||
#: .\cookbook\forms.py:482
|
||||
#, fuzzy
|
||||
#| msgid "Text"
|
||||
msgid "Full Text"
|
||||
msgstr "Texto"
|
||||
msgstr "Texto Completo"
|
||||
|
||||
#: .\cookbook\forms.py:507
|
||||
msgid ""
|
||||
@ -405,10 +395,8 @@ msgid "Prefix to add when copying list to the clipboard."
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:524
|
||||
#, fuzzy
|
||||
#| msgid "Shopping"
|
||||
msgid "Share Shopping List"
|
||||
msgstr "Compras"
|
||||
msgstr "Compartilhar Lista de Compras"
|
||||
|
||||
#: .\cookbook\forms.py:525
|
||||
msgid "Autosync"
|
||||
@ -459,10 +447,8 @@ msgid "Reset all food to inherit the fields configured."
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:557
|
||||
#, fuzzy
|
||||
#| msgid "Food that should be replaced."
|
||||
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
|
||||
msgid "Show recipe counts on search filters"
|
||||
@ -516,10 +502,8 @@ msgid "One of queryset or hash_key must be provided"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Usar frações"
|
||||
msgstr "rotação reversa"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
@ -585,16 +569,12 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s receitas importadas."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipes"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Receitas"
|
||||
msgstr "Fonte da Receita:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
#, fuzzy
|
||||
#| msgid "Note"
|
||||
msgid "Notes"
|
||||
msgstr "Nota"
|
||||
msgstr "Notas"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:52
|
||||
msgid "Nutritional Information"
|
||||
@ -606,10 +586,8 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\integration\recettetek.py:54
|
||||
#: .\cookbook\integration\recipekeeper.py:70
|
||||
#, fuzzy
|
||||
#| msgid "Import"
|
||||
msgid "Imported from"
|
||||
msgstr "Importar"
|
||||
msgstr "Importado de"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:23
|
||||
msgid "Servings"
|
||||
@ -706,32 +684,24 @@ msgid "Raw"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Food Alias"
|
||||
msgstr "Novo Prato"
|
||||
msgstr "Apelido do Alimento"
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
#, fuzzy
|
||||
#| msgid "Units"
|
||||
msgid "Unit Alias"
|
||||
msgstr "Unidades"
|
||||
msgstr "Apelido da Unidade"
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
#, fuzzy
|
||||
#| msgid "Keywords"
|
||||
msgid "Keyword Alias"
|
||||
msgstr "Palavras-chave"
|
||||
msgstr "Apelido de Palavra-chave"
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
msgid "Description Replace"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
#, fuzzy
|
||||
#| msgid "Instructions"
|
||||
msgid "Instruction Replace"
|
||||
msgstr "Instruções"
|
||||
msgstr "Substituir Instruções"
|
||||
|
||||
#: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36
|
||||
#: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48
|
||||
@ -739,10 +709,8 @@ msgid "Recipe"
|
||||
msgstr "Receita"
|
||||
|
||||
#: .\cookbook\models.py:1259
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Food"
|
||||
msgstr "Novo Prato"
|
||||
msgstr "Alimento"
|
||||
|
||||
#: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141
|
||||
msgid "Keyword"
|
||||
@ -880,10 +848,8 @@ msgid "Primary"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\account\email.html:47
|
||||
#, fuzzy
|
||||
#| msgid "Make Header"
|
||||
msgid "Make Primary"
|
||||
msgstr "Adicionar Cabeçalho"
|
||||
msgstr "Tornar Primeiro"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:49
|
||||
msgid "Re-send Verification"
|
||||
@ -1004,10 +970,8 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:12
|
||||
#: .\cookbook\templates\account\password_set.html:12
|
||||
#, fuzzy
|
||||
#| msgid "Settings"
|
||||
msgid "Password"
|
||||
msgstr "Definições"
|
||||
msgstr "Senha"
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:22
|
||||
msgid "Forgot Password?"
|
||||
@ -1050,10 +1014,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:33
|
||||
#, fuzzy
|
||||
#| msgid "Settings"
|
||||
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_done.html:19
|
||||
@ -1125,10 +1087,8 @@ msgid "Shopping"
|
||||
msgstr "Compras"
|
||||
|
||||
#: .\cookbook\templates\base.html:153 .\cookbook\views\lists.py:105
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Foods"
|
||||
msgstr "Novo Prato"
|
||||
msgstr "Alimentos"
|
||||
|
||||
#: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122
|
||||
msgid "Units"
|
||||
@ -1139,20 +1099,16 @@ msgid "Supermarket"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:191
|
||||
#, fuzzy
|
||||
#| msgid "Batch edit Category"
|
||||
msgid "Supermarket Category"
|
||||
msgstr "Editar Categorias em massa"
|
||||
msgstr "Categoria de Supermercado"
|
||||
|
||||
#: .\cookbook\templates\base.html:203 .\cookbook\views\lists.py:171
|
||||
msgid "Automations"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:217 .\cookbook\views\lists.py:207
|
||||
#, fuzzy
|
||||
#| msgid "File ID"
|
||||
msgid "Files"
|
||||
msgstr "ID the ficheiro"
|
||||
msgstr "Arquivos"
|
||||
|
||||
#: .\cookbook\templates\base.html:229
|
||||
msgid "Batch Edit"
|
||||
@ -1166,10 +1122,8 @@ msgstr "Histórico"
|
||||
#: .\cookbook\templates\base.html:255
|
||||
#: .\cookbook\templates\ingredient_editor.html:7
|
||||
#: .\cookbook\templates\ingredient_editor.html:13
|
||||
#, fuzzy
|
||||
#| msgid "Ingredients"
|
||||
msgid "Ingredient Editor"
|
||||
msgstr "Ingredientes"
|
||||
msgstr "Editor de Ingrediente"
|
||||
|
||||
#: .\cookbook\templates\base.html:267
|
||||
#: .\cookbook\templates\export_response.html:7
|
||||
@ -1191,10 +1145,8 @@ msgid "External Recipes"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:301 .\cookbook\templates\space_manage.html:15
|
||||
#, fuzzy
|
||||
#| msgid "Settings"
|
||||
msgid "Space Settings"
|
||||
msgstr "Definições"
|
||||
msgstr "Configurar Espaço"
|
||||
|
||||
#: .\cookbook\templates\base.html:306 .\cookbook\templates\system.html:13
|
||||
msgid "System"
|
||||
@ -1206,10 +1158,8 @@ msgstr "Administração"
|
||||
|
||||
#: .\cookbook\templates\base.html:312
|
||||
#: .\cookbook\templates\space_overview.html:25
|
||||
#, fuzzy
|
||||
#| msgid "Create"
|
||||
msgid "Your Spaces"
|
||||
msgstr "Criar"
|
||||
msgstr "Seus Espaços"
|
||||
|
||||
#: .\cookbook\templates\base.html:323
|
||||
#: .\cookbook\templates\space_overview.html:6
|
||||
@ -1288,19 +1238,15 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:28
|
||||
msgid "Sync Now!"
|
||||
msgstr "Sincronizar"
|
||||
msgstr "Sincronizar Agora!"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:29
|
||||
#, fuzzy
|
||||
#| msgid "Recipes"
|
||||
msgid "Show Recipes"
|
||||
msgstr "Receitas"
|
||||
msgstr "Mostrar Receitas"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:30
|
||||
#, fuzzy
|
||||
#| msgid "View Log"
|
||||
msgid "Show Log"
|
||||
msgstr "Ver Registro"
|
||||
msgstr "Mostrar Log"
|
||||
|
||||
#: .\cookbook\templates\batch\waiting.html:4
|
||||
#: .\cookbook\templates\batch\waiting.html:10
|
||||
@ -1335,7 +1281,7 @@ msgstr "Editar Receita"
|
||||
#: .\cookbook\templates\generic\delete_template.html:21
|
||||
#, python-format
|
||||
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
|
||||
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:22
|
||||
msgid "List"
|
||||
msgstr "Listar "
|
||||
msgstr "Listar"
|
||||
|
||||
#: .\cookbook\templates\generic\list_template.html:36
|
||||
msgid "Filter"
|
||||
@ -1422,13 +1368,13 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Os </b>campos da senha e Token</b> são guardados dentro da base de "
|
||||
"dados como <b>texto simples.</b>\n"
|
||||
"Isto é necessário porque eles são usados para fazer pedidos á API, mas "
|
||||
"também aumenta o risco de\n"
|
||||
"de alguém os roubar. <br/>\n"
|
||||
"Para limitar os possíveis danos, tokens e contas com acesso limitado podem "
|
||||
"ser usadas.\n"
|
||||
" Os campos de <b>senha e Token</b> são armazenados na base de dados "
|
||||
"como <b>texto simples</b>.\n"
|
||||
" Isto é necessário porque eles são usados para fazer pedidos à API, "
|
||||
"mas também aumenta o risco\n"
|
||||
" de alguém os roubar.<br/>\n"
|
||||
" Para limitar os possíveis danos, tokens e contas com acesso limitado "
|
||||
"podem ser usadas.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\index.html:29
|
||||
@ -1441,7 +1387,7 @@ msgstr "Nova Receita"
|
||||
|
||||
#: .\cookbook\templates\index.html:53
|
||||
msgid "Advanced Search"
|
||||
msgstr "Procura avançada "
|
||||
msgstr "Pesquisa avançada"
|
||||
|
||||
#: .\cookbook\templates\index.html:57
|
||||
msgid "Reset Search"
|
||||
@ -1493,8 +1439,6 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:57
|
||||
#: .\cookbook\templates\markdown_info.html:73
|
||||
#, fuzzy
|
||||
#| msgid "or by leaving a blank line inbetween."
|
||||
msgid "or by leaving a blank line in between."
|
||||
msgstr "ou deixando uma linha em branco no meio."
|
||||
|
||||
@ -1518,10 +1462,6 @@ msgid "Lists"
|
||||
msgstr "Listas"
|
||||
|
||||
#: .\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 ""
|
||||
"Lists can ordered or unordered. It is <b>important to leave a blank line "
|
||||
"before the list!</b>"
|
||||
@ -1598,7 +1538,7 @@ msgstr "Cabeçalho"
|
||||
#: .\cookbook\templates\markdown_info.html:157
|
||||
#: .\cookbook\templates\markdown_info.html:178
|
||||
msgid "Cell"
|
||||
msgstr "Célula "
|
||||
msgstr "Célula"
|
||||
|
||||
#: .\cookbook\templates\no_groups_info.html:5
|
||||
#: .\cookbook\templates\no_groups_info.html:12
|
||||
@ -1666,10 +1606,8 @@ msgstr ""
|
||||
#: .\cookbook\templates\search_info.html:5
|
||||
#: .\cookbook\templates\search_info.html:9
|
||||
#: .\cookbook\templates\settings.html:24
|
||||
#, fuzzy
|
||||
#| msgid "Search String"
|
||||
msgid "Search Settings"
|
||||
msgstr "Procurar"
|
||||
msgstr "Configurações de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:10
|
||||
msgid ""
|
||||
@ -1684,10 +1622,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\search_info.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Search Methods"
|
||||
msgstr "Procurar"
|
||||
msgstr "Métodos de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:23
|
||||
msgid ""
|
||||
@ -1769,10 +1705,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\search_info.html:69
|
||||
#, fuzzy
|
||||
#| msgid "Search Recipe"
|
||||
msgid "Search Fields"
|
||||
msgstr "Procure Receita"
|
||||
msgstr "Campos de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:73
|
||||
msgid ""
|
||||
@ -1810,10 +1744,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\search_info.html:95
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Search Index"
|
||||
msgstr "Procurar"
|
||||
msgstr "Índice de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:99
|
||||
msgid ""
|
||||
@ -2012,10 +1944,8 @@ msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:57
|
||||
#, fuzzy
|
||||
#| msgid "Create"
|
||||
msgid "Leave Space"
|
||||
msgstr "Criar"
|
||||
msgstr "Sair do Espaço"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:78
|
||||
#: .\cookbook\templates\space_overview.html:88
|
||||
@ -2034,10 +1964,8 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:96
|
||||
#: .\cookbook\templates\space_overview.html:105
|
||||
#, fuzzy
|
||||
#| msgid "Create"
|
||||
msgid "Create Space"
|
||||
msgstr "Criar"
|
||||
msgstr "Criar Espaço"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:99
|
||||
msgid "Create your own recipe space."
|
||||
@ -2487,10 +2415,8 @@ msgid "Shopping Categories"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\views\lists.py:187
|
||||
#, fuzzy
|
||||
#| msgid "Filter"
|
||||
msgid "Custom Filters"
|
||||
msgstr "Filtrar"
|
||||
msgstr "Filtros Customizados"
|
||||
|
||||
#: .\cookbook\views\lists.py:224
|
||||
msgid "Steps"
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"PO-Revision-Date: 2023-08-13 08:19+0000\n"
|
||||
"Last-Translator: Miha Perpar <miha.perpar2@gmail.com>\n"
|
||||
"Language-Team: Slovenian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/sl/>\n"
|
||||
"Language: sl\n"
|
||||
@ -964,7 +964,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:275
|
||||
msgid "GitHub"
|
||||
msgstr ""
|
||||
msgstr "GitHub"
|
||||
|
||||
#: .\cookbook\templates\base.html:277
|
||||
msgid "Translate Tandoor"
|
||||
@ -1961,7 +1961,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\space.html:106
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
msgstr "uporabnik"
|
||||
|
||||
#: .\cookbook\templates\space.html:107
|
||||
msgid "guest"
|
||||
|
@ -1,9 +1,9 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_scopes import scopes_disabled
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Recipe, Step
|
||||
@ -14,7 +14,7 @@ class Command(BaseCommand):
|
||||
help = _('Rebuilds full text search index on Recipe')
|
||||
|
||||
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')))
|
||||
|
||||
try:
|
||||
|
@ -34,35 +34,14 @@ class RecipeSearchManager(models.Manager):
|
||||
+ SearchVector(StringAgg('steps__ingredients__food__name__unaccent', delimiter=' '), weight='B', config=language)
|
||||
+ SearchVector(StringAgg('keywords__name__unaccent', delimiter=' '), weight='B', config=language))
|
||||
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 (
|
||||
self.get_queryset()
|
||||
.annotate(
|
||||
search=search_vectors,
|
||||
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(
|
||||
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'))
|
||||
|
@ -9,7 +9,7 @@ from django.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
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():
|
||||
@ -21,7 +21,7 @@ def nameSearchField():
|
||||
|
||||
|
||||
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
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
with scopes_disabled():
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
18
cookbook/migrations/0196_food_url.py
Normal file
18
cookbook/migrations/0196_food_url.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
18
cookbook/migrations/0198_propertytype_order.py
Normal file
18
cookbook/migrations/0198_propertytype_order.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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',
|
||||
),
|
||||
]
|
@ -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(),
|
||||
),
|
||||
]
|
17
cookbook/migrations/0202_remove_space_show_facet_count.py
Normal file
17
cookbook/migrations/0202_remove_space_show_facet_count.py
Normal 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',
|
||||
),
|
||||
]
|
17
cookbook/migrations/0203_alter_unique_contstraints.py
Normal file
17
cookbook/migrations/0203_alter_unique_contstraints.py
Normal 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'),
|
||||
),
|
||||
]
|
26
cookbook/migrations/0204_propertytype_fdc_id.py
Normal file
26
cookbook/migrations/0204_propertytype_fdc_id.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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'),
|
||||
),
|
||||
]
|
@ -5,7 +5,6 @@ import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
import oauth2_provider.models
|
||||
from PIL import Image
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
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.validators import MinLengthValidator
|
||||
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.functions import Substr
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from PIL import Image
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
||||
@ -116,10 +116,7 @@ class TreeModel(MP_Node):
|
||||
_full_name_separator = ' > '
|
||||
|
||||
def __str__(self):
|
||||
if self.icon:
|
||||
return f"{self.icon} {self.name}"
|
||||
else:
|
||||
return f"{self.name}"
|
||||
return f"{self.name}"
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
@ -188,7 +185,6 @@ class TreeModel(MP_Node):
|
||||
:param filter: Filter (include) the descendants nodes with the provided Q filter
|
||||
"""
|
||||
descendants = Q()
|
||||
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||
nodes = queryset.values('path', 'depth')
|
||||
for node in nodes:
|
||||
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):
|
||||
# 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')
|
||||
|
||||
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_at = models.DateTimeField(auto_now_add=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)
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
show_facet_count = models.BooleanField(default=False)
|
||||
|
||||
internal_note = models.TextField(blank=True, null=True)
|
||||
|
||||
@ -331,6 +362,7 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
FLATLY = 'FLATLY'
|
||||
SUPERHERO = 'SUPERHERO'
|
||||
TANDOOR = 'TANDOOR'
|
||||
TANDOOR_DARK = 'TANDOOR_DARK'
|
||||
|
||||
THEMES = (
|
||||
(TANDOOR, 'Tandoor'),
|
||||
@ -338,25 +370,14 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
(DARKLY, 'Darkly'),
|
||||
(FLATLY, 'Flatly'),
|
||||
(SUPERHERO, 'Superhero'),
|
||||
(TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'),
|
||||
)
|
||||
|
||||
# Nav colors
|
||||
PRIMARY = 'PRIMARY'
|
||||
SECONDARY = 'SECONDARY'
|
||||
SUCCESS = 'SUCCESS'
|
||||
INFO = 'INFO'
|
||||
WARNING = 'WARNING'
|
||||
DANGER = 'DANGER'
|
||||
LIGHT = 'LIGHT'
|
||||
DARK = 'DARK'
|
||||
|
||||
COLORS = (
|
||||
(PRIMARY, 'Primary'),
|
||||
(SECONDARY, 'Secondary'),
|
||||
(SUCCESS, 'Success'),
|
||||
(INFO, 'Info'),
|
||||
(WARNING, 'Warning'),
|
||||
(DANGER, 'Danger'),
|
||||
NAV_TEXT_COLORS = (
|
||||
(LIGHT, 'Light'),
|
||||
(DARK, 'Dark')
|
||||
)
|
||||
@ -374,8 +395,13 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
|
||||
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')
|
||||
|
||||
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')
|
||||
use_fractions = models.BooleanField(default=FRACTION_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)
|
||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
||||
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
||||
mealplan_autoinclude_related = models.BooleanField(default=True)
|
||||
shopping_add_onhand = models.BooleanField(default=False)
|
||||
filter_to_supermarket = 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)
|
||||
shopping_recent_days = models.PositiveIntegerField(default=7)
|
||||
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
|
||||
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)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@ -527,7 +556,6 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
if SORT_TREE_BY_NAME:
|
||||
node_order_by = ['name']
|
||||
name = models.CharField(max_length=64)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=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)])
|
||||
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)
|
||||
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
|
||||
ignore_shopping = models.BooleanField(default=False) # inherited field
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
|
||||
preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
fdc_id = models.IntegerField(null=True, default=None, blank=True)
|
||||
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
@ -718,23 +747,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
food = ""
|
||||
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)
|
||||
return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'pk']
|
||||
@ -751,6 +764,7 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
|
||||
order = models.IntegerField(default=0)
|
||||
file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
|
||||
show_as_header = models.BooleanField(default=True)
|
||||
show_ingredients_table = models.BooleanField(default=True)
|
||||
search_vector = SearchVectorField(null=True)
|
||||
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)
|
||||
|
||||
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:
|
||||
ordering = ['order', 'pk']
|
||||
@ -778,11 +794,13 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
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)
|
||||
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)
|
||||
|
||||
fdc_id = models.IntegerField(null=True, default=None, blank=True)
|
||||
# TODO show if empty 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', 'open_data_slug'], name='property_type_unique_open_data_slug_per_space')
|
||||
]
|
||||
ordering = ('order',)
|
||||
|
||||
|
||||
class Property(models.Model, PermissionModelMixin):
|
||||
@ -823,7 +842,7 @@ class FoodProperty(models.Model):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food')
|
||||
models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'),
|
||||
]
|
||||
|
||||
|
||||
@ -945,7 +964,6 @@ class RecipeImport(models.Model, PermissionModelMixin):
|
||||
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
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')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
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):
|
||||
name = models.CharField(max_length=128)
|
||||
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)
|
||||
default = models.BooleanField(default=False, blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
@ -999,6 +1016,11 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
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):
|
||||
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')
|
||||
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
|
||||
note = models.TextField(blank=True)
|
||||
date = models.DateField()
|
||||
from_date = models.DateField()
|
||||
to_date = models.DateField()
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@ -1022,7 +1045,7 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
return self.meal_type.name
|
||||
|
||||
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):
|
||||
@ -1142,6 +1165,8 @@ class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, Permis
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
internal_note = models.TextField(blank=True, null=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@ -1308,7 +1333,7 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
|
||||
|
||||
def is_image(self):
|
||||
try:
|
||||
img = Image.open(self.file.file.file)
|
||||
Image.open(self.file.file.file)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@ -1326,10 +1351,25 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
||||
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'
|
||||
|
||||
type = models.CharField(max_length=128,
|
||||
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')),
|
||||
(DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')),))
|
||||
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')),
|
||||
))
|
||||
name = models.CharField(max_length=128, default='')
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
|
@ -67,17 +67,3 @@ class FilterSchema(AutoSchema):
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
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
Loading…
Reference in New Issue
Block a user