Merge pull request #2874 from Mikhail5555/HomeAssistantConnector

Home assistant connector
This commit is contained in:
vabene1111 2024-02-26 08:06:50 +01:00 committed by GitHub
commit c15bd663cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 830 additions and 29 deletions

View File

@ -78,6 +78,7 @@ jobs:
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }} ${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
- name: Django Testing project - name: Django Testing project
timeout-minutes: 6
run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml
- name: Publish Test Results - name: Publish Test Results

1
.gitignore vendored
View File

@ -54,7 +54,6 @@ docs/_build/
target/ target/
\.idea/dataSources/ \.idea/dataSources/
\.idea/dataSources\.xml \.idea/dataSources\.xml
\.idea/dataSources\.local\.xml \.idea/dataSources\.local\.xml

View File

@ -16,7 +16,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingre
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace, TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
ViewLog) ViewLog, ConnectorConfig)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
@ -95,6 +95,14 @@ class StorageAdmin(admin.ModelAdmin):
admin.site.register(Storage, StorageAdmin) 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): class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked') list_display = ('storage', 'path', 'active', 'last_checked')
search_fields = ('storage__name', 'path') search_fields = ('storage__name', 'path')

View File

@ -3,6 +3,7 @@ import traceback
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from django.db import OperationalError, ProgrammingError from django.db import OperationalError, ProgrammingError
from django.db.models.signals import post_save, post_delete
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from recipes.settings import DEBUG from recipes.settings import DEBUG
@ -14,6 +15,12 @@ class CookbookConfig(AppConfig):
def ready(self): def ready(self):
import cookbook.signals # noqa import cookbook.signals # noqa
if not settings.DISABLE_EXTERNAL_CONNECTORS:
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")
# if not settings.DISABLE_TREE_FIX_STARTUP: # if not settings.DISABLE_TREE_FIX_STARTUP:
# # when starting up run fix_tree to: # # when starting up run fix_tree to:
# # a) make sure that nodes are sorted when switching between sort modes # # 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

@ -10,7 +10,7 @@ from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField from hcaptcha.fields import hCaptchaField
from .models import Comment, InviteLink, Keyword, Recipe, 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 SelectWidget(widgets.Select):
@ -160,6 +160,51 @@ class StorageForm(forms.ModelForm):
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)'), }
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 = ConnectorConfig
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'),
}
# TODO: Deprecate # TODO: Deprecate
# class RecipeBookEntryForm(forms.ModelForm): # class RecipeBookEntryForm(forms.ModelForm):
# prefix = 'bookmark' # prefix = 'bookmark'

View File

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

View File

@ -367,6 +367,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
SyncLog.objects.filter(sync__space=self).delete() SyncLog.objects.filter(sync__space=self).delete()
Sync.objects.filter(space=self).delete() Sync.objects.filter(space=self).delete()
Storage.objects.filter(space=self).delete() Storage.objects.filter(space=self).delete()
ConnectorConfig.objects.filter(space=self).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete() ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete() ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
@ -393,6 +394,31 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
return self.name 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): class UserPreference(models.Model, PermissionModelMixin):
# Themes # Themes
BOOTSTRAP = 'BOOTSTRAP' BOOTSTRAP = 'BOOTSTRAP'

View File

@ -34,7 +34,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Step, Storage, Supermarket, SupermarketCategory, Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
UserFile, UserPreference, UserSpace, ViewLog) UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig)
from cookbook.templatetags.custom_tags import markdown from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL from recipes.settings import AWS_ENABLED, MEDIA_URL
@ -420,6 +420,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 SyncSerializer(SpacedModelSerializer):
class Meta: class Meta:
model = Sync model = Sync

View File

@ -3,7 +3,7 @@ from django.utils.html import format_html
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_tables2.utils import A 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): class StorageTable(tables.Table):
@ -15,6 +15,15 @@ class StorageTable(tables.Table):
fields = ('id', 'name', 'method') 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): class ImportLogTable(tables.Table):
sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')]) sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')])

View File

@ -335,6 +335,10 @@
<a class="dropdown-item" href="{% url 'view_space_manage' request.space.pk %}"><i <a class="dropdown-item" href="{% url 'view_space_manage' request.space.pk %}"><i
class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a> class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a>
{% endif %} {% 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 %} {% if user.is_superuser %}
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'view_system' %}"><i <a class="dropdown-item" href="{% url 'view_system' %}"><i

