Compare commits
197 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c94cf1c2e | ||
|
|
1673254934 | ||
|
|
0493ef7e3a | ||
|
|
1fd6a47e9c | ||
|
|
bb52f8902d | ||
|
|
35eff630ff | ||
|
|
8d90fada1d | ||
|
|
2ba2b97f9c | ||
|
|
26408c33f4 | ||
|
|
72b0bd7f1e | ||
|
|
45f0413fb9 | ||
|
|
38c464ebae | ||
|
|
b4f158b913 | ||
|
|
da49b6bda0 | ||
|
|
e7d9d7b7b3 | ||
|
|
5f7a57a258 | ||
|
|
4b1a80a0ed | ||
|
|
8efc3de11f | ||
|
|
1f3cd11964 | ||
|
|
94cfc36ed5 | ||
|
|
d493ba72a1 | ||
|
|
71e5484f0c | ||
|
|
761e423bde | ||
|
|
c8e674da16 | ||
|
|
6f3d4491ed | ||
|
|
54e2615c86 | ||
|
|
77942a7144 | ||
|
|
5a5ce4d736 | ||
|
|
0d966b5e59 | ||
|
|
1dda4126c1 | ||
|
|
5ffe821407 | ||
|
|
f9bfb8e258 | ||
|
|
c6fa635af2 | ||
|
|
50e1eaf645 | ||
|
|
953dc75a8d | ||
|
|
ac5333d0e7 | ||
|
|
ecf985f5e3 | ||
|
|
b6d4c4c3b8 | ||
|
|
30f3a697f0 | ||
|
|
42ced25e10 | ||
|
|
6011cf359f | ||
|
|
f57acc412b | ||
|
|
200cacb9ac | ||
|
|
5c89173373 | ||
|
|
61b67cd37a | ||
|
|
12c2f2f7aa | ||
|
|
3d8b1d6ccb | ||
|
|
aa0d6b5a6b | ||
|
|
64ed75156c | ||
|
|
c6d41e8810 | ||
|
|
2a10843101 | ||
|
|
f861b39d05 | ||
|
|
5c18c09944 | ||
|
|
1bd5f96029 | ||
|
|
988df4eb00 | ||
|
|
bf61b6474e | ||
|
|
be177cf258 | ||
|
|
5059abc232 | ||
|
|
cb63bb2615 | ||
|
|
7ca5a34b28 | ||
|
|
a7ea7a8987 | ||
|
|
8d7b4f614c | ||
|
|
df67d3ce7b | ||
|
|
54119ed1ec | ||
|
|
26f694576a | ||
|
|
7a5b744ff0 | ||
|
|
4058c997de | ||
|
|
4de9be5c89 | ||
|
|
34ee03b720 | ||
|
|
48dacf46c3 | ||
|
|
181c270b34 | ||
|
|
e89c3887ec | ||
|
|
99cd9bfb5b | ||
|
|
8bbccad7a9 | ||
|
|
a59a78f44c | ||
|
|
205bf5253d | ||
|
|
0fed6b9fb3 | ||
|
|
dd3e91e10d | ||
|
|
76b84898f6 | ||
|
|
05d971835f | ||
|
|
0a814fa896 | ||
|
|
05ba11a48e | ||
|
|
6a7a22626e | ||
|
|
1635a3a335 | ||
|
|
1d84e7851b | ||
|
|
44d1cc3a30 | ||
|
|
04b4f552f8 | ||
|
|
6214176fe5 | ||
|
|
205dc11125 | ||
|
|
ba5112e138 | ||
|
|
0e34cc72d5 | ||
|
|
31c6defc93 | ||
|
|
d4c544bb4b | ||
|
|
2b05efeff6 | ||
|
|
d7ddcd3214 | ||
|
|
29133f4236 | ||
|
|
b440b09be5 | ||
|
|
ed1f656167 | ||
|
|
4f3e6d3765 | ||
|
|
46a50d7835 | ||
|
|
65513a8f60 | ||
|
|
044ed1ec18 | ||
|
|
8f53b399c6 | ||
|
|
702c1d67d3 | ||
|
|
c654cc469a | ||
|
|
8df846c9c2 | ||
|
|
7070f6c964 | ||
|
|
b454960676 | ||
|
|
abf8f79136 | ||
|
|
fd028047d6 | ||
|
|
bd0e1bcefe | ||
|
|
a2aa0dc3b9 | ||
|
|
1758aebb73 | ||
|
|
ecffe30062 | ||
|
|
21653465e0 | ||
|
|
f3e11e6358 | ||
|
|
0c381ed46c | ||
|
|
fd978f9c19 | ||
|
|
b069a49954 | ||
|
|
11c8422fbb | ||
|
|
2cb010c8b4 | ||
|
|
8f96c7f0a3 | ||
|
|
3054297357 | ||
|
|
3e083e2168 | ||
|
|
d1174ea50d | ||
|
|
fe11b88fd0 | ||
|
|
a3a2433d2a | ||
|
|
92be2db9fd | ||
|
|
be2f759048 | ||
|
|
52e88ddfd3 | ||
|
|
fe208e9844 | ||
|
|
15e7f32001 | ||
|
|
745c045f06 | ||
|
|
4b5abec458 | ||
|
|
f6ed49b5c4 | ||
|
|
0a0e3a48c3 | ||
|
|
cce2407bc0 | ||
|
|
9b18cab145 | ||
|
|
8b9a09b268 | ||
|
|
db1709cef7 | ||
|
|
4844e5cbc8 | ||
|
|
e90781983f | ||
|
|
86496069b3 | ||
|
|
e1aee23c54 | ||
|
|
1000badd2f | ||
|
|
9ae0b50558 | ||
|
|
f69813f729 | ||
|
|
fcb2c07acd | ||
|
|
a076d20cba | ||
|
|
bae777bc69 | ||
|
|
49781bfa7f | ||
|
|
72d3ace0f9 | ||
|
|
7f44a6f187 | ||
|
|
92b8799d26 | ||
|
|
7f1eecddc4 | ||
|
|
4723a7ecbd | ||
|
|
6af28e6fe5 | ||
|
|
ad1e64fb9a | ||
|
|
add600f3ca | ||
|
|
ad036d7e6c | ||
|
|
d7017902ab | ||
|
|
35743e8be9 | ||
|
|
899a9955fb | ||
|
|
3c08e3a3f1 | ||
|
|
3cabe85091 | ||
|
|
530d6b0cb6 | ||
|
|
9da66c9f6c | ||
|
|
124211a2f4 | ||
|
|
71555fee28 | ||
|
|
05a99c9b64 | ||
|
|
32690f04b2 | ||
|
|
29b74557a6 | ||
|
|
c43e7e0331 | ||
|
|
fe7fd7700d | ||
|
|
c6ef0e0087 | ||
|
|
6149f693ab | ||
|
|
daef57823f | ||
|
|
5c7b9a93ae | ||
|
|
b681364f95 | ||
|
|
40d14eeb9f | ||
|
|
46b09f11b6 | ||
|
|
900291dc5f | ||
|
|
e9f9134e2e | ||
|
|
8fe11b12f8 | ||
|
|
a1cfb7ad9f | ||
|
|
2bddf21175 | ||
|
|
aa5490adb3 | ||
|
|
bea089dd5e | ||
|
|
2c7237adaa | ||
|
|
98af1e1e4c | ||
|
|
4a1aee38a3 | ||
|
|
92c21bc382 | ||
|
|
ba748cc5fe | ||
|
|
22b1a9634a | ||
|
|
eeb5395efc | ||
|
|
6ea259596a | ||
|
|
49275a96fe |
180
.env.template
@@ -1,185 +1,15 @@
|
||||
# only set this to true when testing/debugging
|
||||
# when unset: 1 (true) - dont unset this, just for development
|
||||
DEBUG=0
|
||||
SQL_DEBUG=0
|
||||
DEBUG_TOOLBAR=0
|
||||
# Gunicorn log level for debugging (default value is "info" when unset)
|
||||
# (see https://docs.gunicorn.org/en/stable/settings.html#loglevel for available settings)
|
||||
# GUNICORN_LOG_LEVEL="debug"
|
||||
|
||||
# HTTP port to bind to
|
||||
# TANDOOR_PORT=8080
|
||||
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
# This template contains only required options.
|
||||
# Visit the docs to find more https://docs.tandoor.dev/system/configuration/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
# ---------------------------- 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
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
# DB_OPTIONS= {} # e.g. {"sslmode":"require"} to enable ssl
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_DB=djangodb
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangouser
|
||||
# ---------------------------- AT LEAST ONE REQUIRED -------------------------
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_PASSWORD_FILE=
|
||||
# ---------------------------------------------------------------
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# database connection string, when used overrides other database settings.
|
||||
# format might vary depending on backend
|
||||
# DATABASE_URL = engine://username:password@host:port/dbname
|
||||
|
||||
# the default value for the user preference 'fractions' (enable/disable fraction support)
|
||||
# default: disabled=0
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# default comments enabled=1
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
|
||||
# Users can set a amount of time after which the shopping list is refreshed when they are in viewing mode
|
||||
# This is the minimum interval users can set. Setting this to low will allow users to refresh very frequently which
|
||||
# might cause high load on the server. (Technically they can obviously refresh as often as they want with their own scripts)
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
|
||||
# Default for user setting sticky navbar
|
||||
# STICKY_NAV_PREF_DEFAULT=1
|
||||
|
||||
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
|
||||
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
|
||||
# SCRIPT_NAME=/recipes
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /
|
||||
# this is not required if you are just using a subfolder
|
||||
# This can either be a relative path from the applications base path or the url of an external host
|
||||
# STATIC_URL=/static/
|
||||
|
||||
# If mediafiles are stored at a different location uncomment and change accordingly, MUST END IN /
|
||||
# this is not required if you are just using a subfolder
|
||||
# This can either be a relative path from the applications base path or the url of an external host
|
||||
# MEDIA_URL=/media/
|
||||
|
||||
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
|
||||
# provided that include an additional nxginx container to handle media file serving.
|
||||
# If you know what you are doing turn this back on (1) to serve media files using djangos serve() method.
|
||||
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
# GUNICORN SERVER RELATED SETTINGS (see https://docs.gunicorn.org/en/stable/design.html#how-many-workers for recommended settings)
|
||||
# GUNICORN_WORKERS=1
|
||||
# GUNICORN_THREADS=1
|
||||
|
||||
# S3 Media settings: store mediafiles in s3 or any compatible storage backend (e.g. minio)
|
||||
# as long as S3_ACCESS_KEY is not set S3 features are disabled
|
||||
# S3_ACCESS_KEY=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_BUCKET_NAME=
|
||||
# S3_REGION_NAME= # default none, set your region might be required
|
||||
# S3_QUERYSTRING_AUTH=1 # default true, set to 0 to serve media from a public bucket without signed urls
|
||||
# S3_QUERYSTRING_EXPIRE=3600 # number of seconds querystring are valid for
|
||||
# S3_ENDPOINT_URL= # when using a custom endpoint like minio
|
||||
# S3_CUSTOM_DOMAIN= # when using a CDN/proxy to S3 (see https://github.com/TandoorRecipes/recipes/issues/1943)
|
||||
|
||||
# Email Settings, see https://docs.djangoproject.com/en/3.2/ref/settings/#email-host
|
||||
# Required for email confirmation and password reset (automatically activates if host is set)
|
||||
# EMAIL_HOST=
|
||||
# EMAIL_PORT=
|
||||
# EMAIL_HOST_USER=
|
||||
# EMAIL_HOST_PASSWORD=
|
||||
# EMAIL_USE_TLS=0
|
||||
# EMAIL_USE_SSL=0
|
||||
# email sender address (default 'webmaster@localhost')
|
||||
# DEFAULT_FROM_EMAIL=
|
||||
# prefix used for account related emails (default "[Tandoor Recipes] ")
|
||||
# ACCOUNT_EMAIL_SUBJECT_PREFIX=
|
||||
|
||||
# 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)
|
||||
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
|
||||
# SPACE_DEFAULT_MAX_USERS=0 # 0=unlimited users per space
|
||||
# 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 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
|
||||
|
||||
# If signup is enabled you might want to add a captcha to it to prevent spam
|
||||
# HCAPTCHA_SITEKEY=
|
||||
# HCAPTCHA_SECRET=
|
||||
|
||||
# if signup is enabled you might want to provide urls to data protection policies or terms and conditions
|
||||
# TERMS_URL=
|
||||
# PRIVACY_URL=
|
||||
# IMPRINT_URL=
|
||||
|
||||
# enable serving of prometheus metrics under the /metrics path
|
||||
# ATTENTION: view is not secured (as per the prometheus default way) so make sure to secure it
|
||||
# trough your web server (or leave it open of you dont care if the stats are exposed)
|
||||
# ENABLE_METRICS=0
|
||||
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://docs.tandoor.dev/features/authentication/
|
||||
# SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount.providers.nextcloud,
|
||||
|
||||
# Should a newly created user from a social provider get assigned to the default space and given permission by default ?
|
||||
# ATTENTION: This feature might be deprecated in favor of a space join and public viewing system in the future
|
||||
# default 0 (false), when 1 (true) users will be assigned space and group
|
||||
# SOCIAL_DEFAULT_ACCESS = 1
|
||||
|
||||
# if SOCIAL_DEFAULT_ACCESS is used, which group should be added
|
||||
# SOCIAL_DEFAULT_GROUP=guest
|
||||
|
||||
# Django session cookie settings. Can be changed to allow a single django application to authenticate several applications
|
||||
# when running under the same database
|
||||
# SESSION_COOKIE_DOMAIN=.example.com
|
||||
# SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain
|
||||
|
||||
# by default SORT_TREE_BY_NAME is disabled this will store all Keywords and Food in the order they are created
|
||||
# enabling this setting makes saving new keywords and foods very slow, which doesn't matter in most usecases.
|
||||
# however, when doing large imports of recipes that will create new objects, can increase total run time by 10-15x
|
||||
# Keywords and Food can be manually sorted by name in Admin
|
||||
# This value can also be temporarily changed in Admin, it will revert the next time the application is started
|
||||
# This will be fixed/changed in the future by changing the implementation or finding a better workaround for sorting
|
||||
# SORT_TREE_BY_NAME=0
|
||||
# LDAP authentication
|
||||
# default 0 (false), when 1 (true) list of allowed users will be fetched from LDAP server
|
||||
#LDAP_AUTH=
|
||||
#AUTH_LDAP_SERVER_URI=
|
||||
#AUTH_LDAP_BIND_DN=
|
||||
#AUTH_LDAP_BIND_PASSWORD=
|
||||
#AUTH_LDAP_USER_SEARCH_BASE_DN=
|
||||
#AUTH_LDAP_TLS_CACERTFILE=
|
||||
#AUTH_LDAP_START_TLS=
|
||||
|
||||
# Enables exporting PDF (see export docs)
|
||||
# Disabled by default, uncomment to enable
|
||||
# ENABLE_PDF_EXPORT=1
|
||||
|
||||
# Recipe exports are cached for a certain time by default, adjust time if needed
|
||||
# EXPORT_FILE_CACHE_DURATION=600
|
||||
|
||||
|
||||
1
.idea/dictionaries/vaben.xml
generated
@@ -3,6 +3,7 @@
|
||||
<words>
|
||||
<w>pinia</w>
|
||||
<w>selfhosted</w>
|
||||
<w>unapplied</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
1
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -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>
|
||||
|
||||
@@ -60,9 +60,9 @@ 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',)
|
||||
|
||||
@@ -108,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):
|
||||
@@ -163,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)
|
||||
|
||||
@@ -183,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'
|
||||
|
||||
@@ -198,7 +212,14 @@ class RecipeAdmin(admin.ModelAdmin):
|
||||
|
||||
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)
|
||||
@@ -229,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)
|
||||
|
||||
@@ -258,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
|
||||
@@ -334,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):
|
||||
@@ -349,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)
|
||||
|
||||
@@ -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', 'show_step_ingredients',
|
||||
)
|
||||
|
||||
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'),
|
||||
'show_step_ingredients': _('Show step ingredients table')
|
||||
}
|
||||
|
||||
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.'),
|
||||
'show_step_ingredients': _('Add ingredients table next to recipe steps. Applies at creation time for manually created and URL imported recipes. Individual steps can be overridden in the edit recipe view.')
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'plan_share': MultiSelectWidget,
|
||||
'shopping_share': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
class UserNameForm(forms.ModelForm):
|
||||
prefix = 'name'
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
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()
|
||||
@@ -169,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):
|
||||
|
||||
@@ -309,7 +309,7 @@ class RecipeSearch():
|
||||
|
||||
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:
|
||||
|
||||
@@ -163,10 +163,9 @@ def get_from_scraper(scrape, request):
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
recipe_json['steps'][0]['instruction'] = f"*{recipe_json['description']}* \n\n" + recipe_json['steps'][0]['instruction']
|
||||
else:
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
|
||||
try:
|
||||
for x in scrape.ingredients():
|
||||
@@ -259,13 +258,14 @@ def get_from_youtube_scraper(url, request):
|
||||
]
|
||||
}
|
||||
|
||||
# TODO add automation here
|
||||
try:
|
||||
automation_engine = AutomationEngine(request, source=url)
|
||||
video = YouTube(url=url)
|
||||
video = YouTube(url)
|
||||
video.streams.first() # this is required to execute some kind of generator/web request that fetches the description
|
||||
default_recipe_json['name'] = automation_engine.apply_regex_replace_automation(video.title, Automation.NAME_REPLACE)
|
||||
default_recipe_json['image'] = video.thumbnail_url
|
||||
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
if video.description:
|
||||
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
@@ -415,8 +415,8 @@ def parse_keywords(keyword_json, request):
|
||||
# if alias exists use that instead
|
||||
|
||||
if len(kw) != 0:
|
||||
automation_engine.apply_keyword_automation(kw)
|
||||
if k := Keyword.objects.filter(name=kw, space=request.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})
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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-07-31 14:19+0000\n"
|
||||
"Last-Translator: Mára Štěpánek <stepanekm7@gmail.com>\n"
|
||||
"PO-Revision-Date: 2024-01-09 12:07+0000\n"
|
||||
"Last-Translator: Jan Kubošek <kuboja@outlook.cz>\n"
|
||||
"Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/cs/>\n"
|
||||
"Language: cs\n"
|
||||
@@ -190,7 +190,7 @@ msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud "
|
||||
"(<code>/remote.php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"Pro dropbox ponechejte nevyplňené pole. Pro nextcloud použijte pouze "
|
||||
"Pro dropbox ponechejte nevyplněné pole. Pro nextcloud použijte pouze "
|
||||
"základní url (<code>/remote.php/webdav/</code> bude přidán automaticky)."
|
||||
|
||||
#: .\cookbook\forms.py:263
|
||||
@@ -529,7 +529,7 @@ msgstr "Dávková úprava receptu"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:20
|
||||
msgid "Add the specified keywords to all recipes containing a word"
|
||||
msgstr "Přidat štítek ke všem receptům, které obsahují specifické slovo."
|
||||
msgstr "Přidat štítek ke všem receptům, které obsahují specifické slovo"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:66
|
||||
msgid "Sync"
|
||||
|
||||
@@ -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-10-12 20:19+0000\n"
|
||||
"Last-Translator: pharok <pharok@free.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"
|
||||
@@ -551,7 +551,7 @@ 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"
|
||||
|
||||
BIN
cookbook/locale/he/LC_MESSAGES/django.mo
Normal file
@@ -11,7 +11,7 @@ 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-10-20 14:05+0000\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"
|
||||
@@ -282,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 ""
|
||||
@@ -536,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"
|
||||
@@ -770,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"
|
||||
@@ -1226,10 +1220,8 @@ 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
|
||||
@@ -2132,6 +2124,8 @@ msgstr "Csatlakozás %(provider)s"
|
||||
#, 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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.10 on 2023-08-29 11:59
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import F, Value
|
||||
from django.db.models import F, Value, Count
|
||||
from django.db.models.functions import Concat
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
@@ -13,9 +13,24 @@ def migrate_icons(apps, schema_editor):
|
||||
PropertyType = apps.get_model('cookbook', 'PropertyType')
|
||||
RecipeBook = apps.get_model('cookbook', 'RecipeBook')
|
||||
|
||||
duplicate_meal_types = MealType.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all()
|
||||
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')))
|
||||
|
||||
|
||||
@@ -25,10 +40,7 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
migrations.RunPython(
|
||||
migrate_icons
|
||||
),
|
||||
migrations.RunPython( migrate_icons),
|
||||
migrations.AlterModelOptions(
|
||||
name='propertytype',
|
||||
options={'ordering': ('order',)},
|
||||
|
||||
@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='mealtype',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'name'), name='mt_unique_name_per_space'),
|
||||
constraint=models.UniqueConstraint(fields=('space', 'name', 'created_by'), name='mt_unique_name_per_space'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
# 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',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-06 15:05
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0206_rename_sticky_navbar_userpreference_nav_sticky_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_128',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_128', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_144',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_144', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_180',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_180', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_192',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_192', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_32',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_32', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_512',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_512', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_svg',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_svg', to='cookbook.userfile'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-14 23:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0207_space_logo_color_128_space_logo_color_144_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='app_name',
|
||||
field=models.CharField(blank=True, max_length=40, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='max_owned_spaces',
|
||||
field=models.IntegerField(default=100),
|
||||
),
|
||||
]
|
||||
@@ -24,7 +24,7 @@ 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,
|
||||
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
|
||||
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT, MAX_OWNED_SPACES_PREF_DEFAULT)
|
||||
|
||||
|
||||
def get_user_display_name(self):
|
||||
@@ -251,8 +251,52 @@ 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=BLANK)
|
||||
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=BLANK)
|
||||
app_name = models.CharField(max_length=40, null=True, blank=True, )
|
||||
logo_color_32 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_32')
|
||||
logo_color_128 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_128')
|
||||
logo_color_144 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_144')
|
||||
logo_color_180 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_180')
|
||||
logo_color_192 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_192')
|
||||
logo_color_512 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_512')
|
||||
logo_color_svg = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_svg')
|
||||
|
||||
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)
|
||||
@@ -338,22 +382,10 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
)
|
||||
|
||||
# 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')
|
||||
)
|
||||
@@ -371,8 +403,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)
|
||||
max_owned_spaces = models.IntegerField(default=MAX_OWNED_SPACES_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)
|
||||
@@ -382,7 +419,6 @@ 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)
|
||||
@@ -398,6 +434,15 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk:
|
||||
self.max_owned_spaces = MAX_OWNED_SPACES_PREF_DEFAULT
|
||||
self.comments = COMMENT_PREF_DEFAULT
|
||||
self.nav_sticky = STICKY_NAV_PREF_DEFAULT
|
||||
self.use_kj = KJ_PREF_DEFAULT
|
||||
self.use_fractions = FRACTION_PREF_DEFAULT
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
|
||||
@@ -591,7 +636,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
|
||||
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
|
||||
preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
fdc_id = models.IntegerField(null=True, default=None, blank=True)
|
||||
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
@@ -718,6 +763,9 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'pk']
|
||||
indexes = (
|
||||
@@ -745,7 +793,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']
|
||||
@@ -767,7 +817,7 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
(PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
fdc_id = models.IntegerField(null=True, default=None, blank=True)
|
||||
# TODO show if empty property?
|
||||
# TODO formatting property?
|
||||
|
||||
@@ -809,7 +859,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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -985,7 +1035,7 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='mt_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1311,6 +1361,9 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
|
||||
self.file_size_kb = round(self.file.size / 1000)
|
||||
super(UserFile, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} (#{self.id})'
|
||||
|
||||
|
||||
class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
|
||||
FOOD_ALIAS = 'FOOD_ALIAS'
|
||||
|
||||
@@ -19,6 +19,7 @@ from oauth2_provider.models import AccessToken
|
||||
from PIL import Image
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.fields import IntegerField
|
||||
|
||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
@@ -211,7 +212,7 @@ class UserFileSerializer(serializers.ModelSerializer):
|
||||
Image.open(obj.file.file.file)
|
||||
return self.context['request'].build_absolute_uri(obj.file.url)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
# traceback.print_exc()
|
||||
return ""
|
||||
|
||||
def check_file_limit(self, validated_data):
|
||||
@@ -259,7 +260,7 @@ class UserFileViewSerializer(serializers.ModelSerializer):
|
||||
Image.open(obj.file.file.file)
|
||||
return self.context['request'].build_absolute_uri(obj.file.url)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
# traceback.print_exc()
|
||||
return ""
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -280,6 +281,15 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
|
||||
food_inherit = FoodInheritFieldSerializer(many=True)
|
||||
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
nav_logo = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
custom_space_theme = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_32 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_128 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_144 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_180 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_192 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_512 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_svg = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
|
||||
def get_user_count(self, obj):
|
||||
return UserSpace.objects.filter(space=obj).count()
|
||||
@@ -301,7 +311,8 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'use_plural',)
|
||||
'image', 'nav_logo', 'space_theme', 'custom_space_theme', 'nav_bg_color', 'nav_text_color', 'use_plural',
|
||||
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg',)
|
||||
read_only_fields = (
|
||||
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
||||
'demo',)
|
||||
@@ -371,8 +382,8 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
'user', 'image', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj',
|
||||
'plan_share', 'sticky_navbar',
|
||||
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', 'use_fractions', 'use_kj',
|
||||
'plan_share', 'nav_sticky',
|
||||
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
|
||||
'food_inherit_default', 'default_delay',
|
||||
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
|
||||
@@ -524,6 +535,7 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo
|
||||
|
||||
class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, UniqueFieldsMixin):
|
||||
id = serializers.IntegerField(required=False)
|
||||
order = IntegerField(default=0, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
@@ -985,6 +997,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
shared = UserSerializer(many=True, required=False, allow_null=True)
|
||||
shopping = serializers.SerializerMethodField('in_shopping')
|
||||
|
||||
to_date = serializers.DateField(required=False)
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
@@ -993,6 +1007,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
|
||||
if 'to_date' not in validated_data or validated_data['to_date'] is None:
|
||||
validated_data['to_date'] = validated_data['from_date']
|
||||
|
||||
mealplan = super().create(validated_data)
|
||||
if self.context['request'].data.get('addshopping', False) and self.context['request'].data.get('recipe', None):
|
||||
SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
|
||||
@@ -1013,7 +1031,7 @@ class AutoMealPlanSerializer(serializers.Serializer):
|
||||
start_date = serializers.DateField()
|
||||
end_date = serializers.DateField()
|
||||
meal_type_id = serializers.IntegerField()
|
||||
keywords = KeywordSerializer(many=True)
|
||||
keyword_ids = serializers.ListField()
|
||||
servings = CustomDecimalField()
|
||||
shared = UserSerializer(many=True, required=False, allow_null=True)
|
||||
addshopping = serializers.BooleanField()
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,41 +0,0 @@
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Logo" transform="matrix(0.637323,0,0,0.637323,-243.095,-716.725)">
|
||||
<g id="Kreis" transform="matrix(1.44936,0,0,1.50279,387.258,1039.34)">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584" style="fill:url(#_Linear1);"/>
|
||||
<clipPath id="_clip2">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g id="Shadow" transform="matrix(1.10322,0,0,1.064,-5.58287,50.5786)">
|
||||
<path d="M156.285,427.208L389.554,660.477L668.803,495.551L374.012,200.761L156.285,427.208Z" style="fill:rgb(22,22,22);"/>
|
||||
<g transform="matrix(1,0,0,1,-4.22105,0.775864)">
|
||||
<path d="M208.628,178.613L485.935,455.919L590.027,364.63L296.923,71.526L294.175,138.989L208.628,178.613Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-85.3876,27.8512)">
|
||||
<path d="M310.385,145.641L587.692,422.948L590.392,361.357L297.288,68.253L294.175,138.989L310.385,145.641Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.471,0,0,1.471,406.537,1149.69)">
|
||||
<path d="M256.049,220C286.222,219.994 312.656,207.31 329.388,194.134C346.35,180.754 370.899,183.406 384.611,200.1C407.129,227.376 420.598,261.944 420.598,299.53C420.598,361.08 382.604,437.101 329.764,463.706C307.035,475.15 283.466,480.586 256.098,480.599L256.098,480.599L256.049,480.599L256,480.599L256,480.599C228.632,480.586 205.063,475.15 182.334,463.706C129.494,437.101 91.5,361.08 91.5,299.53C91.5,261.944 104.969,227.376 127.487,200.1C141.199,183.406 165.748,180.754 182.71,194.134C199.442,207.31 225.876,219.994 256.049,220Z" style="fill:rgb(255,203,118);"/>
|
||||
</g>
|
||||
<g id="Flame-2" transform="matrix(0.965725,0,0,0.89175,164.497,436.391)">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z" style="fill:rgb(255,111,0);"/>
|
||||
<clipPath id="_clip3">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip3)">
|
||||
<g transform="matrix(1.28784,-0.270602,0.285942,1.59598,247.349,825.209)">
|
||||
<path d="M255.004,46.957C279.547,58.545 306,85.447 313.307,120.161C325.437,177.791 291.571,193.789 262.496,192.403C215.889,190.181 200.194,153.246 231.326,108.9C250.631,81.401 232.663,36.408 255.004,46.957Z" style="fill:rgb(255,209,0);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Hut" transform="matrix(1.521,0,0,1.521,393.566,1149.06)">
|
||||
<path d="M228.197,408.524C222.698,408.524 217.813,406.688 214.024,403.619C211.776,401.794 210.92,398.752 211.888,396.024C212.856,393.295 215.437,391.472 218.332,391.472C232.214,391.4 256.112,391.396 256.112,391.396C256.112,391.396 280.009,391.4 293.891,391.472C296.786,391.472 299.367,393.295 300.335,396.024C301.303,398.752 300.447,401.794 298.199,403.619C294.41,406.688 289.526,408.524 284.027,408.524L228.197,408.524ZM217.24,378.877C214.208,378.877 211.3,377.671 209.158,375.525C207.015,373.379 205.814,370.469 205.82,367.436C205.831,361.119 205.842,354.539 205.842,354.539C205.842,350.423 203.097,346.814 199.131,345.714C185.313,341.841 175.2,329.468 175.2,314.823C175.2,297.07 190.059,282.657 208.362,282.657C208.362,282.657 208.362,282.657 208.362,282.657C215.401,282.657 221.675,278.218 224.017,271.581C227.243,262.39 236.411,252.015 256,251.998L256,251.998L256.223,251.998L256.223,251.998C275.812,252.015 284.98,262.39 288.206,271.581C290.549,278.218 296.822,282.657 303.861,282.657C303.861,282.657 303.861,282.657 303.861,282.657C322.164,282.657 337.023,297.07 337.023,314.823C337.023,329.468 326.911,341.841 313.093,345.714C309.127,346.814 306.382,350.423 306.381,354.539C306.381,354.539 306.386,361.127 306.391,367.447C306.394,370.478 305.191,373.385 303.049,375.529C300.907,377.672 298.001,378.877 294.971,378.877C275.615,378.877 236.604,378.877 217.24,378.877Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2e-06,0,0,2e-06,3755.77,81.7179)"><stop offset="0" style="stop-color:rgb(39,39,39);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
cookbook/static/assets/logo_color_128.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
BIN
cookbook/static/assets/logo_color_192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
34
cookbook/static/themes/tandoor.min.css
vendored
@@ -4457,28 +4457,28 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-light .navbar-brand, .navbar-light .navbar-brand:focus, .navbar-light .navbar-brand:hover {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link {
|
||||
color: rgba(46, 46, 46, .5)
|
||||
color: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link:focus, .navbar-light .navbar-nav .nav-link:hover {
|
||||
color: rgba(46, 46, 46, .7)
|
||||
color: rgba(0, 0, 0, .7)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link.disabled {
|
||||
color: rgba(46, 46, 46, .3)
|
||||
color: rgba(0, 0, 0, .3)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .active > .nav-link, .navbar-light .navbar-nav .nav-link.active, .navbar-light .navbar-nav .nav-link.show, .navbar-light .navbar-nav .show > .nav-link {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-toggler {
|
||||
color: rgba(46, 46, 46, .5);
|
||||
border-color: rgba(46, 46, 46, .1)
|
||||
color: rgba(0, 0, 0, .5);
|
||||
border-color: rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-toggler-icon {
|
||||
@@ -4486,11 +4486,7 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-light .navbar-text {
|
||||
color: rgba(46, 46, 46, .5)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-text a, .navbar-light .navbar-text a:focus, .navbar-light .navbar-text a:hover {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand, .navbar-dark .navbar-brand:focus, .navbar-dark .navbar-brand:hover {
|
||||
@@ -4498,24 +4494,24 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: hsla(0, 0%, 18%, .5)
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:focus, .navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: hsla(0, 0%, 18%, .75)
|
||||
color: rgba(255, 255, 255, .75)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.disabled {
|
||||
color: hsla(0, 0%, 18%, .25)
|
||||
color: rgba(255, 255, 255, .25)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .active > .nav-link, .navbar-dark .navbar-nav .nav-link.active, .navbar-dark .navbar-nav .nav-link.show, .navbar-dark .navbar-nav .show > .nav-link {
|
||||
color: #2e2e2e
|
||||
color: #FFF
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-toggler {
|
||||
color: rgba(46, 46, 46, 0.5);
|
||||
border-color: rgba(46, 46, 46, 0.5);
|
||||
color: rgba(255, 255, 255, .5);
|
||||
border-color: rgba(255, 255, 255, .1)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-toggler-icon {
|
||||
@@ -4523,7 +4519,7 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text {
|
||||
color: hsla(0, 0%, 18%, .5)
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text a, .navbar-dark .navbar-text a:focus, .navbar-dark .navbar-text a:hover {
|
||||
|
||||
32
cookbook/static/themes/tandoor_dark.min.css
vendored
@@ -4460,28 +4460,28 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-light .navbar-brand, .navbar-light .navbar-brand:focus, .navbar-light .navbar-brand:hover {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link {
|
||||
color: rgba(46, 46, 46, .5)
|
||||
color: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link:focus, .navbar-light .navbar-nav .nav-link:hover {
|
||||
color: rgba(46, 46, 46, .7)
|
||||
color: rgba(0, 0, 0, .7)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link.disabled {
|
||||
color: rgba(46, 46, 46, .3)
|
||||
color: rgba(0, 0, 0, .3)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .active > .nav-link, .navbar-light .navbar-nav .nav-link.active, .navbar-light .navbar-nav .nav-link.show, .navbar-light .navbar-nav .show > .nav-link {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-toggler {
|
||||
color: rgba(46, 46, 46, .5);
|
||||
border-color: rgba(46, 46, 46, .1)
|
||||
color: rgba(0, 0, 0, .5);
|
||||
border-color: rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-toggler-icon {
|
||||
@@ -4489,11 +4489,11 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-light .navbar-text {
|
||||
color: rgba(46, 46, 46, .5)
|
||||
color: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-text a, .navbar-light .navbar-text a:focus, .navbar-light .navbar-text a:hover {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand, .navbar-dark .navbar-brand:focus, .navbar-dark .navbar-brand:hover {
|
||||
@@ -4501,24 +4501,24 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: hsla(0, 0%, 5%, .5)
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:focus, .navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: hsla(0, 0%, 5%, .75)
|
||||
color: rgba(255, 255, 255, .75)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.disabled {
|
||||
color: hsla(0, 0%, 5%, .25)
|
||||
color: rgba(255, 255, 255, .25)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .active > .nav-link, .navbar-dark .navbar-nav .nav-link.active, .navbar-dark .navbar-nav .nav-link.show, .navbar-dark .navbar-nav .show > .nav-link {
|
||||
color: #1E1E1E
|
||||
color: #FFF
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-toggler {
|
||||
color: rgba(46, 46, 46, 0.5);
|
||||
border-color: rgba(46, 46, 46, 0.5);
|
||||
color: rgba(255, 255, 255, .5);
|
||||
border-color: rgba(255, 255, 255, .1)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-toggler-icon {
|
||||
@@ -4526,7 +4526,7 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text {
|
||||
color: hsla(0, 0%, 18%, .5)
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text a, .navbar-dark .navbar-text a:focus, .navbar-dark .navbar-text a:hover {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
{% load theming_tags %}
|
||||
{% load custom_tags %}
|
||||
|
||||
{% theme_values request as theme_values %}
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}
|
||||
@@ -11,28 +13,28 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex,nofollow"/>
|
||||
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="shortcut icon" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'assets/favicon-32x32.png' %}" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="{% static 'assets/favicon-16x16.png' %}" sizes="16x16">
|
||||
|
||||
<link rel="mask-icon" href="{% static 'assets/safari-pinned-tab.svg' %}" color="#161616">
|
||||
<link rel="apple-touch-icon" href="{% static 'assets/apple-touch-icon.png' %}" sizes="180x180">
|
||||
<link rel="icon" href="{{ theme_values.logo_color_svg }}">
|
||||
<link rel="icon" href="{{ theme_values.logo_color_32 }}" sizes="32x32">
|
||||
<link rel="icon" href="{{ theme_values.logo_color_128 }}" sizes="128x128">
|
||||
<link rel="icon" href="{{ theme_values.logo_color_192 }}" sizes="192x192">
|
||||
<link rel="apple-touch-icon" href="{{ theme_values.logo_color_180 }}" sizes="180x180">
|
||||
|
||||
<link rel="manifest" crossorigin="use-credentials" href="{% url 'web_manifest' %}">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/mstile-144x144.png">
|
||||
|
||||
<meta name="msapplication-TileColor" content="{{ theme_values.nav_bg_color }}">
|
||||
<meta name="msapplication-TileImage" content="{{ theme_values.logo_color_144 }}">
|
||||
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#161616">
|
||||
<meta name="msapplication-TileColor" content="#161616">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="theme-color" content="{{ theme_values.nav_bg_color }}">
|
||||
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
|
||||
<!-- Bootstrap 4 -->
|
||||
<link id="id_main_css" href="{% theme_url request %}" rel="stylesheet">
|
||||
<link id="id_main_css" href="{{ theme_values.theme }}" rel="stylesheet">
|
||||
{% if theme_values.custom_theme %}
|
||||
<link id="id_custom_css" href="{{ theme_values.custom_theme }}" rel="stylesheet">
|
||||
{% endif %}
|
||||
|
||||
|
||||
<link href="{% static 'css/app.min.css' %}" rel="stylesheet">
|
||||
<script src="{% static 'js/jquery-3.5.1.min.js' %}"></script>
|
||||
|
||||
@@ -74,15 +76,15 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg {% nav_color request %}"
|
||||
<nav class="navbar navbar-expand-lg {{ theme_values.nav_text_class }}"
|
||||
id="id_main_nav"
|
||||
style="{% sticky_nav request %}">
|
||||
style="{{ theme_values.sticky_nav }}; background-color: {{ theme_values.nav_bg_color }}">
|
||||
|
||||
{% if not request.user.userpreference.left_handed %}
|
||||
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
|
||||
{% if not request.user.is_authenticated or request.user.userpreference.nav_show_logo %}
|
||||
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}"
|
||||
aria-label="Tandoor">
|
||||
<img class="brand-icon" src="{% logo_url request %}" alt="Logo">
|
||||
<img class="brand-icon" src="{{ theme_values.nav_logo }}" alt="Logo">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -93,10 +95,10 @@
|
||||
</button>
|
||||
|
||||
{% if request.user.userpreference.left_handed %}
|
||||
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
|
||||
{% if not request.user.is_authenticated or request.user.userpreference.nav_show_logo %}
|
||||
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}"
|
||||
aria-label="Tandoor">
|
||||
<img class="brand-icon" src="{% logo_url request %}" alt="Logo">
|
||||
<img class="brand-icon" src="{{ theme_values.nav_logo }}" alt="Logo">
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
[](https://github.com/vabene1111/recipes)
|
||||
[GitHub](https://github.com/vabene1111/recipes)
|
||||
|
||||

|
||||

|
||||
</code></pre>
|
||||
|
||||
<div style="text-align: center">
|
||||
@@ -142,7 +142,7 @@
|
||||
<div class="card-body">
|
||||
<a href="https://github.com/vabene1111/recipes">https://github.com/vabene1111/recipes</a> <br/>
|
||||
<a href="https://github.com/vabene1111/recipes">GitHub</a> <br/>
|
||||
<img src="{% static 'assets/favicon.svg' %}" class="img-fluid" alt="{% trans 'This will become an image' %}"
|
||||
<img src="{% static 'assets/logo_color_svg.svg' %}" class="img-fluid" alt="{% trans 'This will become an image' %}"
|
||||
style="height: 3vw">
|
||||
</div>
|
||||
|
||||
|
||||
31
cookbook/templates/property_editor.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{% trans 'Property Editor' %}{% endblock %}
|
||||
|
||||
{% block content_fluid %}
|
||||
|
||||
<div id="app">
|
||||
<property-editor-view></property-editor-view>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.RECIPE_ID = {{ recipe_id }}
|
||||
</script>
|
||||
|
||||
{% render_bundle 'property_editor_view' %}
|
||||
{% endblock %}
|
||||
@@ -83,23 +83,94 @@
|
||||
{% trans 'Everything is fine!' %}
|
||||
{% endif %}
|
||||
|
||||
<h4 class="mt-3">{% trans 'Database' %} <span
|
||||
class="badge badge-{% if postgres %}warning{% else %}success{% endif %}">{% if postgres %}
|
||||
{% trans 'Info' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
|
||||
{% if postgres %}
|
||||
<h4 class="mt-3">{% trans 'Database' %}
|
||||
<span class="badge badge-{{ postgres_status }}">
|
||||
{% if postgres_status == 'warning' %}
|
||||
{% trans 'Info' %}
|
||||
{% elif postgres_status == 'danger' %}
|
||||
{% trans 'Warning' %}
|
||||
{% else %}
|
||||
{% trans 'Ok' %}
|
||||
{% endif %}
|
||||
</span>
|
||||
</h4>
|
||||
{{ postgres_message }}
|
||||
|
||||
<h4 class="mt-3">{% trans 'Migrations' %}
|
||||
<span
|
||||
class="badge badge-{% if missing_migration %}danger{% else %}success{% endif %}">{% if missing_migration %}
|
||||
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
|
||||
|
||||
<p>
|
||||
{% blocktrans %}
|
||||
This application is not running with a Postgres database backend. This is ok but not recommended as some
|
||||
features only work with postgres databases.
|
||||
Migrations should never fail!
|
||||
Failed migrations will likely cause major parts of the app to not function correctly.
|
||||
If a migration fails make sure you are on the latest version and if so please post the migration log and the overview below in a GitHub issue.
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans 'Everything is fine!' %}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<table class="table mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App</th>
|
||||
<th class="text-right">{% trans 'Migrations' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for key,value in migration_info.items %}
|
||||
<tr>
|
||||
|
||||
<td>{{ value.app }}</td>
|
||||
<td class="text-right">
|
||||
<span class="badge badge-{% if value.unapplied_migrations|length > 0 %}danger{% else %}success{% endif %}">
|
||||
{{ value.applied_migrations|length }} / {{ value.total }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% for u in value.unapplied_migrations %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ u }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
|
||||
{# <h4 class="mt-3">#}
|
||||
{# {% trans 'Orphaned Files' %}#}
|
||||
{##}
|
||||
{# <span class="badge badge-{% if orphans|length == 0 %}success{% elif orphans|length <= 25 %}warning{% else %}danger{% endif %}">#}
|
||||
{# {% if orphans|length == 0 %}{% trans 'Success' %}#}
|
||||
{# {% elif orphans|length <= 25 %}{% trans 'Warning' %}#}
|
||||
{# {% else %}{% trans 'Danger' %}#}
|
||||
{# {% endif %}#}
|
||||
{# </span>#}
|
||||
{# </h4>#}
|
||||
|
||||
{# {% if orphans|length == 0 %}#}
|
||||
{# {% trans 'Everything is fine!' %}#}
|
||||
{# {% else %}#}
|
||||
{# {% blocktrans with orphan_count=orphans|length %}#}
|
||||
{# There are currently {{ orphan_count }} orphaned files.#}
|
||||
{# {% endblocktrans %}#}
|
||||
{# <br>#}
|
||||
{# <button id="toggle-button" class="btn btn-info btn-sm" onclick="toggleOrphans()">{% trans 'Show' %}</button>#}
|
||||
{# <button class="btn btn-info btn-sm" onclick="deleteOrphans()">{% trans 'Delete' %}</button>#}
|
||||
{# {% endif %}#}
|
||||
{# <textarea id="orphans-list" style="display:none;" class="form-control" rows="20">#}
|
||||
{#{% for orphan in orphans %}{{ orphan }}#}
|
||||
{#{% endfor %}#}
|
||||
{# </textarea>#}
|
||||
|
||||
<h4 class="mt-3">Debug</h4>
|
||||
<textarea class="form-control" rows="20">
|
||||
Gunicorn Media: {{ gunicorn_media }}
|
||||
Sqlite: {{ postgres }}
|
||||
Debug: {{ debug }}
|
||||
Sqlite: {% if postgres %} {% trans 'False' %} {% else %} {% trans 'True' %} {% endif %}
|
||||
{% if postgres %}PostgreSQL: {{ postgres_version }} {% endif %}
|
||||
Debug: {{ debug }}
|
||||
|
||||
{% for key,value in request.META.items %}{% if key in 'SERVER_PORT,REMOTE_HOST,REMOTE_ADDR,SERVER_PROTOCOL' %}{{ key }}:{{ value }}
|
||||
{% endif %}{% endfor %}
|
||||
@@ -110,4 +181,31 @@ Debug: {{ debug }}
|
||||
</textarea>
|
||||
<br/>
|
||||
<br/>
|
||||
{% endblock %}
|
||||
<form method="POST" id="delete-form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="delete_orphans" value="false">
|
||||
</form>
|
||||
{% block script %}
|
||||
<script>
|
||||
function toggleOrphans() {
|
||||
var orphansList = document.getElementById('orphans-list');
|
||||
var button = document.getElementById('toggle-button');
|
||||
|
||||
if (orphansList.style.display === 'none') {
|
||||
orphansList.style.display = 'block';
|
||||
button.innerText = "{% trans 'Hide' %}";
|
||||
} else {
|
||||
orphansList.style.display = 'none';
|
||||
button.innerText = "{% trans 'Show' %}";
|
||||
}
|
||||
}
|
||||
|
||||
function deleteOrphans() {
|
||||
document.getElementById('delete-form').delete_orphans.value = 'true';
|
||||
document.getElementById('delete-form').submit();
|
||||
}
|
||||
</script>
|
||||
{% endblock script %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
from django import template
|
||||
from django.templatetags.static import static
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import UserPreference
|
||||
from recipes.settings import STICKY_NAV_PREF_DEFAULT
|
||||
from cookbook.models import UserPreference, UserFile, Space
|
||||
from recipes.settings import STICKY_NAV_PREF_DEFAULT, UNAUTHENTICATED_THEME_FROM_SPACE
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def theme_url(request):
|
||||
if not request.user.is_authenticated:
|
||||
return static('themes/tandoor.min.css')
|
||||
def theme_values(request):
|
||||
return get_theming_values(request)
|
||||
|
||||
|
||||
def get_theming_values(request):
|
||||
space = None
|
||||
if getattr(request,'space',None):
|
||||
space = request.space
|
||||
if not request.user.is_authenticated and UNAUTHENTICATED_THEME_FROM_SPACE > 0:
|
||||
with scopes_disabled():
|
||||
space = Space.objects.filter(id=UNAUTHENTICATED_THEME_FROM_SPACE).first()
|
||||
|
||||
themes = {
|
||||
UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css',
|
||||
UserPreference.FLATLY: 'themes/flatly.min.css',
|
||||
@@ -19,35 +29,51 @@ def theme_url(request):
|
||||
UserPreference.TANDOOR: 'themes/tandoor.min.css',
|
||||
UserPreference.TANDOOR_DARK: 'themes/tandoor_dark.min.css',
|
||||
}
|
||||
if request.user.userpreference.theme in themes:
|
||||
return static(themes[request.user.userpreference.theme])
|
||||
else:
|
||||
raise AttributeError
|
||||
nav_text_type_mapping = {Space.DARK: 'navbar-light',
|
||||
Space.LIGHT: 'navbar-dark'} # inverted since navbar-dark means the background
|
||||
|
||||
tv = {
|
||||
'logo_color_32': static('assets/logo_color_32.png'),
|
||||
'logo_color_128': static('assets/logo_color_128.png'),
|
||||
'logo_color_144': static('assets/logo_color_144.png'),
|
||||
'logo_color_180': static('assets/logo_color_180.png'),
|
||||
'logo_color_192': static('assets/logo_color_192.png'),
|
||||
'logo_color_512': static('assets/logo_color_512.png'),
|
||||
'logo_color_svg': static('assets/logo_color_svg.svg'),
|
||||
'custom_theme': None,
|
||||
'theme': static(themes[UserPreference.TANDOOR]),
|
||||
'nav_logo': static('assets/brand_logo.png'),
|
||||
'nav_bg_color': '#ddbf86',
|
||||
'nav_text_class': 'navbar-light',
|
||||
'sticky_nav': 'position: sticky; top: 0; left: 0; z-index: 1000;',
|
||||
'app_name': 'Tandoor Recipes',
|
||||
}
|
||||
|
||||
@register.simple_tag
|
||||
def logo_url(request):
|
||||
if request.user.is_authenticated and request.space.image:
|
||||
return request.space.image.file.url
|
||||
else:
|
||||
return static('assets/brand_logo.png')
|
||||
if request.user.is_authenticated:
|
||||
if request.user.userpreference.theme in themes:
|
||||
tv['theme'] = static(themes[request.user.userpreference.theme])
|
||||
if request.user.userpreference.nav_bg_color:
|
||||
tv['nav_bg_color'] = request.user.userpreference.nav_bg_color
|
||||
if request.user.userpreference.nav_text_color and request.user.userpreference.nav_text_color in nav_text_type_mapping:
|
||||
tv['nav_text_class'] = nav_text_type_mapping[request.user.userpreference.nav_text_color]
|
||||
if not request.user.userpreference.nav_sticky:
|
||||
tv['sticky_nav'] = ''
|
||||
|
||||
if space:
|
||||
for logo in list(tv.keys()):
|
||||
if logo.startswith('logo_color_') and getattr(space, logo, None):
|
||||
tv[logo] = getattr(space, logo).file.url
|
||||
|
||||
@register.simple_tag
|
||||
def nav_color(request):
|
||||
if not request.user.is_authenticated:
|
||||
return 'navbar-light bg-primary'
|
||||
|
||||
if request.user.userpreference.nav_color.lower() in ['light', 'warning', 'info', 'success']:
|
||||
return f'navbar-light bg-{request.user.userpreference.nav_color.lower()}'
|
||||
else:
|
||||
return f'navbar-dark bg-{request.user.userpreference.nav_color.lower()}'
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def sticky_nav(request):
|
||||
if (not request.user.is_authenticated and STICKY_NAV_PREF_DEFAULT) or \
|
||||
(request.user.is_authenticated and request.user.userpreference.sticky_navbar): # noqa: E501
|
||||
return 'position: sticky; top: 0; left: 0; z-index: 1000;'
|
||||
else:
|
||||
return ''
|
||||
if space.custom_space_theme:
|
||||
tv['custom_theme'] = space.custom_space_theme.file.url
|
||||
if space.space_theme in themes:
|
||||
tv['theme'] = static(themes[space.space_theme])
|
||||
if space.nav_logo:
|
||||
tv['nav_logo'] = space.nav_logo.file.url
|
||||
if space.nav_bg_color:
|
||||
tv['nav_bg_color'] = space.nav_bg_color
|
||||
if space.nav_text_color and space.nav_text_color in nav_text_type_mapping:
|
||||
tv['nav_text_class'] = nav_text_type_mapping[space.nav_text_color]
|
||||
if space.app_name:
|
||||
tv['app_name'] = space.app_name
|
||||
return tv
|
||||
|
||||
@@ -61,6 +61,12 @@ def test_list_filter(obj_1, u1_s1):
|
||||
response = json.loads(r.content)
|
||||
assert len(response) == 1
|
||||
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?meal_type={response[0]["meal_type"]["id"]}').content)
|
||||
assert len(response) == 1
|
||||
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?meal_type=0').content)
|
||||
assert len(response) == 0
|
||||
|
||||
response = json.loads(
|
||||
u1_s1.get(f'{reverse(LIST_URL)}?from_date={(datetime.now() + timedelta(days=2)).strftime("%Y-%m-%d")}').content)
|
||||
assert len(response) == 0
|
||||
|
||||
68
cookbook/tests/other/test_theming.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from django.contrib import auth
|
||||
from django.templatetags.static import static
|
||||
from django.test import RequestFactory
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import Space, UserPreference, UserFile
|
||||
from cookbook.templatetags.theming_tags import theme_values, get_theming_values
|
||||
|
||||
|
||||
def test_theming_function(space_1, u1_s1):
|
||||
|
||||
# uf = UserFile.objects.create(name='test', space=space_1, created_by=user) #TODO add file tests
|
||||
user = auth.get_user(u1_s1)
|
||||
request = RequestFactory()
|
||||
request.user = auth.get_user(u1_s1)
|
||||
request.space = space_1
|
||||
|
||||
# defaults apply without setting anything (user preference is automatically created with these defaults)
|
||||
assert get_theming_values(request)['theme'] == static('themes/tandoor.min.css')
|
||||
assert get_theming_values(request)['nav_bg_color'] == '#ddbf86'
|
||||
assert get_theming_values(request)['nav_text_class'] == 'navbar-light'
|
||||
assert get_theming_values(request)['nav_logo'] == static('assets/brand_logo.png')
|
||||
assert get_theming_values(request)['sticky_nav'] == 'position: sticky; top: 0; left: 0; z-index: 1000;'
|
||||
assert get_theming_values(request)['app_name'] == 'Tandoor Recipes'
|
||||
|
||||
with scopes_disabled():
|
||||
up = UserPreference.objects.filter(user=request.user).first()
|
||||
up.theme = UserPreference.TANDOOR_DARK
|
||||
up.nav_bg_color = '#ffffff'
|
||||
up.nav_text_color = UserPreference.LIGHT
|
||||
up.nav_sticky = False
|
||||
up.save()
|
||||
|
||||
request = RequestFactory()
|
||||
request.user = auth.get_user(u1_s1)
|
||||
request.space = space_1
|
||||
|
||||
# user values apply if only those are present
|
||||
assert get_theming_values(request)['theme'] == static('themes/tandoor_dark.min.css')
|
||||
assert get_theming_values(request)['nav_bg_color'] == '#ffffff'
|
||||
assert get_theming_values(request)['nav_text_class'] == 'navbar-dark'
|
||||
assert get_theming_values(request)['sticky_nav'] == ''
|
||||
assert get_theming_values(request)['app_name'] == 'Tandoor Recipes'
|
||||
|
||||
space_1.space_theme = Space.BOOTSTRAP
|
||||
space_1.nav_bg_color = '#000000'
|
||||
space_1.nav_text_color = UserPreference.DARK
|
||||
space_1.app_name = 'test_app_name'
|
||||
space_1.save()
|
||||
|
||||
request = RequestFactory()
|
||||
request.user = auth.get_user(u1_s1)
|
||||
request.space = space_1
|
||||
|
||||
# space settings apply when set
|
||||
assert get_theming_values(request)['theme'] == static('themes/bootstrap.min.css')
|
||||
assert get_theming_values(request)['nav_bg_color'] == '#000000'
|
||||
assert get_theming_values(request)['nav_text_class'] == 'navbar-light'
|
||||
assert get_theming_values(request)['app_name'] == 'test_app_name'
|
||||
|
||||
user.userspace_set.all().delete()
|
||||
request = RequestFactory()
|
||||
request.user = auth.get_user(u1_s1)
|
||||
|
||||
# default user settings should apply when user has no space
|
||||
assert get_theming_values(request)['nav_bg_color'] == '#ffffff'
|
||||
assert get_theming_values(request)['nav_text_class'] == 'navbar-dark'
|
||||
assert get_theming_values(request)['nav_logo'] == static('assets/brand_logo.png')
|
||||
@@ -43,7 +43,7 @@ router.register(r'recipe', api.RecipeViewSet)
|
||||
router.register(r'recipe-book', api.RecipeBookViewSet)
|
||||
router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
|
||||
router.register(r'unit-conversion', api.UnitConversionViewSet)
|
||||
router.register(r'food-property-type', api.PropertyTypeViewSet)
|
||||
router.register(r'food-property-type', api.PropertyTypeViewSet) # TODO rename + regenerate
|
||||
router.register(r'food-property', api.PropertyViewSet)
|
||||
router.register(r'shopping-list', api.ShoppingListViewSet)
|
||||
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
|
||||
@@ -91,6 +91,7 @@ urlpatterns = [
|
||||
path('history/', views.history, name='view_history'),
|
||||
path('supermarket/', views.supermarket, name='view_supermarket'),
|
||||
path('ingredient-editor/', views.ingredient_editor, name='view_ingredient_editor'),
|
||||
path('property-editor/<int:pk>', views.property_editor, name='view_property_editor'),
|
||||
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
|
||||
|
||||
path('api/import/', api.import_files, name='view_import'),
|
||||
@@ -128,7 +129,7 @@ urlpatterns = [
|
||||
path('api/sync_all/', api.sync_all, name='api_sync'),
|
||||
path('api/log_cooking/<int:recipe_id>/', api.log_cooking, name='api_log_cooking'),
|
||||
path('api/plan-ical/<slug:from_date>/<slug:to_date>/', api.get_plan_ical, name='api_get_plan_ical'),
|
||||
path('api/recipe-from-source/', api.recipe_from_source, name='api_recipe_from_source'),
|
||||
path('api/recipe-from-source/', api.RecipeUrlImportView.as_view(), name='api_recipe_from_source'),
|
||||
path('api/backup/', api.get_backup, name='api_backup'),
|
||||
path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'),
|
||||
path('api/share-link/<int:pk>', api.share_link, name='api_share_link'),
|
||||
@@ -161,8 +162,7 @@ urlpatterns = [
|
||||
|
||||
path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript', )),
|
||||
name='service_worker'),
|
||||
path('manifest.json', (TemplateView.as_view(template_name="manifest.json", content_type='application/json', )),
|
||||
name='web_manifest'),
|
||||
path('manifest.json', views.web_manifest, name='web_manifest'),
|
||||
]
|
||||
|
||||
generic_models = (
|
||||
|
||||
@@ -46,7 +46,7 @@ from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
|
||||
@@ -70,12 +70,13 @@ from cookbook.helper.recipe_url_import import (clean_dict, get_from_youtube_scra
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
|
||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
|
||||
FoodInheritField, ImportLog, Ingredient, InviteLink, Keyword, MealPlan,
|
||||
MealType, Property, PropertyType, Recipe, RecipeBook, RecipeBookEntry,
|
||||
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
|
||||
Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserPreference, UserSpace, ViewLog)
|
||||
FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, Property, PropertyType, Recipe,
|
||||
RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
||||
SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
@@ -104,6 +105,7 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
|
||||
UserSerializer, UserSpaceSerializer, ViewLogSerializer)
|
||||
from cookbook.views.import_export import get_integration
|
||||
from recipes import settings
|
||||
from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT
|
||||
|
||||
|
||||
class StandardFilterMixin(ViewSetMixin):
|
||||
@@ -595,6 +597,54 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
created_by=request.user)
|
||||
return Response(content, status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@decorators.action(detail=True, methods=['POST'], )
|
||||
def fdc(self, request, pk):
|
||||
"""
|
||||
updates the food with all possible data from the FDC Api
|
||||
if properties with a fdc_id already exist they will be overridden, if existing properties don't have a fdc_id they won't be changed
|
||||
"""
|
||||
food = self.get_object()
|
||||
|
||||
response = requests.get(f'https://api.nal.usda.gov/fdc/v1/food/{food.fdc_id}?api_key={FDC_API_KEY}')
|
||||
if response.status_code == 429:
|
||||
return JsonResponse({'msg', 'API Key Rate Limit reached/exceeded, see https://api.data.gov/docs/rate-limits/ for more information. Configure your key in Tandoor using environment FDC_API_KEY variable.'}, status=429,
|
||||
json_dumps_params={'indent': 4})
|
||||
|
||||
try:
|
||||
data = json.loads(response.content)
|
||||
|
||||
food_property_list = []
|
||||
|
||||
# delete all properties where the property type has a fdc_id as these should be overridden
|
||||
for fp in food.properties.all():
|
||||
if fp.property_type.fdc_id:
|
||||
fp.delete()
|
||||
|
||||
for pt in PropertyType.objects.filter(space=request.space, fdc_id__gte=0).all():
|
||||
if pt.fdc_id:
|
||||
for fn in data['foodNutrients']:
|
||||
if fn['nutrient']['id'] == pt.fdc_id:
|
||||
food_property_list.append(Property(
|
||||
property_type_id=pt.id,
|
||||
property_amount=round(fn['amount'], 2),
|
||||
import_food_id=food.id,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
|
||||
|
||||
property_food_relation_list = []
|
||||
for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ):
|
||||
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
|
||||
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
|
||||
Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None)
|
||||
|
||||
return self.retrieve(request, pk)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'}, status=500, json_dumps_params={'indent': 4})
|
||||
|
||||
def destroy(self, *args, **kwargs):
|
||||
try:
|
||||
return (super().destroy(self, *args, **kwargs))
|
||||
@@ -649,11 +699,18 @@ class MealPlanViewSet(viewsets.ModelViewSet):
|
||||
|
||||
- **from_date**: filter from (inclusive) a certain date onward
|
||||
- **to_date**: filter upward to (inclusive) certain date
|
||||
- **meal_type**: filter meal plans based on meal_type ID
|
||||
|
||||
"""
|
||||
queryset = MealPlan.objects
|
||||
serializer_class = MealPlanSerializer
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
query_params = [
|
||||
QueryParam(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
|
||||
QueryParam(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
|
||||
QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='int'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.filter(
|
||||
@@ -668,6 +725,11 @@ class MealPlanViewSet(viewsets.ModelViewSet):
|
||||
to_date = self.request.query_params.get('to_date', None)
|
||||
if to_date is not None:
|
||||
queryset = queryset.filter(to_date__lte=to_date)
|
||||
|
||||
meal_type = self.request.query_params.getlist('meal_type', [])
|
||||
if meal_type:
|
||||
queryset = queryset.filter(meal_type__in=meal_type)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@@ -676,7 +738,7 @@ class AutoPlanViewSet(viewsets.ViewSet):
|
||||
serializer = AutoMealPlanSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
keywords = serializer.validated_data['keywords']
|
||||
keyword_ids = serializer.validated_data['keyword_ids']
|
||||
start_date = serializer.validated_data['start_date']
|
||||
end_date = serializer.validated_data['end_date']
|
||||
servings = serializer.validated_data['servings']
|
||||
@@ -691,8 +753,8 @@ class AutoPlanViewSet(viewsets.ViewSet):
|
||||
recipes = Recipe.objects.values('id', 'name')
|
||||
meal_plans = list()
|
||||
|
||||
for keyword in keywords:
|
||||
recipes = recipes.filter(keywords__name=keyword['name'])
|
||||
for keyword_id in keyword_ids:
|
||||
recipes = recipes.filter(keywords__id=keyword_id)
|
||||
|
||||
if len(recipes) == 0:
|
||||
return Response(serializer.data)
|
||||
@@ -1249,6 +1311,10 @@ class AuthTokenThrottle(AnonRateThrottle):
|
||||
rate = '10/day'
|
||||
|
||||
|
||||
class RecipeImportThrottle(UserRateThrottle):
|
||||
rate = DRF_THROTTLE_RECIPE_URL_IMPORT
|
||||
|
||||
|
||||
class CustomAuthToken(ObtainAuthToken):
|
||||
throttle_classes = [AuthTokenThrottle]
|
||||
|
||||
@@ -1274,114 +1340,114 @@ class CustomAuthToken(ObtainAuthToken):
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
# @schema(AutoSchema()) #TODO add proper schema
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
# TODO add rate limiting
|
||||
def recipe_from_source(request):
|
||||
"""
|
||||
function to retrieve a recipe from a given url or source string
|
||||
:param request: standard request with additional post parameters
|
||||
- url: url to use for importing recipe
|
||||
- data: if no url is given recipe is imported from provided source data
|
||||
- (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes
|
||||
:return: JsonResponse containing the parsed json and images
|
||||
"""
|
||||
scrape = None
|
||||
serializer = RecipeFromSourceSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
class RecipeUrlImportView(APIView):
|
||||
throttle_classes = [RecipeImportThrottle]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (
|
||||
bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
|
||||
serializer.validated_data['url'] = bookmarklet.url
|
||||
serializer.validated_data['data'] = bookmarklet.html
|
||||
bookmarklet.delete()
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
function to retrieve a recipe from a given url or source string
|
||||
:param request: standard request with additional post parameters
|
||||
- url: url to use for importing recipe
|
||||
- data: if no url is given recipe is imported from provided source data
|
||||
- (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes
|
||||
:return: JsonResponse containing the parsed json and images
|
||||
"""
|
||||
scrape = None
|
||||
serializer = RecipeFromSourceSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
|
||||
url = serializer.validated_data.get('url', None)
|
||||
data = unquote(serializer.validated_data.get('data', None))
|
||||
if not url and not data:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Nothing to do.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if (b_pk := serializer.validated_data.get('bookmarklet', None)) and (
|
||||
bookmarklet := BookmarkletImport.objects.filter(pk=b_pk).first()):
|
||||
serializer.validated_data['url'] = bookmarklet.url
|
||||
serializer.validated_data['data'] = bookmarklet.html
|
||||
bookmarklet.delete()
|
||||
|
||||
elif url and not data:
|
||||
if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
|
||||
if validators.url(url, public=True):
|
||||
return Response({
|
||||
'recipe_json': get_from_youtube_scraper(url, request),
|
||||
'recipe_images': [],
|
||||
}, status=status.HTTP_200_OK)
|
||||
if re.match(
|
||||
'^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
url):
|
||||
recipe_json = requests.get(
|
||||
url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1],
|
||||
'') + '?share=' +
|
||||
re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
|
||||
recipe_json = clean_dict(recipe_json, 'id')
|
||||
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
|
||||
if serialized_recipe.is_valid():
|
||||
recipe = serialized_recipe.save()
|
||||
if validators.url(recipe_json['image'], public=True):
|
||||
recipe.image = File(handle_image(request,
|
||||
File(io.BytesIO(requests.get(recipe_json['image']).content),
|
||||
name='image'),
|
||||
filetype=pathlib.Path(recipe_json['image']).suffix),
|
||||
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
|
||||
recipe.save()
|
||||
return Response({
|
||||
'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk}))
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
try:
|
||||
url = serializer.validated_data.get('url', None)
|
||||
data = unquote(serializer.validated_data.get('data', None))
|
||||
if not url and not data:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Nothing to do.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
elif url and not data:
|
||||
if re.match('^(https?://)?(www\\.youtube\\.com|youtu\\.be)/.+$', url):
|
||||
if validators.url(url, public=True):
|
||||
scrape = scrape_me(url_path=url, wild_mode=True)
|
||||
return Response({
|
||||
'recipe_json': get_from_youtube_scraper(url, request),
|
||||
'recipe_images': [],
|
||||
}, status=status.HTTP_200_OK)
|
||||
if re.match(
|
||||
'^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
url):
|
||||
recipe_json = requests.get(
|
||||
url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/view/recipe/[0-9]+', url)[1],
|
||||
'') + '?share=' +
|
||||
re.split('/view/recipe/[0-9]+', url)[1].replace('/', '')).json()
|
||||
recipe_json = clean_dict(recipe_json, 'id')
|
||||
serialized_recipe = RecipeExportSerializer(data=recipe_json, context={'request': request})
|
||||
if serialized_recipe.is_valid():
|
||||
recipe = serialized_recipe.save()
|
||||
if validators.url(recipe_json['image'], public=True):
|
||||
recipe.image = File(handle_image(request,
|
||||
File(io.BytesIO(requests.get(recipe_json['image']).content),
|
||||
name='image'),
|
||||
filetype=pathlib.Path(recipe_json['image']).suffix),
|
||||
name=f'{uuid.uuid4()}_{recipe.pk}{pathlib.Path(recipe_json["image"]).suffix}')
|
||||
recipe.save()
|
||||
return Response({
|
||||
'link': request.build_absolute_uri(reverse('view_recipe', args={recipe.pk}))
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
else:
|
||||
try:
|
||||
if validators.url(url, public=True):
|
||||
scrape = scrape_me(url_path=url, wild_mode=True)
|
||||
|
||||
else:
|
||||
else:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Invalid Url')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except NoSchemaFoundInWildMode:
|
||||
pass
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Invalid Url')
|
||||
'msg': _('Connection Refused.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except NoSchemaFoundInWildMode:
|
||||
except requests.exceptions.MissingSchema:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Bad URL Schema.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
try:
|
||||
data_json = json.loads(data)
|
||||
if '@context' not in data_json:
|
||||
data_json['@context'] = 'https://schema.org'
|
||||
if '@type' not in data_json:
|
||||
data_json['@type'] = 'Recipe'
|
||||
data = "<script type='application/ld+json'>" + json.dumps(data_json) + "</script>"
|
||||
except JSONDecodeError:
|
||||
pass
|
||||
except requests.exceptions.ConnectionError:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Connection Refused.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except requests.exceptions.MissingSchema:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('Bad URL Schema.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
try:
|
||||
data_json = json.loads(data)
|
||||
if '@context' not in data_json:
|
||||
data_json['@context'] = 'https://schema.org'
|
||||
if '@type' not in data_json:
|
||||
data_json['@type'] = 'Recipe'
|
||||
data = "<script type='application/ld+json'>" + json.dumps(data_json) + "</script>"
|
||||
except JSONDecodeError:
|
||||
pass
|
||||
scrape = text_scraper(text=data, url=url)
|
||||
if not url and (found_url := scrape.schema.data.get('url', None)):
|
||||
scrape = text_scraper(text=data, url=found_url)
|
||||
scrape = text_scraper(text=data, url=url)
|
||||
if not url and (found_url := scrape.schema.data.get('url', None)):
|
||||
scrape = text_scraper(text=data, url=found_url)
|
||||
|
||||
if scrape:
|
||||
return Response({
|
||||
'recipe_json': helper.get_from_scraper(scrape, request),
|
||||
'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))),
|
||||
}, status=status.HTTP_200_OK)
|
||||
if scrape:
|
||||
return Response({
|
||||
'recipe_json': helper.get_from_scraper(scrape, request),
|
||||
'recipe_images': list(dict.fromkeys(get_images_from_soup(scrape.soup, url))),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
else:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('No usable data could be found.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response({
|
||||
'error': True,
|
||||
'msg': _('No usable data could be found.')
|
||||
}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@@ -1454,7 +1520,7 @@ def import_files(request):
|
||||
"""
|
||||
limit, msg = above_space_limit(request.space)
|
||||
if limit:
|
||||
return Response({'error': msg}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({'error': True, 'msg': _('File is above space limit')}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
form = ImportForm(request.POST, request.FILES)
|
||||
if form.is_valid() and request.FILES != {}:
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from uuid import UUID
|
||||
import subprocess
|
||||
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.core.management import call_command
|
||||
from django.db import models
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -18,13 +25,15 @@ from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
|
||||
SpaceJoinForm, User, UserCreateForm, UserPreference)
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.permission_helper import (group_required, has_group_permission,
|
||||
share_link_valid, switch_user_active_space)
|
||||
from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference,
|
||||
ShareLink, Space, UserSpace, ViewLog)
|
||||
from cookbook.tables import CookLogTable, ViewLogTable
|
||||
from cookbook.templatetags.theming_tags import get_theming_values
|
||||
from cookbook.version_info import VERSION_INFO
|
||||
from recipes.settings import PLUGINS
|
||||
from recipes.settings import PLUGINS, BASE_DIR
|
||||
|
||||
|
||||
def index(request):
|
||||
@@ -70,6 +79,11 @@ def space_overview(request):
|
||||
messages.add_message(request, messages.WARNING, _('This feature is not available in the demo version!'))
|
||||
else:
|
||||
if create_form.is_valid():
|
||||
if Space.objects.filter(created_by=request.user).count() >= request.user.userpreference.max_owned_spaces:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You have the reached the maximum amount of spaces that can be owned by you.') + f' ({request.user.userpreference.max_owned_spaces})')
|
||||
return HttpResponseRedirect(reverse('view_space_overview'))
|
||||
|
||||
created_space = Space.objects.create(
|
||||
name=create_form.cleaned_data['name'],
|
||||
created_by=request.user,
|
||||
@@ -204,6 +218,11 @@ def ingredient_editor(request):
|
||||
return render(request, 'ingredient_editor.html', template_vars)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def property_editor(request, pk):
|
||||
return render(request, 'property_editor.html', {'recipe_id': pk})
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
def shopping_settings(request):
|
||||
if request.space.demo:
|
||||
@@ -220,10 +239,10 @@ def shopping_settings(request):
|
||||
if not sp:
|
||||
sp = SearchPreferenceForm(user=request.user)
|
||||
fields_searched = (
|
||||
len(search_form.cleaned_data['icontains'])
|
||||
+ len(search_form.cleaned_data['istartswith'])
|
||||
+ len(search_form.cleaned_data['trigram'])
|
||||
+ len(search_form.cleaned_data['fulltext'])
|
||||
len(search_form.cleaned_data['icontains'])
|
||||
+ len(search_form.cleaned_data['istartswith'])
|
||||
+ len(search_form.cleaned_data['trigram'])
|
||||
+ len(search_form.cleaned_data['fulltext'])
|
||||
)
|
||||
if search_form.cleaned_data['preset'] == 'fuzzy':
|
||||
sp.search = SearchPreference.SIMPLE
|
||||
@@ -309,17 +328,75 @@ def system(request):
|
||||
if not request.user.is_superuser:
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
postgres_ver = None
|
||||
postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
|
||||
|
||||
if postgres:
|
||||
postgres_current = 16 # will need to be updated as PostgreSQL releases new major versions
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import connection
|
||||
|
||||
postgres_ver = Decimal(str(connection.pg_version).replace('00', '.'))
|
||||
if postgres_ver >= postgres_current:
|
||||
database_status = 'success'
|
||||
database_message = _('Everything is fine!')
|
||||
elif postgres_ver < postgres_current - 2:
|
||||
database_status = 'danger'
|
||||
database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {
|
||||
'v': postgres_ver}
|
||||
else:
|
||||
database_status = 'info'
|
||||
database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {
|
||||
'v1': postgres_ver, 'v2': postgres_current}
|
||||
else:
|
||||
database_status = 'info'
|
||||
database_message = _(
|
||||
'This application is not running with a Postgres database backend. This is ok but not recommended as some features only work with postgres databases.')
|
||||
|
||||
secret_key = False if os.getenv('SECRET_KEY') else True
|
||||
|
||||
if request.method == "POST":
|
||||
del_orphans = request.POST.get('delete_orphans')
|
||||
orphans = get_orphan_files(delete_orphans=str2bool(del_orphans))
|
||||
else:
|
||||
orphans = get_orphan_files()
|
||||
|
||||
out = StringIO()
|
||||
call_command('showmigrations', stdout=out)
|
||||
missing_migration = False
|
||||
migration_info = {}
|
||||
current_app = None
|
||||
for row in out.getvalue().splitlines():
|
||||
if '[ ]' in row and current_app:
|
||||
migration_info[current_app]['unapplied_migrations'].append(row.replace('[ ]', ''))
|
||||
missing_migration = True
|
||||
elif '[X]' in row and current_app:
|
||||
migration_info[current_app]['applied_migrations'].append(row.replace('[x]', ''))
|
||||
elif '(no migrations)' in row and current_app:
|
||||
pass
|
||||
else:
|
||||
current_app = row
|
||||
migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [],
|
||||
'total': 0}
|
||||
|
||||
for key in migration_info.keys():
|
||||
migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(
|
||||
migration_info[key]['applied_migrations'])
|
||||
|
||||
return render(request, 'system.html', {
|
||||
'gunicorn_media': settings.GUNICORN_MEDIA,
|
||||
'debug': settings.DEBUG,
|
||||
'postgres': postgres,
|
||||
'postgres_version': postgres_ver,
|
||||
'postgres_status': database_status,
|
||||
'postgres_message': database_message,
|
||||
'version_info': VERSION_INFO,
|
||||
'plugins': PLUGINS,
|
||||
'secret_key': secret_key
|
||||
'secret_key': secret_key,
|
||||
'orphans': orphans,
|
||||
'migration_info': migration_info,
|
||||
'missing_migration': missing_migration,
|
||||
})
|
||||
|
||||
|
||||
@@ -367,7 +444,8 @@ def invite_link(request, token):
|
||||
link.used_by = request.user
|
||||
link.save()
|
||||
|
||||
user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False)
|
||||
user_space = UserSpace.objects.create(user=request.user, space=link.space,
|
||||
internal_note=link.internal_note, invite_link=link, active=False)
|
||||
|
||||
if request.user.userspace_set.count() == 1:
|
||||
user_space.active = True
|
||||
@@ -408,6 +486,60 @@ def report_share_abuse(request, token):
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
||||
def web_manifest(request):
|
||||
theme_values = get_theming_values(request)
|
||||
|
||||
icons = [
|
||||
{"src": theme_values['logo_color_svg'], "sizes": "any"},
|
||||
{"src": theme_values['logo_color_144'], "type": "image/png", "sizes": "144x144"},
|
||||
{"src": theme_values['logo_color_512'], "type": "image/png", "sizes": "512x512"}
|
||||
]
|
||||
|
||||
manifest_info = {
|
||||
"name": theme_values['app_name'],
|
||||
"short_name": theme_values['app_name'],
|
||||
"description": _("Manage recipes, shopping list, meal plans and more."),
|
||||
"icons": icons,
|
||||
"start_url": "./search",
|
||||
"background_color": theme_values['nav_bg_color'],
|
||||
"display": "standalone",
|
||||
"scope": ".",
|
||||
"theme_color": theme_values['nav_bg_color'],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": _("Plan"),
|
||||
"short_name": _("Plan"),
|
||||
"description": _("View your meal Plan"),
|
||||
"url": "./plan"
|
||||
},
|
||||
{
|
||||
"name": _("Books"),
|
||||
"short_name": _("Books"),
|
||||
"description": _("View your cookbooks"),
|
||||
"url": "./books"
|
||||
},
|
||||
{
|
||||
"name": _("Shopping"),
|
||||
"short_name": _("Shopping"),
|
||||
"description": _("View your shopping lists"),
|
||||
"url": "./list/shopping-list/"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/data/import/url",
|
||||
"method": "GET",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"url": "url",
|
||||
"text": "text"
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return JsonResponse(manifest_info, json_dumps_params={'indent': 4})
|
||||
|
||||
|
||||
def markdown_info(request):
|
||||
return render(request, 'markdown_info.html', {})
|
||||
|
||||
@@ -443,3 +575,48 @@ def test(request):
|
||||
def test2(request):
|
||||
if not settings.DEBUG:
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
||||
def get_orphan_files(delete_orphans=False):
|
||||
# Get list of all image files in media folder
|
||||
media_dir = settings.MEDIA_ROOT
|
||||
|
||||
def find_orphans():
|
||||
image_files = []
|
||||
for root, dirs, files in os.walk(media_dir):
|
||||
for file in files:
|
||||
|
||||
if not file.lower().endswith(('.db')) and not root.lower().endswith(('@eadir')):
|
||||
full_path = os.path.join(root, file)
|
||||
relative_path = os.path.relpath(full_path, media_dir)
|
||||
image_files.append((relative_path, full_path))
|
||||
|
||||
# Get list of all image fields in models
|
||||
image_fields = []
|
||||
for model in apps.get_models():
|
||||
for field in model._meta.get_fields():
|
||||
if isinstance(field, models.ImageField) or isinstance(field, models.FileField):
|
||||
image_fields.append((model, field.name))
|
||||
|
||||
# get all images in the database
|
||||
# TODO I don't know why, but this completely bypasses scope limitations
|
||||
image_paths = []
|
||||
for model, field in image_fields:
|
||||
image_field_paths = model.objects.values_list(field, flat=True)
|
||||
image_paths.extend(image_field_paths)
|
||||
|
||||
# Check each image file against model image fields
|
||||
return [img for img in image_files if img[0] not in image_paths]
|
||||
|
||||
orphans = find_orphans()
|
||||
if delete_orphans:
|
||||
for f in [img[1] for img in orphans]:
|
||||
try:
|
||||
os.remove(f)
|
||||
except FileNotFoundError:
|
||||
print(f"File not found: {f}")
|
||||
except Exception as e:
|
||||
print(f"Error deleting file {f}: {e}")
|
||||
orphans = find_orphans()
|
||||
|
||||
return [img[1] for img in orphans]
|
||||
|
||||
65
docs/faq.md
@@ -1,5 +1,5 @@
|
||||
There are several questions and issues that come up from time to time, here are some answers:
|
||||
please note that the existence of some questions is due the application not being perfect in some parts.
|
||||
please note that the existence of some questions is due the application not being perfect in some parts.
|
||||
Many of those shortcomings are planned to be fixed in future release but simply could not be addressed yet due to time limits.
|
||||
|
||||
## Is there a Tandoor app?
|
||||
@@ -15,7 +15,7 @@ Open Tandoor, click the `add Tandoor to the home screen` message that pops up at
|
||||
|
||||
### Desktop browsers
|
||||
|
||||
#### Google Chrome
|
||||
#### Google Chrome
|
||||
Open Tandoor, open the menu behind the three vertical dots at the top right, select `Install Tandoor Recipes...`
|
||||
|
||||
#### Microsoft Edge
|
||||
@@ -32,6 +32,17 @@ If you just set up your Tandoor instance and you're having issues like;
|
||||
then make sure you have set [all required headers](install/docker.md#required-headers) in your reverse proxy correctly.
|
||||
If that doesn't fix it, you can also refer to the appropriate sub section in the [reverse proxy documentation](install/docker.md#reverse-proxy) and verify your general webserver configuration.
|
||||
|
||||
### Required Headers
|
||||
Navigate to `/system` and review the headers listed in the DEBUG section. At a minimum, if you are using a reverse proxy the headers must match the below conditions.
|
||||
|
||||
| Header | Requirement |
|
||||
| :--- | :---- |
|
||||
| HTTP_HOST:mydomain.tld | The host domain must match the url that you are using to open Tandoor. |
|
||||
| HTTP_X_FORWARDED_HOST:mydomain.tld | The host domain must match the url that you are using to open Tandoor. |
|
||||
| HTTP_X_FORWARDED_PROTO:http(s) | The protocol must match the url you are using to open Tandoor. There must be exactly one protocol listed. |
|
||||
| HTTP_X_SCRIPT_NAME:/subfolder | If you are hosting Tandoor at a subfolder instead of a subdomain this header must exist. |
|
||||
|
||||
|
||||
## Why am I getting CSRF Errors?
|
||||
If you are getting CSRF Errors this is most likely due to a reverse proxy not passing the correct headers.
|
||||
|
||||
@@ -41,19 +52,22 @@ If you are using a plain ngix you might need `proxy_set_header Host $http_host;`
|
||||
Further discussions can be found in this [Issue #518](https://github.com/vabene1111/recipes/issues/518)
|
||||
|
||||
## Why are images not loading?
|
||||
If images are not loading this might be related to the same issue as the CSRF errors (see above).
|
||||
If images are not loading this might be related to the same issue as the CSRF errors (see above).
|
||||
A discussion about that can be found at [Issue #452](https://github.com/vabene1111/recipes/issues/452)
|
||||
|
||||
The other common issue is that the recommended nginx container is removed from the deployment stack.
|
||||
If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or
|
||||
The other common issue is that the recommended nginx container is removed from the deployment stack.
|
||||
If removed, the nginx webserver needs to be replaced by something else that servers the /mediafiles/ directory or
|
||||
`GUNICORN_MEDIA` needs to be enabled to allow media serving by the application container itself.
|
||||
|
||||
## Why am I getting an error stating database files are incompatible with server?
|
||||
Your version of Postgres has been upgraded. See [Updating PostgreSQL](https://docs.tandoor.dev/system/updating/#postgresql)
|
||||
|
||||
|
||||
## Why does the Text/Markdown preview look different than the final recipe?
|
||||
|
||||
Tandoor has always rendered the recipe instructions markdown on the server. This also allows tandoor to implement things like ingredient templating and scaling in text.
|
||||
To make editing easier a markdown editor was added to the frontend with integrated preview as a temporary solution. Since the markdown editor uses a different
|
||||
specification than the server the preview is different to the final result. It is planned to improve this in the future.
|
||||
To make editing easier a markdown editor was added to the frontend with integrated preview as a temporary solution. Since the markdown editor uses a different
|
||||
specification than the server the preview is different to the final result. It is planned to improve this in the future.
|
||||
|
||||
The markdown renderer follows this markdown specification https://daringfireball.net/projects/markdown/
|
||||
|
||||
@@ -66,18 +80,18 @@ To create a new user click on your name (top right corner) and select 'space set
|
||||
|
||||
It is not possible to create users through the admin because users must be assigned a default group and space.
|
||||
|
||||
To change a user's space you need to go to the admin and select User Infos.
|
||||
To change a user's space you need to go to the admin and select User Infos.
|
||||
|
||||
If you use an external auth provider or proxy authentication make sure to specify a default group and space in the
|
||||
If you use an external auth provider or proxy authentication make sure to specify a default group and space in the
|
||||
environment configuration.
|
||||
|
||||
## What are spaces?
|
||||
Spaces are is a type of feature used to separate one installation of Tandoor into several parts.
|
||||
Spaces are is a type of feature used to separate one installation of Tandoor into several parts.
|
||||
In technical terms it is a multi-tenant system.
|
||||
|
||||
You can compare a space to something like google drive or dropbox.
|
||||
You can compare a space to something like google drive or dropbox.
|
||||
There is only one installation of the Dropbox system, but it handles multiple users without them noticing each other.
|
||||
For Tandoor that means all people that work together on one recipe collection can be in one space.
|
||||
For Tandoor that means all people that work together on one recipe collection can be in one space.
|
||||
If you want to host the collection of your friends, family, or neighbor you can create a separate space for them (through the admin interface).
|
||||
|
||||
Sharing between spaces is currently not possible but is planned for future releases.
|
||||
@@ -90,7 +104,7 @@ To reset a lost password if access to the container is lost you need to:
|
||||
3. run `python manage.py changepassword <username>` and follow the steps shown.
|
||||
|
||||
## How can I add an admin user?
|
||||
To create a superuser you need to
|
||||
To create a superuser you need to
|
||||
|
||||
1. execute into the container using `docker-compose exec web_recipes sh`
|
||||
2. activate the virtual environment `source venv/bin/activate`
|
||||
@@ -98,11 +112,26 @@ To create a superuser you need to
|
||||
|
||||
|
||||
## Why cant I get support for my manual setup?
|
||||
Even tough I would love to help everyone get tandoor up and running I have only so much time
|
||||
that I can spend on this project besides work, family and other life things.
|
||||
Due to the countless problems that can occur when manually installing I simply do not have
|
||||
the time to help solving each one.
|
||||
Even tough I would love to help everyone get tandoor up and running I have only so much time
|
||||
that I can spend on this project besides work, family and other life things.
|
||||
Due to the countless problems that can occur when manually installing I simply do not have
|
||||
the time to help solving each one.
|
||||
|
||||
You can install Tandoor manually but please do not expect me or anyone to help you with that.
|
||||
As a general advice: If you do it manually do NOT change anything at first and slowly work yourself
|
||||
to your dream setup.
|
||||
to your dream setup.
|
||||
|
||||
## How can I upgrade postgres (major versions)?
|
||||
Postgres requires manual intervention when updating from one major version to another. The steps are roughly
|
||||
|
||||
1. use `pg_dumpall` to dump your database into SQL (for Docker `docker-compose exec -T <postgres_container_name> pg_dumpall -U <postgres_user_name> -f /path/to/dump.sql`)
|
||||
2. stop the DB / down the container
|
||||
3. move your postgres directory in order to keep it as a backup (e.g. `mv postgres postgres_old`)
|
||||
4. update postgres to the new major version (for Docker just change the version number and pull)
|
||||
5. start the db / up the container (do not start tandoor as it will automatically perform the database migrations which will conflict with loading the dump)
|
||||
6. if not using docker, you might need to create the same postgres user you had in the old database
|
||||
7. load the postgres dump (for Docker `'/usr/local/bin/docker-compose exec -T <postgres_container_name> psql -U <postgres_user_name> <postgres_database_name> < /path/to/dump.sql`)
|
||||
|
||||
If anything fails, go back to the old postgres version and data directory and try again.
|
||||
|
||||
There are many articles and tools online that might provide a good starting point to help you upgrade [1](https://thomasbandt.com/postgres-docker-major-version-upgrade), [2](https://github.com/tianon/docker-postgres-upgrade), [3](https://github.com/vabene1111/DockerPostgresBackups).
|
||||
|
||||
@@ -6,6 +6,12 @@ It is possible to install this application using many different Docker configura
|
||||
|
||||
Please read the instructions on each example carefully and decide if this is the way for you.
|
||||
|
||||
## **DockSTARTer**
|
||||
|
||||
The main goal of [DockSTARTer](https://dockstarter.com/) is to make it quick and easy to get up and running with Docker.
|
||||
You may choose to rely on DockSTARTer for various changes to your Docker system or use DockSTARTer as a stepping stone and learn to do more advanced configurations.
|
||||
Follow the guide for installing DockSTARTer and then run `ds` then select 'Configuration' and 'Select Apps' to get Tandoor up and running quickly and easily.
|
||||
|
||||
## **Docker**
|
||||
|
||||
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `8080`.
|
||||
@@ -110,7 +116,7 @@ in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs
|
||||
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
|
||||
|
||||
!!! warning "Adjust client_max_body_size"
|
||||
By using jwilder's Nginx-proxy, uploads will be restricted to 1 MB file size. This can be resolved by adjusting the ```client_max_body_size``` variable in the jwilder nginx configuration.
|
||||
By using jwilder's Nginx-proxy, uploads will be restricted to 1 MB file size. This can be resolved by adjusting the ```client_max_body_size``` variable in the jwilder nginx configuration.
|
||||
|
||||
Remember to add the appropriate environment variables to the `.env` file:
|
||||
|
||||
@@ -343,10 +349,9 @@ ProxyPassReverse / http://localhost:8080/ # replace port
|
||||
!!!info
|
||||
Always wait at least 2-3 minutes after the very first start, since migrations will take some time!
|
||||
|
||||
!!!warning
|
||||
If you want to use Tandoor on a Raspberry Pi running a 32-bit operating system you will need to use the following
|
||||
docker image tags: `latest-raspi`, `beta-raspi` and the versioned `<x.y.z>-raspi`
|
||||
We strongly recommend using the new 64-bit Raspian image as the 32-bit version is not tested.
|
||||
!!!info
|
||||
In the past there was a special `*-raspi` version of the image. This no longer exists. The normal Tags all support Arm/v7 architectures which should work on all Raspberry Pi's above Version 1 and the first generation Zero.
|
||||
See [Wikipedia Raspberry Pi specifications](https://en.wikipedia.org/wiki/Raspberry_Pi#Specifications).
|
||||
|
||||
If you're having issues with installing Tandoor on your Raspberry Pi or similar device,
|
||||
follow these instructions:
|
||||
@@ -360,11 +365,11 @@ follow these instructions:
|
||||
### Sub Path nginx config
|
||||
|
||||
If hosting under a sub-path you might want to change the default nginx config (which gets mounted through the named volume from the application container into the nginx container)
|
||||
with the following config.
|
||||
with the following config.
|
||||
|
||||
```nginx
|
||||
location /my_app { # change to subfolder name
|
||||
include /config/nginx/proxy.conf;
|
||||
include /config/nginx/proxy.conf;
|
||||
proxy_pass https://mywebapp.com/; # change to your host name:port
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -71,12 +71,13 @@ Basic guide to setup Docker and Portainer TrueNAS Core.
|
||||
-Select "Get Started" to use the Enviroment Portainer is running in
|
||||

|
||||
|
||||
### 3. Install Tandoor Recipies VIA Portainer Web Editor
|
||||
### 3. Install Tandoor Recipes VIA Portainer Web Editor
|
||||
-From the menu select Stacks, click Add stack, give the stack a descriptive name then select Web editor.
|
||||

|
||||
-Use the below code and input it into the Web Editor:
|
||||
|
||||
`version: "3"
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
db_recipes:
|
||||
restart: always
|
||||
@@ -87,13 +88,12 @@ services:
|
||||
- stack.env
|
||||
|
||||
web_recipes:
|
||||
# image: vabene1111/recipes:latest
|
||||
image: vabene1111/recipes:beta
|
||||
image: vabene1111/recipes:latest
|
||||
env_file:
|
||||
- stack.env
|
||||
volumes:
|
||||
- staticfiles:/opt/recipes/staticfiles
|
||||
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
|
||||
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
@@ -116,7 +116,8 @@ services:
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:`
|
||||
staticfiles:
|
||||
```
|
||||
|
||||
-Download the .env template from [HERE](https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template) and load this file by pressing the "Load Variables from .env File" button:
|
||||

|
||||
|
||||
@@ -8,7 +8,7 @@ downloaded and restored through the web interface.
|
||||
When developing a new backup strategy, make sure to also test the restore process!
|
||||
|
||||
## Database
|
||||
Please use any standard way of backing up your database. For most systems this can be achieved by using a dump
|
||||
Please use any standard way of backing up your database. For most systems this can be achieved by using a dump
|
||||
command that will create an SQL file with all the required data.
|
||||
|
||||
Please refer to your Database System documentation.
|
||||
@@ -18,7 +18,7 @@ It is **neither** well tested nor documented so use at your own risk.
|
||||
I would recommend using it only as a starting place for your own backup strategy.
|
||||
|
||||
## Mediafiles
|
||||
The only Data this application stores apart from the database are the media files (e.g. images) used in your
|
||||
The only Data this application stores apart from the database are the media files (e.g. images) used in your
|
||||
recipes.
|
||||
|
||||
They can be found in the mediafiles mounted directory (depending on your installation).
|
||||
@@ -56,3 +56,23 @@ You can now export recipes from Tandoor using the export function. This method r
|
||||
Import:
|
||||
Go to Import > from app > tandoor and select the zip file you want to import from.
|
||||
|
||||
## Backing up using the pgbackup container
|
||||
You can add [pgbackup](https://hub.docker.com/r/prodrigestivill/postgres-backup-local) to manage the scheduling and automatic backup of your postgres database.
|
||||
Modify the below to match your environment and add it to your `docker-compose.yml`
|
||||
|
||||
``` yaml
|
||||
pgbackup:
|
||||
container_name: pgbackup
|
||||
environment:
|
||||
BACKUP_KEEP_DAYS: "8"
|
||||
BACKUP_KEEP_MONTHS: "6"
|
||||
BACKUP_KEEP_WEEKS: "4"
|
||||
POSTGRES_EXTRA_OPTS: -Z6 --schema=public --blobs
|
||||
SCHEDULE: '@daily'
|
||||
# Note: the tag must match the version of postgres you are using
|
||||
image: prodrigestivill/postgres-backup-local:15
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- backups/postgres:/backups
|
||||
```
|
||||
You can manually initiate a backup by running `docker exec -it pgbackup ./backup.sh`
|
||||
|
||||
611
docs/system/configuration.md
Normal file
@@ -0,0 +1,611 @@
|
||||
This page describes all configuration options for the application
|
||||
server. All settings must be configured in the environment of the
|
||||
application server, usually by adding them to the `.env` file.
|
||||
|
||||
## Required Settings
|
||||
|
||||
The following settings need to be set appropriately for your installation.
|
||||
They are included in the default `env.template`.
|
||||
|
||||
### Secret Key
|
||||
|
||||
Random secret key (at least 50 characters), use for example `base64 /dev/urandom | head -c50` to generate one.
|
||||
It is used internally by django for various signing/cryptographic operations and **should be kept secret**.
|
||||
See [Django Docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-SECRET_KEY)
|
||||
|
||||
```
|
||||
SECRET_KEY=#$tp%v6*(*ba01wcz(ip(i5vfz8z$f%qdio&q@anr1#$=%(m4c
|
||||
```
|
||||
|
||||
Alternatively you can point to a file containing just the secret key value. If using containers make sure the file is
|
||||
persistent and available inside the container.
|
||||
|
||||
```
|
||||
SECRET_KEY_FILE=/path/to/file.txt
|
||||
|
||||
// contents of file
|
||||
#$tp%v6*(*ba01wcz(ip(i5vfz8z$f%qdio&q@anr1#$=%(m4c
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
Multiple parameters are required to configure the database.
|
||||
|
||||
| Var | Options | Description |
|
||||
|-------------------|--------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| DB_ENGINE | django.db.backends.postgresql (default) django.db.backends.sqlite3 | Type of database connection. Production should always use postgresql. |
|
||||
| POSTGRES_HOST | any | Used to connect to database server. Use container name in docker setup. |
|
||||
| POSTGRES_DB | any | Name of database. |
|
||||
| POSTGRES_PORT | 1-65535 | Port of database, Postgresql default `5432` |
|
||||
| POSTGRES_USER | any | Username for database connection. |
|
||||
| POSTGRES_PASSWORD | any | Password for database connection. |
|
||||
|
||||
#### Password file
|
||||
|
||||
> default `None` - options: file path
|
||||
|
||||
Path to file containing the database password. Overrides `POSTGRES_PASSWORD`. Only applied when using Docker (or other
|
||||
setups running `boot.sh`)
|
||||
|
||||
```
|
||||
POSTGRES_PASSWORD_FILE=
|
||||
```
|
||||
|
||||
#### Connection String
|
||||
|
||||
> default `None` - options: according to database specifications
|
||||
|
||||
Instead of configuring the connection using multiple individual environment parameters, you can use a connection string.
|
||||
The connection string will override all other database settings.
|
||||
|
||||
```
|
||||
DATABASE_URL = engine://username:password@host:port/dbname
|
||||
```
|
||||
|
||||
#### Connection Options
|
||||
|
||||
> default `{}` - options: according to database specifications
|
||||
|
||||
Additional connection options can be set as shown in the example below.
|
||||
|
||||
```
|
||||
DB_OPTIONS={"sslmode":"require"}
|
||||
```
|
||||
|
||||
## Optional Settings
|
||||
|
||||
All optional settings are, as their name says, optional and can be ignored safely. If you want to know more about what
|
||||
you can do with them take a look through this page. I recommend using the categories to guide yourself.
|
||||
|
||||
### Server configuration
|
||||
|
||||
Configuration options for serving related services.
|
||||
|
||||
#### Port
|
||||
|
||||
> default `8080` - options: `1-65535`
|
||||
|
||||
Port for gunicorn to bind to. Should not be changed if using docker stack with reverse proxy.
|
||||
|
||||
```
|
||||
TANDOOR_PORT=8080
|
||||
```
|
||||
|
||||
#### Allowed Hosts
|
||||
|
||||
> default `*` - options: `recipes.mydomain.com,cooking.mydomain.com,...` (comma seperated domain/ip list)
|
||||
|
||||
Security setting to prevent HTTP Host Header Attacks,
|
||||
see [Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#allowed-hosts).
|
||||
Many reverse proxies handle this and require the setting to be `*` (default).
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS=recipes.mydomain.com
|
||||
```
|
||||
|
||||
#### URL Path
|
||||
|
||||
> default `None` - options: `/custom/url/base/path`
|
||||
|
||||
If base URL is something other than just / (you are serving a subfolder in your proxy for
|
||||
instance http://recipe_app/recipes/)
|
||||
Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
|
||||
|
||||
```
|
||||
SCRIPT_NAME=/recipes
|
||||
```
|
||||
|
||||
#### Static URL
|
||||
|
||||
> default `/static/` - options: `/any/url/path/`, `https://any.domain.name/and/url/path`
|
||||
|
||||
If staticfiles are stored or served from a different location uncomment and change accordingly.
|
||||
This can either be a relative path from the applications base path or the url of an external host.
|
||||
|
||||
!!! info
|
||||
- MUST END IN `/`
|
||||
- This is not required if you are just using a subfolder
|
||||
|
||||
```
|
||||
STATIC_URL=/static/
|
||||
```
|
||||
|
||||
#### Media URL
|
||||
|
||||
> default `/static/` - options: `/any/url/path/`, `https://any.domain.name/and/url/path`
|
||||
|
||||
If mediafiles are stored at a different location uncomment and change accordingly.
|
||||
This can either be a relative path from the applications base path or the url of an external host
|
||||
|
||||
!!! info
|
||||
- MUST END IN `/`
|
||||
- This is **not required** if you are just using a subfolder
|
||||
- This is **not required** if using S3/object storage
|
||||
|
||||
```
|
||||
MEDIA_URL=/media/
|
||||
```
|
||||
|
||||
#### Gunicorn Workers
|
||||
|
||||
> default `3` - options `1-X`
|
||||
|
||||
Set the number of gunicorn workers to start when starting using `boot.sh` (all container installations).
|
||||
The default is likely appropriate for most installations.
|
||||
See [Gunicorn docs](https://docs.gunicorn.org/en/stable/design.html#how-many-workers) for recommended settings.
|
||||
|
||||
```
|
||||
GUNICORN_WORKERS=3
|
||||
```
|
||||
|
||||
#### Gunicorn Threads
|
||||
|
||||
> default `2` - options `1-X`
|
||||
|
||||
Set the number of gunicorn threads to start when starting using `boot.sh` (all container installations).
|
||||
The default is likely appropriate for most installations.
|
||||
See [Gunicorn docs](https://docs.gunicorn.org/en/stable/design.html#how-many-workers) for recommended settings.
|
||||
|
||||
```
|
||||
GUNICORN_THREADS=2
|
||||
```
|
||||
|
||||
#### Gunicorn Media
|
||||
|
||||
> default `0` - options `0`, `1`
|
||||
|
||||
Serve media files directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
|
||||
provided that include an additional nxginx container to handle media file serving.
|
||||
If you know what you are doing turn this on (`1`) to serve media files using djangos serve() method.
|
||||
|
||||
```
|
||||
GUNICORN_MEDIA=0
|
||||
```
|
||||
|
||||
#### CSRF Trusted Origins
|
||||
|
||||
> default `[]` - options: [list,of,trusted,origins]
|
||||
|
||||
Allows setting origins to allow for unsafe requests.
|
||||
See [Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#csrf-trusted-origins)
|
||||
|
||||
```
|
||||
CSRF_TRUSTED_ORIGINS = []
|
||||
```
|
||||
|
||||
#### Cors origins
|
||||
|
||||
> default `False` - options: `False`, `True`
|
||||
|
||||
By default, cross-origin resource sharing is disabled. Enabling this will allow access to your resources from other
|
||||
domains.
|
||||
Please read [the docs](https://github.com/adamchainz/django-cors-headers) carefully before enabling this.
|
||||
|
||||
```
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
```
|
||||
|
||||
#### Session Cookies
|
||||
|
||||
Django session cookie settings. Can be changed to allow a single django application to authenticate several applications
|
||||
when running under the same database.
|
||||
|
||||
```
|
||||
SESSION_COOKIE_DOMAIN=.example.com
|
||||
SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
Some features can be enabled/disabled on a server level because they might change the user experience significantly,
|
||||
they might be unstable/beta or they have performance/security implications.
|
||||
|
||||
#### Captcha
|
||||
|
||||
If you allow signing up to your instance you might want to use a captcha to prevent spam.
|
||||
Tandoor supports HCAPTCHA which is supposed to be a privacy-friendly captcha provider.
|
||||
See [HCAPTCHA website](https://www.hcaptcha.com/) for more information and to acquire your sitekey and secret.
|
||||
|
||||
```
|
||||
HCAPTCHA_SITEKEY=
|
||||
HCAPTCHA_SECRET=
|
||||
```
|
||||
|
||||
#### Metrics
|
||||
|
||||
Enable serving of prometheus metrics under the `/metrics` path
|
||||
|
||||
!!! danger
|
||||
The view is not secured (as per the prometheus default way) so make sure to secure it
|
||||
through your web server.
|
||||
|
||||
```
|
||||
ENABLE_METRICS=0
|
||||
```
|
||||
|
||||
#### Tree Sorting
|
||||
|
||||
> default `0` - options `0`, `1`
|
||||
|
||||
By default SORT_TREE_BY_NAME is disabled this will store all Keywords and Food in the order they are created.
|
||||
Enabling this setting makes saving new keywords and foods very slow, which doesn't matter in most usecases.
|
||||
However, when doing large imports of recipes that will create new objects, can increase total run time by 10-15x
|
||||
Keywords and Food can be manually sorted by name in Admin
|
||||
This value can also be temporarily changed in Admin, it will revert the next time the application is started
|
||||
|
||||
!!! info
|
||||
Disabling tree sorting is a temporary fix, in the future we might find a better implementation to allow tree sorting
|
||||
without the large performance impacts.
|
||||
|
||||
```
|
||||
SORT_TREE_BY_NAME=0
|
||||
```
|
||||
|
||||
#### PDF Export
|
||||
|
||||
> default `0` - options `0`, `1`
|
||||
|
||||
Exporting PDF's is a community contributed feature to export recipes as PDF files. This requires the server to download
|
||||
a chromium binary and is generally implemented only rudimentary and somewhat slow depending on your server device.
|
||||
|
||||
See [Export feature docs](https://docs.tandoor.dev/features/import_export/#pdf) for additional information.
|
||||
|
||||
```
|
||||
ENABLE_PDF_EXPORT=1
|
||||
```
|
||||
|
||||
#### Legal URLS
|
||||
|
||||
Depending on your jurisdiction you might need to provide any of the following URLs for your instance.
|
||||
|
||||
```
|
||||
TERMS_URL=
|
||||
PRIVACY_URL=
|
||||
IMPRINT_URL=
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
All configurable variables regarding authentication.
|
||||
Please also visit the [dedicated docs page](https://docs.tandoor.dev/features/authentication/) for more information.
|
||||
|
||||
#### Default Permissions
|
||||
|
||||
Configures if a newly created user (from social auth or public signup) should automatically join into the given space and
|
||||
default group.
|
||||
|
||||
This setting is targeted at private, single space instances that typically have a custom authentication system managing
|
||||
access to the data.
|
||||
|
||||
!!! danger
|
||||
With public signup enabled this will give everyone access to the data in the given space
|
||||
|
||||
!!! warning
|
||||
This feature might be deprecated in favor of a space join and public viewing system in the future
|
||||
|
||||
> default `0` (disabled) - options `0`, `1-X` (space id)
|
||||
|
||||
When enabled will join user into space and apply group configured in `SOCIAL_DEFAULT_GROUP`.
|
||||
|
||||
```
|
||||
SOCIAL_DEFAULT_ACCESS = 1
|
||||
```
|
||||
|
||||
> default `guest` - options `guest`, `user`, `admin`
|
||||
|
||||
```
|
||||
SOCIAL_DEFAULT_GROUP=guest
|
||||
```
|
||||
|
||||
#### Enable Signup
|
||||
|
||||
> default `0` - options `0`, `1`
|
||||
|
||||
Allow everyone to create local accounts on your application instance (without an invite link)
|
||||
You might want to setup HCAPTCHA to prevent bots from creating accounts/spam.
|
||||
|
||||
!!! info
|
||||
Social accounts will always be able to sign up, if providers are configured
|
||||
|
||||
```
|
||||
ENABLE_SIGNUP=0
|
||||
```
|
||||
|
||||
#### Social Auth
|
||||
|
||||
Allows you to set up external OAuth providers.
|
||||
|
||||
```
|
||||
SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount.providers.nextcloud,
|
||||
```
|
||||
|
||||
#### Remote User Auth
|
||||
> default `0` - options `0`, `1`
|
||||
|
||||
Allow authentication via the REMOTE-USER header (can be used for e.g. authelia).
|
||||
|
||||
!!! danger
|
||||
Leave off if you don't know what you are doing! Enabling this without proper configuration will enable anybody
|
||||
to login with any username!
|
||||
|
||||
```
|
||||
REMOTE_USER_AUTH=0
|
||||
```
|
||||
|
||||
#### LDAP
|
||||
|
||||
LDAP based authentication is disabled by default. You can enable it by setting `LDAP_AUTH` to `1` and configuring the
|
||||
other
|
||||
settings accordingly. Please remove/comment settings you do not need for your setup.
|
||||
|
||||
```
|
||||
LDAP_AUTH=
|
||||
AUTH_LDAP_SERVER_URI=
|
||||
AUTH_LDAP_BIND_DN=
|
||||
AUTH_LDAP_BIND_PASSWORD=
|
||||
AUTH_LDAP_USER_SEARCH_BASE_DN=
|
||||
AUTH_LDAP_TLS_CACERTFILE=
|
||||
AUTH_LDAP_START_TLS=
|
||||
```
|
||||
|
||||
### External Services
|
||||
|
||||
#### Email
|
||||
|
||||
Email Settings, see [Django docs](https://docs.djangoproject.com/en/3.2/ref/settings/#email-host) for additional
|
||||
information.
|
||||
Required for email confirmation and password reset (automatically activates if host is set).
|
||||
|
||||
```
|
||||
EMAIL_HOST=
|
||||
EMAIL_PORT=
|
||||
EMAIL_HOST_USER=
|
||||
EMAIL_HOST_PASSWORD=
|
||||
EMAIL_USE_TLS=0
|
||||
EMAIL_USE_SSL=0
|
||||
# email sender address (default 'webmaster@localhost')
|
||||
DEFAULT_FROM_EMAIL=
|
||||
```
|
||||
|
||||
Optional settings (only copy the ones you need)
|
||||
|
||||
```
|
||||
# prefix used for account related emails (default "[Tandoor Recipes] ")
|
||||
ACCOUNT_EMAIL_SUBJECT_PREFIX=
|
||||
```
|
||||
|
||||
#### S3 Object storage
|
||||
|
||||
If you want to store your users media files using an external storage provider supporting the S3 API's (Like S3,
|
||||
MinIO, ...)
|
||||
configure the following settings accordingly.
|
||||
As long as `S3_ACCESS_KEY` is not set, all object storage related settings are disabled.
|
||||
|
||||
See also [Django Storages Docs](https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html) for additional
|
||||
information.
|
||||
|
||||
!!! info
|
||||
Settings are only named S3 but apply to all compatible object storage providers.
|
||||
|
||||
Required settings
|
||||
|
||||
```
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_BUCKET_NAME=
|
||||
```
|
||||
|
||||
Optional settings (only copy the ones you need)
|
||||
|
||||
```
|
||||
S3_REGION_NAME= # default none, set your region might be required
|
||||
S3_QUERYSTRING_AUTH=1 # default true, set to 0 to serve media from a public bucket without signed urls
|
||||
S3_QUERYSTRING_EXPIRE=3600 # number of seconds querystring are valid for
|
||||
S3_ENDPOINT_URL= # when using a custom endpoint like minio
|
||||
S3_CUSTOM_DOMAIN= # when using a CDN/proxy to S3 (see https://github.com/TandoorRecipes/recipes/issues/1943)
|
||||
```
|
||||
|
||||
#### FDC Api
|
||||
|
||||
The FDC Api is used to automatically load nutrition information from
|
||||
the [FDC Nutrition Database](https://fdc.nal.usda.gov/fdc-app.html#/).
|
||||
The default `DEMO_KEY` is limited to 30 requests / hour or 50 requests / day.
|
||||
If you want to do many requests to the FDC API you need to get a (free) API
|
||||
key [here](https://fdc.nal.usda.gov/api-key-signup.html).
|
||||
|
||||
```
|
||||
FDC_API_KEY=DEMO_KEY
|
||||
```
|
||||
|
||||
### Debugging/Development settings
|
||||
|
||||
!!! warning
|
||||
These settings should not be left on in production as they might provide additional attack surfaces and
|
||||
information to adversaries.
|
||||
|
||||
#### Debug
|
||||
|
||||
> default `0` - options: `0`, `1`
|
||||
|
||||
!!! info
|
||||
Please enable this before posting logs anywhere to ask for help.
|
||||
|
||||
Setting to `1` enables several django debug features and additional
|
||||
logs ([see docs](https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-DEBUG)).
|
||||
|
||||
```
|
||||
DEBUG=0
|
||||
```
|
||||
|
||||
#### Debug Toolbar
|
||||
|
||||
> default `0` - options: `0`, `1`
|
||||
|
||||
Set to `1` to enable django debug toolbar middleware. Toolbar only shows if `DEBUG=1` is set and the requesting IP
|
||||
is in `INTERNAL_IPS`.
|
||||
See [Django Debug Toolbar Docs](https://django-debug-toolbar.readthedocs.io/en/latest/).
|
||||
|
||||
```
|
||||
DEBUG_TOOLBAR=0
|
||||
```
|
||||
|
||||
#### SQL Debug
|
||||
|
||||
> default `0` - options: `0`, `1`
|
||||
|
||||
Set to `1` to enable additional query output on the search page.
|
||||
|
||||
```
|
||||
SQL_DEBUG=0
|
||||
```
|
||||
|
||||
#### Gunicorn Log Level
|
||||
|
||||
> default `info` - options: [see Gunicorn Docs](https://docs.gunicorn.org/en/stable/settings.html#loglevel)
|
||||
|
||||
Increase or decrease the logging done by gunicorn (the python wsgi application).
|
||||
|
||||
```
|
||||
GUNICORN_LOG_LEVEL="debug"
|
||||
```
|
||||
|
||||
### Default User Preferences
|
||||
|
||||
Having default user preferences is nice so that users signing up to your instance already have the settings you deem
|
||||
appropriate.
|
||||
|
||||
#### Fractions
|
||||
|
||||
> default `0` - options: `0`,`1`
|
||||
|
||||
The default value for the user preference 'fractions' (showing amounts as decimals or fractions).
|
||||
|
||||
```
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
```
|
||||
|
||||
#### Comments
|
||||
|
||||
> default `1` - options: `0`,`1`
|
||||
|
||||
The default value for the user preference 'comments' (enable/disable commenting system)
|
||||
|
||||
```
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
```
|
||||
|
||||
#### Sticky Navigation
|
||||
|
||||
> default `1` - options: `0`,`1`
|
||||
|
||||
The default value for the user preference 'sticky navigation' (always show navbar on top or hide when scrolling)
|
||||
|
||||
```
|
||||
STICKY_NAV_PREF_DEFAULT=1
|
||||
```
|
||||
|
||||
#### Max owned spaces
|
||||
|
||||
> default `100` - options: `0-X`
|
||||
|
||||
The default for the number of spaces a user can own. By setting to 0 space creation for users will be disabled.
|
||||
Superusers can always bypass this limit.
|
||||
|
||||
```
|
||||
MAX_OWNED_SPACES_PREF_DEFAULT=100
|
||||
```
|
||||
|
||||
|
||||
### Cosmetic / Preferences
|
||||
|
||||
#### Timezone
|
||||
|
||||
> default `Europe/Berlin` - options: [see timezone DB](https://timezonedb.com/time-zones)
|
||||
|
||||
Default timezone to use for database
|
||||
connections ([see Django docs](https://docs.djangoproject.com/en/5.0/ref/settings/#time-zone)).
|
||||
Usually everything is converted to the users timezone so this setting doesn't really need to be correct.
|
||||
|
||||
```
|
||||
TZ=Europe/Berlin
|
||||
```
|
||||
|
||||
#### Default Theme
|
||||
> default `0` - options `1-X` (space ID)
|
||||
|
||||
Tandoors appearance can be changed on a user and space level but unauthenticated users always see the tandoor default style.
|
||||
With this setting you can specify the ID of a space of which the appearance settings should be applied if a user is not logged in.
|
||||
|
||||
```
|
||||
UNAUTHENTICATED_THEME_FROM_SPACE=
|
||||
```
|
||||
|
||||
### Rate Limiting / Performance
|
||||
|
||||
#### Shopping auto sync
|
||||
|
||||
> default `5` - options: `1-XXX`
|
||||
|
||||
Users can set an amount of time after which the shopping list is automatically refreshed.
|
||||
This is the minimum interval users can set. Setting this to a low value will allow users to automatically refresh very
|
||||
frequently which
|
||||
might cause high load on the server. (Technically they can obviously refresh as often as they want with their own
|
||||
scripts)
|
||||
|
||||
```
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
```
|
||||
|
||||
#### API Url Import throttle
|
||||
|
||||
> default `60/hour` - options: `x/hour`, `x/day`, `x/minute`, `x/second`
|
||||
|
||||
Limits how many recipes a user can import per hour.
|
||||
A rate limit is recommended to prevent users from abusing your server for (DDoS) relay attacks and to prevent external
|
||||
service
|
||||
providers from blocking your server for too many request.
|
||||
|
||||
```
|
||||
DRF_THROTTLE_RECIPE_URL_IMPORT=60/hour
|
||||
```
|
||||
|
||||
#### Default Space Limits
|
||||
You might want to limit how many resources a user might create. The following settings apply automatically to newly
|
||||
created spaces. These defaults can be changed in the admin view after a space has been created.
|
||||
|
||||
If unset, all settings default to unlimited/enabled
|
||||
|
||||
```
|
||||
SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes
|
||||
SPACE_DEFAULT_MAX_USERS=0 # 0=unlimited users per space
|
||||
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
|
||||
```
|
||||
|
||||
#### Export file caching
|
||||
> default `600` - options `1-X`
|
||||
|
||||
Recipe exports are cached for a certain time (in seconds) by default, adjust time if needed
|
||||
```
|
||||
EXPORT_FILE_CACHE_DURATION=600
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
The Updating process depends on your chosen method of [installation](/install/docker)
|
||||
|
||||
While intermediate updates can be skipped when updating please make sure to
|
||||
While intermediate updates can be skipped when updating please make sure to
|
||||
**read the release notes** in case some special action is required to update.
|
||||
|
||||
## Docker
|
||||
@@ -16,7 +16,79 @@ For all setups using Docker the updating process look something like this
|
||||
For all setups using a manual installation updates usually involve downloading the latest source code from GitHub.
|
||||
After that make sure to run:
|
||||
|
||||
1. `manage.py collectstatic`
|
||||
2. `manage.py migrate`
|
||||
1. `pip install -r requirements.txt`
|
||||
2. `manage.py collectstatic`
|
||||
3. `manage.py migrate`
|
||||
4. `cd ./vue`
|
||||
5. `yarn install`
|
||||
6. `yarn build`
|
||||
|
||||
To apply all new migrations and collect new static files.
|
||||
To install latest libraries, apply all new migrations and collect new static files.
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Postgres does not automatically upgrade database files when you change versions and requires manual intervention.
|
||||
One option is to manually [backup/restore](https://docs.tandoor.dev/system/updating/#postgresql) the database.
|
||||
|
||||
A full list of options to upgrade a database provide in the [official PostgreSQL documentation](https://www.postgresql.org/docs/current/upgrading.html).
|
||||
|
||||
1. Collect information about your environment.
|
||||
|
||||
``` bash
|
||||
grep -E 'POSTGRES|DATABASE' ~/.docker/compose/.env
|
||||
docker ps -a --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' | awk 'NR == 1 || /postgres/ || /recipes/'
|
||||
```
|
||||
|
||||
2. Export the tandoor database
|
||||
|
||||
``` bash
|
||||
docker exec -t {{database_container}} pg_dumpall -U {{djangouser}} > ~/tandoor.sql
|
||||
```
|
||||
|
||||
3. Stop the postgres container
|
||||
``` bash
|
||||
docker stop {{database_container}} {{tandoor_container}}
|
||||
```
|
||||
|
||||
4. Rename the tandoor volume
|
||||
|
||||
``` bash
|
||||
sudo mv -R ~/.docker/compose/postgres ~/.docker/compose/postgres.old
|
||||
```
|
||||
|
||||
5. Update image tag on postgres container.
|
||||
|
||||
``` yaml
|
||||
db_recipes:
|
||||
restart: always
|
||||
image: postgres:16-alpine
|
||||
volumes:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
```
|
||||
|
||||
6. Pull and rebuild container.
|
||||
|
||||
``` bash
|
||||
docker-compose pull && docker-compose up -d
|
||||
```
|
||||
|
||||
7. Import the database export
|
||||
|
||||
``` bash
|
||||
cat ~/tandoor.sql | sudo docker exec -i {{database_container}} psql postgres -U {{djangouser}}
|
||||
```
|
||||
8. Install postgres extensions
|
||||
``` bash
|
||||
docker exec -it {{database_container}} psql
|
||||
```
|
||||
then
|
||||
``` psql
|
||||
CREATE EXTENSION IF NOT EXISTS unaccent;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
```
|
||||
|
||||
If anything fails, go back to the old postgres version and data directory and try again.
|
||||
|
||||
There are many articles and tools online that might provide a good starting point to help you upgrade [1](https://thomasbandt.com/postgres-docker-major-version-upgrade), [2](https://github.com/tianon/docker-postgres-upgrade), [3](https://github.com/vabene1111/DockerPostgresBackups).
|
||||
|
||||
@@ -45,6 +45,7 @@ nav:
|
||||
- Storages and Sync: features/external_recipes.md
|
||||
- Import/Export: features/import_export.md
|
||||
- System:
|
||||
- Configuration: system/configuration.md
|
||||
- Updating: system/updating.md
|
||||
- Permission System: system/permissions.md
|
||||
- Backup: system/backup.md
|
||||
|
||||
@@ -44,7 +44,7 @@ INTERNAL_IPS = os.getenv('INTERNAL_IPS').split(
|
||||
',') if os.getenv('INTERNAL_IPS') else ['127.0.0.1']
|
||||
|
||||
# allow djangos wsgi server to server mediafiles
|
||||
GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', True)))
|
||||
GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', False)))
|
||||
|
||||
if os.getenv('REVERSE_PROXY_AUTH') is not None:
|
||||
print('DEPRECATION WARNING: Environment var "REVERSE_PROXY_AUTH" is deprecated. Please use "REMOTE_USER_AUTH".')
|
||||
@@ -57,6 +57,8 @@ COMMENT_PREF_DEFAULT = bool(int(os.getenv('COMMENT_PREF_DEFAULT', True)))
|
||||
FRACTION_PREF_DEFAULT = bool(int(os.getenv('FRACTION_PREF_DEFAULT', False)))
|
||||
KJ_PREF_DEFAULT = bool(int(os.getenv('KJ_PREF_DEFAULT', False)))
|
||||
STICKY_NAV_PREF_DEFAULT = bool(int(os.getenv('STICKY_NAV_PREF_DEFAULT', True)))
|
||||
MAX_OWNED_SPACES_PREF_DEFAULT = int(os.getenv('MAX_OWNED_SPACES_PREF_DEFAULT', 100))
|
||||
UNAUTHENTICATED_THEME_FROM_SPACE = int(os.getenv('UNAUTHENTICATED_THEME_FROM_SPACE', 0))
|
||||
|
||||
# minimum interval that users can set for automatic sync of shopping lists
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL = int(
|
||||
@@ -69,7 +71,8 @@ if os.getenv('CSRF_TRUSTED_ORIGINS'):
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv('CSRF_TRUSTED_ORIGINS').split(',')
|
||||
|
||||
if CORS_ORIGIN_ALLOW_ALL := os.getenv('CORS_ORIGIN_ALLOW_ALL') is not None:
|
||||
print('DEPRECATION WARNING: Environment var "CORS_ORIGIN_ALLOW_ALL" is deprecated. Please use "CORS_ALLOW_ALL_ORIGINS."')
|
||||
print(
|
||||
'DEPRECATION WARNING: Environment var "CORS_ORIGIN_ALLOW_ALL" is deprecated. Please use "CORS_ALLOW_ALL_ORIGINS."')
|
||||
CORS_ALLOW_ALL_ORIGINS = CORS_ORIGIN_ALLOW_ALL
|
||||
else:
|
||||
CORS_ALLOW_ALL_ORIGINS = bool(int(os.getenv("CORS_ALLOW_ALL_ORIGINS", True)))
|
||||
@@ -89,13 +92,15 @@ DJANGO_TABLES2_PAGE_RANGE = 8
|
||||
HCAPTCHA_SITEKEY = os.getenv('HCAPTCHA_SITEKEY', '')
|
||||
HCAPTCHA_SECRET = os.getenv('HCAPTCHA_SECRET', '')
|
||||
|
||||
FDA_API_KEY = os.getenv('FDA_API_KEY', 'DEMO_KEY')
|
||||
FDC_API_KEY = os.getenv('FDC_API_KEY', 'DEMO_KEY')
|
||||
|
||||
SHARING_ABUSE = bool(int(os.getenv('SHARING_ABUSE', False)))
|
||||
SHARING_LIMIT = int(os.getenv('SHARING_LIMIT', 0))
|
||||
|
||||
ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm'
|
||||
|
||||
DRF_THROTTLE_RECIPE_URL_IMPORT = os.getenv('DRF_THROTTLE_RECIPE_URL_IMPORT', '60/hour')
|
||||
|
||||
TERMS_URL = os.getenv('TERMS_URL', '')
|
||||
PRIVACY_URL = os.getenv('PRIVACY_URL', '')
|
||||
IMPRINT_URL = os.getenv('IMPRINT_URL', '')
|
||||
@@ -156,7 +161,8 @@ try:
|
||||
INSTALLED_APPS.append(plugin_module)
|
||||
|
||||
plugin_config = {
|
||||
'name': plugin_class.verbose_name if hasattr(plugin_class, 'verbose_name') else plugin_class.name,
|
||||
'name': plugin_class.verbose_name if hasattr(plugin_class,
|
||||
'verbose_name') else plugin_class.name,
|
||||
'version': plugin_class.VERSION if hasattr(plugin_class, 'VERSION') else 'unknown',
|
||||
'website': plugin_class.website if hasattr(plugin_class, 'website') else '',
|
||||
'github': plugin_class.github if hasattr(plugin_class, 'github') else '',
|
||||
@@ -164,7 +170,8 @@ try:
|
||||
'base_path': os.path.join(BASE_DIR, 'recipes', 'plugins', d),
|
||||
'base_url': plugin_class.base_url,
|
||||
'bundle_name': plugin_class.bundle_name if hasattr(plugin_class, 'bundle_name') else '',
|
||||
'api_router_name': plugin_class.api_router_name if hasattr(plugin_class, 'api_router_name') else '',
|
||||
'api_router_name': plugin_class.api_router_name if hasattr(plugin_class,
|
||||
'api_router_name') else '',
|
||||
'nav_main': plugin_class.nav_main if hasattr(plugin_class, 'nav_main') else '',
|
||||
'nav_dropdown': plugin_class.nav_dropdown if hasattr(plugin_class, 'nav_dropdown') else '',
|
||||
}
|
||||
@@ -218,6 +225,7 @@ MIDDLEWARE = [
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'cookbook.helper.scope_middleware.ScopeMiddleware',
|
||||
'allauth.account.middleware.AccountMiddleware',
|
||||
]
|
||||
|
||||
if DEBUG_TOOLBAR:
|
||||
@@ -253,7 +261,8 @@ if LDAP_AUTH:
|
||||
ldap.SCOPE_SUBTREE,
|
||||
os.getenv('AUTH_LDAP_USER_SEARCH_FILTER_STR', '(uid=%(user)s)'),
|
||||
)
|
||||
AUTH_LDAP_USER_ATTR_MAP = ast.literal_eval(os.getenv('AUTH_LDAP_USER_ATTR_MAP')) if os.getenv('AUTH_LDAP_USER_ATTR_MAP') else {
|
||||
AUTH_LDAP_USER_ATTR_MAP = ast.literal_eval(os.getenv('AUTH_LDAP_USER_ATTR_MAP')) if os.getenv(
|
||||
'AUTH_LDAP_USER_ATTR_MAP') else {
|
||||
'first_name': 'givenName',
|
||||
'last_name': 'sn',
|
||||
'email': 'mail',
|
||||
@@ -350,7 +359,7 @@ WSGI_APPLICATION = 'recipes.wsgi.application'
|
||||
# Load settings from env files
|
||||
if os.getenv('DATABASE_URL'):
|
||||
match = re.match(
|
||||
r'(?P<schema>\w+):\/\/(?:(?P<user>[\w\d_-]+)(?::(?P<password>[^@]+))?@)?(?P<host>[^:/]+)(?:(?P<port>\d+))?(?:/(?P<database>[\w\d/._-]+))?',
|
||||
r'(?P<schema>\w+):\/\/(?:(?P<user>[\w\d_-]+)(?::(?P<password>[^@]+))?@)?(?P<host>[^:/]+)(?::(?P<port>\d+))?(?:/(?P<database>[\w\d/._-]+))?',
|
||||
os.getenv('DATABASE_URL')
|
||||
)
|
||||
settings = match.groupdict()
|
||||
@@ -438,7 +447,7 @@ for p in PLUGINS:
|
||||
if p['bundle_name'] != '':
|
||||
WEBPACK_LOADER[p['bundle_name']] = {
|
||||
'CACHE': not DEBUG,
|
||||
'BUNDLE_DIR_NAME': f'vue/', # must end with slash
|
||||
'BUNDLE_DIR_NAME': 'vue/', # must end with slash
|
||||
'STATS_FILE': os.path.join(p["base_path"], 'vue', 'webpack-stats.json'),
|
||||
'POLL_INTERVAL': 0.1,
|
||||
'TIMEOUT': None,
|
||||
@@ -450,7 +459,11 @@ for p in PLUGINS:
|
||||
|
||||
LANGUAGE_CODE = 'en'
|
||||
|
||||
TIME_ZONE = os.getenv('TIMEZONE') if os.getenv('TIMEZONE') else 'Europe/Berlin'
|
||||
if os.getenv('TIMEZONE') is not None:
|
||||
print('DEPRECATION WARNING: Environment var "TIMEZONE" is deprecated. Please use "TZ" instead.')
|
||||
TIME_ZONE = os.getenv('TIMEZONE') if os.getenv('TIMEZONE') else 'Europe/Berlin'
|
||||
else:
|
||||
TIME_ZONE = os.getenv('TZ') if os.getenv('TZ') else 'Europe/Berlin'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Django==4.2.7
|
||||
cryptography===41.0.4
|
||||
cryptography===41.0.6
|
||||
django-annoying==0.10.6
|
||||
django-autocomplete-light==3.9.4
|
||||
django-cleanup==8.0.0
|
||||
@@ -9,7 +9,7 @@ django-tables2==2.5.3
|
||||
djangorestframework==3.14.0
|
||||
drf-writable-nested==0.7.0
|
||||
django-oauth-toolkit==2.3.0
|
||||
django-debug-toolbar==3.8.1
|
||||
django-debug-toolbar==4.2.0
|
||||
bleach==6.0.0
|
||||
gunicorn==20.1.0
|
||||
lxml==4.9.3
|
||||
@@ -26,13 +26,13 @@ pyyaml==6.0.1
|
||||
uritemplate==4.1.1
|
||||
beautifulsoup4==4.12.2
|
||||
microdata==0.8.0
|
||||
Jinja2==3.1.2
|
||||
Jinja2==3.1.3
|
||||
django-webpack-loader==1.8.1
|
||||
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
|
||||
django-allauth==0.54.0
|
||||
django-allauth==0.58.1
|
||||
recipe-scrapers==14.52.0
|
||||
django-scopes==2.0.0
|
||||
pytest==7.3.1
|
||||
pytest==7.4.3
|
||||
pytest-django==4.6.0
|
||||
django-treebeard==4.7
|
||||
django-cors-headers==4.2.0
|
||||
|
||||
16
version.py
@@ -13,12 +13,11 @@ tandoor_hash = ''
|
||||
try:
|
||||
print('getting tandoor version')
|
||||
r = subprocess.check_output(['git', 'show', '-s'], cwd=BASE_DIR).decode()
|
||||
tandoor_branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=BASE_DIR).decode()
|
||||
tandoor_branch = subprocess.check_output(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd=BASE_DIR).decode().replace('\n', '')
|
||||
tandoor_hash = r.split('\n')[0].split(' ')[1]
|
||||
try:
|
||||
tandoor_tag = subprocess.check_output(['git', 'describe', '--exact-match', tandoor_hash], cwd=BASE_DIR).decode().replace('\n', '')
|
||||
except:
|
||||
|
||||
tandoor_tag = subprocess.check_output(['git', 'describe', '--exact-match', '--tags', tandoor_hash], cwd=BASE_DIR).decode().replace('\n', '')
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
version_info.append({
|
||||
@@ -47,8 +46,9 @@ try:
|
||||
commit_hash = r.split('\n')[0].split(' ')[1]
|
||||
try:
|
||||
print('running describe')
|
||||
tag = subprocess.check_output(['git', 'describe', '--exact-match', commit_hash], cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d)).decode().replace('\n', '')
|
||||
except:
|
||||
tag = subprocess.check_output(['git', 'describe', '--exact-match', commit_hash],
|
||||
cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d)).decode().replace('\n', '')
|
||||
except BaseException:
|
||||
tag = ''
|
||||
|
||||
version_info.append({
|
||||
@@ -66,9 +66,11 @@ try:
|
||||
traceback.print_exc()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print("command '{}' return with error (code {}): {}".format(e.cmd, e.returncode, e.output))
|
||||
except:
|
||||
except BaseException:
|
||||
traceback.print_exc()
|
||||
|
||||
with open('cookbook/version_info.py', 'w+', encoding='UTF-8') as f:
|
||||
print(f"writing version info {version_info}")
|
||||
if not tandoor_tag:
|
||||
tandoor_tag = tandoor_hash
|
||||
f.write(f'TANDOOR_VERSION = "{tandoor_tag}"\nTANDOOR_REF = "{tandoor_hash}"\nVERSION_INFO = {version_info}')
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/eslint-parser": "^7.21.3",
|
||||
"@codemirror/autocomplete": "^6.11.1",
|
||||
"@codemirror/commands": "^6.3.2",
|
||||
"@codemirror/lang-markdown": "^6.2.3",
|
||||
"@codemirror/state": "^6.3.3",
|
||||
"@codemirror/view": "^6.22.2",
|
||||
"@popperjs/core": "^2.11.7",
|
||||
"@vue/cli": "^5.0.8",
|
||||
"@vue/composition-api": "1.7.1",
|
||||
|
||||
@@ -669,8 +669,7 @@ export default {
|
||||
if (url !== '') {
|
||||
this.failed_imports.push(url)
|
||||
}
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
throw "Load Recipe Error"
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_IMPORT, err)
|
||||
})
|
||||
},
|
||||
/**
|
||||
@@ -713,8 +712,7 @@ export default {
|
||||
axios.post(resolveDjangoUrl('view_import'), formData, {headers: {'Content-Type': 'multipart/form-data'}}).then((response) => {
|
||||
window.location.href = resolveDjangoUrl('view_import_response', response.data['import_id'])
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_CREATE)
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_IMPORT, err)
|
||||
})
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -363,20 +363,26 @@ export default {
|
||||
}
|
||||
},
|
||||
mobileSimpleGrid() {
|
||||
let grid = []
|
||||
|
||||
if (this.current_period !== null) {
|
||||
for (const x of Array(7).keys()) {
|
||||
let moment_date = moment(this.current_period.periodStart).add(x, "d")
|
||||
grid.push({
|
||||
date: moment_date,
|
||||
create_default_date: moment_date.format("YYYY-MM-DD"), // improve meal plan edit modal to do formatting itself and accept dates
|
||||
date_label: moment_date.format("dd") + " " + moment_date.format("ll"),
|
||||
plan_entries: this.plan_items.filter((m) => moment_date.isBetween(moment(m.startDate), moment(m.endDate), 'day', '[]'))
|
||||
})
|
||||
}
|
||||
let grid = [];
|
||||
let currentDate = moment();
|
||||
for (let x = 0; x < 7; x++) {
|
||||
let moment_date = currentDate.clone().add(x, "d");
|
||||
grid.push({
|
||||
date: moment_date,
|
||||
create_default_date: moment_date.format("YYYY-MM-DD"),
|
||||
date_label: moment_date.format("dd") + " " + moment_date.format("ll"),
|
||||
plan_entries: this.plan_items.filter(
|
||||
(m) =>
|
||||
moment_date.isBetween(
|
||||
moment(m.startDate),
|
||||
moment(m.endDate),
|
||||
'day',
|
||||
'[]'
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
return grid
|
||||
return grid;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -744,8 +750,12 @@ having to override as much.
|
||||
|
||||
.theme-default .cv-item.continued::before,
|
||||
.theme-default .cv-item.toBeContinued::after {
|
||||
/*
|
||||
removed because it breaks a line and would increase item size https://github.com/TandoorRecipes/recipes/issues/2678
|
||||
|
||||
content: " \21e2 ";
|
||||
color: #999;
|
||||
*/
|
||||
}
|
||||
|
||||
.theme-default .cv-item.toBeContinued {
|
||||
|
||||
@@ -186,10 +186,10 @@ export default {
|
||||
case "ingredient-editor": {
|
||||
let url = resolveDjangoUrl("view_ingredient_editor")
|
||||
if (this.this_model === this.Models.FOOD) {
|
||||
window.location.href = url + '?food_id=' + e.source.id
|
||||
window.open(url + '?food_id=' + e.source.id, "_blank");
|
||||
}
|
||||
if (this.this_model === this.Models.UNIT) {
|
||||
window.location.href = url + '?unit_id=' + e.source.id
|
||||
window.open(url + '?unit_id=' + e.source.id, "_blank");
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
227
vue/src/apps/PropertyEditorView/PropertyEditorView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
|
||||
<div id="app">
|
||||
<div>
|
||||
<div class="row" v-if="recipe" style="max-height: 10vh">
|
||||
|
||||
<div class="col col-8">
|
||||
<h2><a :href="resolveDjangoUrl('view_recipe', recipe.id)">{{ recipe.name }}</a></h2>
|
||||
{{ recipe.description }}
|
||||
<keywords-component :recipe="recipe"></keywords-component>
|
||||
</div>
|
||||
<div class="col col-4" v-if="recipe.image">
|
||||
<img style="max-height: 10vh" class="img-thumbnail float-right" :src="recipe.image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col col-12">
|
||||
<b-button variant="success" href="https://fdc.nal.usda.gov/index.html" target="_blank"><i class="fas fa-external-link-alt"></i> {{$t('FDC_Search')}}</b-button>
|
||||
|
||||
<table class="table table-sm table-bordered table-responsive mt-2 pb-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{{ $t('Name') }}</td>
|
||||
<td>FDC</td>
|
||||
<td>{{ $t('Properties_Food_Amount') }}</td>
|
||||
<td>{{ $t('Properties_Food_Unit') }}</td>
|
||||
<td v-for="pt in property_types" v-bind:key="pt.id">
|
||||
<b-button variant="primary" @click="editing_property_type = pt" class="btn-block">{{ pt.name }}
|
||||
<span v-if="pt.unit !== ''">({{ pt.unit }}) </span> <br/>
|
||||
<b-badge variant="light" ><i class="fas fa-sort-amount-down-alt"></i> {{ pt.order}}</b-badge>
|
||||
<b-badge variant="success" v-if="pt.fdc_id > 0" class="mt-2" v-b-tooltip.hover :title="$t('property_type_fdc_hint')"><i class="fas fa-check"></i> FDC</b-badge>
|
||||
<b-badge variant="warning" v-if="pt.fdc_id < 1" class="mt-2" v-b-tooltip.hover :title="$t('property_type_fdc_hint')"><i class="fas fa-times"></i> FDC</b-badge>
|
||||
</b-button>
|
||||
</td>
|
||||
<td>
|
||||
<b-button variant="success" @click="new_property_type = true"><i class="fas fa-plus"></i></b-button>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in this.foods" v-bind:key="f.id">
|
||||
<td>
|
||||
{{ f.name }}
|
||||
</td>
|
||||
<td style="width: 15em;">
|
||||
<b-input-group>
|
||||
<b-form-input v-model="f.fdc_id" type="number" @change="updateFood(f)" :disabled="f.loading"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="success" @click="updateFoodFromFDC(f)" :disabled="f.loading"><i class="fas fa-sync-alt" :class="{'fa-spin': loading}"></i></b-button>
|
||||
<b-button variant="info" :href="`https://fdc.nal.usda.gov/fdc-app.html#/food-details/${f.fdc_id}`" :disabled="f.fdc_id < 1" target="_blank"><i class="fas fa-external-link-alt"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
</td>
|
||||
<td style="width: 5em; ">
|
||||
<b-input v-model="f.properties_food_amount" type="number" @change="updateFood(f)" :disabled="f.loading"></b-input>
|
||||
</td>
|
||||
<td style="width: 11em;">
|
||||
<generic-multiselect
|
||||
@change="f.properties_food_unit = $event.val; updateFood(f)"
|
||||
:initial_single_selection="f.properties_food_unit"
|
||||
label="name" :model="Models.UNIT"
|
||||
:multiple="false"
|
||||
:disabled="f.loading"/>
|
||||
</td>
|
||||
<td v-for="p in f.properties" v-bind:key="`${f.id}_${p.property_type.id}`">
|
||||
<b-input-group>
|
||||
<b-form-input v-model="p.property_amount" type="number" :disabled="f.loading" v-b-tooltip.focus :title="p.property_type.name" @change="updateFood(f)"></b-form-input>
|
||||
</b-input-group>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<generic-modal-form
|
||||
:show="editing_property_type !== null"
|
||||
:model="Models.PROPERTY_TYPE"
|
||||
:action="Actions.UPDATE"
|
||||
:item1="editing_property_type"
|
||||
@finish-action="editing_property_type = null; loadData()">
|
||||
</generic-modal-form>
|
||||
|
||||
<generic-modal-form
|
||||
:show="new_property_type"
|
||||
:model="Models.PROPERTY_TYPE"
|
||||
:action="Actions.CREATE"
|
||||
@finish-action="new_property_type = false; loadData()">
|
||||
</generic-modal-form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import {ApiMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils";
|
||||
import axios from "axios";
|
||||
import BetaWarning from "@/components/BetaWarning.vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||
import KeywordsComponent from "@/components/KeywordsComponent.vue";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
|
||||
export default {
|
||||
name: "PropertyEditorView",
|
||||
mixins: [ApiMixin],
|
||||
components: {KeywordsComponent, GenericModalForm, GenericMultiselect},
|
||||
computed: {},
|
||||
data() {
|
||||
return {
|
||||
recipe: null,
|
||||
property_types: [],
|
||||
editing_property_type: null,
|
||||
new_property_type: false,
|
||||
loading: false,
|
||||
foods: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
|
||||
this.loadData();
|
||||
},
|
||||
methods: {
|
||||
resolveDjangoUrl,
|
||||
loadData: function () {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.listPropertyTypes().then(result => {
|
||||
this.property_types = result.data
|
||||
|
||||
apiClient.retrieveRecipe(window.RECIPE_ID).then(result => {
|
||||
this.recipe = result.data
|
||||
|
||||
this.foods = []
|
||||
|
||||
this.recipe.steps.forEach(s => {
|
||||
s.ingredients.forEach(i => {
|
||||
if (this.foods.filter(x => (x.id === i.food.id)).length === 0) {
|
||||
this.foods.push(this.buildFood(i.food))
|
||||
}
|
||||
})
|
||||
})
|
||||
this.loading = false;
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
},
|
||||
buildFood: function (food) {
|
||||
/**
|
||||
* Prepare food for display in grid by making sure the food properties are in the same order as property_types and that no types are missing
|
||||
* */
|
||||
|
||||
let existing_properties = {}
|
||||
food.properties.forEach(fp => {
|
||||
existing_properties[fp.property_type.id] = fp
|
||||
})
|
||||
|
||||
let food_properties = []
|
||||
this.property_types.forEach(pt => {
|
||||
let new_food_property = {
|
||||
property_type: pt,
|
||||
property_amount: 0,
|
||||
}
|
||||
if (pt.id in existing_properties) {
|
||||
new_food_property = existing_properties[pt.id]
|
||||
}
|
||||
food_properties.push(new_food_property)
|
||||
})
|
||||
|
||||
this.$set(food, 'loading', false)
|
||||
|
||||
food.properties = food_properties
|
||||
|
||||
return food
|
||||
},
|
||||
spliceInFood: function (food) {
|
||||
/**
|
||||
* replace food in foods list, for example after updates from the server
|
||||
*/
|
||||
this.foods = this.foods.map(f => (f.id === food.id) ? food : f)
|
||||
|
||||
},
|
||||
updateFood: function (food) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.partialUpdateFood(food.id, food).then(result => {
|
||||
this.spliceInFood(this.buildFood(result.data))
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
},
|
||||
updateFoodFromFDC: function (food) {
|
||||
food.loading = true;
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.fdcFood(food.id).then(result => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
this.spliceInFood(this.buildFood(result.data))
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
food.loading = false;
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
22
vue/src/apps/PropertyEditorView/main.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import Vue from 'vue'
|
||||
import App from './PropertyEditorView.vue'
|
||||
import i18n from '@/i18n'
|
||||
import {createPinia, PiniaVuePlugin} from "pinia";
|
||||
|
||||
Vue.config.productionTip = false
|
||||
|
||||
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
|
||||
let publicPath = localStorage.STATIC_URL + 'vue/'
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
publicPath = 'http://localhost:8080/'
|
||||
}
|
||||
export default __webpack_public_path__ = publicPath // eslint-disable-line
|
||||
|
||||
Vue.use(PiniaVuePlugin)
|
||||
const pinia = createPinia()
|
||||
|
||||
new Vue({
|
||||
pinia,
|
||||
i18n,
|
||||
render: h => h(App),
|
||||
}).$mount('#app')
|
||||
@@ -425,7 +425,6 @@
|
||||
v-if="!ingredient.is_header">
|
||||
<input
|
||||
class="form-control"
|
||||
style="height: 100%;"
|
||||
v-model="ingredient.amount"
|
||||
type="number"
|
||||
step="any"
|
||||
@@ -507,7 +506,6 @@
|
||||
<input
|
||||
class="form-control"
|
||||
maxlength="256"
|
||||
style="height: 100%;"
|
||||
v-model="ingredient.note"
|
||||
v-bind:placeholder="$t('Note')"
|
||||
v-on:keydown.tab="
|
||||
@@ -938,10 +936,7 @@ export default {
|
||||
// set default visibility style for each component of the step
|
||||
this.recipe.steps.forEach((s) => {
|
||||
this.$set(s, "time_visible", s.time !== 0)
|
||||
// ingredients_visible determines whether or not the ingredients UI is shown in the edit view
|
||||
// show_ingredients_table determine whether the ingredients table is shown in the read view
|
||||
// these are seperate as one might want to add ingredients but not want the step-level view
|
||||
this.$set(s, "ingredients_visible", s.show_ingredients_table && (s.ingredients.length > 0 || this.recipe.steps.length === 1))
|
||||
this.$set(s, "ingredients_visible", (s.ingredients.length > 0 || this.recipe.steps.length === 1))
|
||||
this.$set(s, "instruction_visible", s.instruction !== "" || this.recipe.steps.length === 1)
|
||||
this.$set(s, "step_recipe_visible", s.step_recipe !== null)
|
||||
this.$set(s, "file_visible", s.file !== null)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="app" style="padding-bottom: 60px">
|
||||
<RecipeSwitcher ref="ref_recipe_switcher"/>
|
||||
<RecipeSwitcher ref="ref_recipe_switcher" />
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-10 col-lg-10 offset-xl-1 offset-lg-1">
|
||||
<div class="row">
|
||||
@@ -153,7 +153,7 @@
|
||||
<div class="row" style="margin-top: 1vh">
|
||||
<div class="col-12" style="text-align: right">
|
||||
<b-button size="sm" variant="secondary" style="margin-right: 8px" @click="$root.$emit('bv::hide::popover')"
|
||||
>{{ $t("Close") }}
|
||||
>{{ $t("Close") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,17 +197,17 @@
|
||||
<span
|
||||
class="text-sm-left text-warning"
|
||||
v-if="ui.expert_mode && search.keywords_fields > 1 && hasDuplicateFilter(search.search_keywords, search.keywords_fields)"
|
||||
>{{ $t("warning_duplicate_filter") }}</span
|
||||
>{{ $t("warning_duplicate_filter") }}</span
|
||||
>
|
||||
<div class="row" v-if="ui.show_keywords">
|
||||
<div class="col-12">
|
||||
<b-input-group class="mt-2" v-for="(k, a) in keywordFields" :key="a">
|
||||
<template #prepend v-if="ui.expert_mode">
|
||||
<b-input-group-text style="width: 3em" @click="addField('keywords', k)">
|
||||
<i class="fas fa-plus-circle text-primary" v-if="k == search.keywords_fields && k < 4"/>
|
||||
<i class="fas fa-plus-circle text-primary" v-if="k == search.keywords_fields && k < 4" />
|
||||
</b-input-group-text>
|
||||
<b-input-group-text style="width: 3em" @click="removeField('keywords', k)">
|
||||
<i class="fas fa-minus-circle text-primary" v-if="k == search.keywords_fields && k > 1"/>
|
||||
<i class="fas fa-minus-circle text-primary" v-if="k == search.keywords_fields && k > 1" />
|
||||
</b-input-group-text>
|
||||
</template>
|
||||
<generic-multiselect
|
||||
@@ -258,17 +258,17 @@
|
||||
<span
|
||||
class="text-sm-left text-warning"
|
||||
v-if="ui.expert_mode && search.foods_fields > 1 && hasDuplicateFilter(search.search_foods, search.foods_fields)"
|
||||
>{{ $t("warning_duplicate_filter") }}</span
|
||||
>{{ $t("warning_duplicate_filter") }}</span
|
||||
>
|
||||
<div class="row" v-if="ui.show_foods">
|
||||
<div class="col-12">
|
||||
<b-input-group class="mt-2" v-for="(f, i) in foodFields" :key="i">
|
||||
<template #prepend v-if="ui.expert_mode">
|
||||
<b-input-group-text style="width: 3em" @click="addField('foods', f)">
|
||||
<i class="fas fa-plus-circle text-primary" v-if="f == search.foods_fields && f < 4"/>
|
||||
<i class="fas fa-plus-circle text-primary" v-if="f == search.foods_fields && f < 4" />
|
||||
</b-input-group-text>
|
||||
<b-input-group-text style="width: 3em" @click="removeField('foods', f)">
|
||||
<i class="fas fa-minus-circle text-primary" v-if="f == search.foods_fields && f > 1"/>
|
||||
<i class="fas fa-minus-circle text-primary" v-if="f == search.foods_fields && f > 1" />
|
||||
</b-input-group-text>
|
||||
</template>
|
||||
<generic-multiselect
|
||||
@@ -314,17 +314,17 @@
|
||||
<span
|
||||
class="text-sm-left text-warning"
|
||||
v-if="ui.expert_mode && search.books_fields > 1 && hasDuplicateFilter(search.search_books, search.books_fields)"
|
||||
>{{ $t("warning_duplicate_filter") }}</span
|
||||
>{{ $t("warning_duplicate_filter") }}</span
|
||||
>
|
||||
<div class="row" v-if="ui.show_books">
|
||||
<div class="col-12">
|
||||
<b-input-group class="mt-2" v-for="(b, i) in bookFields" :key="i">
|
||||
<template #prepend v-if="ui.expert_mode">
|
||||
<b-input-group-text style="width: 3em" @click="addField('books', b)">
|
||||
<i class="fas fa-plus-circle text-primary" v-if="b == search.books_fields && b < 4"/>
|
||||
<i class="fas fa-plus-circle text-primary" v-if="b == search.books_fields && b < 4" />
|
||||
</b-input-group-text>
|
||||
<b-input-group-text style="width: 3em" @click="removeField('books', b)">
|
||||
<i class="fas fa-minus-circle text-primary" v-if="b == search.books_fields && b > 1"/>
|
||||
<i class="fas fa-minus-circle text-primary" v-if="b == search.books_fields && b > 1" />
|
||||
</b-input-group-text>
|
||||
</template>
|
||||
<generic-multiselect
|
||||
@@ -558,16 +558,26 @@
|
||||
<b-input-group-append v-if="ui.show_makenow">
|
||||
<b-input-group-text>
|
||||
{{ $t("make_now") }}
|
||||
<b-form-checkbox v-model="search.makenow" name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none" switch style="width: 4em"/>
|
||||
<b-form-checkbox
|
||||
v-model="search.makenow"
|
||||
name="check-button"
|
||||
@change="refreshData(false)"
|
||||
class="shadow-none"
|
||||
switch
|
||||
style="width: 4em"
|
||||
/>
|
||||
</b-input-group-text>
|
||||
<b-input-group-text>
|
||||
<span>{{ $t("make_now_count") }}</span>
|
||||
<b-form-input type="number" min="0" max="20" v-model="search.makenow_count"
|
||||
@change="refreshData(false)"
|
||||
size="sm" class="mt-1"></b-form-input>
|
||||
|
||||
<b-form-input
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
v-model="search.makenow_count"
|
||||
@change="refreshData(false)"
|
||||
size="sm"
|
||||
class="mt-1"
|
||||
></b-form-input>
|
||||
</b-input-group-text>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
@@ -607,11 +617,11 @@
|
||||
<!-- TODO find a way to localize this that works without explaining localization to each language translator -->
|
||||
Show all recipes that are matched
|
||||
<span v-if="search.search_input">
|
||||
by <i>{{ search.search_input }}</i> <br/>
|
||||
by <i>{{ search.search_input }}</i> <br />
|
||||
</span>
|
||||
<span v-else> without any search term <br/> </span>
|
||||
<span v-else> without any search term <br /> </span>
|
||||
|
||||
<span v-if="search.search_internal"> and are <span class="text-success">internal</span> <br/></span>
|
||||
<span v-if="search.search_internal"> and are <span class="text-success">internal</span> <br /></span>
|
||||
|
||||
<span v-for="k in search.search_keywords" v-bind:key="k.id">
|
||||
<template v-if="k.items.length > 0">
|
||||
@@ -620,7 +630,7 @@
|
||||
contain
|
||||
<b v-if="k.operator">any</b><b v-else>all</b> of the following <span class="text-success">keywords</span>:
|
||||
<i>{{ k.items.flatMap((x) => x.name).join(", ") }}</i>
|
||||
<br/>
|
||||
<br />
|
||||
</template>
|
||||
</span>
|
||||
|
||||
@@ -631,7 +641,7 @@
|
||||
contain
|
||||
<b v-if="k.operator">any</b><b v-else>all</b> of the following <span class="text-success">foods</span>:
|
||||
<i>{{ k.items.flatMap((x) => x.name).join(", ") }}</i>
|
||||
<br/>
|
||||
<br />
|
||||
</template>
|
||||
</span>
|
||||
|
||||
@@ -642,38 +652,38 @@
|
||||
contain
|
||||
<b v-if="k.operator">any</b><b v-else>all</b> of the following <span class="text-success">books</span>:
|
||||
<i>{{ k.items.flatMap((x) => x.name).join(", ") }}</i>
|
||||
<br/>
|
||||
<br />
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span v-if="search.makenow"> and you can <span class="text-success">make right now</span> (based on the on hand flag) <br/></span>
|
||||
<span v-if="search.makenow"> and you can <span class="text-success">make right now</span> (based on the on hand flag) <br /></span>
|
||||
|
||||
<span v-if="search.search_units.length > 0">
|
||||
and contain <b v-if="search.search_units_or">any</b><b v-else>all</b> of the following <span class="text-success">units</span>:
|
||||
<i>{{ search.search_units.flatMap((x) => x.name).join(", ") }}</i
|
||||
><br/>
|
||||
><br />
|
||||
</span>
|
||||
|
||||
<span v-if="search.search_rating !== undefined">
|
||||
and have a <span class="text-success">rating</span> <template v-if="search.search_rating_gte">greater than</template
|
||||
><template v-else> less than</template> or equal to {{ search.search_rating }}<br/>
|
||||
><template v-else> less than</template> or equal to {{ search.search_rating }}<br />
|
||||
</span>
|
||||
|
||||
<span v-if="search.lastcooked !== undefined">
|
||||
and have been <span class="text-success">last cooked</span> <template v-if="search.lastcooked_gte"> after</template
|
||||
><template v-else> before</template> <i>{{ search.lastcooked }}</i
|
||||
><br/>
|
||||
><template v-else> before</template> <i>{{ search.lastcooked }}</i
|
||||
><br />
|
||||
</span>
|
||||
|
||||
<span v-if="search.timescooked !== undefined">
|
||||
and have <span class="text-success">been cooked</span> <template v-if="search.timescooked_gte"> at least</template
|
||||
><template v-else> less than</template> or equal to<i>{{ search.timescooked }}</i> times <br/>
|
||||
><template v-else> less than</template> or equal to<i>{{ search.timescooked }}</i> times <br />
|
||||
</span>
|
||||
|
||||
<span v-if="search.sort_order.length > 0">
|
||||
<span class="text-success">order</span> by
|
||||
<i>{{ search.sort_order.flatMap((x) => x.text).join(", ") }}</i>
|
||||
<br/>
|
||||
<br />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -709,19 +719,19 @@
|
||||
</b-dropdown>
|
||||
|
||||
<b-button variant="outline-primary" size="sm" class="shadow-none ml-1" @click="resetSearch()" v-if="searchFiltered()"
|
||||
><i class="fas fa-file-alt"></i> {{ search.pagination_page }}/{{ Math.ceil(pagination_count / ui.page_size) }} {{ $t("Reset") }}
|
||||
><i class="fas fa-file-alt"></i> {{ search.pagination_page }}/{{ Math.ceil(pagination_count / ui.page_size) }} {{ $t("Reset") }}
|
||||
<i class="fas fa-times-circle"></i>
|
||||
</b-button>
|
||||
|
||||
<b-button variant="outline-primary" size="sm" class="shadow-none ml-1" @click="openRandom()"
|
||||
><i class="fas fa-dice-five"></i> {{ $t("Random") }}
|
||||
><i class="fas fa-dice-five"></i> {{ $t("Random") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="!searchFiltered() && ui.show_meal_plan && meal_plan_grid.length > 0">
|
||||
<hr/>
|
||||
<hr />
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div
|
||||
@@ -736,7 +746,7 @@
|
||||
</div>
|
||||
<div class="flex-grow-1 text-right">
|
||||
<b-button class="hover-button btn-outline-primary btn-sm" @click="showMealPlanEditModal(null, day.create_default_date)"
|
||||
><i class="fa fa-plus"></i
|
||||
><i class="fa fa-plus"></i
|
||||
></b-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -744,8 +754,8 @@
|
||||
<b-list-group-item v-for="plan in day.plan_entries" v-bind:key="plan.id" class="hover-div p-0 pr-2">
|
||||
<div class="d-flex flex-row align-items-center">
|
||||
<div>
|
||||
<img style="height: 50px; width: 50px; object-fit: cover" :src="plan.recipe.image" v-if="plan.recipe?.image"/>
|
||||
<img style="height: 50px; width: 50px; object-fit: cover" :src="image_placeholder" v-else/>
|
||||
<img style="height: 50px; width: 50px; object-fit: cover" :src="plan.recipe.image" v-if="plan.recipe?.image" />
|
||||
<img style="height: 50px; width: 50px; object-fit: cover" :src="image_placeholder" v-else />
|
||||
</div>
|
||||
<div class="flex-grow-1 ml-2" style="text-overflow: ellipsis; overflow-wrap: anywhere">
|
||||
<span class="two-row-text">
|
||||
@@ -755,7 +765,7 @@
|
||||
</div>
|
||||
<div class="hover-button">
|
||||
<b-button @click="showMealPlanEditModal(plan, null)" class="btn-outline-primary btn-sm"
|
||||
><i class="fas fa-pencil-alt"></i
|
||||
><i class="fas fa-pencil-alt"></i
|
||||
></b-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -765,7 +775,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<hr />
|
||||
</template>
|
||||
|
||||
<div v-if="recipes.length > 0" class="mt-4">
|
||||
@@ -853,24 +863,23 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import VueCookies from "vue-cookies"
|
||||
import { BootstrapVue } from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import Vue from "vue"
|
||||
import VueCookies from "vue-cookies"
|
||||
|
||||
import moment from "moment"
|
||||
import _debounce from "lodash/debounce"
|
||||
import moment from "moment"
|
||||
import Multiselect from "vue-multiselect"
|
||||
|
||||
import {ApiMixin, ResolveUrlMixin, StandardToasts, ToastMixin} from "@/utils/utils"
|
||||
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
|
||||
import RecipeCard from "@/components/RecipeCard"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
import {ApiApiFactory} from "@/utils/openapi/api"
|
||||
import {useMealPlanStore} from "@/stores/MealPlanStore"
|
||||
import BottomNavigationBar from "@/components/BottomNavigationBar.vue"
|
||||
import RecipeSwitcher from "@/components/Buttons/RecipeSwitcher"
|
||||
import GenericMultiselect from "@/components/GenericMultiselect"
|
||||
import MealPlanEditModal from "@/components/MealPlanEditModal.vue"
|
||||
import RecipeCard from "@/components/RecipeCard"
|
||||
import { useMealPlanStore } from "@/stores/MealPlanStore"
|
||||
import { ApiApiFactory } from "@/utils/openapi/api"
|
||||
import { ApiMixin, ResolveUrlMixin, StandardToasts, ToastMixin } from "@/utils/utils"
|
||||
|
||||
Vue.use(VueCookies)
|
||||
Vue.use(BootstrapVue)
|
||||
@@ -881,7 +890,7 @@ let UI_COOKIE_NAME = "ui_search_settings"
|
||||
export default {
|
||||
name: "RecipeSearchView",
|
||||
mixins: [ResolveUrlMixin, ApiMixin, ToastMixin],
|
||||
components: {GenericMultiselect, RecipeCard, RecipeSwitcher, Multiselect, BottomNavigationBar, MealPlanEditModal},
|
||||
components: { GenericMultiselect, RecipeCard, RecipeSwitcher, Multiselect, BottomNavigationBar, MealPlanEditModal },
|
||||
data() {
|
||||
return {
|
||||
// this.Models and this.Actions inherited from ApiMixin
|
||||
@@ -898,22 +907,22 @@ export default {
|
||||
search_input: "",
|
||||
search_internal: false,
|
||||
search_keywords: [
|
||||
{items: [], operator: true, not: false},
|
||||
{items: [], operator: false, not: false},
|
||||
{items: [], operator: true, not: true},
|
||||
{items: [], operator: false, not: true},
|
||||
{ items: [], operator: true, not: false },
|
||||
{ items: [], operator: false, not: false },
|
||||
{ items: [], operator: true, not: true },
|
||||
{ items: [], operator: false, not: true },
|
||||
],
|
||||
search_foods: [
|
||||
{items: [], operator: true, not: false},
|
||||
{items: [], operator: false, not: false},
|
||||
{items: [], operator: true, not: true},
|
||||
{items: [], operator: false, not: true},
|
||||
{ items: [], operator: true, not: false },
|
||||
{ items: [], operator: false, not: false },
|
||||
{ items: [], operator: true, not: true },
|
||||
{ items: [], operator: false, not: true },
|
||||
],
|
||||
search_books: [
|
||||
{items: [], operator: true, not: false},
|
||||
{items: [], operator: false, not: false},
|
||||
{items: [], operator: true, not: true},
|
||||
{items: [], operator: false, not: true},
|
||||
{ items: [], operator: true, not: false },
|
||||
{ items: [], operator: false, not: false },
|
||||
{ items: [], operator: true, not: true },
|
||||
{ items: [], operator: false, not: true },
|
||||
],
|
||||
search_units: [],
|
||||
search_units_or: true,
|
||||
@@ -986,7 +995,7 @@ export default {
|
||||
date: moment_date,
|
||||
create_default_date: moment_date.format("YYYY-MM-DD"), // improve meal plan edit modal to do formatting itself and accept dates
|
||||
date_label: moment_date.format("dd") + " " + moment_date.format("ll"),
|
||||
plan_entries: this.meal_plan_store.plan_list.filter((m) => moment_date.isBetween(moment(m.from_date), moment(m.to_date), 'day', '[]'))
|
||||
plan_entries: this.meal_plan_store.plan_list.filter((m) => moment_date.isBetween(moment(m.from_date), moment(m.to_date), "day", "[]")),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1032,12 +1041,12 @@ export default {
|
||||
}
|
||||
|
||||
return [
|
||||
{id: 5, label: "⭐⭐⭐⭐⭐ " + label(5)},
|
||||
{id: 4, label: "⭐⭐⭐⭐ " + label()},
|
||||
{id: 3, label: "⭐⭐⭐ " + label()},
|
||||
{id: 2, label: "⭐⭐ " + label()},
|
||||
{id: 1, label: "⭐ " + label(1)},
|
||||
{id: 0, label: this.$t("Unrated")},
|
||||
{ id: 5, label: "⭐⭐⭐⭐⭐ " + label(5) },
|
||||
{ id: 4, label: "⭐⭐⭐⭐ " + label() },
|
||||
{ id: 3, label: "⭐⭐⭐ " + label() },
|
||||
{ id: 2, label: "⭐⭐ " + label() },
|
||||
{ id: 1, label: "⭐ " + label(1) },
|
||||
{ id: 0, label: this.$t("Unrated") },
|
||||
]
|
||||
},
|
||||
keywordFields: function () {
|
||||
@@ -1063,7 +1072,7 @@ export default {
|
||||
[this.$t("Name"), "name", "A-z", "Z-a"],
|
||||
[this.$t("last_cooked"), "lastcooked", "↑", "↓"],
|
||||
[this.$t("Rating"), "rating", "1-5", "5-1"],
|
||||
[this.$t("times_cooked"), "favorite", "*-x", "x-*"],
|
||||
[this.$t("times_cooked"), "favorite", "x-X", "X-x"],
|
||||
[this.$t("date_created"), "created_at", "↑", "↓"],
|
||||
[this.$t("date_viewed"), "lastviewed", "↑", "↓"],
|
||||
]
|
||||
@@ -1093,7 +1102,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.recipes = Array(this.ui.page_size).fill({loading: true})
|
||||
this.recipes = Array(this.ui.page_size).fill({ loading: true })
|
||||
|
||||
this.$nextTick(function () {
|
||||
if (this.$cookies.isKey(UI_COOKIE_NAME)) {
|
||||
@@ -1162,13 +1171,13 @@ export default {
|
||||
"ui.expert_mode": function (newVal, oldVal) {
|
||||
if (!newVal) {
|
||||
this.search.search_keywords = this.search.search_keywords.map((x) => {
|
||||
return {...x, not: false}
|
||||
return { ...x, not: false }
|
||||
})
|
||||
this.search.search_foods = this.search.search_foods.map((x) => {
|
||||
return {...x, not: false}
|
||||
return { ...x, not: false }
|
||||
})
|
||||
this.search.search_books = this.search.search_books.map((x) => {
|
||||
return {...x, not: false}
|
||||
return { ...x, not: false }
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -1177,7 +1186,7 @@ export default {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
refreshData: _debounce(function (random) {
|
||||
this.recipes_loading = true
|
||||
this.recipes = Array(this.ui.page_size).fill({loading: true})
|
||||
this.recipes = Array(this.ui.page_size).fill({ loading: true })
|
||||
let params = this.buildParams(random)
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.LIST, params).then((result) => {
|
||||
window.scrollTo(0, 0)
|
||||
@@ -1220,13 +1229,13 @@ export default {
|
||||
},
|
||||
resetSearch: function (filter = undefined) {
|
||||
this.search.search_keywords = this.search.search_keywords.map((x) => {
|
||||
return {...x, items: []}
|
||||
return { ...x, items: [] }
|
||||
})
|
||||
this.search.search_foods = this.search.search_foods.map((x) => {
|
||||
return {...x, items: []}
|
||||
return { ...x, items: [] }
|
||||
})
|
||||
this.search.search_books = this.search.search_books.map((x) => {
|
||||
return {...x, items: []}
|
||||
return { ...x, items: [] }
|
||||
})
|
||||
this.search.search_input = filter?.query ?? ""
|
||||
this.search.search_internal = filter?.internal ?? false
|
||||
@@ -1289,7 +1298,7 @@ export default {
|
||||
return
|
||||
},
|
||||
buildParams: function (random) {
|
||||
let params = {options: {query: {}}, page: this.search.pagination_page, pageSize: this.ui.page_size}
|
||||
let params = { options: { query: {} }, page: this.search.pagination_page, pageSize: this.ui.page_size }
|
||||
if (this.search.search_filter) {
|
||||
params.options.query.filter = this.search.search_filter.id
|
||||
return params
|
||||
@@ -1414,7 +1423,7 @@ export default {
|
||||
;["page", "pageSize"].forEach((key) => {
|
||||
delete search[key]
|
||||
})
|
||||
search = {...search, ...search.options.query}
|
||||
search = { ...search, ...search.options.query }
|
||||
console.log("after concat", search)
|
||||
let params = {
|
||||
name: filtername,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-tabs content-class="mt-2" v-model="current_tab" class="mt-md-1" style="margin-top: 22px">
|
||||
<b-tabs content-class="mt-2" v-model="current_tab" class="mt-md-1" style="margin-top: 22px;">
|
||||
<!-- shopping list tab -->
|
||||
<b-tab active>
|
||||
<template #title>
|
||||
@@ -37,12 +37,12 @@
|
||||
<span
|
||||
class="d-none d-md-inline-block">{{ $t('Shopping_list') + ` (${items.filter(x => x.checked === false).length})` }}</span>
|
||||
</template>
|
||||
<div class="container p-0 p-md-3" id="shoppinglist">
|
||||
<div class="row">
|
||||
<div class="container p-0 p-md-3 pb-5" id="shoppinglist">
|
||||
<div class="row pb-5">
|
||||
<div class="col col-md-12 p-0 p-lg-3">
|
||||
<div role="tablist">
|
||||
<!-- add to shopping form -->
|
||||
<div class="container">
|
||||
<div class="container d-lg-block d-print-none d-none">
|
||||
<b-row class="justify-content-md-center align-items-center pl-1 pr-1"
|
||||
v-if="entrymode">
|
||||
<b-col cols="12" md="3" v-if="!ui.entry_mode_simple"
|
||||
@@ -567,14 +567,25 @@
|
||||
:modal_id="new_recipe.id" @finish="finishShopping" :list_recipe="new_recipe.list_recipe"/>
|
||||
|
||||
<bottom-navigation-bar active-view="view_shopping">
|
||||
<template #custom_nav_content>
|
||||
<div class="d-flex flex-row justify-content-around mb-3">
|
||||
|
||||
<b-input-group>
|
||||
<b-form-input v-model="new_item.ingredient" :placeholder="$t('Food')"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button @click="addItem" variant="success">
|
||||
<i class="fas fa-cart-plus "/>
|
||||
</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #custom_create_functions>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
<h6 class="dropdown-header">{{ $t('Shopping_list')}}</h6>
|
||||
|
||||
<a class="dropdown-item" @click="entrymode = !entrymode; " ><i class="fas fa-cart-plus"></i>
|
||||
{{ $t("New_Entry") }}
|
||||
</a>
|
||||
<h6 class="dropdown-header">{{ $t('Shopping_list') }}</h6>
|
||||
|
||||
<DownloadPDF dom="#shoppinglist" name="shopping.pdf" :label="$t('download_pdf')"
|
||||
icon="far fa-file-pdf fa-fw"/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
|
||||
<div class="row mt-2">
|
||||
<div class="col col-12">
|
||||
<b-row class="mt-2">
|
||||
<b-col cols="12">
|
||||
<div v-if="space !== undefined">
|
||||
<h6><i class="fas fa-book"></i> {{ $t('Recipes') }}</h6>
|
||||
<b-progress height="1.5rem" :max="space.max_recipes" variant="success" :striped="true">
|
||||
@@ -32,13 +32,13 @@
|
||||
</b-progress>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col col-12">
|
||||
<b-row class="mt-4">
|
||||
<b-col cols="12">
|
||||
<div v-if="user_spaces !== undefined">
|
||||
<h4 class="mt-2"><i class="fas fa-users"></i> {{ $t('Users') }}</h4>
|
||||
<h4><i class="fas fa-users"></i> {{ $t('Users') }}</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -51,14 +51,14 @@
|
||||
<td>{{ us.user.display_name }}</td>
|
||||
<td>
|
||||
<generic-multiselect
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="us.groups = $event.val; updateUserSpace(us)"
|
||||
label="name"
|
||||
:initial_selection="us.groups"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="true"
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="us.groups = $event.val; updateUserSpace(us)"
|
||||
label="name"
|
||||
:initial_selection="us.groups"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="true"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
@@ -67,12 +67,12 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
|
||||
<div class="row mt-2">
|
||||
<div class="col col-12">
|
||||
<b-row class="mt-2">
|
||||
<b-col cols="12">
|
||||
<div v-if="invite_links !== undefined">
|
||||
<h4 class="mt-2"><i class="fas fa-users"></i> {{ $t('Invites') }}</h4>
|
||||
<table class="table">
|
||||
@@ -90,14 +90,14 @@
|
||||
<td>{{ il.email }}</td>
|
||||
<td>
|
||||
<generic-multiselect
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="il.group = $event.val;"
|
||||
label="name"
|
||||
:initial_single_selection="il.group"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
class="input-group-text m-0 p-0"
|
||||
@change="il.group = $event.val;"
|
||||
label="name"
|
||||
:initial_single_selection="il.group"
|
||||
:model="Models.GROUP"
|
||||
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
|
||||
:limit="10"
|
||||
:multiple="false"
|
||||
/>
|
||||
</td>
|
||||
<td><input type="date" v-model="il.valid_until" class="form-control"></td>
|
||||
@@ -131,46 +131,133 @@
|
||||
</table>
|
||||
<b-button variant="primary" @click="show_invite_create = true">{{ $t('Create') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<div class="row mt-4" v-if="space !== undefined">
|
||||
<div class="col col-12">
|
||||
<h4 class="mt-2"><i class="fas fa-cogs"></i> {{ $t('Settings') }}</h4>
|
||||
<b-row class="mt-4" v-if="space !== undefined">
|
||||
<b-col cols="12">
|
||||
<h4>{{ $t('Cosmetic') }}</h4>
|
||||
<b-alert variant="warning" show><i class="fas fa-exclamation-triangle"></i> {{ $t('Space_Cosmetic_Settings') }}</b-alert>
|
||||
|
||||
<label>{{ $t('Message') }}</label>
|
||||
<b-form-textarea v-model="space.message"></b-form-textarea>
|
||||
<b-form-group :label="$t('Image')" :description="$t('CustomImageHelp')">
|
||||
<generic-multiselect :initial_single_selection="space.image"
|
||||
:model="Models.USERFILE"
|
||||
:multiple="false"
|
||||
@change="space.image = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<label>{{ $t('Image') }}</label>
|
||||
<generic-multiselect :initial_single_selection="space.image"
|
||||
:model="Models.USERFILE"
|
||||
:multiple="false"
|
||||
@change="space.image = $event.val;"></generic-multiselect>
|
||||
<br/>
|
||||
<b-form-group :label="$t('Logo')" :description="$t('CustomNavLogoHelp')">
|
||||
<generic-multiselect :initial_single_selection="space.nav_logo"
|
||||
:model="Models.USERFILE"
|
||||
:multiple="false"
|
||||
@change="space.nav_logo = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-checkbox v-model="space.show_facet_count"> Facet Count</b-form-checkbox>
|
||||
<span class="text-muted small">{{ $t('facet_count_info') }}</span><br/>
|
||||
<b-form-group :label="$t('Theme')">
|
||||
<b-form-select v-model="space.space_theme">
|
||||
<b-form-select-option value="BLANK">----</b-form-select-option>
|
||||
<b-form-select-option value="TANDOOR">Tandoor</b-form-select-option>
|
||||
<b-form-select-option value="TANDOOR_DARK">Tandoor Dark (Beta)</b-form-select-option>
|
||||
<b-form-select-option value="BOOTSTRAP">Bootstrap</b-form-select-option>
|
||||
<b-form-select-option value="DARKLY">Darkly</b-form-select-option>
|
||||
<b-form-select-option value="FLATLY">Flatly</b-form-select-option>
|
||||
<b-form-select-option value="SUPERHERO">Superhero</b-form-select-option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<label>{{ $t('FoodInherit') }}</label>
|
||||
<generic-multiselect :initial_selection="space.food_inherit"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
@change="space.food_inherit = $event.val;">
|
||||
</generic-multiselect>
|
||||
<span class="text-muted small">{{ $t('food_inherit_info') }}</span><br/>
|
||||
|
||||
<a class="btn btn-success" @click="updateSpace()">{{ $t('Update') }}</a><br/>
|
||||
<a class="btn btn-warning mt-1" @click="resetInheritance()">{{ $t('reset_food_inheritance') }}</a><br/>
|
||||
<span class="text-muted small">{{ $t('reset_food_inheritance_info') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<b-form-group :label="$t('CustomTheme')" :description="$t('CustomThemeHelp')">
|
||||
<generic-multiselect :initial_single_selection="space.custom_space_theme"
|
||||
:model="Models.USERFILE"
|
||||
:multiple="false"
|
||||
@change="space.custom_space_theme = $event.val;"></generic-multiselect>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Nav_Color')" :description="$t('Nav_Color_Help')">
|
||||
<b-input-group>
|
||||
<b-form-input type="color" v-model="space.nav_bg_color"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button @click="space.nav_bg_color = ''">{{ $t('Reset') }}</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Nav_Text_Mode')" :description="$t('Nav_Text_Mode_Help')">
|
||||
<b-form-select v-model="space.nav_text_color">
|
||||
<b-form-select-option value="BLANK">----</b-form-select-option>
|
||||
<b-form-select-option value="LIGHT">Light</b-form-select-option>
|
||||
<b-form-select-option value="DARK">Dark</b-form-select-option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<h5>{{ $t('CustomLogos') }}</h5>
|
||||
<p>{{$t('CustomLogoHelp')}} </p>
|
||||
<b-form-group :label="$t('Logo')+' 32x32px'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_32"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_32 = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Logo')+' 128x128px'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_128"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_128 = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Logo')+' 144x144px'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_144"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_144 = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Logo')+' 180x180px'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_180"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_180 = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Logo')+' 192x192px'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_192"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_192 = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Logo')+' 512x512px'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_512"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_512 = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
<b-form-group :label="$t('Logo')+' SVG'">
|
||||
<generic-multiselect :initial_single_selection="space.logo_color_svg"
|
||||
:model="Models.USERFILE" :multiple="false" @change="space.logo_color_svg = $event.val;"></generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-button variant="success" @click="updateSpace()">{{ $t('Update') }}</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row class="mt-4" v-if="space !== undefined">
|
||||
<b-col cols="12">
|
||||
<h4><i class="fas fa-cogs"></i> {{ $t('Settings') }}</h4>
|
||||
|
||||
<b-form-group :label="$t('Message')">
|
||||
<b-form-textarea v-model="space.message"></b-form-textarea>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('FoodInherit')" :description="$t('food_inherit_info')">
|
||||
<generic-multiselect :initial_selection="space.food_inherit"
|
||||
:model="Models.FOOD_INHERIT_FIELDS"
|
||||
@change="space.food_inherit = $event.val;">
|
||||
</generic-multiselect>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('reset_food_inheritance_info')">
|
||||
<b-button-group class="mt-2">
|
||||
<b-button variant="success" @click="updateSpace()">{{ $t('Update') }}</b-button>
|
||||
<b-button variant="warning" @click="resetInheritance()">{{ $t('reset_food_inheritance') }}</b-button>
|
||||
</b-button-group>
|
||||
</b-form-group>
|
||||
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row class="mt-4">
|
||||
<b-col cols="12">
|
||||
<h4>{{ $t('Open_Data_Import') }}</h4>
|
||||
<open-data-import-component></open-data-import-component>
|
||||
</div>
|
||||
</b-col>
|
||||
|
||||
</div>
|
||||
</b-row>
|
||||
|
||||
|
||||
<div class="row mt-4">
|
||||
|
||||
@@ -2,34 +2,8 @@
|
||||
|
||||
<div id="app">
|
||||
<div>
|
||||
<h2 v-if="recipe">{{ recipe.name}}</h2>
|
||||
<markdown-editor-component></markdown-editor-component>
|
||||
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{{ $t('Name') }}</td>
|
||||
<td v-for="pt in property_types" v-bind:key="pt.id">{{ pt.name }}
|
||||
<input type="text" v-model="pt.unit" @change="updatePropertyType(pt)">
|
||||
<input v-model="pt.fdc_id" type="number" placeholder="FDC ID" @change="updatePropertyType(pt)"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in this.foods" v-bind:key="f.food.id">
|
||||
<td>
|
||||
{{ f.food.name }}
|
||||
{{ $t('Property') }} / <input type="number" v-model="f.food.properties_food_amount" @change="updateFood(f.food)">
|
||||
<generic-multiselect
|
||||
@change="f.food.properties_food_unit = $event.val; updateFood(f.food)"
|
||||
:initial_selection="f.food.properties_food_unit"
|
||||
label="name" :model="Models.UNIT"
|
||||
:multiple="false"/>
|
||||
<input v-model="f.food.fdc_id" placeholder="FDC ID">
|
||||
<button>Load FDC</button>
|
||||
</td>
|
||||
<td v-for="p in f.properties" v-bind:key="`${f.id}_${p.property_type.id}`"><input type="number" v-model="p.property_amount"> {{ p.property_type.unit }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,6 +19,9 @@ import axios from "axios";
|
||||
import BetaWarning from "@/components/BetaWarning.vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||
import {Models} from "@/utils/models";
|
||||
import MarkdownEditorComponent from "@/components/MarkdownEditorComponent.vue";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
@@ -53,65 +30,19 @@ Vue.use(BootstrapVue)
|
||||
export default {
|
||||
name: "TestView",
|
||||
mixins: [ApiMixin],
|
||||
components: {GenericMultiselect},
|
||||
computed: {
|
||||
foods: function () {
|
||||
let foods = []
|
||||
if (this.recipe !== null && this.property_types !== []) {
|
||||
this.recipe.steps.forEach(s => {
|
||||
s.ingredients.forEach(i => {
|
||||
let food = {food: i.food, properties: {}}
|
||||
|
||||
this.property_types.forEach(pt => {
|
||||
food.properties[pt.id] = {changed: false, property_amount: 0, property_type: pt}
|
||||
})
|
||||
i.food.properties.forEach(fp => {
|
||||
food.properties[fp.property_type.id] = {changed: false, property_amount: fp.property_amount, property_type: fp.property_type}
|
||||
})
|
||||
foods.push(food)
|
||||
})
|
||||
})
|
||||
}
|
||||
return foods
|
||||
}
|
||||
},
|
||||
components: {MarkdownEditorComponent},
|
||||
computed: {},
|
||||
data() {
|
||||
return {
|
||||
recipe: null,
|
||||
property_types: []
|
||||
}
|
||||
return {}
|
||||
},
|
||||
mounted() {
|
||||
this.$i18n.locale = window.CUSTOM_LOCALE
|
||||
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
apiClient.retrieveRecipe("112").then(result => {
|
||||
this.recipe = result.data
|
||||
})
|
||||
|
||||
apiClient.listPropertyTypes().then(result => {
|
||||
this.property_types = result.data
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
updateFood: function (food) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.partialUpdateFood(food.id, food).then(result => {
|
||||
//TODO handle properly
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
},
|
||||
updatePropertyType: function (pt) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.partialUpdatePropertyType(pt.id, pt).then(result => {
|
||||
//TODO handle properly
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
refreshData: function () {
|
||||
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -227,11 +227,18 @@ export default {
|
||||
|
||||
async autoPlanThread(autoPlan, mealTypeIndex) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
let keyword_ids = []
|
||||
for (const index in autoPlan.keywords[mealTypeIndex]){
|
||||
let keyword = autoPlan.keywords[mealTypeIndex][index]
|
||||
keyword_ids.push(keyword.id)
|
||||
}
|
||||
|
||||
let data = {
|
||||
"start_date": moment(autoPlan.startDay).format("YYYY-MM-DD"),
|
||||
"end_date": moment(autoPlan.endDay).format("YYYY-MM-DD"),
|
||||
"meal_type_id": autoPlan.meal_types[mealTypeIndex].id,
|
||||
"keywords": autoPlan.keywords[mealTypeIndex],
|
||||
"keyword_ids": keyword_ids,
|
||||
"servings": autoPlan.servings,
|
||||
"shared": autoPlan.shared,
|
||||
"addshopping": autoPlan.addshopping
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<!-- bottom button nav -->
|
||||
<div class="fixed-bottom p-1 pt-2 pl-2 pr-2 border-top text-center d-lg-none d-print-none bottom-action-bar bg-white">
|
||||
|
||||
<slot name="custom_nav_content">
|
||||
|
||||
</slot>
|
||||
|
||||
|
||||
<div class="d-flex flex-row justify-content-around">
|
||||
<div class="flex-column" v-if="show_button_1">
|
||||
<slot name="button_1">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div style="cursor:pointer">
|
||||
<a v-if="!button" class="dropdown-item" @click="clipboard"><i :class="icon"></i> {{ label }}</a>
|
||||
<a v-if="!button" class="dropdown-item" @click="clipboard" href="#"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="clipboard">{{ label }}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div style="cursor:pointer">
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile" href="#"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div style="cursor:pointer">
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
<div style="cursor:pointer;">
|
||||
<a v-if="!button" class="dropdown-item" @click="downloadFile" href="#"><i :class="icon"></i> {{ label }}</a>
|
||||
<b-button class="dropdown-item" v-if="button" @click="downloadFile">{{ label }}</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template>
|
||||
<div v-if="recipes !== {}">
|
||||
<div id="switcher" class="align-center">
|
||||
<i class="btn btn-primary fas fa-receipt fa-xl fa-fw shadow-none btn-circle" v-b-toggle.related-recipes/>
|
||||
<i class="btn btn-primary fas fa-receipt fa-xl fa-fw shadow-none btn-circle" v-b-toggle.related-recipes />
|
||||
</div>
|
||||
<b-sidebar id="related-recipes" backdrop right bottom no-header shadow="sm" style="z-index: 10000"
|
||||
@shown="updatePinnedRecipes()">
|
||||
<b-sidebar id="related-recipes" backdrop right bottom no-header shadow="sm" style="z-index: 10000" @shown="updatePinnedRecipes()">
|
||||
<template #default="{ hide }">
|
||||
<div class="d-flex flex-column justify-content-end h-100 p-3 align-items-end">
|
||||
<h5>{{ $t("Planned") }} <i class="fas fa-calendar fa-fw"></i></h5>
|
||||
@@ -19,7 +18,7 @@
|
||||
hide()
|
||||
"
|
||||
href="javascript:void(0);"
|
||||
>{{ r.name }}</a
|
||||
>{{ r.name }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,8 +35,7 @@
|
||||
<div v-for="r in pinned_recipes" :key="`pin${r.id}`">
|
||||
<b-row class="pb-1 pt-1">
|
||||
<b-col cols="2">
|
||||
<a href="javascript:void(0)" @click="unPinRecipe(r)" class="text-muted"><i
|
||||
class="fas fa-times"></i></a>
|
||||
<a href="javascript:void(0)" @click="unPinRecipe(r)" class="text-muted"><i class="fas fa-times"></i></a>
|
||||
</b-col>
|
||||
<b-col cols="10">
|
||||
<a
|
||||
@@ -47,7 +45,7 @@
|
||||
"
|
||||
href="javascript:void(0);"
|
||||
class="align-self-end"
|
||||
>{{ r.name }}
|
||||
>{{ r.name }}
|
||||
</a>
|
||||
</b-col>
|
||||
</b-row>
|
||||
@@ -69,7 +67,7 @@
|
||||
hide()
|
||||
"
|
||||
href="javascript:void(0);"
|
||||
>{{ r.name }}</a
|
||||
>{{ r.name }}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,14 +86,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const {ApiApiFactory} = require("@/utils/openapi/api")
|
||||
import {ResolveUrlMixin} from "@/utils/utils"
|
||||
const { ApiApiFactory } = require("@/utils/openapi/api")
|
||||
import { ResolveUrlMixin } from "@/utils/utils"
|
||||
|
||||
export default {
|
||||
name: "RecipeSwitcher",
|
||||
mixins: [ResolveUrlMixin],
|
||||
props: {
|
||||
recipe: {type: Number, default: undefined},
|
||||
recipe: { type: Number, default: undefined },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -160,14 +158,16 @@ export default {
|
||||
// get related recipes and save them for later
|
||||
if (this.$parent.recipe) {
|
||||
this.related_recipes = [this.$parent.recipe]
|
||||
return apiClient.relatedRecipe(this.$parent.recipe.id, {
|
||||
query: {
|
||||
levels: 2,
|
||||
format: "json"
|
||||
}
|
||||
}).then((result) => {
|
||||
this.related_recipes = this.related_recipes.concat(result.data)
|
||||
})
|
||||
return apiClient
|
||||
.relatedRecipe(this.$parent.recipe.id, {
|
||||
query: {
|
||||
levels: 2,
|
||||
format: "json",
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.related_recipes = this.related_recipes.concat(result.data)
|
||||
})
|
||||
}
|
||||
},
|
||||
loadPinnedRecipes: function () {
|
||||
@@ -179,16 +179,16 @@ export default {
|
||||
// TODO move to utility function moment is in maintenance mode https://momentjs.com/docs/
|
||||
var tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
|
||||
let today = new Date(Date.now() - tzoffset).toISOString().split("T")[0]
|
||||
return apiClient.listMealPlans({query: {from_date: today, to_date: today}}).then((result) => {
|
||||
return apiClient.listMealPlans(today, today).then((result) => {
|
||||
let promises = []
|
||||
result.data.forEach((mealplan) => {
|
||||
this.planned_recipes.push({...mealplan?.recipe, servings: mealplan?.servings})
|
||||
this.planned_recipes.push({ ...mealplan?.recipe, servings: mealplan?.servings })
|
||||
const serving_factor = (mealplan?.servings ?? mealplan?.recipe?.servings ?? 1) / (mealplan?.recipe?.servings ?? 1)
|
||||
promises.push(
|
||||
apiClient.relatedRecipe(mealplan?.recipe?.id, {query: {levels: 2}}).then((r) => {
|
||||
apiClient.relatedRecipe(mealplan?.recipe?.id, { query: { levels: 2 } }).then((r) => {
|
||||
// scale all recipes to mealplan servings
|
||||
r.data = r.data.map((x) => {
|
||||
return {...x, factor: serving_factor}
|
||||
return { ...x, factor: serving_factor }
|
||||
})
|
||||
this.planned_recipes = [...this.planned_recipes, ...r.data]
|
||||
})
|
||||
@@ -220,7 +220,6 @@ export default {
|
||||
z-index: 9000;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
#switcher .btn-circle {
|
||||
position: fixed;
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
|
||||
<h5><i class="fas fa-database"></i> {{ $t('Properties') }}</h5>
|
||||
|
||||
<b-form-group :label="$t('Properties Food Amount')" description=""> <!-- TODO localize -->
|
||||
<b-form-group :label="$t('Properties_Food_Amount')" description="">
|
||||
<b-form-input v-model="food.properties_food_amount"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Properties Food Unit')" description=""> <!-- TODO localize -->
|
||||
<b-form-group :label="$t('Properties_Food_Unit')" description="">
|
||||
<generic-multiselect
|
||||
@change="food.properties_food_unit = $event.val;"
|
||||
:model="Models.UNIT"
|
||||
@@ -204,6 +204,12 @@
|
||||
}}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
<b-form-group :description="$t('substitute_children_help')">
|
||||
<b-form-checkbox v-model="food.substitute_children">{{
|
||||
$t('substitute_children')
|
||||
}}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('InheritFields')" :description="$t('InheritFields_help')">
|
||||
<generic-multiselect
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
@input="selectionChanged"
|
||||
@tag="addNew"
|
||||
@open="selectOpened()"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</multiselect>
|
||||
</template>
|
||||
@@ -74,6 +75,7 @@ export default {
|
||||
allow_create: { type: Boolean, default: false },
|
||||
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
|
||||
clear: { type: Number },
|
||||
disabled: {type: Boolean, default: false, },
|
||||
},
|
||||
watch: {
|
||||
initial_selection: function (newVal, oldVal) {
|
||||
|
||||
106
vue/src/components/MarkdownEditorComponent.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>EDITOR</h1>
|
||||
<div id="editor" style="" class="bg-info">
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {EditorState} from "@codemirror/state"
|
||||
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin} from "@codemirror/view"
|
||||
import {defaultKeymap} from "@codemirror/commands"
|
||||
|
||||
import {markdown} from "@codemirror/lang-markdown"
|
||||
import {autocompletion} from "@codemirror/autocomplete"
|
||||
|
||||
class PlaceholderWidget extends WidgetType { //TODO this is not working for some javascript magic reason
|
||||
name = undefined
|
||||
constructor(name) {
|
||||
console.log(name)
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other) {
|
||||
return this.name == other.name
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
let elt = document.createElement("span")
|
||||
elt.style.cssText = `
|
||||
border: 1px solid blue;
|
||||
border-radius: 4px;
|
||||
padding: 0 3px;
|
||||
background: lightblue;`
|
||||
|
||||
elt.textContent = "Food"
|
||||
return elt
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "MarkdownEditorComponent",
|
||||
props: {},
|
||||
computed: {},
|
||||
mounted() {
|
||||
|
||||
const placeholderMatcher = new MatchDecorator({
|
||||
regexp: /\{\{\singredients\[\d\]\s\}\}/g,
|
||||
decoration: match => Decoration.replace({
|
||||
widget: new PlaceholderWidget(match[0]),
|
||||
})
|
||||
})
|
||||
|
||||
const placeholders = ViewPlugin.fromClass(class {
|
||||
placeholders
|
||||
|
||||
constructor(view) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view)
|
||||
}
|
||||
|
||||
update(update) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders)
|
||||
}
|
||||
}, {
|
||||
decorations: instance => instance.placeholders,
|
||||
provide: plugin => EditorView.atomicRanges.of(view => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none
|
||||
})
|
||||
})
|
||||
|
||||
let startState = EditorState.create({
|
||||
doc: "Das ist eine Beschreibung \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla",
|
||||
extensions: [keymap.of(defaultKeymap), placeholders, markdown(), autocompletion({override: [this.foodTemplateAutoComplete]})]
|
||||
})
|
||||
|
||||
let view = new EditorView({
|
||||
state: startState,
|
||||
extensions: [],
|
||||
parent: document.getElementById("editor")
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
foodTemplateAutoComplete: function (context) {
|
||||
let word = context.matchBefore(/\w*/)
|
||||
if (word.from == word.to && !context.explicit)
|
||||
return null
|
||||
return {
|
||||
from: word.from,
|
||||
options: [
|
||||
{label: "Mehl", type: "text", apply: "{{ ingredients[1] }}", detail: "template"},
|
||||
{label: "Butter", type: "text", apply: "{{ ingredients[2] }}", detail: "template"},
|
||||
{label: "Salz", type: "text", apply: "{{ ingredients[3] }}", detail: "template"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -27,7 +27,7 @@
|
||||
<b-form-input type="date" v-model="entryEditing.from_date"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="secondary" @click="entryEditing.from_date = changeDate(entryEditing.from_date, -1)"><i class="fas fa-minus"></i></b-button>
|
||||
<b-button variant="primary" @click="entryEditing.from_date = changeDate(entryEditing.from_date, 1)"><i class="fas fa-plus"></i></b-button>
|
||||
<b-button variant="primary" @click="entryEditing.from_date = changeDate(entryEditing.from_date, 1)"><i class="fas fa-plus"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<b-form-input type="date" v-model="entryEditing.to_date"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button variant="secondary" @click="entryEditing.to_date = changeDate(entryEditing.to_date, -1)"><i class="fas fa-minus"></i></b-button>
|
||||
<b-button variant="primary" @click="entryEditing.to_date = changeDate(entryEditing.to_date, 1)"><i class="fas fa-plus"></i></b-button>
|
||||
<b-button variant="primary" @click="entryEditing.to_date = changeDate(entryEditing.to_date, 1)"><i class="fas fa-plus"></i></b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
<small tabindex="-1" class="form-text text-muted">{{ $t("EndDate") }}</small>
|
||||
@@ -209,8 +209,14 @@ export default {
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
entryEditing: {
|
||||
handler(newVal) {
|
||||
'entryEditing.from_date': {
|
||||
handler(newVal, oldVal) {
|
||||
if (newVal !== undefined && oldVal !== undefined) {
|
||||
if (newVal !== oldVal && newVal !== this.entryEditing.to_date) {
|
||||
let change = Math.abs(moment(oldVal).diff(moment(this.entryEditing.to_date), 'days')) // even though negative numbers might be correct, they would be illogical as to needs to always be larger than from
|
||||
this.entryEditing.to_date = moment(newVal).add(change, 'd').format("YYYY-MM-DD")
|
||||
}
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
@@ -312,7 +318,7 @@ export default {
|
||||
this.entryEditing.servings = 1
|
||||
}
|
||||
},
|
||||
changeDate(date, change){
|
||||
changeDate(date, change) {
|
||||
return moment(date).add(change, 'd').format("YYYY-MM-DD")
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,6 +25,12 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.new_value = this.value
|
||||
|
||||
if (this.new_value === "") { // if the selection is empty but the options are of type number, set to 0 instead of ""
|
||||
if (typeof this.options[0]['value'] === 'number') {
|
||||
this.new_value = 0
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
new_value: function () {
|
||||
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
props: {
|
||||
field: { type: String, default: "You Forgot To Set Field Name" },
|
||||
label: { type: String, default: "Text Field" },
|
||||
value: { type: String, default: "" },
|
||||
value: { type: Number, default: 0 },
|
||||
placeholder: { type: Number, default: 0 },
|
||||
help: { type: String, default: undefined },
|
||||
subtitle: { type: String, default: undefined },
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
|
||||
<table class="table table-bordered table-sm">
|
||||
<tr >
|
||||
<tr>
|
||||
<td style="border-top: none"></td>
|
||||
<td class="text-right" style="border-top: none">{{ $t('per_serving') }}</td>
|
||||
<td class="text-right" style="border-top: none">{{ $t('total') }}</td>
|
||||
@@ -41,14 +41,18 @@
|
||||
|
||||
<td class="align-middle text-center" v-if="!show_recipe_properties">
|
||||
<a href="#" @click="selected_property = p">
|
||||
<i v-if="p.missing_value" class="text-warning fas fa-exclamation-triangle"></i>
|
||||
<i v-if="!p.missing_value" class="text-muted fas fa-info-circle"></i>
|
||||
<!-- <i v-if="p.missing_value" class="text-warning fas fa-exclamation-triangle"></i>-->
|
||||
<!-- <i v-if="!p.missing_value" class="text-muted fas fa-info-circle"></i>-->
|
||||
<i class="text-muted fas fa-info-circle"></i>
|
||||
<!-- TODO find solution for missing values as 0 can either be missing or actually correct for any given property -->
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
|
||||
<div class="text-center">
|
||||
<b-button variant="success" :href="resolveDjangoUrl('view_property_editor', recipe.id)"><i class="fas fa-table"></i> {{ $t('Property_Editor') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -79,7 +83,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {ApiMixin, roundDecimals, StandardToasts} from "@/utils/utils";
|
||||
import {ApiMixin, resolveDjangoUrl, roundDecimals, StandardToasts} from "@/utils/utils";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
|
||||
@@ -153,11 +157,11 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a,b){
|
||||
if(a.type.order > b.type.order){
|
||||
function compare(a, b) {
|
||||
if (a.type.order > b.type.order) {
|
||||
return 1
|
||||
}
|
||||
if(a.type.order < b.type.order){
|
||||
if (a.type.order < b.type.order) {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
@@ -172,6 +176,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resolveDjangoUrl,
|
||||
roundDecimals,
|
||||
openFoodEditModal: function (food) {
|
||||
console.log(food)
|
||||
|
||||
@@ -250,6 +250,10 @@ export default {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.content:hover .card-img-overlay {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.content-details {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)" v-if="!disabled_options.edit"><i
|
||||
class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
|
||||
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('view_property_editor', recipe.id)" v-if="!disabled_options.edit">
|
||||
<i class="fas fa-table"></i> {{ $t("Property_Editor") }}</a>
|
||||
|
||||
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)"
|
||||
v-if="!recipe.internal && !disabled_options.convert"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
|
||||
|
||||
@@ -209,6 +212,7 @@ export default {
|
||||
this.entryEditing = this.options.entryEditing
|
||||
this.entryEditing.recipe = this.recipe
|
||||
this.entryEditing.from_date = moment(new Date()).format("YYYY-MM-DD")
|
||||
this.entryEditing.to_date = moment(new Date()).format("YYYY-MM-DD")
|
||||
this.$nextTick(function () {
|
||||
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
|
||||
})
|
||||
@@ -259,9 +263,11 @@ export default {
|
||||
},
|
||||
}
|
||||
})
|
||||
if (recipe.nutrition !== null) {
|
||||
delete recipe.nutrition.id
|
||||
}
|
||||
|
||||
recipe.properties = recipe.properties.map(p => {
|
||||
return { ...p, ...{ id: undefined, } }
|
||||
})
|
||||
|
||||
apiClient
|
||||
.createRecipe(recipe)
|
||||
.then((new_recipe) => {
|
||||
|
||||
@@ -277,9 +277,7 @@ export default {
|
||||
}
|
||||
},
|
||||
handleResize: function () {
|
||||
if (document.getElementById('nutrition_container') !== null) {
|
||||
this.ingredient_height = document.getElementById('ingredient_container').clientHeight - document.getElementById('nutrition_container').clientHeight
|
||||
} else {
|
||||
if (document.getElementById('ingredient_container') !== null) {
|
||||
this.ingredient_height = document.getElementById('ingredient_container').clientHeight
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,34 +49,47 @@
|
||||
</b-form-select>
|
||||
|
||||
</b-form-group>
|
||||
|
||||
<b-alert variant="warning" show><i class="fas fa-exclamation-triangle"></i> {{ $t('Space_Cosmetic_Settings') }}</b-alert>
|
||||
|
||||
<b-form-group :label="$t('Theme')">
|
||||
<b-form-select v-model="user_preferences.theme" @change="updateSettings(true);">
|
||||
<b-form-select-option value="TANDOOR">Tandoor</b-form-select-option>
|
||||
<b-form-select-option value="TANDOOR_DARK">Tandoor Dark (Beta)</b-form-select-option>
|
||||
<b-form-select-option value="BOOTSTRAP">Bootstrap</b-form-select-option>
|
||||
<b-form-select-option value="DARKLY">Darkly</b-form-select-option>
|
||||
<b-form-select-option value="FLATLY">Flatly</b-form-select-option>
|
||||
<b-form-select-option value="SUPERHERO">Superhero</b-form-select-option>
|
||||
<b-form-select-option value="TANDOOR_DARK">Tandoor Dark (INCOMPLETE)</b-form-select-option>
|
||||
</b-form-select>
|
||||
|
||||
</b-form-group>
|
||||
<b-form-group :description="$t('Sticky_Nav_Help')">
|
||||
<b-form-checkbox v-model="user_preferences.sticky_navbar" @change="updateSettings(true);">
|
||||
{{ $t('Sticky_Nav') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Nav_Color')" :description="$t('Nav_Color_Help')">
|
||||
<b-form-select v-model="user_preferences.nav_color" @change="updateSettings(true);">
|
||||
<b-form-select-option value="PRIMARY">Primary</b-form-select-option>
|
||||
<b-form-select-option value="SECONDARY">Secondary</b-form-select-option>
|
||||
<b-form-select-option value="SUCCESS">Success</b-form-select-option>
|
||||
<b-form-select-option value="INFO">Info</b-form-select-option>
|
||||
<b-form-select-option value="WARNING">Warning</b-form-select-option>
|
||||
<b-form-select-option value="DANGER">Danger</b-form-select-option>
|
||||
<b-input-group>
|
||||
<b-form-input type="color" v-model="user_preferences.nav_bg_color" @change="updateSettings(true);"></b-form-input>
|
||||
<b-input-group-append>
|
||||
<b-button @click="user_preferences.nav_bg_color = '#ddbf86'; updateSettings(true);">{{ $t('Reset') }}</b-button>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('Nav_Text_Mode')" :description="$t('Nav_Text_Mode_Help')">
|
||||
<b-form-select v-model="user_preferences.nav_text_color" @change="updateSettings(true);">
|
||||
<b-form-select-option value="LIGHT">Light</b-form-select-option>
|
||||
<b-form-select-option value="DARK">Dark</b-form-select-option>
|
||||
</b-form-select>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('Show_Logo_Help')">
|
||||
<b-form-checkbox v-model="user_preferences.nav_show_logo" @change="updateSettings(true);">
|
||||
{{ $t('Show_Logo') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('Sticky_Nav_Help')">
|
||||
<b-form-checkbox v-model="user_preferences.nav_sticky" @change="updateSettings(true);">
|
||||
{{ $t('Sticky_Nav') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"file_upload_disabled": "Nahrávání souborů není povoleno pro Váš prostor.",
|
||||
"warning_space_delete": "Můžete smazat váš prostor včetně všech receptů, nákupních seznamů, jídelníčků a všeho ostatního, co jste vytvořili. Tuto akci nemůžete vzít zpět! Jste si jisti, že chcete pokračovat?",
|
||||
"food_inherit_info": "Pole potravin, která budou standardně zděděna.",
|
||||
"step_time_minutes": "Nastavte čas v minutách",
|
||||
"step_time_minutes": "Délka kroku v minutách",
|
||||
"confirm_delete": "Jste si jisti že chcete odstranit tento {objekt}?",
|
||||
"import_running": "Probíhá import, čekejte prosím!",
|
||||
"all_fields_optional": "Všechna pole jsou nepviná a mohou být ponechána prázdná.",
|
||||
"convert_internal": "Převést na interní recept",
|
||||
"show_only_internal": "Zobrazit pouze interní recepty",
|
||||
"show_split_screen": "Rozdělené zobrazení",
|
||||
"Log_Recipe_Cooking": "",
|
||||
"Log_Recipe_Cooking": "Záznam vaření receptu",
|
||||
"External_Recipe_Image": "Externí obrázek receptu",
|
||||
"Add_to_Shopping": "Přidat k nákupu",
|
||||
"Add_to_Plan": "Přidat do jídelníčku",
|
||||
@@ -35,8 +35,8 @@
|
||||
"Hide_as_header": "Skryj jako nadpis",
|
||||
"Add_nutrition_recipe": "Přidat nutriční hodnoty",
|
||||
"Remove_nutrition_recipe": "Smazat nutriční hodnoty",
|
||||
"Copy_template_reference": "",
|
||||
"Save_and_View": "Uložit & Zobrazit",
|
||||
"Copy_template_reference": "Zkopírovat šablonu odkazu",
|
||||
"Save_and_View": "Uložit a zobrazit",
|
||||
"Manage_Books": "Spravovat kuchařky",
|
||||
"Meal_Plan": "Jídelníček",
|
||||
"Select_Book": "Vyber kuchařku",
|
||||
@@ -44,19 +44,19 @@
|
||||
"Recipe_Image": "Obrázek k receptu",
|
||||
"Import_finished": "Import dokončen",
|
||||
"View_Recipes": "Zobrazit recepty",
|
||||
"Log_Cooking": "",
|
||||
"Log_Cooking": "Zaznamenat vaření",
|
||||
"New_Recipe": "Nový recept",
|
||||
"Url_Import": "Import pomocí URL odkazu",
|
||||
"Reset_Search": "Zrušit filtry vyhledávání",
|
||||
"Recently_Viewed": "Naposledy prohlížené",
|
||||
"Load_More": "Načíst další",
|
||||
"New_Keyword": "Nové klíčové slovo",
|
||||
"Delete_Keyword": "Smazat klíčové slovo",
|
||||
"Edit_Keyword": "Upravit klíčové slovo",
|
||||
"New_Keyword": "Nový štítek",
|
||||
"Delete_Keyword": "Smazat štítek",
|
||||
"Edit_Keyword": "Upravit štítek",
|
||||
"Edit_Recipe": "Upravit recept",
|
||||
"Move_Keyword": "Přesunout klíčové slovo",
|
||||
"Merge_Keyword": "Sloučit klíčové slovo",
|
||||
"Hide_Keywords": "Skrýt klíčové slovo",
|
||||
"Move_Keyword": "Přesunout štítek",
|
||||
"Merge_Keyword": "Sloučit štítek",
|
||||
"Hide_Keywords": "Skrýt štítek",
|
||||
"Hide_Recipes": "Skrýt recept",
|
||||
"Move_Up": "Nahoru",
|
||||
"Move_Down": "Dolů",
|
||||
@@ -76,14 +76,14 @@
|
||||
"Private_Recipe_Help": "Recept můžete zobrazit pouze vy a lidé, se kterými jej sdílíte.",
|
||||
"reusable_help_text": "Má-li pozvánka platit pro více než jednoho uživatele.",
|
||||
"Add_Step": "Přidat krok",
|
||||
"Keywords": "Klíčová slova",
|
||||
"Keywords": "Štítky",
|
||||
"Books": "Kuchařky",
|
||||
"Proteins": "Proteiny",
|
||||
"Fats": "Tuky",
|
||||
"Carbohydrates": "Sacharidy",
|
||||
"Calories": "Kalorie",
|
||||
"Energy": "Energie",
|
||||
"Nutrition": "",
|
||||
"Nutrition": "Výživové hodnoty",
|
||||
"Date": "Datum",
|
||||
"Share": "Sdílet",
|
||||
"Automation": "Automatizace",
|
||||
@@ -153,7 +153,7 @@
|
||||
"Hide_Food": "Skrýt potravinu",
|
||||
"Food_Alias": "Přezdívka potraviny",
|
||||
"Unit_Alias": "Přezdívka jednotky",
|
||||
"Keyword_Alias": "Přezdívka klíčového slova",
|
||||
"Keyword_Alias": "Přezdívka štítku",
|
||||
"Delete_Food": "Smazat potravinu",
|
||||
"No_ID": "ID nenalezeno, odstranění není možné.",
|
||||
"Meal_Plan_Days": "Budoucí jídelníčky",
|
||||
@@ -162,7 +162,7 @@
|
||||
"Food": "Potravina",
|
||||
"Original_Text": "Původní text",
|
||||
"Recipe_Book": "Kuchařka",
|
||||
"del_confirmation_tree": "Jste si jistí, že chcete odstranit {source} se všemi pořazenými ?",
|
||||
"del_confirmation_tree": "Jste si jistí, že chcete odstranit {source} i se všemi potomky?",
|
||||
"delete_title": "Smazat {type}",
|
||||
"create_title": "Nový {type}",
|
||||
"edit_title": "Upravit {type}",
|
||||
@@ -170,7 +170,7 @@
|
||||
"Type": "Typ",
|
||||
"Description": "Popis",
|
||||
"Recipe": "Recept",
|
||||
"tree_root": "",
|
||||
"tree_root": "Kořen stromu",
|
||||
"Icon": "Ikona",
|
||||
"Unit": "Jednotka",
|
||||
"Decimals": "Desetinná místa",
|
||||
@@ -179,7 +179,7 @@
|
||||
"New_Unit": "Nová jednotka",
|
||||
"Create_New_Shopping Category": "Vytvořit novou nákupní kategorii",
|
||||
"Create_New_Food": "Přidat novou potravinu",
|
||||
"Create_New_Keyword": "Přidat nové klíčové slovo",
|
||||
"Create_New_Keyword": "Přidat nový štítek",
|
||||
"Create_New_Unit": "Přidat novou jednotku",
|
||||
"Create_New_Meal_Type": "Přidat nový druh jídla",
|
||||
"Create_New_Shopping_Category": "Přidat novou nákupní kategorii",
|
||||
@@ -254,7 +254,7 @@
|
||||
"SupermarketCategoriesOnly": "Pouze kategorie obchodu",
|
||||
"MoveCategory": "Přesunout do: ",
|
||||
"CountMore": "...+{count} víc",
|
||||
"IgnoreThis": "Nikdy automaticky nepřídávat {food} na nákupní seznam",
|
||||
"IgnoreThis": "Nikdy nepřidávat automaticky {food} na nákupní seznam",
|
||||
"DelayFor": "Odložit na {hours} hodin",
|
||||
"Warning": "Varování",
|
||||
"NoCategory": "Není vybrána žádná kategorie.",
|
||||
@@ -271,7 +271,7 @@
|
||||
"default_delay": "Výchozí doba odložení v hodinách",
|
||||
"plan_share_desc": "Nové položky v jídelníčku budou automaticky sdíleny s vybranými uživateli.",
|
||||
"shopping_share_desc": "Uživatelé uvidí všechny položky na vašem nákupním seznamu. Abyste viděli položky na jejich seznamu, musí si přidat vás.",
|
||||
"shopping_auto_sync_desc": "Nastavením 0 dojde k vypnutí automatické synchronizace. Při prohlížení nákupního seznamu je vždy po uplynutí nastaveného počtu vteřin aktualizován o změny, které mohli provést jiní uživatelé. To je užitečné, pokud nakupujete ve více lidech, ale může používat více dat.",
|
||||
"shopping_auto_sync_desc": "Zadáním hodnoty 0 se automatická synchronizace vypne. Při prohlížení nákupního seznamu se seznam aktualizuje po stanovených sekundách, aby se synchronizovaly změny, které mohl provést někdo jiný. To je užitečné při nakupování s více lidmi, ale spotřebovávají se při tom mobilní data.",
|
||||
"mealplan_autoadd_shopping_desc": "Automaticky podle jídelníčku přidat ingredience na nákupní seznam.",
|
||||
"mealplan_autoexclude_onhand_desc": "Nepřidávat ingredience, které jsou k dispozici, na nákupní seznam, když je vytvořen podle jídelníčku (manuálně nebo automaticky).",
|
||||
"mealplan_autoinclude_related_desc": "Když je nákupní seznam vytvořen podle jídelníčku, přidat i položky z přidružených receptů.",
|
||||
@@ -280,7 +280,7 @@
|
||||
"Coming_Soon": "Již brzy",
|
||||
"Auto_Planner": "Automatický plánovač",
|
||||
"New_Cookbook": "Nová kuchařka",
|
||||
"Hide_Keyword": "Skrýt klíčová slova",
|
||||
"Hide_Keyword": "Skrýt štítky",
|
||||
"Hour": "Hodina",
|
||||
"Hours": "Hodiny",
|
||||
"Day": "Den",
|
||||
@@ -328,7 +328,7 @@
|
||||
"OnHand_help": "Potravina je v inventáři a nebude automaticky přidána na nákupní seznam. Status \"k dipozici\" je sdílen s nakupujícími uživateli.",
|
||||
"ignore_shopping_help": "Nikdy nepřidávat potravinu na nákupní seznam (např. voda)",
|
||||
"shopping_category_help": "Obchody mohou být seřazeny a třízeny pomocí nákupních kategorií podle rozvržení uliček a regálů.",
|
||||
"food_recipe_help": "",
|
||||
"food_recipe_help": "Zde uvedený recept bude připojen k jakémukoli jinému receptu, který používá tuto potravinu",
|
||||
"Foods": "Potraviny",
|
||||
"Account": "Účet",
|
||||
"Cosmetic": "Zobrazení",
|
||||
@@ -338,7 +338,7 @@
|
||||
"simple_mode": "Jednoduchý režim",
|
||||
"advanced": "Pokročilé",
|
||||
"fields": "Pole",
|
||||
"show_keywords": "Zobrazit klíčová slova",
|
||||
"show_keywords": "Zobrazit štítky",
|
||||
"show_foods": "Zobrazit potraviny",
|
||||
"show_books": "Zobrazit kuchařky",
|
||||
"show_rating": "Zobrazit hodnocení",
|
||||
@@ -359,8 +359,8 @@
|
||||
"times_cooked": "Kolkrát vařeno",
|
||||
"date_created": "Datum vytvoření",
|
||||
"show_sortby": "Zobrazit Seřazeno podle",
|
||||
"search_rank": "",
|
||||
"make_now": "",
|
||||
"search_rank": "Skóre shody",
|
||||
"make_now": "Udělat hned",
|
||||
"recipe_filter": "Filtrovat recepty",
|
||||
"book_filter_help": "Zahrnout i recepty z filtru stejně jako manuálně přidané.",
|
||||
"review_shopping": "Zkontrolovat nákupní položky před uložením",
|
||||
@@ -392,9 +392,9 @@
|
||||
"search_create_help_text": "Vytvořit nový recept přímo v Tandoor.",
|
||||
"warning_duplicate_filter": "Varování: Kvůli technickým omezení může použití několika filtrů se stejnou kombinací (a/nebo/ne) přinést neočekávaný výsledek.",
|
||||
"reset_children": "Resetovat propsání podřízených",
|
||||
"reset_children_help": "",
|
||||
"reset_children_help": "Přepíše všechny potomky hodnotami dle nastavení propisovaných polí. Pokud není nastaveno pole Propisovaná pole podřízených, tak bude nastaveno pole Propsat hodnoty polí na aktuální hodnotu.",
|
||||
"reset_food_inheritance": "Resetovat propisování",
|
||||
"reset_food_inheritance_info": "",
|
||||
"reset_food_inheritance_info": "Obnoví u všech potravin propisovaná pole na výchozí hodnotu a nastavit daná pole dle nadřazené položky.",
|
||||
"substitute_help": "Při hledání podle ingrediencí, které jsou k dispozici, jsou zvažovány náhrady.",
|
||||
"substitute_siblings_help": "Všechny potraviny, které sdílejí nadřazenou položku jsou považovány za náhrady.",
|
||||
"substitute_children_help": "Všechny potraviny, které jsou podřízeny této, jsou považovány za náhražky.",
|
||||
@@ -425,8 +425,8 @@
|
||||
"Social_Authentication": "Přihlašování pomocí účtů sociálních sítí",
|
||||
"Random Recipes": "Náhodné recepty",
|
||||
"parameter_count": "Parametr {count}",
|
||||
"select_keyword": "Vybrat klíčové slovo",
|
||||
"add_keyword": "Přidat klíčové slovo",
|
||||
"select_keyword": "Vybrat štítek",
|
||||
"add_keyword": "Přidat štítek",
|
||||
"select_file": "Vybrat soubor",
|
||||
"select_recipe": "Vybrat recept",
|
||||
"select_unit": "Vybrat jednotku",
|
||||
@@ -439,7 +439,7 @@
|
||||
"Username": "Uživatelské jméno",
|
||||
"First_name": "Jméno",
|
||||
"Last_name": "Příjmení",
|
||||
"Keyword": "Klíčové slovo",
|
||||
"Keyword": "Štítek",
|
||||
"Advanced": "Rozšířené",
|
||||
"Page": "Stránka",
|
||||
"Single": "Jednoduchý",
|
||||
@@ -479,10 +479,10 @@
|
||||
"Create Recipe": "Vytvořit recept",
|
||||
"Import Recipe": "Importovat recept",
|
||||
"per_serving": "na porci",
|
||||
"open_data_help_text": "Projekt Tandoor Open Data nabízí komunitou poskytnutá data pro Tandoor. Toto pole je automaticky vyplněno při importu a může být později upraveno.",
|
||||
"Data_Import_Info": "Rozšiřte svůj prostor o seznamy potravin, jednotek a další spravované komunitou, a vylepšete tak svoji sbírku receptů.",
|
||||
"open_data_help_text": "Projekt Tandoor Open Data nabízí komunitou poskytnutá otevřená data pro Tandoor. Toto pole se vyplní automaticky při importu a umožňuje budoucí aktualizace.",
|
||||
"Data_Import_Info": "Rozšiřte svůj prostor o seznamy potravin, jednotek a dalších položek spravovaných komunitou, a vylepšete tak svoji sbírku receptů.",
|
||||
"Update_Existing_Data": "Aktualizovat existující data",
|
||||
"Use_Metric": "Používat metrické jednotky",
|
||||
"Use_Metric": "Použít metrické jednotky",
|
||||
"Learn_More": "Zjistit víc",
|
||||
"converted_unit": "Převedená jendotka",
|
||||
"converted_amount": "Převedené množství",
|
||||
@@ -490,9 +490,67 @@
|
||||
"base_amount": "Základní množství",
|
||||
"Datatype": "Datový typ",
|
||||
"Number of Objects": "Počet Objektů",
|
||||
"Property": "Vlastnost",
|
||||
"Property": "Nutriční vlastnost",
|
||||
"Conversion": "Převod",
|
||||
"Properties": "Vlastnosti",
|
||||
"recipe_property_info": "Můžete také přidávat vlastnosti k Vašim potravinám. Hodnoty budou automaticky přepočteny na základě Vašeho receptu!",
|
||||
"total": "celkem"
|
||||
"Properties": "Nutriční vlastnosti",
|
||||
"recipe_property_info": "Nutriční hodnoty se automaticky dopočtou podle receptu, pokud zadáte nutriční hodnoty přímo potravinám!",
|
||||
"total": "celkem",
|
||||
"CustomTheme": "Vlastní téma",
|
||||
"CustomThemeHelp": "Přepsat styly vybraného motivu nahráním vlastního souboru CSS.",
|
||||
"CustomLogoHelp": "Nahrajte čtvercové obrázky různých velikostí pro úpravu loga v záložce prohlížeče a v nainstalované webové aplikaci.",
|
||||
"err_importing_recipe": "Během importu receptu došlo k chybě!",
|
||||
"Open_Data_Slug": "Identifikátor pro otevřená data",
|
||||
"Open_Data_Import": "Import otevřených dat",
|
||||
"FDC_Search": "Vyhledávání v FDC",
|
||||
"property_type_fdc_hint": "Data z databáze FDC mohou automaticky čerpat pouze typy vlastností se zadaným FDC ID",
|
||||
"StartDate": "Počáteční datum",
|
||||
"EndDate": "Konečné datum",
|
||||
"Welcome": "Vítejte",
|
||||
"Property_Editor": "Editovat nutriční vlastnosti",
|
||||
"FDC_ID": "FDC ID",
|
||||
"FDC_ID_help": "ID v databázi FDC",
|
||||
"CustomImageHelp": "Nahrajte obrázek, který se zobrazí v přehledu prostoru.",
|
||||
"CustomNavLogoHelp": "Nahrajte obrázek, který se má zobrazit jako logo v navigačním panelu.",
|
||||
"CustomLogos": "Vlastní loga",
|
||||
"OrderInformation": "Položky jsou seřazeny podle čísel od malých po velké.",
|
||||
"kg": "kilogram [kg] (metrický systém, hmotnost)",
|
||||
"g": "gram [g] (metrický systém, hmotnost)",
|
||||
"ounce": "unce [oz] (imperiální systém, hmotnost)",
|
||||
"pound": "libra (hmotnost)",
|
||||
"Properties_Food_Unit": "Jednotka nutriční vlastnosti",
|
||||
"Properties_Food_Amount": "Množství nutriční vlastnosti",
|
||||
"tsp": "lžička [tsp] (US, objem)",
|
||||
"imperial_tsp": "lžička imperiální [imp tbsp] (UK, objem)",
|
||||
"Transpose_Words": "Transponovat slova",
|
||||
"show_step_ingredients_setting": "Zobrazit ingredience u jednotlivých kroků receptu",
|
||||
"Logo": "Logo",
|
||||
"Show_Logo": "Zobrazit logo",
|
||||
"show_step_ingredients_setting_help": "Zobrazí tabulku ingrediencí vedle kroků receptu. Nastavení se aplikuje při vytváření receptu a následně je možné volbu změnit při úpravě receptu.",
|
||||
"show_step_ingredients": "Zobrazit ingredience u kroku",
|
||||
"hide_step_ingredients": "Skrýt ingredience u kroku",
|
||||
"Show_Logo_Help": "Zobrazit logo Tandoor nebo logo prostoru na navigačním panelu.",
|
||||
"Nav_Text_Mode_Help": "Pro každé téma se chová jinak.",
|
||||
"Space_Cosmetic_Settings": "Některá kosmetická nastavení mohou měnit správci prostoru a budou mít přednost před nastavením klienta pro daný prostor.",
|
||||
"Nav_Text_Mode": "Textový režim navigace",
|
||||
"show_ingredients_table": "Zobrazit tabulku složek vedle textu kroku",
|
||||
"pint": "pinta [pt] (US, objem)",
|
||||
"quart": "quart [qt] (US, objem)",
|
||||
"imperial_fluid_ounce": "tekutá unce imperiální [imp fl oz] (UK, objem)",
|
||||
"imperial_pint": "pinta imperiální [imp pt] (UK, objem)",
|
||||
"imperial_quart": "quart imperiální [imp qt] (UK, objem)",
|
||||
"gallon": "galon [gal] (US, objem)",
|
||||
"tbsp": "lžíce [tbsp] (US, objem)",
|
||||
"imperial_gallon": "galon imperiální [imp gal] (UK, objem)",
|
||||
"imperial_tbsp": "lžíce imperiální [imp tbsp] (UK, objem)",
|
||||
"Choose_Category": "Vyberte kategorii",
|
||||
"Back": "Zpět",
|
||||
"Food_Replace": "Nahrazení v potravině",
|
||||
"Unit_Replace": "Nahrazení v jednotce",
|
||||
"Name_Replace": "Nahrazení v názvu",
|
||||
"ml": "mililitr [ml] (metrický systém, objem)",
|
||||
"l": "litr [l] (metrický systém, objem)",
|
||||
"fluid_ounce": "tekutá unce [fl oz] (US, objem)",
|
||||
"make_now_count": "Nejvyšší počet chybějících ingrediencí",
|
||||
"Alignment": "Zarovnání",
|
||||
"Never_Unit": "Není jednotkou"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"warning_feature_beta": "Denne funktion er i øjeblikket i BETA (test) stadie. Forvent fejl og fremtidige ændringer (hvor data kan mistes) ved brug af denne funktion.",
|
||||
"warning_feature_beta": "Denne funktion er i øjeblikket i BETA (test)-stadie. Forvent fejl og fremtidige ændringer (hvor data kan mistes) ved brug af denne funktion.",
|
||||
"err_fetching_resource": "Der opstod en fejl under indlæsning af denne ressource!",
|
||||
"err_creating_resource": "Der opstod en fejl under oprettelsen af denne ressource!",
|
||||
"err_updating_resource": "Der opstod en fejl under opdateringen af denne ressource!",
|
||||
@@ -477,5 +477,62 @@
|
||||
"Unpin": "Frigør",
|
||||
"PinnedConfirmation": "{recipe} er fastgjort.",
|
||||
"UnpinnedConfirmation": "{recipe} er frigjort.",
|
||||
"Combine_All_Steps": "Kombiner alle trin til ét felt."
|
||||
"Combine_All_Steps": "Kombiner alle trin til ét felt.",
|
||||
"converted_unit": "Konverteret enhed",
|
||||
"Property": "Egenskab",
|
||||
"OrderInformation": "Objekter er rangeret fra små til store tal.",
|
||||
"show_ingredients_table": "Vis ingredienser i en tabel ved siden af trinnets tekst",
|
||||
"tsp": "teaspoon [tsp] (US, volumen)",
|
||||
"imperial_fluid_ounce": "imperial fluid ounce [imp fl oz] (UK, volumen)",
|
||||
"imperial_tsp": "imperial teaspoon [imp tsp] (UK, volumen)",
|
||||
"open_data_help_text": "Tandoor Open Data projektet tilføjer netværksgenereret data til Tandoor. Dette felt bliver udfyldt automatisk under importering og muliggør fremtidige opdateringer.",
|
||||
"converted_amount": "Konverteret mængde",
|
||||
"StartDate": "Startdato",
|
||||
"EndDate": "Slutdato",
|
||||
"show_step_ingredients_setting": "Vis ingredienser ved siden af opskrifttrin",
|
||||
"l": "liter [l] (metrisk, volumen)",
|
||||
"g": "gram [g] (metrisk, vægt)",
|
||||
"kg": "kilogram [kg] (metrisk, vægt)",
|
||||
"ounce": "ounce [oz] (vægt)",
|
||||
"pound": "pund (vægt)",
|
||||
"ml": "milliliter [ml] (metrisk, volumen)",
|
||||
"fluid_ounce": "flydende ounce [fl oz] (US, volumen)",
|
||||
"pint": "pint [pt] (US, volumen)",
|
||||
"Back": "Tilbage",
|
||||
"quart": "quart [qt] (US, volumen)",
|
||||
"recipe_property_info": "Du kan også tilføje næringsindhold til ingredienser for at udregne indholdet automatisk baseret på din opskrift!",
|
||||
"per_serving": "per serveringer",
|
||||
"Open_Data_Slug": "Open Data Slug",
|
||||
"Open_Data_Import": "Open Data importering",
|
||||
"Data_Import_Info": "Udbyg dit Space og gør din opskriftsamling bedre ved at importere en netværkskurateret liste af ingredienser, enheder og mere.",
|
||||
"Update_Existing_Data": "Opdaterer eksisterende data",
|
||||
"make_now_count": "Oftest manglende ingredienser",
|
||||
"Welcome": "Velkommen",
|
||||
"imperial_pint": "imperial pint [imp pt] (UK, volumen)",
|
||||
"Alignment": "Justering",
|
||||
"gallon": "gallon [gal] (US, volumen)",
|
||||
"Never_Unit": "Aldrig enhed",
|
||||
"FDC_ID": "FDC ID",
|
||||
"FDC_ID_help": "FDC database ID",
|
||||
"Use_Metric": "Benyt metriske enheder",
|
||||
"Learn_More": "Lær mere",
|
||||
"base_unit": "Basisenhed",
|
||||
"base_amount": "Basismængde",
|
||||
"Datatype": "Datatype",
|
||||
"Number of Objects": "Antal objekter",
|
||||
"Conversion": "Konversion",
|
||||
"Properties": "Egenskaber",
|
||||
"show_step_ingredients_setting_help": "Tilføj ingredienstabel ved siden af opskrifttrin. Tilføjes ved oprettelsen. Kan overskrives under rediger opskrift.",
|
||||
"show_step_ingredients": "Vis trinnets ingredienser",
|
||||
"hide_step_ingredients": "Skjul trinnets ingredienser",
|
||||
"total": "total",
|
||||
"tbsp": "tablespoon [tbsp] (US, volumen)",
|
||||
"imperial_quart": "imperial quart [imp qt] (UK, volumen)",
|
||||
"imperial_gallon": "imperial gal [imp gal] (UK, volumen)",
|
||||
"imperial_tbsp": "imperial tablespoon [imp tbsp] (UK, volumen)",
|
||||
"Choose_Category": "Vælg kategori",
|
||||
"Transpose_Words": "Omstil ord",
|
||||
"Name_Replace": "Erstat navn",
|
||||
"Food_Replace": "Erstat ingrediens",
|
||||
"Unit_Replace": "Erstat enhed"
|
||||
}
|
||||
|
||||
@@ -535,5 +535,25 @@
|
||||
"Never_Unit": "Nie Einheit",
|
||||
"Unit_Replace": "Einheit Ersetzen",
|
||||
"quart": "\"Quart\" [qt] (US, Volumen)",
|
||||
"imperial_quart": "Engl. \"Quart\" [imp qt] (UK, Volumen)"
|
||||
"imperial_quart": "Engl. \"Quart\" [imp qt] (UK, Volumen)",
|
||||
"err_importing_recipe": "Beim Importieren des Rezeptes ist ein Fehler aufgetreten!",
|
||||
"property_type_fdc_hint": "Nur Nährwerte mit einer FDC ID können automatisch Daten aus der FDC Datenbank beziehen",
|
||||
"Property_Editor": "Nährwerte bearbeiten",
|
||||
"CustomTheme": "Benutzerdefiniertes Theme",
|
||||
"CustomThemeHelp": "Überschreiben Sie die Stile des ausgewählten Themes, indem Sie eine eigene CSS-Datei hochladen.",
|
||||
"CustomLogoHelp": "Laden Sie quadratische Bilder in verschiedenen Größen hoch, um das Logo im Browsertab und der installierten Webanwendung zu ändern.",
|
||||
"Show_Logo_Help": "Zeigen Sie das Tandoor- oder Space-Logo in der Navigationsleiste an.",
|
||||
"Space_Cosmetic_Settings": "Einige optische Einstellungen können von Administratoren des Bereichs geändert werden und setzen die Client-Einstellungen für diesen Bereich außer Kraft.",
|
||||
"Properties_Food_Amount": "Nährwertangaben",
|
||||
"Properties_Food_Unit": "Nährwert Einheit",
|
||||
"FDC_Search": "FDC Suche",
|
||||
"Logo": "Logo",
|
||||
"Show_Logo": "Logo anzeigen",
|
||||
"Nav_Text_Mode": "Navigation Textmodus",
|
||||
"Nav_Text_Mode_Help": "Verhält sich bei jedem Theme anders.",
|
||||
"FDC_ID": "FDC ID",
|
||||
"FDC_ID_help": "FDC Datenbank ID",
|
||||
"CustomImageHelp": "Laden Sie ein Bild hoch, das in der Space-Übersicht angezeigt werden soll.",
|
||||
"CustomNavLogoHelp": "Laden Sie ein Bild hoch, das als Logo für die Navigationsleiste verwendet werden soll.",
|
||||
"CustomLogos": "Individuelle Logos"
|
||||
}
|
||||
|
||||
@@ -487,5 +487,39 @@
|
||||
"PinnedConfirmation": "{recipe} a été épinglée.",
|
||||
"Back": "Retour",
|
||||
"Open_Data_Import": "Import Open Data",
|
||||
"Data_Import_Info": "Améliorez votre groupe en important des données partagées par la communauté afin d'améliorer vos collections de recettes : listes d'aliments, unités et plus encore."
|
||||
"Data_Import_Info": "Améliorez votre groupe en important des données partagées par la communauté afin d'améliorer vos collections de recettes : listes d'aliments, unités et plus encore.",
|
||||
"Nav_Color": "Couleur de la Navigation",
|
||||
"Nav_Color_Help": "Changer la couleur de la navigation.",
|
||||
"reset_food_inheritance_info": "Réinitialiser tous les champs d'héritage des aliments par les valeurs de leurs parents.",
|
||||
"last_viewed": "Vu dernièrement",
|
||||
"substitute_children_help": "Tout aliment étant enfant de cet aliment est considéré comme substitut.",
|
||||
"show_step_ingredients": "Afficher les ingrédients de l'étape",
|
||||
"FDC_ID": "ID FCD",
|
||||
"FDC_ID_help": "ID de base de données FDC",
|
||||
"reset_food_inheritance": "Réinitialiser l'héritage",
|
||||
"kg": "kilogramme [kg] (métrique, poids)",
|
||||
"ounce": "once [oz] (poids)",
|
||||
"pound": "livre (poids)",
|
||||
"base_amount": "Quantité de base",
|
||||
"hide_step_ingredients": "Cacher les ingrédients de l'étape",
|
||||
"show_ingredients_table": "Afficher une table des ingrédients à coté du texte de l'étape",
|
||||
"Bookmarklet": "Signet",
|
||||
"l": "litre [l] (métrique, volume)",
|
||||
"Choose_Category": "Choisir une catégorie",
|
||||
"err_importing_recipe": "Une erreur s'est produite lors de l'importation de cette recette !",
|
||||
"Properties_Food_Amount": "Propriété Quantité de nourriture",
|
||||
"Properties_Food_Unit": "Propriété Unité de nourriture",
|
||||
"FDC_Search": "Recherche dans le FDC",
|
||||
"property_type_fdc_hint": "Seules les propriétés avec un ID FDC peuvent être mises à jour automatiquement depuis la base FDC",
|
||||
"Property_Editor": "Editeur de propriétés",
|
||||
"warning_duplicate_filter": "Attention : en raison de limitations techniques, l'emploi de multiples filtres (and/or/not) peut mener à des résultats inattendus.",
|
||||
"Social_Authentication": "Authentification Sociale",
|
||||
"total": "total",
|
||||
"g": "gramme [g] (métrique, poids)",
|
||||
"ml": "millilitre [ml] (métrique, volume)",
|
||||
"Never_Unit": "Ne pas mettre d'unité",
|
||||
"Transpose_Words": "Transposer les mots",
|
||||
"Name_Replace": "Remplacer le Nom",
|
||||
"Food_Replace": "Remplacer l'aliment",
|
||||
"Unit_Replace": "Remplacer l'Unité"
|
||||
}
|
||||
|
||||
@@ -263,9 +263,9 @@
|
||||
"Current_Period": "תקופה נוכחית",
|
||||
"Next_Day": "היום הבא",
|
||||
"Previous_Day": "יום קודם",
|
||||
"Inherit": "",
|
||||
"InheritFields": "",
|
||||
"FoodInherit": "",
|
||||
"Inherit": "ירושה",
|
||||
"InheritFields": "ירושת ערכי שדות",
|
||||
"FoodInherit": "ערכי מזון",
|
||||
"ShowUncategorizedFood": "הצג לא מוגדר",
|
||||
"GroupBy": "אסוף לפי",
|
||||
"Language": "שפה",
|
||||
@@ -317,14 +317,14 @@
|
||||
"CategoryName": "שם קטגוריה",
|
||||
"SupermarketName": "שם סופרמרקט",
|
||||
"CategoryInstruction": "גרור קטגוריות לשינוי הסדר שבו הן מופיעות ברשימת הקניות.",
|
||||
"shopping_recent_days_desc": "",
|
||||
"shopping_recent_days": "",
|
||||
"download_pdf": "",
|
||||
"download_csv": "",
|
||||
"shopping_recent_days_desc": "מספר ימי קניות להציג.",
|
||||
"shopping_recent_days": "מספר ימים",
|
||||
"download_pdf": "הורד PDF",
|
||||
"download_csv": "הורד CSV",
|
||||
"csv_delim_help": "",
|
||||
"csv_delim_label": "",
|
||||
"SuccessClipboard": "",
|
||||
"copy_to_clipboard": "",
|
||||
"SuccessClipboard": "רשימת קניות הועתקה",
|
||||
"copy_to_clipboard": "העתק",
|
||||
"csv_prefix_help": "תחילית להוספה כאשר מעתיקים את הרשימה ללוח הכתיבה.",
|
||||
"csv_prefix_label": "רשימת תחיליות",
|
||||
"copy_markdown_table": "העתק כטבלת Markdown",
|
||||
@@ -521,5 +521,15 @@
|
||||
"Alignment": "יישור",
|
||||
"StartDate": "תאריך התחלה",
|
||||
"EndDate": "תאריך סיום",
|
||||
"OrderInformation": "המוצרים מוצגים מהמספר הקטן לגדול."
|
||||
"OrderInformation": "המוצרים מוצגים מהמספר הקטן לגדול.",
|
||||
"FDC_ID_help": "מספר FDC",
|
||||
"FDC_ID": "מספר FDC",
|
||||
"show_step_ingredients_setting": "הצג חומרי גלם בתוך שלבי המרשם",
|
||||
"err_importing_recipe": "שגיאה בעת יבוא המרשם!",
|
||||
"FDC_Search": "חפש FDC",
|
||||
"property_type_fdc_hint": "רק תכונות עם מספר FDC ימשכו מבסיס נתוני FDC",
|
||||
"Property_Editor": "עורך ערכים",
|
||||
"show_step_ingredients_setting_help": "הצג טבלת חומרי גלם לצדי שלבי המרשם. ניתן לשנות בזמן עריכת המרשם.",
|
||||
"show_step_ingredients": "הראה חומרי גלם בשלבי המרשם",
|
||||
"hide_step_ingredients": "הסתר חומרי גלם בשלבי המרשם"
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"Step_Name": "Lépés neve",
|
||||
"Step_Type": "Lépés típusa",
|
||||
"Make_Header": "Átalakítás címsorra",
|
||||
"Make_Ingredient": "",
|
||||
"Make_Ingredient": "Összetevő létrehozása",
|
||||
"Enable_Amount": "Összeg bekapcsolása",
|
||||
"Disable_Amount": "Összeg kikapcsolása",
|
||||
"Ingredient Editor": "Hozzávalók szerkesztője",
|
||||
@@ -172,7 +172,7 @@
|
||||
"and_down": "& le",
|
||||
"Instructions": "Elkészítés",
|
||||
"Unrated": "Nem értékelt",
|
||||
"Automate": "",
|
||||
"Automate": "Automatizálás",
|
||||
"Empty": "Üres",
|
||||
"Key_Ctrl": "Ctrl",
|
||||
"Key_Shift": "Shift",
|
||||
@@ -187,7 +187,7 @@
|
||||
"OnHand": "Jelenleg készleten",
|
||||
"FoodOnHand": "Önnek {food} van készleten.",
|
||||
"FoodNotOnHand": "Önnek {food} nincs készleten.",
|
||||
"Undefined": "",
|
||||
"Undefined": "Meghatározatlan",
|
||||
"Create_Meal_Plan_Entry": "Menüterv bejegyzés létrehozása",
|
||||
"Edit_Meal_Plan_Entry": "Menüterv bejegyzés szerkesztése",
|
||||
"Title": "Cím",
|
||||
@@ -233,7 +233,7 @@
|
||||
"ShowUncategorizedFood": "",
|
||||
"GroupBy": "Csoportosítva",
|
||||
"SupermarketCategoriesOnly": "Csak a szupermarket kategóriák",
|
||||
"MoveCategory": "",
|
||||
"MoveCategory": "Áthelyezés ide: ",
|
||||
"CountMore": "",
|
||||
"IgnoreThis": "",
|
||||
"DelayFor": "Késleltetés {hours} óráig",
|
||||
@@ -241,7 +241,7 @@
|
||||
"NoCategory": "Nincs kategória kiválasztva.",
|
||||
"InheritWarning": "",
|
||||
"ShowDelayed": "",
|
||||
"Completed": "",
|
||||
"Completed": "Kész",
|
||||
"OfflineAlert": "Ön éppen offline állapotban van, a bevásárlólista nem biztos, hogy szinkronizálódik.",
|
||||
"shopping_share": "Bevásárlólista megosztása",
|
||||
"shopping_auto_sync": "Automatikus szinkronizáció",
|
||||
@@ -265,7 +265,7 @@
|
||||
"err_move_self": "",
|
||||
"nothing": "",
|
||||
"err_merge_self": "",
|
||||
"show_sql": "",
|
||||
"show_sql": "SQL megjelenítése",
|
||||
"filter_to_supermarket_desc": "Alapértelmezés szerint a bevásárlólista szűrése csak a kiválasztott szupermarket kategóriáit tartalmazza.",
|
||||
"CategoryName": "Kategória neve",
|
||||
"SupermarketName": "Szupermarket neve",
|
||||
@@ -281,10 +281,10 @@
|
||||
"csv_prefix_help": "A lista vágólapra másolásakor hozzáadandó előtag.",
|
||||
"csv_prefix_label": "",
|
||||
"copy_markdown_table": "",
|
||||
"in_shopping": "",
|
||||
"in_shopping": "Bevásárlólistában",
|
||||
"DelayUntil": "",
|
||||
"Pin": "Kitűzés",
|
||||
"mark_complete": "",
|
||||
"mark_complete": "Késznek jelölés",
|
||||
"QuickEntry": "Gyors bevitel",
|
||||
"shopping_add_onhand_desc": "",
|
||||
"shopping_add_onhand": "",
|
||||
@@ -322,8 +322,8 @@
|
||||
"desc": "Csökkenő",
|
||||
"date_viewed": "Utoljára megtekintve",
|
||||
"last_cooked": "Utoljára elkészítve",
|
||||
"times_cooked": "",
|
||||
"date_created": "",
|
||||
"times_cooked": "szor elkészítve",
|
||||
"date_created": "Létrehozás dátuma",
|
||||
"show_sortby": "",
|
||||
"search_rank": "Keresési rangsor",
|
||||
"make_now": "",
|
||||
@@ -333,7 +333,7 @@
|
||||
"view_recipe": "Recept megtekintése",
|
||||
"copy_to_new": "Másolás új receptbe",
|
||||
"recipe_name": "Recept neve",
|
||||
"paste_ingredients_placeholder": "",
|
||||
"paste_ingredients_placeholder": "Az összetevők listájának beillesztése ide...",
|
||||
"paste_ingredients": "Hozzávalók beillesztése",
|
||||
"ingredient_list": "Hozzávalók listája",
|
||||
"explain": "Magyarázat",
|
||||
@@ -370,19 +370,19 @@
|
||||
"no_pinned_recipes": "Nincsenek kitűzött receptjei!",
|
||||
"Planned": "Tervezett",
|
||||
"Pinned": "Kitűzve",
|
||||
"Imported": "",
|
||||
"Imported": "Importálva",
|
||||
"Quick actions": "Gyors parancsok",
|
||||
"Ratings": "Értékelések",
|
||||
"Internal": "Belső",
|
||||
"Units": "Mennyiségi egységek",
|
||||
"Random Recipes": "",
|
||||
"Random Recipes": "Véletlenszerű receptek",
|
||||
"parameter_count": "Paraméter {count}",
|
||||
"select_keyword": "Kulcsszó kiválasztása",
|
||||
"add_keyword": "Kulcsszó hozzáadása",
|
||||
"select_file": "",
|
||||
"select_file": "Fájl kiválasztása",
|
||||
"select_recipe": "Recept kiválasztása",
|
||||
"select_unit": "",
|
||||
"select_food": "",
|
||||
"select_unit": "Egység kiválasztása",
|
||||
"select_food": "Étel kiválasztása",
|
||||
"remove_selection": "Kijelölés törlése",
|
||||
"empty_list": "A lista üres.",
|
||||
"Select": "Kiválasztás",
|
||||
@@ -391,13 +391,13 @@
|
||||
"Keyword": "Kulcsszó",
|
||||
"Advanced": "Haladó",
|
||||
"Page": "Oldal",
|
||||
"Single": "",
|
||||
"Single": "Egyetlen",
|
||||
"Multiple": "Több",
|
||||
"Reset": "Visszaállítás",
|
||||
"Options": "Opciók",
|
||||
"Create Food": "Alapanyag létrehozása",
|
||||
"create_food_desc": "",
|
||||
"additional_options": "",
|
||||
"additional_options": "További lehetőségek",
|
||||
"Importer_Help": "",
|
||||
"Documentation": "Dokumentáció",
|
||||
"Select_App_To_Import": "Kérjük, válasszon ki egy alkalmazást, amelyből importálni szeretne",
|
||||
@@ -415,9 +415,9 @@
|
||||
"Are_You_Sure": "Biztos benne?",
|
||||
"Plural": "Többes szám",
|
||||
"plural_short": "többes szám",
|
||||
"Use_Plural_Unit_Always": "",
|
||||
"Use_Plural_Unit_Always": "Mindig többes számot használjon az mértékegységhez",
|
||||
"Use_Plural_Unit_Simple": "A mértékegység többes számának dinamikus beállítása",
|
||||
"Use_Plural_Food_Always": "",
|
||||
"Use_Plural_Food_Always": "Mindig többes számot használjon az ételhez",
|
||||
"Use_Plural_Food_Simple": "Alapanyag többes számának dinamikus használata",
|
||||
"plural_usage_info": "",
|
||||
"Back": "Vissza",
|
||||
@@ -516,5 +516,10 @@
|
||||
"API": "API",
|
||||
"Account": "Fiók",
|
||||
"show_step_ingredients_setting": "Hozzávalók megjelenítése a recept lépései mellett",
|
||||
"Disable": "Letiltás"
|
||||
"Disable": "Letiltás",
|
||||
"Name_Replace": "Név cseréje",
|
||||
"make_now_count": "Leginkább hiányzó összetevők",
|
||||
"Combine_All_Steps": "Egyesítse az összes lépést egyetlen mezőbe.",
|
||||
"Food_Replace": "Étel cseréje",
|
||||
"err_importing_recipe": "Hiba történt a recept importálásakor!"
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
"Unit_Alias": "Alias Unità",
|
||||
"Keyword_Alias": "Alias Parola Chiave",
|
||||
"Table_of_Contents": "Indice dei contenuti",
|
||||
"warning_feature_beta": "Questa funzione è attualmente in BETA (non è completa). Potrebbero verificarsi delle anomalie e modifiche che in futuro potrebbero bloccare la funzionalità stessa o rimuove i dati correlati a essa.",
|
||||
"warning_feature_beta": "Questa funzione è attualmente in BETA (non è completa). Potrebbero verificarsi delle anomalie e modifiche che in futuro potrebbero bloccare la funzionalità stessa o rimuove i dati correlati ad essa.",
|
||||
"Shopping_list": "Lista della spesa",
|
||||
"Title": "Titolo",
|
||||
"Create_New_Meal_Type": "Aggiungi nuovo tipo di pasto",
|
||||
@@ -393,7 +393,7 @@
|
||||
"view_recipe": "Mostra ricetta",
|
||||
"copy_to_new": "Copia in una nuova ricetta",
|
||||
"Pinned": "Fissato",
|
||||
"App": "App",
|
||||
"App": "Applicazione",
|
||||
"filter": "Filtro",
|
||||
"explain": "Maggior informazioni",
|
||||
"Website": "Sito web",
|
||||
|
||||
@@ -534,5 +534,25 @@
|
||||
"Food_Replace": "Zastąp produkt",
|
||||
"Unit_Replace": "Zastąp jednostkę",
|
||||
"Alignment": "Wyrównanie",
|
||||
"make_now_count": "Najbardziej brakujące składniki"
|
||||
"make_now_count": "Najbardziej brakujące składniki",
|
||||
"CustomTheme": "Własny motyw",
|
||||
"CustomThemeHelp": "Zastąp style wybranego motywu, przesyłając własny plik CSS.",
|
||||
"CustomLogoHelp": "Prześlij kwadratowe obrazy w różnych rozmiarach, aby zmienić logo w zakładce przeglądarki i zainstalowanej aplikacji internetowej.",
|
||||
"Logo": "Logo",
|
||||
"Show_Logo_Help": "Pokaż logo Tandoor lub przestrzeni na pasku nawigacyjnym.",
|
||||
"Space_Cosmetic_Settings": "Administratorzy przestrzeni mogą zmienić niektóre ustawienia kosmetyczne, które zastąpią ustawienia klienta dla tej przestrzeni.",
|
||||
"err_importing_recipe": "Wystąpił błąd podczas importowania przepisu!",
|
||||
"Properties_Food_Amount": "Właściwości ilości żywności",
|
||||
"Properties_Food_Unit": "Właściwości jednostek żywności",
|
||||
"FDC_Search": "Wyszukiwanie w FDC",
|
||||
"property_type_fdc_hint": "Tylko właściwe typy z identyfikatorem FDC mogą automatycznie pobierać dane z bazy danych FDC",
|
||||
"Property_Editor": "Edytor właściwości",
|
||||
"FDC_ID": "Identyfikator FDC",
|
||||
"FDC_ID_help": "Identyfikator bazy FDC",
|
||||
"CustomImageHelp": "Prześlij obraz, który będzie wyświetlany w przeglądzie przestrzeni.",
|
||||
"CustomNavLogoHelp": "Prześlij obraz, który będzie używany jako logo paska nawigacyjnego.",
|
||||
"CustomLogos": "Własne loga",
|
||||
"Show_Logo": "Pokaż logo",
|
||||
"Nav_Text_Mode": "Tryb nawigacji tekstowej",
|
||||
"Nav_Text_Mode_Help": "Zachowuje się inaczej dla każdego motywu."
|
||||
}
|
||||
|
||||