Compare commits

..

400 Commits

Author SHA1 Message Date
vabene1111
a626bda1ab Merge branch 'develop' 2024-03-05 14:06:56 +01:00
vabene1111
d104974ca8 downgraded home assistant api libnrary 2024-03-05 12:33:02 +01:00
vabene1111
b1a7212fce upgrade container base 2024-03-05 10:36:59 +01:00
vabene1111
eaee474cb7 updated wheel and setuptools rust 2024-03-05 10:34:48 +01:00
vabene1111
64f5b9ad1f Merge pull request #2931 from ambroisie/declarative-media-root
add ability to set 'MEDIA_ROOT'
2024-03-05 09:04:50 +01:00
vabene1111
21f1700d6d Merge branch 'develop'
# Conflicts:
#	cookbook/forms.py
2024-03-05 08:55:33 +01:00
vabene1111
c23df3d474 better shopping placeholder 2024-03-05 08:54:40 +01:00
vabene1111
0f06506f18 fixed device setting supermarkt refresh 2024-03-05 08:39:45 +01:00
vabene1111
56223df80b updated translations 2024-03-05 07:45:43 +01:00
Jocelin Lebreton
fe581e538f Translated using Weblate (French)
Currently translated at 95.2% (540 of 567 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/
2024-03-03 23:19:58 +00:00
M Ugur
88efe7ac8e Translated using Weblate (Turkish)
Currently translated at 6.5% (32 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/tr/
2024-03-03 23:19:58 +00:00
Jocelin Lebreton
be999c726b Translated using Weblate (French)
Currently translated at 96.5% (473 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/fr/
2024-03-03 23:19:58 +00:00
Bruno BELANYI
857d287233 add ability to set 'MEDIA_ROOT' 2024-03-02 14:33:06 +00:00
vabene1111
02ceacd232 Merge pull request #3020 from smilerz/coverage
removed action Publish Results
2024-03-01 22:04:42 +01:00
smilerz
d42308281c remove timeout value on CI action 2024-03-01 14:32:25 -06:00
smilerz
1f09f778c7 removed action Publish Results 2024-03-01 14:13:27 -06:00
vabene1111
2304ec0633 Merge pull request #3016 from smilerz/coverage
Coverage
2024-03-01 19:22:27 +01:00
smilerz
fe358eab16 remove coverage badge from README 2024-03-01 07:59:09 -06:00
Einir Einisson
e656a2da8c Translated using Weblate (Icelandic)
Currently translated at 3.1% (18 of 567 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/is/
2024-03-01 11:19:57 +00:00
vabene1111
2f3beb4f13 Merge pull request #3012 from TandoorRecipes/dependabot/npm_and_yarn/vue/codemirror/lang-markdown-6.2.4
Bump @codemirror/lang-markdown from 6.2.3 to 6.2.4 in /vue
2024-03-01 07:35:19 +01:00
dependabot[bot]
641e65c7ab Bump @codemirror/lang-markdown from 6.2.3 to 6.2.4 in /vue
Bumps [@codemirror/lang-markdown](https://github.com/codemirror/lang-markdown) from 6.2.3 to 6.2.4.
- [Changelog](https://github.com/codemirror/lang-markdown/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/lang-markdown/compare/6.2.3...6.2.4)

---
updated-dependencies:
- dependency-name: "@codemirror/lang-markdown"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 06:34:35 +00:00
vabene1111
d04075732e Merge pull request #3004 from c0mputerguru/ical-default-dates
Support unspecified to/from dates for fetching meal plan ical
2024-03-01 07:33:02 +01:00
vabene1111
678d0dff3a Merge pull request #3014 from TandoorRecipes/dependabot/npm_and_yarn/vue/codemirror/commands-6.3.3
Bump @codemirror/commands from 6.3.2 to 6.3.3 in /vue
2024-03-01 07:31:12 +01:00
dependabot[bot]
ca068a3ae0 Bump @codemirror/commands from 6.3.2 to 6.3.3 in /vue
Bumps [@codemirror/commands](https://github.com/codemirror/commands) from 6.3.2 to 6.3.3.
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/compare/6.3.2...6.3.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-01 00:24:22 +00:00
smilerz
c597aed956 updated branch info 2024-02-29 11:13:25 -06:00
smilerz
7e6f3ad92b add badges to README
publish reports to gh-pages
2024-02-29 11:13:06 -06:00
smilerz
b2c5e3d5e7 Pytest and Coverage Reports 2024-02-29 11:12:19 -06:00
smilerz
9344bf80da added coverage
added pytest reporting
updated Github Action CI/CD to write coverage output to docs
updated GA CI/CD to generate test and coverage badges
2024-02-29 11:11:41 -06:00
smilerz
b78597fa52 updated link to Dependabot documentation 2024-02-29 11:09:52 -06:00
smilerz
75b7397343 created Development dependencies in requirements.txt
exclude installing Dev libraries in Dockerfile
2024-02-29 11:09:46 -06:00
Anand Patel
7b506ff903 Remove unnecessary imports in meal plan ical test. 2024-02-29 16:13:17 +00:00
c0mputerguru
23a6efbb05 Merge branch 'TandoorRecipes:develop' into ical-default-dates 2024-02-29 08:07:21 -08:00
vabene1111
701f631c5f Merge pull request #2998 from TandoorRecipes/dependabot/npm_and_yarn/vue/workbox-expiration-7.0.0
Bump workbox-expiration from 6.6.1 to 7.0.0 in /vue
2024-02-29 16:52:01 +01:00
vabene1111
a829cdd85d Merge pull request #2996 from TandoorRecipes/dependabot/npm_and_yarn/vue/workbox-precaching-7.0.0
Bump workbox-precaching from 6.6.1 to 7.0.0 in /vue
2024-02-29 16:51:52 +01:00
vabene1111
27b2743e82 Merge pull request #2995 from TandoorRecipes/dependabot/pip/django-webpack-loader-3.0.1
Bump django-webpack-loader from 1.8.1 to 3.0.1
2024-02-29 16:51:40 +01:00
vabene1111
095b70d446 Merge pull request #2989 from TandoorRecipes/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2024-02-29 16:51:01 +01:00
vabene1111
da313916db Merge pull request #2991 from TandoorRecipes/dependabot/pip/pyppeteer-2.0.0
Bump pyppeteer from 1.0.2 to 2.0.0
2024-02-29 16:50:53 +01:00
vabene1111
f4ea70081c Merge pull request #2994 from TandoorRecipes/dependabot/pip/lxml-5.1.0
Bump lxml from 4.9.3 to 5.1.0
2024-02-29 16:50:42 +01:00
dependabot[bot]
203e0795ad Bump pyppeteer from 1.0.2 to 2.0.0
Bumps [pyppeteer](https://github.com/pyppeteer/pyppeteer) from 1.0.2 to 2.0.0.
- [Changelog](https://github.com/pyppeteer/pyppeteer/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/pyppeteer/pyppeteer/compare/1.0.2...2.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:50:22 +00:00
vabene1111
ecdeb6b66b Merge pull request #2992 from TandoorRecipes/dependabot/github_actions/actions/cache-4
Bump actions/cache from 3 to 4
2024-02-29 16:50:18 +01:00
vabene1111
003e4a8b37 Merge pull request #2997 from TandoorRecipes/dependabot/npm_and_yarn/vue/codemirror/view-6.24.1
Bump @codemirror/view from 6.23.1 to 6.24.1 in /vue
2024-02-29 16:50:04 +01:00
vabene1111
107984de11 Merge pull request #2993 from TandoorRecipes/dependabot/pip/icalendar-5.0.11
Bump icalendar from 5.0.7 to 5.0.11
2024-02-29 16:49:07 +01:00
dependabot[bot]
70ea97a551 Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:49:02 +00:00
vabene1111
1965a7213a Merge pull request #2990 from TandoorRecipes/dependabot/pip/homeassistant-api-4.2.1
Bump homeassistant-api from 4.1.1.post2 to 4.2.1
2024-02-29 16:48:58 +01:00
dependabot[bot]
9f95f9eb14 Bump @codemirror/view from 6.23.1 to 6.24.1 in /vue
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.23.1 to 6.24.1.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.23.1...6.24.1)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:48:22 +00:00
vabene1111
fd1bdef440 Merge pull request #2988 from TandoorRecipes/dependabot/github_actions/awalsh128/cache-apt-pkgs-action-1.4.1
Bump awalsh128/cache-apt-pkgs-action from 1.3.1 to 1.4.1
2024-02-29 16:47:49 +01:00
vabene1111
60ac24acb0 Merge pull request #2999 from TandoorRecipes/dependabot/npm_and_yarn/vue/vue/composition-api-1.7.2
Bump @vue/composition-api from 1.7.1 to 1.7.2 in /vue
2024-02-29 16:46:57 +01:00
vabene1111
f147f51ba2 Merge pull request #3001 from TandoorRecipes/dependabot/npm_and_yarn/vue/moment-2.30.1
Bump moment from 2.29.4 to 2.30.1 in /vue
2024-02-29 16:46:41 +01:00
dependabot[bot]
b36483410b Bump moment from 2.29.4 to 2.30.1 in /vue
Bumps [moment](https://github.com/moment/moment) from 2.29.4 to 2.30.1.
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.4...2.30.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:44:52 +00:00
vabene1111
98aa1297b1 Merge pull request #3000 from TandoorRecipes/dependabot/npm_and_yarn/vue/codemirror/autocomplete-6.13.0
Bump @codemirror/autocomplete from 6.11.1 to 6.13.0 in /vue
2024-02-29 16:44:35 +01:00
dependabot[bot]
fa273cd4fe Bump @codemirror/autocomplete from 6.11.1 to 6.13.0 in /vue
Bumps [@codemirror/autocomplete](https://github.com/codemirror/autocomplete) from 6.11.1 to 6.13.0.
- [Changelog](https://github.com/codemirror/autocomplete/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/autocomplete/compare/6.11.1...6.13.0)

---
updated-dependencies:
- dependency-name: "@codemirror/autocomplete"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:43:44 +00:00
dependabot[bot]
5da8569b8e Bump @vue/composition-api from 1.7.1 to 1.7.2 in /vue
Bumps [@vue/composition-api](https://github.com/vuejs/composition-api) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/vuejs/composition-api/releases)
- [Changelog](https://github.com/vuejs/composition-api/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/composition-api/compare/v1.7.1...v1.7.2)

---
updated-dependencies:
- dependency-name: "@vue/composition-api"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:43:09 +00:00
dependabot[bot]
46430b81a0 Bump workbox-expiration from 6.6.1 to 7.0.0 in /vue
Bumps [workbox-expiration](https://github.com/googlechrome/workbox) from 6.6.1 to 7.0.0.
- [Release notes](https://github.com/googlechrome/workbox/releases)
- [Commits](https://github.com/googlechrome/workbox/commits/v7.0.0)

---
updated-dependencies:
- dependency-name: workbox-expiration
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:42:47 +00:00
dependabot[bot]
7e3b74b926 Bump workbox-precaching from 6.6.1 to 7.0.0 in /vue
Bumps [workbox-precaching](https://github.com/googlechrome/workbox) from 6.6.1 to 7.0.0.
- [Release notes](https://github.com/googlechrome/workbox/releases)
- [Commits](https://github.com/googlechrome/workbox/commits/v7.0.0)

---
updated-dependencies:
- dependency-name: workbox-precaching
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:57 +00:00
dependabot[bot]
7cf629d8ee Bump django-webpack-loader from 1.8.1 to 3.0.1
Bumps [django-webpack-loader](https://github.com/django-webpack/django-webpack-loader) from 1.8.1 to 3.0.1.
- [Release notes](https://github.com/django-webpack/django-webpack-loader/releases)
- [Changelog](https://github.com/django-webpack/django-webpack-loader/blob/master/CHANGELOG.md)
- [Commits](https://github.com/django-webpack/django-webpack-loader/compare/1.8.1...3.0.1)

---
updated-dependencies:
- dependency-name: django-webpack-loader
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:28 +00:00
dependabot[bot]
20653618bb Bump lxml from 4.9.3 to 5.1.0
Bumps [lxml](https://github.com/lxml/lxml) from 4.9.3 to 5.1.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.9.3...lxml-5.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:24 +00:00
dependabot[bot]
7ffe0efc07 Bump icalendar from 5.0.7 to 5.0.11
Bumps [icalendar](https://github.com/collective/icalendar) from 5.0.7 to 5.0.11.
- [Changelog](https://github.com/collective/icalendar/blob/master/CHANGES.rst)
- [Commits](https://github.com/collective/icalendar/compare/v5.0.7...v5.0.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:13 +00:00
dependabot[bot]
5447b2bce4 Bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:09 +00:00
dependabot[bot]
0cfa61692c Bump homeassistant-api from 4.1.1.post2 to 4.2.1
Bumps [homeassistant-api](https://github.com/GrandMoff100/HomeAssistantAPI) from 4.1.1.post2 to 4.2.1.
- [Release notes](https://github.com/GrandMoff100/HomeAssistantAPI/releases)
- [Commits](https://github.com/GrandMoff100/HomeAssistantAPI/compare/v4.1.1.post2...v4.2.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:05 +00:00
dependabot[bot]
006a83132b Bump awalsh128/cache-apt-pkgs-action from 1.3.1 to 1.4.1
Bumps [awalsh128/cache-apt-pkgs-action](https://github.com/awalsh128/cache-apt-pkgs-action) from 1.3.1 to 1.4.1.
- [Release notes](https://github.com/awalsh128/cache-apt-pkgs-action/releases)
- [Commits](https://github.com/awalsh128/cache-apt-pkgs-action/compare/v1.3.1...v1.4.1)

---
updated-dependencies:
- dependency-name: awalsh128/cache-apt-pkgs-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-29 15:41:01 +00:00
vabene1111
ba9f816513 Merge pull request #2987 from c0mputerguru/devcontainer
Add devcontainer support
2024-02-29 16:40:27 +01:00
Einir Einisson
498cbe0191 Added translation using Weblate (Icelandic) 2024-02-29 10:20:10 +00:00
Anand Patel
49d3d6cbc2 Add tests for plan-ical 2024-02-29 04:35:25 +00:00
Anand Patel
0d56d8a836 Merge commit '23c58868dee75fbe7a082d4cc2113f8e3ea9a9b2' into ical-default-dates 2024-02-28 18:34:09 -08:00
Tomasz Klimczak
6548f7f4d8 Translated using Weblate (Polish)
Currently translated at 100.0% (567 of 567 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2024-02-29 02:19:57 +00:00
M Ugur
e639ff9d77 Translated using Weblate (Turkish)
Currently translated at 3.6% (18 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/tr/
2024-02-29 02:19:57 +00:00
c0mputerguru
23c58868de Allow tokens for getting ical 2024-02-28 18:03:50 -08:00
Anand Patel
3936e4c66f Add self to contributors list 2024-02-29 01:43:40 +00:00
Anand Patel
ea0675b35e Add instructions on how to use devcontainers in vscode. 2024-02-29 01:40:59 +00:00
Anand Patel
f855ba1c0f Update docs as vscode tasks are not devcontainer specific. 2024-02-29 01:30:56 +00:00
Anand Patel
59f645d8c9 Documentation updates for devcontainer based development. 2024-02-29 01:29:25 +00:00
Anand Patel
061f874f45 Refactor some vscode tasks and add doc dependency. 2024-02-29 01:23:11 +00:00
Anand Patel
5ccc8d12f0 Add task to serve documentation. 2024-02-29 01:06:45 +00:00
Anand Patel
42e841d1e6 Add the fact that dev server depends on building Vue components 2024-02-29 00:57:44 +00:00
Anand Patel
ba6cac803c Fix dependencies to set tests up. 2024-02-29 00:47:31 +00:00
Anand Patel
0b51d87a06 Add task for collection of static files as it is needed for tests to pass. 2024-02-29 00:26:49 +00:00
Anand Patel
77d2e29fe4 Add devcontainer configuration 2024-02-28 23:42:52 +00:00
vabene1111
54c5655b85 SLE's dont need to belong to a shopping list 2024-02-28 20:21:03 +01:00
vabene1111
a02e9e806c fixed migration tree 2024-02-28 17:21:18 +01:00
vabene1111
66a904568b fixed merge error 2024-02-28 17:19:10 +01:00
vabene1111
7f6f579757 fixed imports again 2024-02-28 17:11:46 +01:00
vabene1111
6c993aabad Merge pull request #2967 from smilerz/deprecate_old_code
Deprecate old code
2024-02-28 17:10:11 +01:00
vabene1111
1a31847223 Merge branch 'develop' into deprecate_old_code 2024-02-28 17:10:03 +01:00
vabene1111
9996d521f5 fixed imports 2024-02-28 17:02:33 +01:00
vabene1111
7ebbad3827 Merge pull request #2975 from smilerz/api_enhancements
Api enhancements
2024-02-28 17:01:56 +01:00
vabene1111
1e1399cfe9 Merge branch 'develop' into api_enhancements 2024-02-28 17:00:08 +01:00
vabene1111
5b32160051 Merge pull request #2983 from jdecourval/develop
doc: Add installation instructions for ArchLinux
2024-02-28 16:58:16 +01:00
Jerome
d4ba2b9dd2 Add archlinux doc to mkdocs config + add warning 2024-02-28 10:43:49 -05:00
Jerome
961201412e doc: Add installation instructions for ArchLinux 2024-02-28 10:43:49 -05:00
Lukas Åteg
67b294c141 Translated using Weblate (Swedish)
Currently translated at 95.3% (536 of 562 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sv/
2024-02-27 12:19:58 +00:00
Lukas Åteg
d71557dcec 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/
2024-02-27 12:19:58 +00:00
vabene1111
c5d39b1c99 Merge pull request #2979 from jinnatar/develop
docs(authentication): Improve auth docs to match current allauth best practices and syntax
2024-02-27 07:23:39 +01:00
vabene1111
c9e0f40e88 fixed mealmaster regex 2024-02-26 16:29:04 +01:00
vabene1111
dc5de6f0a2 added migration and try/catch to apps.py 2024-02-26 16:13:19 +01:00
Jinna Kiisuo
4a7eb91e67 docs(authentication): Improve auth docs to match current allauth best practices and syntax 2024-02-26 13:55:53 +02:00
vabene1111
c15bd663cb Merge pull request #2874 from Mikhail5555/HomeAssistantConnector
Home assistant connector
2024-02-26 08:06:50 +01:00
vabene1111
95fdf893f4 Merge pull request #2968 from smilerz/keywords_not_required
change Recipe API to make keywords an optional field
2024-02-26 08:01:25 +01:00
vabene1111
63f9d5c181 Merge pull request #2974 from smilerz/logging_api_paging
paginate cooklog and viewlog tables on history view
2024-02-26 07:56:00 +01:00
c0mputerguru
86b80a78d6 Allow non-specified to/from dates for ical. 2024-02-24 20:31:58 -08:00
vabene1111
59ecc40dc6 added comment field and a recipe filter to cook log 2024-02-24 13:18:08 +01:00
vabene1111
ae70064c06 renamed ingredients_markdown and removed ingredients_vue 2024-02-24 13:05:23 +01:00
vabene1111
6de68707ed fixed missing servings get parameter breaking view in some cases 2024-02-24 11:16:09 +01:00
smilerz
6224e38138 add filter for automation type on automation API 2024-02-23 13:27:59 -06:00
smilerz
0e8cac7ab9 paginated steps on GenericModelList
paginate CustomFilter api and GenericModelList
paginate Automation api and GenericModelList
2024-02-23 08:33:50 -06:00
smilerz
64ab768add paginate cooklog and viewlog tables on history view 2024-02-23 07:23:10 -06:00
vabene1111
f6607aa2e3 Merge pull request #2969 from TandoorRecipes/dependabot/pip/cryptography-42.0.4
Bump cryptography from 42.0.2 to 42.0.4
2024-02-22 06:19:10 +01:00
dependabot[bot]
dccdb7cc2f Bump cryptography from 42.0.2 to 42.0.4
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.2 to 42.0.4.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.2...42.0.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-21 20:50:58 +00:00
smilerz
b1f418622f change Recipe API to make keywords an optional field 2024-02-21 08:25:06 -06:00
smilerz
23a7dc0ebf remove django_autocomplete_light from requirements
add back CreateRecipe view
2024-02-21 08:19:45 -06:00
smilerz
70e754eb37 remove unused delete and new forms 2024-02-20 17:56:55 -06:00
smilerz
ae9a78f2e1 remove commented out methods and functions 2024-02-20 17:47:23 -06:00
smilerz
a4849adb4c remove recipebook and recipebookentry pages 2024-02-20 17:30:29 -06:00
smilerz
da9a2b4dc2 remove Supermarket view and template 2024-02-20 17:17:43 -06:00
smilerz
8f65ecfc18 remove dal autocomplete 2024-02-20 17:14:55 -06:00
smilerz
c8c8792ea8 remove ShoppingList model and tests 2024-02-20 17:09:18 -06:00
Mikhail Epifanov
4e43a7a325 add connectors to mkdocs 2024-02-20 17:54:58 +01:00
Mikhail Epifanov
8f3effe194 bump pytest-asyncio for pytest 8.0.0 2024-02-20 17:53:26 +01:00
Mikhail Epifanov
5e508944a3 move env settings to configuration with backlink from connectors page 2024-02-20 17:48:35 +01:00
Mikhail Epifanov
6ce95fb393 add reference to the feature configuration in configuration.md 2024-02-20 09:25:04 +01:00
Mikhail Epifanov
3e641e4d28 Merge remote-tracking branch 'origin/develop' into HomeAssistantConnector
# Conflicts:
#	cookbook/forms.py
#	requirements.txt
2024-02-20 09:18:19 +01:00
vabene1111
de80702e3f fixed property duplicate detection 2024-02-20 07:54:13 +01:00
vabene1111
565a732ff0 remove property 2024-02-19 21:56:16 +01:00
vabene1111
e6fce0b4a7 property editor enhancements 2024-02-19 21:28:38 +01:00
vabene1111
04e0a6df4a Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2024-02-19 20:53:13 +01:00
vabene1111
b8cadf1faa fixed property editor fdc import 2024-02-19 20:53:07 +01:00
vabene1111
2b15a5e6be Merge pull request #2956 from smilerz/openapi_types
fixed openapi schema type int ==> integer
2024-02-19 20:27:42 +01:00
vabene1111
21094eecc6 fixed meal plan not loading 2024-02-19 20:04:31 +01:00
vabene1111
fece7e9bd6 fixed food create 2024-02-19 19:57:56 +01:00
vabene1111
b109e28b0c almost all unit conversion working 2024-02-19 17:20:04 +01:00
smilerz
e766df947e fixed openapi schema type int ==> integer 2024-02-19 07:34:32 -06:00
vabene1111
0725fb0f2b added management command to delete duplicate food properties 2024-02-19 13:07:34 +01:00
vabene1111
33ac00e294 improved importer functions unit and category 2024-02-19 09:41:45 +01:00
vabene1111
6b6556d532 fixed showing import numbers on open data component 2024-02-19 08:09:09 +01:00
vabene1111
47c508831c fixed unit edit not showing plural input 2024-02-19 08:08:17 +01:00
vabene1111
3a349b1bd2 use ID in unit food import 2024-02-18 21:38:52 +01:00
vabene1111
f085e7ff2f fixed merging issue 2024-02-18 21:35:16 +01:00
vabene1111
ac68fd30ae fixed two more import errors 2024-02-18 21:30:02 +01:00
vabene1111
b0439cc13b food editor open data slug fix 2024-02-18 18:20:45 +01:00
vabene1111
7c1de82c8a what you get for not testing locally 2024-02-18 18:12:24 +01:00
vabene1111
7bc567ab95 name uniquness checking for food import 2024-02-18 18:01:59 +01:00
vabene1111
596ec9134e fixed title of space management page 2024-02-18 17:59:06 +01:00
vabene1111
bfb8c31329 fixed issue in open data importer 2024-02-18 17:51:01 +01:00
vabene1111
55b8b78a16 show where properties are missing 2024-02-18 17:48:47 +01:00
vabene1111
9e63c5321e fixed property calculation issues
- don't count ingredients without amounts as missing properties
- don't fail when property_amount is null
2024-02-18 17:45:56 +01:00
vabene1111
28479e96e9 improved property system to differentiate between unknown and zero 2024-02-18 11:26:16 +01:00
vabene1111
717d4d2346 added open data slug to food editor 2024-02-18 09:06:51 +01:00
vabene1111
f57d2ca832 improved open data importer and fdc API 2024-02-18 08:50:50 +01:00
vabene1111
03ccc8e044 Merge pull request #2941 from tomtjes/add-human-friendly-css-classes
Add human-friendly css classes and scaling indicators
2024-02-18 08:05:10 +01:00
vabene1111
d62ba2f5e8 Merge branch 'develop' into add-human-friendly-css-classes 2024-02-18 08:04:27 +01:00
Andrea
d75c4ffaed Translated using Weblate (Italian)
Currently translated at 93.0% (456 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/it/
2024-02-17 19:16:35 +00:00
vabene1111
4869f9596a fixerd serving size parsing in property importer 2024-02-17 11:08:21 +01:00
vabene1111
0989a58af5 fixed rezeptsuite importer 2024-02-17 10:52:08 +01:00
vabene1111
278258946f Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2024-02-17 10:52:02 +01:00
vabene1111
2f0b32f3c2 added help to importer page 2024-02-17 10:51:57 +01:00
vabene1111
d78d23b096 Merge pull request #2924 from TandoorRecipes/dependabot/pip/pytest-django-4.8.0
Bump pytest-django from 4.6.0 to 4.8.0
2024-02-17 10:33:21 +01:00
dependabot[bot]
b6a99e2494 Bump pytest-django from 4.6.0 to 4.8.0
Bumps [pytest-django](https://github.com/pytest-dev/pytest-django) from 4.6.0 to 4.8.0.
- [Release notes](https://github.com/pytest-dev/pytest-django/releases)
- [Changelog](https://github.com/pytest-dev/pytest-django/blob/master/docs/changelog.rst)
- [Commits](https://github.com/pytest-dev/pytest-django/compare/v4.6.0...v4.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-17 09:32:58 +00:00
vabene1111
896ab1636f Merge pull request #2923 from TandoorRecipes/dependabot/pip/pytest-8.0.0
Bump pytest from 7.4.3 to 8.0.0
2024-02-17 10:32:20 +01:00
vabene1111
9cf13e898c all types of unit conversion fixed 2024-02-17 10:10:09 +01:00
vabene1111
705fa18dd6 fixed open data property type and category 2024-02-17 08:43:41 +01:00
vabene1111
b3a283c1a4 fixed two more comparisons 2024-02-17 08:19:46 +01:00
vabene1111
e409fc03e9 Merge pull request #2951 from TandoorRecipes/dependabot/pip/cryptography-42.0.2
Bump cryptography from 42.0.0 to 42.0.2
2024-02-17 08:09:43 +01:00
dependabot[bot]
0a154ec847 Bump cryptography from 42.0.0 to 42.0.2
Bumps [cryptography](https://github.com/pyca/cryptography) from 42.0.0 to 42.0.2.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/42.0.0...42.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-17 00:53:52 +00:00
vabene1111
53e27d62e6 fixed open data import for units 2024-02-16 22:56:19 +01:00
vabene1111
48447f1c8a fixed always false check 2024-02-16 20:40:40 +01:00
vabene1111
ce32c20f67 fixed to space delete 2024-02-16 20:36:08 +01:00
vabene1111
3fd3c8ec12 recipe book entry order 2024-02-16 20:09:38 +01:00
Mikhail Epifanov
f50bf39e60 merge 2024-02-17 00:34:16 +05:30
Mikhail Epifanov
beb860acc6 Merge remote-tracking branch 'origin/develop' into HomeAssistantConnector
# Conflicts:
#	cookbook/views/api.py
#	recipes/settings.py
2024-02-17 00:29:29 +05:30
vabene1111
778f40eac4 Merge pull request #2950 from smilerz/deprecate_settings_form
deprecate unused forms
2024-02-16 19:58:36 +01:00
vabene1111
1883da5e49 Merge branch 'develop' into deprecate_settings_form 2024-02-16 19:58:31 +01:00
dependabot[bot]
dd0ae9b1b3 Bump pytest from 7.4.3 to 8.0.0
Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.4.3 to 8.0.0.
- [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.4.3...8.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-16 18:56:20 +00:00
vabene1111
db484f8adb Merge pull request #2918 from TandoorRecipes/dependabot/npm_and_yarn/vue/webpack-bundle-tracker-3.0.1
Bump webpack-bundle-tracker from 1.8.1 to 3.0.1 in /vue
2024-02-16 19:55:38 +01:00
vabene1111
c94f9291ce Merge pull request #2920 from TandoorRecipes/dependabot/pip/python-ldap-3.4.4
Bump python-ldap from 3.4.3 to 3.4.4
2024-02-16 19:55:10 +01:00
vabene1111
dfbab2c57a Merge pull request #2915 from TandoorRecipes/dependabot/npm_and_yarn/vue/typescript-5.3.3
Bump typescript from 5.1.6 to 5.3.3 in /vue
2024-02-16 19:54:53 +01:00
vabene1111
631af65cf3 Merge pull request #2810 from m7modSy/develop
Reorder books #2338
2024-02-16 19:53:38 +01:00
vabene1111
9b84b82b58 Merge pull request #2919 from TandoorRecipes/dependabot/npm_and_yarn/vue/codemirror/view-6.23.1
Bump @codemirror/view from 6.22.2 to 6.23.1 in /vue
2024-02-16 19:48:48 +01:00
dependabot[bot]
144f72fc79 Bump python-ldap from 3.4.3 to 3.4.4
Bumps [python-ldap](https://github.com/python-ldap/python-ldap) from 3.4.3 to 3.4.4.
- [Release notes](https://github.com/python-ldap/python-ldap/releases)
- [Commits](https://github.com/python-ldap/python-ldap/compare/python-ldap-3.4.3...python-ldap-3.4.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-16 18:48:41 +00:00
vabene1111
a9e9f8345a Merge pull request #2916 from TandoorRecipes/dependabot/npm_and_yarn/vue/axios-1.6.7
Bump axios from 1.6.0 to 1.6.7 in /vue
2024-02-16 19:48:05 +01:00
vabene1111
b7c95a1d11 Merge pull request #2917 from TandoorRecipes/dependabot/npm_and_yarn/vue/pinia-2.1.7
Bump pinia from 2.1.6 to 2.1.7 in /vue
2024-02-16 19:47:52 +01:00
vabene1111
047aace9c2 Merge pull request #2922 from TandoorRecipes/dependabot/pip/django-auth-ldap-4.6.0
Bump django-auth-ldap from 4.4.0 to 4.6.0
2024-02-16 19:47:44 +01:00
vabene1111
b64319a232 Merge pull request #2921 from TandoorRecipes/dependabot/pip/django-tables2-2.7.0
Bump django-tables2 from 2.5.3 to 2.7.0
2024-02-16 19:47:39 +01:00
vabene1111
b99443cb30 Merge pull request #2938 from patmagauran/meal-plan-serving-size-link
Add ability to open recipes with specified number of servings from meal plan
2024-02-16 19:46:36 +01:00
smilerz
9506f2889a deprecate unused forms 2024-02-16 09:21:27 -06:00
tomtjes
f34dc4d242 move escapeCSS function to mixins 2024-02-15 14:56:04 -05:00
vabene1111
5bbcdd2551 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2024-02-15 20:55:02 +01:00
vabene1111
43443305e6 basic bold function 2024-02-15 20:54:54 +01:00
dalan
038d03783f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.2% (558 of 562 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/zh_Hans/
2024-02-15 03:19:57 +00:00
dalan
80c594d486 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (490 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/zh_Hans/
2024-02-15 03:19:57 +00:00
tomtjes
b5a204265a fix CSS escape (remove HTML sanitize) 2024-02-14 17:18:26 -05:00
tomtjes
8e72108290 use BEM classes
add class names for foods, units, keywords (not following BEM closely)
2024-02-14 16:44:12 -05:00
vabene1111
d1042835a5 Merge pull request #2948 from maxemann96/bugfix/wrong-keycloak-docs
Fixed wrong keycloak docs
2024-02-14 07:42:25 +01:00
vabene1111
57d7bda803 added captcha option to password reset form 2024-02-14 07:28:47 +01:00
vabene1111
a088697812 added additional rate limiting to password reset 2024-02-14 07:28:47 +01:00
Patrick Magauran
cf190734b2 Switches to named GET Parameter for servings 2024-02-13 20:59:04 -05:00
Patrick Magauran
a9a3dd6e51 Revert "Add Setting to enable / disable feature"
This reverts commit 768e9f8801.
2024-02-13 20:30:59 -05:00
vabene1111
00ff13ae08 basic button affecting editor 2024-02-13 17:59:44 +01:00
Kirstin Seidel-Gebert
d7d34b2326 Translated using Weblate (German)
Currently translated at 100.0% (490 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2024-02-13 16:19:57 +00:00
Maximilian Hippler
250d58f8a1 Fixed wrong keycloak docs 2024-02-13 16:09:06 +01:00
vabene1111
0499745772 added captcha option to password reset form 2024-02-13 10:56:43 +01:00
vabene1111
1b2c4a3062 added additional rate limiting to password reset 2024-02-13 10:03:24 +01:00
Tomasz Klimczak
9232465e21 Translated using Weblate (Polish)
Currently translated at 100.0% (562 of 562 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2024-02-12 12:19:57 +00:00
Kirstin Seidel-Gebert
30e853088c Translated using Weblate (German)
Currently translated at 100.0% (562 of 562 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2024-02-12 12:19:57 +00:00
Kirstin Seidel-Gebert
c45a88d048 Translated using Weblate (German)
Currently translated at 100.0% (490 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/de/
2024-02-12 12:19:57 +00:00
vabene1111
8f272eba3b able to edit template strings 2024-02-11 10:41:15 +01:00
vabene1111
ad5562d850 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2024-02-11 10:00:54 +01:00
vabene1111
ca35052ac0 some more markdown editor progress 2024-02-11 10:00:49 +01:00
vabene1111
3f19d94d2f Merge pull request #2937 from smilerz/automation_fix
regex replace not run on food if there are no food aliases
2024-02-11 09:31:15 +01:00
Jonan B
b5c3ef72ef Translated using Weblate (Dutch)
Currently translated at 100.0% (490 of 490 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/nl/
2024-02-10 12:20:02 +00:00
vabene1111
cca7b7f558 codemirror history and highlighting 2024-02-10 12:10:21 +01:00
vabene1111
3f962345f7 placeholder working 2024-02-10 10:08:32 +01:00
dependabot[bot]
d92211485d Bump typescript from 5.1.6 to 5.3.3 in /vue
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.1.6 to 5.3.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.1.6...v5.3.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-10 08:29:08 +00:00
vabene1111
0827cb387f markdown editor in test view 2024-02-10 09:28:44 +01:00
vabene1111
b2a4b084d7 Merge branch 'feature/shopping-ui' into develop 2024-02-10 09:26:46 +01:00
vabene1111
8e4e785179 added editor button for scalable number 2024-02-10 09:26:29 +01:00
vabene1111
ef0f181268 Merge branch 'develop' into feature/shopping-ui 2024-02-10 09:12:34 +01:00
vabene1111
f3e42f13b1 Merge pull request #2940 from TandoorRecipes/dependabot/pip/django-4.2.10
Bump django from 4.2.7 to 4.2.10
2024-02-10 09:07:56 +01:00
vabene1111
fb8e04fee5 swipe test view 2024-02-10 09:07:03 +01:00
vabene1111
03c775d1cc added merge capabilities to supermarket category 2024-02-10 09:01:21 +01:00
vabene1111
6aa2aa42c7 added more editing capabilities to line item modal 2024-02-10 08:56:15 +01:00
Mikhail Epifanov
20e1435abf remove migration 2024-02-08 17:28:33 +01:00
tomtjes
0ee5164aac remove class used twice 2024-02-07 20:51:59 -05:00
tomtjes
1819ff2bbd add classes to main components/views 2024-02-07 19:18:51 -05:00
tomtjes
14c2be9277 add classes to scalable numbers
apply "scalable" to all
apply "scaled-up" when ingredient factor is >1
apply "unscaled" when ingredient factor is 1
apply "scaled-down" when factor is <1
2024-02-07 16:51:19 -05:00
dependabot[bot]
6377d55ea5 Bump django from 4.2.7 to 4.2.10
Bumps [django](https://github.com/django/django) from 4.2.7 to 4.2.10.
- [Commits](https://github.com/django/django/compare/4.2.7...4.2.10)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-07 18:21:59 +00:00
Patrick Magauran
768e9f8801 Add Setting to enable / disable feature 2024-02-06 22:52:59 -05:00
Patrick Magauran
e8db2b6401 Clicking on Recipe in Meal Plan auto does servings 2024-02-06 22:37:25 -05:00
Patrick Magauran
13f532a67b Add serving size to recipe url so it loads with num 2024-02-06 22:21:37 -05:00
vabene1111
61ccc6061c updated api client 2024-02-06 19:21:55 +01:00
smilerz
dc7db75963 regex replace not run on food if there are no food aliases 2024-02-06 11:14:47 -06:00
vabene1111
98b06b6f3c improved modal 2024-02-06 14:48:18 +01:00
vabene1111
dffb2d4eae fixed test 2024-02-06 14:42:14 +01:00
vabene1111
08a2b4d0b2 formatted file 2024-02-06 14:10:36 +01:00
vabene1111
5211fbe6da styling and bg sync message 2024-02-06 13:52:26 +01:00
vabene1111
a2cb1ccf3a Revert "playing with checkbox design"
This reverts commit ece7ca7e82.
2024-02-06 13:25:34 +01:00
vabene1111
29438109a6 fixed postgres update doc mv -R 2024-02-06 10:58:17 +01:00
vabene1111
4bb4c3e9a4 Merge pull request #2935 from TandoorRecipes/dependabot/pip/cryptography-42.0.0
Bump cryptography from 41.0.7 to 42.0.0
2024-02-06 10:56:19 +01:00
dependabot[bot]
46dc0f1f03 Bump cryptography from 41.0.7 to 42.0.0
Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.7 to 42.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/41.0.7...42.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-06 03:28:24 +00:00
Mikhail Epifanov
1dc9244ac2 dont use timezone in test 2024-02-06 00:47:46 +01:00
Mikhail Epifanov
962d617839 switch to threading, f multiprocessing in python 2024-02-06 00:37:37 +01:00
Mikhail Epifanov
65a7c82af9 terminate worker on finalize 2024-02-06 00:17:23 +01:00
Mikhail Epifanov
2bfc8b0717 format 2024-02-06 00:11:46 +01:00
Mikhail Epifanov
16e8c1e8e3 disable connector in tests 2024-02-06 00:02:38 +01:00
Mikhail Epifanov
2a6c13fc5c add finalizer to stop worker on terminate 2024-02-05 23:50:57 +01:00
Mikhail Epifanov
408c2271a6 reduce timeout, remove report generation 2024-02-05 23:43:13 +01:00
Mikhail Epifanov
0e945f4bd7 add startup & termination log to worker 2024-02-05 23:40:50 +01:00
Mikhail Epifanov
0279013f72 remove loop closing 2024-02-05 23:37:18 +01:00
Mikhail Epifanov
074244ee12 add timeout to async test 2024-02-05 23:35:39 +01:00
Mikhail Epifanov
247907ef25 move from signals to apps, add dedicated feature docs, add config toggle to menu item, undo unnecessary changes 2024-02-05 23:26:33 +01:00
Mikhail Epifanov
c88dda90d4 Merge branch 'develop' into HomeAssistantConnector 2024-02-05 16:18:23 +01:00
vabene1111
59e949067c Merge branch 'develop' into feature/shopping-ui 2024-02-05 16:15:56 +01:00
vabene1111
3e97fa9633 fixed dash in PR 2024-02-05 16:15:50 +01:00
Mikhail Epifanov
513082255b Merge branch 'develop' into HomeAssistantConnector 2024-02-05 16:09:47 +01:00
vabene1111
1a5881bb4b Merge branch 'develop' into feature/shopping-ui
# Conflicts:
#	cookbook/serializer.py
2024-02-05 15:59:56 +01:00
vabene1111
b6cbb28a87 Merge pull request #2914 from Mikhail5555/CiPipelineImprovements
Add Caching and Test export to CI workflow
2024-02-05 15:50:25 +01:00
vabene1111
36c0fbffbe fixed duplicate detection in migration 0200 2024-02-05 14:52:40 +01:00
vabene1111
febf3a3d86 removed flawed duplicate detection 2024-02-05 14:05:11 +01:00
vabene1111
3f859e5227 Merge branch 'develop' of https://github.com/vabene1111/recipes into develop 2024-02-05 13:52:58 +01:00
vabene1111
7e39e6fea5 fixed meal type constraint 2024-02-05 13:52:52 +01:00
vabene1111
2fd72fe985 Merge pull request #2932 from software2000/develop
Update postgres in documentation
2024-02-05 13:51:33 +01:00
software2000
ce0ee8caaa update postgres in documentation to version 16 2024-02-04 22:32:03 +01:00
Mikhail Epifanov
75c0ca8a9e bunp migration 2024-02-02 20:52:05 +01:00
vabene1111
4eafbddfdb fixed SLE api endpoint 2024-02-02 09:31:33 +01:00
vabene1111
010fb0112f Merge branch 'develop' into feature/shopping-ui 2024-02-02 07:46:48 +01:00
vabene1111
ece7ca7e82 playing with checkbox design 2024-02-01 21:09:21 +01:00
vabene1111
bb1b1a40b6 fixed search on userfile and supermarket generic select 2024-02-01 20:00:51 +01:00
vabene1111
f7b25c9b31 easier supermarket select on mobile 2024-02-01 19:39:11 +01:00
vabene1111
7f6cd16a77 changed default supermarket prefs 2024-02-01 19:36:07 +01:00
dependabot[bot]
c3eb12160a Bump django-auth-ldap from 4.4.0 to 4.6.0
Bumps [django-auth-ldap](https://github.com/django-auth-ldap/django-auth-ldap) from 4.4.0 to 4.6.0.
- [Release notes](https://github.com/django-auth-ldap/django-auth-ldap/releases)
- [Changelog](https://github.com/django-auth-ldap/django-auth-ldap/blob/master/docs/changes.rst)
- [Commits](https://github.com/django-auth-ldap/django-auth-ldap/compare/4.4.0...4.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 00:34:00 +00:00
dependabot[bot]
f017c6ae68 Bump django-tables2 from 2.5.3 to 2.7.0
Bumps [django-tables2](https://github.com/jieter/django-tables2) from 2.5.3 to 2.7.0.
- [Changelog](https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jieter/django-tables2/compare/v2.5.3...v2.7.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 00:33:57 +00:00
dependabot[bot]
b2f270a829 Bump @codemirror/view from 6.22.2 to 6.23.1 in /vue
Bumps [@codemirror/view](https://github.com/codemirror/view) from 6.22.2 to 6.23.1.
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.22.2...6.23.1)

---
updated-dependencies:
- dependency-name: "@codemirror/view"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 00:28:43 +00:00
dependabot[bot]
f87fe43796 Bump webpack-bundle-tracker from 1.8.1 to 3.0.1 in /vue
Bumps [webpack-bundle-tracker](https://github.com/django-webpack/webpack-bundle-tracker) from 1.8.1 to 3.0.1.
- [Release notes](https://github.com/django-webpack/webpack-bundle-tracker/releases)
- [Commits](https://github.com/django-webpack/webpack-bundle-tracker/compare/1.8.1...v3.0.1)

---
updated-dependencies:
- dependency-name: webpack-bundle-tracker
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 00:28:22 +00:00
dependabot[bot]
b46809155a Bump pinia from 2.1.6 to 2.1.7 in /vue
Bumps [pinia](https://github.com/vuejs/pinia) from 2.1.6 to 2.1.7.
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/pinia@2.1.6...pinia@2.1.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 00:28:00 +00:00
dependabot[bot]
30d69432ff Bump axios from 1.6.0 to 1.6.7 in /vue
Bumps [axios](https://github.com/axios/axios) from 1.6.0 to 1.6.7.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.0...v1.6.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-01 00:27:48 +00:00
vabene1111
6ccd74edab Merge branch 'develop' into feature/shopping-ui 2024-01-29 10:29:48 +01:00
Mikhail Epifanov
8b5b063da6 Merge branch 'develop' into HomeAssistantConnector 2024-01-29 09:57:10 +01:00
Mikhail Epifanov
a8983a4b8a undo workflow changes 2024-01-29 09:56:40 +01:00
Mikhail Epifanov
1ad5f4843f Merge branch 'develop' into CiPipelineImprovements 2024-01-29 09:49:39 +01:00
Mikhail Epifanov
971b58ccb0 add caching and test export 2024-01-29 09:48:19 +01:00
vabene1111
77f81523d0 lmiit SLE endpoint and always fitler for recent days 2024-01-29 09:02:11 +01:00
vabene1111
aac729a3a0 fixed firefox 2024-01-29 08:46:34 +01:00
vabene1111
ee7cdacc40 cleared TODOs 2024-01-29 08:41:02 +01:00
vabene1111
0f1a3ba5d8 improvements 2024-01-29 08:37:53 +01:00
vabene1111
4c6410d7ae fixed inspectioin rule 2024-01-29 08:03:51 +01:00
Mikhail Epifanov
502a606534 Update the code based on feedback. set Default to enabled, add to documentation how to disable it. Add extra documentation 2024-01-28 22:59:51 +01:00
vabene1111
fff2ecd58d some fixes 2024-01-28 20:31:24 +01:00
vabene1111
4724104f50 eslint rule 2024-01-28 16:34:12 +01:00
vabene1111
e2ebccda85 fixed meal plan content shift 2024-01-28 16:33:54 +01:00
vabene1111
79b57eab13 fixed paddings/horizontal scrol 2024-01-28 16:29:53 +01:00
Mahmoud
d4fbc266a1 removing wrong import and deleting migrations 2024-01-28 14:04:50 +01:00
vabene1111
558a2d2a30 really no flickering maybe 2024-01-28 14:03:36 +01:00
vabene1111
55877d69a0 hopefully this really prevents multiple auto syncs even in HMR 2024-01-28 12:27:24 +01:00
vabene1111
3c93e760d2 no more loading spinner size flicker 2024-01-28 12:21:24 +01:00
vabene1111
3fbfcb9939 translation and minor style tweaks 2024-01-28 12:17:46 +01:00
vabene1111
44f6a581c7 some more icons 2024-01-28 12:11:25 +01:00
vabene1111
5853ca9243 handle partially delayed/checked 2024-01-28 12:06:27 +01:00
vabene1111
09c1446c06 fixed pdf export 2024-01-28 11:39:00 +01:00
vabene1111
f0853ee11c not full width on large screens 2024-01-28 11:19:06 +01:00
vabene1111
6e6af47d8c fixed migration order 2024-01-28 11:06:38 +01:00
vabene1111
3e51bdc7f0 Merge branch 'develop' into feature/shopping-ui
# Conflicts:
#	cookbook/serializer.py
2024-01-28 11:05:56 +01:00
vabene1111
67f6e04680 Merge branch 'develop' into feature/shopping-ui 2024-01-28 08:32:31 +01:00
Mikhail Epifanov
ba169ba38d better logging on skipped action 2024-01-24 08:59:31 +01:00
Mikhail Epifanov
578bb2af25 better error handling during connector initilization 2024-01-24 08:57:24 +01:00
vabene1111
8e4c8821dc recipe list and detail modal background 2024-01-23 18:48:29 +01:00
vabene1111
7673e794bf moved date format functions to utilities 2024-01-23 18:25:56 +01:00
vabene1111
64e54ceaec added mealplan info to recipes tab 2024-01-23 18:09:13 +01:00
vabene1111
f431e18336 fixed test 2024-01-23 18:03:31 +01:00
vabene1111
5842022d0a fixed some exports 2024-01-23 17:49:03 +01:00
vabene1111
c118dca2c0 Merge branch 'develop' into feature/shopping-ui 2024-01-23 16:04:18 +01:00
vabene1111
43aac60e9c Merge branch 'develop' into feature/shopping-ui 2024-01-23 15:52:31 +01:00
vabene1111
2b9c294c2a undefined always first 2024-01-20 02:51:23 +08:00
vabene1111
7d74979859 properly edit settings 2024-01-20 02:37:28 +08:00
vabene1111
7de9758ee1 auto sync proper data merging 2024-01-20 02:17:48 +08:00
vabene1111
71ea67dc30 sync check items in background 2024-01-20 00:18:45 +08:00
vabene1111
ff77aa7268 added method descirption 2024-01-19 21:59:49 +08:00
vabene1111
e321c80dd6 debugging 2024-01-19 21:42:48 +08:00
vabene1111
12db67bd96 removed unused vars and improved auto sync conditions 2024-01-19 20:48:48 +08:00
vabene1111
57100baf7c improved document visibility handling 2024-01-19 20:09:46 +08:00
vabene1111
05cf7cc081 autosync new entries 2024-01-19 19:55:59 +08:00
vabene1111
c8a070f473 properly display item counter 2024-01-19 19:42:03 +08:00
vabene1111
eae409da67 prevent auto sync from running mulitple time 2024-01-19 19:36:34 +08:00
vabene1111
62e1d860a9 Merge branch 'develop' into feature/shopping-ui 2024-01-19 18:17:33 +08:00
Mikhail Epifanov
5f9d59317b Merge branch 'develop' into HomeAssistantConnector 2024-01-17 22:55:21 +01:00
Mikhail Epifanov
409c0295ec convert example & homeassistant specific configs to a generic with all optional fields 2024-01-17 22:40:44 +01:00
vabene1111
764cd7dba0 SLE bulk permission and tests 2024-01-17 22:32:34 +08:00
vabene1111
dc81ca19b9 fixed old migrations in shopping tree 2024-01-16 08:06:37 +08:00
vabene1111
2697e42af7 added bulk api endpoint for SLE checking 2024-01-16 08:06:26 +08:00
vabene1111
2b1eda12d1 added undo to category checking 2024-01-16 07:33:20 +08:00
vabene1111
40da2cee19 updated migration 2024-01-15 07:58:10 +08:00
vabene1111
31de43196a Merge branch 'develop' into feature/shopping-ui 2024-01-15 07:57:09 +08:00
Mikhail Epifanov
245787b89e make the connectors form be able to display all types for connectors 2024-01-14 17:03:02 +01:00
vabene1111
4a7bd6a885 only auto sync when window is focused 2024-01-14 22:16:38 +08:00
vabene1111
d1865b57f1 group checking and unchecking 2024-01-14 12:14:47 +08:00
vabene1111
1692230f01 swiping working for all items 2024-01-14 12:04:52 +08:00
vabene1111
5a0ca3f4e5 basic swiping working 2024-01-14 11:54:10 +08:00
vabene1111
37c7a62853 Merge branch 'beta' into feature/shopping-ui 2024-01-14 08:40:24 +08:00
Mikhail Epifanov
fb65100b14 add debug logging 2024-01-13 20:30:54 +01:00
Mikhail Epifanov
17163b0dba save cache on failed tests 2024-01-13 16:44:18 +01:00
Mikhail Epifanov
362c0340fc skip whole yarn and static files if there was a cache hit 2024-01-13 16:40:25 +01:00
vabene1111
3d6d560c5d add undo functionality to shopping 2024-01-13 22:58:28 +08:00
vabene1111
fd821c30c7 removed ingredient from shopping list entry API 2024-01-13 21:34:27 +08:00
vabene1111
995d423a6f WIP shopping undo 2024-01-13 21:30:48 +08:00
vabene1111
65dd82e292 fixed to string of ingredient 2024-01-13 21:30:39 +08:00
Mikhail Epifanov
87ede4b9cc change formatting a bit, and add async close method 2024-01-13 13:43:08 +01:00
Mikhail Epifanov
c7dd61e239 add caching to the ci-cd workflow 2024-01-13 12:25:48 +01:00
Mikhail Epifanov
48ac70de95 make the tests check for any error message 2024-01-13 11:56:51 +01:00
vabene1111
8302521427 Merge branch 'develop' into feature/shopping-ui 2024-01-13 08:08:38 +08:00
vabene1111
e045849e89 more theming firendly classses in shopping 2024-01-13 07:36:41 +08:00
Mikhail Epifanov
50eb232fff update tests and fix small bug in connector_manager 2024-01-13 00:24:58 +01:00
Mikhail Epifanov
1a37961ceb add mock to requirements 2024-01-12 23:44:15 +01:00
Mikhail Epifanov
022439e017 increase queue size to account for recipe adding burst 2024-01-12 23:40:16 +01:00
Mikhail Epifanov
9c804863a8 undo accidental changes 2024-01-12 23:15:28 +01:00
Mikhail Epifanov
9cf3bdd5f2 write some simple tests 2024-01-12 23:13:53 +01:00
Mikhail Epifanov
445e64c71e add an config toggle for external connectors 2024-01-12 22:20:55 +01:00
Mikhail Epifanov
d576394c99 run everything in a seperate process 2024-01-12 20:50:23 +01:00
Mikhail Epifanov
a61f79507b add enabled field 2024-01-11 23:11:04 +01:00
Mikhail Epifanov
f1b41461db bugfix for not working space loading 2024-01-11 22:46:29 +01:00
Mikhail Epifanov
6a393acd26 redo migration. cleanup commented out code 2024-01-11 22:35:58 +01:00
Mikhail Epifanov
bf0462cd74 add missing from rebase 2024-01-11 22:14:22 +01:00
Mikhail Epifanov
e5f0c19cdc Add ConnectorManager component which allows for Connectors to listen to triggers and do actions on them. Also add HomeAssistantConfig which stores the configuration for the HomeAssistantConnector 2024-01-11 22:13:20 +01:00
vabene1111
66a07ab39d only show entry scaler if not a recipe 2024-01-10 06:54:54 +08:00
vabene1111
6d4f094455 number scaler component and changing entry amount 2024-01-10 06:51:34 +08:00
vabene1111
69b24db442 added missing string and improved tabs titels 2024-01-09 22:34:48 +08:00
vabene1111
a1ff54bf3f reduced top margin 2024-01-09 22:26:05 +08:00
vabene1111
748935d0b8 Merge branch 'develop' into feature/shopping-ui 2024-01-09 21:55:06 +08:00
vabene1111
a5135de50b Merge branch 'develop' into feature/shopping-ui
# Conflicts:
#	cookbook/models.py
#	vue/src/locales/en.json
2024-01-08 06:45:56 +08:00
vabene1111
44ac3cf51e user pref store with caching 2024-01-05 21:21:34 +08:00
vabene1111
3cab0ab52e basic auto sync working 2024-01-04 20:22:31 +01:00
vabene1111
964afd5f73 fixed migration tree 2024-01-03 15:13:31 +01:00
vabene1111
1fa2186dd0 fixed space theme defaults in model 2024-01-03 15:13:24 +01:00
vabene1111
146e97c8ec Merge branch 'develop' into feature/shopping-ui
# Conflicts:
#	vue/src/locales/en.json
2024-01-03 15:09:30 +01:00
vabene1111
c76f5d9482 starting work on new sync algorithm 2024-01-01 12:01:39 +01:00
vabene1111
475ce44df9 move supermarket editing to store 2023-12-30 08:52:43 +01:00
vabene1111
492d266fbe dont show categories without entries 2023-12-30 08:13:13 +01:00
vabene1111
c695f0dacb update SLR servings UI 2023-12-30 07:59:27 +01:00
vabene1111
57d87c899c total stats 2023-12-28 14:27:55 +01:00
vabene1111
e37f8b3a51 shoppig list structure statistics 2023-12-28 14:25:48 +01:00
vabene1111
df03818f45 fixed button hover effect 2023-12-28 14:03:50 +01:00
vabene1111
063c64d078 improvements to recipe tab 2023-12-28 00:19:12 +01:00
vabene1111
2c3e0b547b stuff and things 2023-12-27 23:20:31 +01:00
vabene1111
999e3794f5 info row settings 2023-12-27 23:04:01 +01:00
vabene1111
5e5caf201c shopping device settings 2023-12-27 22:39:45 +01:00
vabene1111
0ce4d45eeb sorting 2023-12-27 21:15:24 +01:00
vabene1111
0dacdcc72f paddings 2023-12-27 15:39:17 +01:00
vabene1111
629dfd5d52 configurable info row 2023-12-27 14:04:36 +01:00
vabene1111
20bc1c5c2a fixed deleting objects 2023-12-27 12:01:13 +01:00
vabene1111
d3376b33d8 new datastructure 2023-12-27 11:34:55 +01:00
Mahmoud
80c0c71b13 migrations 2023-12-25 19:56:49 +01:00
Mahmoud
42839a5886 Manual order: you can now change the order by dragging and dropping 2023-12-25 19:44:23 +01:00
vabene1111
b0c561661b basic category chaning 2023-12-23 11:11:36 +01:00
vabene1111
ae3818611d fixed checking food always works 2023-12-23 08:46:42 +01:00
vabene1111
d1c4e51842 fixed popover header in dark theme 2023-12-23 08:46:31 +01:00
vabene1111
e6f7f07220 some things actually working 2023-12-22 23:31:28 +01:00
vabene1111
245e8311ba more basics working 2023-12-22 15:10:56 +01:00
vabene1111
3b916cc6a4 fixed schema for recipe from source endpoint 2023-12-22 14:48:58 +01:00
vabene1111
a70ebd5130 somewhat working list 2023-12-22 14:35:17 +01:00
vabene1111
ddf9ef11a0 basic working shopping list store 2023-12-22 13:12:28 +01:00
vabene1111
f65597c391 basic nocer ui, nothing really working 2023-12-22 11:40:17 +01:00
Mahmoud
45c14f6a12 automatic ordering through api 2023-12-17 16:35:46 +01:00
Mahmoud
e423fc1df4 last changes 2023-12-15 02:23:08 +01:00
Mahmoud
3e0b0a87e9 Basic Implementation for reordering books 2023-12-13 15:03:15 +01:00
243 changed files with 39637 additions and 8491 deletions

11
.coveragerc Normal file
View File

@@ -0,0 +1,11 @@
[run]
omit =
*/apps.py,
*/migrations/*,
*/settings*,
*/test*,
*/tests/*,
*urls.py,
*/wsgi*,
manage.py,
*__init__*

26
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.10-alpine3.18
#Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn
#Print all logs without buffering it.
ENV PYTHONUNBUFFERED 1
#This port will be used by gunicorn.
EXPOSE 8000
#This port will be used by vue
EXPOSE 8080
#Install all python dependencies to the image
COPY requirements.txt /tmp/pip-tmp/
RUN \
if [ `apk --print-arch` = "armv7" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt && \
rm -rf /tmp/pip-tmp && \
apk --purge del .build-deps

View File

@@ -0,0 +1,27 @@
// For format details, see https://aka.ms/devcontainer.json.
{
"name": "Tandoor Dev Container",
"build": { "context": "..", "dockerfile": "Dockerfile" },
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [8000, 8080],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt"
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"ms-python.debugpy",
"ms-python.python"
]
}
}
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -1,10 +1,15 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly
- package-ecosystem: "pip"
directory: "/"
schedule:

View File

@@ -3,38 +3,79 @@ name: Continuous Integration
on: [push, pull_request]
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ['3.10']
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10"]
node-version: ["18"]
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: '3.10'
# Build Vue frontend
- uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install Vue dependencies
working-directory: ./vue
run: yarn install
- name: Build Vue dependencies
working-directory: ./vue
run: yarn build
- name: Install Django dependencies
run: |
sudo apt-get -y update
sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev
python -m pip install --upgrade pip
pip install -r requirements.txt
python3 manage.py collectstatic --noinput
python3 manage.py collectstatic_js_reverse
- name: Django Testing project
run: |
pytest
steps:
- uses: actions/checkout@v4
- uses: awalsh128/cache-apt-pkgs-action@v1.4.1
with:
packages: libsasl2-dev python3-dev libldap2-dev libssl-dev
version: 1.0
# Setup python & dependencies
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- name: Install Python Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Cache StaticFiles
uses: actions/cache@v4
id: django_cache
with:
path: |
./cookbook/static
./vue/webpack-stats.json
./staticfiles
key: |
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
# Build Vue frontend & Dependencies
- name: Set up Node ${{ matrix.node-version }}
if: steps.django_cache.outputs.cache-hit != 'true'
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"
cache-dependency-path: ./vue/yarn.lock
- name: Install Vue dependencies
if: steps.django_cache.outputs.cache-hit != 'true'
working-directory: ./vue
run: yarn install
- name: Build Vue dependencies
if: steps.django_cache.outputs.cache-hit != 'true'
working-directory: ./vue
run: yarn build
- name: Compile Django StaticFiles
if: steps.django_cache.outputs.cache-hit != 'true'
run: |
python3 manage.py collectstatic --noinput
python3 manage.py collectstatic_js_reverse
- uses: actions/cache/save@v4
if: steps.django_cache.outputs.cache-hit != 'true'
with:
path: |
./cookbook/static
./vue/webpack-stats.json
./staticfiles
key: |
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
- name: Django Testing project
run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml

View File

@@ -1,12 +1,15 @@
name: Make Docs
on:
push:
branches:
- master
# the 1st condition
workflow_run:
workflows: ["Continuous Integration"]
branches: [master]
types:
- completed
jobs:
deploy:
if: github.repository_owner == 'TandoorRecipes'
if: github.repository_owner == 'TandoorRecipes' && ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -14,4 +17,4 @@ jobs:
with:
python-version: 3.x
- run: pip install mkdocs-material mkdocs-include-markdown-plugin
- run: mkdocs gh-deploy --force
- run: mkdocs gh-deploy --force

5
.gitignore vendored
View File

@@ -43,6 +43,7 @@ htmlcov/
nosetests.xml
coverage.xml
*,cover
docs/reports/**
# Django stuff:
*.log
@@ -54,7 +55,6 @@ docs/_build/
target/
\.idea/dataSources/
\.idea/dataSources\.xml
\.idea/dataSources\.local\.xml
@@ -80,10 +80,11 @@ data/
/docker-compose.override.yml
vue/node_modules
plugins
.vscode/
vetur.config.js
cookbook/static/vue
vue/webpack-stats.json
cookbook/templates/sw.js
.prettierignore
vue/.yarn
vue3/.vite
vue3/node_modules

View File

@@ -1,6 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="vaben">
<words>
<w>mealplan</w>
<w>pinia</w>
<w>selfhosted</w>
<w>unapplied</w>

18
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": ["runserver"],
"django": true,
"justMyCode": true
}
]
}

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"cookbook/tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}

75
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,75 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Migrations",
"type": "shell",
"command": "python3 manage.py migrate",
},
{
"label": "Collect Static Files",
"type": "shell",
"command": "python3 manage.py collectstatic",
"dependsOn": ["Yarn Build"],
},
{
"label": "Setup Dev Server",
"dependsOn": ["Run Migrations", "Yarn Build"],
},
{
"label": "Run Dev Server",
"type": "shell",
"dependsOn": ["Setup Dev Server"],
"command": "python3 manage.py runserver",
},
{
"label": "Yarn Install",
"type": "shell",
"command": "yarn install",
"options": {
"cwd": "${workspaceFolder}/vue"
}
},
{
"label": "Yarn Serve",
"type": "shell",
"command": "yarn serve",
"dependsOn": ["Yarn Install"],
"options": {
"cwd": "${workspaceFolder}/vue"
}
},
{
"label": "Yarn Build",
"type": "shell",
"command": "yarn build",
"dependsOn": ["Yarn Install"],
"options": {
"cwd": "${workspaceFolder}/vue"
},
"group": "build",
},
{
"label": "Setup Tests",
"dependsOn": ["Run Migrations", "Collect Static Files"],
},
{
"label": "Run all pytests",
"type": "shell",
"command": "python3 -m pytest cookbook/tests",
"dependsOn": ["Setup Tests"],
"group": "test",
},
{
"label": "Setup Documentation Dependencies",
"type": "shell",
"command": "pip install mkdocs-material mkdocs-include-markdown-plugin",
},
{
"label": "Serve Documentation",
"type": "shell",
"command": "mkdocs serve",
"dependsOn": ["Setup Documentation Dependencies"],
}
]
}

View File

@@ -20,6 +20,7 @@ Below are some of the larger contributions made yet.
- [murphy83] added support for IPv6 #1490
- [TheHaf] added custom serving size component #1411
- [lostlont] added LDAP support #960
- [c0mputerguru] added devcontainers for ease of development
## Translations

View File

@@ -1,4 +1,4 @@
FROM python:3.10-alpine3.18
FROM python:3.12-alpine3.19
#Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git
@@ -19,12 +19,14 @@ RUN \
if [ `apk --print-arch` = "armv7" ]; then \
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
fi
# remove Development dependencies from requirements.txt
RUN sed -i '/# Development/,$d' requirements.txt
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
python -m venv venv && \
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
venv/bin/pip install wheel==0.37.1 && \
venv/bin/pip install setuptools_rust==1.1.2 && \
venv/bin/pip install wheel==0.42.0 && \
venv/bin/pip install setuptools_rust==1.9.0 && \
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
apk --purge del .build-deps

View File

@@ -39,13 +39,13 @@
- 🔍 Powerful & customizable **search** with fulltext support and [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- 🏷️ Create and search for **tags**, assign them in batch to all files matching certain filters
- ↔️ Quickly merge and rename ingredients, tags and units
- ↔️ Quickly merge and rename ingredients, tags and units
- 📥️ **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
- ➗ Support for **fractions** or decimals
- 🐳 Easy setup with **Docker** and included examples for **Kubernetes**, **Unraid** and **Synology**
- 🎨 Customize your interface with **themes**
- 📦 **Sync** files with Dropbox and Nextcloud
## All the must haves
- 📱Optimized for use on **mobile** devices
@@ -54,7 +54,7 @@
- Many more like recipe scaling, image compression, printing views and supermarkets
This application is meant for people with a collection of recipes they want to share with family and friends or simply
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
a public page.
## Docs
@@ -62,13 +62,13 @@ a public page.
Documentation can be found [here](https://docs.tandoor.dev/).
## Support our work
Tandoor is developed by volunteers in their free time just because its fun. That said earning
Tandoor is developed by volunteers in their free time just because its fun. That said earning
some money with the project allows us to spend more time on it and thus make improvements we otherwise couldn't.
Because of that there are several ways you can support us
- **GitHub Sponsors** You can sponsor contributors of this project on GitHub: [vabene1111](https://github.com/sponsors/vabene1111)
- **Host at Hetzner** We have been very happy customers of Hetzner for multiple years for all of our projects. If you want to get into self-hosting or are tired of the expensive big providers, their cloud servers are a great place to get started. When you sign up via our [referral link](https://hetzner.cloud/?ref=ISdlrLmr9kGj) you will get 20€ worth of cloud credits and we get a small kickback too.
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
## Contributing
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/#contributing-code) **BEFORE** contributing anything!
@@ -100,7 +100,7 @@ Beginning with version 0.10.0 the code in this repository is licensed under the
> I am in the process of getting some professional legal advice to sort out these issues.
> Please also see [Issue 238](https://github.com/vabene1111/recipes/issues/238) for some discussion and **reasoning** regarding the topic.
**Reasoning**
**Reasoning**
**This software and *all* its features are and will always be free for everyone to use and enjoy.**
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.

View File

@@ -13,10 +13,10 @@ from cookbook.managers import DICTIONARY
from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink,
Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
ViewLog)
ViewLog, ConnectorConfig)
class CustomUserAdmin(UserAdmin):
@@ -95,6 +95,14 @@ class StorageAdmin(admin.ModelAdmin):
admin.site.register(Storage, StorageAdmin)
class ConnectorConfigAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'type', 'enabled', 'url')
search_fields = ('name', 'url')
admin.site.register(ConnectorConfig, ConnectorConfigAdmin)
class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked')
search_fields = ('storage__name', 'path')
@@ -315,8 +323,8 @@ admin.site.register(MealPlan, MealPlanAdmin)
class MealTypeAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'order')
search_fields = ('name', 'created_by__username')
list_display = ('name', 'space', 'created_by', 'order')
search_fields = ('name', 'space', 'created_by__username')
admin.site.register(MealType, MealTypeAdmin)
@@ -361,13 +369,6 @@ class ShoppingListEntryAdmin(admin.ModelAdmin):
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
# class ShoppingListAdmin(admin.ModelAdmin):
# list_display = ('id', 'created_by', 'created_at')
# admin.site.register(ShoppingList, ShoppingListAdmin)
class ShareLinkAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'uuid', 'created_at',)

View File

@@ -3,6 +3,7 @@ import traceback
from django.apps import AppConfig
from django.conf import settings
from django.db import OperationalError, ProgrammingError
from django.db.models.signals import post_save, post_delete
from django_scopes import scopes_disabled
from recipes.settings import DEBUG
@@ -14,6 +15,16 @@ class CookbookConfig(AppConfig):
def ready(self):
import cookbook.signals # noqa
if not settings.DISABLE_EXTERNAL_CONNECTORS:
try:
from cookbook.connectors.connector_manager import ConnectorManager # Needs to be here to prevent loading race condition of oauth2 modules in models.py
handler = ConnectorManager()
post_save.connect(handler, dispatch_uid="connector_manager")
post_delete.connect(handler, dispatch_uid="connector_manager")
except Exception as e:
traceback.print_exc()
print('Failed to initialize connectors')
pass
# if not settings.DISABLE_TREE_FIX_STARTUP:
# # when starting up run fix_tree to:
# # a) make sure that nodes are sorted when switching between sort modes

View File

View File

@@ -0,0 +1,29 @@
from abc import ABC, abstractmethod
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
# A Connector is 'destroyed' & recreated each time 'any' ConnectorConfig in a space changes.
class Connector(ABC):
@abstractmethod
def __init__(self, config: ConnectorConfig):
pass
@abstractmethod
async def on_shopping_list_entry_created(self, space: Space, instance: ShoppingListEntry) -> None:
pass
# This method might not trigger on 'direct' entry updates: https://stackoverflow.com/a/35238823
@abstractmethod
async def on_shopping_list_entry_updated(self, space: Space, instance: ShoppingListEntry) -> None:
pass
@abstractmethod
async def on_shopping_list_entry_deleted(self, space: Space, instance: ShoppingListEntry) -> None:
pass
@abstractmethod
async def close(self) -> None:
pass
# TODO: Add Recipes & possibly Meal Place listeners/hooks (And maybe more?)

View File

@@ -0,0 +1,179 @@
import asyncio
import logging
import queue
import threading
from asyncio import Task
from dataclasses import dataclass
from enum import Enum
from types import UnionType
from typing import List, Any, Dict, Optional, Type
from django.conf import settings
from django_scopes import scope
from cookbook.connectors.connector import Connector
from cookbook.connectors.homeassistant import HomeAssistant
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
REGISTERED_CLASSES: UnionType | Type = ShoppingListEntry
class ActionType(Enum):
CREATED = 1
UPDATED = 2
DELETED = 3
@dataclass
class Work:
instance: REGISTERED_CLASSES | ConnectorConfig
actionType: ActionType
# The way ConnectionManager works is as follows:
# 1. On init, it starts a worker & creates a queue for 'Work'
# 2. Then any time its called, it verifies the type of action (create/update/delete) and if the item is of interest, pushes the Work (non-blocking) to the queue.
# 3. The worker consumes said work from the queue.
# 3.1 If the work is of type ConnectorConfig, it flushes its cache of known connectors (per space.id)
# 3.2 If work is of type REGISTERED_CLASSES, it asynchronously fires of all connectors and wait for them to finish (runtime should depend on the 'slowest' connector)
# 4. Work is marked as consumed, and next entry of the queue is consumed.
# Each 'Work' is processed in sequential by the worker, so the throughput is about [workers * the slowest connector]
class ConnectorManager:
_queue: queue.Queue
_listening_to_classes = REGISTERED_CLASSES | ConnectorConfig
def __init__(self):
self._queue = queue.Queue(maxsize=settings.EXTERNAL_CONNECTORS_QUEUE_SIZE)
self._worker = threading.Thread(target=self.worker, args=(0, self._queue,), daemon=True)
self._worker.start()
# Called by post save & post delete signals
def __call__(self, instance: Any, **kwargs) -> None:
if not isinstance(instance, self._listening_to_classes) or not hasattr(instance, "space"):
return
action_type: ActionType
if "created" in kwargs and kwargs["created"]:
action_type = ActionType.CREATED
elif "created" in kwargs and not kwargs["created"]:
action_type = ActionType.UPDATED
elif "origin" in kwargs:
action_type = ActionType.DELETED
else:
return
try:
self._queue.put_nowait(Work(instance, action_type))
except queue.Full:
logging.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
return
def stop(self):
self._queue.join()
self._worker.join()
@staticmethod
def worker(worker_id: int, worker_queue: queue.Queue):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
logging.info(f"started ConnectionManager worker {worker_id}")
# When multiple workers are used, please make sure the cache is shared across all threads, otherwise it might lead to un-expected behavior.
_connectors_cache: Dict[int, List[Connector]] = dict()
while True:
try:
item: Optional[Work] = worker_queue.get()
except KeyboardInterrupt:
break
if item is None:
break
# If a Connector was changed/updated, refresh connector from the database for said space
refresh_connector_cache = isinstance(item.instance, ConnectorConfig)
space: Space = item.instance.space
connectors: Optional[List[Connector]] = _connectors_cache.get(space.id)
if connectors is None or refresh_connector_cache:
if connectors is not None:
loop.run_until_complete(close_connectors(connectors))
with scope(space=space):
connectors: List[Connector] = list()
for config in space.connectorconfig_set.all():
config: ConnectorConfig = config
if not config.enabled:
continue
try:
connector: Optional[Connector] = ConnectorManager.get_connected_for_config(config)
except BaseException:
logging.exception(f"failed to initialize {config.name}")
continue
if connector is not None:
connectors.append(connector)
_connectors_cache[space.id] = connectors
if len(connectors) == 0 or refresh_connector_cache:
worker_queue.task_done()
continue
loop.run_until_complete(run_connectors(connectors, space, item.instance, item.actionType))
worker_queue.task_done()
logging.info(f"terminating ConnectionManager worker {worker_id}")
asyncio.set_event_loop(None)
loop.close()
@staticmethod
def get_connected_for_config(config: ConnectorConfig) -> Optional[Connector]:
match config.type:
case ConnectorConfig.HOMEASSISTANT:
return HomeAssistant(config)
case _:
return None
async def close_connectors(connectors: List[Connector]):
tasks: List[Task] = [asyncio.create_task(connector.close()) for connector in connectors]
if len(tasks) == 0:
return
try:
await asyncio.gather(*tasks, return_exceptions=False)
except BaseException:
logging.exception("received an exception while closing one of the connectors")
async def run_connectors(connectors: List[Connector], space: Space, instance: REGISTERED_CLASSES, action_type: ActionType):
tasks: List[Task] = list()
if isinstance(instance, ShoppingListEntry):
shopping_list_entry: ShoppingListEntry = instance
match action_type:
case ActionType.CREATED:
for connector in connectors:
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_created(space, shopping_list_entry)))
case ActionType.UPDATED:
for connector in connectors:
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_updated(space, shopping_list_entry)))
case ActionType.DELETED:
for connector in connectors:
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_deleted(space, shopping_list_entry)))
if len(tasks) == 0:
return
try:
# Wait for all async tasks to finish, if one fails, the others still continue.
await asyncio.gather(*tasks, return_exceptions=False)
except BaseException:
logging.exception("received an exception from one of the connectors")

View File

@@ -0,0 +1,85 @@
import logging
from logging import Logger
from homeassistant_api import Client, HomeassistantAPIError, Domain
from cookbook.connectors.connector import Connector
from cookbook.models import ShoppingListEntry, ConnectorConfig, Space
class HomeAssistant(Connector):
_domains_cache: dict[str, Domain]
_config: ConnectorConfig
_logger: Logger
_client: Client
def __init__(self, config: ConnectorConfig):
if not config.token or not config.url or not config.todo_entity:
raise ValueError("config for HomeAssistantConnector in incomplete")
self._domains_cache = dict()
self._config = config
self._logger = logging.getLogger("connector.HomeAssistant")
self._client = Client(self._config.url, self._config.token, async_cache_session=False, use_async=True)
async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
if not self._config.on_shopping_list_entry_created_enabled:
return
item, description = _format_shopping_list_entry(shopping_list_entry)
todo_domain = self._domains_cache.get('todo')
try:
if todo_domain is None:
todo_domain = await self._client.async_get_domain('todo')
self._domains_cache['todo'] = todo_domain
logging.debug(f"pushing {item} to {self._config.name}")
await todo_domain.add_item(entity_id=self._config.todo_entity, item=item)
except HomeassistantAPIError as err:
self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
async def on_shopping_list_entry_updated(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
if not self._config.on_shopping_list_entry_updated_enabled:
return
pass
async def on_shopping_list_entry_deleted(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
if not self._config.on_shopping_list_entry_deleted_enabled:
return
item, description = _format_shopping_list_entry(shopping_list_entry)
todo_domain = self._domains_cache.get('todo')
try:
if todo_domain is None:
todo_domain = await self._client.async_get_domain('todo')
self._domains_cache['todo'] = todo_domain
logging.debug(f"deleting {item} from {self._config.name}")
await todo_domain.remove_item(entity_id=self._config.todo_entity, item=item)
except HomeassistantAPIError as err:
self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
async def close(self) -> None:
await self._client.async_cache_session.close()
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
item = shopping_list_entry.food.name
if shopping_list_entry.amount > 0:
item += f" ({shopping_list_entry.amount:.2f}".rstrip('0').rstrip('.')
if shopping_list_entry.unit and shopping_list_entry.unit.base_unit and len(shopping_list_entry.unit.base_unit) > 0:
item += f" {shopping_list_entry.unit.base_unit})"
elif shopping_list_entry.unit and shopping_list_entry.unit.name and len(shopping_list_entry.unit.name) > 0:
item += f" {shopping_list_entry.unit.name})"
else:
item += ")"
description = "Imported by TandoorRecipes"
if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0:
description += f", created by {shopping_list_entry.created_by.first_name}"
else:
description += f", created by {shopping_list_entry.created_by.username}"
return item, description

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from allauth.account.forms import ResetPasswordForm, SignupForm
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -9,18 +10,19 @@ from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry,
SearchPreference, Space, Storage, Sync, User, UserPreference)
from .models import Comment, InviteLink, Keyword, Recipe, SearchPreference, Space, Storage, Sync, User, UserPreference, ConnectorConfig
class SelectWidget(widgets.Select):
class Media:
js = ('custom/js/form_select.js',)
js = ('custom/js/form_select.js', )
class MultiSelectWidget(widgets.SelectMultiple):
class Media:
js = ('custom/js/form_multiselect.js',)
js = ('custom/js/form_multiselect.js', )
# Yes there are some stupid browsers that still dont support this but
@@ -40,9 +42,7 @@ class UserNameForm(forms.ModelForm):
model = User
fields = ('first_name', 'last_name')
help_texts = {
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
}
help_texts = {'first_name': _('Both fields are optional. If none are given the username will be displayed instead')}
class ExternalRecipeForm(forms.ModelForm):
@@ -56,23 +56,14 @@ class ExternalRecipeForm(forms.ModelForm):
class Meta:
model = Recipe
fields = (
'name', 'description', 'servings', 'working_time', 'waiting_time',
'file_path', 'file_uid', 'keywords'
)
fields = ('name', 'description', 'servings', 'working_time', 'waiting_time', 'file_path', 'file_uid', 'keywords')
labels = {
'name': _('Name'),
'keywords': _('Keywords'),
'working_time': _('Preparation time in minutes'),
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
'file_path': _('Path'),
'file_uid': _('Storage UID'),
'name': _('Name'), 'keywords': _('Keywords'), 'working_time': _('Preparation time in minutes'), 'waiting_time': _('Waiting time (cooking/baking) in minutes'),
'file_path': _('Path'), 'file_uid': _('Storage UID'),
}
widgets = {'keywords': MultiSelectWidget}
field_classes = {
'keywords': SafeModelMultipleChoiceField,
}
field_classes = {'keywords': SafeModelMultipleChoiceField, }
class ImportExportBase(forms.Form):
@@ -99,14 +90,11 @@ class ImportExportBase(forms.Form):
REZEPTSUITEDE = 'REZEPTSUITEDE'
PDF = 'PDF'
type = forms.ChoiceField(choices=(
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
(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'), (MELARECIPES, 'Melarecipes'),
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')
))
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (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'), (MELARECIPES, 'Melarecipes'),
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')))
class MultipleFileInput(forms.ClearableFileInput):
@@ -114,6 +102,7 @@ class MultipleFileInput(forms.ClearableFileInput):
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
@@ -129,9 +118,8 @@ class MultipleFileField(forms.FileField):
class ImportForm(ImportExportBase):
files = MultipleFileField(required=True)
duplicates = forms.BooleanField(help_text=_(
'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
required=False)
duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
required=False)
class ExportForm(ImportExportBase):
@@ -150,59 +138,71 @@ class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ('text',)
fields = ('text', )
labels = {
'text': _('Add your comment: '),
}
widgets = {
'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}),
}
labels = {'text': _('Add your comment: '), }
widgets = {'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), }
class StorageForm(forms.ModelForm):
username = forms.CharField(
widget=forms.TextInput(attrs={'autocomplete': 'new-password'}),
required=False
)
password = forms.CharField(
widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
required=False,
help_text=_('Leave empty for dropbox and enter app password for nextcloud.')
)
token = forms.CharField(
widget=forms.TextInput(
attrs={'autocomplete': 'new-password', 'type': 'password'}
),
required=False,
help_text=_('Leave empty for nextcloud and enter api token for dropbox.')
)
username = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), required=False)
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
required=False,
help_text=_('Leave empty for dropbox and enter app password for nextcloud.'))
token = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
required=False,
help_text=_('Leave empty for nextcloud and enter api token for dropbox.'))
class Meta:
model = Storage
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
help_texts = {
'url': _(
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
}
help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), }
# TODO: Deprecate
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all()
class ConnectorConfigForm(forms.ModelForm):
enabled = forms.BooleanField(
help_text="Is the connector enabled",
required=False,
)
on_shopping_list_entry_created_enabled = forms.BooleanField(
help_text="Enable action for ShoppingListEntry created events",
required=False,
)
on_shopping_list_entry_updated_enabled = forms.BooleanField(
help_text="Enable action for ShoppingListEntry updated events",
required=False,
)
on_shopping_list_entry_deleted_enabled = forms.BooleanField(
help_text="Enable action for ShoppingListEntry deleted events",
required=False,
)
update_token = forms.CharField(
widget=forms.TextInput(attrs={'autocomplete': 'update-token', 'type': 'password'}),
required=False,
help_text=_('<a href="https://www.home-assistant.io/docs/authentication/#your-account-profile">Long Lived Access Token</a> for your HomeAssistant instance')
)
url = forms.URLField(
required=False,
help_text=_('Something like http://homeassistant.local:8123/api'),
)
class Meta:
model = RecipeBookEntry
fields = ('book',)
model = ConnectorConfig
field_classes = {
'book': SafeModelChoiceField,
fields = (
'name', 'type', 'enabled', 'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
'on_shopping_list_entry_deleted_enabled', 'url', 'todo_entity',
)
help_texts = {
'url': _('http://homeassistant.local:8123/api for example'),
}
@@ -217,25 +217,14 @@ class SyncForm(forms.ModelForm):
model = Sync
fields = ('storage', 'path', 'active')
field_classes = {
'storage': SafeModelChoiceField,
}
field_classes = {'storage': SafeModelChoiceField, }
labels = {
'storage': _('Storage'),
'path': _('Path'),
'active': _('Active')
}
labels = {'storage': _('Storage'), 'path': _('Path'), 'active': _('Active')}
# TODO deprecate
class BatchEditForm(forms.Form):
search = forms.CharField(label=_('Search String'))
keywords = forms.ModelMultipleChoiceField(
queryset=Keyword.objects.none(),
required=False,
widget=MultiSelectWidget
)
keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.none(), required=False, widget=MultiSelectWidget)
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
@@ -244,6 +233,7 @@ class BatchEditForm(forms.Form):
class ImportRecipeForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
@@ -253,19 +243,13 @@ class ImportRecipeForm(forms.ModelForm):
model = Recipe
fields = ('name', 'keywords', 'file_path', 'file_uid')
labels = {
'name': _('Name'),
'keywords': _('Keywords'),
'file_path': _('Path'),
'file_uid': _('File ID'),
}
labels = {'name': _('Name'), 'keywords': _('Keywords'), 'file_path': _('Path'), 'file_uid': _('File ID'), }
widgets = {'keywords': MultiSelectWidget}
field_classes = {
'keywords': SafeModelChoiceField,
}
field_classes = {'keywords': SafeModelChoiceField, }
class InviteLinkForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
user = kwargs.pop('user')
super().__init__(*args, **kwargs)
@@ -273,8 +257,8 @@ class InviteLinkForm(forms.ModelForm):
def clean(self):
space = self.cleaned_data['space']
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() +
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count()
+ InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
raise ValidationError(_('Maximum number of users for this space reached.'))
def clean_email(self):
@@ -288,12 +272,8 @@ class InviteLinkForm(forms.ModelForm):
class Meta:
model = InviteLink
fields = ('email', 'group', 'valid_until', 'space')
help_texts = {
'email': _('An email address is not required but if present the invite link will be sent to the user.'),
}
field_classes = {
'space': SafeModelChoiceField,
}
help_texts = {'email': _('An email address is not required but if present the invite link will be sent to the user.'), }
field_classes = {'space': SafeModelChoiceField, }
class SpaceCreateForm(forms.Form):
@@ -313,12 +293,12 @@ class SpaceJoinForm(forms.Form):
token = forms.CharField()
class AllAuthSignupForm(forms.Form):
class AllAuthSignupForm(SignupForm):
captcha = hCaptchaField()
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
def __init__(self, **kwargs):
super(AllAuthSignupForm, self).__init__(**kwargs)
super().__init__(**kwargs)
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
self.fields.pop('terms')
if settings.HCAPTCHA_SECRET == '':
@@ -328,135 +308,50 @@ class AllAuthSignupForm(forms.Form):
pass
class CustomPasswordResetForm(ResetPasswordForm):
captcha = hCaptchaField()
def __init__(self, **kwargs):
super(CustomPasswordResetForm, self).__init__(**kwargs)
if settings.HCAPTCHA_SECRET == '':
self.fields.pop('captcha')
class UserCreateForm(forms.Form):
name = forms.CharField(label='Username')
password = forms.CharField(
widget=forms.TextInput(
attrs={'autocomplete': 'new-password', 'type': 'password'}
)
)
password_confirm = forms.CharField(
widget=forms.TextInput(
attrs={'autocomplete': 'new-password', 'type': 'password'}
)
)
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
class SearchPreferenceForm(forms.ModelForm):
prefix = 'search'
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2,
trigram_threshold = forms.DecimalField(min_value=0.01,
max_value=1,
decimal_places=2,
widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
help_text=_(
'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = SearchPreference
fields = (
'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
fields = ('search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
help_texts = {
'search': _(
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
'unaccent': _(
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
'icontains': _(
"Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
'istartswith': _(
"Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
'trigram': _(
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
'fulltext': _(
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
'search': _('Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'), 'lookup':
_('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 'unaccent':
_('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 'icontains':
_("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 'istartswith':
_("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 'trigram':
_("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), 'fulltext':
_("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
}
labels = {
'search': _('Search Method'),
'lookup': _('Fuzzy Lookups'),
'unaccent': _('Ignore Accent'),
'icontains': _("Partial Match"),
'istartswith': _("Starts With"),
'trigram': _("Fuzzy Search"),
'fulltext': _("Full Text")
'search': _('Search Method'), 'lookup': _('Fuzzy Lookups'), 'unaccent': _('Ignore Accent'), 'icontains': _("Partial Match"), 'istartswith': _("Starts With"),
'trigram': _("Fuzzy Search"), 'fulltext': _("Full Text")
}
widgets = {
'search': SelectWidget,
'unaccent': MultiSelectWidget,
'icontains': MultiSelectWidget,
'istartswith': MultiSelectWidget,
'trigram': MultiSelectWidget,
'fulltext': MultiSelectWidget,
}
class ShoppingPreferenceForm(forms.ModelForm):
prefix = 'shopping'
class Meta:
model = UserPreference
fields = (
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
)
help_texts = {
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
'of mobile data. If lower than instance limit it is reset when saving.'
),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."),
'csv_delim': _('Delimiter to use for CSV exports.'),
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
}
labels = {
'shopping_share': _('Share Shopping List'),
'shopping_auto_sync': _('Autosync'),
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
'mealplan_autoinclude_related': _('Include Related'),
'default_delay': _('Default Delay Hours'),
'filter_to_supermarket': _('Filter to Supermarket'),
'shopping_recent_days': _('Recent Days'),
'csv_delim': _('CSV Delimiter'),
"csv_prefix_label": _("List Prefix"),
'shopping_add_onhand': _("Auto On Hand"),
}
widgets = {
'shopping_share': MultiSelectWidget
}
class SpacePreferenceForm(forms.ModelForm):
prefix = 'space'
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
help_text=_("Reset all food to inherit the fields configured."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # populates the post
self.fields['food_inherit'].queryset = Food.inheritable_fields
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit',)
help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'),
'use_plural': _('Use the plural form for units and food inside this space.'),
}
widgets = {
'food_inherit': MultiSelectWidget
'search': SelectWidget, 'unaccent': MultiSelectWidget, 'icontains': MultiSelectWidget, 'istartswith': MultiSelectWidget, 'trigram': MultiSelectWidget, 'fulltext':
MultiSelectWidget,
}

View File

@@ -1,6 +1,4 @@
import cookbook.helper.dal
from cookbook.helper.AllAuthCustomAdapter import AllAuthCustomAdapter
__all__ = [
'dal',
]

View File

@@ -98,7 +98,7 @@ class AutomationEngine:
try:
return self.food_aliases[food.lower()]
except KeyError:
return food
return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE)
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first():
return automation.param_2

View File

@@ -11,4 +11,5 @@ def context_settings(request):
'PRIVACY_URL': settings.PRIVACY_URL,
'IMPRINT_URL': settings.IMPRINT_URL,
'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL,
'DISABLE_EXTERNAL_CONNECTORS': settings.DISABLE_EXTERNAL_CONNECTORS,
}

View File

@@ -1,34 +0,0 @@
from cookbook.models import Food, Keyword, Recipe, Unit
from dal import autocomplete
class BaseAutocomplete(autocomplete.Select2QuerySetView):
model = None
def get_queryset(self):
if not self.request.user.is_authenticated:
return self.model.objects.none()
qs = self.model.objects.filter(space=self.request.space).all()
if self.q:
qs = qs.filter(name__icontains=self.q)
return qs
class KeywordAutocomplete(BaseAutocomplete):
model = Keyword
class IngredientsAutocomplete(BaseAutocomplete):
model = Food
class RecipeAutocomplete(BaseAutocomplete):
model = Recipe
class UnitAutocomplete(BaseAutocomplete):
model = Unit

View File

@@ -1,5 +1,20 @@
import traceback
from collections import defaultdict
from decimal import Decimal
from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket,
SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion)
import re
class OpenDataImportResponse:
total_created = 0
total_updated = 0
total_untouched = 0
total_errored = 0
def to_dict(self):
return {'total_created': self.total_created, 'total_updated': self.total_updated, 'total_untouched': self.total_untouched, 'total_errored': self.total_errored}
class OpenDataImporter:
@@ -18,69 +33,269 @@ class OpenDataImporter:
def _update_slug_cache(self, object_class, datatype):
self.slug_id_cache[datatype] = dict(object_class.objects.filter(space=self.request.space, open_data_slug__isnull=False).values_list('open_data_slug', 'id', ))
def import_units(self):
datatype = 'unit'
@staticmethod
def _is_obj_identical(field_list, obj, existing_obj):
"""
checks if the obj meant for import is identical to an already existing one
:param field_list: list of field names to check
:type field_list: list[str]
:param obj: object meant for import
:type obj: Object
:param existing_obj: object already in DB
:type existing_obj: Object
:return: if objects are identical
:rtype: bool
"""
for field in field_list:
if isinstance(getattr(obj, field), float) or isinstance(getattr(obj, field), Decimal):
if abs(float(getattr(obj, field)) - float(existing_obj[field])) > 0.001: # convert both to float and check if basically equal
print(f'comparing FLOAT {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
return False
elif getattr(obj, field) != existing_obj[field]:
print(f'comparing {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
return False
return True
@staticmethod
def _merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
"""
sometimes there might be two objects conflicting for open data import (one has the slug, the other the name)
this function checks if that is the case and merges the two objects if possible
:param model_type: type of model to check/merge
:type model_type: Model
:param obj: object that should be created/updated
:type obj: Model
:param existing_data_slugs: dict of open data slugs mapped to objects
:type existing_data_slugs: dict
:param existing_data_names: dict of names mapped to objects
:type existing_data_names: dict
:return: true if merge was successful or not necessary else false
:rtype: bool
"""
if obj.open_data_slug in existing_data_slugs and obj.name in existing_data_names and existing_data_slugs[obj.open_data_slug]['pk'] != existing_data_names[obj.name]['pk']:
try:
source_obj = model_type.objects.get(pk=existing_data_slugs[obj.open_data_slug]['pk'])
del existing_data_slugs[obj.open_data_slug]
source_obj.merge_into(model_type.objects.get(pk=existing_data_names[obj.name]['pk']))
return True
except RuntimeError:
return False # in the edge case (e.g. parent/child) that an object cannot be merged don't update it for now
else:
return True
@staticmethod
def _get_existing_obj(obj, existing_data_slugs, existing_data_names):
"""
gets the existing object from slug or name cache
:param obj: object that should be found
:type obj: Model
:param existing_data_slugs: dict of open data slugs mapped to objects
:type existing_data_slugs: dict
:param existing_data_names: dict of names mapped to objects
:type existing_data_names: dict
:return: existing object
:rtype: dict
"""
existing_obj = None
if obj.open_data_slug in existing_data_slugs:
existing_obj = existing_data_slugs[obj.open_data_slug]
elif obj.name in existing_data_names:
existing_obj = existing_data_names[obj.name]
return existing_obj
def import_units(self):
od_response = OpenDataImportResponse()
datatype = 'unit'
model_type = Unit
field_list = ['name', 'plural_name', 'base_unit', 'open_data_slug']
existing_data_slugs = {}
existing_data_names = {}
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_slug']] = obj
existing_data_names[obj['name']] = obj
update_list = []
create_list = []
insert_list = []
for u in list(self.data[datatype].keys()):
insert_list.append(Unit(
obj = model_type(
name=self.data[datatype][u]['name'],
plural_name=self.data[datatype][u]['plural_name'],
base_unit=self.data[datatype][u]['base_unit'] if self.data[datatype][u]['base_unit'] != '' else None,
base_unit=self.data[datatype][u]['base_unit'].lower() if self.data[datatype][u]['base_unit'] != '' else None,
open_data_slug=u,
space=self.request.space
))
)
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
od_response.total_errored += 1
continue # if conflicting objects exist and cannot be merged skip object
if self.update_existing:
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=(
'name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',))
else:
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
else:
create_list.append(obj)
if self.update_existing and len(update_list) > 0:
model_type.objects.bulk_update(update_list, field_list)
od_response.total_updated += len(update_list)
if len(create_list) > 0:
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
od_response.total_created += len(create_list)
return od_response
def import_category(self):
od_response = OpenDataImportResponse()
datatype = 'category'
model_type = SupermarketCategory
field_list = ['name', 'open_data_slug']
existing_data_slugs = {}
existing_data_names = {}
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_slug']] = obj
existing_data_names[obj['name']] = obj
update_list = []
create_list = []
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(SupermarketCategory(
obj = model_type(
name=self.data[datatype][k]['name'],
open_data_slug=k,
space=self.request.space
))
)
return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
od_response.total_errored += 1
continue # if conflicting objects exist and cannot be merged skip object
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
else:
create_list.append(obj)
if self.update_existing and len(update_list) > 0:
model_type.objects.bulk_update(update_list, field_list)
od_response.total_updated += len(update_list)
if len(create_list) > 0:
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
od_response.total_created += len(create_list)
return od_response
def import_property(self):
od_response = OpenDataImportResponse()
datatype = 'property'
model_type = PropertyType
field_list = ['name', 'unit', 'fdc_id', 'open_data_slug']
existing_data_slugs = {}
existing_data_names = {}
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_slug']] = obj
existing_data_names[obj['name']] = obj
update_list = []
create_list = []
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(PropertyType(
obj = model_type(
name=self.data[datatype][k]['name'],
unit=self.data[datatype][k]['unit'],
fdc_id=self.data[datatype][k]['fdc_id'],
open_data_slug=k,
space=self.request.space
))
)
return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
od_response.total_errored += 1
continue # if conflicting objects exist and cannot be merged skip object
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
else:
create_list.append(obj)
if self.update_existing and len(update_list) > 0:
model_type.objects.bulk_update(update_list, field_list)
od_response.total_updated += len(update_list)
if len(create_list) > 0:
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
od_response.total_created += len(create_list)
return od_response
def import_supermarket(self):
od_response = OpenDataImportResponse()
datatype = 'store'
model_type = Supermarket
field_list = ['name', 'open_data_slug']
existing_data_slugs = {}
existing_data_names = {}
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_slug']] = obj
existing_data_names[obj['name']] = obj
update_list = []
create_list = []
self._update_slug_cache(SupermarketCategory, 'category')
insert_list = []
for k in list(self.data[datatype].keys()):
insert_list.append(Supermarket(
obj = model_type(
name=self.data[datatype][k]['name'],
open_data_slug=k,
space=self.request.space
))
)
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
od_response.total_errored += 1
continue # if conflicting objects exist and cannot be merged skip object
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
else:
create_list.append(obj)
if self.update_existing and len(update_list) > 0:
model_type.objects.bulk_update(update_list, field_list)
od_response.total_updated += len(update_list)
if len(create_list) > 0:
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
od_response.total_created += len(create_list)
# always add open data slug if matching supermarket is found, otherwise relation might fail
supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',))
self._update_slug_cache(Supermarket, 'store')
insert_list = []
for k in list(self.data[datatype].keys()):
relations = []
order = 0
@@ -96,115 +311,186 @@ class OpenDataImporter:
SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',))
return supermarkets
return od_response
def import_food(self):
identifier_list = []
od_response = OpenDataImportResponse()
datatype = 'food'
for k in list(self.data[datatype].keys()):
identifier_list.append(self.data[datatype][k]['name'])
identifier_list.append(self.data[datatype][k]['plural_name'])
model_type = Food
field_list = ['name', 'open_data_slug']
existing_objects_flat = []
existing_objects = {}
for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'):
existing_objects_flat.append(f[1])
existing_objects_flat.append(f[2])
existing_objects[f[1]] = f
existing_objects[f[2]] = f
existing_data_slugs = {}
existing_data_names = {}
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_slug']] = obj
existing_data_names[obj['name']] = obj
update_list = []
create_list = []
self._update_slug_cache(Unit, 'unit')
self._update_slug_cache(PropertyType, 'property')
self._update_slug_cache(SupermarketCategory, 'category')
unit_g = Unit.objects.filter(space=self.request.space, base_unit__iexact='g').first()
insert_list = []
insert_list_flat = []
update_list = []
update_field_list = []
for k in list(self.data[datatype].keys()):
if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_flat):
insert_list.append({'data': {
'name': self.data[datatype][k]['name'],
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
'open_data_slug': k,
'space': self.request.space.id,
}})
# build a fake second flat array to prevent duplicate foods from being inserted.
# trying to insert a duplicate would throw a db error :(
insert_list_flat.append(self.data[datatype][k]['name'])
insert_list_flat.append(self.data[datatype][k]['plural_name'])
obj_dict = {
'name': self.data[datatype][k]['name'],
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
'fdc_id': re.sub(r'\D', '', self.data[datatype][k]['fdc_id']) if self.data[datatype][k]['fdc_id'] != '' else None,
'open_data_slug': k,
'properties_food_unit_id': None,
'space_id': self.request.space.id,
}
if unit_g:
obj_dict['properties_food_unit_id'] = unit_g.id
obj = model_type(**obj_dict)
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
od_response.total_errored += 1
continue # if conflicting objects exist and cannot be merged skip object
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
else:
if self.data[datatype][k]['name'] in existing_objects:
existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
else:
existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0]
create_list.append({'data': obj_dict})
if self.update_existing:
update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ]
update_list.append(Food(
id=existing_food_id,
name=self.data[datatype][k]['name'],
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
open_data_slug=k,
))
else:
update_field_list = ['open_data_slug', ]
update_list.append(Food(id=existing_food_id, open_data_slug=k, ))
if self.update_existing and len(update_list) > 0:
model_type.objects.bulk_update(update_list, field_list)
od_response.total_updated += len(update_list)
Food.load_bulk(insert_list, None)
if len(update_list) > 0:
Food.objects.bulk_update(update_list, update_field_list)
if len(create_list) > 0:
Food.load_bulk(create_list, None)
od_response.total_created += len(create_list)
# --------------- PROPERTY STUFF -----------------------
model_type = Property
field_list = ['property_type_id', 'property_amount', 'open_data_food_slug']
existing_data_slugs = {}
existing_data_property_types = {}
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_food_slug']] = obj
existing_data_property_types[obj['property_type_id']] = obj
update_list = []
create_list = []
self._update_slug_cache(Food, 'food')
food_property_list = []
# alias_list = []
for k in list(self.data['food'].keys()):
for fp in self.data['food'][k]['properties']['type_values']:
obj = model_type(
property_type_id=self.slug_id_cache['property'][fp['property_type']],
property_amount=fp['property_value'],
open_data_food_slug=k,
space=self.request.space,
)
for k in list(self.data[datatype].keys()):
for fp in self.data[datatype][k]['properties']['type_values']:
# try catch here because somettimes key "k" is not set for he food cache
try:
food_property_list.append(Property(
property_type_id=self.slug_id_cache['property'][fp['property_type']],
property_amount=fp['property_value'],
import_food_id=self.slug_id_cache['food'][k],
space=self.request.space,
))
except KeyError:
print(str(k) + ' is not in self.slug_id_cache["food"]')
if obj.open_data_food_slug in existing_data_slugs and obj.property_type_id in existing_data_property_types and existing_data_slugs[obj.open_data_food_slug] == existing_data_property_types[obj.property_type_id]:
existing_obj = existing_data_slugs[obj.open_data_food_slug]
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
create_list.append(obj)
if self.update_existing and len(update_list) > 0:
model_type.objects.bulk_update(update_list, field_list)
if len(create_list) > 0:
model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'open_data_food_slug', 'property_type',))
linked_properties = list(FoodProperty.objects.filter(food__space=self.request.space).values_list('property_id', flat=True).all())
property_food_relation_list = []
for p in Property.objects.filter(space=self.request.space, import_food_id__isnull=False).values_list('import_food_id', 'id', ):
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
for p in model_type.objects.filter(space=self.request.space, open_data_food_slug__isnull=False).values_list('open_data_food_slug', 'id', ):
if p[1] == 147:
pass
# slug_id_cache should always exist, don't create relations for already linked properties (ignore_conflicts would do that as well but this is more performant)
if p[0] in self.slug_id_cache['food'] and p[1] not in linked_properties:
property_food_relation_list.append(Food.properties.through(food_id=self.slug_id_cache['food'][p[0]], property_id=p[1]))
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
FoodProperty.objects.bulk_create(property_food_relation_list, unique_fields=('food_id', 'property_id',))
return insert_list + update_list
return od_response
def import_conversion(self):
od_response = OpenDataImportResponse()
datatype = 'conversion'
model_type = UnitConversion
field_list = ['base_amount', 'base_unit_id', 'converted_amount', 'converted_unit_id', 'food_id', 'open_data_slug']
self._update_slug_cache(Food, 'food')
self._update_slug_cache(Unit, 'unit')
existing_data_slugs = {}
existing_data_foods = defaultdict(list)
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
existing_data_slugs[obj['open_data_slug']] = obj
existing_data_foods[obj['food_id']].append(obj)
update_list = []
create_list = []
insert_list = []
for k in list(self.data[datatype].keys()):
# try catch here because sometimes key "k" is not set for he food cache
# try catch here because sometimes key "k" is not set for the food cache
try:
insert_list.append(UnitConversion(
base_amount=self.data[datatype][k]['base_amount'],
obj = model_type(
base_amount=Decimal(self.data[datatype][k]['base_amount']),
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
converted_amount=self.data[datatype][k]['converted_amount'],
converted_amount=Decimal(self.data[datatype][k]['converted_amount']),
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
open_data_slug=k,
space=self.request.space,
created_by=self.request.user,
))
except KeyError:
print(str(k) + ' is not in self.slug_id_cache["food"]')
created_by_id=self.request.user.id,
)
return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
if obj.open_data_slug in existing_data_slugs:
existing_obj = existing_data_slugs[obj.open_data_slug]
if not self._is_obj_identical(field_list, obj, existing_obj):
obj.pk = existing_obj['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
else:
matching_existing_found = False
if obj.food_id in existing_data_foods:
for edf in existing_data_foods[obj.food_id]:
if obj.base_unit_id == edf['base_unit_id'] and obj.converted_unit_id == edf['converted_unit_id']:
matching_existing_found = True
if not self._is_obj_identical(field_list, obj, edf):
obj.pk = edf['pk']
update_list.append(obj)
else:
od_response.total_untouched += 1
if not matching_existing_found:
create_list.append(obj)
except KeyError as e:
traceback.print_exc()
od_response.total_errored += 1
print(self.data[datatype][k]['food'] + ' is not in self.slug_id_cache["food"]')
if self.update_existing and len(update_list) > 0:
od_response.total_updated = model_type.objects.bulk_update(update_list, field_list)
od_response.total_errored += len(update_list) - od_response.total_updated
if len(create_list) > 0:
objs_created = model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
od_response.total_created = len(objs_created)
od_response.total_errored += len(create_list) - od_response.total_created
return od_response

View File

@@ -75,7 +75,7 @@ def is_object_owner(user, obj):
if not user.is_authenticated:
return False
try:
return obj.get_owner() == user
return obj.get_owner() == 'orphan' or obj.get_owner() == user
except Exception:
return False

View File

@@ -45,12 +45,12 @@ class FoodPropertyHelper:
conversions = uch.get_conversions(i)
for pt in property_types:
found_property = False
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
computed_properties[pt.id]['missing_value'] = i.food.properties_food_unit is None
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None: # if food is configured incorrectly
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': None}
computed_properties[pt.id]['missing_value'] = True
else:
for p in i.food.properties.all():
if p.property_type == pt:
if p.property_type == pt and p.property_amount is not None:
for c in conversions:
if c.unit == i.food.properties_food_unit:
found_property = True
@@ -58,13 +58,17 @@ class FoodPropertyHelper:
computed_properties[pt.id]['food_values'] = self.add_or_create(
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
if not found_property:
computed_properties[pt.id]['missing_value'] = True
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
if i.amount == 0: # don't count ingredients without an amount as missing
computed_properties[pt.id]['missing_value'] = computed_properties[pt.id]['missing_value'] or False # don't override if another food was already missing
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
else:
computed_properties[pt.id]['missing_value'] = True
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': None}
return computed_properties
# small dict helper to add to existing key or create new, probably a better way of doing this
# TODO move to central helper ?
# TODO move to central helper ? --> use defaultdict
@staticmethod
def add_or_create(d, key, value, food):
if key in d:

View File

@@ -231,7 +231,7 @@ def get_recipe_properties(space, property_data):
'id': pt.id,
'name': pt.name,
},
'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']),
'property_amount': parse_servings(property_data[properties[p]]) / parse_servings(property_data['servingSize']),
})
return recipe_properties

View File

@@ -1,9 +1,8 @@
from datetime import timedelta
from decimal import Decimal
from django.db.models import F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.translation import gettext as _
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
@@ -27,9 +26,6 @@ def shopping_helper(qs, request):
elif checked in ['true', 1, '1']:
qs = qs.filter(checked=True)
elif checked in ['recent']:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
@@ -79,10 +75,8 @@ class RecipeShoppingEditor():
@staticmethod
def get_shopping_list_recipe(id, user, space):
return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
Q(shoppinglist__created_by=user)
| Q(shoppinglist__shared=user)
| Q(entries__created_by=user)
return ShoppingListRecipe.objects.filter(id=id).filter(entries__space=space).filter(
Q(entries__created_by=user)
| Q(entries__created_by__in=list(user.get_shopping_share()))
).prefetch_related('entries').first()

View File

@@ -22,7 +22,7 @@ class MealMaster(Integration):
if 'Yield:' in line:
servings_text = line.replace('Yield:', '').strip()
else:
if re.match('\s{2,}([0-9])+', line):
if re.match(r'\s{2,}([0-9])+', line):
ingredients.append(line.strip())
else:
directions.append(line.strip())

View File

@@ -1,6 +1,6 @@
import base64
from io import BytesIO
from xml import etree
from lxml import etree
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
@@ -53,7 +53,10 @@ class Rezeptsuitede(Integration):
u = ingredient_parser.get_unit(ingredient.attrib['unit'])
amount = 0
if ingredient.attrib['qty'].strip() != '':
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
try:
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
except ValueError: # sometimes quantities contain words which cant be parsed
pass
ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, ))
try:

View File

@@ -15,8 +15,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-11-22 18:19+0000\n"
"Last-Translator: Spreez <tandoor@larsdev.de>\n"
"PO-Revision-Date: 2024-02-13 16:19+0000\n"
"Last-Translator: Kirstin Seidel-Gebert <kirstin@trebeg.de>\n"
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/de/>\n"
"Language: de\n"
@@ -161,7 +161,7 @@ msgstr "Name"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
msgid "Keywords"
msgstr "Schlüsselwörter"
msgstr "Schlagwörter"
#: .\cookbook\forms.py:125
msgid "Preparation time in minutes"
@@ -1436,9 +1436,9 @@ msgid ""
" "
msgstr ""
"\n"
" <b>Passwort und Token</b> werden im <b>Klartext</b> in der Datenbank "
" <b>Kennwort und Token</b> werden im <b>Klartext</b> in der Datenbank "
"gespeichert.\n"
" Dies ist notwendig da Passwort oder Token benötigt werden, um API-"
" Dies ist notwendig da Kennwort oder Token benötigt werden, um API-"
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/>"
"\n"
" Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder "

View File

@@ -14,8 +14,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-12-10 14:19+0000\n"
"Last-Translator: Robin Wilmet <wilmetrobin@hotmail.com>\n"
"PO-Revision-Date: 2024-03-03 23:19+0000\n"
"Last-Translator: Jocelin Lebreton <jocelin.lebreton@gmail.com>\n"
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/fr/>\n"
"Language: fr\n"
@@ -1802,23 +1802,6 @@ msgstr ""
" "
#: .\cookbook\templates\search_info.html:39
#, fuzzy
#| msgid ""
#| " \n"
#| " Web searches simulate functionality found on many web search "
#| "sites supporting special syntax.\n"
#| " Placing quotes around several words will convert those words "
#| "into a phrase.\n"
#| " 'or' is recongized as searching for the word (or phrase) "
#| "immediately before 'or' OR the word (or phrase) directly after.\n"
#| " '-' is recognized as searching for recipes that do not "
#| "include the word (or phrase) that comes immediately after. \n"
#| " For example searching for 'apple pie' or cherry -butter will "
#| "return any recipe that includes the phrase 'apple pie' or the word "
#| "'cherry' \n"
#| " in any field included in the full text search but exclude any "
#| "recipe that has the word 'butter' in any field included.\n"
#| " "
msgid ""
" \n"
" Web searches simulate functionality found on many web search "
@@ -1869,19 +1852,6 @@ msgstr ""
" "
#: .\cookbook\templates\search_info.html:59
#, fuzzy
#| msgid ""
#| " \n"
#| " Another approach to searching that also requires Postgresql "
#| "is fuzzy search or trigram similarity. A trigram is a group of three "
#| "consecutive characters.\n"
#| " For example searching for 'apple' will create x trigrams "
#| "'app', 'ppl', 'ple' and will create a score of how closely words match "
#| "the generated trigrams.\n"
#| " One benefit of searching trigams is that a search for "
#| "'sandwich' will find mispelled words such as 'sandwhich' that would be "
#| "missed by other methods.\n"
#| " "
msgid ""
" \n"
" Another approach to searching that also requires Postgresql is "
@@ -2465,69 +2435,93 @@ msgstr ""
#: .\cookbook\views\api.py:687
msgid "Query string matched (fuzzy) against object name."
msgstr ""
msgstr "Correspondance (floue) entre la chaîne de requête et le nom de l'objet."
#: .\cookbook\views\api.py:731
msgid ""
"Query string matched (fuzzy) against recipe name. In the future also "
"fulltext search."
msgstr ""
"La chaîne d'interrogation correspond (de manière floue) au nom de la "
"recette. À l'avenir, la recherche en texte intégral sera également possible."
#: .\cookbook\views\api.py:733
msgid ""
"ID of keyword a recipe should have. For multiple repeat parameter. "
"Equivalent to keywords_or"
msgstr ""
"ID du mot-clé qu'une recette doit avoir. Pour les paramètres à répétition "
"multiple. Equivalent à keywords_or"
#: .\cookbook\views\api.py:736
msgid ""
"Keyword IDs, repeat for multiple. Return recipes with any of the keywords"
msgstr ""
"ID des mots-clés, répéter pour plusieurs. Retourner les recettes avec "
"n'importe quel mot-clé"
#: .\cookbook\views\api.py:739
msgid ""
"Keyword IDs, repeat for multiple. Return recipes with all of the keywords."
msgstr ""
"ID des mots-clés, répéter pour plusieurs. Retourner les recettes contenant "
"tous les mots-clés."
#: .\cookbook\views\api.py:742
msgid ""
"Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords."
msgstr ""
"ID des mots-clés, répéter pour plusieurs. Exclure les recettes contenant "
"l'un des mots-clés."
#: .\cookbook\views\api.py:745
msgid ""
"Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords."
msgstr ""
"ID des mots-clés, répéter pour plusieurs. Exclure les recettes contenant "
"l'un des mots-clés."
#: .\cookbook\views\api.py:747
msgid "ID of food a recipe should have. For multiple repeat parameter."
msgstr ""
"ID de l'aliment qu'une recette doit contenir. Pour les paramètres de "
"répétition multiples."
#: .\cookbook\views\api.py:750
msgid "Food IDs, repeat for multiple. Return recipes with any of the foods"
msgstr ""
"ID des aliments, répéter pour plusieurs. Retourner les recettes contenant "
"l'un des aliments"
#: .\cookbook\views\api.py:752
msgid "Food IDs, repeat for multiple. Return recipes with all of the foods."
msgstr ""
"ID des aliments, répéter pour plusieurs. Retourner les recettes avec tous "
"les aliments."
#: .\cookbook\views\api.py:754
msgid "Food IDs, repeat for multiple. Exclude recipes with any of the foods."
msgstr ""
"ID des aliments, répéter pour plusieurs. Exclure les recettes contenant l'un "
"des aliments."
#: .\cookbook\views\api.py:756
msgid "Food IDs, repeat for multiple. Exclude recipes with all of the foods."
msgstr ""
"ID des aliments, répéter pour plusieurs. Exclure les recettes contenant tous "
"les aliments."
#: .\cookbook\views\api.py:757
msgid "ID of unit a recipe should have."
msgstr ""
msgstr "ID de l'unité qu'une recette doit avoir."
#: .\cookbook\views\api.py:759
msgid ""
"Rating a recipe should have or greater. [0 - 5] Negative value filters "
"rating less than."
msgstr ""
"Note qu'une recette devrait avoir ou être supérieure. [0 - 5] Une valeur "
"négative filtre une note inférieure à."
#: .\cookbook\views\api.py:760
msgid "ID of book a recipe should be in. For multiple repeat parameter."
@@ -2686,10 +2680,8 @@ msgid "Invite Link"
msgstr "Lien dinvitation"
#: .\cookbook\views\delete.py:200
#, fuzzy
#| msgid "Members"
msgid "Space Membership"
msgstr "Membres"
msgstr "Adhésion à l'espace"
#: .\cookbook\views\edit.py:116
msgid "You cannot edit this storage!"

View File

@@ -12,8 +12,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2024-02-01 17:22+0000\n"
"Last-Translator: Lorenzo <gerosa.lorenzo.gl@gmail.com>\n"
"PO-Revision-Date: 2024-02-17 19:16+0000\n"
"Last-Translator: Andrea <giovannibecco@mailo.com>\n"
"Language-Team: Italian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/it/>\n"
"Language: it\n"
@@ -2093,7 +2093,7 @@ msgstr "Proprietario"
#, fuzzy
#| msgid "Create Space"
msgid "Leave Space"
msgstr "Crea Istanza"
msgstr "Lascia Istanza"
#: .\cookbook\templates\space_overview.html:78
#: .\cookbook\templates\space_overview.html:88

View File

@@ -13,8 +13,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-08-15 19:19+0000\n"
"Last-Translator: Jochum van der Heide <jochum@famvanderheide.com>\n"
"PO-Revision-Date: 2024-02-10 12:20+0000\n"
"Last-Translator: Jonan B <jonanb@pm.me>\n"
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/nl/>\n"
"Language: nl\n"
@@ -159,7 +159,7 @@ msgstr "Naam"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
msgid "Keywords"
msgstr "Etiketten"
msgstr "Trefwoorden"
#: .\cookbook\forms.py:125
msgid "Preparation time in minutes"
@@ -1224,7 +1224,7 @@ msgstr "Markdown gids"
#: .\cookbook\templates\base.html:329
msgid "GitHub"
msgstr "GitHub"
msgstr "Github"
#: .\cookbook\templates\base.html:331
msgid "Translate Tandoor"

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-11 15:09+0200\n"
"PO-Revision-Date: 2022-04-17 00:31+0000\n"
"Last-Translator: Oskar Stenberg <01ste02@gmail.com>\n"
"PO-Revision-Date: 2024-02-27 12:19+0000\n"
"Last-Translator: Lukas Åteg <lukas@ategsolutions.se>\n"
"Language-Team: Swedish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/sv/>\n"
"Language: sv\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.10.1\n"
"X-Generator: Weblate 4.15\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:91
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
@@ -1812,7 +1812,7 @@ msgstr "Google ld+json info"
#: .\cookbook\templates\url_import.html:268
msgid "GitHub Issues"
msgstr "GitHub Issues"
msgstr "GitHub Problem"
#: .\cookbook\templates\url_import.html:270
msgid "Recipe Markup Specification"
@@ -1852,7 +1852,7 @@ msgstr "Kunde inte tolka korrekt..."
msgid "Batch edit done. %(count)d recipe was updated."
msgid_plural "Batch edit done. %(count)d Recipes where updated."
msgstr[0] "Batchredigering klar. %(count)d recept uppdaterades."
msgstr[1] "Batchredigering klar. %(count)d recept uppdaterades."
msgstr[1] "Batchredigering klar. %(count)d recepten uppdaterades."
#: .\cookbook\views\delete.py:72
msgid "Monitor"

View File

@@ -11,8 +11,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2022-11-06 22:09+0000\n"
"Last-Translator: Gorkem <g.kalipcilar@gmail.com>\n"
"PO-Revision-Date: 2024-03-03 23:19+0000\n"
"Last-Translator: M Ugur <mugurd@gmail.com>\n"
"Language-Team: Turkish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/tr/>\n"
"Language: tr\n"
@@ -20,7 +20,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
"X-Generator: Weblate 4.14.1\n"
"X-Generator: Weblate 4.15\n"
#: .\cookbook\forms.py:52
msgid "Default unit"
@@ -28,41 +28,39 @@ msgstr "Varsayılan birim"
#: .\cookbook\forms.py:53
msgid "Use fractions"
msgstr ""
msgstr "Kesirleri kullan"
#: .\cookbook\forms.py:54
msgid "Use KJ"
msgstr ""
msgstr "KiloJoule kullan"
#: .\cookbook\forms.py:55
msgid "Theme"
msgstr ""
msgstr "Tema"
#: .\cookbook\forms.py:56
msgid "Navbar color"
msgstr ""
msgstr "Gezinti çubuğu rengi"
#: .\cookbook\forms.py:57
msgid "Sticky navbar"
msgstr ""
msgstr "Yapışkan gezinti çubuğu"
#: .\cookbook\forms.py:58
msgid "Default page"
msgstr ""
msgstr "Varsayılan sayfa"
#: .\cookbook\forms.py:59
msgid "Plan sharing"
msgstr ""
msgstr "Plan paylaşımı"
#: .\cookbook\forms.py:60
#, fuzzy
#| msgid "Ingredients"
msgid "Ingredient decimal places"
msgstr "Malzemeler"
msgstr "Malzeme ondalık virgül yeri"
#: .\cookbook\forms.py:61
msgid "Shopping list auto sync period"
msgstr ""
msgstr "Alışveriş listesinin otomatik eşleşme sıklığı"
#: .\cookbook\forms.py:62 .\cookbook\templates\recipe_view.html:36
msgid "Comments"
@@ -70,7 +68,7 @@ msgstr "Yorumlar"
#: .\cookbook\forms.py:63
msgid "Left-handed mode"
msgstr ""
msgstr "Solaklar için"
#: .\cookbook\forms.py:67
msgid ""
@@ -89,10 +87,12 @@ msgid ""
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
"to fractions automatically)"
msgstr ""
"Malzeme miktarı için kesir desteğini etkinleştir (örn. ondalıkları kesire "
"otomatik çevir)"
#: .\cookbook\forms.py:73
msgid "Display nutritional energy amounts in joules instead of calories"
msgstr ""
msgstr "Besin değerlerini kalori yerine jul olarak görüntüle"
#: .\cookbook\forms.py:74
msgid "Users with whom newly created meal plans should be shared by default."
@@ -100,7 +100,7 @@ msgstr ""
#: .\cookbook\forms.py:75
msgid "Users with whom to share shopping lists."
msgstr ""
msgstr "Alışveriş listesinin paylaşılacağı kullanıcılar."
#: .\cookbook\forms.py:76
msgid "Number of decimals to round ingredients."
@@ -129,11 +129,11 @@ msgstr ""
#: .\cookbook\forms.py:83 .\cookbook\forms.py:512
msgid "Automatically add meal plan ingredients to shopping list."
msgstr ""
msgstr "Otomatik olarak yemek planındaki malzemeleri alışveriş listesine ekle."
#: .\cookbook\forms.py:84
msgid "Exclude ingredients that are on hand."
msgstr ""
msgstr "Var olan malzemeleri hariç tut."
#: .\cookbook\forms.py:85
msgid "Will optimize the UI for use with your left hand."
@@ -144,6 +144,8 @@ msgid ""
"Both fields are optional. If none are given the username will be displayed "
"instead"
msgstr ""
"Her iki değer de tercihe bağlıdır. Hiç birisi verilmezse yerlerine kullanıcı "
"adı gösterilecektir"
#: .\cookbook\forms.py:123 .\cookbook\forms.py:314
msgid "Name"
@@ -151,23 +153,23 @@ msgstr "İsim"
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
msgid "Keywords"
msgstr ""
msgstr "Anahtar kelimeler"
#: .\cookbook\forms.py:125
msgid "Preparation time in minutes"
msgstr ""
msgstr "Hazırlık süresi dakika cinsinden"
#: .\cookbook\forms.py:126
msgid "Waiting time (cooking/baking) in minutes"
msgstr ""
msgstr "Bekleme süresi (pişirme/fırınlama) dakika cinsinden"
#: .\cookbook\forms.py:127 .\cookbook\forms.py:283 .\cookbook\forms.py:316
msgid "Path"
msgstr ""
msgstr "Adres"
#: .\cookbook\forms.py:128
msgid "Storage UID"
msgstr ""
msgstr "Saklama UID (biricik tanımlayıcı)"
#: .\cookbook\forms.py:161
msgid "Default"
@@ -424,10 +426,8 @@ msgid "Fields on food that should be inherited by default."
msgstr ""
#: .\cookbook\forms.py:558
#, fuzzy
#| msgid "Show recently viewed recipes on search page."
msgid "Show recipe counts on search filters"
msgstr "Son görüntülenen tarifleri arama sayfasında göster."
msgstr "süzülen tarifleri arama sayfasında göster."
#: .\cookbook\forms.py:559
msgid "Use the plural form for units and food inside this space."

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
"PO-Revision-Date: 2023-02-26 13:15+0000\n"
"Last-Translator: 吕楪 <thy@irithys.com>\n"
"PO-Revision-Date: 2024-02-15 03:19+0000\n"
"Last-Translator: dalan <xzdlj@outlook.com>\n"
"Language-Team: Chinese (Simplified) <http://translate.tandoor.dev/projects/"
"tandoor/recipes-backend/zh_Hans/>\n"
"Language: zh_CN\n"
@@ -480,34 +480,32 @@ msgid "One of queryset or hash_key must be provided"
msgstr "必须提供 queryset 或 hash_key 之一"
#: .\cookbook\helper\recipe_url_import.py:266
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation"
msgstr "使用分数"
msgstr "反向旋转"
#: .\cookbook\helper\recipe_url_import.py:267
msgid "careful rotation"
msgstr ""
msgstr "小心旋转"
#: .\cookbook\helper\recipe_url_import.py:268
msgid "knead"
msgstr ""
msgstr ""
#: .\cookbook\helper\recipe_url_import.py:269
msgid "thicken"
msgstr ""
msgstr "增稠"
#: .\cookbook\helper\recipe_url_import.py:270
msgid "warm up"
msgstr ""
msgstr "预热"
#: .\cookbook\helper\recipe_url_import.py:271
msgid "ferment"
msgstr ""
msgstr "发酵"
#: .\cookbook\helper\recipe_url_import.py:272
msgid "sous-vide"
msgstr ""
msgstr "真空烹调法"
#: .\cookbook\helper\shopping_helper.py:157
msgid "You must supply a servings size"
@@ -549,10 +547,8 @@ msgid "Imported %s recipes."
msgstr "导入了%s菜谱。"
#: .\cookbook\integration\openeats.py:26
#, fuzzy
#| msgid "Recipe Home"
msgid "Recipe source:"
msgstr "菜谱主页"
msgstr "菜谱来源:"
#: .\cookbook\integration\paprika.py:49
msgid "Notes"

View File

@@ -0,0 +1,34 @@
from django.conf import settings
from django.contrib.postgres.search import SearchVector
from django.core.management.base import BaseCommand
from django.db.models import Count
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, FoodProperty, Food
# can be executed at the command line with 'python manage.py rebuildindex'
class Command(BaseCommand):
help = _('Fixes foods with ')
def add_arguments(self, parser):
parser.add_argument('-d', '--dry-run', help='does not delete properties but instead prints them', action='store_true')
def handle(self, *args, **options):
with scopes_disabled():
foods_with_duplicate_properties = Food.objects.annotate(property_type_count=Count('foodproperty__property__property_type') - Count('foodproperty__property__property_type', distinct=True)).filter(property_type_count__gt=0).all()
for f in foods_with_duplicate_properties:
found_property_types = []
for fp in f.properties.all():
if fp.property_type.id in found_property_types:
if options['dry_run']:
print(f'Property id {fp.id} duplicate type {fp.property_type}({fp.property_type.id}) for food {f}({f.id})')
else:
print(f'DELETING property id {fp.id} duplicate type {fp.property_type}({fp.property_type.id}) for food {f}({f.id})')
fp.delete()
else:
found_property_types.append(fp.property_type.id)

View File

@@ -0,0 +1,25 @@
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.postgres.search import SearchVector
from django.core.management.base import BaseCommand
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, Space
class Command(BaseCommand):
help = 'Seeds some basic data (space, account, food)'
def handle(self, *args, **options):
with scopes_disabled():
user = User.objects.get_or_create(username='test')[0]
user.set_password('test')
user.save()
space = Space.objects.get_or_create(
name='Test Space',
created_by=user
)[0]

View File

@@ -6,11 +6,12 @@ from django.conf import settings
from django.db import migrations, models
from django_scopes import scopes_disabled
from cookbook.models import PermissionModelMixin, ShoppingListEntry
from cookbook.models import PermissionModelMixin
def copy_values_to_sle(apps, schema_editor):
with scopes_disabled():
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
entries = ShoppingListEntry.objects.all()
for entry in entries:
if entry.shoppinglist_set.first():

View File

@@ -1,25 +1,22 @@
# Generated by Django 3.2.7 on 2021-10-01 22:34
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db import migrations
from django.utils import timezone
from django.utils.timezone import utc
from django_scopes import scopes_disabled
from cookbook.models import FoodInheritField, ShoppingListEntry
def delete_orphaned_sle(apps, schema_editor):
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
with scopes_disabled():
# shopping list entry is orphaned - delete it
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
def create_inheritfields(apps, schema_editor):
FoodInheritField = apps.get_model('cookbook', 'FoodInheritField')
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
FoodInheritField.objects.create(name='Diet', field='diet')
@@ -29,6 +26,7 @@ def create_inheritfields(apps, schema_editor):
def set_completed_at(apps, schema_editor):
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
month_ago = today_start - timedelta(days=30)

View File

@@ -13,22 +13,22 @@ def migrate_icons(apps, schema_editor):
PropertyType = apps.get_model('cookbook', 'PropertyType')
RecipeBook = apps.get_model('cookbook', 'RecipeBook')
duplicate_meal_types = MealType.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all()
duplicate_meal_types = MealType.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
if len(duplicate_meal_types) > 0:
raise RuntimeError(f'Duplicate MealTypes found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
MealType.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
duplicate_meal_types = Keyword.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all()
duplicate_meal_types = Keyword.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
if len(duplicate_meal_types) > 0:
raise RuntimeError(f'Duplicate Keyword found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
Keyword.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
duplicate_meal_types = PropertyType.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all()
duplicate_meal_types = PropertyType.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
if len(duplicate_meal_types) > 0:
raise RuntimeError(f'Duplicate PropertyType found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
PropertyType.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
duplicate_meal_types = RecipeBook.objects.values('name').annotate(name_count=Count('name')).exclude(name_count=1).all()
duplicate_meal_types = RecipeBook.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
if len(duplicate_meal_types) > 0:
raise RuntimeError(f'Duplicate RecipeBook found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
RecipeBook.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
@@ -40,7 +40,7 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RunPython( migrate_icons),
migrations.RunPython(migrate_icons),
migrations.AlterModelOptions(
name='propertytype',
options={'ordering': ('order',)},

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-01-28 10:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0209_remove_space_use_plural'),
]
operations = [
migrations.AddField(
model_name='shoppinglistentry',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-02-16 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0210_shoppinglistentry_updated_at'),
]
operations = [
migrations.AddField(
model_name='recipebook',
name='order',
field=models.IntegerField(default=0),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-02-18 07:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0211_recipebook_order'),
]
operations = [
migrations.AlterField(
model_name='property',
name='property_amount',
field=models.DecimalField(decimal_places=4, default=None, max_digits=32, null=True),
),
]

View File

@@ -0,0 +1,54 @@
# Generated by Django 4.2.10 on 2024-02-19 13:48
from django.db import migrations, models
from django_scopes import scopes_disabled
def migrate_property_import_slug(apps, schema_editor):
with scopes_disabled():
Property = apps.get_model('cookbook', 'Property')
Food = apps.get_model('cookbook', 'Food')
id_slug_mapping = {}
with scopes_disabled():
for f in Food.objects.filter(open_data_slug__isnull=False).values('id', 'open_data_slug').all():
id_slug_mapping[f['id']] = f['open_data_slug']
property_update_list = []
for p in Property.objects.filter().values('id', 'import_food_id').all():
if p['import_food_id'] in id_slug_mapping:
property_update_list.append(Property(
id=p['id'],
open_data_food_slug=id_slug_mapping[p['import_food_id']]
))
Property.objects.bulk_update(property_update_list, ('open_data_food_slug',))
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0212_alter_property_property_amount'),
]
operations = [
migrations.AddField(
model_name='property',
name='open_data_food_slug',
field=models.CharField(blank=True, default=None, max_length=128, null=True),
),
migrations.RunPython(migrate_property_import_slug),
migrations.RemoveConstraint(
model_name='property',
name='property_unique_import_food_per_space',
),
migrations.RemoveField(
model_name='property',
name='import_food_id',
),
migrations.AddConstraint(
model_name='property',
constraint=models.UniqueConstraint(fields=('space', 'property_type', 'open_data_food_slug'), name='property_unique_import_food_per_space'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 4.2.7 on 2024-02-24 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0213_remove_property_property_unique_import_food_per_space_and_more'),
]
operations = [
migrations.AddField(
model_name='cooklog',
name='comment',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='cooklog',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name='cooklog',
name='rating',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='cooklog',
name='servings',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 4.2.10 on 2024-02-26 14:41
import cookbook.models
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0214_cooklog_comment_cooklog_updated_at_and_more'),
]
operations = [
migrations.CreateModel(
name='ConnectorConfig',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)])),
('type', models.CharField(choices=[('HomeAssistant', 'HomeAssistant')], default='HomeAssistant', max_length=128)),
('enabled', models.BooleanField(default=True, help_text='Is Connector Enabled')),
('on_shopping_list_entry_created_enabled', models.BooleanField(default=False)),
('on_shopping_list_entry_updated_enabled', models.BooleanField(default=False)),
('on_shopping_list_entry_deleted_enabled', models.BooleanField(default=False)),
('url', models.URLField(blank=True, null=True)),
('token', models.CharField(blank=True, max_length=512, null=True)),
('todo_entity', models.CharField(blank=True, max_length=128, null=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
],
bases=(models.Model, cookbook.models.PermissionModelMixin),
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 4.2.10 on 2024-02-28 16:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0215_connectorconfig'),
]
operations = [
migrations.DeleteModel(
name='ShoppingList',
),
]

View File

@@ -209,6 +209,27 @@ class TreeModel(MP_Node):
abstract = True
class MergeModelMixin:
def merge_into(self, target):
"""
very simple merge function that replaces the current instance with the target instance
:param target: target object
:return: target with data merged
"""
if self == target:
raise ValueError('Cannot merge an object with itself')
if getattr(self, 'space', 0) != getattr(target, 'space', 0):
raise RuntimeError('Cannot merge objects from different spaces')
if hasattr(self, 'get_descendants_and_self') and target in callable(getattr(self, 'get_descendants_and_self')):
raise RuntimeError('Cannot merge parent (source) with child (target) object')
# TODO copy field values
class PermissionModelMixin:
@staticmethod
def get_space_key():
@@ -320,10 +341,18 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
BookmarkletImport.objects.filter(space=self).delete()
CustomFilter.objects.filter(space=self).delete()
Property.objects.filter(space=self).delete()
PropertyType.objects.filter(space=self).delete()
Comment.objects.filter(recipe__space=self).delete()
Keyword.objects.filter(space=self).delete()
Ingredient.objects.filter(space=self).delete()
Food.objects.filter(space=self).delete()
Keyword.objects.filter(space=self).delete()
# delete food in batches because treabeard might fail to delete otherwise
while Food.objects.filter(space=self).count() > 0:
pks = Food.objects.filter(space=self).values_list('pk')[:200]
Food.objects.filter(pk__in=pks).delete()
Unit.objects.filter(space=self).delete()
Step.objects.filter(space=self).delete()
NutritionInformation.objects.filter(space=self).delete()
@@ -338,18 +367,20 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
SyncLog.objects.filter(sync__space=self).delete()
Sync.objects.filter(space=self).delete()
Storage.objects.filter(space=self).delete()
ConnectorConfig.objects.filter(space=self).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
ShoppingList.objects.filter(space=self).delete()
ShoppingListEntry.objects.filter(space=self).delete()
ShoppingListRecipe.objects.filter(recipe__space=self).delete()
SupermarketCategoryRelation.objects.filter(supermarket__space=self).delete()
SupermarketCategory.objects.filter(space=self).delete()
Supermarket.objects.filter(space=self).delete()
InviteLink.objects.filter(space=self).delete()
UserFile.objects.filter(space=self).delete()
UserSpace.objects.filter(space=self).delete()
Automation.objects.filter(space=self).delete()
InviteLink.objects.filter(space=self).delete()
TelegramBot.objects.filter(space=self).delete()
self.delete()
def get_owner(self):
@@ -362,6 +393,31 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
return self.name
class ConnectorConfig(models.Model, PermissionModelMixin):
HOMEASSISTANT = 'HomeAssistant'
CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),)
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
type = models.CharField(
choices=CONNECTER_TYPE, max_length=128, default=HOMEASSISTANT
)
enabled = models.BooleanField(default=True, help_text="Is Connector Enabled")
on_shopping_list_entry_created_enabled = models.BooleanField(default=False)
on_shopping_list_entry_updated_enabled = models.BooleanField(default=False)
on_shopping_list_entry_deleted_enabled = models.BooleanField(default=False)
url = models.URLField(blank=True, null=True)
token = models.CharField(max_length=512, blank=True, null=True)
todo_entity = models.CharField(max_length=128, blank=True, null=True)
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
class UserPreference(models.Model, PermissionModelMixin):
# Themes
BOOTSTRAP = 'BOOTSTRAP'
@@ -442,6 +498,7 @@ class UserPreference(models.Model, PermissionModelMixin):
self.use_fractions = FRACTION_PREF_DEFAULT
return super().save(*args, **kwargs)
def __str__(self):
return str(self.user)
@@ -501,7 +558,7 @@ class Sync(models.Model, PermissionModelMixin):
return self.path
class SupermarketCategory(models.Model, PermissionModelMixin):
class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
description = models.TextField(blank=True, null=True)
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
@@ -512,6 +569,14 @@ class SupermarketCategory(models.Model, PermissionModelMixin):
def __str__(self):
return self.name
def merge_into(self, target):
super().merge_into(target)
Food.objects.filter(supermarket_category=self).update(supermarket_category=target)
SupermarketCategoryRelation.objects.filter(category=self).update(category=target)
self.delete()
return target
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
@@ -586,7 +651,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
indexes = (Index(fields=['id', 'name']),)
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin, MergeModelMixin):
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
description = models.TextField(blank=True, null=True)
@@ -596,6 +661,17 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def merge_into(self, target):
super().merge_into(target)
Ingredient.objects.filter(unit=self).update(unit=target)
ShoppingListEntry.objects.filter(unit=self).update(unit=target)
Food.objects.filter(properties_food_unit=self).update(properties_food_unit=target)
Food.objects.filter(preferred_unit=self).update(preferred_unit=target)
Food.objects.filter(preferred_shopping_unit=self).update(preferred_shopping_unit=target)
self.delete()
return target
def __str__(self):
return self.name
@@ -644,6 +720,32 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
def __str__(self):
return self.name
def merge_into(self, target):
"""
very simple merge function that replaces the current food with the target food
also replaces a few attributes on the target field if they were empty before
:param target: target food object
:return: target with data merged
"""
if self == target:
raise ValueError('Cannot merge an object with itself')
if self.space != target.space:
raise RuntimeError('Cannot merge objects from different spaces')
try:
if target in self.get_descendants_and_self():
raise RuntimeError('Cannot merge parent (source) with child (target) object')
except AttributeError:
pass # AttributeError is raised when the object is not a tree and thus does not have the get_descendants_and_self() function
self.properties.all().delete()
self.properties.clear()
Ingredient.objects.filter(food=self).update(food=target)
ShoppingListEntry.objects.filter(food=self).update(food=target)
self.delete()
return target
def delete(self):
if self.ingredient_set.all().exclude(step=None).count() > 0:
raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
@@ -763,7 +865,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
objects = ScopedManager(space='space')
def __str__(self):
return f'{self.pk}: {self.amount} {self.food.name} {self.unit.name}'
return f'{self.pk}: {self.amount} ' + (self.food.name if self.food else ' ') + (self.unit.name if self.unit else '')
class Meta:
ordering = ['order', 'pk']
@@ -801,7 +903,7 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
indexes = (GinIndex(fields=["search_vector"]),)
class PropertyType(models.Model, PermissionModelMixin):
class PropertyType(models.Model, PermissionModelMixin, MergeModelMixin):
NUTRITION = 'NUTRITION'
ALLERGEN = 'ALLERGEN'
PRICE = 'PRICE'
@@ -826,6 +928,13 @@ class PropertyType(models.Model, PermissionModelMixin):
def __str__(self):
return f'{self.name}'
def merge_into(self, target):
super().merge_into(target)
Property.objects.filter(property_type=self).update(property_type=target)
self.delete()
return target
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'),
@@ -835,10 +944,10 @@ class PropertyType(models.Model, PermissionModelMixin):
class Property(models.Model, PermissionModelMixin):
property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32)
property_amount = models.DecimalField(default=None, null=True, decimal_places=4, max_digits=32)
property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT)
import_food_id = models.IntegerField(null=True, blank=True) # field to hold food id when importing properties from the open data project
open_data_food_slug = models.CharField(max_length=128, null=True, blank=True, default=None) # field to hold food id when importing properties from the open data project
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@@ -848,7 +957,7 @@ class Property(models.Model, PermissionModelMixin):
class Meta:
constraints = [
models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space')
models.UniqueConstraint(fields=['space', 'property_type', 'open_data_food_slug'], name='property_unique_import_food_per_space')
]
@@ -983,6 +1092,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
order = models.IntegerField(default=0)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@@ -1084,7 +1194,10 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
def get_owner(self):
try:
return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
if not self.entries.exists():
return 'orphan'
else:
return getattr(self.entries.first(), 'created_by', None)
except AttributeError:
return None
@@ -1099,59 +1212,27 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
checked = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
completed_at = models.DateTimeField(null=True, blank=True)
delay_until = models.DateTimeField(null=True, blank=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
@staticmethod
def get_space_key():
return 'shoppinglist', 'space'
def get_space(self):
return self.shoppinglist_set.first().space
def __str__(self):
return f'Shopping list entry {self.id}'
def get_shared(self):
try:
return self.shoppinglist_set.first().shared.all()
except AttributeError:
return self.created_by.userpreference.shopping_share.all()
return self.created_by.userpreference.shopping_share.all()
def get_owner(self):
try:
return self.created_by or self.shoppinglist_set.first().created_by
return self.created_by
except AttributeError:
return None
class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, PermissionModelMixin):
uuid = models.UUIDField(default=uuid.uuid4)
note = models.TextField(blank=True, null=True)
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL)
finished = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
def __str__(self):
return f'Shopping list {self.id}'
def get_shared(self):
try:
return self.shared.all() or self.created_by.userpreference.shopping_share.all()
except AttributeError:
return []
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
uuid = models.UUIDField(default=uuid.uuid4)
@@ -1206,10 +1287,13 @@ class TelegramBot(models.Model, PermissionModelMixin):
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
rating = models.IntegerField(null=True, blank=True)
servings = models.IntegerField(null=True, blank=True)
comment = models.TextField(null=True, blank=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(default=timezone.now)
rating = models.IntegerField(null=True)
servings = models.IntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')

View File

@@ -44,12 +44,12 @@ class TreeSchema(AutoSchema):
"name": 'root', "in": "query", "required": False,
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(
obj=api_name),
'schema': {'type': 'int', },
'schema': {'type': 'integer', },
})
parameters.append({
"name": 'tree', "in": "query", "required": False,
"description": 'Return all self and children of {} with ID [int].'.format(api_name),
'schema': {'type': 'int', },
'schema': {'type': 'integer', },
})
return parameters

View File

@@ -1,4 +1,3 @@
import traceback
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
@@ -31,10 +30,10 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
Keyword, MealPlan, MealType, NutritionInformation, Property,
PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport,
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
ShareLink, ShoppingListEntry, ShoppingListRecipe, Space,
Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
UserFile, UserPreference, UserSpace, ViewLog)
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL
@@ -57,7 +56,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):
pass
@@ -122,7 +122,8 @@ class CustomOnHandField(serializers.Field):
if not self.context["request"].user.is_authenticated:
return []
shared_users = []
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
if c := caches['default'].get(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
@@ -348,7 +349,7 @@ class MealTypeSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
validated_data['created_by'] = self.context['request'].user
obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, created_by=self.context['request'].user, defaults=validated_data)
return obj
class Meta:
@@ -382,13 +383,15 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserPreference
fields = (
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page', 'use_fractions', 'use_kj',
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page',
'use_fractions', 'use_kj',
'plan_share', 'nav_sticky',
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
'food_inherit_default', 'default_delay',
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
'csv_delim', 'csv_prefix',
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', 'food_children_exist'
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients',
'food_children_exist'
)
@@ -413,6 +416,27 @@ class StorageSerializer(SpacedModelSerializer):
}
class ConnectorConfigConfigSerializer(SpacedModelSerializer):
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
class Meta:
model = ConnectorConfig
fields = (
'id', 'name', 'url', 'token', 'todo_entity', 'enabled',
'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
'on_shopping_list_entry_deleted_enabled', 'created_by'
)
read_only_fields = ('created_by',)
extra_kwargs = {
'token': {'write_only': True},
}
class SyncSerializer(SpacedModelSerializer):
class Meta:
model = Sync
@@ -477,10 +501,13 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin)
if x := validated_data.get('name', None):
validated_data['plural_name'] = x.strip()
if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first():
if unit := Unit.objects.filter(
Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']),
space=space).first():
return unit
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
def update(self, instance, validated_data):
@@ -500,7 +527,8 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
def update(self, instance, validated_data):
@@ -525,7 +553,8 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
class Meta:
@@ -540,7 +569,8 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
def create(self, validated_data):
validated_data['name'] = validated_data['name'].strip()
space = validated_data.pop('space', self.context['request'].space)
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space,
defaults=validated_data)
return obj
class Meta:
@@ -550,7 +580,7 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
property_type = PropertyTypeSerializer()
property_amount = CustomDecimalField()
property_amount = CustomDecimalField(allow_null=True)
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
@@ -665,12 +695,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
properties = validated_data.pop('properties', None)
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space,
properties_food_unit=properties_food_unit,
defaults=validated_data)
if properties and len(properties) > 0:
for p in properties:
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space))
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'],
property_amount=p['property_amount'], space=space))
return obj
@@ -702,7 +734,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id',
'food_onhand', 'supermarket_category',
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug',
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields',
'open_data_slug',
)
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
@@ -726,7 +759,8 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
uch = UnitConversionHelper(self.context['request'].space)
conversions = []
for c in uch.get_conversions(obj):
conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
conversions.append(
{'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
return conversions
else:
return []
@@ -755,8 +789,7 @@ class IngredientSerializer(IngredientSimpleSerializer):
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
ingredients = IngredientSerializer(many=True)
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue')
instructions_markdown = serializers.SerializerMethodField('get_instructions_markdown')
file = UserFileViewSerializer(allow_null=True, required=False)
step_recipe_data = serializers.SerializerMethodField('get_step_recipe_data')
recipe_filter = 'steps'
@@ -765,10 +798,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
def get_ingredients_vue(self, obj):
return obj.get_instruction_render()
def get_ingredients_markdown(self, obj):
def get_instructions_markdown(self, obj):
return obj.get_instruction_render()
def get_step_recipes(self, obj):
@@ -783,8 +813,8 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
class Meta:
model = Step
fields = (
'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown',
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe',
'id', 'name', 'instruction', 'ingredients', 'instructions_markdown',
'time', 'order', 'show_as_header', 'file', 'step_recipe',
'step_recipe_data', 'numrecipe', 'show_ingredients_table'
)
@@ -855,6 +885,13 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
return False
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = '__all__'
read_only_fields = ['id', 'created_at', 'created_by', 'updated_at', ]
class RecipeOverviewSerializer(RecipeBaseSerializer):
keywords = KeywordLabelSerializer(many=True)
new = serializers.SerializerMethodField('is_recipe_new')
@@ -883,7 +920,7 @@ class RecipeSerializer(RecipeBaseSerializer):
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
properties = PropertySerializer(many=True, required=False)
steps = StepSerializer(many=True)
keywords = KeywordSerializer(many=True)
keywords = KeywordSerializer(many=True, required=False)
shared = UserSerializer(many=True, required=False)
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
@@ -898,7 +935,8 @@ class RecipeSerializer(RecipeBaseSerializer):
fields = (
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating',
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings',
'file_path', 'servings_text', 'rating',
'last_cooked',
'private', 'shared',
)
@@ -931,12 +969,6 @@ class RecipeImportSerializer(SpacedModelSerializer):
fields = '__all__'
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = Comment
fields = '__all__'
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
shared = UserSerializer(many=True, required=False)
@@ -960,7 +992,7 @@ class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer)
class Meta:
model = RecipeBook
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter')
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter', 'order')
read_only_fields = ('created_by',)
@@ -977,7 +1009,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
@@ -1041,6 +1074,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
recipe_name = serializers.ReadOnlyField(source='recipe.name')
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date')
mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name')
servings = CustomDecimalField()
def get_name(self, obj):
@@ -1064,14 +1099,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingListRecipe
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date',
'mealplan_type')
read_only_fields = ('id',)
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True, required=False)
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
amount = CustomDecimalField()
created_by = UserSerializer(read_only=True)
@@ -1082,7 +1117,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
# autosync values are only needed for frequent 'checked' value updating
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
for f in list(set(fields) - set(['id', 'checked'])):
for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])):
del fields[f]
return fields
@@ -1124,11 +1159,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
class Meta:
model = ShoppingListEntry
fields = (
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked',
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked',
'recipe_mealplan',
'created_by', 'created_at', 'completed_at', 'delay_until'
'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until'
)
read_only_fields = ('id', 'created_by', 'created_at',)
read_only_fields = ('id', 'created_by', 'created_at', 'updated_at',)
class ShoppingListEntryBulkSerializer(serializers.Serializer):
ids = serializers.ListField()
checked = serializers.BooleanField()
# TODO deprecate
@@ -1138,37 +1178,6 @@ class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
fields = ('id', 'checked')
# TODO deprecate
class ShoppingListSerializer(WritableNestedModelSerializer):
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
shared = UserSerializer(many=True)
supermarket = SupermarketSerializer(allow_null=True)
def create(self, validated_data):
validated_data['space'] = self.context['request'].space
validated_data['created_by'] = self.context['request'].user
return super().create(validated_data)
class Meta:
model = ShoppingList
fields = (
'id', 'uuid', 'note', 'recipes', 'entries',
'shared', 'finished', 'supermarket', 'created_by', 'created_at'
)
read_only_fields = ('id', 'created_by',)
# TODO deprecate
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
class Meta:
model = ShoppingList
fields = ('id', 'entries',)
read_only_fields = ('id',)
class ShareLinkSerializer(SpacedModelSerializer):
class Meta:
model = ShareLink
@@ -1176,6 +1185,8 @@ class ShareLinkSerializer(SpacedModelSerializer):
class CookLogSerializer(serializers.ModelSerializer):
created_by = UserSerializer(read_only=True)
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
@@ -1183,7 +1194,7 @@ class CookLogSerializer(serializers.ModelSerializer):
class Meta:
model = CookLog
fields = ('id', 'recipe', 'servings', 'rating', 'created_by', 'created_at')
fields = ('id', 'recipe', 'servings', 'rating', 'comment', 'created_by', 'created_at', 'updated_at')
read_only_fields = ('id', 'created_by')
@@ -1283,7 +1294,8 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
class Meta:
model = InviteLink
fields = (
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',)
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
'created_at',)
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)

View File

@@ -480,7 +480,7 @@ hr {
margin-top: 1rem;
margin-bottom: 1rem;
border: 0;
border-top: 1px solid rgba(0, 0, 0, .1)
border-top: 1px solid #ced4da;
}
.small, small {

View File

@@ -2850,89 +2850,41 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
color: #fff
}
.btn-primary:hover {
background: transparent;
color: #b98766;
border: 1px solid #b98766
}
.btn-secondary {
transition: all .5s ease-in-out;
color: #fff
}
.btn-secondary:hover {
background: transparent;
color: #b55e4f;
border: 1px solid #b55e4f
}
.btn-success {
transition: all .5s ease-in-out;
color: #fff
}
.btn-success:hover {
background: transparent;
color: #82aa8b;
border: 1px solid #82aa8b
}
.btn-info {
transition: all .5s ease-in-out;
color: #fff
}
.btn-info:hover {
background: transparent;
color: #385f84;
border: 1px solid #385f84
}
.btn-warning {
transition: all .5s ease-in-out;
color: #fff
}
.btn-warning:hover {
background: transparent;
color: #eaaa21;
border: 1px solid #eaaa21
}
.btn-danger {
transition: all .5s ease-in-out;
color: #fff
}
.btn-danger:hover {
background: transparent;
color: #a7240e;
border: 1px solid #a7240e
}
.btn-light {
transition: all .5s ease-in-out;
color: #fff
}
.btn-light:hover {
background-color: hsla(0, 0%, 18%, .5);
color: #cfd5cd;
border: 1px solid hsla(0, 0%, 18%, .5)
}
.btn-dark {
transition: all .5s ease-in-out;
color: #fff
}
.btn-dark:hover {
background: transparent;
color: #221e1e;
border: 1px solid #221e1e
}
.btn-opacity-primary {
color: #b98766;
background-color: #0012a7;
@@ -6155,7 +6107,7 @@ a.close.disabled {
padding: .5rem .75rem;
margin-bottom: 0;
font-size: 1rem;
background-color: #f7f7f7;
background-color: #242424;
border-bottom: 1px solid #ebebeb;
border-top-left-radius: calc(.3rem - 1px);
border-top-right-radius: calc(.3rem - 1px)

View File

@@ -3,7 +3,7 @@ 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, RecipeImport, Storage, Sync, SyncLog, ViewLog
from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog, ConnectorConfig
class StorageTable(tables.Table):
@@ -15,6 +15,15 @@ class StorageTable(tables.Table):
fields = ('id', 'name', 'method')
class ConnectorConfigTable(tables.Table):
id = tables.LinkColumn('edit_connector_config', args=[A('id')])
class Meta:
model = ConnectorConfig
template_name = 'generic/table_template.html'
fields = ('id', 'name', 'type', 'enabled')
class ImportLogTable(tables.Table):
sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')])

View File

@@ -34,5 +34,14 @@
</div>
</div>
<div class="row mt-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3 text-center">
<a href="{% url 'account_login' %}">{% trans "Sign In" %}</a>
{% if SIGNUP_ENABLED %}
- <a href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -7,11 +7,32 @@
{% block title %}{% trans "Password Reset" %}{% endblock %}
{% block content %}
<h3>{% trans "Password Reset" %}</h3>
{% if user.is_authenticated %}
{% include "account/snippets/already_logged_in.html" %}
{% include "account/snippets/already_logged_in.html" %}
{% endif %}
<p>{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}</p>
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{% trans "Password Reset" %}</h3>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
<hr>
<p>{% blocktrans %}We have sent you an e-mail. Please contact us if you do not receive it within a few minutes.{% endblocktrans %}</p>
</div>
</div>
<div class="row mt-3">
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3 text-center">
<a href="{% url 'account_login' %}">{% trans "Sign In" %}</a>
{% if SIGNUP_ENABLED %}
- <a href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -114,7 +114,7 @@
class="fas fa-fw fa-calendar"></i> {% trans 'Meal-Plan' %}</a>
</li>
<li class="nav-item {% if request.resolver_match.url_name in 'list_shopping_list,view_shopping' %}active{% endif %}">
<a class="nav-link" href="{% url 'list_shopping_list' %}"><i
<a class="nav-link" href="{% url 'view_shopping' %}"><i
class="fas fa-fw fa-shopping-cart"></i> {% trans 'Shopping' %}</a>
</li>
<li class="nav-item {% if request.resolver_match.url_name in 'view_books' %}active{% endif %}">
@@ -335,6 +335,10 @@
<a class="dropdown-item" href="{% url 'view_space_manage' request.space.pk %}"><i
class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a>
{% endif %}
{% if not DISABLE_EXTERNAL_CONNECTORS and request.user == request.space.created_by or not DISABLE_EXTERNAL_CONNECTORS and user.is_superuser %}
<a class="dropdown-item" href="{% url 'list_connector_config' %}"><i
class="fas fa-sync-alt fa-fw"></i> {% trans 'External Connectors' %}</a>
{% endif %}
{% if user.is_superuser %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'view_system' %}"><i
@@ -404,7 +408,7 @@
</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 %}"
<div class="container-fluid mt-2 mt-md-3 mt-xl-3 mt-lg-3{% 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">

View File

@@ -1,32 +0,0 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Profile' %}{% endblock %}
{% block content %}
<div id="app" >
<profile-view></profile-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
</script>
{% render_bundle 'profile_view' %}
{% endblock %}

View File

@@ -79,6 +79,7 @@
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.RECIPE_ID = {{recipe.pk}};
window.RECIPE_SERVINGS = '{{ servings }}'
window.SHARE_UID = '{{ share }}';
window.USER_PREF = {
'use_fractions': {% if request.user.userpreference.use_fractions %} true {% else %} false {% endif %},

View File

@@ -2,6 +2,7 @@
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% block title %} {{ title }} {% endblock %}
{% block content_fluid %}
@@ -10,16 +11,21 @@
<shopping-list-view></shopping-list-view>
</div>
{% endblock %} {% block script %} {% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
{% endblock %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.SHOPPING_MIN_AUTOSYNC_INTERVAL = {{ SHOPPING_MIN_AUTOSYNC_INTERVAL }}
</script>
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
{% render_bundle 'shopping_list_view' %} {% endblock %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
window.SHOPPING_MIN_AUTOSYNC_INTERVAL = {{ SHOPPING_MIN_AUTOSYNC_INTERVAL }}
</script>
{% render_bundle 'shopping_list_view' %}
{% endblock %}

View File

@@ -4,7 +4,7 @@
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Search' %}{% endblock %}
{% block title %}{% trans 'Space Management' %}{% endblock %}
{% block content %}

View File

@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% block title %}{% trans 'Supermarket' %}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}">
{% endblock %}
{% block content %}
<div id="app" >
<supermarket-view></supermarket-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.CUSTOM_LOCALE = '{{ request.LANGUAGE_CODE }}'
</script>
{% render_bundle 'supermarket_view' %}
{% endblock %}

View File

@@ -112,8 +112,12 @@ def recipe_last(recipe, user):
def page_help(page_name):
help_pages = {
'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/',
'list_connector_config': 'https://docs.tandoor.dev/features/connectors/',
'new_connector_config': 'https://docs.tandoor.dev/features/connectors/',
'edit_connector_config': 'https://docs.tandoor.dev/features/connectors/',
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
'view_import': 'https://docs.tandoor.dev/features/import_export/',
'data_import_url': 'https://docs.tandoor.dev/features/import_export/',
'view_export': 'https://docs.tandoor.dev/features/import_export/',
'list_automation': 'https://docs.tandoor.dev/features/automation/',
}

View File

@@ -0,0 +1,126 @@
import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import ConnectorConfig
LIST_URL = 'api:connectorconfig-list'
DETAIL_URL = 'api:connectorconfig-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1):
return ConnectorConfig.objects.create(
name='HomeAssistant 1', token='token', url='url', todo_entity='todo.shopping_list', enabled=True, created_by=auth.get_user(u1_s1), space=space_1, )
@pytest.fixture
def obj_2(space_1, u1_s1):
return ConnectorConfig.objects.create(
name='HomeAssistant 2', token='token', url='url', todo_entity='todo.shopping_list', enabled=True, created_by=auth.get_user(u1_s1), space=space_1, )
@pytest.mark.parametrize(
"arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 403],
['a1_s1', 200],
])
def test_list_permission(arg, request):
c = request.getfixturevalue(arg[0])
r = c.get(reverse(LIST_URL))
assert r.status_code == arg[1]
if r.status_code == 200:
response = json.loads(r.content)
assert 'token' not in response
def test_list_space(obj_1, obj_2, a1_s1, a1_s2, space_2):
assert len(json.loads(a1_s1.get(reverse(LIST_URL)).content)) == 2
assert len(json.loads(a1_s2.get(reverse(LIST_URL)).content)) == 0
obj_1.space = space_2
obj_1.save()
assert len(json.loads(a1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(a1_s2.get(reverse(LIST_URL)).content)) == 1
@pytest.mark.parametrize(
"arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 403],
['a1_s1', 200],
['g1_s2', 403],
['u1_s2', 403],
['a1_s2', 404],
])
def test_update(arg, request, obj_1):
test_token = '1234'
c = request.getfixturevalue(arg[0])
r = c.patch(
reverse(
DETAIL_URL,
args={obj_1.id}
),
{'name': 'new', 'token': test_token},
content_type='application/json'
)
response = json.loads(r.content)
assert r.status_code == arg[1]
if r.status_code == 200:
assert response['name'] == 'new'
obj_1.refresh_from_db()
assert obj_1.token == test_token
@pytest.mark.parametrize(
"arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 403],
['a1_s1', 201],
])
def test_add(arg, request, a1_s2, obj_1):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'name': 'test', 'url': 'http://localhost:8123/api', 'token': '1234', 'enabled': 'true'},
content_type='application/json'
)
response = json.loads(r.content)
print(r.content)
assert r.status_code == arg[1]
if r.status_code == 201:
assert response['name'] == 'test'
r = c.get(reverse(DETAIL_URL, args={response['id']}))
assert r.status_code == 200
r = a1_s2.get(reverse(DETAIL_URL, args={response['id']}))
assert r.status_code == 404
def test_delete(a1_s1, a1_s2, obj_1):
r = a1_s2.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 404
r = a1_s1.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 204
with scopes_disabled():
assert ConnectorConfig.objects.count() == 0

View File

@@ -0,0 +1,88 @@
from datetime import datetime, timedelta
import pytest
from django.contrib import auth
from django.urls import reverse
from icalendar import Calendar
from cookbook.models import MealPlan, MealType
BOUND_URL = 'api_get_plan_ical'
FROM_URL = 'api_get_plan_ical_from'
FUTURE_URL = 'api_get_plan_ical_future'
@pytest.fixture()
def meal_type(space_1, u1_s1):
return MealType.objects.get_or_create(name='test', space=space_1, created_by=auth.get_user(u1_s1))[0]
@pytest.fixture()
def obj_1(space_1, recipe_1_s1, meal_type, u1_s1):
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now(), to_date=datetime.now(),
created_by=auth.get_user(u1_s1))
@pytest.fixture
def obj_2(space_1, recipe_1_s1, meal_type, u1_s1):
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now()+timedelta(days=30), to_date=datetime.now()+timedelta(days=30),
created_by=auth.get_user(u1_s1))
@pytest.fixture
def obj_3(space_1, recipe_1_s1, meal_type, u1_s1):
return MealPlan.objects.create(recipe=recipe_1_s1, space=space_1, meal_type=meal_type, from_date=datetime.now()+timedelta(days=-30), to_date=datetime.now()+timedelta(days=-1),
created_by=auth.get_user(u1_s1))
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 200],
['a1_s1', 200],
])
def test_permissions(arg, request):
c = request.getfixturevalue(arg[0])
assert c.get(reverse(FUTURE_URL)).status_code == arg[1]
def test_future(obj_1, obj_2, obj_3, u1_s1):
r = u1_s1.get(reverse(FUTURE_URL))
assert r.status_code == 200
cal = Calendar.from_ical(r.getvalue().decode('UTF-8'))
events = cal.walk('VEVENT')
assert len(events) == 2
def test_from(obj_1, obj_2, obj_3, u1_s1):
from_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d")
r = u1_s1.get(reverse(FROM_URL, kwargs={'from_date': from_date_slug}))
assert r.status_code == 200
cal = Calendar.from_ical(r.getvalue().decode('UTF-8'))
events = cal.walk('VEVENT')
assert len(events) == 1
def test_bound(obj_1, obj_2, obj_3, u1_s1):
from_date_slug = (datetime.now()+timedelta(days=-1)).strftime("%Y-%m-%d")
to_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d")
r = u1_s1.get(reverse(BOUND_URL, kwargs={'from_date': from_date_slug, 'to_date': to_date_slug}))
assert r.status_code == 200
cal = Calendar.from_ical(r.getvalue().decode('UTF-8'))
events = cal.walk('VEVENT')
assert len(events) == 1
def test_event(obj_1, u1_s1):
from_date_slug = (datetime.now()+timedelta(days=-1)).strftime("%Y-%m-%d")
to_date_slug = (datetime.now()+timedelta(days=1)).strftime("%Y-%m-%d")
r = u1_s1.get(reverse(BOUND_URL, kwargs={'from_date': from_date_slug, 'to_date': to_date_slug}))
cal = Calendar.from_ical(r.getvalue().decode('UTF-8'))
events = cal.walk('VEVENT')
assert len(events) == 1
event = events[0]
assert int(event['uid']) == obj_1.id
assert event['summary'] == f'{obj_1.meal_type.name}: {obj_1.get_label()}'
assert event['description'] == obj_1.note
assert event.decoded('dtstart') == datetime.now().date()
assert event.decoded('dtend') == datetime.now().date()

View File

@@ -1,135 +0,0 @@
import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import ShoppingList
LIST_URL = 'api:shoppinglist-list'
DETAIL_URL = 'api:shoppinglist-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1):
return ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
@pytest.fixture
def obj_2(space_1, u1_s1):
return ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 200],
['u1_s1', 200],
['a1_s1', 200],
])
def test_list_permission(arg, request):
c = request.getfixturevalue(arg[0])
assert c.get(reverse(LIST_URL)).status_code == arg[1]
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
obj_1.space = space_2
obj_1.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
def test_share(obj_1, u1_s1, u2_s1, u1_s2):
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
obj_1.shared.add(auth.get_user(u2_s1))
obj_1.shared.add(auth.get_user(u1_s2))
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
def test_new_share(request, obj_1, u1_s1, u2_s1, u1_s2):
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
with scopes_disabled():
user = auth.get_user(u1_s1)
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
user.userpreference.save()
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 404],
['u1_s1', 200],
['a1_s1', 404],
['g1_s2', 404],
['u1_s2', 404],
['a1_s2', 404],
])
def test_update(arg, request, obj_1):
c = request.getfixturevalue(arg[0])
r = c.patch(
reverse(
DETAIL_URL,
args={obj_1.id}
),
{'note': 'new'},
content_type='application/json'
)
assert r.status_code == arg[1]
if r.status_code == 200:
response = json.loads(r.content)
assert response['note'] == 'new'
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 201],
['u1_s1', 201],
['a1_s1', 201],
])
def test_add(arg, request):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'note': 'test', 'recipes': [], 'shared': [], 'entries': [], 'supermarket': None},
content_type='application/json'
)
response = json.loads(r.content)
print(r.content)
assert r.status_code == arg[1]
if r.status_code == 201:
assert response['note'] == 'test'
def test_delete(u1_s1, u1_s2, obj_1):
r = u1_s2.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 404
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 204

View File

@@ -1,121 +0,0 @@
import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, ShoppingList, ShoppingListEntry
LIST_URL = 'api:shoppinglistentry-list'
DETAIL_URL = 'api:shoppinglistentry-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@pytest.fixture
def obj_2(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 200],
['u1_s1', 200],
['a1_s1', 200],
])
def test_list_permission(arg, request):
c = request.getfixturevalue(arg[0])
assert c.get(reverse(LIST_URL)).status_code == arg[1]
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
with scopes_disabled():
s = ShoppingList.objects.first()
e = ShoppingListEntry.objects.first()
s.space = space_2
e.space = space_2
s.save()
e.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 404],
['u1_s1', 200],
['a1_s1', 404],
['g1_s2', 404],
['u1_s2', 404],
['a1_s2', 404],
])
def test_update(arg, request, obj_1):
c = request.getfixturevalue(arg[0])
r = c.patch(
reverse(
DETAIL_URL,
args={obj_1.id}
),
{'amount': 2},
content_type='application/json'
)
assert r.status_code == arg[1]
if r.status_code == 200:
response = json.loads(r.content)
assert response['amount'] == 2
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 201],
['u1_s1', 201],
['a1_s1', 201],
])
def test_add(arg, request, obj_1):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'food': {
'id': obj_1.food.__dict__['id'],
'name': obj_1.food.__dict__['name'],
}, 'amount': 1},
content_type='application/json'
)
response = json.loads(r.content)
print(r.content)
assert r.status_code == arg[1]
if r.status_code == 201:
assert response['food']['id'] == obj_1.food.pk
def test_delete(u1_s1, u1_s2, obj_1):
r = u1_s2.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 404
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 204

View File

@@ -214,6 +214,9 @@ def test_completed(sle, u1_s1):
def test_recent(sle, u1_s1):
user = auth.get_user(u1_s1)
user.userpreference.shopping_recent_days = 7 # hardcoded API limit 14 days
user.userpreference.save()
today_start = timezone.now().replace(hour=0, minute=0, second=0)
# past_date within recent_days threshold

View File

@@ -3,9 +3,8 @@ import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import ShoppingList, ShoppingListRecipe
from cookbook.models import ShoppingListEntry, ShoppingListRecipe
LIST_URL = 'api:shoppinglistrecipe-list'
DETAIL_URL = 'api:shoppinglistrecipe-detail'
@@ -14,81 +13,31 @@ DETAIL_URL = 'api:shoppinglistrecipe-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1, recipe_1_s1):
r = ShoppingListRecipe.objects.create(recipe=recipe_1_s1, servings=1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.recipes.add(r)
for ing in r.recipe.steps.first().ingredients.all():
ShoppingListEntry.objects.create(list_recipe=r, ingredient=ing, food=ing.food, unit=ing.unit, amount=ing.amount, created_by=auth.get_user(u1_s1), space=space_1)
return r
@pytest.fixture
def obj_2(space_1, u1_s1, recipe_1_s1):
r = ShoppingListRecipe.objects.create(recipe=recipe_1_s1, servings=1)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.recipes.add(r)
return r
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 200],
['u1_s1', 200],
['a1_s1', 200],
])
@pytest.mark.parametrize("arg", [['a_u', 403], ['g1_s1', 200], ['u1_s1', 200], ['a1_s1', 200], ])
def test_list_permission(arg, request):
c = request.getfixturevalue(arg[0])
assert c.get(reverse(LIST_URL)).status_code == arg[1]
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
with scopes_disabled():
s = ShoppingList.objects.first()
s.space = space_2
s.save()
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 404],
['u1_s1', 200],
['a1_s1', 404],
['g1_s2', 404],
['u1_s2', 404],
['a1_s2', 404],
])
@pytest.mark.parametrize("arg", [['a_u', 403], ['g1_s1', 404], ['u1_s1', 200], ['a1_s1', 404], ['g1_s2', 404], ['u1_s2', 404], ['a1_s2', 404], ])
def test_update(arg, request, obj_1):
c = request.getfixturevalue(arg[0])
r = c.patch(
reverse(
DETAIL_URL,
args={obj_1.id}
),
{'servings': 2},
content_type='application/json'
)
r = c.patch(reverse(DETAIL_URL, args={obj_1.id}), {'servings': 2}, content_type='application/json')
assert r.status_code == arg[1]
if r.status_code == 200:
response = json.loads(r.content)
assert response['servings'] == 2
@pytest.mark.parametrize("arg", [
['a_u', 403],
['g1_s1', 201],
['u1_s1', 201],
['a1_s1', 201],
])
@pytest.mark.parametrize("arg", [['a_u', 403], ['g1_s1', 201], ['u1_s1', 201], ['a1_s1', 201], ])
def test_add(arg, request, obj_1, recipe_1_s1):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'recipe': recipe_1_s1.pk, 'servings': 1},
content_type='application/json'
)
r = c.post(reverse(LIST_URL), {'recipe': recipe_1_s1.pk, 'servings': 1}, content_type='application/json')
response = json.loads(r.content)
print(r.content)
assert r.status_code == arg[1]
@@ -97,19 +46,9 @@ def test_add(arg, request, obj_1, recipe_1_s1):
def test_delete(u1_s1, u1_s2, obj_1):
r = u1_s2.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
r = u1_s2.delete(reverse(DETAIL_URL, args={obj_1.id}))
assert r.status_code == 404
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
r = u1_s1.delete(reverse(DETAIL_URL, args={obj_1.id}))
assert r.status_code == 204

View File

@@ -7,7 +7,7 @@ from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, Ingredient
from cookbook.models import Food, Ingredient, ShoppingListRecipe, ShoppingListEntry
from cookbook.tests.factories import MealPlanFactory, RecipeFactory, StepFactory, UserFactory
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
@@ -126,7 +126,7 @@ def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
all_ing = [x['ingredient'] for x in r]
all_ing = list(ShoppingListEntry.objects.filter(list_recipe__recipe=recipe).all().values_list('ingredient', flat=True))
keep_ing = all_ing[1:-1] # remove first and last element
del keep_ing[int(len(keep_ing) / 2)] # remove a middle element
list_recipe = r[0]['list_recipe']

View File

@@ -6,7 +6,7 @@ from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry, Unit
from cookbook.models import Food, Ingredient, ShoppingListEntry, Unit
LIST_URL = 'api:unit-list'
DETAIL_URL = 'api:unit-detail'
@@ -50,8 +50,6 @@ def ing_3_s2(obj_3, space_2, u2_s2):
@pytest.fixture()
def sle_1_s1(obj_1, u1_s1, space_1):
e = ShoppingListEntry.objects.create(unit=obj_1, food=random_food(space_1, u1_s1), created_by=auth.get_user(u1_s1), space=space_1,)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
return e
@@ -63,8 +61,6 @@ def sle_2_s1(obj_2, u1_s1, space_1):
@pytest.fixture()
def sle_3_s2(obj_3, u2_s2, space_2):
e = ShoppingListEntry.objects.create(unit=obj_3, food=random_food(space_2, u2_s2), created_by=auth.get_user(u2_s2), space=space_2)
s = ShoppingList.objects.create(created_by=auth.get_user(u2_s2), space=space_2)
s.entries.add(e)
return e

View File

@@ -0,0 +1,75 @@
import pytest
from django.contrib import auth
from django.contrib import messages
from django.contrib.messages import get_messages
from django.urls import reverse
from pytest_django.asserts import assertTemplateUsed
from cookbook.models import ConnectorConfig
EDIT_VIEW_NAME = 'edit_connector_config'
@pytest.fixture
def home_assistant_config_obj(a1_s1, space_1):
return ConnectorConfig.objects.create(
name='HomeAssistant 1',
type=ConnectorConfig.HOMEASSISTANT,
token='token',
url='http://localhost:8123/api',
todo_entity='todo.shopping_list',
enabled=True,
created_by=auth.get_user(a1_s1),
space=space_1,
)
def test_edit_connector_config_homeassistant(home_assistant_config_obj: ConnectorConfig, a1_s1, a1_s2):
new_token = '1234_token'
r = a1_s1.post(
reverse(EDIT_VIEW_NAME, args={home_assistant_config_obj.pk}),
{
'name': home_assistant_config_obj.name,
'type': home_assistant_config_obj.type,
'url': home_assistant_config_obj.url,
'update_token': new_token,
'todo_entity': home_assistant_config_obj.todo_entity,
'enabled': home_assistant_config_obj.enabled,
}
)
assert r.status_code == 302
r_messages = [m for m in get_messages(r.wsgi_request)]
assert not any(m.level > messages.SUCCESS for m in r_messages)
home_assistant_config_obj.refresh_from_db()
assert home_assistant_config_obj.token == new_token
r = a1_s2.post(
reverse(EDIT_VIEW_NAME, args={home_assistant_config_obj.pk}),
{
'name': home_assistant_config_obj.name,
'type': home_assistant_config_obj.type,
'url': home_assistant_config_obj.url,
'todo_entity': home_assistant_config_obj.todo_entity,
'update_token': new_token,
'enabled': home_assistant_config_obj.enabled,
}
)
assert r.status_code == 404
@pytest.mark.parametrize(
"arg", [
['a_u', 302],
['g1_s1', 302],
['u1_s1', 302],
['a1_s1', 200],
['g1_s2', 302],
['u1_s2', 302],
['a1_s2', 404],
])
def test_view_permission(arg, request, home_assistant_config_obj):
c = request.getfixturevalue(arg[0])
assert c.get(reverse(EDIT_VIEW_NAME, args={home_assistant_config_obj.pk})).status_code == arg[1]

View File

@@ -1,7 +1,10 @@
from cookbook.models import Storage
from django.contrib import auth
from django.urls import reverse
import pytest
from django.contrib import auth
from django.contrib import messages
from django.contrib.messages import get_messages
from django.urls import reverse
from cookbook.models import Storage
@pytest.fixture
@@ -29,6 +32,9 @@ def test_edit_storage(storage_obj, a1_s1, a1_s2):
)
storage_obj.refresh_from_db()
assert r.status_code == 200
r_messages = [m for m in get_messages(r.wsgi_request)]
assert not any(m.level > messages.SUCCESS for m in r_messages)
assert storage_obj.password == '1234_pw'
assert storage_obj.token == '1234_token'

View File

@@ -0,0 +1,25 @@
import pytest
from django.contrib import auth
from mock.mock import Mock
from cookbook.connectors.connector import Connector
from cookbook.connectors.connector_manager import run_connectors, ActionType
from cookbook.models import ShoppingListEntry, Food
@pytest.fixture()
def obj_1(space_1, u1_s1):
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
return e
@pytest.mark.timeout(10)
@pytest.mark.asyncio
async def test_run_connectors(space_1, u1_s1, obj_1) -> None:
connector_mock = Mock(spec=Connector)
await run_connectors([connector_mock], space_1, obj_1, ActionType.DELETED)
assert not connector_mock.on_shopping_list_entry_updated.called
assert not connector_mock.on_shopping_list_entry_created.called
connector_mock.on_shopping_list_entry_deleted.assert_called_once_with(space_1, obj_1)

View File

@@ -67,7 +67,7 @@ def test_food_property(space_1, space_2, u1_s1):
assert property_values[property_fat.id]['name'] == property_fat.name
assert abs(property_values[property_fat.id]['total_value']) < 0.0001
assert abs(property_values[property_fat.id]['food_values'][food_1.id]['value']) < 0.0001
assert property_values[property_fat.id]['food_values'][food_1.id]['value'] is None
print('\n----------- TEST PROPERTY - PROPERTY CALCULATION UNIT CONVERSION ---------------')
uc1 = UnitConversion.objects.create(

View File

@@ -1,8 +1,9 @@
import itertools
import json
from datetime import timedelta
from datetime import timedelta, datetime
import pytest
import pytz
from django.conf import settings
from django.contrib import auth
from django.urls import reverse
@@ -343,7 +344,7 @@ def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, sp
Recipe.objects.filter(id=recipe.id).update(
updated_at=recipe.created_at)
date = (timezone.now() - timedelta(days=15)).strftime("%Y-%m-%d")
date = (datetime.now() - timedelta(days=15)).strftime("%Y-%m-%d")
param1 = f"?{param_type}={date}"
param2 = f"?{param_type}=-{date}"
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param1}').content)

View File

@@ -5,20 +5,22 @@ from django.views.generic import TemplateView
from rest_framework import permissions, routers
from rest_framework.schemas import get_schema_view
from cookbook.helper import dal
from cookbook.version_info import TANDOOR_VERSION
from recipes.settings import DEBUG, PLUGINS
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, PropertyType,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Space, Step,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, Space, Step,
Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UnitConversion,
UserFile, UserSpace, get_model_name)
UserFile, UserSpace, get_model_name, ConnectorConfig)
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
from .views.api import CustomAuthToken, ImportOpenData
# extend DRF default router class to allow including additional routers
class DefaultRouter(routers.DefaultRouter):
def extend(self, r):
self.registry.extend(r.registry)
@@ -45,12 +47,12 @@ router.register(r'recipe-book-entry', api.RecipeBookEntryViewSet)
router.register(r'unit-conversion', api.UnitConversionViewSet)
router.register(r'food-property-type', api.PropertyTypeViewSet) # TODO rename + regenerate
router.register(r'food-property', api.PropertyViewSet)
router.register(r'shopping-list', api.ShoppingListViewSet)
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
router.register(r'space', api.SpaceViewSet)
router.register(r'step', api.StepViewSet)
router.register(r'storage', api.StorageViewSet)
router.register(r'connector-config', api.ConnectorConfigConfigViewSet)
router.register(r'supermarket', api.SupermarketViewSet)
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
@@ -78,7 +80,6 @@ urlpatterns = [
path('space-overview', views.space_overview, name='view_space_overview'),
path('space-manage/<int:space_id>', views.space_manage, name='view_space_manage'),
path('switch-space/<int:space_id>', views.switch_space, name='view_switch_space'),
path('profile/<int:user_id>', views.view_profile, name='view_profile'),
path('no-perm', views.no_perm, name='view_no_perm'),
path('invite/<slug:token>', views.invite_link, name='view_invite'),
path('system/', views.system, name='view_system'),
@@ -87,34 +88,27 @@ urlpatterns = [
path('plan/', views.meal_plan, name='view_plan'),
path('shopping/', lists.shopping_list, name='view_shopping'),
path('settings/', views.user_settings, name='view_settings'),
path('settings-shopping/', views.shopping_settings, name='view_shopping_settings'),
path('settings-shopping/', views.shopping_settings, name='view_shopping_settings'), # TODO rename to search 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('property-editor/<int:pk>', views.property_editor, name='view_property_editor'),
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
path('api/import/', api.import_files, name='view_import'),
path('import-response/<int:pk>/', import_export.import_response, name='view_import_response'),
path('export/', import_export.export_recipe, name='view_export'),
path('export-response/<int:pk>/', import_export.export_response, name='view_export_response'),
path('export-file/<int:pk>/', import_export.export_file, name='view_export_file'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
path('view/recipe/<int:pk>/<slug:share>', views.recipe_view, name='view_recipe'),
path('new/recipe-import/<int:import_id>/', new.create_new_external_recipe, name='new_recipe_import'),
path('new/share-link/<int:pk>/', new.share_link, name='new_share_link'),
path('edit/recipe/<int:pk>/', edit.switch_recipe, name='edit_recipe'),
# for internal use only
path('edit/recipe/internal/<int:pk>/', edit.internal_recipe_update, name='edit_internal_recipe'),
path('edit/recipe/external/<int:pk>/', edit.ExternalRecipeUpdate.as_view(), name='edit_external_recipe'),
path('edit/recipe/convert/<int:pk>/', edit.convert_recipe, name='edit_convert_recipe'),
path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'),
path('delete/recipe-source/<int:pk>/', delete.delete_recipe_source, name='delete_recipe_source'),
# TODO move to generic "new" view
@@ -123,11 +117,12 @@ urlpatterns = [
path('data/batch/import', data.batch_import, name='data_batch_import'),
path('data/sync/wait', data.sync_wait, name='data_sync_wait'),
path('data/import/url', data.import_url, name='data_import_url'),
path('api/get_external_file_link/<int:recipe_id>/', api.get_external_file_link, name='api_get_external_file_link'),
path('api/get_recipe_file/<int:recipe_id>/', api.get_recipe_file, name='api_get_recipe_file'),
path('api/sync_all/', api.sync_all, name='api_sync'),
path('api/log_cooking/<int:recipe_id>/', api.log_cooking, name='api_log_cooking'),
path('api/plan-ical/', api.get_plan_ical, name='api_get_plan_ical_future'),
path('api/plan-ical/<slug:from_date>/', api.get_plan_ical, name='api_get_plan_ical_from'),
path('api/plan-ical/<slug:from_date>/<slug:to_date>/', api.get_plan_ical, name='api_get_plan_ical'),
path('api/recipe-from-source/', api.RecipeUrlImportView.as_view(), name='api_recipe_from_source'),
path('api/backup/', api.get_backup, name='api_backup'),
@@ -136,75 +131,45 @@ urlpatterns = [
path('api/reset-food-inheritance/', api.reset_food_inheritance, name='api_reset_food_inheritance'),
path('api/switch-active-space/<int:space_id>/', api.switch_active_space, name='api_switch_active_space'),
path('api/download-file/<int:file_id>/', api.download_file, name='api_download_file'),
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?
path('telegram/setup/<int:pk>', telegram.setup_bot, name='telegram_setup'),
path('telegram/remove/<int:pk>', telegram.remove_bot, name='telegram_remove'),
path('telegram/hook/<slug:token>/', telegram.hook, name='telegram_hook'),
path('docs/markdown/', views.markdown_info, name='docs_markdown'),
path('docs/search/', views.search_info, name='docs_search'),
path('docs/api/', views.api_info, name='docs_api'),
path('openapi/', get_schema_view(title="Django Recipes", version=TANDOOR_VERSION, public=True,
permission_classes=(permissions.AllowAny,)), name='openapi-schema'),
path('openapi/', get_schema_view(title="Django Recipes", version=TANDOOR_VERSION, public=True, permission_classes=(permissions.AllowAny, )), name='openapi-schema'),
path('api/', include((router.urls, 'api'))),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('api-token-auth/', CustomAuthToken.as_view()),
path('api-import-open-data/', ImportOpenData.as_view(), name='api_import_open_data'),
path('offline/', views.offline, name='view_offline'),
path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript', )),
name='service_worker'),
path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript',
)), name='service_worker'),
path('manifest.json', views.web_manifest, name='web_manifest'),
]
generic_models = (
Recipe, RecipeImport, Storage, RecipeBook, SyncLog, Sync,
Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space
Recipe, RecipeImport, Storage, ConnectorConfig, RecipeBook, SyncLog, Sync,
Comment, RecipeBookEntry, InviteLink, UserSpace, Space
)
for m in generic_models:
py_name = get_model_name(m)
url_name = py_name.replace('_', '-')
if c := locate(f'cookbook.views.new.{m.__name__}Create'):
urlpatterns.append(
path(
f'new/{url_name}/', c.as_view(), name=f'new_{py_name}'
)
)
urlpatterns.append(path(f'new/{url_name}/', c.as_view(), name=f'new_{py_name}'))
if c := locate(f'cookbook.views.edit.{m.__name__}Update'):
urlpatterns.append(
path(
f'edit/{url_name}/<int:pk>/',
c.as_view(),
name=f'edit_{py_name}'
)
)
urlpatterns.append(path(f'edit/{url_name}/<int:pk>/', c.as_view(), name=f'edit_{py_name}'))
if c := getattr(lists, py_name, None):
urlpatterns.append(
path(
f'list/{url_name}/', c, name=f'list_{py_name}'
)
)
urlpatterns.append(path(f'list/{url_name}/', c, name=f'list_{py_name}'))
if c := locate(f'cookbook.views.delete.{m.__name__}Delete'):
urlpatterns.append(
path(
f'delete/{url_name}/<int:pk>/',
c.as_view(),
name=f'delete_{py_name}'
)
)
urlpatterns.append(path(f'delete/{url_name}/<int:pk>/', c.as_view(), name=f'delete_{py_name}'))
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile, Step, CustomFilter, UnitConversion, PropertyType]
for m in vue_models:
@@ -212,11 +177,7 @@ for m in vue_models:
url_name = py_name.replace('_', '-')
if c := getattr(lists, py_name, None):
urlpatterns.append(
path(
f'list/{url_name}/', c, name=f'list_{py_name}'
)
)
urlpatterns.append(path(f'list/{url_name}/', c, name=f'list_{py_name}'))
if DEBUG:
urlpatterns.append(path('test/', views.test, name='view_test'))

File diff suppressed because it is too large Load Diff

View File

@@ -8,8 +8,12 @@ from django.utils.translation import gettext as _
from django.views.generic import DeleteView
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required
from cookbook.models import Comment, InviteLink, Recipe, RecipeImport, Space, Storage, Sync, UserSpace
from cookbook.models import (Comment, InviteLink, MealPlan, Recipe, RecipeBook, RecipeBookEntry,
RecipeImport, Space, Storage, Sync, UserSpace)
RecipeImport, Space, Storage, Sync, UserSpace, ConnectorConfig)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -114,14 +118,24 @@ class StorageDelete(GroupRequiredMixin, DeleteView):
try:
return self.delete(request, *args, **kwargs)
except ProtectedError:
messages.add_message(
request,
messages.WARNING,
_('Could not delete this storage backend as it is used in at least one monitor.') # noqa: E501
)
messages.add_message(request, messages.WARNING,
_('Could not delete this storage backend as it is used in at least one monitor.') # noqa: E501
)
return HttpResponseRedirect(reverse('list_storage'))
class ConnectorConfigDelete(GroupRequiredMixin, DeleteView):
groups_required = ['admin']
template_name = "generic/delete_template.html"
model = ConnectorConfig
success_url = reverse_lazy('list_connector_config')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Connectors Config Backend")
return context
class CommentDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Comment
@@ -133,40 +147,6 @@ class CommentDelete(OwnerRequiredMixin, DeleteView):
return context
class RecipeBookDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = RecipeBook
success_url = reverse_lazy('view_books')
def get_context_data(self, **kwargs):
context = super(RecipeBookDelete, self).get_context_data(**kwargs)
context['title'] = _("Recipe Book")
return context
class RecipeBookEntryDelete(OwnerRequiredMixin, DeleteView):
groups_required = ['user']
template_name = "generic/delete_template.html"
model = RecipeBookEntry
success_url = reverse_lazy('view_books')
def get_context_data(self, **kwargs):
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)
context['title'] = _("Bookmarks")
return context
class MealPlanDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = MealPlan
success_url = reverse_lazy('view_plan')
def get_context_data(self, **kwargs):
context = super(MealPlanDelete, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
return context
class InviteLinkDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = InviteLink

View File

@@ -1,3 +1,4 @@
import copy
import os
from django.contrib import messages
@@ -8,15 +9,16 @@ from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from django.views.generic.edit import FormMixin
from cookbook.forms import CommentForm, ExternalRecipeForm, StorageForm, SyncForm
from cookbook.helper.permission_helper import (GroupRequiredMixin, OwnerRequiredMixin,
above_space_limit, group_required)
from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync
from cookbook.forms import CommentForm, ExternalRecipeForm, StorageForm, SyncForm, ConnectorConfigForm
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, above_space_limit, group_required
from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync, ConnectorConfig
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from recipes import settings
VALUE_NOT_CHANGED = '__NO__CHANGE__'
@group_required('guest')
def switch_recipe(request, pk):
@@ -76,7 +78,7 @@ class SyncUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing):
@group_required('admin')
def edit_storage(request, pk):
instance = get_object_or_404(Storage, pk=pk, space=request.space)
instance: Storage = get_object_or_404(Storage, pk=pk, space=request.space)
if not (instance.created_by == request.user or request.user.is_superuser):
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!'))
@@ -87,41 +89,58 @@ def edit_storage(request, pk):
return redirect('index')
if request.method == "POST":
form = StorageForm(request.POST, instance=instance)
form = StorageForm(request.POST, instance=copy.deepcopy(instance))
if form.is_valid():
instance.name = form.cleaned_data['name']
instance.method = form.cleaned_data['method']
instance.username = form.cleaned_data['username']
instance.url = form.cleaned_data['url']
instance.path = form.cleaned_data['path']
if form.cleaned_data['password'] != '__NO__CHANGE__':
if form.cleaned_data['password'] != VALUE_NOT_CHANGED:
instance.password = form.cleaned_data['password']
if form.cleaned_data['token'] != '__NO__CHANGE__':
if form.cleaned_data['token'] != VALUE_NOT_CHANGED:
instance.token = form.cleaned_data['token']
instance.save()
messages.add_message(
request, messages.SUCCESS, _('Storage saved!')
)
messages.add_message(request, messages.SUCCESS, _('Storage saved!'))
else:
messages.add_message(
request,
messages.ERROR,
_('There was an error updating this storage backend!')
)
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend!'))
else:
pseudo_instance = instance
pseudo_instance.password = '__NO__CHANGE__'
pseudo_instance.token = '__NO__CHANGE__'
pseudo_instance.password = VALUE_NOT_CHANGED
pseudo_instance.token = VALUE_NOT_CHANGED
form = StorageForm(instance=pseudo_instance)
return render(
request,
'generic/edit_template.html',
{'form': form, 'title': _('Storage')}
)
return render(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')})
class ConnectorConfigUpdate(GroupRequiredMixin, UpdateView):
groups_required = ['admin']
template_name = "generic/edit_template.html"
model = ConnectorConfig
form_class = ConnectorConfigForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['initial']['update_token'] = VALUE_NOT_CHANGED
return kwargs
def form_valid(self, form):
if form.cleaned_data['update_token'] != VALUE_NOT_CHANGED and form.cleaned_data['update_token'] != "":
form.instance.token = form.cleaned_data['update_token']
messages.add_message(self.request, messages.SUCCESS, _('Config saved!'))
return super(ConnectorConfigUpdate, self).form_valid(form)
def get_success_url(self):
return reverse('edit_connector_config', kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("ConnectorConfig")
return context
class CommentUpdate(OwnerRequiredMixin, UpdateView):
@@ -135,9 +154,7 @@ class CommentUpdate(OwnerRequiredMixin, UpdateView):
def get_context_data(self, **kwargs):
context = super(CommentUpdate, self).get_context_data(**kwargs)
context['title'] = _("Comment")
context['view_url'] = reverse(
'view_recipe', args=[self.object.recipe.pk]
)
context['view_url'] = reverse('view_recipe', args=[self.object.recipe.pk])
return context
@@ -176,11 +193,7 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing):
if self.object.storage.method == Storage.LOCAL:
Local.rename_file(old_recipe, self.object.name)
self.object.file_path = "%s/%s%s" % (
os.path.dirname(self.object.file_path),
self.object.name,
os.path.splitext(self.object.file_path)[1]
)
self.object.file_path = "%s/%s%s" % (os.path.dirname(self.object.file_path), self.object.name, os.path.splitext(self.object.file_path)[1])
messages.add_message(self.request, messages.SUCCESS, _('Changes saved!'))
return super(ExternalRecipeUpdate, self).form_valid(form)
@@ -197,7 +210,5 @@ class ExternalRecipeUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing):
context['title'] = _("Recipe")
context['view_url'] = reverse('view_recipe', args=[self.object.pk])
if self.object.storage:
context['delete_external_url'] = reverse(
'delete_recipe_source', args=[self.object.pk]
)
context['delete_external_url'] = reverse('delete_recipe_source', args=[self.object.pk])
return context

View File

@@ -6,8 +6,8 @@ from django.utils.translation import gettext as _
from django_tables2 import RequestConfig
from cookbook.helper.permission_helper import group_required
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile
from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile, ConnectorConfig
from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable, ConnectorConfigTable
@group_required('admin')
@@ -65,6 +65,22 @@ def storage(request):
)
@group_required('admin')
def connector_config(request):
table = ConnectorConfigTable(ConnectorConfig.objects.filter(space=request.space).all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(
request,
'generic/list_template.html',
{
'title': _("Connector Config Backend"),
'table': table,
'create_url': 'new_connector_config'
}
)
@group_required('admin')
def invite_link(request):
table = InviteLinkTable(

View File

@@ -1,4 +1,3 @@
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
@@ -6,9 +5,9 @@ from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import CreateView
from cookbook.forms import ImportRecipeForm, Storage, StorageForm
from cookbook.forms import ImportRecipeForm, Storage, StorageForm, ConnectorConfigForm
from cookbook.helper.permission_helper import GroupRequiredMixin, above_space_limit, group_required
from cookbook.models import Recipe, RecipeImport, ShareLink, Step
from cookbook.models import Recipe, RecipeImport, ShareLink, Step, ConnectorConfig
from recipes import settings
@@ -16,7 +15,7 @@ class RecipeCreate(GroupRequiredMixin, CreateView):
groups_required = ['user']
template_name = "generic/new_template.html"
model = Recipe
fields = ('name',)
fields = ('name', )
def form_valid(self, form):
limit, msg = above_space_limit(self.request.space)
@@ -71,6 +70,35 @@ class StorageCreate(GroupRequiredMixin, CreateView):
return context
class ConnectorConfigCreate(GroupRequiredMixin, CreateView):
groups_required = ['admin']
template_name = "generic/new_template.html"
model = ConnectorConfig
form_class = ConnectorConfigForm
success_url = reverse_lazy('list_connector_config')
def form_valid(self, form):
if self.request.space.demo:
messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
return redirect('index')
if settings.DISABLE_EXTERNAL_CONNECTORS:
messages.add_message(self.request, messages.ERROR, _('This feature is not enabled by the server admin!'))
return redirect('index')
obj = form.save(commit=False)
obj.token = form.cleaned_data['update_token']
obj.created_by = self.request.user
obj.space = self.request.space
obj.save()
return HttpResponseRedirect(reverse('edit_connector_config', kwargs={'pk': obj.pk}))
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Connector Config Backend")
return context
@group_required('user')
def create_new_external_recipe(request, import_id):
if request.method == "POST":
@@ -98,12 +126,6 @@ def create_new_external_recipe(request, import_id):
messages.add_message(request, messages.ERROR, _('There was an error importing this recipe!'))
else:
new_recipe = get_object_or_404(RecipeImport, pk=import_id, space=request.space)
form = ImportRecipeForm(
initial={
'file_path': new_recipe.file_path,
'name': new_recipe.name,
'file_uid': new_recipe.file_uid
}, space=request.space
)
form = ImportRecipeForm(initial={'file_path': new_recipe.file_path, 'name': new_recipe.name, 'file_uid': new_recipe.file_uid}, space=request.space)
return render(request, 'forms/edit_import_recipe.html', {'form': form})

View File

@@ -1,10 +1,8 @@
import json
import os
import re
from datetime import datetime
from io import StringIO
from uuid import UUID
import subprocess
from django.apps import apps
from django.conf import settings
@@ -17,23 +15,19 @@ from django.core.management import call_command
from django.db import models
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.templatetags.static import static
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
SpaceJoinForm, User, UserCreateForm, UserPreference)
from cookbook.forms import CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm, SpaceJoinForm, User, UserCreateForm, UserPreference
from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.permission_helper import (group_required, has_group_permission,
share_link_valid, switch_user_active_space)
from cookbook.models import (Comment, CookLog, InviteLink, SearchFields, SearchPreference,
ShareLink, Space, UserSpace, ViewLog)
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid, switch_user_active_space
from cookbook.models import Comment, CookLog, InviteLink, SearchFields, SearchPreference, ShareLink, Space, UserSpace, ViewLog
from cookbook.tables import CookLogTable, ViewLogTable
from cookbook.templatetags.theming_tags import get_theming_values
from cookbook.version_info import VERSION_INFO
from recipes.settings import PLUGINS, BASE_DIR
from recipes.settings import PLUGINS
def index(request):
@@ -44,11 +38,7 @@ def index(request):
return HttpResponseRedirect(reverse_lazy('view_search'))
try:
page_map = {
UserPreference.SEARCH: reverse_lazy('view_search'),
UserPreference.PLAN: reverse_lazy('view_plan'),
UserPreference.BOOKS: reverse_lazy('view_books'),
}
page_map = {UserPreference.SEARCH: reverse_lazy('view_search'), UserPreference.PLAN: reverse_lazy('view_plan'), UserPreference.BOOKS: reverse_lazy('view_books'), }
return HttpResponseRedirect(page_map.get(request.user.userpreference.default_page))
except UserPreference.DoesNotExist:
@@ -84,14 +74,13 @@ def space_overview(request):
_('You have the reached the maximum amount of spaces that can be owned by you.') + f' ({request.user.userpreference.max_owned_spaces})')
return HttpResponseRedirect(reverse('view_space_overview'))
created_space = Space.objects.create(
name=create_form.cleaned_data['name'],
created_by=request.user,
max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
max_users=settings.SPACE_DEFAULT_MAX_USERS,
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
)
created_space = Space.objects.create(name=create_form.cleaned_data['name'],
created_by=request.user,
max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
max_users=settings.SPACE_DEFAULT_MAX_USERS,
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
)
user_space = UserSpace.objects.create(space=created_space, user=request.user, active=False)
user_space.groups.add(Group.objects.filter(name='admin').get())
@@ -135,23 +124,18 @@ def recipe_view(request, pk, share=None):
recipe = get_object_or_404(Recipe, pk=pk)
if not request.user.is_authenticated and not share_link_valid(recipe, share):
messages.add_message(request, messages.ERROR,
_('You do not have the required permissions to view this page!'))
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
if not (has_group_permission(request.user,
('guest',)) and recipe.space == request.space) and not share_link_valid(recipe,
share):
messages.add_message(request, messages.ERROR,
_('You do not have the required permissions to view this page!'))
if not (has_group_permission(request.user, ('guest',)) and recipe.space == request.space) and not share_link_valid(recipe, share):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse('index'))
comments = Comment.objects.filter(recipe__space=request.space, recipe=recipe)
if request.method == "POST":
if not request.user.is_authenticated:
messages.add_message(request, messages.ERROR,
_('You do not have the required permissions to perform this action!'))
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to perform this action!'))
return HttpResponseRedirect(reverse('view_recipe', kwargs={'pk': recipe.pk, 'share': share}))
comment_form = CommentForm(request.POST, prefix='comment')
@@ -167,13 +151,14 @@ def recipe_view(request, pk, share=None):
comment_form = CommentForm()
if request.user.is_authenticated:
if not ViewLog.objects.filter(recipe=recipe, created_by=request.user,
created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)),
space=request.space).exists():
if not ViewLog.objects.filter(recipe=recipe, created_by=request.user, created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)), space=request.space).exists():
ViewLog.objects.create(recipe=recipe, created_by=request.user, space=request.space)
return render(request, 'recipe_view.html',
{'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, })
servings = recipe.servings
if request.method == "GET" and 'servings' in request.GET:
servings = request.GET.get("servings")
return render(request, 'recipe_view.html', {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, 'servings': servings})
@group_required('user')
@@ -186,16 +171,6 @@ def meal_plan(request):
return render(request, 'meal_plan.html', {})
@group_required('user')
def supermarket(request):
return render(request, 'supermarket.html', {})
@group_required('user')
def view_profile(request, user_id):
return render(request, 'profile.html', {})
@group_required('guest')
def user_settings(request):
if request.space.demo:
@@ -238,12 +213,8 @@ def shopping_settings(request):
if search_form.is_valid():
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'])
)
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']))
if search_form.cleaned_data['preset'] == 'fuzzy':
sp.search = SearchPreference.SIMPLE
sp.lookup = True
@@ -268,13 +239,10 @@ def shopping_settings(request):
elif fields_searched == 0:
search_form.add_error(None, _('You must select at least one field to search!'))
search_error = True
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(
search_form.cleaned_data['fulltext']) == 0:
search_form.add_error('search',
_('To use this search method you must select at least one full text search field!'))
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(search_form.cleaned_data['fulltext']) == 0:
search_form.add_error('search', _('To use this search method you must select at least one full text search field!'))
search_error = True
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(
search_form.cleaned_data['trigram']) > 0:
elif search_form.cleaned_data['search'] in ['websearch', 'raw'] and len(search_form.cleaned_data['trigram']) > 0:
search_form.add_error(None, _('Fuzzy search is not compatible with this search method!'))
search_error = True
else:
@@ -290,8 +258,7 @@ def shopping_settings(request):
else:
search_error = True
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
sp.fulltext.all())
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(sp.fulltext.all())
if sp and not search_error and fields_searched > 0:
search_form = SearchPreferenceForm(instance=sp)
elif not search_error:
@@ -304,23 +271,16 @@ def shopping_settings(request):
sp.fulltext.clear()
sp.save()
return render(request, 'settings.html', {
'search_form': search_form,
})
return render(request, 'settings.html', {'search_form': search_form, })
@group_required('guest')
def history(request):
view_log = ViewLogTable(
ViewLog.objects.filter(
created_by=request.user, space=request.space
).order_by('-created_at').all()
)
cook_log = CookLogTable(
CookLog.objects.filter(
created_by=request.user
).order_by('-created_at').all()
)
view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user, space=request.space).order_by('-created_at').all(), prefix="viewlog-")
view_log.paginate(page=request.GET.get("viewlog-page", 1), per_page=25)
cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all(), prefix="cooklog-")
cook_log.paginate(page=request.GET.get("cooklog-page", 1), per_page=25)
return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log})
@@ -343,12 +303,10 @@ def system(request):
database_message = _('Everything is fine!')
elif postgres_ver < postgres_current - 2:
database_status = 'danger'
database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {
'v': postgres_ver}
database_message = _('PostgreSQL %(v)s is deprecated. Upgrade to a fully supported version!') % {'v': postgres_ver}
else:
database_status = 'info'
database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {
'v1': postgres_ver, 'v2': postgres_current}
database_message = _('You are running PostgreSQL %(v1)s. PostgreSQL %(v2)s is recommended') % {'v1': postgres_ver, 'v2': postgres_current}
else:
database_status = 'info'
database_message = _(
@@ -377,34 +335,27 @@ def system(request):
pass
else:
current_app = row
migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [],
'total': 0}
migration_info[current_app] = {'app': current_app, 'unapplied_migrations': [], 'applied_migrations': [], 'total': 0}
for key in migration_info.keys():
migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(
migration_info[key]['applied_migrations'])
migration_info[key]['total'] = len(migration_info[key]['unapplied_migrations']) + len(migration_info[key]['applied_migrations'])
return render(request, 'system.html', {
'gunicorn_media': settings.GUNICORN_MEDIA,
'debug': settings.DEBUG,
'postgres': postgres,
'postgres_version': postgres_ver,
'postgres_status': database_status,
'postgres_message': database_message,
'version_info': VERSION_INFO,
'plugins': PLUGINS,
'secret_key': secret_key,
'orphans': orphans,
'migration_info': migration_info,
'missing_migration': missing_migration,
})
return render(
request, 'system.html', {
'gunicorn_media': settings.GUNICORN_MEDIA, 'debug': settings.DEBUG, 'postgres': postgres, 'postgres_version': postgres_ver, 'postgres_status': database_status,
'postgres_message': database_message, 'version_info': VERSION_INFO, 'plugins': PLUGINS, 'secret_key': secret_key, 'orphans': orphans, 'migration_info': migration_info,
'missing_migration': missing_migration,
})
def setup(request):
with scopes_disabled():
if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS:
messages.add_message(request, messages.ERROR,
_('The setup page can only be used to create the first user! If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.'))
messages.add_message(
request, messages.ERROR,
_('The setup page can only be used to create the first user! \
If you have forgotten your superuser credentials please consult the django documentation on how to reset passwords.'
))
return HttpResponseRedirect(reverse('account_login'))
if request.method == 'POST':
@@ -444,8 +395,7 @@ def invite_link(request, token):
link.used_by = request.user
link.save()
user_space = UserSpace.objects.create(user=request.user, space=link.space,
internal_note=link.internal_note, invite_link=link, active=False)
user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False)
if request.user.userspace_set.count() == 1:
user_space.active = True
@@ -475,66 +425,36 @@ def space_manage(request, space_id):
def report_share_abuse(request, token):
if not settings.SHARING_ABUSE:
messages.add_message(request, messages.WARNING,
_('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.'))
messages.add_message(request, messages.WARNING, _('Reporting share links is not enabled for this instance. Please notify the page administrator to report problems.'))
else:
if link := ShareLink.objects.filter(uuid=token).first():
link.abuse_blocked = True
link.save()
messages.add_message(request, messages.WARNING,
_('Recipe sharing link has been disabled! For additional information please contact the page administrator.'))
messages.add_message(request, messages.WARNING, _('Recipe sharing link has been disabled! For additional information please contact the page administrator.'))
return HttpResponseRedirect(reverse('index'))
def web_manifest(request):
theme_values = get_theming_values(request)
icons = [
{"src": theme_values['logo_color_svg'], "sizes": "any"},
{"src": theme_values['logo_color_144'], "type": "image/png", "sizes": "144x144"},
{"src": theme_values['logo_color_512'], "type": "image/png", "sizes": "512x512"}
]
icons = [{"src": theme_values['logo_color_svg'], "sizes": "any"}, {"src": theme_values['logo_color_144'], "type": "image/png", "sizes": "144x144"},
{"src": theme_values['logo_color_512'], "type": "image/png", "sizes": "512x512"}]
manifest_info = {
"name": theme_values['app_name'],
"short_name": theme_values['app_name'],
"description": _("Manage recipes, shopping list, meal plans and more."),
"icons": icons,
"start_url": "./search",
"background_color": theme_values['nav_bg_color'],
"display": "standalone",
"scope": ".",
"theme_color": theme_values['nav_bg_color'],
"shortcuts": [
{
"name": _("Plan"),
"short_name": _("Plan"),
"description": _("View your meal Plan"),
"url": "./plan"
},
{
"name": _("Books"),
"short_name": _("Books"),
"description": _("View your cookbooks"),
"url": "./books"
},
{
"name": _("Shopping"),
"short_name": _("Shopping"),
"description": _("View your shopping lists"),
"url": "./list/shopping-list/"
}
],
"share_target": {
"action": "/data/import/url",
"method": "GET",
"params": {
"title": "title",
"url": "url",
"text": "text"
}
}
"name":
theme_values['app_name'], "short_name":
theme_values['app_name'], "description":
_("Manage recipes, shopping list, meal plans and more."), "icons":
icons, "start_url":
"./search", "background_color":
theme_values['nav_bg_color'], "display":
"standalone", "scope":
".", "theme_color":
theme_values['nav_bg_color'], "shortcuts":
[{"name": _("Plan"), "short_name": _("Plan"), "description": _("View your meal Plan"), "url":
"./plan"}, {"name": _("Books"), "short_name": _("Books"), "description": _("View your cookbooks"), "url": "./books"},
{"name": _("Shopping"), "short_name": _("Shopping"), "description": _("View your shopping lists"), "url":
"./list/shopping-list/"}], "share_target": {"action": "/data/import/url", "method": "GET", "params": {"title": "title", "url": "url", "text": "text"}}
}
return JsonResponse(manifest_info, json_dumps_params={'indent': 4})
@@ -564,9 +484,7 @@ def test(request):
from cookbook.helper.ingredient_parser import IngredientParser
parser = IngredientParser(request, False)
data = {
'original': '90g golden syrup'
}
data = {'original': '90g golden syrup'}
data['parsed'] = parser.parse(data['original'])
return render(request, 'test.html', {'data': data})

View File

@@ -22,6 +22,31 @@ If you want to contribute bug fixes or small tweaks then your pull requests are
!!! info
The dev setup is a little messy as this application combines the best (at least in my opinion) of both Django and Vue.js.
### Devcontainer Setup
There is a [devcontainer](https://containers.dev) set up to ease development. It is optimized for VSCode, but should be able to
be used by other editors as well. Once the container is running, you can do things like start a Django dev server, start a Vue.js
dev server, run python tests, etc. by either using the VSCode tasks below, or manually running commands described in the individual
technology sections below.
In VSCode, simply check out the git repository, and then via the command palette, choose `Dev Containers: Reopen in container`.
If you need to change python dependencies (requierments.txt) or OS packages, you will need to rebuild the container. If you are
changing OS package requirements, you will need to update both the main `Dockerfile` and the `.devcontainer/Dockerfile`.
### VSCode Tasks
If you use VSCode, there are a number of tasks that are available. Here are a few of the key ones:
* `Setup Dev Server` - Runs all the prerequisite steps so that the dev server can be run inside VSCode.
* `Setup Tests` - Runs all prerequisites so tests can be run inside VSCode.
Once these are run, you should be able to run/debug a django server in VSCode as well as run/debug tests directly through VSCode.
There are also a few other tasks specified in case you have specific development needs:
* `Run Dev Server` - Runs a django development server not connected to VSCode.
* `Run all pytests` - Runs all the pytests outside of VSCode.
* `Yarn Serve` - Runs development Vue.js server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
* `Serve Documentation` - Runs a documentation server. Useful if you want to see how changes to documentation show up.
### 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.

2
docs/coverage/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Created by coverage.py
*

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="114" height="20" role="img" aria-label="coverage: 58.61%"><title>coverage: 58.61%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="114" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="53" height="20" fill="#fe7d37"/><rect width="114" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="865" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="430">58.61%</text><text x="865" y="140" transform="scale(.1)" fill="#fff" textLength="430">58.61%</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

9118
docs/coverage/coverage.xml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,624 @@
// Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0
// For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt
// Coverage.py HTML report browser code.
/*jslint browser: true, sloppy: true, vars: true, plusplus: true, maxerr: 50, indent: 4 */
/*global coverage: true, document, window, $ */
coverage = {};
// General helpers
function debounce(callback, wait) {
let timeoutId = null;
return function(...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
callback.apply(this, args);
}, wait);
};
};
function checkVisible(element) {
const rect = element.getBoundingClientRect();
const viewBottom = Math.max(document.documentElement.clientHeight, window.innerHeight);
const viewTop = 30;
return !(rect.bottom < viewTop || rect.top >= viewBottom);
}
function on_click(sel, fn) {
const elt = document.querySelector(sel);
if (elt) {
elt.addEventListener("click", fn);
}
}
// Helpers for table sorting
function getCellValue(row, column = 0) {
const cell = row.cells[column] // nosemgrep: eslint.detect-object-injection
if (cell.childElementCount == 1) {
const child = cell.firstElementChild
if (child instanceof HTMLTimeElement && child.dateTime) {
return child.dateTime
} else if (child instanceof HTMLDataElement && child.value) {
return child.value
}
}
return cell.innerText || cell.textContent;
}
function rowComparator(rowA, rowB, column = 0) {
let valueA = getCellValue(rowA, column);
let valueB = getCellValue(rowB, column);
if (!isNaN(valueA) && !isNaN(valueB)) {
return valueA - valueB
}
return valueA.localeCompare(valueB, undefined, {numeric: true});
}
function sortColumn(th) {
// Get the current sorting direction of the selected header,
// clear state on other headers and then set the new sorting direction
const currentSortOrder = th.getAttribute("aria-sort");
[...th.parentElement.cells].forEach(header => header.setAttribute("aria-sort", "none"));
if (currentSortOrder === "none") {
th.setAttribute("aria-sort", th.dataset.defaultSortOrder || "ascending");
} else {
th.setAttribute("aria-sort", currentSortOrder === "ascending" ? "descending" : "ascending");
}
const column = [...th.parentElement.cells].indexOf(th)
// Sort all rows and afterwards append them in order to move them in the DOM
Array.from(th.closest("table").querySelectorAll("tbody tr"))
.sort((rowA, rowB) => rowComparator(rowA, rowB, column) * (th.getAttribute("aria-sort") === "ascending" ? 1 : -1))
.forEach(tr => tr.parentElement.appendChild(tr) );
}
// Find all the elements with data-shortcut attribute, and use them to assign a shortcut key.
coverage.assign_shortkeys = function () {
document.querySelectorAll("[data-shortcut]").forEach(element => {
document.addEventListener("keypress", event => {
if (event.target.tagName.toLowerCase() === "input") {
return; // ignore keypress from search filter
}
if (event.key === element.dataset.shortcut) {
element.click();
}
});
});
};
// Create the events for the filter box.
coverage.wire_up_filter = function () {
// Cache elements.
const table = document.querySelector("table.index");
const table_body_rows = table.querySelectorAll("tbody tr");
const no_rows = document.getElementById("no_rows");
// Observe filter keyevents.
document.getElementById("filter").addEventListener("input", debounce(event => {
// Keep running total of each metric, first index contains number of shown rows
const totals = new Array(table.rows[0].cells.length).fill(0);
// Accumulate the percentage as fraction
totals[totals.length - 1] = { "numer": 0, "denom": 0 }; // nosemgrep: eslint.detect-object-injection
// Hide / show elements.
table_body_rows.forEach(row => {
if (!row.cells[0].textContent.includes(event.target.value)) {
// hide
row.classList.add("hidden");
return;
}
// show
row.classList.remove("hidden");
totals[0]++;
for (let column = 1; column < totals.length; column++) {
// Accumulate dynamic totals
cell = row.cells[column] // nosemgrep: eslint.detect-object-injection
if (column === totals.length - 1) {
// Last column contains percentage
const [numer, denom] = cell.dataset.ratio.split(" ");
totals[column]["numer"] += parseInt(numer, 10); // nosemgrep: eslint.detect-object-injection
totals[column]["denom"] += parseInt(denom, 10); // nosemgrep: eslint.detect-object-injection
} else {
totals[column] += parseInt(cell.textContent, 10); // nosemgrep: eslint.detect-object-injection
}
}
});
// Show placeholder if no rows will be displayed.
if (!totals[0]) {
// Show placeholder, hide table.
no_rows.style.display = "block";
table.style.display = "none";
return;
}
// Hide placeholder, show table.
no_rows.style.display = null;
table.style.display = null;
const footer = table.tFoot.rows[0];
// Calculate new dynamic sum values based on visible rows.
for (let column = 1; column < totals.length; column++) {
// Get footer cell element.
const cell = footer.cells[column]; // nosemgrep: eslint.detect-object-injection
// Set value into dynamic footer cell element.
if (column === totals.length - 1) {
// Percentage column uses the numerator and denominator,
// and adapts to the number of decimal places.
const match = /\.([0-9]+)/.exec(cell.textContent);
const places = match ? match[1].length : 0;
const { numer, denom } = totals[column]; // nosemgrep: eslint.detect-object-injection
cell.dataset.ratio = `${numer} ${denom}`;
// Check denom to prevent NaN if filtered files contain no statements
cell.textContent = denom
? `${(numer * 100 / denom).toFixed(places)}%`
: `${(100).toFixed(places)}%`;
} else {
cell.textContent = totals[column]; // nosemgrep: eslint.detect-object-injection
}
}
}));
// Trigger change event on setup, to force filter on page refresh
// (filter value may still be present).
document.getElementById("filter").dispatchEvent(new Event("input"));
};
coverage.INDEX_SORT_STORAGE = "COVERAGE_INDEX_SORT_2";
// Loaded on index.html
coverage.index_ready = function () {
coverage.assign_shortkeys();
coverage.wire_up_filter();
document.querySelectorAll("[data-sortable] th[aria-sort]").forEach(
th => th.addEventListener("click", e => sortColumn(e.target))
);
// Look for a localStorage item containing previous sort settings:
const stored_list = localStorage.getItem(coverage.INDEX_SORT_STORAGE);
if (stored_list) {
const {column, direction} = JSON.parse(stored_list);
const th = document.querySelector("[data-sortable]").tHead.rows[0].cells[column]; // nosemgrep: eslint.detect-object-injection
th.setAttribute("aria-sort", direction === "ascending" ? "descending" : "ascending");
th.click()
}
// Watch for page unload events so we can save the final sort settings:
window.addEventListener("unload", function () {
const th = document.querySelector('[data-sortable] th[aria-sort="ascending"], [data-sortable] [aria-sort="descending"]');
if (!th) {
return;
}
localStorage.setItem(coverage.INDEX_SORT_STORAGE, JSON.stringify({
column: [...th.parentElement.cells].indexOf(th),
direction: th.getAttribute("aria-sort"),
}));
});
on_click(".button_prev_file", coverage.to_prev_file);
on_click(".button_next_file", coverage.to_next_file);
on_click(".button_show_hide_help", coverage.show_hide_help);
};
// -- pyfile stuff --
coverage.LINE_FILTERS_STORAGE = "COVERAGE_LINE_FILTERS";
coverage.pyfile_ready = function () {
// If we're directed to a particular line number, highlight the line.
var frag = location.hash;
if (frag.length > 2 && frag[1] === "t") {
document.querySelector(frag).closest(".n").classList.add("highlight");
coverage.set_sel(parseInt(frag.substr(2), 10));
} else {
coverage.set_sel(0);
}
on_click(".button_toggle_run", coverage.toggle_lines);
on_click(".button_toggle_mis", coverage.toggle_lines);
on_click(".button_toggle_exc", coverage.toggle_lines);
on_click(".button_toggle_par", coverage.toggle_lines);
on_click(".button_next_chunk", coverage.to_next_chunk_nicely);
on_click(".button_prev_chunk", coverage.to_prev_chunk_nicely);
on_click(".button_top_of_page", coverage.to_top);
on_click(".button_first_chunk", coverage.to_first_chunk);
on_click(".button_prev_file", coverage.to_prev_file);
on_click(".button_next_file", coverage.to_next_file);
on_click(".button_to_index", coverage.to_index);
on_click(".button_show_hide_help", coverage.show_hide_help);
coverage.filters = undefined;
try {
coverage.filters = localStorage.getItem(coverage.LINE_FILTERS_STORAGE);
} catch(err) {}
if (coverage.filters) {
coverage.filters = JSON.parse(coverage.filters);
}
else {
coverage.filters = {run: false, exc: true, mis: true, par: true};
}
for (cls in coverage.filters) {
coverage.set_line_visibilty(cls, coverage.filters[cls]); // nosemgrep: eslint.detect-object-injection
}
coverage.assign_shortkeys();
coverage.init_scroll_markers();
coverage.wire_up_sticky_header();
document.querySelectorAll("[id^=ctxs]").forEach(
cbox => cbox.addEventListener("click", coverage.expand_contexts)
);
// Rebuild scroll markers when the window height changes.
window.addEventListener("resize", coverage.build_scroll_markers);
};
coverage.toggle_lines = function (event) {
const btn = event.target.closest("button");
const category = btn.value
const show = !btn.classList.contains("show_" + category);
coverage.set_line_visibilty(category, show);
coverage.build_scroll_markers();
coverage.filters[category] = show;
try {
localStorage.setItem(coverage.LINE_FILTERS_STORAGE, JSON.stringify(coverage.filters));
} catch(err) {}
};
coverage.set_line_visibilty = function (category, should_show) {
const cls = "show_" + category;
const btn = document.querySelector(".button_toggle_" + category);
if (btn) {
if (should_show) {
document.querySelectorAll("#source ." + category).forEach(e => e.classList.add(cls));
btn.classList.add(cls);
}
else {
document.querySelectorAll("#source ." + category).forEach(e => e.classList.remove(cls));
btn.classList.remove(cls);
}
}
};
// Return the nth line div.
coverage.line_elt = function (n) {
return document.getElementById("t" + n)?.closest("p");
};
// Set the selection. b and e are line numbers.
coverage.set_sel = function (b, e) {
// The first line selected.
coverage.sel_begin = b;
// The next line not selected.
coverage.sel_end = (e === undefined) ? b+1 : e;
};
coverage.to_top = function () {
coverage.set_sel(0, 1);
coverage.scroll_window(0);
};
coverage.to_first_chunk = function () {
coverage.set_sel(0, 1);
coverage.to_next_chunk();
};
coverage.to_prev_file = function () {
window.location = document.getElementById("prevFileLink").href;
}
coverage.to_next_file = function () {
window.location = document.getElementById("nextFileLink").href;
}
coverage.to_index = function () {
location.href = document.getElementById("indexLink").href;
}
coverage.show_hide_help = function () {
const helpCheck = document.getElementById("help_panel_state")
helpCheck.checked = !helpCheck.checked;
}
// Return a string indicating what kind of chunk this line belongs to,
// or null if not a chunk.
coverage.chunk_indicator = function (line_elt) {
const classes = line_elt?.className;
if (!classes) {
return null;
}
const match = classes.match(/\bshow_\w+\b/);
if (!match) {
return null;
}
return match[0];
};
coverage.to_next_chunk = function () {
const c = coverage;
// Find the start of the next colored chunk.
var probe = c.sel_end;
var chunk_indicator, probe_line;
while (true) {
probe_line = c.line_elt(probe);
if (!probe_line) {
return;
}
chunk_indicator = c.chunk_indicator(probe_line);
if (chunk_indicator) {
break;
}
probe++;
}
// There's a next chunk, `probe` points to it.
var begin = probe;
// Find the end of this chunk.
var next_indicator = chunk_indicator;
while (next_indicator === chunk_indicator) {
probe++;
probe_line = c.line_elt(probe);
next_indicator = c.chunk_indicator(probe_line);
}
c.set_sel(begin, probe);
c.show_selection();
};
coverage.to_prev_chunk = function () {
const c = coverage;
// Find the end of the prev colored chunk.
var probe = c.sel_begin-1;
var probe_line = c.line_elt(probe);
if (!probe_line) {
return;
}
var chunk_indicator = c.chunk_indicator(probe_line);
while (probe > 1 && !chunk_indicator) {
probe--;
probe_line = c.line_elt(probe);
if (!probe_line) {
return;
}
chunk_indicator = c.chunk_indicator(probe_line);
}
// There's a prev chunk, `probe` points to its last line.
var end = probe+1;
// Find the beginning of this chunk.
var prev_indicator = chunk_indicator;
while (prev_indicator === chunk_indicator) {
probe--;
if (probe <= 0) {
return;
}
probe_line = c.line_elt(probe);
prev_indicator = c.chunk_indicator(probe_line);
}
c.set_sel(probe+1, end);
c.show_selection();
};
// Returns 0, 1, or 2: how many of the two ends of the selection are on
// the screen right now?
coverage.selection_ends_on_screen = function () {
if (coverage.sel_begin === 0) {
return 0;
}
const begin = coverage.line_elt(coverage.sel_begin);
const end = coverage.line_elt(coverage.sel_end-1);
return (
(checkVisible(begin) ? 1 : 0)
+ (checkVisible(end) ? 1 : 0)
);
};
coverage.to_next_chunk_nicely = function () {
if (coverage.selection_ends_on_screen() === 0) {
// The selection is entirely off the screen:
// Set the top line on the screen as selection.
// This will select the top-left of the viewport
// As this is most likely the span with the line number we take the parent
const line = document.elementFromPoint(0, 0).parentElement;
if (line.parentElement !== document.getElementById("source")) {
// The element is not a source line but the header or similar
coverage.select_line_or_chunk(1);
} else {
// We extract the line number from the id
coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10));
}
}
coverage.to_next_chunk();
};
coverage.to_prev_chunk_nicely = function () {
if (coverage.selection_ends_on_screen() === 0) {
// The selection is entirely off the screen:
// Set the lowest line on the screen as selection.
// This will select the bottom-left of the viewport
// As this is most likely the span with the line number we take the parent
const line = document.elementFromPoint(document.documentElement.clientHeight-1, 0).parentElement;
if (line.parentElement !== document.getElementById("source")) {
// The element is not a source line but the header or similar
coverage.select_line_or_chunk(coverage.lines_len);
} else {
// We extract the line number from the id
coverage.select_line_or_chunk(parseInt(line.id.substring(1), 10));
}
}
coverage.to_prev_chunk();
};
// Select line number lineno, or if it is in a colored chunk, select the
// entire chunk
coverage.select_line_or_chunk = function (lineno) {
var c = coverage;
var probe_line = c.line_elt(lineno);
if (!probe_line) {
return;
}
var the_indicator = c.chunk_indicator(probe_line);
if (the_indicator) {
// The line is in a highlighted chunk.
// Search backward for the first line.
var probe = lineno;
var indicator = the_indicator;
while (probe > 0 && indicator === the_indicator) {
probe--;
probe_line = c.line_elt(probe);
if (!probe_line) {
break;
}
indicator = c.chunk_indicator(probe_line);
}
var begin = probe + 1;
// Search forward for the last line.
probe = lineno;
indicator = the_indicator;
while (indicator === the_indicator) {
probe++;
probe_line = c.line_elt(probe);
indicator = c.chunk_indicator(probe_line);
}
coverage.set_sel(begin, probe);
}
else {
coverage.set_sel(lineno);
}
};
coverage.show_selection = function () {
// Highlight the lines in the chunk
document.querySelectorAll("#source .highlight").forEach(e => e.classList.remove("highlight"));
for (let probe = coverage.sel_begin; probe < coverage.sel_end; probe++) {
coverage.line_elt(probe).querySelector(".n").classList.add("highlight");
}
coverage.scroll_to_selection();
};
coverage.scroll_to_selection = function () {
// Scroll the page if the chunk isn't fully visible.
if (coverage.selection_ends_on_screen() < 2) {
const element = coverage.line_elt(coverage.sel_begin);
coverage.scroll_window(element.offsetTop - 60);
}
};
coverage.scroll_window = function (to_pos) {
window.scroll({top: to_pos, behavior: "smooth"});
};
coverage.init_scroll_markers = function () {
// Init some variables
coverage.lines_len = document.querySelectorAll("#source > p").length;
// Build html
coverage.build_scroll_markers();
};
coverage.build_scroll_markers = function () {
const temp_scroll_marker = document.getElementById("scroll_marker")
if (temp_scroll_marker) temp_scroll_marker.remove();
// Don't build markers if the window has no scroll bar.
if (document.body.scrollHeight <= window.innerHeight) {
return;
}
const marker_scale = window.innerHeight / document.body.scrollHeight;
const line_height = Math.min(Math.max(3, window.innerHeight / coverage.lines_len), 10);
let previous_line = -99, last_mark, last_top;
const scroll_marker = document.createElement("div");
scroll_marker.id = "scroll_marker";
document.getElementById("source").querySelectorAll(
"p.show_run, p.show_mis, p.show_exc, p.show_exc, p.show_par"
).forEach(element => {
const line_top = Math.floor(element.offsetTop * marker_scale);
const line_number = parseInt(element.querySelector(".n a").id.substr(1));
if (line_number === previous_line + 1) {
// If this solid missed block just make previous mark higher.
last_mark.style.height = `${line_top + line_height - last_top}px`;
} else {
// Add colored line in scroll_marker block.
last_mark = document.createElement("div");
last_mark.id = `m${line_number}`;
last_mark.classList.add("marker");
last_mark.style.height = `${line_height}px`;
last_mark.style.top = `${line_top}px`;
scroll_marker.append(last_mark);
last_top = line_top;
}
previous_line = line_number;
});
// Append last to prevent layout calculation
document.body.append(scroll_marker);
};
coverage.wire_up_sticky_header = function () {
const header = document.querySelector("header");
const header_bottom = (
header.querySelector(".content h2").getBoundingClientRect().top -
header.getBoundingClientRect().top
);
function updateHeader() {
if (window.scrollY > header_bottom) {
header.classList.add("sticky");
} else {
header.classList.remove("sticky");
}
}
window.addEventListener("scroll", updateHeader);
updateHeader();
};
coverage.expand_contexts = function (e) {
var ctxs = e.target.parentNode.querySelector(".ctxs");
if (!ctxs.classList.contains("expanded")) {
var ctxs_text = ctxs.textContent;
var width = Number(ctxs_text[0]);
ctxs.textContent = "";
for (var i = 1; i < ctxs_text.length; i += width) {
key = ctxs_text.substring(i, i + width).trim();
ctxs.appendChild(document.createTextNode(contexts[key]));
ctxs.appendChild(document.createElement("br"));
}
ctxs.classList.add("expanded");
}
};
document.addEventListener("DOMContentLoaded", () => {
if (document.body.classList.contains("indexfile")) {
coverage.index_ready();
} else {
coverage.pyfile_ready();
}
});

View File

@@ -0,0 +1,249 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Coverage for cookbook/provider/dropbox.py: 28%</title>
<link rel="icon" sizes="32x32" href="favicon_32.png">
<link rel="stylesheet" href="style.css" type="text/css">
<script type="text/javascript" src="coverage_html.js" defer></script>
</head>
<body class="pyfile">
<header>
<div class="content">
<h1>
<span class="text">Coverage for </span><b>cookbook/provider/dropbox.py</b>:
<span class="pc_cov">28%</span>
</h1>
<aside id="help_panel_wrapper">
<input id="help_panel_state" type="checkbox">
<label for="help_panel_state">
<img id="keyboard_icon" src="keybd_closed.png" alt="Show/hide keyboard shortcuts" />
</label>
<div id="help_panel">
<p class="legend">Shortcuts on this page</p>
<div class="keyhelp">
<p>
<kbd>r</kbd>
<kbd>m</kbd>
<kbd>x</kbd>
&nbsp; toggle line displays
</p>
<p>
<kbd>j</kbd>
<kbd>k</kbd>
&nbsp; next/prev highlighted chunk
</p>
<p>
<kbd>0</kbd> &nbsp; (zero) top of page
</p>
<p>
<kbd>1</kbd> &nbsp; (one) first highlighted chunk
</p>
<p>
<kbd>[</kbd>
<kbd>]</kbd>
&nbsp; prev/next file
</p>
<p>
<kbd>u</kbd> &nbsp; up to the index
</p>
<p>
<kbd>?</kbd> &nbsp; show/hide this help
</p>
</div>
</div>
</aside>
<h2>
<span class="text">75 statements &nbsp;</span>
<button type="button" class="run button_toggle_run" value="run" data-shortcut="r" title="Toggle lines run">21<span class="text"> run</span></button>
<button type="button" class="mis show_mis button_toggle_mis" value="mis" data-shortcut="m" title="Toggle lines missing">54<span class="text"> missing</span></button>
<button type="button" class="exc show_exc button_toggle_exc" value="exc" data-shortcut="x" title="Toggle lines excluded">0<span class="text"> excluded</span></button>
</h2>
<p class="text">
<a id="prevFileLink" class="nav" href="d_a167ab5b5108d61e_models_py.html">&#xab; prev</a> &nbsp; &nbsp;
<a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
<a id="nextFileLink" class="nav" href="d_0b5495cf37ee6c4f_local_py.html">&#xbb; next</a>
&nbsp; &nbsp; &nbsp;
<a class="nav" href="https://coverage.readthedocs.io/en/7.4.0">coverage.py v7.4.0</a>,
created at 2023-12-28 15:03 +0100
</p>
<aside class="hidden">
<button type="button" class="button_next_chunk" data-shortcut="j"/>
<button type="button" class="button_prev_chunk" data-shortcut="k"/>
<button type="button" class="button_top_of_page" data-shortcut="0"/>
<button type="button" class="button_first_chunk" data-shortcut="1"/>
<button type="button" class="button_prev_file" data-shortcut="["/>
<button type="button" class="button_next_file" data-shortcut="]"/>
<button type="button" class="button_to_index" data-shortcut="u"/>
<button type="button" class="button_show_hide_help" data-shortcut="?"/>
</aside>
</div>
</header>
<main id="source">
<p class="run"><span class="n"><a id="t1" href="#t1">1</a></span><span class="t"><span class="key">import</span> <span class="nam">io</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t2" href="#t2">2</a></span><span class="t"><span class="key">import</span> <span class="nam">json</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t3" href="#t3">3</a></span><span class="t"><span class="key">import</span> <span class="nam">os</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t4" href="#t4">4</a></span><span class="t"><span class="key">from</span> <span class="nam">datetime</span> <span class="key">import</span> <span class="nam">datetime</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t5" href="#t5">5</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t6" href="#t6">6</a></span><span class="t"><span class="key">import</span> <span class="nam">requests</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t7" href="#t7">7</a></span><span class="t"><span class="key">import</span> <span class="nam">validators</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t8" href="#t8">8</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t9" href="#t9">9</a></span><span class="t"><span class="key">from</span> <span class="nam">cookbook</span><span class="op">.</span><span class="nam">models</span> <span class="key">import</span> <span class="nam">Recipe</span><span class="op">,</span> <span class="nam">RecipeImport</span><span class="op">,</span> <span class="nam">SyncLog</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t10" href="#t10">10</a></span><span class="t"><span class="key">from</span> <span class="nam">cookbook</span><span class="op">.</span><span class="nam">provider</span><span class="op">.</span><span class="nam">provider</span> <span class="key">import</span> <span class="nam">Provider</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t11" href="#t11">11</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t12" href="#t12">12</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t13" href="#t13">13</a></span><span class="t"><span class="key">class</span> <span class="nam">Dropbox</span><span class="op">(</span><span class="nam">Provider</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t14" href="#t14">14</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t15" href="#t15">15</a></span><span class="t"> <span class="op">@</span><span class="nam">staticmethod</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t16" href="#t16">16</a></span><span class="t"> <span class="key">def</span> <span class="nam">import_all</span><span class="op">(</span><span class="nam">monitor</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t17" href="#t17">17</a></span><span class="t"> <span class="nam">url</span> <span class="op">=</span> <span class="str">"https://api.dropboxapi.com/2/files/list_folder"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t18" href="#t18">18</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t19" href="#t19">19</a></span><span class="t"> <span class="nam">headers</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t20" href="#t20">20</a></span><span class="t"> <span class="str">"Authorization"</span><span class="op">:</span> <span class="str">"Bearer "</span> <span class="op">+</span> <span class="nam">monitor</span><span class="op">.</span><span class="nam">storage</span><span class="op">.</span><span class="nam">token</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t21" href="#t21">21</a></span><span class="t"> <span class="str">"Content-Type"</span><span class="op">:</span> <span class="str">"application/json"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t22" href="#t22">22</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t23" href="#t23">23</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t24" href="#t24">24</a></span><span class="t"> <span class="nam">data</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t25" href="#t25">25</a></span><span class="t"> <span class="str">"path"</span><span class="op">:</span> <span class="nam">monitor</span><span class="op">.</span><span class="nam">path</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t26" href="#t26">26</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t27" href="#t27">27</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t28" href="#t28">28</a></span><span class="t"> <span class="nam">r</span> <span class="op">=</span> <span class="nam">requests</span><span class="op">.</span><span class="nam">post</span><span class="op">(</span><span class="nam">url</span><span class="op">,</span> <span class="nam">headers</span><span class="op">=</span><span class="nam">headers</span><span class="op">,</span> <span class="nam">data</span><span class="op">=</span><span class="nam">json</span><span class="op">.</span><span class="nam">dumps</span><span class="op">(</span><span class="nam">data</span><span class="op">)</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t29" href="#t29">29</a></span><span class="t"> <span class="key">try</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t30" href="#t30">30</a></span><span class="t"> <span class="nam">recipes</span> <span class="op">=</span> <span class="nam">r</span><span class="op">.</span><span class="nam">json</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t31" href="#t31">31</a></span><span class="t"> <span class="key">except</span> <span class="nam">ValueError</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t32" href="#t32">32</a></span><span class="t"> <span class="nam">log_entry</span> <span class="op">=</span> <span class="nam">SyncLog</span><span class="op">(</span><span class="nam">status</span><span class="op">=</span><span class="str">'ERROR'</span><span class="op">,</span> <span class="nam">msg</span><span class="op">=</span><span class="nam">str</span><span class="op">(</span><span class="nam">r</span><span class="op">)</span><span class="op">,</span> <span class="nam">sync</span><span class="op">=</span><span class="nam">monitor</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t33" href="#t33">33</a></span><span class="t"> <span class="nam">log_entry</span><span class="op">.</span><span class="nam">save</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t34" href="#t34">34</a></span><span class="t"> <span class="key">return</span> <span class="nam">r</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t35" href="#t35">35</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t36" href="#t36">36</a></span><span class="t"> <span class="nam">import_count</span> <span class="op">=</span> <span class="num">0</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t37" href="#t37">37</a></span><span class="t"> <span class="com"># TODO check if has_more is set and import that as well</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t38" href="#t38">38</a></span><span class="t"> <span class="key">for</span> <span class="nam">recipe</span> <span class="key">in</span> <span class="nam">recipes</span><span class="op">[</span><span class="str">'entries'</span><span class="op">]</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t39" href="#t39">39</a></span><span class="t"> <span class="nam">path</span> <span class="op">=</span> <span class="nam">recipe</span><span class="op">[</span><span class="str">'path_lower'</span><span class="op">]</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t40" href="#t40">40</a></span><span class="t"> <span class="key">if</span> <span class="key">not</span> <span class="nam">Recipe</span><span class="op">.</span><span class="nam">objects</span><span class="op">.</span><span class="nam">filter</span><span class="op">(</span><span class="nam">file_path__iexact</span><span class="op">=</span><span class="nam">path</span><span class="op">,</span> <span class="nam">space</span><span class="op">=</span><span class="nam">monitor</span><span class="op">.</span><span class="nam">space</span><span class="op">)</span><span class="op">.</span><span class="nam">exists</span><span class="op">(</span><span class="op">)</span> <span class="key">and</span> <span class="key">not</span> <span class="nam">RecipeImport</span><span class="op">.</span><span class="nam">objects</span><span class="op">.</span><span class="nam">filter</span><span class="op">(</span><span class="nam">file_path</span><span class="op">=</span><span class="nam">path</span><span class="op">,</span> <span class="nam">space</span><span class="op">=</span><span class="nam">monitor</span><span class="op">.</span><span class="nam">space</span><span class="op">)</span><span class="op">.</span><span class="nam">exists</span><span class="op">(</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t41" href="#t41">41</a></span><span class="t"> <span class="nam">name</span> <span class="op">=</span> <span class="nam">os</span><span class="op">.</span><span class="nam">path</span><span class="op">.</span><span class="nam">splitext</span><span class="op">(</span><span class="nam">recipe</span><span class="op">[</span><span class="str">'name'</span><span class="op">]</span><span class="op">)</span><span class="op">[</span><span class="num">0</span><span class="op">]</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t42" href="#t42">42</a></span><span class="t"> <span class="nam">new_recipe</span> <span class="op">=</span> <span class="nam">RecipeImport</span><span class="op">(</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t43" href="#t43">43</a></span><span class="t"> <span class="nam">name</span><span class="op">=</span><span class="nam">name</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t44" href="#t44">44</a></span><span class="t"> <span class="nam">file_path</span><span class="op">=</span><span class="nam">path</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t45" href="#t45">45</a></span><span class="t"> <span class="nam">storage</span><span class="op">=</span><span class="nam">monitor</span><span class="op">.</span><span class="nam">storage</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t46" href="#t46">46</a></span><span class="t"> <span class="nam">file_uid</span><span class="op">=</span><span class="nam">recipe</span><span class="op">[</span><span class="str">'id'</span><span class="op">]</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t47" href="#t47">47</a></span><span class="t"> <span class="nam">space</span><span class="op">=</span><span class="nam">monitor</span><span class="op">.</span><span class="nam">space</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t48" href="#t48">48</a></span><span class="t"> <span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t49" href="#t49">49</a></span><span class="t"> <span class="nam">new_recipe</span><span class="op">.</span><span class="nam">save</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t50" href="#t50">50</a></span><span class="t"> <span class="nam">import_count</span> <span class="op">+=</span> <span class="num">1</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t51" href="#t51">51</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t52" href="#t52">52</a></span><span class="t"> <span class="nam">log_entry</span> <span class="op">=</span> <span class="nam">SyncLog</span><span class="op">(</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t53" href="#t53">53</a></span><span class="t"> <span class="nam">status</span><span class="op">=</span><span class="str">'SUCCESS'</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t54" href="#t54">54</a></span><span class="t"> <span class="nam">msg</span><span class="op">=</span><span class="str">'Imported '</span> <span class="op">+</span> <span class="nam">str</span><span class="op">(</span><span class="nam">import_count</span><span class="op">)</span> <span class="op">+</span> <span class="str">' recipes'</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t55" href="#t55">55</a></span><span class="t"> <span class="nam">sync</span><span class="op">=</span><span class="nam">monitor</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t56" href="#t56">56</a></span><span class="t"> <span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t57" href="#t57">57</a></span><span class="t"> <span class="nam">log_entry</span><span class="op">.</span><span class="nam">save</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t58" href="#t58">58</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t59" href="#t59">59</a></span><span class="t"> <span class="nam">monitor</span><span class="op">.</span><span class="nam">last_checked</span> <span class="op">=</span> <span class="nam">datetime</span><span class="op">.</span><span class="nam">now</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t60" href="#t60">60</a></span><span class="t"> <span class="nam">monitor</span><span class="op">.</span><span class="nam">save</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t61" href="#t61">61</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t62" href="#t62">62</a></span><span class="t"> <span class="key">return</span> <span class="key">True</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t63" href="#t63">63</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t64" href="#t64">64</a></span><span class="t"> <span class="op">@</span><span class="nam">staticmethod</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t65" href="#t65">65</a></span><span class="t"> <span class="key">def</span> <span class="nam">create_share_link</span><span class="op">(</span><span class="nam">recipe</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t66" href="#t66">66</a></span><span class="t"> <span class="nam">url</span> <span class="op">=</span> <span class="str">"https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings"</span> <span class="com"># noqa: E501</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t67" href="#t67">67</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t68" href="#t68">68</a></span><span class="t"> <span class="nam">headers</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t69" href="#t69">69</a></span><span class="t"> <span class="str">"Authorization"</span><span class="op">:</span> <span class="str">"Bearer "</span> <span class="op">+</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">storage</span><span class="op">.</span><span class="nam">token</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t70" href="#t70">70</a></span><span class="t"> <span class="str">"Content-Type"</span><span class="op">:</span> <span class="str">"application/json"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t71" href="#t71">71</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t72" href="#t72">72</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t73" href="#t73">73</a></span><span class="t"> <span class="nam">data</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t74" href="#t74">74</a></span><span class="t"> <span class="str">"path"</span><span class="op">:</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">file_uid</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t75" href="#t75">75</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t76" href="#t76">76</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t77" href="#t77">77</a></span><span class="t"> <span class="nam">r</span> <span class="op">=</span> <span class="nam">requests</span><span class="op">.</span><span class="nam">post</span><span class="op">(</span><span class="nam">url</span><span class="op">,</span> <span class="nam">headers</span><span class="op">=</span><span class="nam">headers</span><span class="op">,</span> <span class="nam">data</span><span class="op">=</span><span class="nam">json</span><span class="op">.</span><span class="nam">dumps</span><span class="op">(</span><span class="nam">data</span><span class="op">)</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t78" href="#t78">78</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t79" href="#t79">79</a></span><span class="t"> <span class="key">return</span> <span class="nam">r</span><span class="op">.</span><span class="nam">json</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t80" href="#t80">80</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t81" href="#t81">81</a></span><span class="t"> <span class="op">@</span><span class="nam">staticmethod</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t82" href="#t82">82</a></span><span class="t"> <span class="key">def</span> <span class="nam">get_share_link</span><span class="op">(</span><span class="nam">recipe</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t83" href="#t83">83</a></span><span class="t"> <span class="nam">url</span> <span class="op">=</span> <span class="str">"https://api.dropboxapi.com/2/sharing/list_shared_links"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t84" href="#t84">84</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t85" href="#t85">85</a></span><span class="t"> <span class="nam">headers</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t86" href="#t86">86</a></span><span class="t"> <span class="str">"Authorization"</span><span class="op">:</span> <span class="str">"Bearer "</span> <span class="op">+</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">storage</span><span class="op">.</span><span class="nam">token</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t87" href="#t87">87</a></span><span class="t"> <span class="str">"Content-Type"</span><span class="op">:</span> <span class="str">"application/json"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t88" href="#t88">88</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t89" href="#t89">89</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t90" href="#t90">90</a></span><span class="t"> <span class="nam">data</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t91" href="#t91">91</a></span><span class="t"> <span class="str">"path"</span><span class="op">:</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">file_path</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t92" href="#t92">92</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t93" href="#t93">93</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t94" href="#t94">94</a></span><span class="t"> <span class="nam">r</span> <span class="op">=</span> <span class="nam">requests</span><span class="op">.</span><span class="nam">post</span><span class="op">(</span><span class="nam">url</span><span class="op">,</span> <span class="nam">headers</span><span class="op">=</span><span class="nam">headers</span><span class="op">,</span> <span class="nam">data</span><span class="op">=</span><span class="nam">json</span><span class="op">.</span><span class="nam">dumps</span><span class="op">(</span><span class="nam">data</span><span class="op">)</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t95" href="#t95">95</a></span><span class="t"> <span class="nam">p</span> <span class="op">=</span> <span class="nam">r</span><span class="op">.</span><span class="nam">json</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t96" href="#t96">96</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t97" href="#t97">97</a></span><span class="t"> <span class="key">for</span> <span class="nam">link</span> <span class="key">in</span> <span class="nam">p</span><span class="op">[</span><span class="str">'links'</span><span class="op">]</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t98" href="#t98">98</a></span><span class="t"> <span class="key">return</span> <span class="nam">link</span><span class="op">[</span><span class="str">'url'</span><span class="op">]</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t99" href="#t99">99</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t100" href="#t100">100</a></span><span class="t"> <span class="nam">response</span> <span class="op">=</span> <span class="nam">Dropbox</span><span class="op">.</span><span class="nam">create_share_link</span><span class="op">(</span><span class="nam">recipe</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t101" href="#t101">101</a></span><span class="t"> <span class="key">return</span> <span class="nam">response</span><span class="op">[</span><span class="str">'url'</span><span class="op">]</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t102" href="#t102">102</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t103" href="#t103">103</a></span><span class="t"> <span class="op">@</span><span class="nam">staticmethod</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t104" href="#t104">104</a></span><span class="t"> <span class="key">def</span> <span class="nam">get_file</span><span class="op">(</span><span class="nam">recipe</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t105" href="#t105">105</a></span><span class="t"> <span class="key">if</span> <span class="key">not</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">link</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t106" href="#t106">106</a></span><span class="t"> <span class="nam">recipe</span><span class="op">.</span><span class="nam">link</span> <span class="op">=</span> <span class="nam">Dropbox</span><span class="op">.</span><span class="nam">get_share_link</span><span class="op">(</span><span class="nam">recipe</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t107" href="#t107">107</a></span><span class="t"> <span class="nam">recipe</span><span class="op">.</span><span class="nam">save</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t108" href="#t108">108</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t109" href="#t109">109</a></span><span class="t"> <span class="nam">url</span> <span class="op">=</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">link</span><span class="op">.</span><span class="nam">replace</span><span class="op">(</span><span class="str">'www.dropbox.'</span><span class="op">,</span> <span class="str">'dl.dropboxusercontent.'</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t110" href="#t110">110</a></span><span class="t"> <span class="key">if</span> <span class="nam">validators</span><span class="op">.</span><span class="nam">url</span><span class="op">(</span><span class="nam">url</span><span class="op">,</span> <span class="nam">public</span><span class="op">=</span><span class="key">True</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t111" href="#t111">111</a></span><span class="t"> <span class="nam">response</span> <span class="op">=</span> <span class="nam">requests</span><span class="op">.</span><span class="nam">get</span><span class="op">(</span><span class="nam">url</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t112" href="#t112">112</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t113" href="#t113">113</a></span><span class="t"> <span class="key">return</span> <span class="nam">io</span><span class="op">.</span><span class="nam">BytesIO</span><span class="op">(</span><span class="nam">response</span><span class="op">.</span><span class="nam">content</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t114" href="#t114">114</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t115" href="#t115">115</a></span><span class="t"> <span class="op">@</span><span class="nam">staticmethod</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t116" href="#t116">116</a></span><span class="t"> <span class="key">def</span> <span class="nam">rename_file</span><span class="op">(</span><span class="nam">recipe</span><span class="op">,</span> <span class="nam">new_name</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t117" href="#t117">117</a></span><span class="t"> <span class="nam">url</span> <span class="op">=</span> <span class="str">"https://api.dropboxapi.com/2/files/move_v2"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t118" href="#t118">118</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t119" href="#t119">119</a></span><span class="t"> <span class="nam">headers</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t120" href="#t120">120</a></span><span class="t"> <span class="str">"Authorization"</span><span class="op">:</span> <span class="str">"Bearer "</span> <span class="op">+</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">storage</span><span class="op">.</span><span class="nam">token</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t121" href="#t121">121</a></span><span class="t"> <span class="str">"Content-Type"</span><span class="op">:</span> <span class="str">"application/json"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t122" href="#t122">122</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t123" href="#t123">123</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t124" href="#t124">124</a></span><span class="t"> <span class="nam">data</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t125" href="#t125">125</a></span><span class="t"> <span class="str">"from_path"</span><span class="op">:</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">file_path</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t126" href="#t126">126</a></span><span class="t"> <span class="str">"to_path"</span><span class="op">:</span> <span class="str">"%s/%s%s"</span> <span class="op">%</span> <span class="op">(</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t127" href="#t127">127</a></span><span class="t"> <span class="nam">os</span><span class="op">.</span><span class="nam">path</span><span class="op">.</span><span class="nam">dirname</span><span class="op">(</span><span class="nam">recipe</span><span class="op">.</span><span class="nam">file_path</span><span class="op">)</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t128" href="#t128">128</a></span><span class="t"> <span class="nam">new_name</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t129" href="#t129">129</a></span><span class="t"> <span class="nam">os</span><span class="op">.</span><span class="nam">path</span><span class="op">.</span><span class="nam">splitext</span><span class="op">(</span><span class="nam">recipe</span><span class="op">.</span><span class="nam">file_path</span><span class="op">)</span><span class="op">[</span><span class="num">1</span><span class="op">]</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t130" href="#t130">130</a></span><span class="t"> <span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t131" href="#t131">131</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t132" href="#t132">132</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t133" href="#t133">133</a></span><span class="t"> <span class="nam">r</span> <span class="op">=</span> <span class="nam">requests</span><span class="op">.</span><span class="nam">post</span><span class="op">(</span><span class="nam">url</span><span class="op">,</span> <span class="nam">headers</span><span class="op">=</span><span class="nam">headers</span><span class="op">,</span> <span class="nam">data</span><span class="op">=</span><span class="nam">json</span><span class="op">.</span><span class="nam">dumps</span><span class="op">(</span><span class="nam">data</span><span class="op">)</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t134" href="#t134">134</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t135" href="#t135">135</a></span><span class="t"> <span class="key">return</span> <span class="nam">r</span><span class="op">.</span><span class="nam">json</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t136" href="#t136">136</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t137" href="#t137">137</a></span><span class="t"> <span class="op">@</span><span class="nam">staticmethod</span>&nbsp;</span><span class="r"></span></p>
<p class="run"><span class="n"><a id="t138" href="#t138">138</a></span><span class="t"> <span class="key">def</span> <span class="nam">delete_file</span><span class="op">(</span><span class="nam">recipe</span><span class="op">)</span><span class="op">:</span>&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t139" href="#t139">139</a></span><span class="t"> <span class="nam">url</span> <span class="op">=</span> <span class="str">"https://api.dropboxapi.com/2/files/delete_v2"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t140" href="#t140">140</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t141" href="#t141">141</a></span><span class="t"> <span class="nam">headers</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t142" href="#t142">142</a></span><span class="t"> <span class="str">"Authorization"</span><span class="op">:</span> <span class="str">"Bearer "</span> <span class="op">+</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">storage</span><span class="op">.</span><span class="nam">token</span><span class="op">,</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t143" href="#t143">143</a></span><span class="t"> <span class="str">"Content-Type"</span><span class="op">:</span> <span class="str">"application/json"</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t144" href="#t144">144</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t145" href="#t145">145</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t146" href="#t146">146</a></span><span class="t"> <span class="nam">data</span> <span class="op">=</span> <span class="op">{</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t147" href="#t147">147</a></span><span class="t"> <span class="str">"path"</span><span class="op">:</span> <span class="nam">recipe</span><span class="op">.</span><span class="nam">file_path</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t148" href="#t148">148</a></span><span class="t"> <span class="op">}</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t149" href="#t149">149</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t150" href="#t150">150</a></span><span class="t"> <span class="nam">r</span> <span class="op">=</span> <span class="nam">requests</span><span class="op">.</span><span class="nam">post</span><span class="op">(</span><span class="nam">url</span><span class="op">,</span> <span class="nam">headers</span><span class="op">=</span><span class="nam">headers</span><span class="op">,</span> <span class="nam">data</span><span class="op">=</span><span class="nam">json</span><span class="op">.</span><span class="nam">dumps</span><span class="op">(</span><span class="nam">data</span><span class="op">)</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
<p class="pln"><span class="n"><a id="t151" href="#t151">151</a></span><span class="t">&nbsp;</span><span class="r"></span></p>
<p class="mis show_mis"><span class="n"><a id="t152" href="#t152">152</a></span><span class="t"> <span class="key">return</span> <span class="nam">r</span><span class="op">.</span><span class="nam">json</span><span class="op">(</span><span class="op">)</span>&nbsp;</span><span class="r"></span></p>
</main>
<footer>
<div class="content">
<p>
<a id="prevFileLink" class="nav" href="d_a167ab5b5108d61e_models_py.html">&#xab; prev</a> &nbsp; &nbsp;
<a id="indexLink" class="nav" href="index.html">&Hat; index</a> &nbsp; &nbsp;
<a id="nextFileLink" class="nav" href="d_0b5495cf37ee6c4f_local_py.html">&#xbb; next</a>
&nbsp; &nbsp; &nbsp;
<a class="nav" href="https://coverage.readthedocs.io/en/7.4.0">coverage.py v7.4.0</a>,
created at 2023-12-28 15:03 +0100
</p>
</div>
</footer>
</body>
</html>

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