View File

@ -112,6 +112,9 @@ def recipe_last(recipe, user):
def page_help(page_name): def page_help(page_name):
help_pages = { help_pages = {
'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/', '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_shopping': 'https://docs.tandoor.dev/features/shopping/',
'view_import': 'https://docs.tandoor.dev/features/import_export/', 'view_import': 'https://docs.tandoor.dev/features/import_export/',
'data_import_url': 'https://docs.tandoor.dev/features/import_export/', 'data_import_url': 'https://docs.tandoor.dev/features/import_export/',

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,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 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 @pytest.fixture
@ -29,6 +32,9 @@ def test_edit_storage(storage_obj, a1_s1, a1_s2):
) )
storage_obj.refresh_from_db() storage_obj.refresh_from_db()
assert r.status_code == 200 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.password == '1234_pw'
assert storage_obj.token == '1234_token' assert storage_obj.token == '1234_token'

View File

@ -0,0 +1,27 @@
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, ShoppingList, 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)
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
s.entries.add(e)
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

@ -1,8 +1,9 @@
import itertools import itertools
import json import json
from datetime import timedelta from datetime import timedelta, datetime
import pytest import pytest
import pytz
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.urls import reverse 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( Recipe.objects.filter(id=recipe.id).update(
updated_at=recipe.created_at) 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}" param1 = f"?{param_type}={date}"
param2 = f"?{param_type}=-{date}" param2 = f"?{param_type}=-{date}"
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param1}').content) r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param1}').content)

View File

@ -12,7 +12,7 @@ from recipes.settings import DEBUG, PLUGINS
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, PropertyType, from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, PropertyType,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Space, Step, Recipe, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Space, Step,
Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UnitConversion, 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 import api, data, delete, edit, import_export, lists, new, telegram, views
from .views.api import CustomAuthToken, ImportOpenData from .views.api import CustomAuthToken, ImportOpenData
@ -51,6 +51,7 @@ router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
router.register(r'space', api.SpaceViewSet) router.register(r'space', api.SpaceViewSet)
router.register(r'step', api.StepViewSet) router.register(r'step', api.StepViewSet)
router.register(r'storage', api.StorageViewSet) router.register(r'storage', api.StorageViewSet)
router.register(r'connector-config', api.ConnectorConfigConfigViewSet)
router.register(r'supermarket', api.SupermarketViewSet) router.register(r'supermarket', api.SupermarketViewSet)
router.register(r'supermarket-category', api.SupermarketCategoryViewSet) router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet) router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
@ -166,7 +167,7 @@ urlpatterns = [
] ]
generic_models = ( generic_models = (
Recipe, RecipeImport, Storage, RecipeBook, SyncLog, Sync, Recipe, RecipeImport, Storage, ConnectorConfig, RecipeBook, SyncLog, Sync,
Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space
) )

View File

@ -77,7 +77,7 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilte
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace, SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
ViewLog) ViewLog, ConnectorConfig)
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
@ -104,7 +104,7 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
SyncLogSerializer, SyncSerializer, UnitConversionSerializer, SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
UnitSerializer, UserFileSerializer, UserPreferenceSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
UserSerializer, UserSpaceSerializer, ViewLogSerializer, UserSerializer, UserSpaceSerializer, ViewLogSerializer,
ShoppingListEntryBulkSerializer) ShoppingListEntryBulkSerializer, ConnectorConfigConfigSerializer)
from cookbook.views.import_export import get_integration from cookbook.views.import_export import get_integration
from recipes import settings from recipes import settings
from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT from recipes.settings import FDC_API_KEY, DRF_THROTTLE_RECIPE_URL_IMPORT
@ -464,6 +464,15 @@ class StorageViewSet(viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space) return self.queryset.filter(space=self.request.space)
class ConnectorConfigConfigViewSet(viewsets.ModelViewSet):
queryset = ConnectorConfig.objects
serializer_class = ConnectorConfigConfigSerializer
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
def get_queryset(self):
return self.queryset.filter(space=self.request.space)
class SyncViewSet(viewsets.ModelViewSet): class SyncViewSet(viewsets.ModelViewSet):
queryset = Sync.objects queryset = Sync.objects
serializer_class = SyncSerializer serializer_class = SyncSerializer

