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/*') }}
|
${{ 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
1
.gitignore
vendored
@ -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
|
||||||
|
@ -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')
|
||||||
|
@ -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
|
||||||
|
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 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'
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
|
@ -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'
|
||||||
|
@ -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
|
||||||
|
@ -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')])
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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/',
|
||||||
|
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
|
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'
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
@ -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":
|
||||||
|
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
|
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
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user