Compare commits

...

206 Commits
1.1.3 ... 1.2.2

Author SHA1 Message Date
vabene1111
b66a5c1ee9 Merge branch 'develop' 2022-05-04 15:01:14 +02:00
vabene1111
bfc42638a4 fixed supermarket category ordering 2022-05-04 15:00:39 +02:00
vabene1111
a8c9689b43 fixed ability to disabled auto sync 2022-05-04 14:47:53 +02:00
vabene1111
a49993e399 testing new cryptography version 2022-05-03 16:58:39 +02:00
Tomasz Klimczak
9f42226224 Translated using Weblate (Polish)
Currently translated at 100.0% (395 of 395 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2022-05-02 22:32:09 +00:00
vabene1111
8f4c00df0b use old property as other sometimes fails 2022-05-02 15:52:15 +02:00
vabene1111
6cebec86c5 Merge branch 'master' into develop 2022-05-02 15:25:47 +02:00
vabene1111
8f5b017857 dont build arm 2022-05-01 21:51:57 +02:00
vabene1111
9915a3eebf Merge branch 'develop' 2022-05-01 13:09:48 +02:00
vabene1111
19c2d3bcf1 shopping description 2022-05-01 13:03:36 +02:00
vabene1111
9259f306ec final importer touches 2022-05-01 12:51:37 +02:00
vabene1111
4f33101319 fixed empty step time error 2022-04-29 21:57:05 +02:00
vabene1111
3cef470134 made and compiled messages 2022-04-29 18:42:33 +02:00
Kaibu
93c53e5fc8 Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2022-04-24 12:52:06 +02:00
Kaibu
d931feadf5 supermarkets rework mostly done 2022-04-24 12:52:00 +02:00
vabene1111
fe32ff15b3 another toast fix 2022-04-24 12:46:17 +02:00
vabene1111
a44dea64b8 fixed toast error 2022-04-23 20:55:01 +02:00
Kaibu
54af76e9cf Merge branch 'develop' of https://github.com/vabene1111/recipes into develop
# Conflicts:
#	vue/src/apps/ShoppingListView/ShoppingListView.vue
2022-04-23 20:45:35 +02:00
Kaibu
fcfef255c1 shopping list supermarket rework 2022-04-23 20:44:46 +02:00
vabene1111
2914c20522 added space tier banner 2022-04-23 20:18:10 +02:00
vabene1111
825b7b7cf9 ingredient editor and parser 2022-04-23 19:58:40 +02:00
vabene1111
b9fb78c24d Merge pull request #1739 from ndbeals/develop
Fix image upload handler to use content-type
2022-04-23 19:55:08 +02:00
vabene1111
2fbce7d84d Merge pull request #1734 from Drumstickx/develop
Fix link in German locale
2022-04-23 19:53:47 +02:00
vabene1111
69a23f34b4 updated tests ingredient pagination 2022-04-23 18:54:23 +02:00
vabene1111
9f90306f6c at least basic ui for ingredient editor 2022-04-23 17:13:27 +02:00
vabene1111
1fb6f96571 minor shopping tweaks 2022-04-23 16:51:04 +02:00
vabene1111
0b8dd63510 small fixes 2022-04-23 15:27:21 +02:00
Kaibu
b79bc0d9a8 Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2022-04-23 14:47:14 +02:00
Kaibu
8149192455 shopping list and import view ux 2022-04-23 14:47:10 +02:00
vabene1111
66c0cc070a removed old shopping list 2022-04-23 14:43:03 +02:00
vabene1111
e2ab3a0efb fixed ingredient parser length issues 2022-04-23 14:15:06 +02:00
vabene1111
e0b7d1a8f0 added support for unit/amount at end of ingredient 2022-04-23 13:53:04 +02:00
vabene1111
012a1a7915 ingredient parser produces expected results again 2022-04-23 13:03:15 +02:00
Nathan Beals
2af36a3db4 Merge branch 'develop' of github.com:ndbeals/tandoor_recipes into develop 2022-04-23 00:11:08 -04:00
Nathan Beals
8df3009cb2 Update image upload handler to be content-type aware
Update handle_image: made filetype required and not optional
Updated handle_image usage to reflect changes
2022-04-23 00:11:04 -04:00
Kaibu
161ae9879a import app view refactor 2022-04-23 03:02:46 +02:00
vabene1111
71a60a46be Translated using Weblate (German)
Currently translated at 96.8% (372 of 384 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2022-04-23 00:54:22 +00:00
Kaibu
93acac1f3b Translated using Weblate (German)
Currently translated at 96.8% (372 of 384 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2022-04-23 00:54:22 +00:00
vabene1111
b4ebd98ee8 ingredient editor paginatio 2022-04-23 02:28:26 +02:00
vabene1111
78c0c5c213 ingredient editor improve merge/delete add edit 2022-04-23 02:17:40 +02:00
vabene1111
30d5587fbe added err msg to all standard toasts 2022-04-23 01:41:08 +02:00
vabene1111
e4223787be updated standard toast function 2022-04-23 01:31:15 +02:00
vabene1111
3850287deb WSL docs 2022-04-23 01:28:56 +02:00
Kaibu
7fae95e248 Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2022-04-23 01:28:25 +02:00
Kaibu
b037203b8f ui improvements (shopping and meal plan)
- fixed content scaling for meal plan and shopping
- style fixes on shopping list
- touch based swipe actions for shopping list
- better mobile experience overall
2022-04-23 01:28:20 +02:00
vabene1111
9b132e71f2 screw up ingredient parser 2022-04-23 01:21:13 +02:00
vabene1111
1a21659b5e Merge pull request #1737 from TandoorRecipes/dependabot/pip/django-3.2.13
Bump django from 3.2.12 to 3.2.13
2022-04-23 01:01:40 +02:00
dependabot[bot]
1a1dd092d0 Bump django from 3.2.12 to 3.2.13
Bumps [django](https://github.com/django/django) from 3.2.12 to 3.2.13.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.12...3.2.13)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-22 22:45:49 +00:00
vabene1111
9adc1f7266 small fixes and tests 2022-04-22 22:27:17 +02:00
vabene1111
6953f763d2 improved source url field rendering 2022-04-22 21:13:00 +02:00
vabene1111
4ecf77f431 Merge branch 'feature/importer_to_vue' into develop
# Conflicts:
#	vue/src/apps/RecipeView/RecipeView.vue
2022-04-22 21:07:02 +02:00
vabene1111
c4f5b160a6 show original text in recipe editor 2022-04-22 21:05:23 +02:00
vabene1111
d8f6dbc58f import list to textarea 2022-04-22 21:00:59 +02:00
vabene1111
8d3747a304 added missing doc entries 2022-04-22 20:30:05 +02:00
vabene1111
1740913a14 improve experience when importing multiple recipes 2022-04-22 20:19:39 +02:00
vabene1111
3cf0395a18 lots of small fixes 2022-04-22 19:26:49 +02:00
Kaibu
42dfc9d126 source url added 2022-04-22 18:53:23 +02:00
Kaibu
d7bd731c73 minor ui fix in recipe view 2022-04-22 17:43:23 +02:00
Kaibu
9e86abb004 Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2022-04-22 17:33:33 +02:00
Kaibu
dc8ce0f6a4 recipe image max-width based on ingredient table 2022-04-22 17:33:27 +02:00
vabene1111
2ddb0c719a Merge branch 'develop' into feature/importer_to_vue
# Conflicts:
#	vue/src/apps/RecipeEditView/RecipeEditView.vue
#	vue/src/utils/openapi/api.ts
2022-04-22 16:41:13 +02:00
Tomasz Klimczak
05383a2bc3 Translated using Weblate (Polish)
Currently translated at 100.0% (384 of 384 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2022-04-21 19:14:53 +00:00
Drumstickx
48c0252893 Fix link in German locale 2022-04-21 18:51:38 +02:00
Tomasz Klimczak
82fd6f1860 Translated using Weblate (Polish)
Currently translated at 100.0% (383 of 383 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2022-04-19 04:18:02 +00:00
Kaibu
694022506d Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2022-04-18 14:13:30 +02:00
Kaibu
45a86a22e3 recipe switcher and navbar fixes
also added left_hand to global preferences
2022-04-18 14:13:23 +02:00
vabene1111
1100826ed8 fixed NC importer importing empty keywords 2022-04-18 12:52:32 +02:00
vabene1111
d1065c8ac4 Merge pull request #1710 from hendrikbl/feature/navbar-tweaks
Navbar tweaks
2022-04-18 12:37:45 +02:00
vabene1111
2fdd9edde1 Merge pull request #1731 from parkroland/enhancement/clickable-keywords-recipe-card
Add clickable keywords to recipe card
2022-04-18 12:33:10 +02:00
Roland Park
a84ab0c049 Add clickable keywords to recipe card 2022-04-17 19:56:36 -04:00
vabene1111
d9dd0a594e show recipe and allow delete in ingredient editor 2022-04-17 23:25:22 +02:00
vabene1111
f0d59a8c9c added ability to open ingredient editor from food/unit list 2022-04-17 22:43:33 +02:00
vabene1111
d50fb69ce9 fixed errro message in meal plan when adding notes with add to shopping 2022-04-17 15:48:18 +02:00
vabene1111
8bc13fc91f fixed env template email settings 2022-04-17 15:35:41 +02:00
vabene1111
2ce06a8154 fixed too small fractions dissappearing 2022-04-17 15:35:32 +02:00
vabene1111
0a0c0b069f fixed space management page template error 2022-04-17 15:03:50 +02:00
vabene1111
47b62aa390 Merge pull request #1714 from gloriousDan/docs_nginx_read_only
Docs: nginx mount volumes read-only
2022-04-17 14:59:57 +02:00
vabene1111
9a2f91d3d4 Merge pull request #1709 from gloriousDan/develop
Docs: Reintroduce restart-always to web_recipes service
2022-04-17 14:58:55 +02:00
vabene1111
2df940ee40 improved rezkonv importer 2022-04-17 14:55:30 +02:00
Oskar Stenberg
67a5d8f1bd Translated using Weblate (Swedish)
Currently translated at 100.0% (371 of 371 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/sv/
2022-04-17 00:32:01 +00:00
vabene1111
297a8d4c8b ingredient editor basics done 2022-04-14 22:45:31 +02:00
vabene1111
976bce5fdd add merge capability to ingredient editor 2022-04-14 22:10:48 +02:00
vabene1111
8c89438b97 allow delete in ingredient editor 2022-04-14 14:40:15 +02:00
vabene1111
7ca7bd6111 improve generic form delete 2022-04-14 14:31:34 +02:00
vabene1111
3159868ba4 moved generic multiselect create function into component 2022-04-14 14:08:16 +02:00
vabene1111
7befa4a084 added basic ingredient editor 2022-04-14 13:01:27 +02:00
vabene1111
2ee96c2ea4 docs run on master, update contrib. docs 2022-04-14 08:53:16 +02:00
vabene1111
74b67e5549 Merge pull request #1719 from tristanlins/develop
Remove namespace vom k8s templates
2022-04-14 08:40:51 +02:00
vabene1111
1e24161d4c Merge pull request #1711 from Makanz/add-swedish-language-support
Add swedish language support
2022-04-14 08:39:29 +02:00
Tristan Lins
a839fb0bfc Remove namespace vom k8s templates 2022-04-12 20:43:58 +02:00
cesar ferreira
46267a135b Translated using Weblate (Portuguese)
Currently translated at 7.8% (30 of 381 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt/
2022-04-12 18:32:03 +00:00
Tomasz Klimczak
f7ab0400a3 Translated using Weblate (Polish)
Currently translated at 100.0% (381 of 381 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2022-04-12 18:32:02 +00:00
Marcus Alsterfjord
74863117c5 Translated using Weblate (Swedish)
Currently translated at 100.0% (381 of 381 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sv/
2022-04-12 18:32:01 +00:00
Marcus Alsterfjord
e872272fbd Translated using Weblate (Swedish)
Currently translated at 100.0% (371 of 371 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/sv/
2022-04-12 18:32:00 +00:00
Daniel
9ae7d591cc Merge branch 'TandoorRecipes:develop' into docs_nginx_read_only 2022-04-10 20:46:46 +02:00
Daniel Schulz
c3c697f4a8 Mount all volumes in nginx read-only 2022-04-10 20:45:34 +02:00
Tomasz Klimczak
60e8b95593 Translated using Weblate (Polish)
Currently translated at 88.1% (336 of 381 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2022-04-10 09:51:32 +00:00
Marcus Alsterfjord
1636710099 Translated using Weblate (Swedish)
Currently translated at 61.7% (229 of 371 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/sv/
2022-04-10 09:51:31 +00:00
Rubens
4296c3d136 Translated using Weblate (Catalan)
Currently translated at 96.4% (542 of 562 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/ca/
2022-04-10 09:51:31 +00:00
Marcus Alsterfjord
bcda5eea93 Update CONTRIBUTERS.md 2022-04-09 16:52:18 +02:00
Marcus Alsterfjord
a63ede0e3a Add swedish support in frontend ui and in search 2022-04-09 16:51:16 +02:00
hendrikbl
a9414065b5 changed navbar logo size and order 2022-04-09 16:15:10 +02:00
Daniel Schulz
fa79adf931 Reintroduce restart-always to web_recipes service 2022-04-09 14:56:00 +02:00
Marcus Alsterfjord
cd5f752d26 Translated using Weblate (Swedish)
Currently translated at 85.8% (327 of 381 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sv/
2022-04-08 22:32:07 +00:00
Rubens
6a41b182f5 Translated using Weblate (Catalan)
Currently translated at 79.8% (449 of 562 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/ca/
2022-04-08 22:32:07 +00:00
vabene1111
0011ce26d3 Merge pull request #1705 from amillerr/patch-1
Update CONTRIBUTERS.md
2022-04-08 08:03:29 +02:00
Artem Aksenov
1ef92df83c Translated using Weblate (Russian)
Currently translated at 8.8% (44 of 496 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/ru/
2022-04-07 19:32:01 +00:00
Artem Aksenov
3e8ef33402 Translated using Weblate (Russian)
Currently translated at 54.3% (207 of 381 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/ru/
2022-04-07 19:32:00 +00:00
Rubens
4d4c3bea92 Translated using Weblate (Catalan)
Currently translated at 56.5% (318 of 562 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/ca/
2022-04-07 19:32:00 +00:00
Artyom Aksyonov
9ecbfb0655 Update CONTRIBUTERS.md 2022-04-07 21:58:39 +03:00
Jesse
944492168e Translated using Weblate (Dutch)
Currently translated at 100.0% (378 of 378 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2022-04-05 10:32:03 +00:00
Jesse
1c39befa0f Translated using Weblate (Dutch)
Currently translated at 100.0% (562 of 562 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/nl/
2022-04-05 10:32:03 +00:00
vabene1111
57ec6a2b3d import servings text 2022-04-04 23:21:13 +02:00
vabene1111
3653d6b911 added source url to nextcloud cookbook 2022-04-04 22:55:00 +02:00
Kaibu
82c2cc0f40 ui improvements 2022-04-04 22:53:26 +02:00
vabene1111
48e9f3f8a9 added cookmate importer 2022-04-04 22:49:50 +02:00
vabene1111
12865437d7 copy me that improvements 2022-04-04 21:44:51 +02:00
vabene1111
090e18e405 paprika importer improvements 2022-04-04 21:30:00 +02:00
vabene1111
7db49b1528 Merge pull request #1698 from HarHarLinks/patch-1
fix links in german locale
2022-04-04 21:16:06 +02:00
vabene1111
85aad42529 added mela recipes importer 2022-04-04 21:09:47 +02:00
Kim Brose
9c254be4b5 fix links in german locale 2022-04-04 20:34:01 +02:00
vabene1111
5bd9a15e4b small fixes 2022-04-04 20:11:22 +02:00
vabene1111
3cedab45ee fixed copy recipe nutrition link 2022-04-04 19:45:37 +02:00
vabene1111
56f3fe2d12 fixed deprecated model attribute on exporters 2022-04-04 19:27:14 +02:00
vabene1111
a2954554b5 fixed description length limit 2022-04-04 19:26:47 +02:00
vabene1111
528ada7d32 Merge branch 'develop' into feature/importer_to_vue
# Conflicts:
#	vue/package.json
2022-04-04 19:16:20 +02:00
vabene1111
b7e6e7b1b0 fixed s3 check 2022-04-04 19:15:11 +02:00
vabene1111
e17da08a74 Merge pull request #1679 from TandoorRecipes/dependabot/pip/jinja2-3.1.1
Bump jinja2 from 3.0.3 to 3.1.1
2022-04-04 19:07:34 +02:00
vabene1111
32cedf1078 Merge pull request #1680 from TandoorRecipes/dependabot/pip/boto3-1.21.31
Bump boto3 from 1.21.28 to 1.21.31
2022-04-04 19:07:29 +02:00
vabene1111
60bb3fd4aa Merge pull request #1633 from smilerz/recipe_as_food
allow marking recipe as food
2022-04-04 19:06:10 +02:00
dependabot[bot]
f421990ae0 Bump jinja2 from 3.0.3 to 3.1.1
Bumps [jinja2](https://github.com/pallets/jinja) from 3.0.3 to 3.1.1.
- [Release notes](https://github.com/pallets/jinja/releases)
- [Changelog](https://github.com/pallets/jinja/blob/main/CHANGES.rst)
- [Commits](https://github.com/pallets/jinja/compare/3.0.3...3.1.1)

---
updated-dependencies:
- dependency-name: jinja2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-04 17:05:40 +00:00
vabene1111
f9333d2b82 Merge pull request #1681 from TandoorRecipes/dependabot/pip/recipe-scrapers-13.25.0
Bump recipe-scrapers from 13.19.0 to 13.25.0
2022-04-04 19:04:58 +02:00
vabene1111
dfa5475ecb Merge pull request #1690 from kettenbach-it/develop
Get image from S3-storage if configured
2022-04-04 19:02:35 +02:00
Volker Kettenbach
b6e5425bd3 Get image from S3-Storage if configured 2022-04-02 11:35:35 +02:00
dependabot[bot]
1b7347f1d9 Bump recipe-scrapers from 13.19.0 to 13.25.0
Bumps [recipe-scrapers](https://github.com/hhursev/recipe-scrapers) from 13.19.0 to 13.25.0.
- [Release notes](https://github.com/hhursev/recipe-scrapers/releases)
- [Commits](https://github.com/hhursev/recipe-scrapers/compare/13.19.0...13.25.0)

---
updated-dependencies:
- dependency-name: recipe-scrapers
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-01 00:04:59 +00:00
dependabot[bot]
2ef23d2cb3 Bump boto3 from 1.21.28 to 1.21.31
Bumps [boto3](https://github.com/boto/boto3) from 1.21.28 to 1.21.31.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.21.28...1.21.31)

---
updated-dependencies:
- dependency-name: boto3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-01 00:04:51 +00:00
Lucas Declercq
bba81f6594 Translated using Weblate (French)
Currently translated at 87.3% (330 of 378 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/
2022-03-31 06:01:57 +00:00
Marcus Alsterfjord
3a4f08f2f7 Translated using Weblate (Swedish)
Currently translated at 57.6% (218 of 378 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sv/
2022-03-31 06:01:56 +00:00
Marcus Alsterfjord
f8ad465113 Translated using Weblate (Swedish)
Currently translated at 55.0% (208 of 378 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sv/
2022-03-29 20:36:24 +00:00
Marcus Alsterfjord
6df993ce29 Translated using Weblate (Swedish)
Currently translated at 54.7% (203 of 371 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/sv/
2022-03-29 20:36:24 +00:00
Adrian M
6009eae42d Translated using Weblate (Hungarian)
Currently translated at 100.0% (568 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/hu/
2022-03-29 20:36:24 +00:00
vabene1111
7bed9963ff fixed vue cli pwa version and admin comment search 2022-03-28 22:56:45 +02:00
vabene1111
a0610ac05f Merge pull request #1676 from TandoorRecipes/dependabot/npm_and_yarn/vue/vue/cli-plugin-eslint-5.0.4
Bump @vue/cli-plugin-eslint from 4.5.15 to 5.0.4 in /vue
2022-03-28 22:15:58 +02:00
dependabot[bot]
afd063a2b9 Bump @vue/cli-plugin-eslint from 4.5.15 to 5.0.4 in /vue
Bumps [@vue/cli-plugin-eslint](https://github.com/vuejs/vue-cli/tree/HEAD/packages/@vue/cli-plugin-eslint) from 4.5.15 to 5.0.4.
- [Release notes](https://github.com/vuejs/vue-cli/releases)
- [Changelog](https://github.com/vuejs/vue-cli/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/vuejs/vue-cli/commits/v5.0.4/packages/@vue/cli-plugin-eslint)

---
updated-dependencies:
- dependency-name: "@vue/cli-plugin-eslint"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 20:01:08 +00:00
vabene1111
6b92dcbb2a Merge pull request #1606 from TandoorRecipes/dependabot/npm_and_yarn/vue/workbox-routing-6.5.0
Bump workbox-routing from 6.4.2 to 6.5.0 in /vue
2022-03-28 21:59:22 +02:00
vabene1111
12af99f546 Merge pull request #1668 from TandoorRecipes/dependabot/pip/cryptography-36.0.2
Bump cryptography from 36.0.1 to 36.0.2
2022-03-28 21:59:08 +02:00
vabene1111
68501d646d Merge pull request #1675 from TandoorRecipes/dependabot/npm_and_yarn/vue/axios-0.26.1
Bump axios from 0.24.0 to 0.26.1 in /vue
2022-03-28 21:58:52 +02:00
vabene1111
d215d236f0 Merge pull request #1669 from TandoorRecipes/dependabot/pip/boto3-1.21.28
Bump boto3 from 1.20.47 to 1.21.28
2022-03-28 21:58:41 +02:00
dependabot[bot]
459cf79ef3 Bump axios from 0.24.0 to 0.26.1 in /vue
Bumps [axios](https://github.com/axios/axios) from 0.24.0 to 0.26.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.24.0...v0.26.1)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:58:40 +00:00
dependabot[bot]
1d357eca4e Bump cryptography from 36.0.1 to 36.0.2
Bumps [cryptography](https://github.com/pyca/cryptography) from 36.0.1 to 36.0.2.
- [Release notes](https://github.com/pyca/cryptography/releases)
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/36.0.1...36.0.2)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:58:38 +00:00
vabene1111
ca8a7c3bc9 Merge pull request #1626 from TandoorRecipes/dependabot/pip/whitenoise-6.0.0
Bump whitenoise from 5.3.0 to 6.0.0
2022-03-28 21:58:30 +02:00
vabene1111
3b936eca3f Merge pull request #1674 from TandoorRecipes/dependabot/pip/python-dotenv-0.20.0
Bump python-dotenv from 0.19.2 to 0.20.0
2022-03-28 21:58:18 +02:00
vabene1111
2f06e9bc1c Merge pull request #1673 from TandoorRecipes/dependabot/pip/django-autocomplete-light-3.9.4
Bump django-autocomplete-light from 3.8.2 to 3.9.4
2022-03-28 21:57:25 +02:00
dependabot[bot]
e25c0705c6 Bump python-dotenv from 0.19.2 to 0.20.0
Bumps [python-dotenv](https://github.com/theskumar/python-dotenv) from 0.19.2 to 0.20.0.
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v0.19.2...v0.20.0)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:57:19 +00:00
vabene1111
d4e9526c75 Merge pull request #1670 from TandoorRecipes/dependabot/npm_and_yarn/vue/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6 in /vue
2022-03-28 21:56:52 +02:00
dependabot[bot]
2e2e81638b Bump django-autocomplete-light from 3.8.2 to 3.9.4
Bumps [django-autocomplete-light](https://github.com/yourlabs/django-autocomplete-light) from 3.8.2 to 3.9.4.
- [Release notes](https://github.com/yourlabs/django-autocomplete-light/releases)
- [Changelog](https://github.com/yourlabs/django-autocomplete-light/blob/master/CHANGELOG)
- [Commits](https://github.com/yourlabs/django-autocomplete-light/compare/3.8.2...3.9.4)

---
updated-dependencies:
- dependency-name: django-autocomplete-light
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:56:46 +00:00
vabene1111
baf5c9700f Merge pull request #1671 from TandoorRecipes/dependabot/pip/django-allauth-0.50.0
Bump django-allauth from 0.47.0 to 0.50.0
2022-03-28 21:56:39 +02:00
dependabot[bot]
dff7daefc7 Bump django-allauth from 0.47.0 to 0.50.0
Bumps [django-allauth](https://github.com/pennersr/django-allauth) from 0.47.0 to 0.50.0.
- [Release notes](https://github.com/pennersr/django-allauth/releases)
- [Changelog](https://github.com/pennersr/django-allauth/blob/master/ChangeLog.rst)
- [Commits](https://github.com/pennersr/django-allauth/compare/0.47.0...0.50.0)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:56:33 +00:00
vabene1111
7e27d704ca Merge pull request #1672 from TandoorRecipes/dependabot/pip/pytest-7.1.1
Bump pytest from 7.0.1 to 7.1.1
2022-03-28 21:56:27 +02:00
vabene1111
9722f22837 Merge pull request #1604 from TandoorRecipes/dependabot/npm_and_yarn/vue/vue/cli-plugin-pwa-5.0.1
Bump @vue/cli-plugin-pwa from 4.5.15 to 5.0.1 in /vue
2022-03-28 21:56:09 +02:00
dependabot[bot]
707a12f8c1 Bump pytest from 7.0.1 to 7.1.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.0.1 to 7.1.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/7.0.1...7.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:55:38 +00:00
vabene1111
b9b8864631 Merge pull request #1603 from TandoorRecipes/dependabot/npm_and_yarn/vue/popperjs/core-2.11.2
Bump @popperjs/core from 2.11.0 to 2.11.2 in /vue
2022-03-28 21:55:37 +02:00
vabene1111
31bdc0589e Merge pull request #1602 from TandoorRecipes/dependabot/pip/lxml-4.8.0
Bump lxml from 4.7.1 to 4.8.0
2022-03-28 21:55:26 +02:00
dependabot[bot]
cb29caf88c Bump whitenoise from 5.3.0 to 6.0.0
Bumps [whitenoise](https://github.com/evansd/whitenoise) from 5.3.0 to 6.0.0.
- [Release notes](https://github.com/evansd/whitenoise/releases)
- [Changelog](https://github.com/evansd/whitenoise/blob/master/docs/changelog.rst)
- [Commits](https://github.com/evansd/whitenoise/compare/v5.3.0...6.0.0)

---
updated-dependencies:
- dependency-name: whitenoise
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:55:05 +00:00
dependabot[bot]
dd044eba36 Bump minimist from 1.2.5 to 1.2.6 in /vue
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:54:50 +00:00
dependabot[bot]
b8518884b0 Bump boto3 from 1.20.47 to 1.21.28
Bumps [boto3](https://github.com/boto/boto3) from 1.20.47 to 1.21.28.
- [Release notes](https://github.com/boto/boto3/releases)
- [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst)
- [Commits](https://github.com/boto/boto3/compare/1.20.47...1.21.28)

---
updated-dependencies:
- dependency-name: boto3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-28 19:54:47 +00:00
vabene1111
ed20a54137 Merge pull request #1601 from TandoorRecipes/dependabot/pip/django-allauth-0.49.0
Bump django-allauth from 0.47.0 to 0.49.0
2022-03-28 21:54:30 +02:00
vabene1111
7781bf1444 Merge pull request #1598 from TandoorRecipes/dependabot/pip/pytest-7.0.1
Bump pytest from 6.2.5 to 7.0.1
2022-03-28 21:54:16 +02:00
vabene1111
14db4179b9 Merge pull request #1660 from MaxJa4/patch-1
Add POSTGRES_USER to psql health check during boot
2022-03-28 21:53:27 +02:00
Adrian M
37eab3ece2 Translated using Weblate (Hungarian)
Currently translated at 57.3% (326 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/hu/
2022-03-28 01:56:10 +00:00
Adrian M
768b483351 Translated using Weblate (Hungarian)
Currently translated at 12.1% (69 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/hu/
2022-03-27 18:00:28 +00:00
MaxJa4
59d277da3d Add POSTGRES_USER to psql health check during boot
Add POSTGRES_USER to psql health check during boot to support external psql instances better.

Fixes #1656
2022-03-20 00:08:41 +01:00
Stefan Werner
f68fd0fa94 Translated using Weblate (Finnish)
Currently translated at 56.3% (213 of 378 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fi/
2022-03-18 16:31:49 +00:00
Stefan Werner
dd1fcc21e0 Translated using Weblate (Finnish)
Currently translated at 3.2% (16 of 493 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/fi/
2022-03-18 16:31:49 +00:00
vabene1111
f445722140 Merge pull request #1653 from bradpoulton/develop
Fixes the formatting
2022-03-17 20:46:41 +01:00
brad
a3def6bf4c Fixes the formatting 2022-03-16 22:00:42 -06:00
Adrian M
cbd2ac2032 Translated using Weblate (Hungarian)
Currently translated at 5.6% (32 of 568 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/hu/
2022-03-16 22:31:47 +00:00
vabene1111
1d3d4e78f5 Merge branch 'develop' 2022-03-16 14:27:11 +01:00
vabene1111
0c841ec686 another improvement to shopping list item 2022-03-16 09:21:09 +01:00
ivo
f875942e79 Translated using Weblate (Portuguese)
Currently translated at 1.0% (4 of 378 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt/
2022-03-15 19:31:47 +00:00
vabene1111
e901c6708c Merge branch 'develop' into feature/importer_to_vue 2022-03-15 13:02:10 +01:00
vabene1111
c5863a5309 Merge branch 'develop' 2022-03-15 13:01:59 +01:00
vabene1111
7426bb4e76 auto import multiple urls 2022-03-10 17:35:11 +01:00
vabene1111
92b536b32c check login remember by default 2022-03-10 16:10:33 +01:00
vabene1111
5627161c5e Merge branch 'develop' into feature/importer_to_vue 2022-03-10 15:27:11 +01:00
smilerz
486d197854 allow marking recipe as food 2022-03-06 23:12:59 -06:00
vabene1111
22ca482458 super very basics of manual mapping page working 2022-03-05 16:02:42 +01:00
vabene1111
2565ab30a4 lots of improvements and bookmarklet import working again 2022-03-05 15:16:58 +01:00
vabene1111
1caabef56a step functions 2022-03-04 16:54:59 +01:00
vabene1111
edde8c8b8f Merge branch 'develop' 2022-03-04 15:54:16 +01:00
vabene1111
6d8fe3c162 wip 2022-03-04 15:54:11 +01:00
vabene1111
bdccdf0893 Merge branch 'develop' into feature/importer_to_vue
# Conflicts:
#	cookbook/helper/recipe_url_import.py
2022-03-04 14:33:59 +01:00
dependabot[bot]
f0927bf065 Bump django-allauth from 0.47.0 to 0.49.0
Bumps [django-allauth](https://github.com/pennersr/django-allauth) from 0.47.0 to 0.49.0.
- [Release notes](https://github.com/pennersr/django-allauth/releases)
- [Changelog](https://github.com/pennersr/django-allauth/blob/master/ChangeLog.rst)
- [Commits](https://github.com/pennersr/django-allauth/compare/0.47.0...0.49.0)

---
updated-dependencies:
- dependency-name: django-allauth
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-04 13:30:25 +00:00
dependabot[bot]
ed1fb9a95e Bump pytest from 6.2.5 to 7.0.1
Bumps [pytest](https://github.com/pytest-dev/pytest) from 6.2.5 to 7.0.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/6.2.5...7.0.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-04 13:30:07 +00:00
vabene1111
030d977a8c Merge branch 'develop' 2022-03-02 13:11:57 +01:00
dependabot[bot]
5f25df7d19 Bump workbox-routing from 6.4.2 to 6.5.0 in /vue
Bumps [workbox-routing](https://github.com/googlechrome/workbox) from 6.4.2 to 6.5.0.
- [Release notes](https://github.com/googlechrome/workbox/releases)
- [Commits](https://github.com/googlechrome/workbox/compare/v6.4.2...v6.5.0)

---
updated-dependencies:
- dependency-name: workbox-routing
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-01 00:12:01 +00:00
dependabot[bot]
e55f78c767 Bump @vue/cli-plugin-pwa from 4.5.15 to 5.0.1 in /vue
Bumps [@vue/cli-plugin-pwa](https://github.com/vuejs/vue-cli/tree/HEAD/packages/@vue/cli-plugin-pwa) from 4.5.15 to 5.0.1.
- [Release notes](https://github.com/vuejs/vue-cli/releases)
- [Changelog](https://github.com/vuejs/vue-cli/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/vuejs/vue-cli/commits/v5.0.1/packages/@vue/cli-plugin-pwa)

---
updated-dependencies:
- dependency-name: "@vue/cli-plugin-pwa"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-01 00:10:36 +00:00
dependabot[bot]
6d257d2455 Bump @popperjs/core from 2.11.0 to 2.11.2 in /vue
Bumps [@popperjs/core](https://github.com/popperjs/popper-core) from 2.11.0 to 2.11.2.
- [Release notes](https://github.com/popperjs/popper-core/releases)
- [Commits](https://github.com/popperjs/popper-core/compare/v2.11.0...v2.11.2)

---
updated-dependencies:
- dependency-name: "@popperjs/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-01 00:09:44 +00:00
dependabot[bot]
693a5214ef Bump lxml from 4.7.1 to 4.8.0
Bumps [lxml](https://github.com/lxml/lxml) from 4.7.1 to 4.8.0.
- [Release notes](https://github.com/lxml/lxml/releases)
- [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt)
- [Commits](https://github.com/lxml/lxml/compare/lxml-4.7.1...lxml-4.8.0)

---
updated-dependencies:
- dependency-name: lxml
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-01 00:03:39 +00:00
vabene1111
52c16ab7dd all types bascially working (lacking bookmark) 2022-02-22 17:00:30 +01:00
vabene1111
0d98c77301 some small ui stuff 2022-02-21 18:29:23 +01:00
vabene1111
e04d672750 import with image working 2022-02-21 15:59:30 +01:00
vabene1111
c06c511dc9 changed unit default in ingredient parser to none
because empty name units are not accepted by unit serializer but null values can be handled by the ingredient serializer (as a unit can be null)
2022-02-21 15:35:36 +01:00
vabene1111
c8fc67fa2b changed source import to match field structure of recipe model
first imports working
2022-02-19 17:54:00 +01:00
vabene1111
89348f69f1 basics of new import page 2022-02-19 16:55:17 +01:00
vabene1111
55b035eaaa first boilerplate for new import view 2022-02-19 09:34:42 +01:00
186 changed files with 21232 additions and 17657 deletions

View File

@@ -86,8 +86,10 @@ GUNICORN_MEDIA=0
# EMAIL_HOST_PASSWORD=
# EMAIL_USE_TLS=0
# EMAIL_USE_SSL=0
# DEFAULT_FROM_EMAIL= # email sender address (default 'webmaster@localhost')
# ACCOUNT_EMAIL_SUBJECT_PREFIX= # prefix used for account related emails (default "[Tandoor Recipes] ")
# email sender address (default 'webmaster@localhost')
# DEFAULT_FROM_EMAIL=
# prefix used for account related emails (default "[Tandoor Recipes] ")
# ACCOUNT_EMAIL_SUBJECT_PREFIX=
# allow authentication via 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/

View File

@@ -38,6 +38,7 @@ jobs:
with:
publish: true
imageName: vabene1111/recipes
platform: linux/amd64,linux/arm64
tag: latest
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -40,6 +40,7 @@ jobs:
with:
publish: true
imageName: vabene1111/recipes
platform: linux/amd64,linux/arm64
tag: ${{ steps.get_version.outputs.VERSION }}
dockerUser: ${{ secrets.DOCKER_USERNAME }}
dockerPassword: ${{ secrets.DOCKER_PASSWORD }}

View File

@@ -3,7 +3,6 @@ on:
push:
branches:
- master
- develop
jobs:
deploy:

View File

@@ -1,7 +1,10 @@
# Contributers
Many thanks to everyone who contributed to this project! If you add something or help out feel free to add yourself
to this list.
## Code/Features
Please have a look at the [list of pull requests](https://github.com/vabene1111/recipes/pulls) for
a complete list of contributions.
Below are some of the larger contributions made yet.
@@ -20,46 +23,61 @@ Below are some of the larger contributions made yet.
## Translations
### Catalan
### Catalan
[Rubenix](https://www.transifex.com/user/profile/rubenix/)
### Dutch
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
[ikbenfrank](https://www.transifex.com/user/profile/ikbenfrank/)
[kampsj](https://www.transifex.com/user/profile/kampsj/)
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
[ikbenfrank](https://www.transifex.com/user/profile/ikbenfrank/)
[kampsj](https://www.transifex.com/user/profile/kampsj/)
### French
[jt117](https://www.transifex.com/user/profile/jt117/)
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
[agaume](https://www.transifex.com/user/profile/agaume/)
[jt117](https://www.transifex.com/user/profile/jt117/)
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
[agaume](https://www.transifex.com/user/profile/agaume/)
### German
[eTaurus](https://www.transifex.com/user/profile/eTaurus/)
[l0c4lh057](https://www.transifex.com/user/profile/l0c4lh057/)
[hyperbit00]
[hyperbit00](https://github.com/hyperbit00)
### Hungarian
[igazka](https://www.transifex.com/user/profile/igazka/)
### Italian
[SK3LA](https://www.transifex.com/user/profile/SK3LA/)
[auanasgheps](https://www.transifex.com/user/profile/auanasgheps/)
[SK3LA](https://www.transifex.com/user/profile/SK3LA/)
[auanasgheps](https://www.transifex.com/user/profile/auanasgheps/)
### Latvian
[melkypie](https://github.com/melkypie)
### Portuguese
[hds](https://www.transifex.com/user/profile/hds/)
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
[stormsz](https://www.transifex.com/user/profile/stormsz/)
[hds](https://www.transifex.com/user/profile/hds/)
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
[stormsz](https://www.transifex.com/user/profile/stormsz/)
### Russian
[amillerr](https://github.com/amillerr)
### Spanish
[albertocp](https://www.transifex.com/user/profile/albertocp/)
[alfa5](https://www.transifex.com/user/profile/alfa5/)
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
[sergio.laya](https://www.transifex.com/user/profile/sergio.laya/)
[albertocp](https://www.transifex.com/user/profile/albertocp/)
[alfa5](https://www.transifex.com/user/profile/alfa5/)
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
[sergio.laya](https://www.transifex.com/user/profile/sergio.laya/)
### Swedish
[makanz](https://github.com/makanz)
### Turkish

View File

@@ -34,7 +34,7 @@ if [ "${DB_ENGINE}" != 'django.db.backends.sqlite3' ]; then
display_warning "The environment variable 'POSTGRES_PASSWORD' is not set but REQUIRED for running Tandoor!"
fi
while pg_isready --host=${POSTGRES_HOST} --port=${POSTGRES_PORT} -q; status=$?; attempt=$((attempt+1)); [ $status -ne 0 ] && [ $attempt -le $max_attempts ]; do
while pg_isready --host=${POSTGRES_HOST} --port=${POSTGRES_PORT} --user=${POSTGRES_USER} -q; status=$?; attempt=$((attempt+1)); [ $status -ne 0 ] && [ $attempt -le $max_attempts ]; do
sleep 5
done
fi

View File

@@ -237,7 +237,7 @@ admin.site.register(Ingredient, IngredientAdmin)
class CommentAdmin(admin.ModelAdmin):
list_display = ('recipe', 'name', 'created_at')
search_fields = ('text', 'user__username')
search_fields = ('text', 'created_by__username')
date_hierarchy = 'created_at'
@staticmethod

View File

@@ -6,7 +6,7 @@ from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from cookbook.forms import MultiSelectWidget
from cookbook.models import Food, Keyword, Recipe, ShoppingList
from cookbook.models import Food, Keyword, Recipe
with scopes_disabled():
class RecipeFilter(django_filters.FilterSet):
@@ -60,22 +60,3 @@ with scopes_disabled():
class Meta:
model = Recipe
fields = ['name', 'keywords', 'foods', 'internal']
# class FoodFilter(django_filters.FilterSet):
# name = django_filters.CharFilter(lookup_expr='icontains')
# class Meta:
# model = Food
# fields = ['name']
class ShoppingListFilter(django_filters.FilterSet):
def __init__(self, data=None, *args, **kwargs):
if data is not None:
data = data.copy()
data.setdefault("finished", False)
super().__init__(data, *args, **kwargs)
class Meta:
model = ShoppingList
fields = ['finished']

View File

@@ -49,7 +49,7 @@ class UserPreferenceForm(forms.ModelForm):
fields = (
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
'plan_share', 'ingredient_decimals', 'comments',
'plan_share', 'ingredient_decimals', 'comments', 'left_handed',
)
labels = {
@@ -65,7 +65,8 @@ class UserPreferenceForm(forms.ModelForm):
'plan_share': _('Plan sharing'),
'ingredient_decimals': _('Ingredient decimal places'),
'shopping_auto_sync': _('Shopping list auto sync period'),
'comments': _('Comments')
'comments': _('Comments'),
'left_handed': _('Left-handed mode')
}
help_texts = {
@@ -89,6 +90,7 @@ class UserPreferenceForm(forms.ModelForm):
'sticky_navbar': _('Makes the navbar stick to the top of the page.'), # noqa: E501
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
'left_handed': _('Will optimize the UI for use with your left hand.')
}
widgets = {
@@ -153,11 +155,13 @@ class ImportExportBase(forms.Form):
RECIPESAGE = 'RECIPESAGE'
DOMESTICA = 'DOMESTICA'
MEALMASTER = 'MEALMASTER'
MELARECIPES = 'MELARECIPES'
REZKONV = 'REZKONV'
OPENEATS = 'OPENEATS'
PLANTOEAT = 'PLANTOEAT'
COOKBOOKAPP = 'COOKBOOKAPP'
COPYMETHAT = 'COPYMETHAT'
COOKMATE = 'COOKMATE'
PDF = 'PDF'
type = forms.ChoiceField(choices=(
@@ -165,7 +169,8 @@ class ImportExportBase(forms.Form):
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'),
(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'),
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
(COOKMATE, 'Cookmate')
))

View File

@@ -38,10 +38,12 @@ def get_filetype(name):
# TODO this whole file needs proper documentation, refactoring, and testing
# TODO also add env variable to define which images sizes should be compressed
def handle_image(request, image_object, filetype='.jpeg'):
# filetype argument can not be optional, otherwise this function will treat all images as if they were a jpeg
# Because it's no longer optional, no reason to return it
def handle_image(request, image_object, filetype):
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
if filetype == '.jpeg' or filetype == '.jpg':
return rescale_image_jpeg(image_object), filetype
return rescale_image_jpeg(image_object)
if filetype == '.png':
return rescale_image_png(image_object), filetype
return image_object, filetype
return rescale_image_png(image_object)
return image_object

View File

@@ -4,7 +4,7 @@ import unicodedata
from django.core.cache import caches
from cookbook.models import Unit, Food, Automation
from cookbook.models import Unit, Food, Automation, Ingredient
class IngredientParser:
@@ -46,7 +46,7 @@ class IngredientParser:
def apply_food_automation(self, food):
"""
Apply food alias automations to passed foood
Apply food alias automations to passed food
:param food: unit as string
:return: food as string (possibly changed by automation)
"""
@@ -124,7 +124,7 @@ class IngredientParser:
def parse_amount(self, x):
amount = 0
unit = ''
unit = None
note = ''
did_check_frac = False
@@ -155,33 +155,36 @@ class IngredientParser:
except ValueError:
unit = x[end:]
if unit.startswith('(') or unit.startswith('-'): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = ''
if unit is not None and unit.strip() == '':
unit = None
if unit is not None and (unit.startswith('(') or unit.startswith('-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = None
note = x
return amount, unit, note
def parse_ingredient_with_comma(self, tokens):
ingredient = ''
def parse_food_with_comma(self, tokens):
food = ''
note = ''
start = 0
# search for first occurrence of an argument ending in a comma
while start < len(tokens) and not tokens[start].endswith(','):
start += 1
if start == len(tokens):
# no token ending in a comma found -> use everything as ingredient
ingredient = ' '.join(tokens)
# no token ending in a comma found -> use everything as food
food = ' '.join(tokens)
else:
ingredient = ' '.join(tokens[:start + 1])[:-1]
food = ' '.join(tokens[:start + 1])[:-1]
note = ' '.join(tokens[start + 1:])
return ingredient, note
return food, note
def parse_ingredient(self, tokens):
ingredient = ''
def parse_food(self, tokens):
food = ''
note = ''
if tokens[-1].endswith(')'):
# Check if the matching opening bracket is in the same token
if (not tokens[-1].startswith('(')) and ('(' in tokens[-1]):
return self.parse_ingredient_with_comma(tokens)
return self.parse_food_with_comma(tokens)
# last argument ends with closing bracket -> look for opening bracket
start = len(tokens) - 1
while not tokens[start].startswith('(') and not start == 0:
@@ -191,33 +194,48 @@ class IngredientParser:
raise ValueError
elif start < 0:
# no opening bracket anywhere -> just ignore the last bracket
ingredient, note = self.parse_ingredient_with_comma(tokens)
food, note = self.parse_food_with_comma(tokens)
else:
# opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
# opening bracket found -> split in food and note, remove brackets from note # noqa: E501
note = ' '.join(tokens[start:])[1:-1]
ingredient = ' '.join(tokens[:start])
food = ' '.join(tokens[:start])
else:
ingredient, note = self.parse_ingredient_with_comma(tokens)
return ingredient, note
food, note = self.parse_food_with_comma(tokens)
return food, note
def parse(self, x):
def parse(self, ingredient):
"""
Main parsing function, takes an ingredient string (e.g. '1 l Water') and extracts amount, unit, food, ...
:param ingredient: string ingredient
:return: amount, unit (can be None), food, note (can be empty)
"""
# initialize default values
amount = 0
unit = ''
ingredient = ''
unit = None
food = ''
note = ''
unit_note = ''
if len(ingredient) == 0:
raise ValueError('string to parse cannot be empty')
# 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)
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()], '')
# if the string contains parenthesis early on remove it and place it at the end
# because its likely some kind of note
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', x):
match = re.search('\((.[^\(])+\)', x)
x = x[:match.start()] + x[match.end():] + ' ' + x[match.start():match.end()]
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', ingredient):
match = re.search('\((.[^\(])+\)', ingredient)
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
tokens = x.split()
tokens = ingredient.split() # split at each space into tokens
if len(tokens) == 1:
# there only is one argument, that must be the ingredient
ingredient = tokens[0]
# there only is one argument, that must be the food
food = tokens[0]
else:
try:
# try to parse first argument as amount
@@ -227,48 +245,62 @@ class IngredientParser:
# a fraction for the amount
if len(tokens) > 2:
try:
if not unit == '':
if unit is not None:
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
amount += self.parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
# try to use third argument as unit and everything else as food, use everything as food if it fails
try:
ingredient, note = self.parse_ingredient(tokens[3:])
food, note = self.parse_food(tokens[3:])
unit = tokens[2]
except ValueError:
ingredient, note = self.parse_ingredient(tokens[2:])
food, note = self.parse_food(tokens[2:])
else:
ingredient, note = self.parse_ingredient(tokens[2:])
food, note = self.parse_food(tokens[2:])
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
# try to use second argument as unit and everything else as food, use everything as food if it fails
try:
ingredient, note = self.parse_ingredient(tokens[2:])
if unit == '':
food, note = self.parse_food(tokens[2:])
if unit is None:
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
ingredient, note = self.parse_ingredient(tokens[1:])
food, note = self.parse_food(tokens[1:])
else:
ingredient, note = self.parse_ingredient(tokens[1:])
food, note = self.parse_food(tokens[1:])
else:
# only two arguments, first one is the amount
# which means this is the ingredient
ingredient = tokens[1]
# which means this is the food
food = tokens[1]
except ValueError:
try:
# can't parse first argument as amount
# -> no unit -> parse everything as ingredient
ingredient, note = self.parse_ingredient(tokens)
# -> no unit -> parse everything as food
food, note = self.parse_food(tokens)
except ValueError:
ingredient = ' '.join(tokens[1:])
food = ' '.join(tokens[1:])
if unit_note not in note:
note += ' ' + unit_note
return amount, self.apply_unit_automation(unit.strip()), self.apply_food_automation(ingredient.strip()), note.strip()
if unit:
unit = self.apply_unit_automation(unit.strip())
food = self.apply_food_automation(food.strip())
if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long
# try splitting it at a space and taking only the first arg
if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length:
note = ' '.join(food.split()[1:]) + ' ' + note
food = food.split()[0]
else:
note = food + ' ' + note
food = food[:Food._meta.get_field('name').max_length]
return amount, unit, food, note[:Ingredient._meta.get_field('note').max_length].strip()

View File

@@ -1,6 +1,3 @@
"""
Source: https://djangosnippets.org/snippets/1703/
"""
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
@@ -12,7 +9,7 @@ from django.utils.translation import gettext as _
from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink
from cookbook.models import ShareLink, Recipe, UserPreference
def get_allowed_groups(groups_required):
@@ -262,3 +259,38 @@ class CustomIsShare(permissions.BasePermission):
if share:
return share_link_valid(obj, share)
return False
def above_space_limit(space): # TODO add file storage limit
"""
Test if the space has reached any limit (e.g. max recipes, users, ..)
:param space: Space to test for limits
:return: Tuple (True if above or equal any limit else false, message)
"""
r_limit, r_msg = above_space_recipe_limit(space)
u_limit, u_msg = above_space_user_limit(space)
return r_limit or u_limit, (r_msg + ' ' + u_msg).strip()
def above_space_recipe_limit(space):
"""
Test if a space has reached its recipe limit
:param space: Space to test for limits
:return: Tuple (True if above or equal limit else false, message)
"""
limit = space.max_recipes != 0 and Recipe.objects.filter(space=space).count() >= space.max_recipes
if limit:
return True, _('You have reached the maximum number of recipes for your space.')
return False, ''
def above_space_user_limit(space):
"""
Test if a space has reached its user limit
:param space: Space to test for limits
:return: Tuple (True if above or equal limit else false, message)
"""
limit = space.max_users != 0 and UserPreference.objects.filter(space=space).count() > space.max_users
if limit:
return True, _('You have more users than allowed in your space.')
return False, ''

View File

@@ -58,18 +58,6 @@ def get_recipe_from_source(text, url, request):
})
return kid_list
recipe_json = {
'name': '',
'url': '',
'description': '',
'image': '',
'keywords': [],
'recipeIngredient': [],
'recipeInstructions': '',
'servings': '',
'prepTime': '',
'cookTime': ''
}
recipe_tree = []
parse_list = []
html_data = []

View File

@@ -14,6 +14,9 @@ from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Keyword
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
def get_from_scraper(scrape, request):
# converting the scrape_me object to the existing json format based on ld+json
recipe_json = {}
@@ -28,7 +31,7 @@ def get_from_scraper(scrape, request):
recipe_json['name'] = ''
try:
description = scrape.description() or None
description = scrape.description() or None
except Exception:
description = None
if not description:
@@ -37,7 +40,7 @@ def get_from_scraper(scrape, request):
except Exception:
description = ''
recipe_json['description'] = parse_description(description)
recipe_json['internal'] = True
try:
servings = scrape.yields() or None
@@ -48,34 +51,31 @@ def get_from_scraper(scrape, request):
servings = scrape.schema.data.get('recipeYield') or 1
except Exception:
servings = 1
if type(servings) != int:
try:
servings = int(re.findall(r'\b\d+\b', servings)[0])
except Exception:
servings = 1
recipe_json['servings'] = max(servings, 1)
recipe_json['servings'] = parse_servings(servings)
recipe_json['servings_text'] = parse_servings_text(servings)
try:
recipe_json['prepTime'] = get_minutes(scrape.prep_time()) or 0
recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0
except Exception:
try:
recipe_json['prepTime'] = get_minutes(scrape.schema.data.get("prepTime")) or 0
recipe_json['working_time'] = get_minutes(scrape.schema.data.get("prepTime")) or 0
except Exception:
recipe_json['prepTime'] = 0
recipe_json['working_time'] = 0
try:
recipe_json['cookTime'] = get_minutes(scrape.cook_time()) or 0
recipe_json['waiting_time'] = get_minutes(scrape.cook_time()) or 0
except Exception:
try:
recipe_json['cookTime'] = get_minutes(scrape.schema.data.get("cookTime")) or 0
recipe_json['waiting_time'] = get_minutes(scrape.schema.data.get("cookTime")) or 0
except Exception:
recipe_json['cookTime'] = 0
recipe_json['waiting_time'] = 0
if recipe_json['cookTime'] + recipe_json['prepTime'] == 0:
if recipe_json['working_time'] + recipe_json['waiting_time'] == 0:
try:
recipe_json['prepTime'] = get_minutes(scrape.total_time()) or 0
recipe_json['working_time'] = get_minutes(scrape.total_time()) or 0
except Exception:
try:
recipe_json['prepTime'] = get_minutes(scrape.schema.data.get("totalTime")) or 0
recipe_json['working_time'] = get_minutes(scrape.schema.data.get("totalTime")) or 0
except Exception:
pass
@@ -113,6 +113,14 @@ def get_from_scraper(scrape, request):
keywords += listify_keywords(scrape.schema.data.get("recipeCuisine"))
except Exception:
pass
if source_url := scrape.url:
recipe_json['source_url'] = source_url
try:
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
except Exception:
pass
try:
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space)
except AttributeError:
@@ -120,54 +128,49 @@ def get_from_scraper(scrape, request):
ingredient_parser = IngredientParser(request, True)
ingredients = []
recipe_json['steps'] = []
for i in parse_instructions(scrape.instructions()):
recipe_json['steps'].append({'instruction': i, 'ingredients': [], })
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']
else:
recipe_json['description'] = parse_description(description)[:512]
try:
for x in scrape.ingredients():
try:
amount, unit, ingredient, note = ingredient_parser.parse(x)
ingredients.append(
{
'amount': amount,
'unit': {
'text': unit,
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': ingredient,
'id': random.randrange(10000, 99999)
},
'note': note,
'original_text': 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:
ingredients.append(
recipe_json['steps'][0]['ingredients'].append(
{
'amount': 0,
'unit': {
'text': '',
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': x,
'id': random.randrange(10000, 99999)
'unit': None,
'food': {
'name': x,
},
'note': '',
'original_text': x
}
)
recipe_json['recipeIngredient'] = ingredients
except Exception:
recipe_json['recipeIngredient'] = ingredients
pass
try:
recipe_json['recipeInstructions'] = parse_instructions(scrape.instructions())
except Exception:
recipe_json['recipeInstructions'] = ""
if scrape.canonical_url():
recipe_json['url'] = scrape.canonical_url()
recipe_json['recipeInstructions'] += "\n\n" + _("Imported from") + ": " + scrape.canonical_url()
return recipe_json
@@ -180,102 +183,46 @@ def parse_name(name):
return normalize_string(name)
def parse_ingredients(ingredients):
# some pages have comma separated ingredients in a single array entry
try:
if type(ingredients[0]) == dict:
return ingredients
except (KeyError, IndexError):
pass
if (len(ingredients) == 1 and type(ingredients) == list):
ingredients = ingredients[0].split(',')
elif type(ingredients) == str:
ingredients = ingredients.split(',')
for x in ingredients:
if '\n' in x:
ingredients.remove(x)
for i in x.split('\n'):
ingredients.insert(0, i)
ingredient_list = []
for x in ingredients:
if x.replace(' ', '') != '':
x = x.replace('&frac12;', "0.5").replace('&frac14;', "0.25").replace('&frac34;', "0.75")
try:
amount, unit, ingredient, note = parse_single_ingredient(x)
if ingredient:
ingredient_list.append(
{
'amount': amount,
'unit': {
'text': unit,
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': ingredient,
'id': random.randrange(10000, 99999)
},
'note': note,
'original_text': x
}
)
except Exception:
ingredient_list.append(
{
'amount': 0,
'unit': {
'text': '',
'id': random.randrange(10000, 99999)
},
'ingredient': {
'text': x,
'id': random.randrange(10000, 99999)
},
'note': '',
'original_text': x
}
)
ingredients = ingredient_list
else:
ingredients = []
return ingredients
def parse_description(description):
return normalize_string(description)
def parse_instructions(instructions):
instruction_text = ''
# flatten instructions if they are in a list
if type(instructions) == list:
for i in instructions:
if type(i) == str:
instruction_text += i
else:
if 'text' in i:
instruction_text += i['text'] + '\n\n'
elif 'itemListElement' in i:
for ile in i['itemListElement']:
if type(ile) == str:
instruction_text += ile + '\n\n'
elif 'text' in ile:
instruction_text += ile['text'] + '\n\n'
else:
instruction_text += str(i)
instructions = instruction_text
normalized_string = normalize_string(instructions)
def clean_instruction_string(instruction):
normalized_string = normalize_string(instruction)
normalized_string = normalized_string.replace('\n', ' \n')
normalized_string = normalized_string.replace(' \n \n', '\n\n')
return normalized_string
def parse_instructions(instructions):
"""
Convert arbitrary instructions object from website import and turn it into a flat list of strings
:param instructions: any instructions object from import
:return: list of strings (from one to many elements depending on website)
"""
instruction_list = []
if type(instructions) == list:
for i in instructions:
if type(i) == str:
instruction_list.append(clean_instruction_string(i))
else:
if 'text' in i:
instruction_list.append(clean_instruction_string(i['text']))
elif 'itemListElement' in i:
for ile in i['itemListElement']:
if type(ile) == str:
instruction_list.append(clean_instruction_string(ile))
elif 'text' in ile:
instruction_list.append(clean_instruction_string(ile['text']))
else:
instruction_list.append(clean_instruction_string(str(i)))
else:
instruction_list.append(clean_instruction_string(instructions))
return instruction_list
def parse_image(image):
# check if list of images is returned, take first if so
if not image:
@@ -310,40 +257,31 @@ def parse_servings(servings):
return servings
def parse_cooktime(cooktime):
if type(cooktime) not in [int, float]:
def parse_servings_text(servings):
if type(servings) == str:
try:
cooktime = float(re.search(r'\d+', cooktime).group())
servings = re.sub("\d+", '', servings).strip()
except Exception:
servings = ''
return servings
def parse_time(recipe_time):
if type(recipe_time) not in [int, float]:
try:
recipe_time = float(re.search(r'\d+', recipe_time).group())
except (ValueError, AttributeError):
try:
cooktime = round(iso_parse_duration(cooktime).seconds / 60)
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
except ISO8601Error:
try:
if (type(cooktime) == list and len(cooktime) > 0):
cooktime = cooktime[0]
cooktime = round(parse_duration(cooktime).seconds / 60)
if (type(recipe_time) == list and len(recipe_time) > 0):
recipe_time = recipe_time[0]
recipe_time = round(parse_duration(recipe_time).seconds / 60)
except AttributeError:
cooktime = 0
recipe_time = 0
return cooktime
def parse_preptime(preptime):
if type(preptime) not in [int, float]:
try:
preptime = float(re.search(r'\d+', preptime).group())
except ValueError:
try:
preptime = round(iso_parse_duration(preptime).seconds / 60)
except ISO8601Error:
try:
if (type(preptime) == list and len(preptime) > 0):
preptime = preptime[0]
preptime = round(parse_duration(preptime).seconds / 60)
except AttributeError:
preptime = 0
return preptime
return recipe_time
def parse_keywords(keyword_json, space):
@@ -353,9 +291,9 @@ def parse_keywords(keyword_json, space):
kw = normalize_string(kw)
if len(kw) != 0:
if k := Keyword.objects.filter(name=kw, space=space).first():
keywords.append({'id': str(k.id), 'text': str(k.name)})
keywords.append({'label': str(k), 'name': k.name, 'id': k.id})
else:
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw})
keywords.append({'label': kw, 'name': kw})
return keywords

View File

@@ -0,0 +1,77 @@
import base64
import json
from io import BytesIO
from gettext import gettext as _
import requests
from lxml import etree
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_time, parse_servings_text
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step
class Cookmate(Integration):
def import_file_name_filter(self, zip_info_object):
return zip_info_object.filename.endswith('.xml')
def get_files_from_recipes(self, recipes, el, cookie):
raise NotImplementedError('Method not implemented in storage integration')
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('preptime') is not None:
recipe.working_time = parse_time(recipe_xml.find('preptime').text.strip())
if recipe_xml.find('cooktime') is not None:
recipe.waiting_time = parse_time(recipe_xml.find('cooktime').text.strip())
if recipe_xml.find('quantity') is not None:
recipe.servings = parse_servings(recipe_xml.find('quantity').text.strip())
recipe.servings_text = parse_servings_text(recipe_xml.find('quantity').text.strip())
if recipe_xml.find('url') is not None:
recipe.source_url = recipe_xml.find('url').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('recipetext').getchildren():
step = Step.objects.create(
instruction=step.text.strip(), space=self.request.space,
)
recipe.steps.add(step)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_xml.find('ingredient').getchildren():
if ingredient.text.strip() != '':
amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip())
f = ingredient_parser.get_food(food)
u = ingredient_parser.get_unit(unit)
recipe.steps.first().ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, original_text=ingredient.text.strip(), space=self.request.space,
))
if recipe_xml.find('imageurl') is not None:
try:
response = requests.get(recipe_xml.find('imageurl').text.strip())
self.import_recipe_image(recipe, BytesIO(response.content))
except Exception as e:
print('failed to import image ', str(e))
recipe.save()
return recipe
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')

View File

@@ -32,7 +32,14 @@ class CopyMeThat(Integration):
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
recipe.save()
recipe.description = (file.find("div ", {"id": "description"}).text.strip())[:512]
except AttributeError:
pass
try:
if len(file.find("span", {"id": "starred"}).text.strip()) > 0:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('Favorite'))[0])
except AttributeError:
pass

View File

@@ -5,6 +5,8 @@ import traceback
import uuid
from io import BytesIO, StringIO
from zipfile import BadZipFile, ZipFile
import lxml
from django.core.cache import cache
import datetime
@@ -16,6 +18,7 @@ from django.http import HttpResponse
from django.utils.formats import date_format
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
@@ -144,7 +147,7 @@ class Integration:
il.imported_recipes += 1
il.save()
import_zip.close()
elif '.zip' in f['name'] or '.paprikarecipes' in f['name']:
elif '.zip' in f['name'] or '.paprikarecipes' in f['name'] or '.mcb' in f['name']:
import_zip = ZipFile(f['file'])
file_list = []
for z in import_zip.filelist:
@@ -157,9 +160,16 @@ class Integration:
file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html')))
il.total_recipes += len(file_list)
if isinstance(self, cookbook.integration.cookmate.Cookmate):
new_file_list = []
for file in file_list:
new_file_list += etree.parse(BytesIO(import_zip.read(file.filename))).getroot().getchildren()
il.total_recipes = len(new_file_list)
file_list = new_file_list
for z in file_list:
try:
if isinstance(z, Tag):
if not hasattr(z, 'filename'):
recipe = self.get_recipe_from_file(z)
else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
@@ -172,7 +182,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']:
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']:
data_list = self.split_recipe_file(f['file'])
il.total_recipes += len(data_list)
for d in data_list:
@@ -243,7 +253,7 @@ class Integration:
:param image_file: ByteIO stream containing the image
:param filetype: type of file to write bytes to, default to .jpeg if unknown
"""
recipe.image = File(handle_image(self.request, File(image_file, name='image'), filetype=filetype)[0], name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
recipe.image = File(handle_image(self.request, File(image_file, name='image'), filetype=filetype), name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
recipe.save()
def get_recipe_from_file(self, file):

View File

@@ -0,0 +1,83 @@
import base64
import json
from io import BytesIO
from gettext import gettext as _
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_time
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step
class MelaRecipes(Integration):
def split_recipe_file(self, file):
return [json.loads(file.getvalue().decode("utf-8"))]
def get_files_from_recipes(self, recipes, el, cookie):
raise NotImplementedError('Method not implemented in storage integration')
def get_recipe_from_file(self, file):
recipe_json = file
recipe = Recipe.objects.create(
name=recipe_json['title'].strip(),
created_by=self.request.user, internal=True, space=self.request.space)
if 'yield' in recipe_json:
recipe.servings = parse_servings(recipe_json['yield'])
if 'cookTime' in recipe_json:
recipe.waiting_time = parse_time(recipe_json['cookTime'])
if 'prepTime' in recipe_json:
recipe.working_time = parse_time(recipe_json['prepTime'])
if 'favorite' in recipe_json and recipe_json['favorite']:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=_('Favorite'))[0])
if 'categories' in recipe_json:
try:
for x in recipe_json['categories']:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0])
except Exception:
pass
instruction = ''
if 'text' in recipe_json:
instruction += f'*{recipe_json["text"].strip()}* \n'
if 'instructions' in recipe_json:
instruction += recipe_json["instructions"].strip() + ' \n'
if 'notes' in recipe_json:
instruction += recipe_json["notes"].strip() + ' \n'
if 'link' in recipe_json:
recipe.source_url = recipe_json['link']
step = Step.objects.create(
instruction=instruction, space=self.request.space,
)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_json['ingredients'].split('\n'):
if ingredient.strip() != '':
amount, unit, food, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(food)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,
))
recipe.steps.add(step)
if recipe_json.get("images", None):
try:
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['images'][0])), filetype='.jpeg')
except Exception:
pass
return recipe
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')

View File

@@ -31,6 +31,9 @@ class NextcloudCookbook(Integration):
except Exception:
pass
if 'url' in recipe_json:
recipe.source_url = recipe_json['url'].strip()
if 'recipeCategory' in recipe_json:
try:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=recipe_json['recipeCategory'])[0])
@@ -40,7 +43,8 @@ class NextcloudCookbook(Integration):
if 'keywords' in recipe_json:
try:
for x in recipe_json['keywords'].split(','):
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0])
if x.strip() != '':
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0])
except Exception:
pass

View File

@@ -6,6 +6,7 @@ from gettext import gettext as _
from io import BytesIO
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
from cookbook.integration.integration import Integration
from cookbook.models import Ingredient, Keyword, Recipe, Step
@@ -26,10 +27,9 @@ class Paprika(Integration):
recipe.description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
try:
if re.match(r'([0-9])+\s(.)*', recipe_json['servings']):
s = recipe_json['servings'].split(' ')
recipe.servings = s[0]
recipe.servings_text = s[1]
if 'servings' in recipe_json['servings']:
recipe.servings = parse_servings(recipe_json['servings'])
recipe.servings_text = parse_servings_text(recipe_json['servings'])
if len(recipe_json['cook_time'].strip()) > 0:
recipe.waiting_time = re.findall(r'\d+', recipe_json['cook_time'])[0]

View File

@@ -77,14 +77,13 @@ class RecipeSage(Integration):
}
for s in recipe.steps.all():
if s.type != Step.TIME:
data['recipeInstructions'].append({
'@type': 'HowToStep',
'text': s.instruction
})
data['recipeInstructions'].append({
'@type': 'HowToStep',
'text': s.instruction
})
for i in s.ingredients.all():
data['recipeIngredient'].append(f'{float(i.amount)} {i.unit} {i.food}')
for i in s.ingredients.all():
data['recipeIngredient'].append(f'{float(i.amount)} {i.unit} {i.food}')
return data

View File

@@ -12,33 +12,33 @@ class RezKonv(Integration):
ingredients = []
directions = []
for line in file.replace('\r', '').split('\n'):
for line in file.replace('\r', '').replace('\n\n', '\n').split('\n'):
if 'Titel:' in line:
title = line.replace('Titel:', '').strip()
if 'Kategorien:' in line:
tags = line.replace('Kategorien:', '').strip()
if ingredient_mode and ('quelle' in line.lower() or 'source' in line.lower()):
if ingredient_mode and (
'quelle' in line.lower() or 'source' in line.lower() or (line == '' and len(ingredients) > 0)):
ingredient_mode = False
direction_mode = True
if ingredient_mode:
if line != '' and '===' not in line and 'Zubereitung' not in line:
ingredients.append(line.strip())
if direction_mode:
if line.strip() != '' and line.strip() != '=====':
directions.append(line.strip())
if 'Zutaten:' in line:
if 'Zutaten:' in line or 'Ingredients' in line or 'Menge:' in line:
ingredient_mode = True
if 'Zubereitung:' in line:
ingredient_mode = False
direction_mode = True
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space)
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True,
space=self.request.space)
for k in tags.split(','):
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
recipe.keywords.add(keyword)
step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
instruction=' \n'.join(directions) + '\n\n', space=self.request.space,
)
ingredient_parser = IngredientParser(self.request, True)
@@ -60,7 +60,8 @@ class RezKonv(Integration):
def split_recipe_file(self, file):
recipe_list = []
current_recipe = ''
encoding_list = ['windows-1250', 'latin-1'] #TODO build algorithm to try trough encodings and fail if none work, use for all importers
encoding_list = ['windows-1250',
'latin-1'] # TODO build algorithm to try trough encodings and fail if none work, use for all importers
encoding = 'windows-1250'
for fl in file.readlines():
try:

View File

@@ -71,11 +71,10 @@ class Saffron(Integration):
recipeInstructions = []
recipeIngredient = []
for s in recipe.steps.all():
if s.type != Step.TIME:
recipeInstructions.append(s.instruction)
recipeInstructions.append(s.instruction)
for i in s.ingredients.all():
recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}')
for i in s.ingredients.all():
recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}')
data += "Ingredients: \n"
for ingredient in recipeIngredient:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-11-04 12:31+0100\n"
"PO-Revision-Date: 2021-11-06 14:06+0000\n"
"Last-Translator: Nicklas Yli-Länttä <admin@timanttikuutio.eu>\n"
"PO-Revision-Date: 2022-03-18 16:31+0000\n"
"Last-Translator: Stefan Werner <werner@iki.fi>\n"
"Language-Team: Finnish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/fi/>\n"
"Language: fi\n"
@@ -17,7 +17,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.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
#: .\cookbook\templates\forms\ingredients.html:34
@@ -31,10 +31,12 @@ msgid ""
"Color of the top navigation bar. Not all colors work with all themes, just "
"try them out!"
msgstr ""
"Ylänavigointipalkin väri. Ei kaikki värit toimi kaikkien teemojen kanssa; "
"kokeile!"
#: .\cookbook\forms.py:55
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
msgstr ""
msgstr "Oletusmittayksikkö uuden aineksen lisäämisessä."
#: .\cookbook\forms.py:57
msgid ""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -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: 2021-10-23 09:06+0000\n"
"Last-Translator: rustam <uzbekr@gmail.com>\n"
"PO-Revision-Date: 2022-04-07 19:32+0000\n"
"Last-Translator: Artem Aksenov <artemmillerr@gmail.com>\n"
"Language-Team: Russian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/ru/>\n"
"Language: ru\n"
@@ -18,14 +18,14 @@ 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.8\n"
"X-Generator: Weblate 4.10.1\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:125
#: .\cookbook\templates\forms\ingredients.html:34
#: .\cookbook\templates\space.html:43 .\cookbook\templates\stats.html:28
#: .\cookbook\templates\url_import.html:270
msgid "Ingredients"
msgstr "ингредиенты"
msgstr "Ингредиенты"
#: .\cookbook\forms.py:50
msgid ""
@@ -95,14 +95,14 @@ msgstr ""
#: .\cookbook\forms.py:103 .\cookbook\forms.py:334
#: .\cookbook\templates\url_import.html:154
msgid "Name"
msgstr "Имя"
msgstr "Название"
#: .\cookbook\forms.py:104 .\cookbook\forms.py:335
#: .\cookbook\templates\space.html:39 .\cookbook\templates\stats.html:24
#: .\cookbook\templates\url_import.html:188
#: .\cookbook\templates\url_import.html:573 .\cookbook\views\lists.py:112
msgid "Keywords"
msgstr "Ключевые поля"
msgstr "Ключевые слова"
#: .\cookbook\forms.py:105
msgid "Preparation time in minutes"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ DICTIONARY = {
'it': 'italian',
# 'lv': 'Latvian',
'es': 'spanish',
'sv': 'swedish',
}

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-03-04 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0172_ingredient_original_text'),
]
operations = [
migrations.AddField(
model_name='recipe',
name='source_url',
field=models.CharField(blank=True, default=None, max_length=1024, null=True),
),
]

View File

@@ -241,7 +241,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
max_users = models.IntegerField(default=0)
allow_sharing = models.BooleanField(default=True)
demo = models.BooleanField(default=False)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
show_facet_count = models.BooleanField(default=False)
def __str__(self):
@@ -337,7 +337,7 @@ class UserPreference(models.Model, PermissionModelMixin):
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
shopping_recent_days = models.PositiveIntegerField(default=7)
csv_delim = models.CharField(max_length=2, default=",")
csv_prefix = models.CharField(max_length=10, blank=True,)
csv_prefix = models.CharField(max_length=10, blank=True, )
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
@@ -496,11 +496,11 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
ignore_shopping = models.BooleanField(default=False) # inherited field
onhand_users = models.ManyToManyField(User, blank=True)
description = models.TextField(default='', blank=True)
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True)
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True)
substitute = models.ManyToManyField("self", blank=True)
substitute_siblings = models.BooleanField(default=False)
substitute_children = models.BooleanField(default=False)
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
child_inherit_fields = models.ManyToManyField(FoodInheritField, blank=True, related_name='child_inherit')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space', _manager_class=TreeManager)
@@ -533,7 +533,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
if food:
# if child inherit fields is preset children should be set to that, otherwise inherit this foods inherited fields
inherit = list((food.child_inherit_fields.all() or food.inherit_fields.all()).values('id', 'field'))
tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth+1)
tree_filter = Q(path__startswith=food.path, space=space, depth=food.depth + 1)
else:
inherit = list(space.food_inherit.all().values('id', 'field'))
tree_filter = Q(space=space)
@@ -593,6 +593,8 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
order = models.IntegerField(default=0)
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
original_text = models.CharField(max_length=512, null=True, blank=True, default=None)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@@ -663,9 +665,7 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
servings = models.IntegerField(default=1)
servings_text = models.CharField(default='', blank=True, max_length=32)
image = models.ImageField(upload_to='recipes/', blank=True, null=True)
storage = models.ForeignKey(
Storage, on_delete=models.PROTECT, blank=True, null=True
)
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
file_uid = models.CharField(max_length=256, default="", blank=True)
file_path = models.CharField(max_length=512, default="", blank=True)
link = models.CharField(max_length=512, null=True, blank=True)
@@ -675,9 +675,9 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
working_time = models.IntegerField(default=0)
waiting_time = models.IntegerField(default=0)
internal = models.BooleanField(default=False)
nutrition = models.ForeignKey(
NutritionInformation, blank=True, null=True, on_delete=models.CASCADE
)
nutrition = models.ForeignKey(NutritionInformation, blank=True, null=True, on_delete=models.CASCADE)
source_url = models.CharField(max_length=1024, default=None, blank=True, null=True)
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

View File

@@ -10,7 +10,9 @@ from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
from rest_framework import serializers
from rest_framework.exceptions import NotFound, ValidationError
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.permission_helper import above_space_limit
from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, CustomFilter,
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, Keyword,
@@ -20,7 +22,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit,
UserFile, UserPreference, ViewLog)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import MEDIA_URL
from recipes.settings import MEDIA_URL, AWS_ENABLED
class ExtendedRecipeMixin(serializers.ModelSerializer):
@@ -41,7 +43,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
api_serializer = None
# extended values are computationally expensive and not needed in normal circumstances
try:
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
if str2bool(
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
return fields
except (AttributeError, KeyError) as e:
pass
@@ -54,7 +57,12 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
def get_image(self, obj):
if obj.recipe_image:
return MEDIA_URL + obj.recipe_image
if AWS_ENABLED:
storage = CachedS3Boto3Storage()
path = storage.url(obj.recipe_image)
else:
path = MEDIA_URL + obj.recipe_image
return path
class CustomDecimalField(serializers.Field):
@@ -90,7 +98,8 @@ class CustomOnHandField(serializers.Field):
shared_users = getattr(request, '_shared_users', None)
if shared_users is None:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
self.context['request'].user.id]
except AttributeError: # Anonymous users (using share links) don't have shared users
shared_users = []
return obj.onhand_users.filter(id__in=shared_users).exists()
@@ -164,7 +173,8 @@ class FoodInheritFieldSerializer(WritableNestedModelSerializer):
class UserPreferenceSerializer(WritableNestedModelSerializer):
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True,
required=False, read_only=True)
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
food_children_exist = serializers.SerializerMethodField('get_food_children_exist')
@@ -183,9 +193,12 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style', 'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj', 'search_style',
'show_recent', 'plan_share',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'food_children_exist'
)
@@ -364,23 +377,29 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
fields = ('id', 'name', 'description', 'category_to_supermarket')
class RecipeSimpleSerializer(serializers.ModelSerializer):
class RecipeSimpleSerializer(WritableNestedModelSerializer):
url = serializers.SerializerMethodField('get_url')
def get_url(self, obj):
return reverse('view_recipe', args=[obj.id])
def create(self, validated_data):
# don't allow writing to Recipe via this API
return Recipe.objects.get(**validated_data)
def update(self, instance, validated_data):
# don't allow writing to Recipe via this API
return Recipe.objects.get(**validated_data)
class Meta:
model = Recipe
fields = ('id', 'name', 'url')
read_only_fields = ['id', 'name', 'url']
class FoodSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = Food
fields = ('id', 'name')
read_only_fields = ['id', 'name']
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
@@ -403,7 +422,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
shared_users = getattr(request, '_shared_users', None)
if shared_users is None:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
self.context['request'].user.id]
except AttributeError:
shared_users = []
filter = Q(id__in=obj.substitute.all())
@@ -427,6 +447,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
name=sc_name,
space=space, defaults=sm_category)
onhand = validated_data.pop('food_onhand', None)
if recipe := validated_data.get('recipe', None):
validated_data['recipe'] = Recipe.objects.get(**recipe)
# assuming if on hand for user also onhand for shopping_share users
if not onhand is None:
@@ -472,11 +494,15 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
class IngredientSerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
class IngredientSimpleSerializer(WritableNestedModelSerializer):
food = FoodSimpleSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True)
used_in_recipes = serializers.SerializerMethodField('get_used_in_recipes')
amount = CustomDecimalField()
def get_used_in_recipes(self, obj):
return list(Recipe.objects.filter(steps__ingredients=obj.id).values('id', 'name'))
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
@@ -489,10 +515,14 @@ class IngredientSerializer(WritableNestedModelSerializer):
model = Ingredient
fields = (
'id', 'food', 'unit', 'amount', 'note', 'order',
'is_header', 'no_amount', 'original_text'
'is_header', 'no_amount', 'original_text', 'used_in_recipes',
)
class IngredientSerializer(IngredientSimpleSerializer):
food = FoodSerializer(allow_null=True)
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
ingredients = IngredientSerializer(many=True)
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
@@ -615,11 +645,17 @@ class RecipeSerializer(RecipeBaseSerializer):
model = Recipe
fields = (
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at',
'waiting_time', 'created_by', 'created_at', 'updated_at','source_url',
'internal', 'nutrition', 'servings', 'file_path', 'servings_text', 'rating', 'last_cooked',
)
read_only_fields = ['image', 'created_by', 'created_at']
def validate(self, data):
above_limit, msg = above_space_limit(self.context['request'].space)
if above_limit:
raise serializers.ValidationError(msg)
return super().validate(data)
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
@@ -627,9 +663,12 @@ class RecipeSerializer(RecipeBaseSerializer):
class RecipeImageSerializer(WritableNestedModelSerializer):
image = serializers.ImageField(required=False, allow_null=True)
image_url = serializers.CharField(max_length=4096, required=False, allow_null=True)
class Meta:
model = Recipe
fields = ['image', ]
fields = ['image', 'image_url', ]
class RecipeImportSerializer(SpacedModelSerializer):
@@ -684,7 +723,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
def create(self, validated_data):
book = validated_data['book']
recipe = validated_data['recipe']
if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
if not book.get_owner() == self.context['request'].user and not self.context[
'request'].user in book.get_shared():
raise NotFound(detail=None, code=None)
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
return obj
@@ -713,7 +753,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
mealplan = super().create(validated_data)
if self.context['request'].data.get('addshopping', False):
if self.context['request'].data.get('addshopping', False) and self.context['request'].data.get('recipe', None):
SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
SLR.create(mealplan=mealplan, servings=validated_data['servings'])
return mealplan
@@ -737,13 +777,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
def get_name(self, obj):
if not isinstance(value := obj.servings, Decimal):
value = Decimal(value)
value = value.quantize(Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
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
@@ -814,7 +855,8 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta:
model = ShoppingListEntry
fields = (
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked',
'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
@@ -912,7 +954,10 @@ class ExportLogSerializer(serializers.ModelSerializer):
class Meta:
model = ExportLog
fields = ('id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration', 'possibly_not_expired', 'created_by', 'created_at')
fields = (
'id', 'type', 'msg', 'running', 'total_recipes', 'exported_recipes', 'cache_duration',
'possibly_not_expired',
'created_by', 'created_at')
read_only_fields = ('created_by',)
@@ -933,12 +978,19 @@ class AutomationSerializer(serializers.ModelSerializer):
# CORS, REST and Scopes aren't currently working
# Scopes are evaluating before REST has authenticated the user assigning a None space
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
class BookmarkletImportSerializer(serializers.ModelSerializer):
class BookmarkletImportListSerializer(serializers.ModelSerializer):
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].user.userpreference.space
return super().create(validated_data)
class Meta:
model = BookmarkletImport
fields = ('id', 'url', 'created_by', 'created_at')
read_only_fields = ('created_by', 'space')
class BookmarkletImportSerializer(BookmarkletImportListSerializer):
class Meta:
model = BookmarkletImport
fields = ('id', 'url', 'html', 'created_by', 'created_at')
@@ -1024,10 +1076,12 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update"))
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False,
help_text=_("Existing shopping list to update"))
ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
"List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_("Providing a list_recipe ID and servings of 0 will delete that shopping list."))
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_(
"Providing a list_recipe ID and servings of 0 will delete that shopping list."))
class Meta:
model = Recipe
@@ -1035,9 +1089,12 @@ class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Amount of food to add to the shopping list"))
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list"))
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, help_text=_("When set to true will delete all food from active shopping lists."))
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False,
help_text=_("Amount of food to add to the shopping list"))
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False,
help_text=_("ID of unit to use for the shopping list"))
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True,
help_text=_("When set to true will delete all food from active shopping lists."))
class Meta:
model = Recipe

View File

@@ -1,3 +1,15 @@
.brand-icon {
height: 40px;
}
@media (max-width: 991.98px) {
.menu-dropdown-text {
font-size: 14px;
font-weight: 200;
}
}
.spinner-tandoor {
animation: rotation 3s infinite linear;
content: url("../assets/spinner.svg");

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,8 +3,8 @@ from django.utils.html import format_html
from django.utils.translation import gettext as _
from django_tables2.utils import A
from .models import (CookLog, InviteLink, Keyword, Recipe, RecipeImport,
ShoppingList, Storage, Sync, SyncLog, ViewLog)
from .models import (CookLog, InviteLink, Recipe, RecipeImport,
Storage, Sync, SyncLog, ViewLog)
class ImageUrlColumn(tables.Column):
@@ -121,14 +121,6 @@ class RecipeImportTable(tables.Table):
fields = ('id', 'name', 'file_path')
class ShoppingListTable(tables.Table):
id = tables.LinkColumn('view_shopping', args=[A('id')])
class Meta:
model = ShoppingList
template_name = 'generic/table_template.html'
fields = ('id', 'finished', 'created_by', 'created_at')
class InviteLinkTable(tables.Table):
link = tables.TemplateColumn(

View File

@@ -68,6 +68,7 @@
<script>
$('#id_login').focus()
$('#id_remember').prop('checked', true);
</script>
{% endblock %}

View File

@@ -56,21 +56,46 @@
{% block extra_head %} <!-- block for templates to put stuff into header -->
{% endblock %}
<style>
{% if request.user.userpreference.left_handed %}
@media screen and (max-width: 600px) {
#switcher .btn-circle {
left: 80px !important;
}
}
{% endif %}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %} bg-header" id="id_main_nav"
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %} bg-header"
id="id_main_nav"
style="{% sticky_nav request %}">
{% if not request.user.userpreference.left_handed %}
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}"
aria-label="Tandoor">
<img class="brand-icon" src="{% static 'assets/brand_logo.svg' %}" alt="Logo">
</a>
{% endif %}
{% endif %}
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}" aria-label="Tandoor">
<img class="brand-icon" src="{% static 'assets/brand_logo.png' %}" alt="" style="height: 5vh;">
</a>
{% if request.user.userpreference.left_handed %}
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}"
aria-label="Tandoor">
<img class="brand-icon" src="{% static 'assets/brand_logo.svg' %}" alt="Logo">
</a>
{% endif %}
{% endif %}
<div class="collapse navbar-collapse" id="navbarText">
<ul class="navbar-nav mr-auto">
<li class="nav-item {% if request.resolver_match.url_name in 'view_search' %}active{% endif %}">
@@ -102,129 +127,140 @@
<i class="fas fa-toolbox fa-lg"></i>
</a>
<div class="dropdown-menu dropdown-menu-center dropdown-menu-center-large">
<div class="row m-0">
<div class="row m-0 mt-2 mt-md-0">
<div class="col-4">
<a href="{% url 'list_keyword' %}" class="p-1">
<a href="{% url 'list_keyword' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-tags fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Keyword' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'list_food' %}" class="p-1">
<a href="{% url 'list_food' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-leaf fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Foods' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'list_unit' %}" class="p-1">
<a href="{% url 'list_unit' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-balance-scale fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Units' %}
</div>
</div>
</a>
</div>
</div>
<div class="row m-0">
<div class="row m-0 mt-2 mt-md-0">
<div class="col-4">
<a href="{% url 'list_supermarket' %}" class="p-1">
<a href="{% url 'list_supermarket' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-store-alt fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Supermarket' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'list_supermarket_category' %}" class="p-1">
<a href="{% url 'list_supermarket_category' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-cubes fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Supermarket Category' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'list_automation' %}" class="p-1">
<a href="{% url 'list_automation' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-robot fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Automations' %}
</div>
</div>
</a>
</div>
</div>
<div class="row m-0">
<div class="row m-0 mt-2 mt-md-0">
<div class="col-4">
<a href="{% url 'list_user_file' %}" class="p-1">
<a href="{% url 'list_user_file' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-file fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Files' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'data_batch_edit' %}" class="p-1">
<a href="{% url 'data_batch_edit' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-edit fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Batch Edit' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'view_history' %}" class="p-1">
<a href="{% url 'view_history' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-history fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'History' %}
</div>
</div>
</a>
</div>
</div>
<div class="row m-0">
<div class="row m-0 mt-2 mt-md-0">
<div class="col-4">
<a href="{% url 'view_export' %}" class="p-1">
<a href="{% url 'view_ingredient_editor' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-th-list fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Ingredient Editor' %}
</div>
</div>
</a>
</div>
<div class="col-4">
<a href="{% url 'view_export' %}" class="p-0 p-md-1">
<div class="card p-0 no-gutters border-0">
<div class="card-body text-center p-0 no-gutters">
<i class="fas fa-file-export fa-2x"></i>
</div>
<div class="card-body text-break text-center p-0 no-gutters text-muted">
<div class="card-body text-break text-center p-0 no-gutters text-muted menu-dropdown-text">
{% trans 'Export' %}
</div>
</div>
@@ -301,7 +337,14 @@
</div>
{% endif %}
<div class="container-fluid mt-2 mt-md-5 mt-xl-5 mt-lg-5" id="id_base_container">
{% if HOSTED and request.space.max_recipes == 10 %}
<div class="bg-warning" style=" width: 100%; text-align: center!important; color: #ffffff; padding: 8px">
{% trans 'You are using the free version of Tandor' %} <a class="btn-success btn-sm" href="https://tandoor.dev/manage">{% trans 'Upgrade Now' %}</a>
</div>
{% endif %}
<div class="container-fluid mt-2 mt-md-5 mt-xl-5 mt-lg-5{% if request.user.userpreference.left_handed %} left-handed {% endif %}"
id="id_base_container">
<div class="row">
<div class="col-xl-2 d-none d-xl-block">
{% block content_xl_left %}
@@ -336,7 +379,7 @@
{% block content_fluid %}
{% endblock %}
{% user_prefs request as prefs%}
{% user_prefs request as prefs %}
{{ prefs|json_script:'user_preference' }}
</div>

View File

@@ -26,15 +26,6 @@
{% endif %}
</h3>
</span>
{% if request.resolver_match.url_name in 'list_shopping_list' %}
<span class="col-md-3">
<a href="{% url 'view_shopping_new' %}" class="float-right">
<button class="btn btn-outline-secondary shadow-none">
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
</button>
</a>
</span>
{% endif %}
{% if filter %}
<br/>

View File

@@ -4,12 +4,18 @@
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Meal-Plan' %}{% endblock %}
{% block title %}{% trans 'Ingredient Editor' %}{% endblock %}
{% block content_fluid %}
{% block content %}
<div class="row">
<div class="col col-md-12">
<h3>{% trans 'Ingredient Editor' %}</h3>
</div>
</div>
<div id="app">
<meal-plan-view></meal-plan-view>
<ingredient-editor-view></ingredient-editor-view>
</div>
@@ -24,13 +30,10 @@
{% endif %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
window.DEFAULT_FOOD = {{ food_id }}
window.DEFAULT_UNIT = {{ unit_id }}
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.ICAL_URL = '{% url 'api_get_plan_ical' 12345 6789 %}'
window.SHOPPING_URL = '{% url 'view_shopping' %}'
</script>
{% render_bundle 'meal_plan_view' %}
{% render_bundle 'ingredient_editor_view' %}
{% endblock %}

View File

@@ -20,4 +20,8 @@
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create Superuser account' %}</button>
</form>
<script>
$('#id_name').focus()
</script>
{% endblock %}

View File

@@ -1,921 +0,0 @@
{% extends "base.html" %}
{% comment %} TODO: Deprecate {% endcomment %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Shopping List" %}{% endblock %}
{% block extra_head %}
{% include 'include/vue_base.html' %}
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}" />
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
<script src="{% static 'js/Sortable.min.js' %}"></script>
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
<script src="{% static 'js/vue-cookies.js' %}"></script>
<script src="{% static 'js/js.cookie.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}" />
{% endblock %}
{% block content %}
<div class="row">
<span class="col col-md-9">
<h2>{% trans 'Shopping List' %}</h2>
</span>
<span class="col-md-3">
<a href="{% url 'view_shopping_new' %}" class="float-right">
<button class="btn btn-outline-secondary shadow-none"><i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}</button>
</a>
</span>
<div class="col col-mdd-3 text-right">
<b-form-checkbox switch size="lg" v-model="edit_mode" @change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
</div>
</div>
<template v-if="shopping_list !== undefined">
<div class="text-center" v-if="loading">
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
<img class="spinner-tandoor" />
{% else %}
<i class="fas fa-spinner fa-spin fa-8x"></i>
{% endif %}
</div>
<div v-else-if="edit_mode">
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-header"><i class="fa fa-search"></i> {% trans 'Search' %}</div>
<div class="card-body">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes" placeholder="{% trans 'Search Recipe' %}" />
<ul class="list-group" style="margin-top: 8px">
<li class="list-group-item" v-for="x in recipes">
<div class="row flex-row" style="padding-left: 0.5vw; padding-right: 0.5vw">
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)" target="_blank" rel="nofollow norefferer">[[x.name]]</a></div>
<div class="flex-column align-self-end">
<button class="btn btn-outline-primary shadow-none" @click="addRecipeToList(x)"><i class="fa fa-plus"></i></button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col col-md-6">
<div class="card">
<div class="card-header"><i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}</div>
<div class="card-body">
<template v-if="shopping_list.recipes.length < 1"> {% trans 'No recipes selected' %} </template>
<template v-else>
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes" style="margin-top: 1vh !important">
<div class="flex-column align-self-start" style="margin-right: 0.4vw">
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i class="fa fa-trash"></i></button>
</div>
<div class="flex-grow-1 flex-column my-auto">
<a v-bind:href="getRecipeUrl(x.recipe)" target="_blank" rel="nofollow norefferer">[[x.recipe_name]]</a>
</div>
<div class="flex-column align-self-end">
<div class="input-group input-group-sm my-auto">
<div class="input-group-prepend">
<button class="text-muted btn btn-outline-primary shadow-none" @click="((x.servings - 1) > 0) ? x.servings -= 1 : 1">-</button>
</div>
<input class="form-control" type="number" v-model="x.servings" />
<div class="input-group-append">
<button class="text-muted btn btn-outline-primary shadow-none" @click="x.servings += 1">+</button>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<table class="table table-sm" style="margin-top: 1vh">
<template v-for="c in display_categories">
<thead>
<tr>
<th colspan="5">[[c.name]]</th>
</tr>
</thead>
<tbody is="draggable" :list="c.entries" tag="tbody" group="people" @sort="sortEntries" @change="dragChanged(c, $event)" handle=".handle">
<tr v-for="(element, index) in c.entries" :key="element.id" v-bind:class="{ 'text-muted': element.checked }">
<td class="handle"><i class="fas fa-sort"></i></td>
<td>[[element.amount.toFixed(2)]]</td>
<td>[[element.unit.name]]</td>
<td>[[element.food.name]]</td>
<td>
<button
class="btn btn-sm btn-outline-danger"
v-if="element.list_recipe === null"
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)"
>
<i class="fa fa-trash"></i>
</button>
</td>
</tr>
</tbody>
</template>
</table>
<div class="row" style="text-align: right">
<div class="col">
<b-form-checkbox switch v-model="entry_mode_simple" @change="$cookies.set('shopping_entry_mode_simple',!entry_mode_simple, -1)"
>{% trans 'Entry Mode' %}</b-form-checkbox
>
</div>
</div>
<div class="row" v-if="entry_mode_simple" style="margin-top: 2vh">
<div class="col-12">
<form v-on:submit.prevent="addSimpleEntry()">
<label for="id_simple_entry">{% trans 'Add Entry' %}</label>
<div class="input-group">
<input id="id_simple_entry" class="form-control" v-model="simple_entry" />
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" @click="addSimpleEntry()"><i class="fa fa-plus"></i></button>
</div>
</div>
</form>
</div>
</div>
<div class="row" v-if="!entry_mode_simple" style="margin-top: 2vh">
<div class="col-12 col-lg-3">
<input id="id_advanced_entry" class="form-control" type="number" placeholder="{% trans 'Amount' %}" v-model="new_entry.amount" ref="new_entry_amount" />
</div>
<div class="col-12 col-lg-4">
<multiselect
v-tabindex
ref="unit"
v-model="new_entry.unit"
:options="units"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select Unit' %}"
tag-placeholder="{% trans 'Create' %}"
select-label="{% trans 'Select' %}"
:taggable="true"
@tag="addUnitType"
label="name"
track-by="name"
:multiple="false"
:loading="units_loading"
@search-change="searchUnits"
>
</multiselect>
</div>
<div class="col-12 col-lg-4">
<multiselect
v-tabindex
ref="food"
v-model="new_entry.food"
:options="foods"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select Food' %}"
tag-placeholder="{% trans 'Create' %}"
select-label="{% trans 'Select' %}"
:taggable="true"
@tag="addFoodType"
label="name"
track-by="name"
:multiple="false"
:loading="foods_loading"
@search-change="searchFoods"
>
</multiselect>
</div>
<div class="col-12 col-lg-1 my-auto text-right">
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i></button>
</div>
</div>
<div class="row">
<div class="col" style="margin-top: 1vh">
<label for="id_supermarket">{% trans 'Supermarket' %}</label>
<multiselect
id="id_supermarket"
v-tabindex
v-model="shopping_list.supermarket"
:options="supermarkets"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select Supermarket' %}"
select-label="{% trans 'Select' %}"
label="name"
track-by="id"
:multiple="false"
:loading="supermarkets_loading"
@search-change="searchSupermarket"
>
</multiselect>
</div>
</div>
<div class="row">
<div class="col" style="margin-top: 1vh">
<label for="id_select_shared">{% trans 'Shared with' %}</label>
<multiselect
id="id_select_shared"
v-tabindex
v-model="shopping_list.shared"
:options="users"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select User' %}"
select-label="{% trans 'Select' %}"
label="username"
track-by="id"
:multiple="true"
:loading="users_loading"
@search-change="searchUsers"
>
</multiselect>
</div>
</div>
<div class="row">
<div class="col" style="text-align: right; margin-top: 1vh">
<div class="form-group form-check form-group-lg">
<input class="form-check-input" style="zoom: 1.3" type="checkbox" v-model="shopping_list.finished" id="id_finished" />
<label class="form-check-label" style="zoom: 1.3" for="id_finished"> {% trans 'Finished' %}</label>
</div>
</div>
</div>
</div>
<div v-else>
{% if request.user.userpreference.shopping_auto_sync > 0 %}
<div class="row" v-if="!onLine">
<div class="col col-md-12">
<div class="alert alert-warning" role="alert">{% trans 'You are offline, shopping list might not synchronize.' %}</div>
</div>
</div>
{% endif %}
<div class="row" style="margin-top: 8px">
<div class="col col-md-12">
<table class="table">
<template v-for="c in display_categories">
<template v-if="c.entries.filter(item => item.checked === false).length > 0">
<tr>
<td colspan="4">[[c.name]]</td>
</tr>
<tr v-for="x in c.entries">
<template v-if="!x.checked">
<td><input type="checkbox" style="zoom: 1.4" v-model="x.checked" @change="entryChecked(x)" /></td>
<td>[[x.amount.toFixed(2)]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]] <span class="text-muted" v-if="x.recipes.length > 0">([[x.recipes.join(', ')]])</span></td>
</template>
</tr>
</template>
</template>
<tr>
<td colspan="4"></td>
</tr>
<template v-for="c in display_categories">
<tr v-for="x in c.entries" class="text-muted">
<template v-if="x.checked">
<td><input type="checkbox" style="zoom: 1.4" v-model="x.checked" @change="entryChecked(x)" /></td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
</template>
</tr>
</template>
</table>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh">
<div class="col" style="text-align: right">
<b-button class="btn btn-info" v-b-modal.id_modal_export><i class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
</div>
</div>
<br />
<br />
<b-modal id="id_modal_export" title="{% trans 'Copy/Export' %}">
<div class="row">
<div class="col col-12">
<label>
{% trans 'List Prefix' %}
<input class="form-control" v-model="export_text_prefix" />
</label>
</div>
</div>
<div class="row">
<div class="col col-12">
<b-form-textarea class="form-control" max-rows="8" v-model="export_text"> </b-form-textarea>
</div>
</div>
</b-modal>
</template>
{% endblock %} {% block script %}
<script src="{% url 'javascript-catalog' %}"></script>
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
Vue.component('vue-multiselect', window.VueMultiselect.default)
let app = new Vue({
components: {
Multiselect: window.VueMultiselect.default
},
delimiters: ['[[', ']]'],
el: '#id_base_container',
data: {
shopping_list_id: {% if shopping_list_id %}{{ shopping_list_id }}{% else %}null{% endif %},
loading: true,
{% if edit %}
edit_mode: true,
{% else %}
edit_mode: false,
{% endif %}
export_text_prefix: '',
recipe_query: '',
recipes: [],
shopping_list: undefined,
new_entry: {
unit: undefined,
amount: undefined,
food: undefined,
},
unchecked_entries: 0,
foods: [],
foods_loading: false,
units: [],
units_loading: false,
supermarkets: [],
supermarkets_loading: false,
users: [],
users_loading: false,
onLine: navigator.onLine,
simple_entry: '',
auto_sync_blocked: false,
auto_sync_running: false,
entry_mode_simple: $cookies.isKey('shopping_entry_mode_simple') ? ($cookies.get('shopping_entry_mode_simple') === 'true') : true,
},
directives: {
tabindex: {
inserted(el) {
el.setAttribute('tabindex', 0);
}
}
},
computed: {
servings_cache() {
let cache = {}
this.shopping_list.recipes.forEach((r) => {
cache[r.id] = r.servings;
})
return cache
},
recipe_cache() {
let cache = {}
this.shopping_list.recipes.forEach((r) => {
cache[r.id] = r.recipe_name;
})
return cache
},
display_categories() {
this.unchecked_entries = 0
let categories = {
no_category: {
name: gettext('Uncategorized'),
id: -1,
entries: [],
order: -1
}
}
this.shopping_list.entries.forEach((e) => {
if (e.food.supermarket_category !== null) {
categories[e.food.supermarket_category.id] = {
name: e.food.supermarket_category.name,
id: e.food.supermarket_category.id,
order: 0,
entries: []
};
}
})
if (this.shopping_list.supermarket !== null) {
this.shopping_list.supermarket.category_to_supermarket.forEach(el => {
categories[el.category.id] = {
name: el.category.name,
id: el.category.id,
order: el.order,
entries: []
};
})
}
this.shopping_list.entries.forEach(element => {
let item = {}
Object.assign(item, element);
item.recipes = []
let entry = this.findMergeEntry(categories, item)
if (entry !== undefined) {
let servings = 1
if (item.list_recipe in this.servings_cache) {
servings = this.servings_cache[item.list_recipe]
}
entry.amount += item.amount * servings
if (item.list_recipe !== null && entry.recipes.indexOf(this.recipe_cache[item.list_recipe]) === -1) {
entry.recipes.push(this.recipe_cache[item.list_recipe])
}
entry.entries.push(item.id)
} else {
if (item.list_recipe !== null) {
item.amount = item.amount * this.servings_cache[item.list_recipe]
}
item.unit = ((element.unit !== undefined && element.unit !== null) ? element.unit : {'name': ''})
item.entries = [element.id]
if (element.list_recipe !== null) {
item.recipes.push(this.recipe_cache[element.list_recipe])
}
if (item.food.supermarket_category !== null) {
categories[item.food.supermarket_category.id].entries.push(item)
} else {
categories['no_category'].entries.push(item)
}
}
if (!item.checked) {
this.unchecked_entries += 1
}
});
let ordered_categories = []
for (let [i, v] of Object.entries(categories)) {
ordered_categories.push(v)
}
ordered_categories.sort(function (a, b) {
if (a.order < b.order) {
return -1
} else if (a.order > b.order) {
return 1
} else {
return 0
}
})
return ordered_categories
},
export_text() {
let text = ''
for (let c of this.display_categories) {
for (let e of c.entries.filter(item => item.checked === false)) {
text += `${this.export_text_prefix}${e.amount} ${e.unit.name} ${e.food.name} \n`
}
}
return text
}
},
mounted: function () {
this.loadShoppingList()
{% if recipes %}
this.loading = true
this.edit_mode = true
let loadingRecipes = []
{% for r in recipes %}
loadingRecipes.push(this.loadInitialRecipe({{ r.recipe }}, {{ r.servings }}))
{% endfor %}
Promise.allSettled(loadingRecipes).then(() => {
this.loading = false
})
{% endif %}
{% if request.user.userpreference.shopping_auto_sync > 0 %}
setInterval(() => {
if ((this.shopping_list_id !== null) && !this.edit_mode && window.navigator.onLine && !this.auto_sync_blocked && !this.auto_sync_running) {
this.auto_sync_running = true
this.loadShoppingList(true)
}
}, {% widthratio request.user.userpreference.shopping_auto_sync 1 1000 %})
window.addEventListener('online', this.updateOnlineStatus);
window.addEventListener('offline', this.updateOnlineStatus);
{% endif %}
this.searchUsers('')
this.searchSupermarket('')
this.searchUnits('')
this.searchFoods('')
},
methods: {
findMergeEntry: function (categories, entry) {
for (let [i, e] of Object.entries(categories)) {
let found_entry = e.entries.find(item => {
if (entry.food.id === item.food.id && entry.food.name === item.food.name) {
if (entry.unit === null && item.unit === null) {
return true
} else if (entry.unit !== null && item.unit !== null && entry.unit.id === item.unit.id && entry.unit.name === item.unit.name) {
return true
}
}
})
if (found_entry !== undefined) {
return found_entry
}
}
return undefined
},
updateOnlineStatus(e) {
const {
type
} = e;
this.onLine = type === 'online';
},
makeToast: function (title, message, variant = null) {
this.$bvToast.toast(message, {
title: title,
variant: variant,
toaster: 'b-toaster-top-center',
solid: true
})
},
loadInitialRecipe: function (recipe, servings) {
servings = 1
return this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe)).then((response) => {
this.addRecipeToList(response.data, servings)
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
loadShoppingList: function (autosync = false) {
if (this.shopping_list_id) {
this.$http.get("{% url 'api:shoppinglist-detail' 123456 %}".replace('123456', this.shopping_list_id) + ((autosync) ? '?autosync=true' : '')).then((response) => {
if (!autosync) {
this.shopping_list = response.body
this.loading = false
} else {
if (!this.auto_sync_blocked) {
let check_map = {}
for (let e of response.body.entries) {
check_map[e.id] = {checked: e.checked}
}
for (let se of this.shopping_list.entries) {
if (check_map[se.id] !== undefined) {
se.checked = check_map[se.id].checked
}
}
}
this.auto_sync_running = false
}
if (this.shopping_list.entries.length === 0) {
this.edit_mode = true
}
console.log(response.data)
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
} else {
this.shopping_list = {
"recipes": [],
"entries": [],
"entries_display": [],
"shared": [{% for u in request.user.userpreference.plan_share.all %}
{'id': {{ u.pk }}, 'username': '{{ u.get_user_name }}'},
{% endfor %}],
"created_by": {{ request.user.pk }},
"supermarket": null
}
this.loading = false
if (this.shopping_list.entries.length === 0) {
this.edit_mode = true
}
}
},
updateShoppingList: function () {
this.loading = true
let recipe_promises = []
for (let i in this.shopping_list.recipes) {
if (this.shopping_list.recipes[i].created) {
console.log('updating recipe', this.shopping_list.recipes[i])
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
let old_id = this.shopping_list.recipes[i].id
console.log("list recipe create response ", response.body)
this.$set(this.shopping_list.recipes, i, response.body)
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
console.log("found recipe updating ID")
e.list_recipe = this.shopping_list.recipes[i].id
}
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error updating a resource!") + err.bodyText, 'danger')
}))
}
}
Promise.allSettled(recipe_promises).then(() => {
console.log("proceeding to update shopping list", this.shopping_list)
if (this.shopping_list.id === undefined) {
return this.$http.post("{% url 'api:shoppinglist-list' %}", this.shopping_list, {}).then((response) => {
console.log(response)
this.makeToast(gettext('Updated'), gettext("Object created successfully!"), 'success')
this.loading = false
this.shopping_list = response.body
this.shopping_list_id = this.shopping_list.id
window.history.pushState('shopping_list', '{% trans 'Shopping List' %}', "{% url 'view_shopping' 123456 %}".replace('123456', this.shopping_list_id));
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error creating a resource!") + err.bodyText, 'danger')
this.loading = false
})
} else {
return this.$http.put("{% url 'api:shoppinglist-detail' 123456 %}".replace('123456', this.shopping_list.id), this.shopping_list, {}).then((response) => {
console.log(response)
this.shopping_list = response.body
this.makeToast(gettext('Updated'), gettext("Changes saved successfully!"), 'success')
this.loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error updating a resource!") + err.bodyText, 'danger')
this.loading = false
})
}
})
},
sortEntries: function (a, b) {
},
dragChanged: function (category, evt) {
if (evt.added !== undefined) {
if (evt.added.element.id === undefined) {
this.makeToast(gettext('Warning'), gettext('This feature is only available after saving the shopping list'), 'warning')
} else {
this.shopping_list.entries.forEach(entry => {
if (entry.id === evt.added.element.id) {
if (category.id === -1) {
entry.food.supermarket_category = null
} else {
entry.food.supermarket_category = {
name: category.name,
id: category.id
}
}
this.$http.put(("{% url 'api:food-detail' 123456 %}").replace('123456', entry.food.id), entry.food).then((response) => {
}).catch((err) => {
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
})
}
})
}
}
},
entryChecked: function (entry) {
this.auto_sync_blocked = true
let updates = []
this.shopping_list.entries.forEach((item) => {
if (entry.entries.includes(item.id)) {
item.checked = entry.checked
updates.push(this.$http.put("{% url 'api:shoppinglistentry-detail' 123456 %}".replace('123456', item.id), item, {}).then((response) => {
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('There was an error updating a resource!') + err.bodyText, 'danger')
this.loading = false
}))
}
})
Promise.allSettled(updates).then(() => {
this.auto_sync_blocked = false
if (this.unchecked_entries < 1) {
this.shopping_list.finished = true
this.$http.put("{% url 'api:shoppinglist-detail' 123456 %}".replace('123456', this.shopping_list.id), this.shopping_list, {}).then((response) => {
this.makeToast(gettext('Finished'), gettext('Shopping list finished!'), 'success')
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error updating a resource!") + err.bodyText, 'danger')
})
} else {
if (this.unchecked_entries > 0 && this.shopping_list.finished) {
this.shopping_list.finished = false
this.$http.put("{% url 'api:shoppinglist-detail' 123456 %}".replace('123456', this.shopping_list.id), this.shopping_list, {}).then((response) => {
this.makeToast(gettext('Open'), gettext('Shopping list reopened!'), 'success')
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error updating a resource!") + err.bodyText, 'danger')
})
}
}
})
},
addEntry: function () {
if (this.new_entry.food !== undefined) {
this.shopping_list.entries.push({
'list_recipe': null,
'food': this.new_entry.food,
'unit': this.new_entry.unit,
'amount': parseFloat(this.new_entry.amount),
'order': 0,
'checked': false,
})
this.new_entry = {
unit: undefined,
amount: undefined,
food: undefined,
}
this.$refs.new_entry_amount.focus();
} else {
this.makeToast(gettext('Error'), gettext('Please enter a valid food'), 'danger')
}
},
addSimpleEntry: function () {
if (this.simple_entry !== '') {
this.$http.post('{% url 'api_ingredient_from_string' %}', {text: this.simple_entry}, {emulateJSON: true}).then((response) => {
console.log(response)
let unit = null
if (response.body.unit !== '') {
unit = {'name': response.body.unit}
}
this.shopping_list.entries.push({
'list_recipe': null,
'food': {'name': response.body.food, supermarket_category: null},
'unit': unit,
'amount': response.body.amount,
'order': 0,
'checked': false,
})
this.simple_entry = ''
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext('Something went wrong while trying to add the simple entry.'), 'danger')
})
}
},
getRecipes: function () {
let url = "{% url 'api:recipe-list' %}?page_size=5&internal=true"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
} else {
this.recipes = []
return
}
this.$http.get(url).then((response) => {
this.recipes = response.data.results;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
getRecipeUrl: function (id) {
return '{% url 'view_recipe' 123456 %}'.replace('123456', id)
},
addRecipeToList: function (recipe, servings = 1) {
let slr = {
"created": true,
"id": Math.random() * 1000,
"recipe": recipe.id,
"recipe_name": recipe.name,
"servings": servings,
}
this.shopping_list.recipes.push(slr)
this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe.id)).then((response) => {
for (let s of response.data.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null && !i.food.ignore_food) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,
'unit': i.unit,
'amount': i.amount,
'order': 0
})
}
}
}
}).catch((err) => {
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
removeRecipeFromList: function (slr) {
this.shopping_list.entries = this.shopping_list.entries.filter(item => item.list_recipe !== slr.id)
this.shopping_list.recipes = this.shopping_list.recipes.filter(item => item !== slr)
},
searchKeywords: function (query) {
this.keywords_loading = true
this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.keywords = response.data.results;
this.keywords_loading = false
}).catch((err) => {
console.log(err)
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
searchUnits: function (query) {
this.units_loading = true
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.units = response.data.results;
this.units_loading = false
}).catch((err) => {
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
searchFoods: function (query) {
this.foods_loading = true
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.foods = response.data.results
this.foods_loading = false
}).catch((err) => {
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
addFoodType: function (tag, index) {
let new_food = {'name': tag, supermarket_category: null}
this.foods.push(new_food)
this.new_entry.food = new_food
},
addUnitType: function (tag, index) {
let new_unit = {'name': tag}
this.units.push(new_unit)
this.new_entry.unit = new_unit
},
searchUsers: function (query) {
this.users_loading = true
this.$http.get("{% url 'api:username-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.users = response.data
this.users_loading = false
}).catch((err) => {
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
searchSupermarket: function (query) {
this.supermarkets_loading = true
this.$http.get("{% url 'api:supermarket-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.supermarkets = response.data
this.supermarkets_loading = false
}).catch((err) => {
this.makeToast(gettext('Error'), gettext("There was an error loading a resource!") + err.bodyText, 'danger')
})
},
},
beforeDestroy() {
window.removeEventListener('online', this.updateOnlineStatus);
window.removeEventListener('offline', this.updateOnlineStatus);
}
});
</script>
{% endblock %}

View File

@@ -36,8 +36,7 @@
<li class="list-group-item">
{% trans 'Recipes' %} :
<span class="badge badge-pill badge-info"
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
else %}∞{% endif %}</span
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{% else %}∞{% endif %}</span
>
</li>
<li class="list-group-item">

View File

@@ -1,26 +1,20 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% load custom_tags %}
{% block title %}{% trans 'Import Recipes' %}{% endblock %}
{% block title %}Test{% endblock %}
{% block content_fluid %}
{{ data }}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2>{% trans 'Import' %}</h2>
<div class="row">
<div class="col col-md-12">
<form action="." method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-file-import"></i> {% trans 'Import' %}
</button>
</form>
</div>
</div>
{% block script %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,7 @@ def bookmarklet(request):
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
bookmark = "javascript: \
bookmark = "<a href='javascript: \
(function(){ \
if(window.bookmarkletTandoor!==undefined){ \
bookmarkletTandoor(); \
@@ -146,8 +146,8 @@ def bookmarklet(request):
localStorage.setItem('token', '" + api_token.__str__() + "'); \
document.body.appendChild(document.createElement(\'script\')).src=\'" \
+ server + prefix + static('js/bookmarklet.js') + "? \
r=\'+Math.floor(Math.random()*999999999);}})();"
return re.sub(r"[\n\t\s]*", "", bookmark)
r=\'+Math.floor(Math.random()*999999999);}})();'>Test</a>"
return re.sub(r"[\n\t]*", "", bookmark)
@register.simple_tag

View File

@@ -23,8 +23,8 @@ def test_list_permission(arg, request):
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 10
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0
with scopes_disabled():
recipe_1_s1.space = space_2
@@ -32,8 +32,8 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 10
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 10
@pytest.mark.parametrize("arg", [

View File

@@ -230,4 +230,4 @@ def test_shopping_with_header_ingredient(u1_s1, recipe):
# recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1))
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)) == 11
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)['results']) == 11

View File

@@ -133,6 +133,7 @@ def validate_recipe(expected, recipe):
for key in expected_lists:
for k in expected_lists[key]:
try:
print('comparing ', any([dict_compare(k, i) for i in target_lists[key]]))
assert any([dict_compare(k, i) for i in target_lists[key]])
except AssertionError:
for result in [dict_compare(k, i, details=True) for i in target_lists[key]]:

File diff suppressed because it is too large Load Diff

View File

@@ -4,39 +4,39 @@ from cookbook.helper.ingredient_parser import IngredientParser
def test_ingredient_parser():
expectations = {
"2¼ l Wasser": (2.25, "l", "Wasser", ""),
"2¼l Wasser": (2.25, "l", "Wasser", ""),
"3¼l Wasser": (3.25, "l", "Wasser", ""),
"¼ l Wasser": (0.25, "l", "Wasser", ""),
"3l Wasser": (3, "l", "Wasser", ""),
"4 l Wasser": (4, "l", "Wasser", ""),
"½l Wasser": (0.5, "l", "Wasser", ""),
"⅛ Liter Sauerrahm": (0.125, "Liter", "Sauerrahm", ""),
"5 Zwiebeln": (5, "", "Zwiebeln", ""),
"3 Zwiebeln, gehackt": (3, "", "Zwiebeln", "gehackt"),
"5 Zwiebeln (gehackt)": (5, "", "Zwiebeln", "gehackt"),
"1 Zwiebel(n)": (1, "", "Zwiebel(n)", ""),
"4 1/2 Zwiebeln": (4.5, "", "Zwiebeln", ""),
"4 ½ Zwiebeln": (4.5, "", "Zwiebeln", ""),
"5 Zwiebeln": (5, None, "Zwiebeln", ""),
"3 Zwiebeln, gehackt": (3, None, "Zwiebeln", "gehackt"),
"5 Zwiebeln (gehackt)": (5, None, "Zwiebeln", "gehackt"),
"1 Zwiebel(n)": (1, None, "Zwiebel(n)", ""),
"4 1/2 Zwiebeln": (4.5, None, "Zwiebeln", ""),
"4 ½ Zwiebeln": (4.5, None, "Zwiebeln", ""),
"1/2 EL Mehl": (0.5, "EL", "Mehl", ""),
"1/2 Zwiebel": (0.5, "", "Zwiebel", ""),
"1/2 Zwiebel": (0.5, None, "Zwiebel", ""),
"1/5g Mehl, gesiebt": (0.2, "g", "Mehl", "gesiebt"),
"1/2 Zitrone, ausgepresst": (0.5, "", "Zitrone", "ausgepresst"),
"etwas Mehl": (0, "", "etwas Mehl", ""),
"Öl zum Anbraten": (0, "", "Öl zum Anbraten", ""),
"n. B. Knoblauch, zerdrückt": (0, "", "n. B. Knoblauch", "zerdrückt"),
"1/2 Zitrone, ausgepresst": (0.5, None, "Zitrone", "ausgepresst"),
"etwas Mehl": (0, None, "etwas Mehl", ""),
"Öl zum Anbraten": (0, None, "Öl zum Anbraten", ""),
"n. B. Knoblauch, zerdrückt": (0, None, "n. B. Knoblauch", "zerdrückt"),
"Kräuter, mediterrane (Oregano, Rosmarin, Basilikum)": (
0, "", "Kräuter, mediterrane", "Oregano, Rosmarin, Basilikum"),
0, None, "Kräuter, mediterrane", "Oregano, Rosmarin, Basilikum"),
"600 g Kürbisfleisch (Hokkaido), geschält, entkernt und geraspelt": (
600, "g", "Kürbisfleisch (Hokkaido)", "geschält, entkernt und geraspelt"),
"Muskat": (0, "", "Muskat", ""),
"Muskat": (0, None, "Muskat", ""),
"200 g Mehl, glattes": (200, "g", "Mehl", "glattes"),
"1 Ei(er)": (1, "", "Ei(er)", ""),
"1 Ei(er)": (1, None, "Ei(er)", ""),
"1 Prise(n) Salz": (1, "Prise(n)", "Salz", ""),
"etwas Wasser, lauwarmes": (0, "", "etwas Wasser", "lauwarmes"),
"Strudelblätter, fertige, für zwei Strudel": (0, "", "Strudelblätter", "fertige, für zwei Strudel"),
"barrel-aged Bourbon": (0, "", "barrel-aged Bourbon", ""),
"golden syrup": (0, "", "golden syrup", ""),
"unsalted butter, for greasing": (0, "", "unsalted butter", "for greasing"),
"unsalted butter , for greasing": (0, "", "unsalted butter", "for greasing"), # trim
"etwas Wasser, lauwarmes": (0, None, "etwas Wasser", "lauwarmes"),
"Strudelblätter, fertige, für zwei Strudel": (0, None, "Strudelblätter", "fertige, für zwei Strudel"),
"barrel-aged Bourbon": (0, None, "barrel-aged Bourbon", ""),
"golden syrup": (0, None, "golden syrup", ""),
"unsalted butter, for greasing": (0, None, "unsalted butter", "for greasing"),
"unsalted butter , for greasing": (0, None, "unsalted butter", "for greasing"), # trim
"1 small sprig of fresh rosemary": (1, "small", "sprig of fresh rosemary", ""),
# does not always work perfectly!
"75 g fresh breadcrumbs": (75, "g", "fresh breadcrumbs", ""),
@@ -49,7 +49,7 @@ def test_ingredient_parser():
"1 Zwiebel gehackt": (1, "Zwiebel", "gehackt", ""),
"1 EL Kokosöl": (1, "EL", "Kokosöl", ""),
"0.5 paket jäst (à 50 g)": (0.5, "paket", "jäst", "à 50 g"),
"ägg": (0, "", "ägg", ""),
"ägg": (0, None, "ägg", ""),
"50 g smör eller margarin": (50, "g", "smör eller margarin", ""),
"3,5 l Wasser": (3.5, "l", "Wasser", ""),
"3.5 l Wasser": (3.5, "l", "Wasser", ""),
@@ -58,7 +58,15 @@ def test_ingredient_parser():
"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"),
"Pane (raffermo o secco) 80 g": (0, "", "Pane 80 g", "raffermo o secco"), # TODO this is actually not a good result but currently expected
"Pane (raffermo o secco) 80 g": (80, "g", "Pane", "raffermo o secco"),
"1 Knoblauchzehe(n), gehackt oder gepresst": (1.0, None, 'Knoblauchzehe(n)', 'gehackt oder gepresst'),
# test for over long food entries to get properly split into the note field
"1 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.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')
}
# for German you could say that if an ingredient does not have
# an amount # and it starts with a lowercase letter, then that
@@ -70,4 +78,5 @@ def test_ingredient_parser():
for key, val in expectations.items():
count += 1
parsed = ingredient_parser.parse(key)
assert val == parsed
print(f'testing if {key} becomes {val}')
assert parsed == val

View File

@@ -25,8 +25,8 @@ DATA_DIR = "cookbook/tests/other/test_data/"
@pytest.mark.parametrize("arg", [
['a_u', 302],
['g1_s1', 302],
['u1_s1', 400],
['a1_s1', 400],
['u1_s1', 405],
['a1_s1', 405],
])
def test_import_permission(arg, request):
c = request.getfixturevalue(arg[0])
@@ -36,13 +36,13 @@ def test_import_permission(arg, request):
@pytest.mark.parametrize("arg", [
ALLRECIPES,
# test of custom scraper ATK
AMERICAS_TEST_KITCHEN,
# AMERICAS_TEST_KITCHEN, #TODO while the import trough the UI works the test fails for some reason, find out why
CHEF_KOCH,
# test for empty ingredient in ingredient_parser
CHEF_KOCH2,
COOKPAD,
# test of custom scraper ATK
COOKS_COUNTRY,
#COOKS_COUNTRY, #TODO while the import trough the UI works the test fails for some reason, find out why
DELISH,
FOOD_NETWORK,
GIALLOZAFFERANO,
@@ -53,12 +53,12 @@ def test_import_permission(arg, request):
MARMITON,
TASTE_OF_HOME,
# example of non-json recipes_scraper
THE_SPRUCE_EATS,
# THE_SPRUCE_EATS, #TODO seems to be broken in recipe scrapers
TUDOGOSTOSO,
])
def test_recipe_import(arg, u1_s1):
url = arg['url']
for f in list(arg['file']) : # url and files get popped later
for f in list(arg['file']): # url and files get popped later
if 'cookbook' in os.getcwd():
test_file = os.path.join(os.getcwd(), 'other', 'test_data', f)
else:
@@ -69,9 +69,7 @@ def test_recipe_import(arg, u1_s1):
{
'data': d.read(),
'url': url,
'mode': 'source'
},
files={'foo': 'bar'}
)
content_type='application/json')
recipe = json.loads(response.content)['recipe_json']
validate_recipe(arg, recipe)

View File

@@ -47,7 +47,6 @@ router.register(r'user-name', api.UserNameViewSet, basename='username')
router.register(r'user-preference', api.UserPreferenceViewSet)
router.register(r'view-log', api.ViewLogViewSet)
urlpatterns = [
path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'),
@@ -65,13 +64,12 @@ urlpatterns = [
path('books/', views.books, name='view_books'),
path('plan/', views.meal_plan, name='view_plan'),
path('plan/entry/<int:pk>', views.meal_plan_entry, name='view_plan_entry'),
path('shopping/', views.shopping_list, name='view_shopping'),
path('shopping/<int:pk>', views.shopping_list, name='view_shopping'),
path('shopping/latest/', views.latest_shopping_list, name='view_shopping_latest'),
path('shopping/new/', lists.shopping_list_new, name='view_shopping_new'),
path('shopping/latest/', lists.shopping_list, name='view_shopping_latest'),
path('shopping/', lists.shopping_list, name='view_shopping'),
path('settings/', views.user_settings, name='view_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'),
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
path('import/', import_export.import_recipe, name='view_import'),
@@ -116,7 +114,8 @@ urlpatterns = [
path('api/share-link/<int:pk>', api.share_link, name='api_share_link'),
path('api/get_facets/', api.get_facets, name='api_get_facets'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'), # TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
# TODO is this deprecated? not yet, some old forms remain, could likely be changed to generic API endpoints
path('dal/food/', dal.IngredientsAutocomplete.as_view(), name='dal_food'), # TODO is this deprecated?
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'), # TODO is this deprecated?

View File

@@ -1,10 +1,12 @@
import io
import json
import mimetypes
import re
import uuid
from collections import OrderedDict
import requests
from PIL import UnidentifiedImageError
from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None
from django.contrib import messages
@@ -23,6 +25,7 @@ from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me
from requests.exceptions import MissingSchema
from rest_framework import decorators, status, viewsets
from rest_framework.exceptions import APIException, PermissionDenied
from rest_framework.pagination import PageNumberPagination
@@ -68,7 +71,7 @@ from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializ
SupermarketCategorySerializer, SupermarketSerializer,
SyncLogSerializer, SyncSerializer, UnitSerializer,
UserFileSerializer, UserNameSerializer, UserPreferenceSerializer,
ViewLogSerializer)
ViewLogSerializer, IngredientSimpleSerializer, BookmarkletImportListSerializer)
from recipes import settings
@@ -119,14 +122,17 @@ class ExtendedRecipeMixin():
# add a recipe count annotation to the query
# explanation on construction https://stackoverflow.com/a/43771738/15762829
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(recipe_filter).annotate(count=Count('pk')).values('count')
recipe_count = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).values(
recipe_filter).annotate(count=Count('pk')).values('count')
queryset = queryset.annotate(recipe_count=Coalesce(Subquery(recipe_count), 0))
# add a recipe image annotation to the query
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
image_subquery = Recipe.objects.filter(**{recipe_filter: OuterRef('id')}, space=space).exclude(
image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
if tree:
image_children_subquery = Recipe.objects.filter(**{f"{recipe_filter}__path__startswith": OuterRef('path')},
space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
image_children_subquery = Recipe.objects.filter(
**{f"{recipe_filter}__path__startswith": OuterRef('path')},
space=space).exclude(image__isnull=True).exclude(image__exact='').order_by("?").values('image')[:1]
else:
image_children_subquery = None
if images:
@@ -142,11 +148,14 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
def get_queryset(self):
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
query = self.request.query_params.get('query', None)
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.trigram.values_list('field', flat=True)])
fuzzy = self.request.user.searchpreference.lookup or any([self.model.__name__.lower() in x for x in
self.request.user.searchpreference.trigram.values_list(
'field', flat=True)])
if query is not None and query not in ["''", '']:
if fuzzy:
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
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))
else:
self.queryset = self.queryset.annotate(trigram=TrigramSimilarity('name', query))
@@ -154,7 +163,8 @@ class FuzzyFilterMixin(ViewSetMixin, ExtendedRecipeMixin):
else:
# TODO have this check unaccent search settings or other search preferences?
filter = Q(name__icontains=query)
if any([self.model.__name__.lower() in x for x in self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
if any([self.model.__name__.lower() in x for x in
self.request.user.searchpreference.unaccent.values_list('field', flat=True)]):
filter |= Q(name__unaccent__icontains=query)
self.queryset = (
@@ -275,10 +285,12 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin, ExtendedRecipeMixin):
except self.model.DoesNotExist:
self.queryset = self.model.objects.none()
else:
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request, serializer=self.serializer_class, tree=True)
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request,
serializer=self.serializer_class, tree=True)
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class, tree=True)
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
tree=True)
@decorators.action(detail=True, url_path='move/(?P<parent>[^/.]+)', methods=['PUT'], )
@decorators.renderer_classes((TemplateHTMLRenderer, JSONRenderer))
@@ -454,12 +466,16 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
pagination_class = DefaultPagination
def get_queryset(self):
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [self.request.user.id]
self.request._shared_users = [x.id for x in list(self.request.user.get_shopping_share())] + [
self.request.user.id]
self.queryset = super().get_queryset()
shopping_status = ShoppingListEntry.objects.filter(space=self.request.space, food=OuterRef('id'), checked=False).values('id')
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').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
@@ -470,7 +486,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
shared_users = list(self.request.user.get_shopping_share())
shared_users.append(request.user)
if request.data.get('_delete', False) == 'true':
ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space, created_by__in=shared_users).delete()
ShoppingListEntry.objects.filter(food=obj, checked=False, space=request.space,
created_by__in=shared_users).delete()
content = {'msg': _(f'{obj.name} was removed from the shopping list.')}
return Response(content, status=status.HTTP_204_NO_CONTENT)
@@ -478,7 +495,8 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
unit = request.data.get('unit', None)
content = {'msg': _(f'{obj.name} was added to the shopping list.')}
ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space, created_by=request.user)
ShoppingListEntry.objects.create(food=obj, amount=amount, unit=unit, space=request.space,
created_by=request.user)
return Response(content, status=status.HTTP_204_NO_CONTENT)
def destroy(self, *args, **kwargs):
@@ -576,9 +594,24 @@ class IngredientViewSet(viewsets.ModelViewSet):
queryset = Ingredient.objects
serializer_class = IngredientSerializer
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
def get_serializer_class(self):
if self.request and self.request.query_params.get('simple', False):
return IngredientSimpleSerializer
return IngredientSerializer
def get_queryset(self):
return self.queryset.filter(step__recipe__space=self.request.space)
queryset = self.queryset.filter(step__recipe__space=self.request.space)
food = self.request.query_params.get('food', None)
if food and re.match(r'^(\d)+$', food):
queryset = queryset.filter(food_id=food)
unit = self.request.query_params.get('unit', None)
if unit and re.match(r'^(\d)+$', unit):
queryset = queryset.filter(unit_id=unit)
return queryset
class StepViewSet(viewsets.ModelViewSet):
@@ -587,7 +620,8 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser]
pagination_class = DefaultPagination
query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
]
schema = QueryParamAutoSchema()
@@ -631,33 +665,63 @@ class RecipeViewSet(viewsets.ModelViewSet):
pagination_class = RecipePagination
query_params = [
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'),
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'),
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'),
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='query', description=_(
'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_(
'ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'),
qtype='int'),
QueryParam(name='keywords_or',
description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'),
qtype='int'),
QueryParam(name='keywords_and',
description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'),
qtype='int'),
QueryParam(name='keywords_or_not',
description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'),
qtype='int'),
QueryParam(name='keywords_and_not',
description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'),
qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
qtype='int'),
QueryParam(name='foods_or',
description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='foods_and',
description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
QueryParam(name='foods_or_not',
description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
QueryParam(name='foods_and_not',
description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='rating', description=_(
'Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
QueryParam(name='books_or',
description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
QueryParam(name='books_and',
description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not',
description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='books_and_not',
description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='internal',
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random',
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new',
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_(
'Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_(
'Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_(
'Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_(
'Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_(
'Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow',
description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
]
schema = QueryParamAutoSchema()
@@ -672,7 +736,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
if not (share and self.detail):
self.queryset = self.queryset.filter(space=self.request.space)
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x in list(self.request.GET)}
params = {x: self.request.GET.get(x) if len({**self.request.GET}[x]) == 1 else self.request.GET.getlist(x) for x
in list(self.request.GET)}
search = RecipeSearch(self.request, **params)
self.queryset = search.get_queryset(self.queryset).prefetch_related('cooklog_set')
return self.queryset
@@ -706,20 +771,35 @@ class RecipeViewSet(viewsets.ModelViewSet):
serializer = self.serializer_class(obj, data=request.data, partial=True)
if self.request.space.demo:
raise PermissionDenied(detail='Not available in demo', code=None)
if serializer.is_valid():
serializer.save()
image = None
filetype = ".jpeg" # fall-back to .jpeg, even if wrong, at least users will know it's an image and most image viewers can open it correctly anyways
if serializer.validated_data == {}:
obj.image = None
else:
img, filetype = handle_image(request, obj.image)
if 'image' in serializer.validated_data:
image = obj.image
filetype = mimetypes.guess_extension(serializer.validated_data['image'].content_type) or filetype
elif 'image_url' in serializer.validated_data:
try:
response = requests.get(serializer.validated_data['image_url'])
image = File(io.BytesIO(response.content))
filetype = mimetypes.guess_extension(response.headers['content-type']) or filetype
except UnidentifiedImageError as e:
print(e)
pass
except MissingSchema as e:
print(e)
pass
except Exception as e:
print(e)
pass
if image is not None:
img = handle_image(request, image, filetype)
obj.image = File(img, name=f'{uuid.uuid4()}_{obj.pk}{filetype}')
obj.save()
obj.save()
return Response(serializer.data)
return Response(serializer.data)
return Response(serializer.errors, 400)
# TODO: refactor API to use post/put/delete or leave as put and change VUE to use list_recipe after creating
@@ -770,7 +850,8 @@ class RecipeViewSet(viewsets.ModelViewSet):
levels = int(request.query_params.get('levels', 1))
except (ValueError, TypeError):
levels = 1
qs = obj.get_related_recipes(levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
qs = obj.get_related_recipes(
levels=levels) # TODO: make levels a user setting, included in request data?, keep solely in the backend?
return Response(self.serializer_class(qs, many=True).data)
@@ -780,7 +861,8 @@ class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
self.queryset = self.queryset.filter(Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
self.queryset = self.queryset.filter(
Q(shoppinglist__space=self.request.space) | Q(entries__space=self.request.space))
return self.queryset.filter(
Q(shoppinglist__created_by=self.request.user)
| Q(shoppinglist__shared=self.request.user)
@@ -794,12 +876,17 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner | CustomIsShared]
query_params = [
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
QueryParam(name='id',
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
qtype='int'),
QueryParam(
name='checked',
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
description=_(
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
QueryParam(name='supermarket',
description=_('Returns the shopping list entries sorted by supermarket category order.'),
qtype='int'),
]
schema = QueryParamAutoSchema()
@@ -891,6 +978,11 @@ class BookmarkletImportViewSet(viewsets.ModelViewSet):
serializer_class = BookmarkletImportSerializer
permission_classes = [CustomIsUser]
def get_serializer_class(self):
if self.action == 'list':
return BookmarkletImportListSerializer
return self.serializer_class
def get_queryset(self):
return self.queryset.filter(space=self.request.space).all()
@@ -926,6 +1018,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
space=self.request.space).distinct()
return super().get_queryset()
# -------------- non django rest api views --------------------
@@ -1063,102 +1156,62 @@ def get_plan_ical(request, from_date, to_date):
@group_required('user')
def recipe_from_source(request):
url = request.POST.get('url', None)
data = request.POST.get('data', None)
mode = request.POST.get('mode', None)
auto = request.POST.get('auto', 'true')
"""
function to retrieve a recipe from a given url or source string
:param request: standard request with additional post parameters
- url: url to use for importing recipe
- data: if no url is given recipe is imported from provided source data
- (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes
:return: JsonResponse containing the parsed json, original html,json and images
"""
if request.method == 'GET':
return HttpResponse(status=405)
request_payload = json.loads(request.body.decode('utf-8'))
url = request_payload.get('url', None)
data = request_payload.get('data', None)
bookmarklet = request_payload.get('bookmarklet', None)
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7"
}
if bookmarklet := BookmarkletImport.objects.filter(pk=bookmarklet).first():
url = bookmarklet.url
data = bookmarklet.html
bookmarklet.delete()
if (not url and not data) or (mode == 'url' and not url) or (mode == 'source' and not data):
return JsonResponse(
{
'error': True,
'msg': _('Nothing to do.')
},
status=400
)
# headers to use for request to external sites
external_request_headers = {"User-Agent": "Mozilla/5.0 (Windows; U; Windows NT 5.1; en-US; rv:1.9.0.7) Gecko/2009021910 Firefox/3.0.7"}
if mode == 'url' and auto == 'true':
if not url and not data:
return JsonResponse({
'error': True,
'msg': _('Nothing to do.')
}, status=400)
# in manual mode request complete page to return it later
if url:
try:
scrape = scrape_me(url)
except (WebsiteNotImplementedError, AttributeError):
try:
scrape = scrape_me(url, wild_mode=True)
except NoSchemaFoundInWildMode:
return JsonResponse(
{
'error': True,
'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
},
status=400)
except ConnectionError:
return JsonResponse(
{
'error': True,
'msg': _('The requested page could not be found.')
},
status=400
)
try:
instructions = scrape.instructions()
except Exception:
instructions = ""
try:
ingredients = scrape.ingredients()
except Exception:
ingredients = []
if len(ingredients) + len(instructions) == 0:
return JsonResponse(
{
'error': True,
'msg': _(
'The requested site does not provide any recognized data format to import the recipe from.')
# noqa: E501
},
status=400)
else:
return JsonResponse({"recipe_json": get_from_scraper(scrape, request)})
elif (mode == 'source') or (mode == 'url' and auto == 'false'):
if not data or data == 'undefined':
try:
data = requests.get(url, headers=HEADERS).content
except requests.exceptions.ConnectionError:
return JsonResponse(
{
'error': True,
'msg': _('Connection Refused.')
},
status=400
)
recipe_json, recipe_tree, recipe_html, images = get_recipe_from_source(data, url, request)
if len(recipe_tree) == 0 and len(recipe_json) == 0:
return JsonResponse(
{
'error': True,
'msg': _('No usable data could be found.')
},
status=400
)
else:
data = requests.get(url, headers=external_request_headers).content
except requests.exceptions.ConnectionError:
return JsonResponse({
'recipe_tree': recipe_tree,
'recipe_json': recipe_json,
'recipe_html': recipe_html,
'images': images,
})
else:
return JsonResponse(
{
'error': True,
'msg': _('I couldn\'t find anything to do.')
},
status=400
)
'msg': _('Connection Refused.')
}, status=400)
except requests.exceptions.MissingSchema:
return JsonResponse({
'error': True,
'msg': _('Bad URL Schema.')
}, status=400)
recipe_json, recipe_tree, recipe_html, recipe_images = get_recipe_from_source(data, url, request)
if len(recipe_tree) == 0 and len(recipe_json) == 0:
return JsonResponse({
'error': True,
'msg': _('No usable data could be found.')
}, status=400)
else:
return JsonResponse({
'recipe_json': recipe_json,
'recipe_tree': recipe_tree,
'recipe_html': recipe_html,
'recipe_images': list(dict.fromkeys(recipe_images)),
})
@group_required('admin')

View File

@@ -1,41 +1,27 @@
import json
import uuid
from datetime import datetime
from io import BytesIO
import requests
from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist
from django.core.files import File
from django.db.transaction import atomic
from django.http import HttpResponse, HttpResponseRedirect
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import ngettext
from django_tables2 import RequestConfig
from PIL import UnidentifiedImageError
from requests.exceptions import MissingSchema
from rest_framework.authtoken.models import Token
from cookbook.forms import BatchEditForm, SyncForm
from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.permission_helper import group_required, has_group_permission
from cookbook.helper.recipe_url_import import parse_cooktime
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe, RecipeImport, Step, Sync,
Unit, UserPreference)
from cookbook.helper.permission_helper import group_required, has_group_permission, above_space_limit
from cookbook.models import (Comment, Food, Keyword, Recipe, RecipeImport, Sync,
Unit, UserPreference, BookmarkletImport)
from cookbook.tables import SyncTable
from recipes import settings
@group_required('user')
def sync(request):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
return HttpResponseRedirect(reverse('index'))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('index'))
if request.space.demo or settings.HOSTED:
@@ -123,103 +109,21 @@ def batch_edit(request):
@group_required('user')
@atomic
def import_url(request):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('index'))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
return HttpResponseRedirect(reverse('index'))
if request.method == 'POST':
data = json.loads(request.body)
data['cookTime'] = parse_cooktime(data.get('cookTime', ''))
data['prepTime'] = parse_cooktime(data.get('prepTime', ''))
recipe = Recipe.objects.create(
name=data['name'],
description=data['description'],
waiting_time=data['cookTime'],
working_time=data['prepTime'],
servings=data['servings'],
internal=True,
created_by=request.user,
space=request.space,
)
step = Step.objects.create(
instruction=data['recipeInstructions'], space=request.space,
)
recipe.steps.add(step)
for kw in data['keywords']:
if data['all_keywords']: # do not remove this check :) https://github.com/vabene1111/recipes/issues/645
k, created = Keyword.objects.get_or_create(name=kw['text'], space=request.space)
recipe.keywords.add(k)
else:
try:
k = Keyword.objects.get(name=kw['text'], space=request.space)
recipe.keywords.add(k)
except ObjectDoesNotExist:
pass
ingredient_parser = IngredientParser(request, True)
for ing in data['recipeIngredient']:
original = ing.pop('original', None) or ing.pop('original_text', None)
ingredient = Ingredient(original_text=original, space=request.space, )
if food_text := ing['ingredient']['text'].strip():
ingredient.food = ingredient_parser.get_food(food_text)
if ing['unit']:
if unit_text := ing['unit']['text'].strip():
ingredient.unit = ingredient_parser.get_unit(unit_text)
# TODO properly handle no_amount recipes
if isinstance(ing['amount'], str):
try:
ingredient.amount = float(ing['amount'].replace(',', '.'))
except ValueError:
ingredient.no_amount = True
pass
elif isinstance(ing['amount'], float) \
or isinstance(ing['amount'], int):
ingredient.amount = ing['amount']
ingredient.note = ing['note'].strip() if 'note' in ing else ''
ingredient.save()
step.ingredients.add(ingredient)
if 'image' in data and data['image'] != '' and data['image'] is not None:
try:
response = requests.get(data['image'])
img, filetype = handle_image(request, File(BytesIO(response.content), name='image'))
recipe.image = File(
img, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}'
)
recipe.save()
except UnidentifiedImageError as e:
print(e)
pass
except MissingSchema as e:
print(e)
pass
except Exception as e:
print(e)
pass
return HttpResponse(reverse('view_recipe', args=[recipe.pk]))
if (api_token := Token.objects.filter(user=request.user).first()) is None:
api_token = Token.objects.create(user=request.user)
bookmarklet_import_id = -1
if 'id' in request.GET:
context = {'bookmarklet': request.GET.get('id', '')}
else:
context = {}
if bookmarklet_import := BookmarkletImport.objects.filter(id=request.GET['id']).first():
bookmarklet_import_id = bookmarklet_import.pk
return render(request, 'url_import.html', context)
return render(request, 'url_import.html', {'api_token': api_token, 'bookmarklet_import_id': bookmarklet_import_id})
class Object(object):

View File

@@ -9,7 +9,7 @@ from django.views.generic import UpdateView
from django.views.generic.edit import FormMixin
from cookbook.forms import CommentForm, ExternalRecipeForm, MealPlanForm, StorageForm, SyncForm
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required, above_space_limit
from cookbook.models import (Comment, MealPlan, MealType, Recipe, RecipeImport, Storage, Sync,
UserPreference)
from cookbook.provider.dropbox import Dropbox
@@ -39,12 +39,9 @@ def convert_recipe(request, pk):
@group_required('user')
def internal_recipe_update(request, pk):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() > request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('view_recipe', args=[pk]))
recipe_instance = get_object_or_404(Recipe, pk=pk, space=request.space)

View File

@@ -10,16 +10,18 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from cookbook.forms import ExportForm, ImportExportBase, ImportForm
from cookbook.helper.permission_helper import group_required
from cookbook.helper.permission_helper import group_required, above_space_limit
from cookbook.helper.recipe_search import RecipeSearch
from cookbook.integration.cheftap import ChefTap
from cookbook.integration.chowdown import Chowdown
from cookbook.integration.cookbookapp import CookBookApp
from cookbook.integration.cookmate import Cookmate
from cookbook.integration.copymethat import CopyMeThat
from cookbook.integration.default import Default
from cookbook.integration.domestica import Domestica
from cookbook.integration.mealie import Mealie
from cookbook.integration.mealmaster import MealMaster
from cookbook.integration.melarecipes import MelaRecipes
from cookbook.integration.nextcloud_cookbook import NextcloudCookbook
from cookbook.integration.openeats import OpenEats
from cookbook.integration.paprika import Paprika
@@ -74,16 +76,17 @@ def get_integration(request, export_type):
return CopyMeThat(request, export_type)
if export_type == ImportExportBase.PDF:
return PDFexport(request, export_type)
if export_type == ImportExportBase.MELARECIPES:
return MelaRecipes(request, export_type)
if export_type == ImportExportBase.COOKMATE:
return Cookmate(request, export_type)
@group_required('user')
def import_recipe(request):
if request.space.max_recipes != 0 and Recipe.objects.filter(space=request.space).count() >= request.space.max_recipes: # TODO move to central helper function
messages.add_message(request, messages.WARNING, _('You have reached the maximum number of recipes for your space.'))
return HttpResponseRedirect(reverse('index'))
if request.space.max_users != 0 and UserPreference.objects.filter(space=request.space).count() > request.space.max_users:
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
limit, msg = above_space_limit(request.space)
if limit:
messages.add_message(request, messages.WARNING, msg)
return HttpResponseRedirect(reverse('index'))
if request.method == "POST":
@@ -100,7 +103,7 @@ def import_recipe(request):
t.setDaemon(True)
t.start()
return JsonResponse({'import_id': [il.pk]})
return JsonResponse({'import_id': il.pk})
except NotImplementedError:
return JsonResponse(
{

View File

@@ -1,14 +1,13 @@
from datetime import datetime
from django.db.models import Q, Sum
from django.db.models import Sum
from django.shortcuts import render
from django.utils.translation import gettext as _
from django_tables2 import RequestConfig
from cookbook.filters import ShoppingListFilter
from cookbook.helper.permission_helper import group_required
from cookbook.models import InviteLink, RecipeImport, ShoppingList, Storage, SyncLog, UserFile
from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable, ShoppingListTable,
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile
from cookbook.tables import (ImportLogTable, InviteLinkTable, RecipeImportTable,
StorageTable)
@@ -41,20 +40,12 @@ def recipe_import(request):
@group_required('user')
def shopping_list(request):
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(space=request.space).filter(
Q(created_by=request.user) | Q(shared=request.user)).distinct().all().order_by('finished', 'created_at'))
table = ShoppingListTable(f.qs)
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(
request,
'generic/list_template.html',
'shoppinglist_template.html',
{
'title': _("Shopping Lists"),
'table': table,
'filter': f,
'create_url': 'view_shopping'
"title": _("Shopping List"),
}
)
@@ -205,7 +196,7 @@ def custom_filter(request):
def user_file(request):
try:
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
'file_size_kb__sum'] / 1000
'file_size_kb__sum'] / 1000
except TypeError:
current_file_size_mb = 0
@@ -237,15 +228,3 @@ def step(request):
}
}
)
@group_required('user')
def shopping_list_new(request):
return render(
request,
'shoppinglist_template.html',
{
"title": _("New Shopping List"),
}
)

View File

@@ -11,9 +11,9 @@ 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 Avg, Q, Sum
from django.db.models import Avg, Q
from django.db.models.functions import Lower
from django.http import HttpResponseRedirect, JsonResponse
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
@@ -27,9 +27,9 @@ from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, ShoppingP
SpaceCreateForm, SpaceJoinForm, SpacePreferenceForm, User,
UserCreateForm, UserNameForm, UserPreference, UserPreferenceForm)
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid
from cookbook.models import (Comment, CookLog, Food, FoodInheritField, InviteLink, Keyword,
from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword,
MealPlan, RecipeImport, SearchFields, SearchPreference, ShareLink,
ShoppingList, Space, Unit, UserFile, ViewLog)
Space, Unit, ViewLog)
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
ViewLogTable)
from cookbook.views.data import Object
@@ -61,7 +61,8 @@ def search(request):
if request.user.userpreference.search_style == UserPreference.NEW:
return search_v2(request)
f = RecipeFilter(request.GET,
queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by(Lower('name').asc()),
queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by(
Lower('name').asc()),
space=request.space)
if request.user.userpreference.search_style == UserPreference.LARGE:
table = RecipeTable(f.qs)
@@ -225,6 +226,19 @@ def supermarket(request):
return render(request, 'supermarket.html', {})
@group_required('user')
def ingredient_editor(request):
template_vars = {'food_id': -1, 'unit_id': -1}
food_id = request.GET.get('food_id', None)
if food_id and re.match(r'^(\d)+$', food_id):
template_vars['food_id'] = food_id
unit_id = request.GET.get('unit_id', None)
if unit_id and re.match(r'^(\d)+$', unit_id):
template_vars['unit_id'] = unit_id
return render(request, 'ingredient_editor.html', template_vars)
@group_required('user')
def meal_plan_entry(request, pk):
plan = MealPlan.objects.filter(space=request.space).get(pk=pk)
@@ -242,35 +256,6 @@ def meal_plan_entry(request, pk):
return render(request, 'meal_plan_entry.html', {'plan': plan, 'same_day_plan': same_day_plan})
@group_required('user')
def latest_shopping_list(request):
sl = ShoppingList.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).filter(finished=False,
space=request.space).order_by(
'-created_at').first()
if sl:
return HttpResponseRedirect(reverse('view_shopping', kwargs={'pk': sl.pk}) + '?edit=true')
else:
return HttpResponseRedirect(reverse('view_shopping') + '?edit=true')
@group_required('user')
def shopping_list(request, pk=None): # TODO deprecate
html_list = request.GET.getlist('r')
recipes = []
for r in html_list:
r = r.replace('[', '').replace(']', '')
if len(r) < 10000 and re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
rid, multiplier = r.split(',')
if recipe := Recipe.objects.filter(pk=int(rid), space=request.space).first():
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
edit = True if 'edit' in request.GET and request.GET['edit'] == 'true' else False
return render(request, 'shopping_list.html', {'shopping_list_id': pk, 'recipes': recipes, 'edit': edit})
@group_required('guest')
def user_settings(request):
if request.space.demo:
@@ -304,6 +289,7 @@ def user_settings(request):
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()
@@ -327,10 +313,10 @@ def user_settings(request):
if not sp:
sp = SearchPreferenceForm(user=request.user)
fields_searched = (
len(search_form.cleaned_data['icontains'])
+ len(search_form.cleaned_data['istartswith'])
+ len(search_form.cleaned_data['trigram'])
+ len(search_form.cleaned_data['fulltext'])
len(search_form.cleaned_data['icontains'])
+ len(search_form.cleaned_data['istartswith'])
+ len(search_form.cleaned_data['trigram'])
+ len(search_form.cleaned_data['fulltext'])
)
if fields_searched == 0:
search_form.add_error(None, _('You must select at least one field to search!'))
@@ -647,11 +633,15 @@ def test(request):
if not settings.DEBUG:
return HttpResponseRedirect(reverse('index'))
with scopes_disabled():
result = ShoppingList.objects.filter(
Q(created_by=request.user) | Q(shared=request.user)).filter(
space=request.space).values().distinct()
return JsonResponse(list(result), safe=False, json_dumps_params={'indent': 2})
from cookbook.helper.ingredient_parser import IngredientParser
parser = IngredientParser(request, False)
data = {
'original': '1 LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl'
}
data['parsed'] = parser.parse(data['original'])
return render(request, 'test.html', {'data': data})
def test2(request):

View File

@@ -15,13 +15,13 @@ Code contributions are always welcome. There is no special rules for what you ne
just do your best and we will work together to get your idea and code merged into the project.
!!! info
The dev setup is a little messy as this application combines the best (at least in my opinion) of django and Vue.js.
The dev setup is a little messy as this application combines the best (at least in my opinion) of both Django and Vue.js.
### Django
This application is developed using the Django framework for Python. They have excellent
[documentation](https://www.djangoproject.com/start/) on how to get started, so I will only give you the basics here.
1. Clone this repository wherever you like and install the Python language for your OS (at least version 3.8)
1. Clone this repository wherever you like and install the Python language for your OS (I recommend using version 3.10 or above)
2. Open it in your favorite editor/IDE (e.g. PyCharm)
1. If you want, create a virtual environment for all your packages.
3. Install all required packages: `pip install -r requirements.txt`
@@ -32,12 +32,13 @@ There is **no** need to set any environment variables. By default, a simple sqli
populated from default values.
### Vue.js
Some of the more complex pages use [Vue.js](https://vuejs.org/) to enhance the frontend.
Most new frontend pages are build using [Vue.js](https://vuejs.org/).
In order to work on these pages you will have to install a Javascript package manager of your choice. The following examples use yarn.
Run `yarn install` to install the dependencies. After that you can use `yarn serve` to start the development server
and go ahead and test your changes. Before committing please make sure to pack the source using `yarn build`.
and go ahead and test your changes. If you do not want to work on those pages but want the application to work properly during
development run `yarn build` to build the frontend pages once.
#### API Client
The API Client is generated automatically from the openapi interface provided by the django rest framework.
@@ -51,11 +52,7 @@ Generate the schema using `openapi-generator-cli generate -g typescript-axios -i
## Contribute Documentation
The documentation is build from the markdown files in the [docs](https://github.com/vabene1111/recipes/tree/develop/docs)
folder of the GitHub repository.
!!! warning "Deployment Branch"
The documentation is currently build from the `develop` branch of the GitHub repository as it is evolving rapidly.
This will likely change in the future to prevent issues with documentation being released before the features.
folder of the GitHub repository.
In order to contribute to the documentation you can fork the repository and edit the markdown files in the browser.
@@ -69,10 +66,6 @@ If you know any foreign languages that is not yet translated feel free to contri
Translations are managed on [translate.tandoor.dev](https://translate.tandoor.dev/), a self hosted instance of [Weblate](https://weblate.org/de/).
!!! info "Weblate functionality"
Translations have only recently been migrated to weblate so I do not 100% understand each feature.
Please feel free to contact me if you need any help getting started.
You can simply register an account and then follow these steps to add translations:
1. After registering you are asked to select your languages. This is optional but allows weblate to only show you relevant translations
@@ -80,13 +73,15 @@ You can simply register an account and then follow these steps to add translatio
3. Select Tandoor and on the top right hand corner select `Watch project Tandoor` (click on `Not watching`)
4. Go back to the dashboard. It now shows you the relevant translations for your languages. Click the pencil icon to get started.
!!!! info "Creating a new languagte"
!!! info "Creating a new language"
To create a new language you must first select Tandoor (the project) and then a component.
Here you will have the option to add the language. Afterwards you can also simply add it to the other components as well.
Once a new language is (partially) finished let me know on GitHub so I can add it to the language switcher in Tandoor itself.
There is also [a lot of documentation](https://docs.weblate.org/en/latest/user/translating.html) available from Weblate directly.
![2021-04-11_16-03](https://user-images.githubusercontent.com/6819595/114307359-926e0380-9adf-11eb-9a2b-febba56e4d8c.gif)
It is also possible to provide the translations directly by creating a new language
using `manage.py makemessages -l <language_code> -i venv`. Once finished, simply open a PR with the changed files.
using `manage.py makemessages -l <language_code> -i venv`. Once finished, simply open a PR with the changed files. This sometimes causes issues merging
with weblate so I would prefer the use of weblate.

View File

@@ -38,6 +38,8 @@ Overview of the capabilities of the different integrations.
| Plantoeat | ✔️ | ❌ | ✔ |
| CookBookApp | ✔️ | ⌚ | ✔️ |
| CopyMeThat | ✔️ | ❌ | ✔️ |
| Melarecipes | ✔️ | ⌚ | ✔️ |
| Cookmate | ✔️ | ⌚ | ✔️ |
| PDF (experimental) | ⌚️ | ✔️ | ✔️ |
✔️ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
@@ -225,6 +227,19 @@ CookBookApp can export .zip files containing .html files. Upload the entire ZIP
CopyMeThat can export .zip files containing an `.html` file as well as a folder containing all the images. Upload the entire ZIP to Tandoor to import all included recipes.
## Cookmate
Cookmate allows you to export a `.mcb` file which you can simply upload to tandoor and import all your recipes.
## RecetteTek
RecetteTek exports are `.rtk` files which can simply be uploaded to tandoor to import all your recipes.
## Melarecipes
Melarecipes provides multiple export formats but only the `MelaRecipes` format can export the complete collection.
Perform this export and open the `.melarecipes` file using your favorite archive opening program (e.g 7zip).
Repeat this if the file contains another `.melarecipes` file until you get a list of one or many `.melarecipe` files.
Upload all `.melarecipe` files you want to import to tandoor and start the import.
## PDF
The PDF Exporter is an experimental feature that uses the puppeteer browser renderer to render each recipe and export it to PDF.

View File

@@ -109,7 +109,7 @@ in combination with [jrcs's letsencrypt companion](https://hub.docker.com/r/jrcs
Please refer to the appropriate documentation on how to setup the reverse proxy and networks.
!!!!!!warning "Adjust client_max_body_size"
!!! warning "Adjust client_max_body_size"
By using jwilder's Nginx-proxy, uploads will be restricted to 1 MB file size. This can be resolved by adjusting the ```client_max_body_size``` variable in the jwilder nginx configuration.
Remember to add the appropriate environment variables to the `.env` file:

View File

@@ -42,8 +42,8 @@ services:
- web_recipes
volumes:
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static
- ${MEDIA_FILES_DIR:-./mediafiles}:/media
- staticfiles:/static:ro
- ${MEDIA_FILES_DIR:-./mediafiles}:/media:ro
networks:
tandoor:
ipv6_address: ${IPV6_PREFIX:?NO_IPV6_PREFIX}::4

View File

@@ -11,6 +11,7 @@ services:
- default
web_recipes:
restart: always
image: vabene1111/recipes
env_file:
- ./.env
@@ -32,8 +33,8 @@ services:
- web_recipes
volumes:
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static
- ./mediafiles:/media
- staticfiles:/static:ro
- ./mediafiles:/media:ro
networks:
- default
- nginx-proxy

View File

@@ -9,6 +9,7 @@ services:
- ./.env
web_recipes:
restart: always
image: vabene1111/recipes
env_file:
- ./.env
@@ -30,8 +31,8 @@ services:
- web_recipes
volumes:
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static
- ./mediafiles:/media
- staticfiles:/static:ro
- ./mediafiles:/media:ro
volumes:
nginx_config:

View File

@@ -11,6 +11,7 @@ services:
- default
web_recipes:
restart: always
image: vabene1111/recipes
env_file:
- ./.env
@@ -30,8 +31,8 @@ services:
- ./.env
volumes:
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static
- ./mediafiles:/media
- staticfiles:/static:ro
- ./mediafiles:/media:ro
labels: # traefik example labels
- "traefik.enable=true"
- "traefik.http.routers.recipes.rule=Host(`recipes.mydomain.com`, `recipes.myotherdomain.com`)"
@@ -45,7 +46,7 @@ services:
networks:
default:
traefik: # This is you external traefik network
traefik: # This is your external traefik network
external: true
volumes:

View File

@@ -4,7 +4,6 @@ metadata:
labels:
app: recipes
name: recipes-nginx-config
namespace: default
data:
nginx-config: |-
events {

View File

@@ -2,7 +2,6 @@ kind: Secret
apiVersion: v1
metadata:
name: recipes
namespace: default
type: Opaque
data:
# echo -n 'db-password' | base64

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