View File

@ -9,7 +9,7 @@ from django.views.generic import DeleteView
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required
from cookbook.models import (Comment, InviteLink, MealPlan, Recipe, RecipeBook, RecipeBookEntry, 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.dropbox import Dropbox
from cookbook.provider.local import Local from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
@ -122,6 +122,18 @@ class StorageDelete(GroupRequiredMixin, DeleteView):
return HttpResponseRedirect(reverse('list_storage')) 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): class CommentDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html" template_name = "generic/delete_template.html"
model = Comment model = Comment

View File

@ -1,3 +1,4 @@
import copy
import os import os
from django.contrib import messages from django.contrib import messages
@ -8,14 +9,16 @@ from django.utils.translation import gettext as _
from django.views.generic import UpdateView from django.views.generic import UpdateView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from cookbook.forms import CommentForm, ExternalRecipeForm, StorageForm, SyncForm from cookbook.forms import CommentForm, ExternalRecipeForm, StorageForm, SyncForm, ConnectorConfigForm
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, above_space_limit, group_required from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, above_space_limit, group_required
from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync, ConnectorConfig
from cookbook.provider.dropbox import Dropbox from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud from cookbook.provider.nextcloud import Nextcloud
from recipes import settings from recipes import settings
VALUE_NOT_CHANGED = '__NO__CHANGE__'
@group_required('guest') @group_required('guest')
def switch_recipe(request, pk): def switch_recipe(request, pk):
@ -75,7 +78,7 @@ class SyncUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing):
@group_required('admin') @group_required('admin')
def edit_storage(request, pk): 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): if not (instance.created_by == request.user or request.user.is_superuser):
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!')) messages.add_message(request, messages.ERROR, _('You cannot edit this storage!'))
@ -86,17 +89,18 @@ def edit_storage(request, pk):
return redirect('index') return redirect('index')
if request.method == "POST": if request.method == "POST":
form = StorageForm(request.POST, instance=instance) form = StorageForm(request.POST, instance=copy.deepcopy(instance))
if form.is_valid(): if form.is_valid():
instance.name = form.cleaned_data['name'] instance.name = form.cleaned_data['name']
instance.method = form.cleaned_data['method'] instance.method = form.cleaned_data['method']
instance.username = form.cleaned_data['username'] instance.username = form.cleaned_data['username']
instance.url = form.cleaned_data['url'] 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'] 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.token = form.cleaned_data['token']
instance.save() instance.save()
@ -106,13 +110,39 @@ def edit_storage(request, pk):
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: else:
pseudo_instance = instance pseudo_instance = instance
pseudo_instance.password = '__NO__CHANGE__' pseudo_instance.password = VALUE_NOT_CHANGED
pseudo_instance.token = '__NO__CHANGE__' pseudo_instance.token = VALUE_NOT_CHANGED
form = StorageForm(instance=pseudo_instance) 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): class CommentUpdate(OwnerRequiredMixin, UpdateView):
template_name = "generic/edit_template.html" template_name = "generic/edit_template.html"
model = Comment model = Comment

View File

