Merge pull request #2874 from Mikhail5555/HomeAssistantConnector
Home assistant connector
This commit is contained in:
commit
c15bd663cb
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -54,7 +54,6 @@ docs/_build/
|
||||
target/
|
||||
|
||||
\.idea/dataSources/
|
||||
|
||||
\.idea/dataSources\.xml
|
||||
|
||||
\.idea/dataSources\.local\.xml
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
0
cookbook/connectors/__init__.py
Normal file
0
cookbook/connectors/__init__.py
Normal file
29
cookbook/connectors/connector.py
Normal file
29
cookbook/connectors/connector.py
Normal 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?)
|
179
cookbook/connectors/connector_manager.py
Normal file
179
cookbook/connectors/connector_manager.py
Normal 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")
|
85
cookbook/connectors/homeassistant.py
Normal file
85
cookbook/connectors/homeassistant.py
Normal 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
|
@ -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 (<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
|
||||
# class RecipeBookEntryForm(forms.ModelForm):
|
||||
# prefix = 'bookmark'
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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')])
|
||||
|
||||
|
@ -335,6 +335,10 @@
|
||||
<a class="dropdown-item" href="{% url 'view_space_manage' request.space.pk %}"><i
|
||||
class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a>
|
||||
{% endif %}
|
||||
{% if not DISABLE_EXTERNAL_CONNECTORS and request.user == request.space.created_by or not DISABLE_EXTERNAL_CONNECTORS and user.is_superuser %}
|
||||
<a class="dropdown-item" href="{% url 'list_connector_config' %}"><i
|
||||
class="fas fa-sync-alt fa-fw"></i> {% trans 'External Connectors' %}</a>
|
||||
{% endif %}
|
||||
{% if user.is_superuser %}
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="{% url 'view_system' %}"><i
|
||||
|
@ -112,6 +112,9 @@ def recipe_last(recipe, user):
|
||||
def page_help(page_name):
|
||||
help_pages = {
|
||||
'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/',
|
||||
'list_connector_config': 'https://docs.tandoor.dev/features/connectors/',
|
||||
'new_connector_config': 'https://docs.tandoor.dev/features/connectors/',
|
||||
'edit_connector_config': 'https://docs.tandoor.dev/features/connectors/',
|
||||
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
|
||||
'view_import': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'data_import_url': 'https://docs.tandoor.dev/features/import_export/',
|
||||
|
126
cookbook/tests/api/test_api_connector_config.py
Normal file
126
cookbook/tests/api/test_api_connector_config.py
Normal 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
|
75
cookbook/tests/edits/test_edits_connector_config.py
Normal file
75
cookbook/tests/edits/test_edits_connector_config.py
Normal 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]
|
@ -1,7 +1,10 @@
|
||||
from cookbook.models import Storage
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.contrib import messages
|
||||
from django.contrib.messages import get_messages
|
||||
from django.urls import reverse
|
||||
|
||||
from cookbook.models import Storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -29,6 +32,9 @@ def test_edit_storage(storage_obj, a1_s1, a1_s2):
|
||||
)
|
||||
storage_obj.refresh_from_db()
|
||||
assert r.status_code == 200
|
||||
r_messages = [m for m in get_messages(r.wsgi_request)]
|
||||
assert not any(m.level > messages.SUCCESS for m in r_messages)
|
||||
|
||||
assert storage_obj.password == '1234_pw'
|
||||
assert storage_obj.token == '1234_token'
|
||||
|
||||
|
27
cookbook/tests/other/test_connector_manager.py
Normal file
27
cookbook/tests/other/test_connector_manager.py
Normal 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)
|
@ -1,8 +1,9 @@
|
||||
import itertools
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
@ -343,7 +344,7 @@ def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, sp
|
||||
Recipe.objects.filter(id=recipe.id).update(
|
||||
updated_at=recipe.created_at)
|
||||
|
||||
date = (timezone.now() - timedelta(days=15)).strftime("%Y-%m-%d")
|
||||
date = (datetime.now() - timedelta(days=15)).strftime("%Y-%m-%d")
|
||||
param1 = f"?{param_type}={date}"
|
||||
param2 = f"?{param_type}=-{date}"
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param1}').content)
|
||||
|
@ -12,7 +12,7 @@ from recipes.settings import DEBUG, PLUGINS
|
||||
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, PropertyType,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList, Space, Step,
|
||||
Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserSpace, get_model_name)
|
||||
UserFile, UserSpace, get_model_name, ConnectorConfig)
|
||||
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||
from .views.api import CustomAuthToken, ImportOpenData
|
||||
|
||||
@ -51,6 +51,7 @@ router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
|
||||
router.register(r'space', api.SpaceViewSet)
|
||||
router.register(r'step', api.StepViewSet)
|
||||
router.register(r'storage', api.StorageViewSet)
|
||||
router.register(r'connector-config', api.ConnectorConfigConfigViewSet)
|
||||
router.register(r'supermarket', api.SupermarketViewSet)
|
||||
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
|
||||
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
|
||||
@ -166,7 +167,7 @@ urlpatterns = [
|
||||
]
|
||||
|
||||
generic_models = (
|
||||
Recipe, RecipeImport, Storage, RecipeBook, SyncLog, Sync,
|
||||
Recipe, RecipeImport, Storage, ConnectorConfig, RecipeBook, SyncLog, Sync,
|
||||
Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space
|
||||
)
|
||||
|
||||
|
@ -77,7 +77,7 @@ from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilte
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
|
||||
SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog)
|
||||
ViewLog, ConnectorConfig)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
@ -104,7 +104,7 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
|
||||
SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
|
||||
UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
|
||||
UserSerializer, UserSpaceSerializer, ViewLogSerializer,
|
||||
ShoppingListEntryBulkSerializer)
|
||||
ShoppingListEntryBulkSerializer, ConnectorConfigConfigSerializer)
|
||||
from cookbook.views.import_export import get_integration
|
||||
from recipes import settings
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
queryset = Sync.objects
|
||||
serializer_class = SyncSerializer
|
||||
|
@ -9,7 +9,7 @@ from django.views.generic import DeleteView
|
||||
|
||||
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required
|
||||
from cookbook.models import (Comment, InviteLink, MealPlan, Recipe, RecipeBook, RecipeBookEntry,
|
||||
RecipeImport, Space, Storage, Sync, UserSpace)
|
||||
RecipeImport, Space, Storage, Sync, UserSpace, ConnectorConfig)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
@ -122,6 +122,18 @@ class StorageDelete(GroupRequiredMixin, DeleteView):
|
||||
return HttpResponseRedirect(reverse('list_storage'))
|
||||
|
||||
|
||||
class ConnectorConfigDelete(GroupRequiredMixin, DeleteView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/delete_template.html"
|
||||
model = ConnectorConfig
|
||||
success_url = reverse_lazy('list_connector_config')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['title'] = _("Connectors Config Backend")
|
||||
return context
|
||||
|
||||
|
||||
class CommentDelete(OwnerRequiredMixin, DeleteView):
|
||||
template_name = "generic/delete_template.html"
|
||||
model = Comment
|
||||
|
@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import os
|
||||
|
||||
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.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.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.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
from recipes import settings
|
||||
|
||||
VALUE_NOT_CHANGED = '__NO__CHANGE__'
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
def switch_recipe(request, pk):
|
||||
@ -75,7 +78,7 @@ class SyncUpdate(GroupRequiredMixin, UpdateView, SpaceFormMixing):
|
||||
|
||||
@group_required('admin')
|
||||
def edit_storage(request, pk):
|
||||
instance = get_object_or_404(Storage, pk=pk, space=request.space)
|
||||
instance: Storage = get_object_or_404(Storage, pk=pk, space=request.space)
|
||||
|
||||
if not (instance.created_by == request.user or request.user.is_superuser):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!'))
|
||||
@ -86,17 +89,18 @@ def edit_storage(request, pk):
|
||||
return redirect('index')
|
||||
|
||||
if request.method == "POST":
|
||||
form = StorageForm(request.POST, instance=instance)
|
||||
form = StorageForm(request.POST, instance=copy.deepcopy(instance))
|
||||
if form.is_valid():
|
||||
instance.name = form.cleaned_data['name']
|
||||
instance.method = form.cleaned_data['method']
|
||||
instance.username = form.cleaned_data['username']
|
||||
instance.url = form.cleaned_data['url']
|
||||
instance.path = form.cleaned_data['path']
|
||||
|
||||
if form.cleaned_data['password'] != '__NO__CHANGE__':
|
||||
if form.cleaned_data['password'] != VALUE_NOT_CHANGED:
|
||||
instance.password = form.cleaned_data['password']
|
||||
|
||||
if form.cleaned_data['token'] != '__NO__CHANGE__':
|
||||
if form.cleaned_data['token'] != VALUE_NOT_CHANGED:
|
||||
instance.token = form.cleaned_data['token']
|
||||
|
||||
instance.save()
|
||||
@ -106,13 +110,39 @@ def edit_storage(request, pk):
|
||||
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend!'))
|
||||
else:
|
||||
pseudo_instance = instance
|
||||
pseudo_instance.password = '__NO__CHANGE__'
|
||||
pseudo_instance.token = '__NO__CHANGE__'
|
||||
pseudo_instance.password = VALUE_NOT_CHANGED
|
||||
pseudo_instance.token = VALUE_NOT_CHANGED
|
||||
form = StorageForm(instance=pseudo_instance)
|
||||
|
||||
return render(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')})
|
||||
|
||||
|
||||
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):
|
||||
template_name = "generic/edit_template.html"
|
||||
model = Comment
|
||||
|
@ -6,8 +6,8 @@ from django.utils.translation import gettext as _
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile
|
||||
from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable
|
||||
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile, ConnectorConfig
|
||||
from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable, ConnectorConfigTable
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
@ -65,6 +65,22 @@ def storage(request):
|
||||
)
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
def connector_config(request):
|
||||
table = ConnectorConfigTable(ConnectorConfig.objects.filter(space=request.space).all())
|
||||
RequestConfig(request, paginate={'per_page': 25}).configure(table)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'generic/list_template.html',
|
||||
{
|
||||
'title': _("Connector Config Backend"),
|
||||
'table': table,
|
||||
'create_url': 'new_connector_config'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@group_required('admin')
|
||||
def invite_link(request):
|
||||
table = InviteLinkTable(
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@ -6,9 +5,9 @@ from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import CreateView
|
||||
|
||||
from cookbook.forms import ImportRecipeForm, Storage, StorageForm
|
||||
from cookbook.forms import ImportRecipeForm, Storage, StorageForm, ConnectorConfigForm
|
||||
from cookbook.helper.permission_helper import GroupRequiredMixin, above_space_limit, group_required
|
||||
from cookbook.models import Recipe, RecipeImport, ShareLink, Step
|
||||
from cookbook.models import Recipe, RecipeImport, ShareLink, Step, ConnectorConfig
|
||||
from recipes import settings
|
||||
|
||||
|
||||
@ -71,6 +70,35 @@ class StorageCreate(GroupRequiredMixin, CreateView):
|
||||
return context
|
||||
|
||||
|
||||
class ConnectorConfigCreate(GroupRequiredMixin, CreateView):
|
||||
groups_required = ['admin']
|
||||
template_name = "generic/new_template.html"
|
||||
model = ConnectorConfig
|
||||
form_class = ConnectorConfigForm
|
||||
success_url = reverse_lazy('list_connector_config')
|
||||
|
||||
def form_valid(self, form):
|
||||
if self.request.space.demo:
|
||||
messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
|
||||
return redirect('index')
|
||||
|
||||
if settings.DISABLE_EXTERNAL_CONNECTORS:
|
||||
messages.add_message(self.request, messages.ERROR, _('This feature is not enabled by the server admin!'))
|
||||
return redirect('index')
|
||||
|
||||
obj = form.save(commit=False)
|
||||
obj.token = form.cleaned_data['update_token']
|
||||
obj.created_by = self.request.user
|
||||
obj.space = self.request.space
|
||||
obj.save()
|
||||
return HttpResponseRedirect(reverse('edit_connector_config', kwargs={'pk': obj.pk}))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['title'] = _("Connector Config Backend")
|
||||
return context
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def create_new_external_recipe(request, import_id):
|
||||
if request.method == "POST":
|
||||
|
34
docs/features/connectors.md
Normal file
34
docs/features/connectors.md
Normal 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
|
||||

|
||||
2. Get/create a todo list entry you want to sync too.
|
||||

|
||||
3. Create a connector
|
||||

|
||||
4. ???
|
||||
5. Profit
|
@ -437,6 +437,18 @@ key [here](https://fdc.nal.usda.gov/api-key-signup.html).
|
||||
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
|
||||
|
||||
!!! warning
|
||||
|
@ -42,6 +42,7 @@ nav:
|
||||
- Shopping: features/shopping.md
|
||||
- Authentication: features/authentication.md
|
||||
- Automation: features/automation.md
|
||||
- Connectors: features/connectors.md
|
||||
- Storages and Sync: features/external_recipes.md
|
||||
- Import/Export: features/import_export.md
|
||||
- System:
|
||||
|
@ -569,4 +569,7 @@ ACCOUNT_RATE_LIMITS = {
|
||||
"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)
|
||||
|
@ -26,6 +26,7 @@ pyyaml==6.0.1
|
||||
uritemplate==4.1.1
|
||||
beautifulsoup4==4.12.2
|
||||
microdata==0.8.0
|
||||
mock==5.1.0
|
||||
Jinja2==3.1.3
|
||||
django-webpack-loader==1.8.1
|
||||
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
|
||||
@ -33,6 +34,7 @@ django-allauth==0.61.1
|
||||
recipe-scrapers==14.52.0
|
||||
django-scopes==2.0.0
|
||||
pytest==8.0.0
|
||||
pytest-asyncio==0.23.5
|
||||
pytest-django==4.8.0
|
||||
django-treebeard==4.7
|
||||
django-cors-headers==4.2.0
|
||||
@ -46,3 +48,4 @@ pytest-factoryboy==2.6.0
|
||||
pyppeteer==1.0.2
|
||||
validators==0.20.0
|
||||
pytube==15.0.0
|
||||
homeassistant-api==4.1.1.post2
|
||||
|
Loading…
Reference in New Issue
Block a user