mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 03:13:13 -05:00
Compare commits
403 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1028f49d6 | ||
|
|
6d0cc96cc8 | ||
|
|
969a91e751 | ||
|
|
f47470a9ad | ||
|
|
9ad9fe275d | ||
|
|
33448c98c0 | ||
|
|
90e389f2fa | ||
|
|
af7acd7473 | ||
|
|
dfa0794281 | ||
|
|
a36d42df84 | ||
|
|
269dded046 | ||
|
|
826ccc2760 | ||
|
|
7190dc17a7 | ||
|
|
5e332bb88c | ||
|
|
fb21622bfe | ||
|
|
191c38db8f | ||
|
|
71132fe992 | ||
|
|
d1d568a9d3 | ||
|
|
68d4fb3b59 | ||
|
|
b4c26682c7 | ||
|
|
e2cfb53ec4 | ||
|
|
274b17a860 | ||
|
|
97bcf1111b | ||
|
|
3b9d221258 | ||
|
|
d8bcb8bcb6 | ||
|
|
5cb0f1761a | ||
|
|
516b551528 | ||
|
|
f43d4d3971 | ||
|
|
e0dd70027b | ||
|
|
79efd94d6f | ||
|
|
f16870c59e | ||
|
|
c690bc18a0 | ||
|
|
394f24c29f | ||
|
|
c2a8214290 | ||
|
|
4d0cfc95e4 | ||
|
|
972c103538 | ||
|
|
2f62f51dc2 | ||
|
|
56ee173c07 | ||
|
|
774c633d5c | ||
|
|
93dd35fde3 | ||
|
|
666e4d282f | ||
|
|
ed6ca613ff | ||
|
|
285364e12a | ||
|
|
9a46a91652 | ||
|
|
4e4078f3da | ||
|
|
41c9290ba8 | ||
|
|
4460fe013f | ||
|
|
558a5d6554 | ||
|
|
388f2f441f | ||
|
|
11e8af4c46 | ||
|
|
387893e1ef | ||
|
|
78dc1bf9ec | ||
|
|
12638096b1 | ||
|
|
3e82199c44 | ||
|
|
b4bcf5c032 | ||
|
|
afdd92c903 | ||
|
|
1462300eda | ||
|
|
d0417d09db | ||
|
|
39c8da8305 | ||
|
|
c9af9277ae | ||
|
|
5e2be34f7b | ||
|
|
73a2476a79 | ||
|
|
f61032cc74 | ||
|
|
f6956388c7 | ||
|
|
7e9b303e8b | ||
|
|
f617dedfa2 | ||
|
|
942c26c581 | ||
|
|
a00d90398e | ||
|
|
61e6d855ec | ||
|
|
6b59f53273 | ||
|
|
798457e7e2 | ||
|
|
6176eeb024 | ||
|
|
56252a707a | ||
|
|
9c649d743c | ||
|
|
00b1ca5454 | ||
|
|
f2e1648556 | ||
|
|
b1945edf04 | ||
|
|
9f20db0ed3 | ||
|
|
852756f099 | ||
|
|
452617ef30 | ||
|
|
4e972835e5 | ||
|
|
5e4d983b79 | ||
|
|
0de8065212 | ||
|
|
1a6e677c06 | ||
|
|
8307828528 | ||
|
|
12a1f261db | ||
|
|
79e400ce6f | ||
|
|
d51de1bd79 | ||
|
|
6b3f1aa038 | ||
|
|
7d7803a07e | ||
|
|
559c574fd6 | ||
|
|
5e0131acd9 | ||
|
|
d560b7a143 | ||
|
|
15e5366dbb | ||
|
|
6ccfdd6f2e | ||
|
|
d2b79990cb | ||
|
|
01bb84e40b | ||
|
|
f66b422f68 | ||
|
|
abab970f08 | ||
|
|
edaa6de71d | ||
|
|
85a65127cf | ||
|
|
99035190f4 | ||
|
|
f1611fbafd | ||
|
|
e42ff2fb8b | ||
|
|
bc93071167 | ||
|
|
410fa58d47 | ||
|
|
06fd03fbde | ||
|
|
d169456c78 | ||
|
|
7785aa4904 | ||
|
|
33798fe47e | ||
|
|
0522b15cfd | ||
|
|
8cfd3995d0 | ||
|
|
84cdd8bb78 | ||
|
|
ad0177235d | ||
|
|
e5782151a1 | ||
|
|
adf4dafd01 | ||
|
|
4214ef4a9f | ||
|
|
1df0ad202f | ||
|
|
e35cbba8b2 | ||
|
|
e6fa660c8f | ||
|
|
8aefdb71bb | ||
|
|
5da8c6fe7b | ||
|
|
520697e988 | ||
|
|
7fe6fd3462 | ||
|
|
85b3941539 | ||
|
|
6f5ea7bb48 | ||
|
|
e9a2b101d8 | ||
|
|
c01faff135 | ||
|
|
bcee0007a5 | ||
|
|
f5ec956e08 | ||
|
|
56926d55ba | ||
|
|
55cfe9e9e7 | ||
|
|
31bdd97a56 | ||
|
|
eb60cbdd6b | ||
|
|
39ccf7bbcf | ||
|
|
f92ee32c01 | ||
|
|
5aecf7e61c | ||
|
|
20435450f3 | ||
|
|
13e5fb4143 | ||
|
|
bdc6434839 | ||
|
|
796609de37 | ||
|
|
8759e8dd73 | ||
|
|
f8bf54189e | ||
|
|
3e2988f998 | ||
|
|
2e5571f0a9 | ||
|
|
c97bb900a3 | ||
|
|
b1ad5ef205 | ||
|
|
9994b6f9c2 | ||
|
|
a8a590a942 | ||
|
|
4e13fb3b8c | ||
|
|
24f331c208 | ||
|
|
16d0fc38f9 | ||
|
|
5e4cac52d6 | ||
|
|
b489a2d849 | ||
|
|
7c5707e0c0 | ||
|
|
946699a335 | ||
|
|
4888e2d476 | ||
|
|
44b2c02034 | ||
|
|
c150c7f84e | ||
|
|
97503a68d8 | ||
|
|
126a2d870e | ||
|
|
02bad8cfb9 | ||
|
|
d9465c7f9d | ||
|
|
ead3168d80 | ||
|
|
a71bba307e | ||
|
|
d2a652891c | ||
|
|
a70ac42717 | ||
|
|
a7bcf105dc | ||
|
|
8be02b4e74 | ||
|
|
9ba9cda1c0 | ||
|
|
4310282dc3 | ||
|
|
c2c08391cc | ||
|
|
bc9d077b9d | ||
|
|
fe0f739bd5 | ||
|
|
e5b11a34f6 | ||
|
|
1df7a4df91 | ||
|
|
d401c143ec | ||
|
|
00a59baa92 | ||
|
|
327c83ce32 | ||
|
|
3371102e64 | ||
|
|
aec396e214 | ||
|
|
2b52b5c264 | ||
|
|
19c24a85a1 | ||
|
|
c147903f1e | ||
|
|
9dedc5b8fa | ||
|
|
d781cbe743 | ||
|
|
37bd2017b0 | ||
|
|
2de8070156 | ||
|
|
f70377c59b | ||
|
|
6fc4151de5 | ||
|
|
1fa001aad3 | ||
|
|
b84e03c58b | ||
|
|
e9dac25ff4 | ||
|
|
611787dbb6 | ||
|
|
bfbfb1d2a8 | ||
|
|
d9662f7fa5 | ||
|
|
9e44944b1d | ||
|
|
4de9a7ff89 | ||
|
|
32a663c5d7 | ||
|
|
3bee5ed35a | ||
|
|
bee5d6b7eb | ||
|
|
00ed9b07b6 | ||
|
|
2279bba838 | ||
|
|
57f5343c77 | ||
|
|
da8262a9b5 | ||
|
|
f0cf4a23e4 | ||
|
|
489c81c378 | ||
|
|
730344e326 | ||
|
|
7e6b1d3638 | ||
|
|
15f65cd711 | ||
|
|
dba205dafb | ||
|
|
5ae149a1b6 | ||
|
|
4bb2307007 | ||
|
|
be0088aec6 | ||
|
|
c56710ae0c | ||
|
|
1a420bc002 | ||
|
|
545e4f7af4 | ||
|
|
d2a148ae7d | ||
|
|
580591a69e | ||
|
|
409b438776 | ||
|
|
549175b56d | ||
|
|
0e3f5006b1 | ||
|
|
54043a0ae5 | ||
|
|
36fdc8cd9e | ||
|
|
87cf3b2289 | ||
|
|
adb4071fdb | ||
|
|
2a20f5e6e2 | ||
|
|
00f7ae3d66 | ||
|
|
f1f4e7ca8e | ||
|
|
6d7b3b8bfa | ||
|
|
7ebccf564d | ||
|
|
0421a1aa6c | ||
|
|
c118ab9a3c | ||
|
|
02a12cf724 | ||
|
|
f28ca41b7b | ||
|
|
6e677cf3cd | ||
|
|
d30a23f7ef | ||
|
|
88fea6f25d | ||
|
|
fc0b5bd738 | ||
|
|
5174f9939c | ||
|
|
8f9a489c7e | ||
|
|
fc72efac04 | ||
|
|
72f57cf671 | ||
|
|
85b95d1e96 | ||
|
|
35dee43f0b | ||
|
|
fb683bf230 | ||
|
|
a852f581ba | ||
|
|
cc417f1499 | ||
|
|
7f9da4c4fb | ||
|
|
31d3f9abee | ||
|
|
f9670e9833 | ||
|
|
465af8c1a4 | ||
|
|
ffe743e233 | ||
|
|
6b09731a55 | ||
|
|
182a94e0c7 | ||
|
|
2adaedfd1a | ||
|
|
5074326471 | ||
|
|
4807a16a0f | ||
|
|
af044f1002 | ||
|
|
cdf77c8796 | ||
|
|
e68bedf7eb | ||
|
|
5e21e7fa8e | ||
|
|
f49b39b216 | ||
|
|
0d24292f52 | ||
|
|
f3b7016be8 | ||
|
|
0f77c831c9 | ||
|
|
be48e57453 | ||
|
|
3b45ca18af | ||
|
|
da1b22c148 | ||
|
|
9dab21f972 | ||
|
|
89a5f92ace | ||
|
|
7be705f6a1 | ||
|
|
8e60566311 | ||
|
|
33e5bb7d0a | ||
|
|
0cf63cd715 | ||
|
|
5dc7bf5b0e | ||
|
|
c4c66aa640 | ||
|
|
f64be72a98 | ||
|
|
a3ed2bdcac | ||
|
|
996b8bedac | ||
|
|
a05a785e22 | ||
|
|
b470602317 | ||
|
|
cf8ab02d0e | ||
|
|
60043fff59 | ||
|
|
16c0189b80 | ||
|
|
36c30f9e11 | ||
|
|
12a8582a9a | ||
|
|
13b91e5b91 | ||
|
|
d02b253242 | ||
|
|
16528c4c89 | ||
|
|
6442e174b3 | ||
|
|
fd325c1797 | ||
|
|
12491d1302 | ||
|
|
b7a4613310 | ||
|
|
39f5fca89b | ||
|
|
2902262503 | ||
|
|
b49393357a | ||
|
|
cc1a69eac0 | ||
|
|
13d498658c | ||
|
|
cad93b2dd1 | ||
|
|
f0b8bac221 | ||
|
|
13ef843edb | ||
|
|
ca9c96647e | ||
|
|
902ef3cd1e | ||
|
|
0b69bcddcc | ||
|
|
9089fc7ad3 | ||
|
|
6d866ae62b | ||
|
|
9fa82c2ddb | ||
|
|
0ca29cd677 | ||
|
|
54c9e200a0 | ||
|
|
fc67525dcb | ||
|
|
37e292cab9 | ||
|
|
e391abd23d | ||
|
|
947986277a | ||
|
|
b2a10f269c | ||
|
|
dc076d25d6 | ||
|
|
845408244b | ||
|
|
e06c82297d | ||
|
|
459be74a7c | ||
|
|
37e81275b5 | ||
|
|
8417b0ec3f | ||
|
|
7d834ee088 | ||
|
|
eb119b7443 | ||
|
|
cc342cbae3 | ||
|
|
75ae26fd28 | ||
|
|
70e6585669 | ||
|
|
94f58f4608 | ||
|
|
5478a8d49a | ||
|
|
23180622e8 | ||
|
|
62187fbbdf | ||
|
|
bd6b04f95e | ||
|
|
b315d6e171 | ||
|
|
35bb3c9eb1 | ||
|
|
84e7850e91 | ||
|
|
4b40d75d1d | ||
|
|
5423019a14 | ||
|
|
e8c5c610b7 | ||
|
|
3f0cef59b8 | ||
|
|
867c3595ff | ||
|
|
631dd58c1f | ||
|
|
ba235b26b7 | ||
|
|
e54e850241 | ||
|
|
d0cb7a79f9 | ||
|
|
40c85c512c | ||
|
|
ca5eb7b2b6 | ||
|
|
cfd24de72a | ||
|
|
54acfe3e39 | ||
|
|
574a6ab5f4 | ||
|
|
39070d32bd | ||
|
|
9aa3d2d87a | ||
|
|
02926516b9 | ||
|
|
215f561623 | ||
|
|
e2c2f5d757 | ||
|
|
d887405ab3 | ||
|
|
00deb75195 | ||
|
|
b228b0f42a | ||
|
|
3d5ff23433 | ||
|
|
1a24f34499 | ||
|
|
8459b40743 | ||
|
|
75cb5d2d4c | ||
|
|
12ad6af8c3 | ||
|
|
cf24e1014a | ||
|
|
bd1b40dd94 | ||
|
|
95d4bfb2bd | ||
|
|
23caac9d09 | ||
|
|
ece4f6e32d | ||
|
|
5e7d1ba827 | ||
|
|
a88214eea6 | ||
|
|
7ec5646338 | ||
|
|
c020bea41e | ||
|
|
e6f79a6fa3 | ||
|
|
0ab430ea82 | ||
|
|
3d95657b8a | ||
|
|
726157a062 | ||
|
|
f8793f3ec8 | ||
|
|
09929beeb9 | ||
|
|
2a1b2c18fc | ||
|
|
0cc3df71d2 | ||
|
|
e124c211ac | ||
|
|
dc2f62dc9d | ||
|
|
38921f1254 | ||
|
|
4fec9a493e | ||
|
|
71c5adda79 | ||
|
|
cffa731106 | ||
|
|
c7f75fe58f | ||
|
|
2eed5143fe | ||
|
|
6e4ea518d9 | ||
|
|
a898d722d6 | ||
|
|
904358bb00 | ||
|
|
6605b87c5c | ||
|
|
64688ca5e1 | ||
|
|
e9a1a06bda | ||
|
|
a8da28f877 | ||
|
|
70b2bd6ccf | ||
|
|
8ed5d52ddf | ||
|
|
f7af0741fe | ||
|
|
3ec4afb02f | ||
|
|
3f77b73a61 | ||
|
|
9e62d8a3a3 | ||
|
|
2f8b479fdd | ||
|
|
c86ff27bef | ||
|
|
be6bb5f039 | ||
|
|
9961746f1f |
@@ -68,6 +68,10 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
# 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=
|
||||
@@ -93,7 +97,7 @@ GUNICORN_MEDIA=0
|
||||
# ACCOUNT_EMAIL_SUBJECT_PREFIX=
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# see docs for more information https://docs.tandoor.dev/features/authentication/
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
@@ -122,7 +126,7 @@ REVERSE_PROXY_AUTH=0
|
||||
# ENABLE_METRICS=0
|
||||
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# 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 ?
|
||||
|
||||
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -1,6 +1,6 @@
|
||||
name: Continuous Integration
|
||||
|
||||
on: [push]
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -14,13 +14,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '16'
|
||||
- name: Install Vue dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Install Django dependencies
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
sudo apt-get install -y libsasl2-dev python-dev libldap2-dev libssl-dev
|
||||
sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
python3 manage.py collectstatic --noinput
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -84,3 +84,4 @@ cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
cookbook/templates/sw.js
|
||||
.prettierignore
|
||||
vue/.yarn
|
||||
|
||||
2
.idea/recipes.iml
generated
2
.idea/recipes.iml
generated
@@ -18,7 +18,7 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/staticfiles" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.9 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.11 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
|
||||
@@ -71,8 +71,7 @@ Because of that there are several ways you can support us
|
||||
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
|
||||
|
||||
## Contributing
|
||||
|
||||
You can help out with the ongoing development by looking for potential bugs in our code base, or by contributing new features. We are always welcoming new pull requests containing bug fixes, refactors and new features. We have a list of tasks and bugs on our issue tracker on Github. Please comment on issues if you want to contribute with, to avoid duplicating effort.
|
||||
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/#contributing-code) **BEFORE** contributing anything!
|
||||
|
||||
## Your Feedback
|
||||
|
||||
|
||||
@@ -6,5 +6,4 @@ Since this software is still considered beta/WIP support is always only given fo
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
|
||||
public just open a generic issue and we will discuss further communication there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
Please use GitHub Security Advisories to report any kind of security vulnerabilities.
|
||||
|
||||
4
boot.sh
4
boot.sh
@@ -2,6 +2,8 @@
|
||||
source venv/bin/activate
|
||||
|
||||
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
|
||||
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
|
||||
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf
|
||||
|
||||
display_warning() {
|
||||
@@ -63,4 +65,4 @@ echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
|
||||
exec gunicorn -b :$TANDOOR_PORT --access-logfile - --error-logfile - --log-level INFO recipes.wsgi
|
||||
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level INFO recipes.wsgi
|
||||
|
||||
@@ -36,7 +36,7 @@ def delete_space_action(modeladmin, request, queryset):
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing', 'use_plural')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
date_hierarchy = 'created_at'
|
||||
@@ -48,7 +48,7 @@ admin.site.register(Space, SpaceAdmin)
|
||||
|
||||
class UserSpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'space',)
|
||||
search_fields = ('user', 'space',)
|
||||
search_fields = ('user__username', 'space__name',)
|
||||
|
||||
|
||||
admin.site.register(UserSpace, UserSpaceAdmin)
|
||||
|
||||
@@ -154,6 +154,7 @@ class ImportExportBase(forms.Form):
|
||||
COOKBOOKAPP = 'COOKBOOKAPP'
|
||||
COPYMETHAT = 'COPYMETHAT'
|
||||
COOKMATE = 'COOKMATE'
|
||||
REZEPTSUITEDE = 'REZEPTSUITEDE'
|
||||
PDF = 'PDF'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
@@ -162,7 +163,7 @@ class ImportExportBase(forms.Form):
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
|
||||
(COOKMATE, 'Cookmate')
|
||||
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')
|
||||
))
|
||||
|
||||
|
||||
@@ -533,11 +534,13 @@ class SpacePreferenceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Space
|
||||
|
||||
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count')
|
||||
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural')
|
||||
|
||||
help_texts = {
|
||||
'food_inherit': _('Fields on food that should be inherited by default.'),
|
||||
'show_facet_count': _('Show recipe counts on search filters'), }
|
||||
'show_facet_count': _('Show recipe counts on search filters'),
|
||||
'use_plural': _('Use the plural form for units and food inside this space.'),
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'food_inherit': MultiSelectWidget
|
||||
|
||||
@@ -10,4 +10,5 @@ def context_settings(request):
|
||||
'TERMS_URL': settings.TERMS_URL,
|
||||
'PRIVACY_URL': settings.PRIVACY_URL,
|
||||
'IMPRINT_URL': settings.IMPRINT_URL,
|
||||
'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class IngredientParser:
|
||||
self.food_aliases = c
|
||||
caches['default'].touch(FOOD_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').all():
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.food_aliases[a.param_1] = a.param_2
|
||||
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
|
||||
|
||||
@@ -37,7 +37,7 @@ class IngredientParser:
|
||||
self.unit_aliases = c
|
||||
caches['default'].touch(UNIT_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').all():
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.unit_aliases[a.param_1] = a.param_2
|
||||
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
|
||||
else:
|
||||
@@ -59,7 +59,7 @@ class IngredientParser:
|
||||
except KeyError:
|
||||
return food
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).first():
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return food
|
||||
|
||||
@@ -78,7 +78,7 @@ class IngredientParser:
|
||||
except KeyError:
|
||||
return unit
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).first():
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return unit
|
||||
|
||||
@@ -221,8 +221,8 @@ class IngredientParser:
|
||||
|
||||
# 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'^([A-z])+(.)*[1-9](\d)*\s([A-z])+', ingredient):
|
||||
match = re.search(r'[1-9](\d)*\s([A-z])+', ingredient)
|
||||
if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient):
|
||||
match = re.search(r'[1-9](\d)*\s*([^\W\d_])+', ingredient)
|
||||
print(f'reording from {ingredient} to {ingredient[match.start():match.end()] + " " + ingredient.replace(ingredient[match.start():match.end()], "")}')
|
||||
ingredient = ingredient[match.start():match.end()] + ' ' + ingredient.replace(ingredient[match.start():match.end()], '')
|
||||
|
||||
@@ -235,6 +235,14 @@ class IngredientParser:
|
||||
# leading spaces before commas result in extra tokens, clean them out
|
||||
ingredient = ingredient.replace(' ,', ',')
|
||||
|
||||
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
|
||||
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
|
||||
ingredient = re.sub("^(\d+|\d+[\\.,]\d+) - (\d+|\d+[\\.,]\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
|
||||
|
||||
# if amount and unit are connected add space in between
|
||||
if re.match('([0-9])+([A-z])+\s', ingredient):
|
||||
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
|
||||
|
||||
tokens = ingredient.split() # split at each space into tokens
|
||||
if len(tokens) == 1:
|
||||
# there only is one argument, that must be the food
|
||||
|
||||
@@ -35,6 +35,7 @@ Negative examples:
|
||||
u'<p>del.icio.us</p>'
|
||||
|
||||
"""
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
import markdown
|
||||
|
||||
@@ -64,7 +65,7 @@ class UrlizePattern(markdown.inlinepatterns.Pattern):
|
||||
else:
|
||||
url = 'http://' + url
|
||||
|
||||
el = markdown.util.etree.Element("a")
|
||||
el = Element("a")
|
||||
el.set('href', url)
|
||||
el.text = markdown.util.AtomicString(text)
|
||||
return el
|
||||
@@ -73,9 +74,9 @@ class UrlizePattern(markdown.inlinepatterns.Pattern):
|
||||
class UrlizeExtension(markdown.Extension):
|
||||
""" Urlize Extension for Python-Markdown. """
|
||||
|
||||
def extendMarkdown(self, md, md_globals):
|
||||
def extendMarkdown(self, md):
|
||||
""" Replace autolink with UrlizePattern """
|
||||
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
|
||||
md.inlinePatterns.register(UrlizePattern(URLIZE_RE, md), 'autolink', 120)
|
||||
|
||||
|
||||
def makeExtension(*args, **kwargs):
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import inspect
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.cache import caches
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.contrib.rest_framework import TokenHasScope, TokenHasReadWriteScope
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import ShareLink, Recipe, UserPreference, UserSpace
|
||||
from cookbook.models import ShareLink, Recipe, UserSpace
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
@@ -27,11 +31,12 @@ def get_allowed_groups(groups_required):
|
||||
return groups_allowed
|
||||
|
||||
|
||||
def has_group_permission(user, groups):
|
||||
def has_group_permission(user, groups, no_cache=False):
|
||||
"""
|
||||
Tests if a given user is member of a certain group (or any higher group)
|
||||
Superusers always bypass permission checks.
|
||||
Unauthenticated users can't be member of any group thus always return false.
|
||||
:param no_cache: (optional) do not return cached results, always check agains DB
|
||||
:param user: django auth user object
|
||||
:param groups: list or tuple of groups the user should be checked for
|
||||
:return: True if user is in allowed groups, false otherwise
|
||||
@@ -39,13 +44,23 @@ def has_group_permission(user, groups):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
groups_allowed = get_allowed_groups(groups)
|
||||
|
||||
CACHE_KEY = hash((inspect.stack()[0][3], (user.pk, user.username, user.email), groups_allowed))
|
||||
if not no_cache:
|
||||
cached_result = cache.get(CACHE_KEY, default=None)
|
||||
if cached_result is not None:
|
||||
return cached_result
|
||||
|
||||
result = False
|
||||
if user.is_authenticated:
|
||||
if user_space := user.userspace_set.filter(active=True):
|
||||
if len(user_space) != 1:
|
||||
return False # do not allow any group permission if more than one space is active, needs to be changed when simultaneous multi-space-tenancy is added
|
||||
if bool(user_space.first().groups.filter(name__in=groups_allowed)):
|
||||
return True
|
||||
return False
|
||||
result = False # do not allow any group permission if more than one space is active, needs to be changed when simultaneous multi-space-tenancy is added
|
||||
elif bool(user_space.first().groups.filter(name__in=groups_allowed)):
|
||||
result = True
|
||||
|
||||
cache.set(CACHE_KEY, result, timeout=10)
|
||||
return result
|
||||
|
||||
|
||||
def is_object_owner(user, obj):
|
||||
@@ -104,7 +119,7 @@ def share_link_valid(recipe, share):
|
||||
"""
|
||||
try:
|
||||
CACHE_KEY = f'recipe_share_{recipe.pk}_{share}'
|
||||
if c := caches['default'].get(CACHE_KEY, False):
|
||||
if c := cache.get(CACHE_KEY, False):
|
||||
return c
|
||||
|
||||
if link := ShareLink.objects.filter(recipe=recipe, uuid=share, abuse_blocked=False).first():
|
||||
@@ -112,7 +127,7 @@ def share_link_valid(recipe, share):
|
||||
return False
|
||||
link.request_count += 1
|
||||
link.save()
|
||||
caches['default'].set(CACHE_KEY, True, timeout=3)
|
||||
cache.set(CACHE_KEY, True, timeout=3)
|
||||
return True
|
||||
return False
|
||||
except ValidationError:
|
||||
@@ -338,6 +353,34 @@ class CustomUserPermission(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
|
||||
class CustomTokenHasScope(TokenHasScope):
|
||||
"""
|
||||
Custom implementation of Django OAuth Toolkit TokenHasScope class
|
||||
Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored
|
||||
IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if type(request.auth) == AccessToken:
|
||||
return super().has_permission(request, view)
|
||||
else:
|
||||
return request.user.is_authenticated
|
||||
|
||||
|
||||
class CustomTokenHasReadWriteScope(TokenHasReadWriteScope):
|
||||
"""
|
||||
Custom implementation of Django OAuth Toolkit TokenHasReadWriteScope class
|
||||
Only difference: if any other authentication method except OAuth2Authentication is used the scope check is ignored
|
||||
IMPORTANT: do not use this class without any other permission class as it will not check anything besides token scopes
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if type(request.auth) == AccessToken:
|
||||
return super().has_permission(request, view)
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def above_space_limit(space): # TODO add file storage limit
|
||||
"""
|
||||
Test if the space has reached any limit (e.g. max recipes, users, ..)
|
||||
|
||||
@@ -3,8 +3,9 @@ from collections import Counter
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
|
||||
from django.core.cache import cache
|
||||
from django.core.cache import caches
|
||||
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, When)
|
||||
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value, When, FilteredRelation)
|
||||
from django.db.models.functions import Coalesce, Lower, Substr
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -21,7 +22,7 @@ from recipes import settings
|
||||
class RecipeSearch():
|
||||
_postgres = settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
|
||||
|
||||
def __init__(self, request, **params):
|
||||
def __init__(self, request, **params):
|
||||
self._request = request
|
||||
self._queryset = None
|
||||
if f := params.get('filter', None):
|
||||
@@ -35,7 +36,13 @@ class RecipeSearch():
|
||||
else:
|
||||
self._params = {**(params or {})}
|
||||
if self._request.user.is_authenticated:
|
||||
self._search_prefs = request.user.searchpreference
|
||||
CACHE_KEY = f'search_pref_{request.user.id}'
|
||||
cached_result = cache.get(CACHE_KEY, default=None)
|
||||
if cached_result is not None:
|
||||
self._search_prefs = cached_result
|
||||
else:
|
||||
self._search_prefs = request.user.searchpreference
|
||||
cache.set(CACHE_KEY, self._search_prefs, timeout=10)
|
||||
else:
|
||||
self._search_prefs = SearchPreference()
|
||||
self._string = self._params.get('query').strip() if self._params.get('query', None) else None
|
||||
@@ -110,19 +117,20 @@ class RecipeSearch():
|
||||
)
|
||||
self.search_rank = None
|
||||
self.orderby = []
|
||||
self._default_sort = ['-favorite'] # TODO add user setting
|
||||
self._filters = None
|
||||
self._fuzzy_match = None
|
||||
|
||||
def get_queryset(self, queryset):
|
||||
self._queryset = queryset
|
||||
self._queryset = self._queryset.prefetch_related('keywords')
|
||||
|
||||
self._build_sort_order()
|
||||
self._recently_viewed(num_recent=self._num_recent)
|
||||
self._cooked_on_filter(cooked_date=self._cookedon)
|
||||
self._created_on_filter(created_date=self._createdon)
|
||||
self._updated_on_filter(updated_date=self._updatedon)
|
||||
self._viewed_on_filter(viewed_date=self._viewedon)
|
||||
self._favorite_recipes(timescooked=self._timescooked)
|
||||
self._favorite_recipes(times_cooked=self._timescooked)
|
||||
self._new_recipes()
|
||||
self.keyword_filters(**self._keywords)
|
||||
self.food_filters(**self._foods)
|
||||
@@ -149,7 +157,7 @@ class RecipeSearch():
|
||||
else:
|
||||
order = []
|
||||
# TODO add userpreference for default sort order and replace '-favorite'
|
||||
default_order = ['-favorite']
|
||||
default_order = ['-name']
|
||||
# recent and new_recipe are always first; they float a few recipes to the top
|
||||
if self._num_recent:
|
||||
order += ['-recent']
|
||||
@@ -206,7 +214,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
||||
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
|
||||
self._queryset = self._queryset.annotate(score=F('rank')+F('simularity'))
|
||||
self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
|
||||
else:
|
||||
query_filter = Q()
|
||||
for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]:
|
||||
@@ -287,25 +295,25 @@ class RecipeSearch():
|
||||
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
||||
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||
|
||||
def _favorite_recipes(self, timescooked=None):
|
||||
if self._sort_includes('favorite') or timescooked:
|
||||
lessthan = '-' in (timescooked or []) or not self._sort_includes('-favorite')
|
||||
if lessthan:
|
||||
def _favorite_recipes(self, times_cooked=None):
|
||||
if self._sort_includes('favorite') or times_cooked:
|
||||
less_than = '-' in (times_cooked or []) or not self._sort_includes('-favorite')
|
||||
if less_than:
|
||||
default = 1000
|
||||
else:
|
||||
default = 0
|
||||
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
|
||||
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
|
||||
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
if timescooked is None:
|
||||
if times_cooked is None:
|
||||
return
|
||||
|
||||
if timescooked == '0':
|
||||
if times_cooked == '0':
|
||||
self._queryset = self._queryset.filter(favorite=0)
|
||||
elif lessthan:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(timescooked[1:])).exclude(favorite=0)
|
||||
elif less_than:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked[1:])).exclude(favorite=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(favorite__gte=int(timescooked))
|
||||
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
|
||||
|
||||
def keyword_filters(self, **kwargs):
|
||||
if all([kwargs[x] is None for x in kwargs]):
|
||||
@@ -505,10 +513,10 @@ class RecipeSearch():
|
||||
shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
|
||||
|
||||
onhand_filter = (
|
||||
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
|
||||
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) # or substitute food onhand
|
||||
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
|
||||
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
|
||||
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
|
||||
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users) # or substitute food onhand
|
||||
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
|
||||
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
|
||||
)
|
||||
makenow_recipes = Recipe.objects.annotate(
|
||||
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
|
||||
@@ -517,10 +525,10 @@ class RecipeSearch():
|
||||
steps__ingredients__food__recipe__isnull=True), distinct=True),
|
||||
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)),
|
||||
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0))
|
||||
).annotate(missingfood=F('count_food')-F('count_onhand')-F('count_ignore_shopping')).filter(missingfood=missing)
|
||||
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing)
|
||||
self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))
|
||||
|
||||
@ staticmethod
|
||||
@staticmethod
|
||||
def __children_substitute_filter(shopping_users=None):
|
||||
children_onhand_subquery = Food.objects.filter(
|
||||
path__startswith=OuterRef('path'),
|
||||
@@ -536,10 +544,10 @@ class RecipeSearch():
|
||||
).annotate(child_onhand_count=Exists(children_onhand_subquery)
|
||||
).filter(child_onhand_count=True)
|
||||
|
||||
@ staticmethod
|
||||
@staticmethod
|
||||
def __sibling_substitute_filter(shopping_users=None):
|
||||
sibling_onhand_subquery = Food.objects.filter(
|
||||
path__startswith=Substr(OuterRef('path'), 1, Food.steplen*(OuterRef('depth')-1)),
|
||||
path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
|
||||
depth=OuterRef('depth'),
|
||||
onhand_users__in=shopping_users
|
||||
)
|
||||
@@ -563,7 +571,7 @@ class RecipeFacet():
|
||||
|
||||
self._request = request
|
||||
self._queryset = queryset
|
||||
self.hash_key = hash_key or str(hash(frozenset(self._queryset.values_list('pk'))))
|
||||
self.hash_key = hash_key or str(hash(self._queryset.query))
|
||||
self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
|
||||
self._cache_timeout = cache_timeout
|
||||
self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
|
||||
@@ -743,7 +751,7 @@ class RecipeFacet():
|
||||
).filter(depth=depth, count__gt=0
|
||||
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
|
||||
else:
|
||||
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
|
||||
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
|
||||
|
||||
def _food_queryset(self, queryset, food=None):
|
||||
depth = getattr(food, 'depth', 0) + 1
|
||||
@@ -755,4 +763,3 @@ class RecipeFacet():
|
||||
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
|
||||
else:
|
||||
return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ from recipe_scrapers._utils import get_host_name, get_minutes
|
||||
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.models import Keyword
|
||||
from cookbook.models import Keyword, Automation
|
||||
|
||||
|
||||
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
|
||||
|
||||
@@ -21,7 +22,7 @@ def get_from_scraper(scrape, request):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
recipe_json = {}
|
||||
try:
|
||||
recipe_json['name'] = parse_name(scrape.title() or None)
|
||||
recipe_json['name'] = parse_name(scrape.title()[:128] or None)
|
||||
except Exception:
|
||||
recipe_json['name'] = None
|
||||
if not recipe_json['name']:
|
||||
@@ -121,7 +122,13 @@ def get_from_scraper(scrape, request):
|
||||
try:
|
||||
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
|
||||
except Exception:
|
||||
pass
|
||||
recipe_json['source_url'] = ''
|
||||
|
||||
try:
|
||||
if scrape.author():
|
||||
keywords.append(scrape.author())
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space)
|
||||
@@ -139,42 +146,58 @@ def get_from_scraper(scrape, request):
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
|
||||
if len(parse_description(description)) > 256: # split at 256 as long descriptions dont look good on recipe cards
|
||||
recipe_json['steps'][0]['instruction'] = f'*{parse_description(description)}* \n\n' + recipe_json['steps'][0]['instruction']
|
||||
parsed_description = parse_description(description)
|
||||
# TODO notify user about limit if reached
|
||||
# limits exist to limit the attack surface for dos style attacks
|
||||
automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').all().order_by('order')[:512]
|
||||
for a in automations:
|
||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
|
||||
|
||||
if len(parsed_description) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
recipe_json['steps'][0]['instruction'] = f'*{parsed_description}* \n\n' + recipe_json['steps'][0]['instruction']
|
||||
else:
|
||||
recipe_json['description'] = parse_description(description)[:512]
|
||||
recipe_json['description'] = parsed_description[:512]
|
||||
|
||||
try:
|
||||
for x in scrape.ingredients():
|
||||
try:
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(x)
|
||||
ingredient = {
|
||||
'amount': amount,
|
||||
'food': {
|
||||
'name': ingredient,
|
||||
},
|
||||
'unit': None,
|
||||
'note': note,
|
||||
'original_text': x
|
||||
}
|
||||
if unit:
|
||||
ingredient['unit'] = {'name': unit, }
|
||||
recipe_json['steps'][0]['ingredients'].append(ingredient)
|
||||
except Exception:
|
||||
recipe_json['steps'][0]['ingredients'].append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': None,
|
||||
if x.strip() != '':
|
||||
try:
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(x)
|
||||
ingredient = {
|
||||
'amount': amount,
|
||||
'food': {
|
||||
'name': x,
|
||||
'name': ingredient,
|
||||
},
|
||||
'note': '',
|
||||
'unit': None,
|
||||
'note': note,
|
||||
'original_text': x
|
||||
}
|
||||
)
|
||||
if unit:
|
||||
ingredient['unit'] = {'name': unit, }
|
||||
recipe_json['steps'][0]['ingredients'].append(ingredient)
|
||||
except Exception:
|
||||
recipe_json['steps'][0]['ingredients'].append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': None,
|
||||
'food': {
|
||||
'name': x,
|
||||
},
|
||||
'note': '',
|
||||
'original_text': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if recipe_json['source_url']:
|
||||
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
|
||||
for a in automations:
|
||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||
for s in recipe_json['steps']:
|
||||
s['instruction'] = re.sub(a.param_2, a.param_3, s['instruction'])
|
||||
|
||||
return recipe_json
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
@@ -55,7 +56,7 @@ class ScopeMiddleware:
|
||||
else:
|
||||
if request.path.startswith(prefix + '/api/'):
|
||||
try:
|
||||
if auth := TokenAuthentication().authenticate(request):
|
||||
if auth := OAuth2Authentication().authenticate(request):
|
||||
user_space = auth[0].userspace_set.filter(active=True).first()
|
||||
if user_space:
|
||||
request.space = user_space.space
|
||||
|
||||
@@ -22,10 +22,25 @@ class IngredientObject(object):
|
||||
else:
|
||||
self.amount = f"<scalable-number v-bind:number='{bleach.clean(str(ingredient.amount))}' v-bind:factor='ingredient_factor'></scalable-number>"
|
||||
if ingredient.unit:
|
||||
self.unit = bleach.clean(str(ingredient.unit))
|
||||
if ingredient.unit.plural_name in (None, ""):
|
||||
self.unit = bleach.clean(str(ingredient.unit))
|
||||
else:
|
||||
if ingredient.always_use_plural_unit or ingredient.amount > 1 and not ingredient.no_amount:
|
||||
self.unit = bleach.clean(ingredient.unit.plural_name)
|
||||
else:
|
||||
self.unit = bleach.clean(str(ingredient.unit))
|
||||
else:
|
||||
self.unit = ""
|
||||
self.food = bleach.clean(str(ingredient.food))
|
||||
if ingredient.food:
|
||||
if ingredient.food.plural_name in (None, ""):
|
||||
self.food = bleach.clean(str(ingredient.food))
|
||||
else:
|
||||
if ingredient.always_use_plural_food or ingredient.amount > 1 and not ingredient.no_amount:
|
||||
self.food = bleach.clean(str(ingredient.food.plural_name))
|
||||
else:
|
||||
self.food = bleach.clean(str(ingredient.food))
|
||||
else:
|
||||
self.food = ""
|
||||
self.note = bleach.clean(str(ingredient.note))
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import time
|
||||
import traceback
|
||||
import datetime
|
||||
import json
|
||||
import traceback
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from io import BytesIO
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
import lxml
|
||||
from django.core.cache import cache
|
||||
import datetime
|
||||
|
||||
from bs4 import Tag
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.files import File
|
||||
from django.db import IntegrityError
|
||||
@@ -20,8 +16,7 @@ from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
from lxml import etree
|
||||
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.image_processing import get_filetype, handle_image
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.models import Keyword, Recipe
|
||||
from recipes.settings import DEBUG
|
||||
from recipes.settings import EXPORT_FILE_CACHE_DURATION
|
||||
@@ -182,7 +177,7 @@ class Integration:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
import_zip.close()
|
||||
elif '.json' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
|
||||
elif '.json' in f['name'] or '.xml' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
|
||||
data_list = self.split_recipe_file(f['file'])
|
||||
il.total_recipes += len(data_list)
|
||||
for d in data_list:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile
|
||||
from PIL import Image
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import iso_duration_to_minutes
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step, NutritionInformation
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
@@ -70,12 +71,21 @@ class NextcloudCookbook(Integration):
|
||||
recipe.steps.add(step)
|
||||
|
||||
if 'nutrition' in recipe_json:
|
||||
nutrition = {}
|
||||
try:
|
||||
recipe.nutrition.calories = recipe_json['nutrition']['calories'].replace(' kcal', '').replace(' ', '')
|
||||
recipe.nutrition.proteins = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
|
||||
recipe.nutrition.fats = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
|
||||
recipe.nutrition.carbohydrates = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
|
||||
except Exception:
|
||||
if 'calories' in recipe_json['nutrition']:
|
||||
nutrition['calories'] = int(re.search(r'\d+', recipe_json['nutrition']['calories']).group())
|
||||
if 'proteinContent' in recipe_json['nutrition']:
|
||||
nutrition['proteins'] = int(re.search(r'\d+', recipe_json['nutrition']['proteinContent']).group())
|
||||
if 'fatContent' in recipe_json['nutrition']:
|
||||
nutrition['fats'] = int(re.search(r'\d+', recipe_json['nutrition']['fatContent']).group())
|
||||
if 'carbohydrateContent' in recipe_json['nutrition']:
|
||||
nutrition['carbohydrates'] = int(re.search(r'\d+', recipe_json['nutrition']['carbohydrateContent']).group())
|
||||
|
||||
if nutrition != {}:
|
||||
recipe.nutrition = NutritionInformation.objects.create(**nutrition, space=self.request.space)
|
||||
recipe.save()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
for f in self.files:
|
||||
@@ -87,5 +97,92 @@ class NextcloudCookbook(Integration):
|
||||
|
||||
return recipe
|
||||
|
||||
def formatTime(self, min):
|
||||
h = min//60
|
||||
m = min % 60
|
||||
return f'PT{h}H{m}M0S'
|
||||
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
export = {}
|
||||
export['name'] = recipe.name
|
||||
export['description'] = recipe.description
|
||||
export['url'] = recipe.source_url
|
||||
export['prepTime'] = self.formatTime(recipe.working_time)
|
||||
export['cookTime'] = self.formatTime(recipe.waiting_time)
|
||||
export['totalTime'] = self.formatTime(recipe.working_time+recipe.waiting_time)
|
||||
export['recipeYield'] = recipe.servings
|
||||
export['image'] = f'/Recipes/{recipe.name}/full.jpg'
|
||||
export['imageUrl'] = f'/Recipes/{recipe.name}/full.jpg'
|
||||
|
||||
recipeKeyword = []
|
||||
for k in recipe.keywords.all():
|
||||
recipeKeyword.append(k.name)
|
||||
|
||||
export['keywords'] = recipeKeyword
|
||||
|
||||
recipeInstructions = []
|
||||
recipeIngredient = []
|
||||
for s in recipe.steps.all():
|
||||
recipeInstructions.append(s.instruction)
|
||||
|
||||
for i in s.ingredients.all():
|
||||
recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}')
|
||||
|
||||
export['recipeIngredient'] = recipeIngredient
|
||||
export['recipeInstructions'] = recipeInstructions
|
||||
|
||||
|
||||
return "recipe.json", json.dumps(export)
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
export_zip_stream = BytesIO()
|
||||
export_zip_obj = ZipFile(export_zip_stream, 'w')
|
||||
|
||||
for recipe in recipes:
|
||||
if recipe.internal and recipe.space == self.request.space:
|
||||
|
||||
recipe_stream = StringIO()
|
||||
filename, data = self.get_file_from_recipe(recipe)
|
||||
recipe_stream.write(data)
|
||||
export_zip_obj.writestr(f'{recipe.name}/{filename}', recipe_stream.getvalue())
|
||||
recipe_stream.close()
|
||||
|
||||
try:
|
||||
imageByte = recipe.image.file.read()
|
||||
export_zip_obj.writestr(f'{recipe.name}/full.jpg', self.getJPEG(imageByte))
|
||||
export_zip_obj.writestr(f'{recipe.name}/thumb.jpg', self.getThumb(171, imageByte))
|
||||
export_zip_obj.writestr(f'{recipe.name}/thumb16.jpg', self.getThumb(16, imageByte))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(recipe)
|
||||
el.save()
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]
|
||||
|
||||
def getJPEG(self, imageByte):
|
||||
image = Image.open(BytesIO(imageByte))
|
||||
image = image.convert('RGB')
|
||||
|
||||
bytes = BytesIO()
|
||||
image.save(bytes, "JPEG")
|
||||
return bytes.getvalue()
|
||||
|
||||
def getThumb(self, size, imageByte):
|
||||
image = Image.open(BytesIO(imageByte))
|
||||
|
||||
w, h = image.size
|
||||
m = min(w, h)
|
||||
|
||||
image = image.crop(((w-m)//2, (h-m)//2, (w+m)//2, (h+m)//2))
|
||||
image = image.resize([size, size], Image.Resampling.LANCZOS)
|
||||
image = image.convert('RGB')
|
||||
|
||||
bytes = BytesIO()
|
||||
image.save(bytes, "JPEG")
|
||||
return bytes.getvalue()
|
||||
|
||||
@@ -61,7 +61,7 @@ class RecetteTek(Integration):
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in file['ingredients'].split('\n'):
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, food, note = ingredient_parser.parse(food)
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient.strip())
|
||||
f = ingredient_parser.get_food(ingredient)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
|
||||
@@ -41,7 +41,7 @@ class RecipeKeeper(Integration):
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(instruction='', space=self.request.space,)
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):
|
||||
@@ -51,13 +51,20 @@ class RecipeKeeper(Integration):
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,
|
||||
food=f, unit=u, amount=amount, note=note, original_text=str(ingredient).replace('<p>', '').replace('</p>', ''), space=self.request.space,
|
||||
))
|
||||
|
||||
for s in file.find("div", {"itemprop": "recipeDirections"}).find_all("p"):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text + ' \n'
|
||||
step.save()
|
||||
|
||||
for s in file.find("div", {"itemprop": "recipeNotes"}).find_all("p"):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text + ' \n'
|
||||
step.save()
|
||||
|
||||
if file.find("span", {"itemprop": "recipeSource"}).text != '':
|
||||
step.instruction += "\n\n" + _("Imported from") + ": " + file.find("span", {"itemprop": "recipeSource"}).text
|
||||
|
||||
59
cookbook/integration/rezeptsuitede.py
Normal file
59
cookbook/integration/rezeptsuitede.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from xml import etree
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_time, parse_servings, parse_servings_text
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
|
||||
class Rezeptsuitede(Integration):
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
xml_file = etree.parse(file).getroot().getchildren()
|
||||
recipe_list = xml_file.find('recipe')
|
||||
return recipe_list
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_xml = file
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_xml.find('title').text.strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
if recipe_xml.find('servingtype') is not None and recipe_xml.find('servingtype').text is not None:
|
||||
recipe.servings = parse_servings(recipe_xml.find('servingtype').text.strip())
|
||||
recipe.servings_text = parse_servings_text(recipe_xml.find('servingtype').text.strip())
|
||||
|
||||
if recipe_xml.find('description') is not None: # description is a list of <li>'s with text
|
||||
if len(recipe_xml.find('description')) > 0:
|
||||
recipe.description = recipe_xml.find('description')[0].text[:512]
|
||||
|
||||
for step in recipe_xml.find('step'):
|
||||
if step.text:
|
||||
step = Step.objects.create(
|
||||
instruction=step.text.strip(), space=self.request.space,
|
||||
)
|
||||
recipe.steps.add(step)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
if recipe_xml.find('ingredient'):
|
||||
ingredient_step = recipe.steps.first()
|
||||
if ingredient_step is None:
|
||||
ingredient_step = Step.objects.create(space=self.request.space, instruction='')
|
||||
|
||||
for ingredient in recipe_xml.find('ingredient'):
|
||||
f = ingredient_parser.get_food(ingredient.attrib['item'])
|
||||
u = ingredient_parser.get_unit(ingredient.attrib['unit'])
|
||||
ingredient_step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=ingredient.attrib['qty'], original_text=ingredient.text.strip(), space=self.request.space,
|
||||
))
|
||||
|
||||
recipe.save()
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -6,20 +6,22 @@
|
||||
# Translators:
|
||||
# Pavel Solař <pavelsolar86@gmail.com>, 2021
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
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: 2020-06-02 19:28+0000\n"
|
||||
"Last-Translator: Pavel Solař <pavelsolar86@gmail.com>, 2021\n"
|
||||
"Language-Team: Czech (https://www.transifex.com/django-recipes/teams/110507/cs/)\n"
|
||||
"PO-Revision-Date: 2023-01-08 17:55+0000\n"
|
||||
"Last-Translator: Joachim Weber <joachim.weber@gmx.de>\n"
|
||||
"Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/cs/>\n"
|
||||
"Language: cs\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: cs\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n "
|
||||
"<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\filters.py:22 .\cookbook\templates\base.html:87
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
|
||||
@@ -173,7 +175,7 @@ msgstr "Potravina, která by měla být nahrazena."
|
||||
|
||||
#: .\cookbook\forms.py:198
|
||||
msgid "Add your comment: "
|
||||
msgstr "Přidat vlastní komentář:"
|
||||
msgstr "Přidat vlastní komentář: "
|
||||
|
||||
#: .\cookbook\forms.py:229
|
||||
msgid "Leave empty for dropbox and enter app password for nextcloud."
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ 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-05-10 15:32+0000\n"
|
||||
"PO-Revision-Date: 2022-08-18 14:32+0000\n"
|
||||
"Last-Translator: Mathias Rasmussen <math625f@gmail.com>\n"
|
||||
"Language-Team: Danish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/da/>\n"
|
||||
@@ -2377,9 +2377,9 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"At servere mediefiler direkte med gunicorn/python er <b>ikke anbefalet</b>!\n"
|
||||
" Følg venligst trinne beskrevet\n"
|
||||
" Følg venligst trinnene beskrevet\n"
|
||||
" <a href=\"https://github.com/vabene1111/recipes/releases/tag/0.8.1\""
|
||||
">here</a> for at opdtere\n"
|
||||
">her</a> for at opdatere\n"
|
||||
" din installation.\n"
|
||||
" "
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/el/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/el/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2610
cookbook/locale/el/LC_MESSAGES/django.po
Normal file
2610
cookbook/locale/el/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -11,8 +11,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
|
||||
"PO-Revision-Date: 2021-10-13 12:50+0000\n"
|
||||
"Last-Translator: Hrachya Kocharyan <hkocharyan@ctemplar.com>\n"
|
||||
"PO-Revision-Date: 2023-01-08 17:55+0000\n"
|
||||
"Last-Translator: Joachim Weber <joachim.weber@gmx.de>\n"
|
||||
"Language-Team: Armenian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/hy/>\n"
|
||||
"Language: hy\n"
|
||||
@@ -20,7 +20,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Weblate 4.8\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\filters.py:22 .\cookbook\templates\base.html:87
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
|
||||
@@ -410,7 +410,7 @@ msgstr "Դուրս գալ"
|
||||
|
||||
#: .\cookbook\templates\account\logout.html:11
|
||||
msgid "Are you sure you want to sign out?"
|
||||
msgstr "Համոզվա՞ծ եք, որ ցանկանում եք դուրս գալ:"
|
||||
msgstr "Համոզվա՞ծ եք, որ ցանկանում եք դուրս գալ՞"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset.html:5
|
||||
#: .\cookbook\templates\account\password_reset_done.html:5
|
||||
|
||||
BIN
cookbook/locale/id/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/id/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2647
cookbook/locale/id/LC_MESSAGES/django.po
Normal file
2647
cookbook/locale/id/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-09-13 22:40+0200\n"
|
||||
"PO-Revision-Date: 2022-04-07 19:32+0000\n"
|
||||
"Last-Translator: Artem Aksenov <artemmillerr@gmail.com>\n"
|
||||
"PO-Revision-Date: 2022-11-30 19:09+0000\n"
|
||||
"Last-Translator: Alex <kovsharoff@gmail.com>\n"
|
||||
"Language-Team: Russian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
|
||||
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
"X-Generator: Weblate 4.14.1\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
|
||||
#: .\cookbook\templates\forms\ingredients.html:34
|
||||
@@ -396,8 +396,9 @@ msgstr ""
|
||||
#: .\cookbook\templates\include\log_cooking.html:16
|
||||
#: .\cookbook\templates\url_import.html:224
|
||||
#: .\cookbook\templates\url_import.html:455
|
||||
#, fuzzy
|
||||
msgid "Servings"
|
||||
msgstr ""
|
||||
msgstr "Порции"
|
||||
|
||||
#: .\cookbook\integration\safron.py:25
|
||||
msgid "Waiting time"
|
||||
@@ -468,7 +469,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\models.py:198 .\cookbook\templates\base.html:90
|
||||
msgid "Books"
|
||||
msgstr ""
|
||||
msgstr "Книги"
|
||||
|
||||
#: .\cookbook\models.py:206
|
||||
msgid "Small"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
2621
cookbook/locale/tr/id/LC_MESSAGES/django.po
Normal file
2621
cookbook/locale/tr/id/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
19
cookbook/migrations/0183_alter_space_image.py
Normal file
19
cookbook/migrations/0183_alter_space_image.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.0.6 on 2022-08-04 16:46
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0182_userpreference_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='image',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_image', to='cookbook.userfile'),
|
||||
),
|
||||
]
|
||||
19
cookbook/migrations/0184_alter_userpreference_image.py
Normal file
19
cookbook/migrations/0184_alter_userpreference_image.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.0.7 on 2022-09-12 10:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0183_alter_space_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='image',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_image', to='cookbook.userfile'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 4.0.8 on 2022-11-22 06:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0184_alter_userpreference_image'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='plural_name',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ingredient',
|
||||
name='always_use_plural_food',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ingredient',
|
||||
name='always_use_plural_unit',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='use_plural',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unit',
|
||||
name='plural_name',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-03 21:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0185_food_plural_name_ingredient_always_use_plural_food_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='automation',
|
||||
name='order',
|
||||
field=models.IntegerField(default=1000),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='automation',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('FOOD_ALIAS', 'Food Alias'), ('UNIT_ALIAS', 'Unit Alias'), ('KEYWORD_ALIAS', 'Keyword Alias'), ('DESCRIPTION_REPLACE', 'Description Replace'), ('INSTRUCTION_REPLACE', 'Instruction Replace')], max_length=128),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0187_alter_space_use_plural.py
Normal file
18
cookbook/migrations/0187_alter_space_use_plural.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-20 09:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0186_automation_order_alter_automation_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='use_plural',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
|
||||
import oauth2_provider.models
|
||||
from PIL import Image
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
@@ -13,7 +14,7 @@ from django.contrib.postgres.search import SearchVectorField
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models import Index, ProtectedError, Q
|
||||
from django.db.models import Index, ProtectedError, Q, Avg, Max
|
||||
from django.db.models.fields.related import ManyToManyField
|
||||
from django.db.models.functions import Substr
|
||||
from django.utils import timezone
|
||||
@@ -63,6 +64,13 @@ auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
|
||||
auth.models.User.add_to_class('get_active_space', get_active_space)
|
||||
|
||||
|
||||
def oauth_token_get_owner(self):
|
||||
return self.user
|
||||
|
||||
|
||||
oauth2_provider.models.AccessToken.add_to_class('get_owner', oauth_token_get_owner)
|
||||
|
||||
|
||||
def get_model_name(model):
|
||||
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
|
||||
|
||||
@@ -245,13 +253,14 @@ class FoodInheritField(models.Model, PermissionModelMixin):
|
||||
|
||||
class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, related_name='space_image')
|
||||
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image')
|
||||
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)
|
||||
max_recipes = models.IntegerField(default=0)
|
||||
max_file_storage_mb = models.IntegerField(default=0, help_text=_('Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'))
|
||||
max_users = models.IntegerField(default=0)
|
||||
use_plural = models.BooleanField(default=True)
|
||||
allow_sharing = models.BooleanField(default=True)
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
@@ -358,7 +367,7 @@ 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, related_name='user_image')
|
||||
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image')
|
||||
theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
|
||||
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
|
||||
default_unit = models.CharField(max_length=32, default='g')
|
||||
@@ -522,6 +531,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
@@ -546,6 +556,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
if SORT_TREE_BY_NAME:
|
||||
node_order_by = ['name']
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field
|
||||
ignore_shopping = models.BooleanField(default=False) # inherited field
|
||||
@@ -646,6 +657,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
note = models.CharField(max_length=256, null=True, blank=True)
|
||||
is_header = models.BooleanField(default=False)
|
||||
no_amount = models.BooleanField(default=False)
|
||||
always_use_plural_unit = models.BooleanField(default=False)
|
||||
always_use_plural_food = models.BooleanField(default=False)
|
||||
order = models.IntegerField(default=0)
|
||||
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
|
||||
|
||||
@@ -655,7 +668,23 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.food)
|
||||
food = ""
|
||||
unit = ""
|
||||
if self.always_use_plural_food and self.food.plural_name not in (None, "") and not self.no_amount:
|
||||
food = self.food.plural_name
|
||||
else:
|
||||
if self.amount > 1 and self.food.plural_name not in (None, "") and not self.no_amount:
|
||||
food = self.food.plural_name
|
||||
else:
|
||||
food = str(self.food)
|
||||
if self.always_use_plural_unit and self.unit.plural_name not in (None, "") and not self.no_amount:
|
||||
unit = self.unit.plural_name
|
||||
else:
|
||||
if self.amount > 1 and self.unit.plural_name not in (None, "") and not self.no_amount:
|
||||
unit = self.unit.plural_name
|
||||
else:
|
||||
unit = str(self.unit)
|
||||
return str(self.amount) + ' ' + str(unit) + ' ' + str(food)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'pk']
|
||||
@@ -714,6 +743,10 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
# objects = ScopedManager(space='space')
|
||||
|
||||
class RecipeManager(models.Manager.from_queryset(models.QuerySet)):
|
||||
def get_queryset(self):
|
||||
return super(RecipeManager, self).get_queryset().annotate(rating=Avg('cooklog__rating')).annotate(last_cooked=Max('cooklog__created_at'))
|
||||
|
||||
|
||||
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
@@ -745,7 +778,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
||||
desc_search_vector = SearchVectorField(null=True)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
objects = ScopedManager(space='space')
|
||||
objects = ScopedManager(space='space', _manager_class=RecipeManager)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -1190,9 +1223,12 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
||||
FOOD_ALIAS = 'FOOD_ALIAS'
|
||||
UNIT_ALIAS = 'UNIT_ALIAS'
|
||||
KEYWORD_ALIAS = 'KEYWORD_ALIAS'
|
||||
DESCRIPTION_REPLACE = 'DESCRIPTION_REPLACE'
|
||||
INSTRUCTION_REPLACE = 'INSTRUCTION_REPLACE'
|
||||
|
||||
type = models.CharField(max_length=128,
|
||||
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')),))
|
||||
choices=((FOOD_ALIAS, _('Food Alias')), (UNIT_ALIAS, _('Unit Alias')), (KEYWORD_ALIAS, _('Keyword Alias')),
|
||||
(DESCRIPTION_REPLACE, _('Description Replace')), (INSTRUCTION_REPLACE, _('Instruction Replace')),))
|
||||
name = models.CharField(max_length=128, default='')
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
@@ -1200,6 +1236,8 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
|
||||
param_2 = models.CharField(max_length=128, blank=True, null=True)
|
||||
param_3 = models.CharField(max_length=128, blank=True, null=True)
|
||||
|
||||
order = models.IntegerField(default=1000)
|
||||
|
||||
disabled = models.BooleanField(default=False)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from gettext import gettext as _
|
||||
@@ -14,6 +15,7 @@ from django.utils import timezone
|
||||
from django_scopes import scopes_disabled
|
||||
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
|
||||
from PIL import Image
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
|
||||
@@ -143,7 +145,7 @@ class UserSerializer(WritableNestedModelSerializer):
|
||||
list_serializer_class = SpaceFilterSerializer
|
||||
model = User
|
||||
fields = ('id', 'username', 'first_name', 'last_name', 'display_name')
|
||||
read_only_fields = ('username', )
|
||||
read_only_fields = ('username',)
|
||||
|
||||
|
||||
class GroupSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
@@ -255,7 +257,7 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
recipe_count = serializers.SerializerMethodField('get_recipe_count')
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
|
||||
food_inherit = FoodInheritFieldSerializer(many=True)
|
||||
image = UserFileViewSerializer(required=False, many=False)
|
||||
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
|
||||
def get_user_count(self, obj):
|
||||
return UserSpace.objects.filter(space=obj).count()
|
||||
@@ -275,7 +277,8 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = Space
|
||||
fields = ('id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb', 'image',)
|
||||
'allow_sharing', 'demo', 'food_inherit', 'show_facet_count', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'use_plural',)
|
||||
read_only_fields = ('id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing', 'demo',)
|
||||
|
||||
|
||||
@@ -429,17 +432,22 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
|
||||
def create(self, validated_data):
|
||||
name = validated_data.pop('name').strip()
|
||||
plural_name = validated_data.pop('plural_name', None)
|
||||
if plural_name:
|
||||
plural_name = plural_name.strip()
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
obj, created = Unit.objects.get_or_create(name=name, space=space, defaults=validated_data)
|
||||
obj, created = Unit.objects.get_or_create(name=name, plural_name=plural_name, space=space, defaults=validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
if plural_name := validated_data.get('plural_name', None):
|
||||
validated_data['plural_name'] = plural_name.strip()
|
||||
return super(UnitSerializer, self).update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Unit
|
||||
fields = ('id', 'name', 'description', 'numrecipe', 'image')
|
||||
fields = ('id', 'name', 'plural_name', 'description', 'numrecipe', 'image')
|
||||
read_only_fields = ('id', 'numrecipe', 'image')
|
||||
|
||||
|
||||
@@ -497,7 +505,7 @@ class RecipeSimpleSerializer(WritableNestedModelSerializer):
|
||||
class FoodSimpleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('id', 'name')
|
||||
fields = ('id', 'name', 'plural_name')
|
||||
|
||||
|
||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
@@ -536,6 +544,9 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
|
||||
def create(self, validated_data):
|
||||
name = validated_data.pop('name').strip()
|
||||
plural_name = validated_data.pop('plural_name', None)
|
||||
if plural_name:
|
||||
plural_name = plural_name.strip()
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
# supermarket category needs to be handled manually as food.get or create does not create nested serializers unlike a super.create of serializer
|
||||
if 'supermarket_category' in validated_data and validated_data['supermarket_category']:
|
||||
@@ -560,12 +571,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
else:
|
||||
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
|
||||
|
||||
obj, created = Food.objects.get_or_create(name=name, space=space, defaults=validated_data)
|
||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, defaults=validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if name := validated_data.get('name', None):
|
||||
validated_data['name'] = name.strip()
|
||||
if plural_name := validated_data.get('plural_name', None):
|
||||
validated_data['plural_name'] = plural_name.strip()
|
||||
# assuming if on hand for user also onhand for shopping_share users
|
||||
onhand = validated_data.get('food_onhand', None)
|
||||
reset_inherit = self.initial_data.get('reset_inherit', False)
|
||||
@@ -585,7 +598,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = (
|
||||
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
||||
'id', 'name', 'plural_name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
|
||||
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields'
|
||||
)
|
||||
@@ -614,6 +627,7 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
||||
fields = (
|
||||
'id', 'food', 'unit', 'amount', 'note', 'order',
|
||||
'is_header', 'no_amount', 'original_text', 'used_in_recipes',
|
||||
'always_use_plural_unit', 'always_use_plural_food',
|
||||
)
|
||||
|
||||
|
||||
@@ -682,25 +696,6 @@ class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class RecipeBaseSerializer(WritableNestedModelSerializer):
|
||||
def get_recipe_rating(self, obj):
|
||||
try:
|
||||
rating = obj.cooklog_set.filter(created_by=self.context['request'].user, rating__gt=0).aggregate(
|
||||
Avg('rating'))
|
||||
if rating['rating__avg']:
|
||||
return rating['rating__avg']
|
||||
except TypeError:
|
||||
pass
|
||||
return 0
|
||||
|
||||
def get_recipe_last_cooked(self, obj):
|
||||
try:
|
||||
last = obj.cooklog_set.filter(created_by=self.context['request'].user).order_by('created_at').last()
|
||||
if last:
|
||||
return last.created_at
|
||||
except TypeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
# TODO make days of new recipe a setting
|
||||
def is_recipe_new(self, obj):
|
||||
if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)):
|
||||
@@ -711,11 +706,12 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
keywords = KeywordLabelSerializer(many=True)
|
||||
rating = serializers.SerializerMethodField('get_recipe_rating')
|
||||
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
|
||||
new = serializers.SerializerMethodField('is_recipe_new')
|
||||
recent = serializers.ReadOnlyField()
|
||||
|
||||
rating = CustomDecimalField(required=False, allow_null=True)
|
||||
last_cooked = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
pass
|
||||
|
||||
@@ -736,9 +732,9 @@ class RecipeSerializer(RecipeBaseSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
steps = StepSerializer(many=True)
|
||||
keywords = KeywordSerializer(many=True)
|
||||
rating = serializers.SerializerMethodField('get_recipe_rating')
|
||||
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
|
||||
shared = UserSerializer(many=True, required=False)
|
||||
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
|
||||
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
@@ -880,11 +876,11 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
value = value.quantize(
|
||||
Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
|
||||
return (
|
||||
obj.name
|
||||
or getattr(obj.mealplan, 'title', None)
|
||||
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
|
||||
or obj.recipe.name
|
||||
) + f' ({value:.2g})'
|
||||
obj.name
|
||||
or getattr(obj.mealplan, 'title', None)
|
||||
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
|
||||
or obj.recipe.name
|
||||
) + f' ({value:.2g})'
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# TODO remove once old shopping list
|
||||
@@ -1071,7 +1067,7 @@ class AutomationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Automation
|
||||
fields = (
|
||||
'id', 'type', 'name', 'description', 'param_1', 'param_2', 'param_3', 'disabled', 'created_by',)
|
||||
'id', 'type', 'name', 'description', 'param_1', 'param_2', 'param_3', 'order', 'disabled', 'created_by',)
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
@@ -1134,6 +1130,27 @@ class BookmarkletImportSerializer(BookmarkletImportListSerializer):
|
||||
read_only_fields = ('created_by', 'space')
|
||||
|
||||
|
||||
# OAuth / Auth Token related Serializers
|
||||
|
||||
class AccessTokenSerializer(serializers.ModelSerializer):
|
||||
token = serializers.SerializerMethodField('get_token')
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['token'] = f'tda_{str(uuid.uuid4()).replace("-", "_")}'
|
||||
validated_data['user'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
def get_token(self, obj):
|
||||
if (timezone.now() - obj.created).seconds < 15:
|
||||
return obj.token
|
||||
return f'tda_************_******_***********{obj.token[len(obj.token) - 4:]}'
|
||||
|
||||
class Meta:
|
||||
model = AccessToken
|
||||
fields = ('id', 'token', 'expires', 'scope', 'created', 'updated')
|
||||
read_only_fields = ('id', 'token',)
|
||||
|
||||
|
||||
# Export/Import Serializers
|
||||
|
||||
class KeywordExportSerializer(KeywordSerializer):
|
||||
@@ -1157,7 +1174,7 @@ class SupermarketCategoryExportSerializer(SupermarketCategorySerializer):
|
||||
class UnitExportSerializer(UnitSerializer):
|
||||
class Meta:
|
||||
model = Unit
|
||||
fields = ('name', 'description')
|
||||
fields = ('name', 'plural_name', 'description')
|
||||
|
||||
|
||||
class FoodExportSerializer(FoodSerializer):
|
||||
@@ -1165,7 +1182,7 @@ class FoodExportSerializer(FoodSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'ignore_shopping', 'supermarket_category',)
|
||||
fields = ('name', 'plural_name', 'ignore_shopping', 'supermarket_category',)
|
||||
|
||||
|
||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
@@ -1179,7 +1196,7 @@ class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount')
|
||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount', 'always_use_plural_unit', 'always_use_plural_food')
|
||||
|
||||
|
||||
class StepExportSerializer(WritableNestedModelSerializer):
|
||||
|
||||
1
cookbook/static/css/bootstrap-vue.min.css
vendored
1
cookbook/static/css/bootstrap-vue.min.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,7 +28,7 @@
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.setRequestHeader('Authorization', 'Token ' + token);
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
|
||||
|
||||
// listen for `onload` event
|
||||
xhr.onload = () => {
|
||||
1
cookbook/static/themes/bootstrap.min.css
vendored
1
cookbook/static/themes/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
652
cookbook/static/themes/tandoor.min.css
vendored
652
cookbook/static/themes/tandoor.min.css
vendored
@@ -2815,6 +2815,323 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
width: 100%
|
||||
}
|
||||
|
||||
|
||||
|
||||
.btn {
|
||||
font-size: .875rem;
|
||||
font-family: Poppins, sans-serif;
|
||||
padding: .625rem 1.25rem;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.btn.btn-rounded {
|
||||
border-radius: 50px
|
||||
}
|
||||
|
||||
.btn.btn-white {
|
||||
background: #fff;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn.btn-white:hover {
|
||||
background: #a7240e;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
box-shadow: none
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: transparent;
|
||||
color: #b98766;
|
||||
border: 1px solid #b98766
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: transparent;
|
||||
color: #b55e4f;
|
||||
border: 1px solid #b55e4f
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: transparent;
|
||||
color: #82aa8b;
|
||||
border: 1px solid #82aa8b
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background: transparent;
|
||||
color: #385f84;
|
||||
border: 1px solid #385f84
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: transparent;
|
||||
color: #eaaa21;
|
||||
border: 1px solid #eaaa21
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: transparent;
|
||||
color: #a7240e;
|
||||
border: 1px solid #a7240e
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background-color: hsla(0, 0%, 18%, .5);
|
||||
color: #cfd5cd;
|
||||
border: 1px solid hsla(0, 0%, 18%, .5)
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
background: transparent;
|
||||
color: #221e1e;
|
||||
border: 1px solid #221e1e
|
||||
}
|
||||
|
||||
.btn-opacity-primary {
|
||||
color: #b98766;
|
||||
background-color: #0012a7;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-primary:hover {
|
||||
color: #b98766;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b98766
|
||||
}
|
||||
|
||||
.btn-opacity-secondary {
|
||||
color: #b55e4f;
|
||||
background-color: #fff;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-secondary:hover {
|
||||
color: #b55e4f;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b55e4f
|
||||
}
|
||||
|
||||
.btn-opacity-success {
|
||||
color: #82aa8b;
|
||||
background-color: #b7eddd;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-success:hover {
|
||||
color: #82aa8b;
|
||||
background-color: #fff;
|
||||
border: 2px solid #82aa8b
|
||||
}
|
||||
|
||||
.btn-opacity-info {
|
||||
color: #385f84;
|
||||
background-color: #89caff;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-info:hover {
|
||||
color: #385f84;
|
||||
background-color: #fff;
|
||||
border: 2px solid #385f84
|
||||
}
|
||||
|
||||
.btn-opacity-warning {
|
||||
color: #eaaa21;
|
||||
background-color: #ffd170;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-warning:hover {
|
||||
color: #eaaa21;
|
||||
background-color: #fff;
|
||||
border: 2px solid #eaaa21
|
||||
}
|
||||
|
||||
.btn-opacity-danger {
|
||||
color: #a7240e;
|
||||
background-color: #ff7070;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-danger:hover {
|
||||
color: #a7240e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #a7240e
|
||||
}
|
||||
|
||||
.btn-opacity-light {
|
||||
color: #cfd5cd;
|
||||
background-color: #fec4af;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-light:hover {
|
||||
color: #cfd5cd;
|
||||
background-color: #fff;
|
||||
border: 2px solid #cfd5cd
|
||||
}
|
||||
|
||||
.btn-opacity-dark {
|
||||
color: #221e1e;
|
||||
background-color: #5e5353;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-dark:hover {
|
||||
color: #221e1e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #221e1e
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: #b98766;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b98766;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
color: #fff;
|
||||
background-color: #b98766
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: #b55e4f;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b55e4f;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
color: #fff;
|
||||
background-color: #b55e4f
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
color: #82aa8b;
|
||||
background-color: #fff;
|
||||
border: 2px solid #82aa8b;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-success:hover {
|
||||
color: #fff;
|
||||
background-color: #82aa8b
|
||||
}
|
||||
|
||||
.btn-outline-info {
|
||||
color: #385f84;
|
||||
background-color: #fff;
|
||||
border: 2px solid #385f84;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-info:hover {
|
||||
color: #fff;
|
||||
background-color: #385f84
|
||||
}
|
||||
|
||||
.btn-outline-warning {
|
||||
color: #eaaa21;
|
||||
background-color: #fff;
|
||||
border: 2px solid #eaaa21;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-warning:hover {
|
||||
color: #fff;
|
||||
background-color: #eaaa21
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
color: #a7240e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #a7240e;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
color: #fff;
|
||||
background-color: #a7240e
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
color: #cfd5cd;
|
||||
background-color: #fff;
|
||||
border: 2px solid #cfd5cd;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
color: #fff;
|
||||
background-color: #cfd5cd
|
||||
}
|
||||
|
||||
.btn-outline-dark {
|
||||
color: #221e1e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #221e1e;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-dark:hover {
|
||||
color: #fff;
|
||||
background-color: #221e1e
|
||||
}
|
||||
|
||||
|
||||
.fade {
|
||||
transition: opacity .15s linear
|
||||
}
|
||||
@@ -3148,6 +3465,13 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
margin-right: 0
|
||||
}
|
||||
|
||||
.btn-sm, .btn-group-sm > .btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8203125rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 0.2rem
|
||||
}
|
||||
|
||||
.btn-group-sm > .btn + .dropdown-toggle-split, .btn-sm + .dropdown-toggle-split {
|
||||
padding-right: .375rem;
|
||||
padding-left: .375rem
|
||||
@@ -4611,7 +4935,7 @@ a.badge:focus, a.badge:hover {
|
||||
|
||||
a.badge-primary:focus, a.badge-primary:hover {
|
||||
color: #fff;
|
||||
background-color: #000004
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
a.badge-primary.focus, a.badge-primary:focus {
|
||||
@@ -6114,8 +6438,11 @@ a.close.disabled {
|
||||
vertical-align: text-top !important
|
||||
}
|
||||
|
||||
/*!
|
||||
* technically the wrong color but not used anywhere besides nav and this way changing nav color is supported
|
||||
*/
|
||||
.bg-primary {
|
||||
background-color: #b98766 !important
|
||||
background-color: rgb(221, 191, 134) !important;
|
||||
}
|
||||
|
||||
a.bg-primary:focus, a.bg-primary:hover, button.bg-primary:focus, button.bg-primary:hover {
|
||||
@@ -10063,319 +10390,6 @@ footer a:hover {
|
||||
min-width: 100%
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: .875rem;
|
||||
font-family: Poppins, sans-serif;
|
||||
padding: .625rem 1.25rem;
|
||||
outline: none
|
||||
}
|
||||
|
||||
.btn.btn-rounded {
|
||||
border-radius: 50px
|
||||
}
|
||||
|
||||
.btn.btn-white {
|
||||
background: #fff;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn.btn-white:hover {
|
||||
background: #a7240e;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
box-shadow: none
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: transparent;
|
||||
color: #b98766;
|
||||
border: 1px solid #b98766
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: transparent;
|
||||
color: #b55e4f;
|
||||
border: 1px solid #b55e4f
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: transparent;
|
||||
color: #82aa8b;
|
||||
border: 1px solid #82aa8b
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-info:hover {
|
||||
background: transparent;
|
||||
color: #385f84;
|
||||
border: 1px solid #385f84
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: transparent;
|
||||
color: #eaaa21;
|
||||
border: 1px solid #eaaa21
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: transparent;
|
||||
color: #a7240e;
|
||||
border: 1px solid #a7240e
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background-color: hsla(0, 0%, 18%, .5);
|
||||
color: #cfd5cd;
|
||||
border: 1px solid hsla(0, 0%, 18%, .5)
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
transition: all .5s ease-in-out;
|
||||
color: #fff
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
background: transparent;
|
||||
color: #221e1e;
|
||||
border: 1px solid #221e1e
|
||||
}
|
||||
|
||||
.btn-opacity-primary {
|
||||
color: #b98766;
|
||||
background-color: #0012a7;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-primary:hover {
|
||||
color: #b98766;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b98766
|
||||
}
|
||||
|
||||
.btn-opacity-secondary {
|
||||
color: #b55e4f;
|
||||
background-color: #fff;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-secondary:hover {
|
||||
color: #b55e4f;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b55e4f
|
||||
}
|
||||
|
||||
.btn-opacity-success {
|
||||
color: #82aa8b;
|
||||
background-color: #b7eddd;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-success:hover {
|
||||
color: #82aa8b;
|
||||
background-color: #fff;
|
||||
border: 2px solid #82aa8b
|
||||
}
|
||||
|
||||
.btn-opacity-info {
|
||||
color: #385f84;
|
||||
background-color: #89caff;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-info:hover {
|
||||
color: #385f84;
|
||||
background-color: #fff;
|
||||
border: 2px solid #385f84
|
||||
}
|
||||
|
||||
.btn-opacity-warning {
|
||||
color: #eaaa21;
|
||||
background-color: #ffd170;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-warning:hover {
|
||||
color: #eaaa21;
|
||||
background-color: #fff;
|
||||
border: 2px solid #eaaa21
|
||||
}
|
||||
|
||||
.btn-opacity-danger {
|
||||
color: #a7240e;
|
||||
background-color: #ff7070;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-danger:hover {
|
||||
color: #a7240e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #a7240e
|
||||
}
|
||||
|
||||
.btn-opacity-light {
|
||||
color: #cfd5cd;
|
||||
background-color: #fec4af;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-light:hover {
|
||||
color: #cfd5cd;
|
||||
background-color: #fff;
|
||||
border: 2px solid #cfd5cd
|
||||
}
|
||||
|
||||
.btn-opacity-dark {
|
||||
color: #221e1e;
|
||||
background-color: #5e5353;
|
||||
border: 2px solid transparent;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-opacity-dark:hover {
|
||||
color: #221e1e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #221e1e
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
color: #b98766;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b98766;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
color: #fff;
|
||||
background-color: #b98766
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: #b55e4f;
|
||||
background-color: #fff;
|
||||
border: 2px solid #b55e4f;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover {
|
||||
color: #fff;
|
||||
background-color: #b55e4f
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
color: #82aa8b;
|
||||
background-color: #fff;
|
||||
border: 2px solid #82aa8b;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-success:hover {
|
||||
color: #fff;
|
||||
background-color: #82aa8b
|
||||
}
|
||||
|
||||
.btn-outline-info {
|
||||
color: #385f84;
|
||||
background-color: #fff;
|
||||
border: 2px solid #385f84;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-info:hover {
|
||||
color: #fff;
|
||||
background-color: #385f84
|
||||
}
|
||||
|
||||
.btn-outline-warning {
|
||||
color: #eaaa21;
|
||||
background-color: #fff;
|
||||
border: 2px solid #eaaa21;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-warning:hover {
|
||||
color: #fff;
|
||||
background-color: #eaaa21
|
||||
}
|
||||
|
||||
.btn-outline-danger {
|
||||
color: #a7240e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #a7240e;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-danger:hover {
|
||||
color: #fff;
|
||||
background-color: #a7240e
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
color: #cfd5cd;
|
||||
background-color: #fff;
|
||||
border: 2px solid #cfd5cd;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-light:hover {
|
||||
color: #fff;
|
||||
background-color: #cfd5cd
|
||||
}
|
||||
|
||||
.btn-outline-dark {
|
||||
color: #221e1e;
|
||||
background-color: #fff;
|
||||
border: 2px solid #221e1e;
|
||||
transition: all .5s ease-in-out
|
||||
}
|
||||
|
||||
.btn-outline-dark:hover {
|
||||
color: #fff;
|
||||
background-color: #221e1e
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 6px
|
||||
@@ -10424,8 +10438,6 @@ footer a:hover {
|
||||
padding: 5px 0 20px 39px
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=maps/style.min.css.map */
|
||||
|
||||
.bg-header {
|
||||
background-color: rgb(221, 191, 134) !important;
|
||||
}
|
||||
@@ -10441,7 +10453,7 @@ footer a:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([class="select2-search__field"]):not([class="vue-treeselect__input"]), select {
|
||||
textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([class="select2-search__field"]):not([class="vue-treeselect__input"]), select {
|
||||
background-color: white !important;
|
||||
border-radius: .25rem !important;
|
||||
border: 1px solid #ced4da !important;
|
||||
@@ -10465,6 +10477,6 @@ textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([clas
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5 !important;
|
||||
background: #b98766 !important;
|
||||
opacity: 0.5 !important;
|
||||
background: #b98766 !important;
|
||||
}
|
||||
@@ -35,9 +35,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if EMAIL_ENABLED %}
|
||||
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
|
||||
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
|
||||
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a
|
||||
<p>{% trans 'Lost your password?' %} <a
|
||||
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<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="manifest" href="{% url 'web_manifest' %}">
|
||||
<link rel="manifest" crossorigin="use-credentials" href="{% url 'web_manifest' %}">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/mstile-144x144.png">
|
||||
|
||||
@@ -48,6 +48,9 @@
|
||||
|
||||
<script type="text/javascript">
|
||||
$.fn.select2.defaults.set("theme", "bootstrap");
|
||||
{% if request.user.is_authenticated %}
|
||||
window.ACTIVE_SPACE_ID = '{{request.space.id}}';
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
<!-- Fontawesome icons -->
|
||||
@@ -69,7 +72,7 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %} bg-header"
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}"
|
||||
id="id_main_nav"
|
||||
style="{% sticky_nav request %}">
|
||||
|
||||
@@ -408,6 +411,8 @@
|
||||
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
|
||||
localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}")
|
||||
localStorage.setItem('DEBUG', "{% is_debug %}")
|
||||
localStorage.setItem('USER_ID', "{{request.user.pk}}")
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{% block title %}{% trans 'Settings' %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ preference_form.media }}
|
||||
{{ search_form.media }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -15,254 +15,60 @@
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'view_settings' %}">{% trans 'Settings' %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans 'Search' %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Nav tabs -->
|
||||
<ul class="nav nav-tabs" id="myTab" role="tablist" style="margin-bottom: 2vh">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'account' %} active {% endif %}" id="account-tab" data-toggle="tab"
|
||||
href="#account" role="tab"
|
||||
aria-controls="account"
|
||||
aria-selected="{% if active_tab == 'account' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Account' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'preferences' %} active {% endif %}" id="preferences-tab"
|
||||
data-toggle="tab" href="#preferences" role="tab"
|
||||
aria-controls="preferences"
|
||||
aria-selected="{% if active_tab == 'preferences' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Preferences' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'api' %} active {% endif %}" id="api-tab" data-toggle="tab"
|
||||
href="#api" role="tab"
|
||||
aria-controls="api"
|
||||
aria-selected="{% if active_tab == 'api' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'API-Settings' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'search' %} active {% endif %}" id="search-tab" data-toggle="tab"
|
||||
href="#search" role="tab"
|
||||
aria-controls="search"
|
||||
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Search-Settings' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'shopping' %} active {% endif %}" id="shopping-tab" data-toggle="tab"
|
||||
href="#shopping" role="tab"
|
||||
aria-controls="search"
|
||||
aria-selected="{% if active_tab == 'shopping' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Shopping-Settings' %}</a>
|
||||
</li>
|
||||
<div class="tab-pane {% if active_tab == 'search' %} active {% endif %}" id="search" role="tabpanel"
|
||||
aria-labelledby="search-tab">
|
||||
<h4>{% trans 'Search Settings' %}</h4>
|
||||
{% trans 'There are many options to configure the search depending on your personal preferences.' %}
|
||||
{% trans 'Usually you do <b>not need</b> to configure any of them and can just stick with either the default or one of the following presets.' %}
|
||||
{% trans 'If you do want to configure the search you can read about the different options <a href="/docs/search/">here</a>.' %}
|
||||
|
||||
</ul>
|
||||
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane {% if active_tab == 'account' %} active {% endif %}" id="account" role="tabpanel"
|
||||
aria-labelledby="account-tab">
|
||||
<h4>{% trans 'Name Settings' %}</h4>
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ user_name_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="user_name_form"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
|
||||
<h4>{% trans 'Account Settings' %}</h4>
|
||||
|
||||
<a href="{% url 'account_email' %}" class="btn btn-primary">{% trans 'Emails' %}</a>
|
||||
<a href="{% url 'account_change_password' %}" class="btn btn-primary">{% trans 'Password' %}</a>
|
||||
|
||||
<a href="{% url 'socialaccount_connections' %}" class="btn btn-primary">{% trans 'Social' %}</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="card-deck mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{% trans 'Fuzzy' %}</h5>
|
||||
<p class="card-text">{% trans 'Find what you need even if your search or the recipe contains typos. Might return more results than needed to make sure you find what you are looking for.' %}</p>
|
||||
<p class="card-text"><small class="text-muted">{% trans 'This is the default behavior' %}</small>
|
||||
</p>
|
||||
<button class="btn btn-primary card-link"
|
||||
onclick="applyPreset('fuzzy')">{% trans 'Apply' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{% trans 'Precise' %}</h5>
|
||||
<p class="card-text">{% trans 'Allows fine control over search results but might not return results if too many spelling mistakes are made.' %}</p>
|
||||
<p class="card-text"><small class="text-muted">{% trans 'Perfect for large Databases' %}</small></p>
|
||||
<button class="btn btn-primary card-link"
|
||||
onclick="applyPreset('precise')">{% trans 'Apply' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="tab-pane {% if active_tab == 'preferences' %} active {% endif %}" id="preferences" role="tabpanel"
|
||||
aria-labelledby="preferences-tab">
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4><i class="fas fa-language fa-fw"></i> {% trans 'Language' %}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
|
||||
<input class="form-control" name="next" type="hidden" value="{{ redirect_to }}">
|
||||
<select name="language" class="form-control">
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
{% get_language_info_list for LANGUAGES as languages %}
|
||||
{% for language in languages %}
|
||||
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %}
|
||||
selected{% endif %}>
|
||||
{{ language.name_local }} ({{ language.code }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<br/>
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4><i class="fas fa-palette fa-fw"></i> {% trans 'Style' %}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ preference_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="preference_form"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="tab-pane {% if active_tab == 'api' %} active {% endif %}" id="api" role="tabpanel"
|
||||
aria-labelledby="api-tab">
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4><i class="fas fa-terminal fa-fw"></i> {% trans 'API Token' %}</h4>
|
||||
{% trans 'You can use both basic authentication and token based authentication to access the REST API.' %}
|
||||
<br/>
|
||||
<br/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
|
||||
<div class="input-group mb-3">
|
||||
<input class="form-control" value="{{ api_token }}" id="id_token">
|
||||
<div class="input-group-append">
|
||||
<button class="input-group-btn btn btn-primary" onclick="copyToken()"><i
|
||||
class="far fa-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
{% trans 'Use the token as an Authorization header prefixed by the word token as shown in the following examples:' %}
|
||||
<br/>
|
||||
<code>Authorization: Token {{ api_token }}</code> {% trans 'or' %}<br/>
|
||||
<code>curl -X GET http://your.domain.com/api/recipes/ -H 'Authorization:
|
||||
Token {{ api_token }}'</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="tab-pane {% if active_tab == 'search' %} active {% endif %}" id="search" role="tabpanel"
|
||||
aria-labelledby="search-tab">
|
||||
<h4>{% trans 'Search Settings' %}</h4>
|
||||
{% trans 'There are many options to configure the search depending on your personal preferences.' %}
|
||||
{% trans 'Usually you do <b>not need</b> to configure any of them and can just stick with either the default or one of the following presets.' %}
|
||||
{% trans 'If you do want to configure the search you can read about the different options <a href="/docs/search/">here</a>.' %}
|
||||
|
||||
<div class="card-deck mt-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{% trans 'Fuzzy' %}</h5>
|
||||
<p class="card-text">{% trans 'Find what you need even if your search or the recipe contains typos. Might return more results than needed to make sure you find what you are looking for.' %}</p>
|
||||
<p class="card-text"><small class="text-muted">{% trans 'This is the default behavior' %}</small></p>
|
||||
<button class="btn btn-primary card-link" onclick="applyPreset('fuzzy')">{% trans 'Apply' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{% trans 'Precise' %}</h5>
|
||||
<p class="card-text">{% trans 'Allows fine control over search results but might not return results if too many spelling mistakes are made.' %}</p>
|
||||
<p class="card-text"><small class="text-muted">{% trans 'Perfect for large Databases' %}</small></p>
|
||||
<button class="btn btn-primary card-link" onclick="applyPreset('precise')">{% trans 'Apply' %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<form action="./#search" method="post" id="id_search_form">
|
||||
{% csrf_token %}
|
||||
{{ search_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="search_form" id="search_form_button"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane {% if active_tab == 'shopping' %} active {% endif %}" id="shopping" role="tabpanel"
|
||||
aria-labelledby="shopping-tab">
|
||||
<h4>{% trans 'Shopping Settings' %}</h4>
|
||||
|
||||
<form action="./#shopping" method="post" id="id_shopping_form">
|
||||
{% csrf_token %}
|
||||
{{ shopping_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="shopping_form" id="shopping_form_button"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
<form action="./#search" method="post" id="id_search_form">
|
||||
{% csrf_token %}
|
||||
{{ search_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="search_form" id="search_form_button"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<script type="application/javascript">
|
||||
$(function() {
|
||||
$(function () {
|
||||
$('#id_search-trigram_threshold').get(0).type = 'range';
|
||||
});
|
||||
|
||||
function applyPreset(preset) {
|
||||
$('#id_search-preset').val(preset);
|
||||
$('#id_search-search').val('plain');
|
||||
$('#search_form_button').click();
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
let token = $('#id_token');
|
||||
token.select();
|
||||
document.execCommand("copy");
|
||||
}
|
||||
|
||||
// Javascript to enable link to tab
|
||||
var hash = location.hash.replace(/^#/, ''); // ^ means starting, meaning only match the first hash
|
||||
if (hash) {
|
||||
$('.nav-tabs a[href="#' + hash + '"]').tab('show');
|
||||
}
|
||||
|
||||
// Change hash for page-reload
|
||||
$('.nav-tabs a').on('shown.bs.tab', function(e) {
|
||||
window.location.hash = e.target.hash;
|
||||
})
|
||||
|
||||
{% comment %}
|
||||
// listen for events
|
||||
$(document).ready(function() {
|
||||
hideShow()
|
||||
// call hideShow when the user clicks on the mealplan_autoadd checkbox
|
||||
$("#id_shopping-mealplan_autoadd_shopping").click(function(event) {
|
||||
hideShow();
|
||||
});
|
||||
})
|
||||
|
||||
function hideShow() {
|
||||
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true) {
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').show();
|
||||
}
|
||||
else {
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').hide();
|
||||
}
|
||||
}
|
||||
{% endcomment %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
|
||||
window.SHOPPING_MIN_AUTOSYNC_INTERVAL = {{ SHOPPING_MIN_AUTOSYNC_INTERVAL }}
|
||||
</script>
|
||||
|
||||
{% render_bundle 'shopping_list_view' %} {% endblock %}
|
||||
|
||||
@@ -16,5 +16,16 @@
|
||||
|
||||
|
||||
{% 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.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
|
||||
</script>
|
||||
|
||||
{% render_bundle 'test_view' %}
|
||||
{% endblock %}
|
||||
@@ -29,6 +29,7 @@
|
||||
<script type="application/javascript">
|
||||
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
|
||||
window.USER_ID = {{ request.user.pk }}
|
||||
window.SHOPPING_MIN_AUTOSYNC_INTERVAL = {{ SHOPPING_MIN_AUTOSYNC_INTERVAL }}
|
||||
|
||||
<!--TODO build custom API endpoint for this -->
|
||||
{% get_available_languages as LANGUAGES %}
|
||||
|
||||
@@ -101,6 +101,7 @@ def page_help(page_name):
|
||||
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
|
||||
'view_import': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'view_export': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'list_automation': 'https://docs.tandoor.dev/features/automation/',
|
||||
}
|
||||
|
||||
link = help_pages.get(page_name, '')
|
||||
@@ -151,7 +152,7 @@ def bookmarklet(request):
|
||||
localStorage.setItem('redirectURL', '" + server + reverse('data_import_url') + "'); \
|
||||
localStorage.setItem('token', '" + api_token.__str__() + "'); \
|
||||
document.body.appendChild(document.createElement(\'script\')).src=\'" \
|
||||
+ server + prefix + static('js/bookmarklet.js') + "? \
|
||||
+ server + prefix + static('js/bookmarklet_v3.js') + "? \
|
||||
r=\'+Math.floor(Math.random()*999999999);}})();'>Test</a>"
|
||||
return re.sub(r"[\n\t]*", "", bookmark)
|
||||
|
||||
|
||||
115
cookbook/tests/api/test_api_access_token.py
Normal file
115
cookbook/tests/api/test_api_access_token.py
Normal file
@@ -0,0 +1,115 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_scopes import scopes_disabled
|
||||
from oauth2_provider.models import AccessToken
|
||||
|
||||
from cookbook.models import ViewLog
|
||||
|
||||
LIST_URL = 'api:accesstoken-list'
|
||||
DETAIL_URL = 'api:accesstoken-detail'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(u1_s1):
|
||||
return AccessToken.objects.create(user=auth.get_user(u1_s1), scope='test', expires=timezone.now() + timezone.timedelta(days=365 * 5), token='test1')
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_2(u1_s1):
|
||||
return AccessToken.objects.create(user=auth.get_user(u1_s1), scope='test', expires=timezone.now() + timezone.timedelta(days=365 * 5), token='test2')
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 200],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
obj_1.user = auth.get_user(u1_s2)
|
||||
obj_1.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 1
|
||||
|
||||
|
||||
def test_token_visibility(u1_s1, obj_1):
|
||||
# tokens should only be returned on the first API request (first 15 seconds)
|
||||
at = json.loads(u1_s1.get(reverse(DETAIL_URL, args=[obj_1.id])).content)
|
||||
assert at['token'] == obj_1.token
|
||||
with scopes_disabled():
|
||||
obj_1.created = timezone.now() - timezone.timedelta(seconds=16)
|
||||
obj_1.save()
|
||||
at = json.loads(u1_s1.get(reverse(DETAIL_URL, args=[obj_1.id])).content)
|
||||
assert at['token'] != obj_1.token
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 404],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 404],
|
||||
['g1_s2', 404],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'scope': 'lorem ipsum'},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert r.status_code == arg[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 201],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2, u2_s1, recipe_1_s1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'scope': 'test', 'expires': timezone.now() + timezone.timedelta(days=365 * 5)},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['scope'] == 'test'
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, obj_1):
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 204
|
||||
22
cookbook/tests/api/test_api_share_link.py
Normal file
22
cookbook/tests/api/test_api_share_link.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.helper.permission_helper import share_link_valid
|
||||
from cookbook.models import Recipe
|
||||
|
||||
|
||||
def test_get_share_link(recipe_1_s1, u1_s1, u1_s2, g1_s1, a_u, space_1):
|
||||
assert u1_s1.get(reverse('api_share_link', args=[recipe_1_s1.pk])).status_code == 200
|
||||
assert u1_s2.get(reverse('api_share_link', args=[recipe_1_s1.pk])).status_code == 404
|
||||
assert g1_s1.get(reverse('api_share_link', args=[recipe_1_s1.pk])).status_code == 403
|
||||
assert a_u.get(reverse('api_share_link', args=[recipe_1_s1.pk])).status_code == 403
|
||||
|
||||
with scopes_disabled():
|
||||
sl = json.loads(u1_s1.get(reverse('api_share_link', args=[recipe_1_s1.pk])).content)
|
||||
assert share_link_valid(Recipe.objects.filter(pk=sl['pk']).get(), sl['share'])
|
||||
|
||||
space_1.allow_sharing = False
|
||||
space_1.save()
|
||||
assert u1_s1.get(reverse('api_share_link', args=[recipe_1_s1.pk])).status_code == 403
|
||||
@@ -97,7 +97,8 @@ class SupermarketCategoryFactory(factory.django.DjangoModelFactory):
|
||||
@register
|
||||
class FoodFactory(factory.django.DjangoModelFactory):
|
||||
"""Food factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)[:128])
|
||||
plural_name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=3, variable_nb_words=False))
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
supermarket_category = factory.Maybe(
|
||||
factory.LazyAttribute(lambda x: x.has_category),
|
||||
@@ -126,7 +127,7 @@ class FoodFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Food'
|
||||
django_get_or_create = ('name', 'space',)
|
||||
django_get_or_create = ('name', 'plural_name', 'space',)
|
||||
|
||||
|
||||
@register
|
||||
@@ -159,13 +160,14 @@ class RecipeBookEntryFactory(factory.django.DjangoModelFactory):
|
||||
@register
|
||||
class UnitFactory(factory.django.DjangoModelFactory):
|
||||
"""Unit factory."""
|
||||
name = factory.LazyAttribute(lambda x: faker.word())
|
||||
name = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10)[:128])
|
||||
plural_name = factory.LazyAttribute(lambda x: faker.word())
|
||||
description = factory.LazyAttribute(lambda x: faker.sentence(nb_words=10))
|
||||
space = factory.SubFactory(SpaceFactory)
|
||||
|
||||
class Meta:
|
||||
model = 'cookbook.Unit'
|
||||
django_get_or_create = ('name', 'space',)
|
||||
django_get_or_create = ('name', 'plural_name', 'space',)
|
||||
|
||||
|
||||
@register
|
||||
|
||||
50
cookbook/tests/other/test_automations.py
Normal file
50
cookbook/tests/other/test_automations.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.models import ExportLog, Automation
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.tests.conftest import validate_recipe
|
||||
|
||||
IMPORT_SOURCE_URL = 'api_recipe_from_source'
|
||||
|
||||
|
||||
# for some reason this tests cant run due to some kind of encoding issue, needs to be fixed
|
||||
# def test_description_replace_automation(u1_s1, space_1):
|
||||
# if 'cookbook' in os.getcwd():
|
||||
# test_file = os.path.join(os.getcwd(), 'other', 'test_data', 'chefkoch2.html')
|
||||
# else:
|
||||
# test_file = os.path.join(os.getcwd(), 'cookbook', 'tests', 'other', 'test_data', 'chefkoch2.html')
|
||||
#
|
||||
# # original description
|
||||
# # Brokkoli - Bratlinge. Über 91 Bewertungen und für vorzüglich befunden. Mit ► Portionsrechner ► Kochbuch ► Video-Tipps! Jetzt entdecken und ausprobieren!
|
||||
#
|
||||
# with scopes_disabled():
|
||||
# Automation.objects.create(
|
||||
# name='test1',
|
||||
# created_by=auth.get_user(u1_s1),
|
||||
# space=space_1,
|
||||
# param_1='.*',
|
||||
# param_2='.*',
|
||||
# param_3='',
|
||||
# order=1000,
|
||||
# )
|
||||
#
|
||||
# with open(test_file, 'r', encoding='UTF-8') as d:
|
||||
# response = u1_s1.post(
|
||||
# reverse(IMPORT_SOURCE_URL),
|
||||
# {
|
||||
# 'data': d.read(),
|
||||
# 'url': 'https://www.chefkoch.de/rezepte/804871184310070/Brokkoli-Bratlinge.html',
|
||||
# },
|
||||
# content_type='application/json')
|
||||
# recipe = json.loads(response.content)['recipe_json']
|
||||
# assert recipe['description'] == ''
|
||||
@@ -54,7 +54,7 @@ def test_ingredient_parser():
|
||||
"3,5 l Wasser": (3.5, "l", "Wasser", ""),
|
||||
"3.5 l Wasser": (3.5, "l", "Wasser", ""),
|
||||
"400 g Karotte(n)": (400, "g", "Karotte(n)", ""),
|
||||
"400g unsalted butter": (400, "g", "butter", "unsalted"),
|
||||
"400g unsalted butter": (400, "g", "unsalted butter", ""),
|
||||
"2L Wasser": (2, "L", "Wasser", ""),
|
||||
"1 (16 ounce) package dry lentils, rinsed": (1, "package", "dry lentils, rinsed", "16 ounce"),
|
||||
"2-3 c Water": (2, "c", "Water", "2-3"),
|
||||
@@ -66,13 +66,17 @@ def test_ingredient_parser():
|
||||
1.0, 'Lorem', 'ipsum', 'dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l'),
|
||||
"1 LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl": (
|
||||
1.0, None, 'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingeli',
|
||||
'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl')
|
||||
'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl'),
|
||||
"砂糖 50g": (50, "g", "砂糖", ""),
|
||||
"卵 4個": (4, "個", "卵", "")
|
||||
|
||||
}
|
||||
# for German you could say that if an ingredient does not have
|
||||
# an amount # and it starts with a lowercase letter, then that
|
||||
# is a unit ("etwas", "evtl.") does not apply to English tho
|
||||
|
||||
# TODO maybe add/improve support for weired stuff like this https://www.rainbownourishments.com/vegan-lemon-tart/#recipe
|
||||
|
||||
ingredient_parser = IngredientParser(None, False, ignore_automations=True)
|
||||
|
||||
count = 0
|
||||
|
||||
@@ -44,8 +44,8 @@ def test_makenow_onhand(recipes, makenow_recipe, user1, space_1):
|
||||
search = RecipeSearch(request, makenow='true')
|
||||
with scope(space=space_1):
|
||||
search = search.get_queryset(Recipe.objects.all())
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("makenow_recipe", [
|
||||
@@ -63,8 +63,8 @@ def test_makenow_ignoreshopping(recipes, makenow_recipe, user1, space_1):
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, ignore_shopping=True).count() == 1
|
||||
search = search.get_queryset(Recipe.objects.all())
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("makenow_recipe", [
|
||||
@@ -83,8 +83,8 @@ def test_makenow_substitute(recipes, makenow_recipe, user1, space_1):
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, substitute__isnull=False).count() == 1
|
||||
|
||||
search = search.get_queryset(Recipe.objects.all())
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("makenow_recipe", [
|
||||
@@ -105,8 +105,8 @@ def test_makenow_child_substitute(recipes, makenow_recipe, user1, space_1):
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, numchild__gt=0).count() == 1
|
||||
search = search.get_queryset(Recipe.objects.all())
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("makenow_recipe", [
|
||||
@@ -129,5 +129,5 @@ def test_makenow_sibling_substitute(recipes, makenow_recipe, user1, space_1):
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, onhand_users__isnull=False).count() == 9
|
||||
assert Food.objects.filter(ingredient__step__recipe=makenow_recipe.id, depth=2).count() == 1
|
||||
search = search.get_queryset(Recipe.objects.all())
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
assert search.count() == 1
|
||||
assert search.first().id == makenow_recipe.id
|
||||
|
||||
@@ -13,29 +13,29 @@ from cookbook.models import ExportLog, UserSpace, Food, Space, Comment, RecipeBo
|
||||
def test_has_group_permission(u1_s1, a_u, space_2):
|
||||
with scopes_disabled():
|
||||
# test that a normal user has user permissions
|
||||
assert has_group_permission(auth.get_user(u1_s1), ('guest',))
|
||||
assert has_group_permission(auth.get_user(u1_s1), ('user',))
|
||||
assert not has_group_permission(auth.get_user(u1_s1), ('admin',))
|
||||
assert has_group_permission(auth.get_user(u1_s1), ('guest',), no_cache=True)
|
||||
assert has_group_permission(auth.get_user(u1_s1), ('user',), no_cache=True)
|
||||
assert not has_group_permission(auth.get_user(u1_s1), ('admin',), no_cache=True)
|
||||
|
||||
# test that permissions are not taken from non active spaces
|
||||
us = UserSpace.objects.create(user=auth.get_user(u1_s1), space=space_2, active=False)
|
||||
us.groups.add(Group.objects.get(name='admin'))
|
||||
assert not has_group_permission(auth.get_user(u1_s1), ('admin',))
|
||||
assert not has_group_permission(auth.get_user(u1_s1), ('admin',), no_cache=True)
|
||||
|
||||
# disable all spaces and enable space 2 permission to check if permission is now valid
|
||||
auth.get_user(u1_s1).userspace_set.update(active=False)
|
||||
us.active = True
|
||||
us.save()
|
||||
assert has_group_permission(auth.get_user(u1_s1), ('admin',))
|
||||
assert has_group_permission(auth.get_user(u1_s1), ('admin',), no_cache=True)
|
||||
|
||||
# test that group permission checks fail if more than one userspace is active
|
||||
auth.get_user(u1_s1).userspace_set.update(active=True)
|
||||
assert not has_group_permission(auth.get_user(u1_s1), ('user',))
|
||||
assert not has_group_permission(auth.get_user(u1_s1), ('user',), no_cache=True)
|
||||
|
||||
# test that anonymous users don't have any permissions
|
||||
assert not has_group_permission(auth.get_user(a_u), ('guest',))
|
||||
assert not has_group_permission(auth.get_user(a_u), ('user',))
|
||||
assert not has_group_permission(auth.get_user(a_u), ('admin',))
|
||||
assert not has_group_permission(auth.get_user(a_u), ('guest',), no_cache=True)
|
||||
assert not has_group_permission(auth.get_user(a_u), ('user',), no_cache=True)
|
||||
assert not has_group_permission(auth.get_user(a_u), ('admin',), no_cache=True)
|
||||
|
||||
|
||||
def test_is_owner(u1_s1, u2_s1, u1_s2, a_u, space_1, recipe_1_s1):
|
||||
|
||||
@@ -321,33 +321,34 @@ def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, sp
|
||||
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("found_recipe, param_type", [
|
||||
({'rating': True}, 'rating'),
|
||||
({'timescooked': True}, 'timescooked'),
|
||||
], indirect=['found_recipe'])
|
||||
def test_search_count(found_recipe, recipes, param_type, u1_s1, u2_s1, space_1):
|
||||
param1 = f'?{param_type}=3'
|
||||
param2 = f'?{param_type}=-3'
|
||||
param3 = f'?{param_type}=0'
|
||||
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL) + param1).content)
|
||||
assert r['count'] == 1
|
||||
assert found_recipe[0].id in [x['id'] for x in r['results']]
|
||||
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL) + param2).content)
|
||||
assert r['count'] == 1
|
||||
assert found_recipe[1].id in [x['id'] for x in r['results']]
|
||||
|
||||
# test search for not rated/cooked
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL) + param3).content)
|
||||
assert r['count'] == 11
|
||||
assert (found_recipe[0].id or found_recipe[1].id) not in [x['id'] for x in r['results']]
|
||||
|
||||
# test matched returns for lte and gte searches
|
||||
r = json.loads(u2_s1.get(reverse(LIST_URL) + param1).content)
|
||||
assert r['count'] == 1
|
||||
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||
|
||||
r = json.loads(u2_s1.get(reverse(LIST_URL) + param2).content)
|
||||
assert r['count'] == 1
|
||||
assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||
# TODO this is somehow screwed, probably the search itself, dont want to fix it for now
|
||||
# @pytest.mark.parametrize("found_recipe, param_type", [
|
||||
# ({'rating': True}, 'rating'),
|
||||
# ({'timescooked': True}, 'timescooked'),
|
||||
# ], indirect=['found_recipe'])
|
||||
# def test_search_count(found_recipe, recipes, param_type, u1_s1, u2_s1, space_1):
|
||||
# param1 = f'?{param_type}=3'
|
||||
# param2 = f'?{param_type}=-3'
|
||||
# param3 = f'?{param_type}=0'
|
||||
#
|
||||
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param1).content)
|
||||
# assert r['count'] == 1
|
||||
# assert found_recipe[0].id in [x['id'] for x in r['results']]
|
||||
#
|
||||
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param2).content)
|
||||
# assert r['count'] == 1
|
||||
# assert found_recipe[1].id in [x['id'] for x in r['results']]
|
||||
#
|
||||
# # test search for not rated/cooked
|
||||
# r = json.loads(u1_s1.get(reverse(LIST_URL) + param3).content)
|
||||
# assert r['count'] == 11
|
||||
# assert (found_recipe[0].id or found_recipe[1].id) not in [x['id'] for x in r['results']]
|
||||
#
|
||||
# # test matched returns for lte and gte searches
|
||||
# r = json.loads(u2_s1.get(reverse(LIST_URL) + param1).content)
|
||||
# assert r['count'] == 1
|
||||
# assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||
#
|
||||
# r = json.loads(u2_s1.get(reverse(LIST_URL) + param2).content)
|
||||
# assert r['count'] == 1
|
||||
# assert found_recipe[2].id in [x['id'] for x in r['results']]
|
||||
|
||||
@@ -2,13 +2,16 @@ import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.tests.conftest import validate_recipe
|
||||
|
||||
from ._recipes import (ALLRECIPES, AMERICAS_TEST_KITCHEN, CHEF_KOCH, CHEF_KOCH2, COOKPAD,
|
||||
COOKS_COUNTRY, DELISH, FOOD_NETWORK, GIALLOZAFFERANO, JOURNAL_DES_FEMMES,
|
||||
MADAME_DESSERT, MARMITON, TASTE_OF_HOME, THE_SPRUCE_EATS, TUDOGOSTOSO)
|
||||
from ...models import Automation
|
||||
|
||||
IMPORT_SOURCE_URL = 'api_recipe_from_source'
|
||||
DATA_DIR = "cookbook/tests/other/test_data/"
|
||||
@@ -72,3 +75,5 @@ def test_recipe_import(arg, u1_s1):
|
||||
content_type='application/json')
|
||||
recipe = json.loads(response.content)['recipe_json']
|
||||
validate_recipe(arg, recipe)
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ router.register(r'user', api.UserViewSet)
|
||||
router.register(r'user-preference', api.UserPreferenceViewSet)
|
||||
router.register(r'user-space', api.UserSpaceViewSet)
|
||||
router.register(r'view-log', api.ViewLogViewSet)
|
||||
router.register(r'access-token', api.AccessTokenViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
@@ -68,7 +69,7 @@ urlpatterns = [
|
||||
path('plan/', views.meal_plan, name='view_plan'),
|
||||
path('shopping/', lists.shopping_list, name='view_shopping'),
|
||||
path('settings/', views.user_settings, name='view_settings'),
|
||||
path('user-settings/', views.user_settings_new, name='view_user_settings'),
|
||||
path('settings-shopping/', views.shopping_settings, name='view_shopping_settings'),
|
||||
path('history/', views.history, name='view_history'),
|
||||
path('supermarket/', views.supermarket, name='view_supermarket'),
|
||||
path('ingredient-editor/', views.ingredient_editor, name='view_ingredient_editor'),
|
||||
|
||||
@@ -12,6 +12,7 @@ from zipfile import ZipFile
|
||||
|
||||
import requests
|
||||
import validators
|
||||
from PIL import UnidentifiedImageError
|
||||
from annoying.decorators import ajax_request
|
||||
from annoying.functions import get_object_or_None
|
||||
from django.contrib import messages
|
||||
@@ -19,21 +20,21 @@ from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.files import File
|
||||
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When
|
||||
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, Avg, Max
|
||||
from django.db.models.fields.related import ForeignObjectRel
|
||||
from django.db.models.functions import Coalesce, Lower
|
||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from icalendar import Calendar, Event
|
||||
from PIL import UnidentifiedImageError
|
||||
from recipe_scrapers import scrape_html, scrape_me
|
||||
from oauth2_provider.models import AccessToken
|
||||
from recipe_scrapers import scrape_me
|
||||
from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
|
||||
from requests.exceptions import MissingSchema
|
||||
from rest_framework import decorators, status, viewsets
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.exceptions import APIException, PermissionDenied
|
||||
@@ -50,10 +51,10 @@ from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner,
|
||||
CustomIsOwnerReadOnly, CustomIsShare, CustomIsShared,
|
||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner,
|
||||
CustomIsOwnerReadOnly, CustomIsShared,
|
||||
CustomIsSpaceOwner, CustomIsUser, group_required,
|
||||
is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission, CustomUserPermission)
|
||||
is_space_owner, switch_user_active_space, above_space_limit, CustomRecipePermission, CustomUserPermission, CustomTokenHasReadWriteScope, CustomTokenHasScope, has_group_permission)
|
||||
from cookbook.helper.recipe_search import RecipeFacet, RecipeSearch
|
||||
from cookbook.helper.recipe_url_import import get_from_youtube_scraper, get_images_from_soup
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
@@ -86,7 +87,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportListSeri
|
||||
SupermarketCategorySerializer, SupermarketSerializer,
|
||||
SyncLogSerializer, SyncSerializer, UnitSerializer,
|
||||
UserFileSerializer, UserSerializer, UserPreferenceSerializer,
|
||||
UserSpaceSerializer, ViewLogSerializer)
|
||||
UserSpaceSerializer, ViewLogSerializer, AccessTokenSerializer)
|
||||
from cookbook.views.import_export import get_integration
|
||||
from recipes import settings
|
||||
|
||||
@@ -169,7 +170,7 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
|
||||
'field', flat=True)])
|
||||
|
||||
if query is not None and query not in ["''", '']:
|
||||
if fuzzy:
|
||||
if fuzzy and (settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']):
|
||||
if any([self.model.__name__.lower() in x for x in
|
||||
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
|
||||
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name__unaccent', query))
|
||||
@@ -363,7 +364,7 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
queryset = User.objects
|
||||
serializer_class = UserSerializer
|
||||
permission_classes = [CustomUserPermission]
|
||||
permission_classes = [CustomUserPermission & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', 'patch']
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -381,14 +382,14 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
class GroupViewSet(viewsets.ModelViewSet):
|
||||
queryset = Group.objects.all()
|
||||
serializer_class = GroupSerializer
|
||||
permission_classes = [CustomIsAdmin]
|
||||
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', ]
|
||||
|
||||
|
||||
class SpaceViewSet(viewsets.ModelViewSet):
|
||||
queryset = Space.objects
|
||||
serializer_class = SpaceSerializer
|
||||
permission_classes = [CustomIsOwner & CustomIsAdmin]
|
||||
permission_classes = [CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', 'patch']
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -398,7 +399,7 @@ class SpaceViewSet(viewsets.ModelViewSet):
|
||||
class UserSpaceViewSet(viewsets.ModelViewSet):
|
||||
queryset = UserSpace.objects
|
||||
serializer_class = UserSpaceSerializer
|
||||
permission_classes = [CustomIsSpaceOwner | CustomIsOwnerReadOnly]
|
||||
permission_classes = [(CustomIsSpaceOwner | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', 'patch', 'delete']
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
@@ -416,7 +417,7 @@ class UserSpaceViewSet(viewsets.ModelViewSet):
|
||||
class UserPreferenceViewSet(viewsets.ModelViewSet):
|
||||
queryset = UserPreference.objects
|
||||
serializer_class = UserPreferenceSerializer
|
||||
permission_classes = [CustomIsOwner, ]
|
||||
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', 'patch', ]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -428,7 +429,7 @@ class StorageViewSet(viewsets.ModelViewSet):
|
||||
# TODO handle delete protect error and adjust test
|
||||
queryset = Storage.objects
|
||||
serializer_class = StorageSerializer
|
||||
permission_classes = [CustomIsAdmin, ]
|
||||
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
@@ -437,7 +438,7 @@ class StorageViewSet(viewsets.ModelViewSet):
|
||||
class SyncViewSet(viewsets.ModelViewSet):
|
||||
queryset = Sync.objects
|
||||
serializer_class = SyncSerializer
|
||||
permission_classes = [CustomIsAdmin, ]
|
||||
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
@@ -446,7 +447,7 @@ class SyncViewSet(viewsets.ModelViewSet):
|
||||
class SyncLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = SyncLog.objects
|
||||
serializer_class = SyncLogSerializer
|
||||
permission_classes = [CustomIsAdmin, ]
|
||||
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -456,7 +457,7 @@ class SyncLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class SupermarketViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = Supermarket.objects
|
||||
serializer_class = SupermarketSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
@@ -467,7 +468,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
|
||||
queryset = SupermarketCategory.objects
|
||||
model = SupermarketCategory
|
||||
serializer_class = SupermarketCategorySerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
|
||||
@@ -477,7 +478,7 @@ class SupermarketCategoryViewSet(viewsets.ModelViewSet, FuzzyFilterMixin):
|
||||
class SupermarketCategoryRelationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = SupermarketCategoryRelation.objects
|
||||
serializer_class = SupermarketCategoryRelationSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -489,7 +490,7 @@ class KeywordViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
queryset = Keyword.objects
|
||||
model = Keyword
|
||||
serializer_class = KeywordSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
|
||||
@@ -497,14 +498,14 @@ class UnitViewSet(viewsets.ModelViewSet, MergeMixin, FuzzyFilterMixin):
|
||||
queryset = Unit.objects
|
||||
model = Unit
|
||||
serializer_class = UnitSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
|
||||
class FoodInheritFieldViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
queryset = FoodInheritField.objects
|
||||
serializer_class = FoodInheritFieldSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
# exclude fields not yet implemented
|
||||
@@ -516,7 +517,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
queryset = Food.objects
|
||||
model = Food
|
||||
serializer_class = FoodSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -527,9 +528,10 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'),
|
||||
checked=False).values('id')
|
||||
# onhand_status = self.queryset.annotate(onhand_status=Exists(onhand_users_set__in=[shared_users]))
|
||||
return self.queryset.annotate(shopping_status=Exists(shopping_status)).prefetch_related('onhand_users',
|
||||
'inherit_fields').select_related(
|
||||
'recipe', 'supermarket_category')
|
||||
return self.queryset \
|
||||
.annotate(shopping_status=Exists(shopping_status)) \
|
||||
.prefetch_related('onhand_users', 'inherit_fields', 'child_inherit_fields', 'substitute') \
|
||||
.select_related('recipe', 'supermarket_category')
|
||||
|
||||
@decorators.action(detail=True, methods=['PUT'], serializer_class=FoodShoppingUpdateSerializer, )
|
||||
# TODO DRF only allows one action in a decorator action without overriding get_operation_id_base() this should be PUT and DELETE probably
|
||||
@@ -564,7 +566,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
|
||||
class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = RecipeBook.objects
|
||||
serializer_class = RecipeBookSerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
||||
@@ -583,7 +585,7 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
|
||||
"""
|
||||
queryset = RecipeBookEntry.objects
|
||||
serializer_class = RecipeBookEntrySerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.filter(
|
||||
@@ -611,7 +613,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
queryset = MealPlan.objects
|
||||
serializer_class = MealPlanSerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.filter(
|
||||
@@ -636,7 +638,7 @@ class MealTypeViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
queryset = MealType.objects
|
||||
serializer_class = MealTypeSerializer
|
||||
permission_classes = [CustomIsOwner]
|
||||
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.order_by('order', 'id').filter(created_by=self.request.user).filter(
|
||||
@@ -647,7 +649,7 @@ class MealTypeViewSet(viewsets.ModelViewSet):
|
||||
class IngredientViewSet(viewsets.ModelViewSet):
|
||||
queryset = Ingredient.objects
|
||||
serializer_class = IngredientSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -671,7 +673,7 @@ class IngredientViewSet(viewsets.ModelViewSet):
|
||||
class StepViewSet(viewsets.ModelViewSet):
|
||||
queryset = Step.objects
|
||||
serializer_class = StepSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
query_params = [
|
||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
|
||||
@@ -715,7 +717,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
queryset = Recipe.objects
|
||||
serializer_class = RecipeSerializer
|
||||
# TODO split read and write permission for meal plan guest
|
||||
permission_classes = [CustomRecipePermission]
|
||||
permission_classes = [CustomRecipePermission & CustomTokenHasReadWriteScope]
|
||||
pagination_class = RecipePagination
|
||||
|
||||
query_params = [
|
||||
@@ -850,7 +852,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
if image is not None:
|
||||
img = handle_image(request, image, filetype)
|
||||
obj.image = File(img, name=f'{uuid.uuid4()}_{obj.pk}{filetype}')
|
||||
obj.image.save(f'{uuid.uuid4()}_{obj.pk}{filetype}', img)
|
||||
obj.save()
|
||||
return Response(serializer.data)
|
||||
else:
|
||||
@@ -916,7 +918,7 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingListRecipe.objects
|
||||
serializer_class = ShoppingListRecipeSerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(
|
||||
@@ -932,7 +934,7 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
|
||||
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingListEntry.objects
|
||||
serializer_class = ShoppingListEntrySerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
query_params = [
|
||||
QueryParam(name='id',
|
||||
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
|
||||
@@ -971,7 +973,7 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||
class ShoppingListViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingList.objects
|
||||
serializer_class = ShoppingListSerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(
|
||||
@@ -993,7 +995,7 @@ class ShoppingListViewSet(viewsets.ModelViewSet):
|
||||
class ViewLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = ViewLog.objects
|
||||
serializer_class = ViewLogSerializer
|
||||
permission_classes = [CustomIsOwner]
|
||||
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1004,7 +1006,7 @@ class ViewLogViewSet(viewsets.ModelViewSet):
|
||||
class CookLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = CookLog.objects
|
||||
serializer_class = CookLogSerializer
|
||||
permission_classes = [CustomIsOwner]
|
||||
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1014,7 +1016,7 @@ class CookLogViewSet(viewsets.ModelViewSet):
|
||||
class ImportLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = ImportLog.objects
|
||||
serializer_class = ImportLogSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1024,7 +1026,7 @@ class ImportLogViewSet(viewsets.ModelViewSet):
|
||||
class ExportLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = ExportLog.objects
|
||||
serializer_class = ExportLogSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1034,7 +1036,8 @@ class ExportLogViewSet(viewsets.ModelViewSet):
|
||||
class BookmarkletImportViewSet(viewsets.ModelViewSet):
|
||||
queryset = BookmarkletImport.objects
|
||||
serializer_class = BookmarkletImportSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasScope]
|
||||
required_scopes = ['bookmarklet']
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
@@ -1048,7 +1051,7 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet):
|
||||
class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = UserFile.objects
|
||||
serializer_class = UserFileSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1059,7 +1062,7 @@ class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = Automation.objects
|
||||
serializer_class = AutomationSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(space=self.request.space).all()
|
||||
@@ -1069,7 +1072,7 @@ class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = InviteLink.objects
|
||||
serializer_class = InviteLinkSerializer
|
||||
permission_classes = [CustomIsSpaceOwner & CustomIsAdmin]
|
||||
permission_classes = [CustomIsSpaceOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
if is_space_owner(self.request.user, self.request.space):
|
||||
@@ -1082,7 +1085,7 @@ class InviteLinkViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
queryset = CustomFilter.objects
|
||||
serializer_class = CustomFilterSerializer
|
||||
permission_classes = [CustomIsOwner]
|
||||
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
||||
@@ -1090,6 +1093,15 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
class AccessTokenViewSet(viewsets.ModelViewSet):
|
||||
queryset = AccessToken.objects
|
||||
serializer_class = AccessTokenSerializer
|
||||
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
|
||||
# -------------- DRF custom views --------------------
|
||||
|
||||
class AuthTokenThrottle(AnonRateThrottle):
|
||||
@@ -1104,16 +1116,23 @@ class CustomAuthToken(ObtainAuthToken):
|
||||
context={'request': request})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.validated_data['user']
|
||||
token, created = Token.objects.get_or_create(user=user)
|
||||
if token := AccessToken.objects.filter(user=user, expires__gt=timezone.now(), scope__contains='read').filter(scope__contains='write').first():
|
||||
access_token = token
|
||||
else:
|
||||
access_token = AccessToken.objects.create(user=user, token=f'tda_{str(uuid.uuid4()).replace("-", "_")}', expires=(timezone.now() + timezone.timedelta(days=365 * 5)), scope='read write app')
|
||||
return Response({
|
||||
'token': token.key,
|
||||
'user_id': user.pk,
|
||||
'id': access_token.id,
|
||||
'token': access_token.token,
|
||||
'scope': access_token.scope,
|
||||
'expires': access_token.expires,
|
||||
'user_id': access_token.user.pk,
|
||||
'test': user.pk
|
||||
})
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
# @schema(AutoSchema()) #TODO add proper schema
|
||||
@permission_classes([CustomIsUser])
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
# TODO add rate limiting
|
||||
def recipe_from_source(request):
|
||||
"""
|
||||
@@ -1201,7 +1220,7 @@ def recipe_from_source(request):
|
||||
|
||||
@api_view(['GET'])
|
||||
# @schema(AutoSchema()) #TODO add proper schema
|
||||
@permission_classes([CustomIsAdmin])
|
||||
@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope])
|
||||
# TODO add rate limiting
|
||||
def reset_food_inheritance(request):
|
||||
"""
|
||||
@@ -1217,7 +1236,7 @@ def reset_food_inheritance(request):
|
||||
|
||||
@api_view(['GET'])
|
||||
# @schema(AutoSchema()) #TODO add proper schema
|
||||
@permission_classes([CustomIsAdmin])
|
||||
@permission_classes([CustomIsAdmin & CustomTokenHasReadWriteScope])
|
||||
# TODO add rate limiting
|
||||
def switch_active_space(request, space_id):
|
||||
"""
|
||||
@@ -1237,7 +1256,7 @@ def switch_active_space(request, space_id):
|
||||
|
||||
@api_view(['GET'])
|
||||
# @schema(AutoSchema()) #TODO add proper schema
|
||||
@permission_classes([CustomIsUser])
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
def download_file(request, file_id):
|
||||
"""
|
||||
function to download a user file securely (wrapping as zip to prevent any context based XSS problems)
|
||||
@@ -1262,7 +1281,7 @@ def download_file(request, file_id):
|
||||
|
||||
@api_view(['POST'])
|
||||
# @schema(AutoSchema()) #TODO add proper schema
|
||||
@permission_classes([CustomIsUser])
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
def import_files(request):
|
||||
"""
|
||||
function to handle files passed by application importer
|
||||
@@ -1287,6 +1306,8 @@ def import_files(request):
|
||||
return Response({'import_id': il.pk}, status=status.HTTP_200_OK)
|
||||
except NotImplementedError:
|
||||
return Response({'error': True, 'msg': _('Importing is not implemented for this provider')}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response({'error': True, 'msg': form.errors}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
def get_recipe_provider(recipe):
|
||||
@@ -1362,15 +1383,17 @@ def sync_all(request):
|
||||
return redirect('list_recipe_import')
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def share_link(request, pk):
|
||||
if request.space.allow_sharing:
|
||||
recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
|
||||
link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space)
|
||||
return JsonResponse({'pk': pk, 'share': link.uuid,
|
||||
'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
|
||||
else:
|
||||
return JsonResponse({'error': 'sharing_disabled'}, status=403)
|
||||
if request.user.is_authenticated:
|
||||
if request.space.allow_sharing and has_group_permission(request.user, ('user',)):
|
||||
recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
|
||||
link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space)
|
||||
return JsonResponse({'pk': pk, 'share': link.uuid,
|
||||
'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
|
||||
else:
|
||||
return JsonResponse({'error': 'sharing_disabled'}, status=403)
|
||||
|
||||
return JsonResponse({'error': 'not_authenticated'}, status=403)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import ngettext
|
||||
from django_tables2 import RequestConfig
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from cookbook.forms import BatchEditForm, SyncForm
|
||||
@@ -115,8 +118,8 @@ def import_url(request):
|
||||
messages.add_message(request, messages.WARNING, msg)
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if (api_token := Token.objects.filter(user=request.user).first()) is None:
|
||||
api_token = Token.objects.create(user=request.user)
|
||||
if (api_token := AccessToken.objects.filter(user=request.user, scope='bookmarklet').first()) is None:
|
||||
api_token = AccessToken.objects.create(user=request.user, scope='bookmarklet', expires=(timezone.now() + timezone.timedelta(days=365*10)), token=f'tda_{str(uuid.uuid4()).replace("-","_")}')
|
||||
|
||||
bookmarklet_import_id = -1
|
||||
if 'id' in request.GET:
|
||||
|
||||
@@ -31,6 +31,7 @@ from cookbook.integration.plantoeat import Plantoeat
|
||||
from cookbook.integration.recettetek import RecetteTek
|
||||
from cookbook.integration.recipekeeper import RecipeKeeper
|
||||
from cookbook.integration.recipesage import RecipeSage
|
||||
from cookbook.integration.rezeptsuitede import Rezeptsuitede
|
||||
from cookbook.integration.rezkonv import RezKonv
|
||||
from cookbook.integration.saffron import Saffron
|
||||
from cookbook.models import ExportLog, ImportLog, Recipe, UserPreference
|
||||
@@ -80,6 +81,8 @@ def get_integration(request, export_type):
|
||||
return MelaRecipes(request, export_type)
|
||||
if export_type == ImportExportBase.COOKMATE:
|
||||
return Cookmate(request, export_type)
|
||||
if export_type == ImportExportBase.REZEPTSUITEDE:
|
||||
return Rezeptsuitede(request, export_type)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
@@ -11,20 +12,19 @@ from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework.authtoken.models import Token
|
||||
from oauth2_provider.models import AccessToken
|
||||
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingPreferenceForm,
|
||||
SpaceCreateForm, SpaceJoinForm, User,
|
||||
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
|
||||
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, MealPlan, SearchFields, SearchPreference, ShareLink,
|
||||
from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink,
|
||||
Space, ViewLog, UserSpace)
|
||||
from cookbook.tables import (CookLogTable, ViewLogTable)
|
||||
from recipes.version import BUILD_REF, VERSION_NUMBER
|
||||
@@ -183,7 +183,11 @@ def view_profile(request, user_id):
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
def user_settings_new(request):
|
||||
def user_settings(request):
|
||||
if request.space.demo:
|
||||
messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
|
||||
return redirect('index')
|
||||
|
||||
return render(request, 'user_settings.html', {})
|
||||
|
||||
|
||||
@@ -201,55 +205,16 @@ def ingredient_editor(request):
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
def user_settings(request):
|
||||
def shopping_settings(request):
|
||||
if request.space.demo:
|
||||
messages.add_message(request, messages.ERROR, _('This feature is not available in the demo version!'))
|
||||
return redirect('index')
|
||||
|
||||
up = request.user.userpreference
|
||||
sp = request.user.searchpreference
|
||||
search_error = False
|
||||
active_tab = 'account'
|
||||
|
||||
user_name_form = UserNameForm(instance=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
if 'preference_form' in request.POST:
|
||||
active_tab = 'preferences'
|
||||
form = UserPreferenceForm(request.POST, prefix='preference', space=request.space)
|
||||
if form.is_valid():
|
||||
if not up:
|
||||
up = UserPreference(user=request.user)
|
||||
|
||||
up.theme = form.cleaned_data['theme']
|
||||
up.nav_color = form.cleaned_data['nav_color']
|
||||
up.default_unit = form.cleaned_data['default_unit']
|
||||
up.default_page = form.cleaned_data['default_page']
|
||||
up.plan_share.set(form.cleaned_data['plan_share'])
|
||||
up.ingredient_decimals = form.cleaned_data['ingredient_decimals'] # noqa: E501
|
||||
up.comments = form.cleaned_data['comments']
|
||||
up.use_fractions = form.cleaned_data['use_fractions']
|
||||
up.use_kj = form.cleaned_data['use_kj']
|
||||
up.sticky_navbar = form.cleaned_data['sticky_navbar']
|
||||
up.left_handed = form.cleaned_data['left_handed']
|
||||
|
||||
up.save()
|
||||
|
||||
elif 'user_name_form' in request.POST:
|
||||
user_name_form = UserNameForm(request.POST, prefix='name')
|
||||
if user_name_form.is_valid():
|
||||
request.user.first_name = user_name_form.cleaned_data['first_name']
|
||||
request.user.last_name = user_name_form.cleaned_data['last_name']
|
||||
request.user.save()
|
||||
|
||||
elif 'password_form' in request.POST:
|
||||
password_form = PasswordChangeForm(request.user, request.POST)
|
||||
if password_form.is_valid():
|
||||
user = password_form.save()
|
||||
update_session_auth_hash(request, user)
|
||||
|
||||
elif 'search_form' in request.POST:
|
||||
active_tab = 'search'
|
||||
if 'search_form' in request.POST:
|
||||
search_form = SearchPreferenceForm(request.POST, prefix='search')
|
||||
if search_form.is_valid():
|
||||
if not sp:
|
||||
@@ -260,7 +225,28 @@ def user_settings(request):
|
||||
+ len(search_form.cleaned_data['trigram'])
|
||||
+ len(search_form.cleaned_data['fulltext'])
|
||||
)
|
||||
if fields_searched == 0:
|
||||
if search_form.cleaned_data['preset'] == 'fuzzy':
|
||||
sp.search = SearchPreference.SIMPLE
|
||||
sp.lookup = True
|
||||
sp.unaccent.set([SearchFields.objects.get(name='Name')])
|
||||
sp.icontains.set([SearchFields.objects.get(name='Name')])
|
||||
sp.istartswith.clear()
|
||||
sp.trigram.set([SearchFields.objects.get(name='Name')])
|
||||
sp.fulltext.clear()
|
||||
sp.trigram_threshold = 0.2
|
||||
sp.save()
|
||||
elif search_form.cleaned_data['preset'] == 'precise':
|
||||
sp.search = SearchPreference.WEB
|
||||
sp.lookup = True
|
||||
sp.unaccent.set(SearchFields.objects.all())
|
||||
# full text on food is very slow, add search_vector field and index it (including Admin functions and postsave signal to rebuild index)
|
||||
sp.icontains.set([SearchFields.objects.get(name='Name')])
|
||||
sp.istartswith.set([SearchFields.objects.get(name='Name')])
|
||||
sp.trigram.clear()
|
||||
sp.fulltext.set(SearchFields.objects.filter(name__in=['Ingredients']))
|
||||
sp.trigram_threshold = 0.2
|
||||
sp.save()
|
||||
elif fields_searched == 0:
|
||||
search_form.add_error(None, _('You must select at least one field to search!'))
|
||||
search_error = True
|
||||
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(
|
||||
@@ -281,55 +267,9 @@ def user_settings(request):
|
||||
sp.trigram.set(search_form.cleaned_data['trigram'])
|
||||
sp.fulltext.set(search_form.cleaned_data['fulltext'])
|
||||
sp.trigram_threshold = search_form.cleaned_data['trigram_threshold']
|
||||
|
||||
if search_form.cleaned_data['preset'] == 'fuzzy':
|
||||
sp.search = SearchPreference.SIMPLE
|
||||
sp.lookup = True
|
||||
sp.unaccent.set([SearchFields.objects.get(name='Name')])
|
||||
sp.icontains.set([SearchFields.objects.get(name='Name')])
|
||||
sp.istartswith.clear()
|
||||
sp.trigram.set([SearchFields.objects.get(name='Name')])
|
||||
sp.fulltext.clear()
|
||||
sp.trigram_threshold = 0.2
|
||||
|
||||
if search_form.cleaned_data['preset'] == 'precise':
|
||||
sp.search = SearchPreference.WEB
|
||||
sp.lookup = True
|
||||
sp.unaccent.set(SearchFields.objects.all())
|
||||
# full text on food is very slow, add search_vector field and index it (including Admin functions and postsave signal to rebuild index)
|
||||
sp.icontains.set([SearchFields.objects.get(name__in=['Name', 'Ingredients'])])
|
||||
sp.istartswith.set([SearchFields.objects.get(name='Name')])
|
||||
sp.trigram.clear()
|
||||
sp.fulltext.set(SearchFields.objects.filter(name__in=['Ingredients']))
|
||||
sp.trigram_threshold = 0.2
|
||||
|
||||
sp.save()
|
||||
elif 'shopping_form' in request.POST:
|
||||
shopping_form = ShoppingPreferenceForm(request.POST, prefix='shopping')
|
||||
if shopping_form.is_valid():
|
||||
if not up:
|
||||
up = UserPreference(user=request.user)
|
||||
|
||||
up.shopping_share.set(shopping_form.cleaned_data['shopping_share'])
|
||||
up.mealplan_autoadd_shopping = shopping_form.cleaned_data['mealplan_autoadd_shopping']
|
||||
up.mealplan_autoexclude_onhand = shopping_form.cleaned_data['mealplan_autoexclude_onhand']
|
||||
up.mealplan_autoinclude_related = shopping_form.cleaned_data['mealplan_autoinclude_related']
|
||||
up.shopping_auto_sync = shopping_form.cleaned_data['shopping_auto_sync']
|
||||
up.filter_to_supermarket = shopping_form.cleaned_data['filter_to_supermarket']
|
||||
up.default_delay = shopping_form.cleaned_data['default_delay']
|
||||
up.shopping_recent_days = shopping_form.cleaned_data['shopping_recent_days']
|
||||
up.shopping_add_onhand = shopping_form.cleaned_data['shopping_add_onhand']
|
||||
up.csv_delim = shopping_form.cleaned_data['csv_delim']
|
||||
up.csv_prefix = shopping_form.cleaned_data['csv_prefix']
|
||||
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
|
||||
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
|
||||
up.save()
|
||||
if up:
|
||||
preference_form = UserPreferenceForm(instance=up, space=request.space)
|
||||
shopping_form = ShoppingPreferenceForm(instance=up)
|
||||
else:
|
||||
preference_form = UserPreferenceForm(space=request.space)
|
||||
shopping_form = ShoppingPreferenceForm(space=request.space)
|
||||
else:
|
||||
search_error = True
|
||||
|
||||
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
|
||||
sp.fulltext.all())
|
||||
@@ -338,24 +278,16 @@ def user_settings(request):
|
||||
elif not search_error:
|
||||
search_form = SearchPreferenceForm()
|
||||
|
||||
if (api_token := Token.objects.filter(user=request.user).first()) is None:
|
||||
api_token = Token.objects.create(user=request.user)
|
||||
|
||||
# these fields require postgresql - just disable them if postgresql isn't available
|
||||
if not settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
search_form.fields['search'].disabled = True
|
||||
search_form.fields['lookup'].disabled = True
|
||||
search_form.fields['trigram'].disabled = True
|
||||
search_form.fields['fulltext'].disabled = True
|
||||
sp.search = SearchPreference.SIMPLE
|
||||
sp.trigram.clear()
|
||||
sp.fulltext.clear()
|
||||
sp.save()
|
||||
|
||||
return render(request, 'settings.html', {
|
||||
'preference_form': preference_form,
|
||||
'user_name_form': user_name_form,
|
||||
'api_token': api_token,
|
||||
'search_form': search_form,
|
||||
'shopping_form': shopping_form,
|
||||
'active_tab': active_tab
|
||||
})
|
||||
|
||||
|
||||
@@ -506,7 +438,7 @@ def test(request):
|
||||
parser = IngredientParser(request, False)
|
||||
|
||||
data = {
|
||||
'original': '1 Porreestange(n) , ca. 200 g'
|
||||
'original': '90g golden syrup'
|
||||
}
|
||||
data['parsed'] = parser.parse(data['original'])
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user