@ -6,8 +6,8 @@ from django.utils.translation import gettext as _
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from cookbook.helper.permission_helper import group_required from cookbook.helper.permission_helper import group_required
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile, ConnectorConfig
from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable, ConnectorConfigTable
@group_required('admin') @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') @group_required('admin')
def invite_link(request): def invite_link(request):
table = InviteLinkTable( table = InviteLinkTable(

View File

@ -1,4 +1,3 @@
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render 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.utils.translation import gettext as _
from django.views.generic import CreateView 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.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 from recipes import settings
@ -71,6 +70,35 @@ class StorageCreate(GroupRequiredMixin, CreateView):
return context 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') @group_required('user')
def create_new_external_recipe(request, import_id): def create_new_external_recipe(request, import_id):
if request.method == "POST": if request.method == "POST":

View File

@ -0,0 +1,34 @@
!!! warning
Connectors are currently in a beta stage.
## Connectors
Connectors are a powerful add-on component to TandoorRecipes.
They allow for certain actions to be translated to api calls to external services.
!!! danger
In order for this application to push data to external providers it needs to store authentication information.
Please use read only/separate accounts or app passwords wherever possible.
for the configuration please see [Configuration](https://docs.tandoor.dev/system/configuration/#connectors)
## Current Connectors
### HomeAssistant
The current HomeAssistant connector supports the following features:
1. Push newly created shopping list items.
2. Pushes all shopping list items if a recipe is added to the shopping list.
3. Removed todo's from HomeAssistant IF they are unchanged and are removed through TandoorRecipes.
#### How to configure:
Step 1:
1. Generate a HomeAssistant Long-Lived Access Tokens
![Profile Page](https://github.com/TandoorRecipes/recipes/assets/7824786/15ebeec5-5be3-48db-97d1-c698405db533)
2. Get/create a todo list entry you want to sync too.
![Todos Viewer](https://github.com/TandoorRecipes/recipes/assets/7824786/506c4c34-3d40-48ae-a80c-e50374c5c618)
3. Create a connector
![New Connector Config](https://github.com/TandoorRecipes/recipes/assets/7824786/7f14f181-1341-4cab-959b-a6bef79e2159)
4. ???
5. Profit

View File

@ -437,6 +437,18 @@ key [here](https://fdc.nal.usda.gov/api-key-signup.html).
FDC_API_KEY=DEMO_KEY FDC_API_KEY=DEMO_KEY
``` ```
#### Connectors
- `DISABLE_EXTERNAL_CONNECTORS` is a global switch to disable External Connectors entirely.
- `EXTERNAL_CONNECTORS_QUEUE_SIZE` is the amount of changes that are kept in memory if the worker cannot keep up.
(External) Connectors are used to sync the status from Tandoor to other services. More info can be found [here](https://docs.tandoor.dev/features/connectors/).
```env
DISABLE_EXTERNAL_CONNECTORS=0 # Default 0 (false), set to 1 (true) to disable connectors
EXTERNAL_CONNECTORS_QUEUE_SIZE=100 # Defaults to 100, set to any number >1
```
### Debugging/Development settings ### Debugging/Development settings
!!! warning !!! warning

View File

@ -42,6 +42,7 @@ nav:
- Shopping: features/shopping.md - Shopping: features/shopping.md
- Authentication: features/authentication.md - Authentication: features/authentication.md
- Automation: features/automation.md - Automation: features/automation.md
- Connectors: features/connectors.md
- Storages and Sync: features/external_recipes.md - Storages and Sync: features/external_recipes.md
- Import/Export: features/import_export.md - Import/Export: features/import_export.md
- System: - System:

View File

@ -569,4 +569,7 @@ ACCOUNT_RATE_LIMITS = {
"login": "5/m/ip", "login": "5/m/ip",
} }
DISABLE_EXTERNAL_CONNECTORS = bool(int(os.getenv('DISABLE_EXTERNAL_CONNECTORS', False)))
EXTERNAL_CONNECTORS_QUEUE_SIZE = int(os.getenv('EXTERNAL_CONNECTORS_QUEUE_SIZE', 100))
mimetypes.add_type("text/javascript", ".js", True) mimetypes.add_type("text/javascript", ".js", True)

View File

@ -26,6 +26,7 @@ pyyaml==6.0.1
uritemplate==4.1.1 uritemplate==4.1.1
beautifulsoup4==4.12.2 beautifulsoup4==4.12.2
microdata==0.8.0 microdata==0.8.0
mock==5.1.0
Jinja2==3.1.3 Jinja2==3.1.3
django-webpack-loader==1.8.1 django-webpack-loader==1.8.1
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82 git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
@ -33,6 +34,7 @@ django-allauth==0.61.1
recipe-scrapers==14.52.0 recipe-scrapers==14.52.0
django-scopes==2.0.0 django-scopes==2.0.0
pytest==8.0.0 pytest==8.0.0
pytest-asyncio==0.23.5
pytest-django==4.8.0 pytest-django==4.8.0
django-treebeard==4.7 django-treebeard==4.7
django-cors-headers==4.2.0 django-cors-headers==4.2.0
@ -46,3 +48,4 @@ pytest-factoryboy==2.6.0
pyppeteer==1.0.2 pyppeteer==1.0.2
validators==0.20.0 validators==0.20.0
pytube==15.0.0 pytube==15.0.0
homeassistant-api==4.1.1.post2