mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-28 12:39:36 -05:00
Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bfe5bb6a0 | ||
|
|
f6f6754669 | ||
|
|
752a25b1f8 | ||
|
|
8e8e4bb8bb | ||
|
|
06dc02d6b2 | ||
|
|
540daf2a38 | ||
|
|
9e671d93cb | ||
|
|
269be4e31a | ||
|
|
5969edc0b8 | ||
|
|
42176f42ed | ||
|
|
8c8bb159ea | ||
|
|
b9cf29a0ec | ||
|
|
5db9f33723 | ||
|
|
1ba09cc119 | ||
|
|
02bbe3fa13 | ||
|
|
0c77ca91c1 | ||
|
|
fbadf14b58 | ||
|
|
2558fe6c2b | ||
|
|
10fca9b5ae | ||
|
|
01f338e58b | ||
|
|
5e998796ab | ||
|
|
40231f45f6 | ||
|
|
b19b51c275 | ||
|
|
854877e685 | ||
|
|
028a8ddbda | ||
|
|
abb81209af | ||
|
|
578201c519 | ||
|
|
a5a23b366e | ||
|
|
ac57837f53 | ||
|
|
5cdc8302bb | ||
|
|
b095718545 | ||
|
|
0b53285b89 | ||
|
|
d1ea4360ca | ||
|
|
257372db5a | ||
|
|
855f20f2da | ||
|
|
4f73e57ab2 | ||
|
|
1b529bba10 | ||
|
|
072cd00e59 | ||
|
|
4b03c1a8dd | ||
|
|
f614413fb1 | ||
|
|
74153d79b8 | ||
|
|
edf06944e0 | ||
|
|
a02582e9f8 | ||
|
|
c4ff29beda | ||
|
|
cc839a1ae9 | ||
|
|
af9a95651d | ||
|
|
45106ae99a | ||
|
|
0ac3c55180 | ||
|
|
a289fd427b | ||
|
|
6385866e98 | ||
|
|
c2176305db | ||
|
|
fd3760198b | ||
|
|
826ddddd5d | ||
|
|
93613c9781 | ||
|
|
818ca0b2e4 | ||
|
|
17d34c5ca7 | ||
|
|
965f7c04d8 | ||
|
|
0e1e312f7c | ||
|
|
202d8afa10 | ||
|
|
769561349f | ||
|
|
0cb415f70d | ||
|
|
6d4dbc26a4 | ||
|
|
63759c985f | ||
|
|
98d4ce5ff8 | ||
|
|
2cf9c288be | ||
|
|
9254a36636 | ||
|
|
3f258fbd87 | ||
|
|
7100f4eb8b | ||
|
|
7285d8b01c | ||
|
|
78da168a2e | ||
|
|
f54009ae00 | ||
|
|
f92f18ecb2 | ||
|
|
44832f7c11 | ||
|
|
e475115b6a | ||
|
|
b32a7a9134 | ||
|
|
381adb2a5b | ||
|
|
d976fafd45 | ||
|
|
7e7657e444 | ||
|
|
4b551c595a | ||
|
|
c87964f6d0 | ||
|
|
353b2d6d21 | ||
|
|
1d58f318ca | ||
|
|
771375b40b | ||
|
|
1e4f6d243c | ||
|
|
717c989ae9 | ||
|
|
4933726b79 | ||
|
|
d81e7f6c2b | ||
|
|
f1ab60bcd1 | ||
|
|
68e6ab4be5 | ||
|
|
7ea9592398 | ||
|
|
52d8e8d9ba | ||
|
|
b7e23df4c2 | ||
|
|
fce57f7f03 | ||
|
|
7eaa33ac69 | ||
|
|
7ce2c042c3 | ||
|
|
371d8a76b8 | ||
|
|
7f63451b9a | ||
|
|
ee8237d493 | ||
|
|
91d3c43422 | ||
|
|
1700b9c531 | ||
|
|
7133249f4b | ||
|
|
ab9ea87549 | ||
|
|
975ad34672 | ||
|
|
6cb6861228 | ||
|
|
cc24964368 | ||
|
|
714242b6df | ||
|
|
c33c4ed79b | ||
|
|
971f8f3dad | ||
|
|
ca5fda7927 | ||
|
|
2e6cc3e58e | ||
|
|
42b7d1afcb | ||
|
|
99868e4e80 | ||
|
|
38b22f3a56 | ||
|
|
b70a3bce9f | ||
|
|
fb821ba0ef | ||
|
|
f14acc371d | ||
|
|
c031db9019 | ||
|
|
78686faad3 | ||
|
|
b9e806b818 | ||
|
|
d7669279ff | ||
|
|
4293ec77c0 | ||
|
|
0a30c39add | ||
|
|
1b6449270b | ||
|
|
3ae264eea7 | ||
|
|
c9dc7164f5 | ||
|
|
5751ba1ec5 | ||
|
|
3eca8c6db4 | ||
|
|
5cccbb8e5c | ||
|
|
4390703c0c | ||
|
|
47bc3cfbe7 | ||
|
|
30a2012e90 | ||
|
|
b38ea866b4 | ||
|
|
5a0b9e14d2 | ||
|
|
4f0f59a55c | ||
|
|
768b678c93 | ||
|
|
10373b6ac5 | ||
|
|
4e0780d512 | ||
|
|
959ad2a45c | ||
|
|
94045905d3 | ||
|
|
ad8d8daf79 | ||
|
|
b623abf81e | ||
|
|
f8b8d3f199 | ||
|
|
be388b0d10 | ||
|
|
41a448578a | ||
|
|
441c55936d | ||
|
|
b67281bbc8 | ||
|
|
5a1a5f3c4d | ||
|
|
30e2fc4895 | ||
|
|
57304f9c6c | ||
|
|
87327b0959 | ||
|
|
7957413ca0 | ||
|
|
d99a157416 | ||
|
|
20b812c2cc | ||
|
|
7bfa23b953 | ||
|
|
ae37abf8b2 | ||
|
|
5f211e420e | ||
|
|
1235cb8da5 | ||
|
|
5e3a5eb8f5 | ||
|
|
b6a42e8e81 | ||
|
|
49539ef3ba | ||
|
|
db310c4076 | ||
|
|
0248e1c500 | ||
|
|
db04386997 | ||
|
|
54f0b2b036 | ||
|
|
33b23b299d | ||
|
|
a047613edb | ||
|
|
149cf93618 | ||
|
|
20e0c948c4 | ||
|
|
ceb68af503 | ||
|
|
d8c86a4bb8 | ||
|
|
a9dcc7261c | ||
|
|
341c6abc02 | ||
|
|
5c2d92103b | ||
|
|
7b9bd5bc2a | ||
|
|
e242412ec4 | ||
|
|
6aaec29c8a | ||
|
|
854af133c4 | ||
|
|
ac961ef7d2 | ||
|
|
b6f3ed6bd9 | ||
|
|
ccf56e24be | ||
|
|
5298b69d83 | ||
|
|
f2f004db87 | ||
|
|
9416406732 | ||
|
|
eeae2c1740 | ||
|
|
45d3fd34be | ||
|
|
bd61906aa4 | ||
|
|
c322782e89 | ||
|
|
2e6becb73d | ||
|
|
d2aeef7e63 | ||
|
|
8e700ba53c | ||
|
|
2f203d7786 | ||
|
|
2d021a83cf | ||
|
|
dda2cc16e7 | ||
|
|
07957814f9 | ||
|
|
658bc5ca54 | ||
|
|
539eb8e612 | ||
|
|
ba54a44e04 | ||
|
|
5ecddaf02f | ||
|
|
a6c0dba684 | ||
|
|
7986d9c8f3 | ||
|
|
02523f5325 | ||
|
|
36887b3488 | ||
|
|
bb77f80abf | ||
|
|
9c92e0f4c0 | ||
|
|
a6e8fa8ddf | ||
|
|
37fb0418ac | ||
|
|
2264050d40 | ||
|
|
aebc4a45ff | ||
|
|
f061e02a95 | ||
|
|
952d50d8dd | ||
|
|
3489216daf | ||
|
|
8e9285a24e | ||
|
|
8f55e15767 | ||
|
|
f2ce164a1e | ||
|
|
bfdd5a8bfc | ||
|
|
cf61de0dba | ||
|
|
29a937c44d |
@@ -6,6 +6,9 @@
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
SECRET_KEY=
|
||||
|
||||
# allowed hosts (see documentation), should be set to your hostname(s) but might be * (default) for some proxies/providers
|
||||
# ALLOWED_HOSTS=recipes.mydomain.com
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
|
||||
22
.flake8
Normal file
22
.flake8
Normal file
@@ -0,0 +1,22 @@
|
||||
[flake8]
|
||||
extend-ignore =
|
||||
# Whitespace before ':' - Required for black compatibility
|
||||
E203,
|
||||
# Line break occurred before a binary operator - Required for black compatibility
|
||||
W503,
|
||||
# Comparison to False should be 'if cond is False:' or 'if not cond:'
|
||||
E712
|
||||
exclude =
|
||||
.git,
|
||||
**/__pycache__,
|
||||
**/.git,
|
||||
**/.svn,
|
||||
**/.hg,
|
||||
**/CVS,
|
||||
**/.DS_Store,
|
||||
.vscode,
|
||||
**/*.pyc
|
||||
per-file-ignores=
|
||||
cookbook/apps.py:F401
|
||||
max-line-length = 179
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.4.1
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.4.2
|
||||
with:
|
||||
packages: libsasl2-dev python3-dev libldap2-dev libssl-dev
|
||||
version: 1.0
|
||||
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -47,6 +47,11 @@ docs/reports/**
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
mediafiles/
|
||||
*.sqlite3*
|
||||
staticfiles/
|
||||
postgresql/
|
||||
data/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
@@ -59,32 +64,23 @@ target/
|
||||
|
||||
\.idea/dataSources\.local\.xml
|
||||
|
||||
venv/
|
||||
|
||||
mediafiles/
|
||||
|
||||
*.sqlite3*
|
||||
|
||||
\.idea/workspace\.xml
|
||||
|
||||
\.idea/misc\.xml
|
||||
|
||||
# Deployment
|
||||
|
||||
\.env
|
||||
staticfiles/
|
||||
postgresql/
|
||||
data/
|
||||
|
||||
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
plugins
|
||||
vetur.config.js
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
vue3/node_modules
|
||||
cookbook/templates/sw.js
|
||||
.prettierignore
|
||||
vue/.yarn
|
||||
vue3/.vite
|
||||
vue3/node_modules
|
||||
|
||||
# Configs
|
||||
vetur.config.js
|
||||
venv/
|
||||
|
||||
31
.idea/watcherTasks.xml
generated
Normal file
31
.idea/watcherTasks.xml
generated
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions">
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="-m flake8 $FilePath$ --config $ContentRoot$\.flake8" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
<option name="exitCodeBehavior" value="ALWAYS" />
|
||||
<option name="fileExtension" value="py" />
|
||||
<option name="immediateSync" value="false" />
|
||||
<option name="name" value="Flake8 Watcher" />
|
||||
<option name="output" value="$FilePath$" />
|
||||
<option name="outputFilters">
|
||||
<array>
|
||||
<FilterInfo>
|
||||
<option name="description" value="" />
|
||||
<option name="name" value="" />
|
||||
<option name="regExp" value="$FILE_PATH$:$LINE$:$COLUMN$: $MESSAGE$" />
|
||||
</FilterInfo>
|
||||
</array>
|
||||
</option>
|
||||
<option name="outputFromStdout" value="false" />
|
||||
<option name="program" value="$PyInterpreterDirectory$/python" />
|
||||
<option name="runOnExternalChanges" value="false" />
|
||||
<option name="scopeName" value="Current File" />
|
||||
<option name="trackOnlyRoot" value="false" />
|
||||
<option name="workingDir" value="" />
|
||||
<envs />
|
||||
</TaskOptions>
|
||||
</component>
|
||||
</project>
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# generated files
|
||||
api.ts
|
||||
vue/src/apps/*.js
|
||||
vue/node_modules
|
||||
staticfiles/
|
||||
docs/reports/
|
||||
/vue3/src/openapi/
|
||||
|
||||
# ignored files - prettier interferes with django templates and github actions
|
||||
*.html
|
||||
*.yml
|
||||
*.yaml
|
||||
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 179,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"experimentalTernaries": true
|
||||
}
|
||||
21
.vscode/launch.json
vendored
21
.vscode/launch.json
vendored
@@ -4,7 +4,6 @@
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
@@ -13,6 +12,22 @@
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Python: Debug Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"purpose": [
|
||||
"debug-test"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
// coverage and pytest can't both be running at the same time
|
||||
"PYTEST_ADDOPTS": "--no-cov"
|
||||
},
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -3,5 +3,12 @@
|
||||
"cookbook/tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
"python.testing.pytestEnabled": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "eeyore.yapf",
|
||||
},
|
||||
"yapf.args": [],
|
||||
"isort.args": []
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg li
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
ENV DOCKER true
|
||||
|
||||
#This port will be used by gunicorn.
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Because of that there are several ways you can support us
|
||||
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/#contributing-code) **BEFORE** contributing anything!
|
||||
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/guidelines/) **BEFORE** contributing anything!
|
||||
|
||||
## Your Feedback
|
||||
|
||||
|
||||
11
boot.sh
11
boot.sh
@@ -67,7 +67,7 @@ echo "Migrating database"
|
||||
|
||||
python manage.py migrate
|
||||
|
||||
echo "Generating static files"
|
||||
echo "Collecting static files, this may take a while..."
|
||||
|
||||
python manage.py collectstatic_js_reverse
|
||||
python manage.py collectstatic --noinput
|
||||
@@ -76,4 +76,11 @@ echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
|
||||
# Check if IPv6 is enabled, only then run gunicorn with ipv6 support
|
||||
if [ "$ipv6_disable" -eq 0 ]; then
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
else
|
||||
exec gunicorn -b ":$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
fi
|
||||
@@ -185,7 +185,7 @@ class StepAdmin(admin.ModelAdmin):
|
||||
@admin.display(description="Name")
|
||||
def recipe_and_name(obj):
|
||||
if not obj.recipe_set.exists():
|
||||
return f"Orphaned Step{'':s if not obj.name else f': {obj.name}'}"
|
||||
return "Orphaned Step" + ('' if not obj.name else f': {obj.name}')
|
||||
return f"{obj.recipe_set.first().name}: {obj.name}" if obj.name else obj.recipe_set.first().name
|
||||
|
||||
|
||||
@@ -376,10 +376,17 @@ class ShareLinkAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
@admin.action(description='Delete all properties with type')
|
||||
def delete_properties_with_type(modeladmin, request, queryset):
|
||||
for pt in queryset:
|
||||
Property.objects.filter(property_type=pt).delete()
|
||||
|
||||
|
||||
class PropertyTypeAdmin(admin.ModelAdmin):
|
||||
search_fields = ('space',)
|
||||
search_fields = ('name',)
|
||||
|
||||
list_display = ('id', 'space', 'name', 'fdc_id')
|
||||
actions = [delete_properties_with_type]
|
||||
|
||||
|
||||
admin.site.register(PropertyType, PropertyTypeAdmin)
|
||||
|
||||
@@ -5,6 +5,7 @@ import threading
|
||||
from asyncio import Task
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from logging import Logger
|
||||
from types import UnionType
|
||||
from typing import List, Any, Dict, Optional, Type
|
||||
|
||||
@@ -39,10 +40,12 @@ class Work:
|
||||
# 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:
|
||||
_logger: Logger
|
||||
_queue: queue.Queue
|
||||
_listening_to_classes = REGISTERED_CLASSES | ConnectorConfig
|
||||
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger("recipes.connector")
|
||||
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()
|
||||
@@ -65,7 +68,7 @@ class ConnectorManager:
|
||||
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)}")
|
||||
self._logger.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
|
||||
return
|
||||
|
||||
def stop(self):
|
||||
@@ -74,10 +77,12 @@ class ConnectorManager:
|
||||
|
||||
@staticmethod
|
||||
def worker(worker_id: int, worker_queue: queue.Queue):
|
||||
logger = logging.getLogger("recipes.connector.worker")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
logging.info(f"started ConnectionManager worker {worker_id}")
|
||||
logger.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()
|
||||
@@ -91,6 +96,8 @@ class ConnectorManager:
|
||||
if item is None:
|
||||
break
|
||||
|
||||
logger.debug(f"received {item.instance=} with {item.actionType=}")
|
||||
|
||||
# If a Connector was changed/updated, refresh connector from the database for said space
|
||||
refresh_connector_cache = isinstance(item.instance, ConnectorConfig)
|
||||
|
||||
@@ -111,7 +118,7 @@ class ConnectorManager:
|
||||
try:
|
||||
connector: Optional[Connector] = ConnectorManager.get_connected_for_config(config)
|
||||
except BaseException:
|
||||
logging.exception(f"failed to initialize {config.name}")
|
||||
logger.exception(f"failed to initialize {config.name}")
|
||||
continue
|
||||
|
||||
if connector is not None:
|
||||
@@ -123,10 +130,12 @@ class ConnectorManager:
|
||||
worker_queue.task_done()
|
||||
continue
|
||||
|
||||
logger.debug(f"running {len(connectors)} connectors for {item.instance=} with {item.actionType=}")
|
||||
|
||||
loop.run_until_complete(run_connectors(connectors, space, item.instance, item.actionType))
|
||||
worker_queue.task_done()
|
||||
|
||||
logging.info(f"terminating ConnectionManager worker {worker_id}")
|
||||
logger.info(f"terminating ConnectionManager worker {worker_id}")
|
||||
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
from typing import Dict, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from homeassistant_api import Client, HomeassistantAPIError, Domain
|
||||
from aiohttp import ClientError, request
|
||||
|
||||
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._logger = logging.getLogger(f"recipes.connector.homeassistant.{config.name}")
|
||||
|
||||
if config.url[-1] != "/":
|
||||
config.url += "/"
|
||||
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 homeassistant_api_call(self, method: str, path: str, data: Dict) -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._config.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
async with request(method, urljoin(self._config.url, path), headers=headers, json=data) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
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:
|
||||
@@ -28,16 +38,18 @@ class HomeAssistant(Connector):
|
||||
|
||||
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
|
||||
self._logger.debug(f"adding {item=}")
|
||||
|
||||
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)=}")
|
||||
data = {
|
||||
"entity_id": self._config.todo_entity,
|
||||
"item": item,
|
||||
"description": description,
|
||||
}
|
||||
|
||||
try:
|
||||
await self.homeassistant_api_call("POST", "services/todo/add_item", data)
|
||||
except ClientError as err:
|
||||
self._logger.warning(f"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:
|
||||
@@ -48,24 +60,31 @@ class HomeAssistant(Connector):
|
||||
if not self._config.on_shopping_list_entry_deleted_enabled:
|
||||
return
|
||||
|
||||
item, description = _format_shopping_list_entry(shopping_list_entry)
|
||||
if not hasattr(shopping_list_entry._state.fields_cache, "food"):
|
||||
# Sometimes the food foreign key is not loaded, and we cant load it from an async process
|
||||
self._logger.debug("required property was not present in ShoppingListEntry")
|
||||
return
|
||||
|
||||
item, _ = _format_shopping_list_entry(shopping_list_entry)
|
||||
|
||||
self._logger.debug(f"removing {item=}")
|
||||
|
||||
data = {
|
||||
"entity_id": self._config.todo_entity,
|
||||
"item": item,
|
||||
}
|
||||
|
||||
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)=}")
|
||||
await self.homeassistant_api_call("POST", "services/todo/remove_item", data)
|
||||
except ClientError as err:
|
||||
# This error will always trigger if the item is not present/found
|
||||
self._logger.debug(f"received an exception from the api: {err=}, {type(err)=}")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.async_cache_session.close()
|
||||
pass
|
||||
|
||||
|
||||
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
|
||||
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry) -> Tuple[str, str]:
|
||||
item = shopping_list_entry.food.name
|
||||
if shopping_list_entry.amount > 0:
|
||||
item += f" ({shopping_list_entry.amount:.2f}".rstrip('0').rstrip('.')
|
||||
@@ -76,10 +95,10 @@ def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
|
||||
else:
|
||||
item += ")"
|
||||
|
||||
description = "Imported by TandoorRecipes"
|
||||
description = "From 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}"
|
||||
description += f", by {shopping_list_entry.created_by.first_name}"
|
||||
else:
|
||||
description += f", created by {shopping_list_entry.created_by.username}"
|
||||
description += f", by {shopping_list_entry.created_by.username}"
|
||||
|
||||
return item, description
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.test.runner import DiscoverRunner
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
class CustomTestRunner(DiscoverRunner):
|
||||
def run_tests(self, *args, **kwargs):
|
||||
with scopes_disabled():
|
||||
return super().run_tests(*args, **kwargs)
|
||||
@@ -1,4 +1,12 @@
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.db.models import Func
|
||||
from ipaddress import ip_address
|
||||
|
||||
from recipes import settings
|
||||
|
||||
|
||||
class Round(Func):
|
||||
@@ -11,3 +19,30 @@ def str2bool(v):
|
||||
return v
|
||||
else:
|
||||
return v.lower() in ("yes", "true", "1")
|
||||
|
||||
|
||||
"""
|
||||
validates an url that is supposed to be imported
|
||||
checks that the protocol used is http(s) and that no local address is accessed
|
||||
@:param url to test
|
||||
@:return true if url is valid, false otherwise
|
||||
"""
|
||||
|
||||
|
||||
def validate_import_url(url):
|
||||
try:
|
||||
validator = URLValidator(schemes=['http', 'https'])
|
||||
validator(url)
|
||||
except ValidationError:
|
||||
# if schema is not http or https, consider url invalid
|
||||
return False
|
||||
|
||||
# resolve IP address of url
|
||||
try:
|
||||
url_ip_address = ip_address(str(socket.gethostbyname(urlparse(url).hostname)))
|
||||
except (ValueError, AttributeError, TypeError, Exception) as e:
|
||||
# if ip cannot be parsed, consider url invalid
|
||||
return False
|
||||
|
||||
# validate that IP is neither private nor any other special address
|
||||
return not any([url_ip_address.is_private, url_ip_address.is_reserved, url_ip_address.is_loopback, url_ip_address.is_multicast, url_ip_address.is_link_local, ])
|
||||
|
||||
@@ -71,7 +71,7 @@ class FoodPropertyHelper:
|
||||
# TODO move to central helper ? --> use defaultdict
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d:
|
||||
if key in d and d[key]['value']:
|
||||
d[key]['value'] += value
|
||||
else:
|
||||
d[key] = {'id': food.id, 'food': food.name, 'value': value}
|
||||
|
||||
@@ -15,12 +15,9 @@ from cookbook.models import Automation, Keyword, PropertyType
|
||||
|
||||
|
||||
def get_from_scraper(scrape, request):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
# converting the scrape_html object to the existing json format based on ld+json
|
||||
|
||||
recipe_json = {
|
||||
'steps': [],
|
||||
'internal': True
|
||||
}
|
||||
recipe_json = {'steps': [], 'internal': True}
|
||||
keywords = []
|
||||
|
||||
# assign source URL
|
||||
@@ -157,11 +154,18 @@ def get_from_scraper(scrape, request):
|
||||
# assign steps
|
||||
try:
|
||||
for i in parse_instructions(scrape.instructions()):
|
||||
recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients, })
|
||||
recipe_json['steps'].append({
|
||||
'instruction': i,
|
||||
'ingredients': [],
|
||||
'show_ingredients_table': request.user.userpreference.show_step_ingredients,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
recipe_json['steps'].append({
|
||||
'instruction': '',
|
||||
'ingredients': [],
|
||||
})
|
||||
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
@@ -182,20 +186,20 @@ def get_from_scraper(scrape, request):
|
||||
'original_text': x
|
||||
}
|
||||
if unit:
|
||||
ingredient['unit'] = {'name': unit, }
|
||||
ingredient['unit'] = {
|
||||
'name': unit,
|
||||
}
|
||||
recipe_json['steps'][0]['ingredients'].append(ingredient)
|
||||
except Exception:
|
||||
recipe_json['steps'][0]['ingredients'].append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': None,
|
||||
'food': {
|
||||
'name': x,
|
||||
},
|
||||
'note': '',
|
||||
'original_text': x
|
||||
}
|
||||
)
|
||||
recipe_json['steps'][0]['ingredients'].append({
|
||||
'amount': 0,
|
||||
'unit': None,
|
||||
'food': {
|
||||
'name': x,
|
||||
},
|
||||
'note': '',
|
||||
'original_text': x
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -248,14 +252,16 @@ def get_from_youtube_scraper(url, request):
|
||||
'working_time': 0,
|
||||
'waiting_time': 0,
|
||||
'image': "",
|
||||
'keywords': [{'name': kw.name, 'label': kw.name, 'id': kw.pk}],
|
||||
'keywords': [{
|
||||
'name': kw.name,
|
||||
'label': kw.name,
|
||||
'id': kw.pk
|
||||
}],
|
||||
'source_url': url,
|
||||
'steps': [
|
||||
{
|
||||
'ingredients': [],
|
||||
'instruction': ''
|
||||
}
|
||||
]
|
||||
'steps': [{
|
||||
'ingredients': [],
|
||||
'instruction': ''
|
||||
}]
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -367,8 +373,8 @@ def parse_servings(servings):
|
||||
servings = 1
|
||||
elif isinstance(servings, list):
|
||||
try:
|
||||
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
||||
except KeyError:
|
||||
servings = int(re.findall(r'\b\d+\b', str(servings[0]))[0])
|
||||
except (KeyError, IndexError):
|
||||
servings = 1
|
||||
return servings
|
||||
|
||||
@@ -452,10 +458,7 @@ def normalize_string(string):
|
||||
|
||||
|
||||
def iso_duration_to_minutes(string):
|
||||
match = re.match(
|
||||
r'P((?P<years>\d+)Y)?((?P<months>\d+)M)?((?P<weeks>\d+)W)?((?P<days>\d+)D)?T((?P<hours>\d+)H)?((?P<minutes>\d+)M)?((?P<seconds>\d+)S)?',
|
||||
string
|
||||
).groupdict()
|
||||
match = re.match(r'P((?P<years>\d+)Y)?((?P<months>\d+)M)?((?P<weeks>\d+)W)?((?P<days>\d+)D)?T((?P<hours>\d+)H)?((?P<minutes>\d+)M)?((?P<seconds>\d+)S)?', string).groupdict()
|
||||
return int(match['days'] or 0) * 24 * 60 + int(match['hours'] or 0) * 60 + int(match['minutes'] or 0)
|
||||
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import json
|
||||
from recipe_scrapers._abstract import AbstractScraper
|
||||
|
||||
|
||||
class CooksIllustrated(AbstractScraper):
|
||||
@classmethod
|
||||
def host(cls, site='cooksillustrated'):
|
||||
return {
|
||||
'cooksillustrated': f"{site}.com",
|
||||
'americastestkitchen': f"{site}.com",
|
||||
'cookscountry': f"{site}.com",
|
||||
}.get(site)
|
||||
|
||||
def title(self):
|
||||
return self.schema.title()
|
||||
|
||||
def image(self):
|
||||
return self.schema.image()
|
||||
|
||||
def total_time(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
return self.recipe['recipeTimeNote']
|
||||
|
||||
def yields(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
return self.recipe['yields']
|
||||
|
||||
def ingredients(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
ingredients = []
|
||||
for group in self.recipe['ingredientGroups']:
|
||||
ingredients += group['fields']['recipeIngredientItems']
|
||||
return [
|
||||
"{} {} {}{}".format(
|
||||
i['fields']['qty'] or '',
|
||||
i['fields']['measurement'] or '',
|
||||
i['fields']['ingredient']['fields']['title'] or '',
|
||||
i['fields']['postText'] or ''
|
||||
)
|
||||
for i in ingredients
|
||||
]
|
||||
|
||||
def instructions(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
if self.recipe.get('headnote', False):
|
||||
i = ['Note: ' + self.recipe.get('headnote', '')]
|
||||
else:
|
||||
i = []
|
||||
return "\n".join(
|
||||
i
|
||||
+ [self.recipe.get('whyThisWorks', '')]
|
||||
+ [
|
||||
instruction['fields']['content']
|
||||
for instruction in self.recipe['instructions']
|
||||
]
|
||||
)
|
||||
|
||||
def nutrients(self):
|
||||
raise NotImplementedError("This should be implemented.")
|
||||
|
||||
def get_recipe(self):
|
||||
j = json.loads(self.soup.find(type='application/json').string)
|
||||
name = list(j['props']['initialState']['content']['documents'])[0]
|
||||
self.recipe = j['props']['initialState']['content']['documents'][name]
|
||||
@@ -1,43 +0,0 @@
|
||||
from json import JSONDecodeError
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from recipe_scrapers import SCRAPERS, get_host_name
|
||||
from recipe_scrapers._factory import SchemaScraperFactory
|
||||
from recipe_scrapers._schemaorg import SchemaOrg
|
||||
|
||||
from .cooksillustrated import CooksIllustrated
|
||||
|
||||
CUSTOM_SCRAPERS = {
|
||||
CooksIllustrated.host(site="cooksillustrated"): CooksIllustrated,
|
||||
CooksIllustrated.host(site="americastestkitchen"): CooksIllustrated,
|
||||
CooksIllustrated.host(site="cookscountry"): CooksIllustrated,
|
||||
}
|
||||
SCRAPERS.update(CUSTOM_SCRAPERS)
|
||||
|
||||
|
||||
def text_scraper(text, url=None):
|
||||
domain = None
|
||||
if url:
|
||||
domain = get_host_name(url)
|
||||
if domain in SCRAPERS:
|
||||
scraper_class = SCRAPERS[domain]
|
||||
else:
|
||||
scraper_class = SchemaScraperFactory.SchemaScraper
|
||||
|
||||
class TextScraper(scraper_class):
|
||||
def __init__(
|
||||
self,
|
||||
html=None,
|
||||
url=None,
|
||||
):
|
||||
self.wild_mode = False
|
||||
self.meta_http_equiv = False
|
||||
self.soup = BeautifulSoup(html, "html.parser")
|
||||
self.url = url
|
||||
self.recipe = None
|
||||
try:
|
||||
self.schema = SchemaOrg(html)
|
||||
except (JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
return TextScraper(url=url, html=text)
|
||||
@@ -20,6 +20,7 @@ CONVERSION_TABLE = {
|
||||
'gallon': 0.264172,
|
||||
'tbsp': 67.628,
|
||||
'tsp': 202.884,
|
||||
'us_cup': 4.22675,
|
||||
'imperial_fluid_ounce': 35.1951,
|
||||
'imperial_pint': 1.75975,
|
||||
'imperial_quart': 0.879877,
|
||||
|
||||
@@ -2,12 +2,12 @@ import re
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
|
||||
iso_duration_to_minutes)
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from recipe_scrapers import scrape_html
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
@@ -20,7 +20,7 @@ class CookBookApp(Integration):
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_html = file.getvalue().decode("utf-8")
|
||||
|
||||
scrape = text_scraper(text=recipe_html)
|
||||
scrape = scrape_html(html=recipe_html, org_url="https://cookbookapp.import", supported_only=False)
|
||||
recipe_json = get_from_scraper(scrape, self.request)
|
||||
images = list(dict.fromkeys(get_images_from_soup(scrape.soup, None)))
|
||||
|
||||
@@ -63,7 +63,7 @@ class CookBookApp(Integration):
|
||||
if len(images) > 0:
|
||||
try:
|
||||
url = images[0]
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -69,7 +69,7 @@ class Cookmate(Integration):
|
||||
if recipe_xml.find('imageurl') is not None:
|
||||
try:
|
||||
url = recipe_xml.find('imageurl').text.strip()
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
@@ -60,20 +60,20 @@ class NextcloudCookbook(Integration):
|
||||
step = Step.objects.create(
|
||||
instruction=s, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
if not ingredients_added:
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
|
||||
|
||||
ingredients_added = True
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
if ingredients_added == False:
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient)
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,
|
||||
))
|
||||
ingredients_added = True
|
||||
if ingredient.startswith('##'):
|
||||
subheader = ingredient.replace('##', '', 1)
|
||||
step.ingredients.add(Ingredient.objects.create(note=subheader, is_header=True, no_amount=True, space=self.request.space))
|
||||
else:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient)
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if 'nutrition' in recipe_json:
|
||||
|
||||
@@ -6,8 +6,8 @@ from gettext import gettext as _
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -87,7 +87,7 @@ class Paprika(Integration):
|
||||
try:
|
||||
if recipe_json.get("image_url", None):
|
||||
url = recipe_json.get("image_url", None)
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
@@ -18,32 +19,38 @@ class Plantoeat(Integration):
|
||||
tags = None
|
||||
ingredients = []
|
||||
directions = []
|
||||
description = ''
|
||||
fields = {}
|
||||
for line in file.replace('\r', '').split('\n'):
|
||||
if line.strip() != '':
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').replace('"', '').strip()
|
||||
fields['name'] = line.replace('Title:', '').replace('"', '').strip()
|
||||
if 'Description:' in line:
|
||||
description = line.replace('Description:', '').strip()
|
||||
if 'Source:' in line or 'Serves:' in line or 'Prep Time:' in line or 'Cook Time:' in line:
|
||||
directions.append(line.strip() + '\n')
|
||||
fields['description'] = line.replace('Description:', '').strip()
|
||||
if 'Serves:' in line:
|
||||
fields['servings'] = parse_servings(line.replace('Serves:', '').strip())
|
||||
if 'Source:' in line:
|
||||
fields['source_url'] = line.replace('Source:', '').strip()
|
||||
if 'Photo Url:' in line:
|
||||
image_url = line.replace('Photo Url:', '').strip()
|
||||
if 'Prep Time:' in line:
|
||||
fields['working_time'] = parse_time(line.replace('Prep Time:', '').strip())
|
||||
if 'Cook Time:' in line:
|
||||
fields['waiting_time'] = parse_time(line.replace('Cook Time:', '').strip())
|
||||
if 'Tags:' in line:
|
||||
tags = line.replace('Tags:', '').strip()
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Instructions:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip() + '\n')
|
||||
if 'Ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Directions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Ingredients:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip() + '\n')
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
recipe = Recipe.objects.create(**fields, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
@@ -68,7 +75,7 @@ class Plantoeat(Integration):
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
if validators.url(image_url, public=True):
|
||||
if validate_import_url(image_url):
|
||||
response = requests.get(image_url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
@@ -5,9 +5,10 @@ from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -125,7 +126,7 @@ class RecetteTek(Integration):
|
||||
else:
|
||||
if file['originalPicture'] != '':
|
||||
url = file['originalPicture']
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
if imghdr.what(BytesIO(response.content)) is not None:
|
||||
self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
|
||||
|
||||
@@ -2,8 +2,8 @@ import json
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -56,7 +56,7 @@ class RecipeSage(Integration):
|
||||
if len(file['image']) > 0:
|
||||
try:
|
||||
url = file['image'][0]
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-09 06:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0216_delete_shoppinglist'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='default_page',
|
||||
field=models.CharField(choices=[('SEARCH', 'Search'), ('PLAN', 'Meal-Plan'), ('BOOKS', 'Books'), ('SHOPPING', 'Shopping')], default='SEARCH', max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 4.2.11 on 2024-05-01 10:56
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def timezone_correction(apps, schema_editor):
|
||||
# when converting from date to datetime the field becomes timezone aware and defaults to 00:00:00 UTC
|
||||
# this will be converted to a local datetime on the client which, for all negative offset timezones,
|
||||
# would mean that the plan item shows one day prior to the previous planning date
|
||||
# by setting the time on the old entries to 11:00 this issue will be limited to a very small number of people (see https://en.wikipedia.org/wiki/Time_zone#/media/File:World_Time_Zones_Map.svg)
|
||||
# the server timezone could be used to guess zone of most of the clients but that would also not be perfect so this much simpler alternative is chosen
|
||||
with scopes_disabled():
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
meal_plans = MealPlan.objects.all()
|
||||
for mp in meal_plans:
|
||||
mp.from_date += timedelta(hours=12)
|
||||
mp.to_date += timedelta(hours=12)
|
||||
|
||||
MealPlan.objects.bulk_update(meal_plans, ['from_date', 'to_date'], batch_size=1000)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0217_alter_userpreference_default_page'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mealtype',
|
||||
name='time',
|
||||
field=models.TimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mealplan',
|
||||
name='from_date',
|
||||
field=models.DateTimeField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mealplan',
|
||||
name='to_date',
|
||||
field=models.DateTimeField(),
|
||||
),
|
||||
migrations.RunPython(timezone_correction)
|
||||
]
|
||||
@@ -417,7 +417,6 @@ class ConnectorConfig(models.Model, PermissionModelMixin):
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
|
||||
|
||||
class UserPreference(models.Model, PermissionModelMixin):
|
||||
# Themes
|
||||
BOOTSTRAP = 'BOOTSTRAP'
|
||||
@@ -449,11 +448,13 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
SEARCH = 'SEARCH'
|
||||
PLAN = 'PLAN'
|
||||
BOOKS = 'BOOKS'
|
||||
SHOPPING = 'SHOPPING'
|
||||
|
||||
PAGES = (
|
||||
(SEARCH, _('Search')),
|
||||
(PLAN, _('Meal-Plan')),
|
||||
(BOOKS, _('Books')),
|
||||
(SHOPPING, _('Shopping')),
|
||||
)
|
||||
|
||||
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
||||
@@ -1133,6 +1134,7 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
order = models.IntegerField(default=0)
|
||||
color = models.CharField(max_length=7, blank=True, null=True)
|
||||
time = models.TimeField(null=True, blank=True)
|
||||
default = models.BooleanField(default=False, blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
@@ -1156,8 +1158,8 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
|
||||
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
|
||||
note = models.TextField(blank=True)
|
||||
from_date = models.DateField()
|
||||
to_date = models.DateField()
|
||||
from_date = models.DateTimeField()
|
||||
to_date = models.DateTimeField()
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@@ -4,8 +4,8 @@ import os
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.models import Recipe, RecipeImport, SyncLog
|
||||
from cookbook.provider.provider import Provider
|
||||
|
||||
@@ -107,7 +107,7 @@ class Dropbox(Provider):
|
||||
recipe.save()
|
||||
|
||||
url = recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
|
||||
return io.BytesIO(response.content)
|
||||
|
||||
@@ -4,8 +4,9 @@ import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
import validators
|
||||
import webdav3.client as wc
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.models import Recipe, RecipeImport, SyncLog
|
||||
from cookbook.provider.provider import Provider
|
||||
from requests.auth import HTTPBasicAuth
|
||||
@@ -93,7 +94,7 @@ class Nextcloud(Provider):
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
r = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
|
||||
@@ -355,7 +355,7 @@ class MealTypeSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
list_serializer_class = SpaceFilterSerializer
|
||||
model = MealType
|
||||
fields = ('id', 'name', 'order', 'color', 'default', 'created_by')
|
||||
fields = ('id', 'name', 'order', 'time', 'color', 'default', 'created_by')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
@@ -1030,7 +1030,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
shared = UserSerializer(many=True, required=False, allow_null=True)
|
||||
shopping = serializers.SerializerMethodField('in_shopping')
|
||||
|
||||
to_date = serializers.DateField(required=False)
|
||||
to_date = serializers.DateTimeField(required=False)
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% load custom_tags %}
|
||||
|
||||
{% theme_values request as theme_values %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{% block title %}
|
||||
|
||||
@@ -82,6 +82,17 @@
|
||||
{% else %}
|
||||
{% trans 'Everything is fine!' %}
|
||||
{% endif %}
|
||||
|
||||
<h4 class="mt-3">{% trans 'Allowed Hosts' %} <span
|
||||
class="badge badge-{% if '*' in allowed_hosts %}warning{% else %}success{% endif %}">{% if '*' in allowed_hosts %}
|
||||
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
|
||||
{% if debug %}
|
||||
{% blocktrans %}
|
||||
Your allowed hosts are configured to allow every host. This might be ok in some setups but should be avoided. Please see the docs about this.
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans 'Everything is fine!' %}
|
||||
{% endif %}
|
||||
|
||||
<h4 class="mt-3">{% trans 'Database' %}
|
||||
<span class="badge badge-{{ postgres_status }}">
|
||||
|
||||
319
cookbook/tests/api/docs/reports/tests/assets/style.css
Normal file
319
cookbook/tests/api/docs/reports/tests/assets/style.css
Normal file
@@ -0,0 +1,319 @@
|
||||
body {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
/* do not increase min-width as some may use split screens */
|
||||
min-width: 800px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
p {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* SUMMARY INFORMATION
|
||||
******************************/
|
||||
#environment td {
|
||||
padding: 5px;
|
||||
border: 1px solid #e6e6e6;
|
||||
vertical-align: top;
|
||||
}
|
||||
#environment tr:nth-child(odd) {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
#environment ul {
|
||||
margin: 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* TEST RESULT COLORS
|
||||
******************************/
|
||||
span.passed,
|
||||
.passed .col-result {
|
||||
color: green;
|
||||
}
|
||||
|
||||
span.skipped,
|
||||
span.xfailed,
|
||||
span.rerun,
|
||||
.skipped .col-result,
|
||||
.xfailed .col-result,
|
||||
.rerun .col-result {
|
||||
color: orange;
|
||||
}
|
||||
|
||||
span.error,
|
||||
span.failed,
|
||||
span.xpassed,
|
||||
.error .col-result,
|
||||
.failed .col-result,
|
||||
.xpassed .col-result {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.col-links__extra {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
/******************************
|
||||
* RESULTS TABLE
|
||||
*
|
||||
* 1. Table Layout
|
||||
* 2. Extra
|
||||
* 3. Sorting items
|
||||
*
|
||||
******************************/
|
||||
/*------------------
|
||||
* 1. Table Layout
|
||||
*------------------*/
|
||||
#results-table {
|
||||
border: 1px solid #e6e6e6;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
#results-table th,
|
||||
#results-table td {
|
||||
padding: 5px;
|
||||
border: 1px solid #e6e6e6;
|
||||
text-align: left;
|
||||
}
|
||||
#results-table th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/*------------------
|
||||
* 2. Extra
|
||||
*------------------*/
|
||||
.logwrapper {
|
||||
max-height: 230px;
|
||||
overflow-y: scroll;
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
.logwrapper.expanded {
|
||||
max-height: none;
|
||||
}
|
||||
.logwrapper.expanded .logexpander:after {
|
||||
content: "collapse [-]";
|
||||
}
|
||||
.logwrapper .logexpander {
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
top: 10px;
|
||||
width: max-content;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
padding: 5px 7px;
|
||||
margin: 10px 0 10px calc(100% - 80px);
|
||||
cursor: pointer;
|
||||
background-color: #e6e6e6;
|
||||
}
|
||||
.logwrapper .logexpander:after {
|
||||
content: "expand [+]";
|
||||
}
|
||||
.logwrapper .logexpander:hover {
|
||||
color: #000;
|
||||
border-color: #000;
|
||||
}
|
||||
.logwrapper .log {
|
||||
min-height: 40px;
|
||||
position: relative;
|
||||
top: -50px;
|
||||
height: calc(100% + 50px);
|
||||
border: 1px solid #e6e6e6;
|
||||
color: black;
|
||||
display: block;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
padding: 5px;
|
||||
padding-right: 80px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
div.media {
|
||||
border: 1px solid #e6e6e6;
|
||||
float: right;
|
||||
height: 240px;
|
||||
margin: 0 5px;
|
||||
overflow: hidden;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.media-container {
|
||||
display: grid;
|
||||
grid-template-columns: 25px auto 25px;
|
||||
align-items: center;
|
||||
flex: 1 1;
|
||||
overflow: hidden;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.media-container--fullscreen {
|
||||
grid-template-columns: 0px auto 0px;
|
||||
}
|
||||
|
||||
.media-container__nav--right,
|
||||
.media-container__nav--left {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.media-container__viewport {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
height: inherit;
|
||||
}
|
||||
.media-container__viewport img,
|
||||
.media-container__viewport video {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.media__name,
|
||||
.media__counter {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
flex: 0 0 25px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsible td:not(.col-links) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.collapsible td:not(.col-links):hover::after {
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.col-result {
|
||||
width: 130px;
|
||||
}
|
||||
.col-result:hover::after {
|
||||
content: " (hide details)";
|
||||
}
|
||||
|
||||
.col-result.collapsed:hover::after {
|
||||
content: " (show details)";
|
||||
}
|
||||
|
||||
#environment-header h2:hover::after {
|
||||
content: " (hide details)";
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#environment-header.collapsed h2:hover::after {
|
||||
content: " (show details)";
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/*------------------
|
||||
* 3. Sorting items
|
||||
*------------------*/
|
||||
.sortable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sortable.desc:after {
|
||||
content: " ";
|
||||
position: relative;
|
||||
left: 5px;
|
||||
bottom: -12.5px;
|
||||
border: 10px solid #4caf50;
|
||||
border-bottom: 0;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
.sortable.asc:after {
|
||||
content: " ";
|
||||
position: relative;
|
||||
left: 5px;
|
||||
bottom: 12.5px;
|
||||
border: 10px solid #4caf50;
|
||||
border-top: 0;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
}
|
||||
|
||||
.hidden, .summary__reload__button.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.summary__data {
|
||||
flex: 0 0 550px;
|
||||
}
|
||||
.summary__reload {
|
||||
flex: 1 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.summary__reload__button {
|
||||
flex: 0 0 300px;
|
||||
display: flex;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
background-color: #4caf50;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.summary__reload__button:hover {
|
||||
background-color: #46a049;
|
||||
}
|
||||
.summary__spacer {
|
||||
flex: 0 0 550px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.collapse {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.filters button,
|
||||
.collapse button {
|
||||
color: #999;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.filters button:hover,
|
||||
.collapse button:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.filter__label {
|
||||
margin-right: 10px;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user