diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 202de7b2..6eb6195d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,6 +78,7 @@ jobs:
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
- name: Django Testing project
+ timeout-minutes: 6
run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml
- name: Publish Test Results
diff --git a/.gitignore b/.gitignore
index 1657d4ca..2a977bef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,7 +54,6 @@ docs/_build/
target/
\.idea/dataSources/
-
\.idea/dataSources\.xml
\.idea/dataSources\.local\.xml
diff --git a/cookbook/admin.py b/cookbook/admin.py
index fc148afe..96ebad97 100644
--- a/cookbook/admin.py
+++ b/cookbook/admin.py
@@ -16,7 +16,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingre
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
- ViewLog)
+ ViewLog, ConnectorConfig)
class CustomUserAdmin(UserAdmin):
@@ -95,6 +95,14 @@ class StorageAdmin(admin.ModelAdmin):
admin.site.register(Storage, StorageAdmin)
+class ConnectorConfigAdmin(admin.ModelAdmin):
+ list_display = ('id', 'name', 'type', 'enabled', 'url')
+ search_fields = ('name', 'url')
+
+
+admin.site.register(ConnectorConfig, ConnectorConfigAdmin)
+
+
class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked')
search_fields = ('storage__name', 'path')
diff --git a/cookbook/apps.py b/cookbook/apps.py
index e551319d..3bd8d847 100644
--- a/cookbook/apps.py
+++ b/cookbook/apps.py
@@ -3,6 +3,7 @@ import traceback
from django.apps import AppConfig
from django.conf import settings
from django.db import OperationalError, ProgrammingError
+from django.db.models.signals import post_save, post_delete
from django_scopes import scopes_disabled
from recipes.settings import DEBUG
@@ -14,6 +15,12 @@ class CookbookConfig(AppConfig):
def ready(self):
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:
# # when starting up run fix_tree to:
# # a) make sure that nodes are sorted when switching between sort modes
diff --git a/cookbook/connectors/__init__.py b/cookbook/connectors/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/cookbook/connectors/connector.py b/cookbook/connectors/connector.py
new file mode 100644
index 00000000..27e9408d
--- /dev/null
+++ b/cookbook/connectors/connector.py
@@ -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?)
diff --git a/cookbook/connectors/connector_manager.py b/cookbook/connectors/connector_manager.py
new file mode 100644
index 00000000..e247db30
--- /dev/null
+++ b/cookbook/connectors/connector_manager.py
@@ -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")
diff --git a/cookbook/connectors/homeassistant.py b/cookbook/connectors/homeassistant.py
new file mode 100644
index 00000000..e653c3f3
--- /dev/null
+++ b/cookbook/connectors/homeassistant.py
@@ -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
diff --git a/cookbook/forms.py b/cookbook/forms.py
index 40237cae..872bfa01 100644
--- a/cookbook/forms.py
+++ b/cookbook/forms.py
@@ -10,7 +10,7 @@ from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
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):
@@ -160,6 +160,51 @@ class StorageForm(forms.ModelForm):
help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (/remote.php/webdav/
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=_('Long Lived Access Token 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
# class RecipeBookEntryForm(forms.ModelForm):
# prefix = 'bookmark'
diff --git a/cookbook/helper/context_processors.py b/cookbook/helper/context_processors.py
index 30e704c1..9beb8b9b 100644
--- a/cookbook/helper/context_processors.py
+++ b/cookbook/helper/context_processors.py
@@ -11,4 +11,5 @@ def context_settings(request):
'PRIVACY_URL': settings.PRIVACY_URL,
'IMPRINT_URL': settings.IMPRINT_URL,
'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL,
+ 'DISABLE_EXTERNAL_CONNECTORS': settings.DISABLE_EXTERNAL_CONNECTORS,
}
diff --git a/cookbook/models.py b/cookbook/models.py
index f9c16607..4b8e5172 100644
--- a/cookbook/models.py
+++ b/cookbook/models.py
@@ -367,6 +367,7 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
SyncLog.objects.filter(sync__space=self).delete()
Sync.objects.filter(space=self).delete()
Storage.objects.filter(space=self).delete()
+ ConnectorConfig.objects.filter(space=self).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
@@ -393,6 +394,31 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
return self.name
+class ConnectorConfig(models.Model, PermissionModelMixin):
+ HOMEASSISTANT = 'HomeAssistant'
+ CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),)
+
+ name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
+ type = models.CharField(
+ choices=CONNECTER_TYPE, max_length=128, default=HOMEASSISTANT
+ )
+
+ enabled = models.BooleanField(default=True, help_text="Is Connector Enabled")
+ on_shopping_list_entry_created_enabled = models.BooleanField(default=False)
+ on_shopping_list_entry_updated_enabled = models.BooleanField(default=False)
+ on_shopping_list_entry_deleted_enabled = models.BooleanField(default=False)
+
+ url = models.URLField(blank=True, null=True)
+ token = models.CharField(max_length=512, blank=True, null=True)
+ todo_entity = models.CharField(max_length=128, blank=True, null=True)
+
+ created_by = models.ForeignKey(User, on_delete=models.PROTECT)
+
+ space = models.ForeignKey(Space, on_delete=models.CASCADE)
+ objects = ScopedManager(space='space')
+
+
+
class UserPreference(models.Model, PermissionModelMixin):
# Themes
BOOTSTRAP = 'BOOTSTRAP'
diff --git a/cookbook/serializer.py b/cookbook/serializer.py
index 330c4450..ec9c0d4f 100644
--- a/cookbook/serializer.py
+++ b/cookbook/serializer.py
@@ -34,7 +34,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
- UserFile, UserPreference, UserSpace, ViewLog)
+ UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL
@@ -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 Meta:
model = Sync
diff --git a/cookbook/tables.py b/cookbook/tables.py
index 6392f791..3fcec9c3 100644
--- a/cookbook/tables.py
+++ b/cookbook/tables.py
@@ -3,7 +3,7 @@ from django.utils.html import format_html
from django.utils.translation import gettext as _
from django_tables2.utils import A
-from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog
+from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog, ConnectorConfig
class StorageTable(tables.Table):
@@ -15,6 +15,15 @@ class StorageTable(tables.Table):
fields = ('id', 'name', 'method')
+class ConnectorConfigTable(tables.Table):
+ id = tables.LinkColumn('edit_connector_config', args=[A('id')])
+
+ class Meta:
+ model = ConnectorConfig
+ template_name = 'generic/table_template.html'
+ fields = ('id', 'name', 'type', 'enabled')
+
+
class ImportLogTable(tables.Table):
sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')])
diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html
index 14b3a18f..a20696a3 100644
--- a/cookbook/templates/base.html
+++ b/cookbook/templates/base.html
@@ -335,6 +335,10 @@
{% trans 'Space Settings' %}
{% endif %}
+ {% if not DISABLE_EXTERNAL_CONNECTORS and request.user == request.space.created_by or not DISABLE_EXTERNAL_CONNECTORS and user.is_superuser %}
+ {% trans 'External Connectors' %}
+ {% endif %}
{% if user.is_superuser %}