diff --git a/cookbook/admin.py b/cookbook/admin.py
index 82324628..4a2b2697 100644
--- a/cookbook/admin.py
+++ b/cookbook/admin.py
@@ -16,7 +16,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingre
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
- ViewLog)
+ ViewLog, HomeAssistantConfig)
class CustomUserAdmin(UserAdmin):
@@ -95,6 +95,14 @@ class StorageAdmin(admin.ModelAdmin):
admin.site.register(Storage, StorageAdmin)
+class HomeAssistantConfigAdmin(admin.ModelAdmin):
+ list_display = ('name',)
+ search_fields = ('name',)
+
+
+admin.site.register(HomeAssistantConfig, HomeAssistantConfigAdmin)
+
+
class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked')
search_fields = ('storage__name', 'path')
diff --git a/cookbook/connectors/__init__.py b/cookbook/connectors/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/cookbook/connectors/connector.py b/cookbook/connectors/connector.py
new file mode 100644
index 00000000..d2a435d7
--- /dev/null
+++ b/cookbook/connectors/connector.py
@@ -0,0 +1,41 @@
+from abc import ABC, abstractmethod
+
+from cookbook.models import ShoppingListEntry, Space
+
+
+class Connector(ABC):
+ @abstractmethod
+ async def on_shopping_list_entry_created(self, space: Space, instance: ShoppingListEntry) -> None:
+ pass
+
+ @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
+ # def on_recipe_created(self, instance: Recipe, **kwargs) -> None:
+ # pass
+ #
+ # @abstractmethod
+ # def on_recipe_updated(self, instance: Recipe, **kwargs) -> None:
+ # pass
+ #
+ # @abstractmethod
+ # def on_recipe_deleted(self, instance: Recipe, **kwargs) -> None:
+ # pass
+ #
+ # @abstractmethod
+ # def on_meal_plan_created(self, instance: MealPlan, **kwargs) -> None:
+ # pass
+ #
+ # @abstractmethod
+ # def on_meal_plan_updated(self, instance: MealPlan, **kwargs) -> None:
+ # pass
+ #
+ # @abstractmethod
+ # def on_meal_plan_deleted(self, instance: MealPlan, **kwargs) -> None:
+ # pass
diff --git a/cookbook/connectors/connector_manager.py b/cookbook/connectors/connector_manager.py
new file mode 100644
index 00000000..0d3df8cf
--- /dev/null
+++ b/cookbook/connectors/connector_manager.py
@@ -0,0 +1,98 @@
+import asyncio
+from enum import Enum
+from types import UnionType
+from typing import List, Any, Dict
+
+from django_scopes import scope
+
+from cookbook.connectors.connector import Connector
+from cookbook.connectors.homeassistant import HomeAssistant
+from cookbook.models import ShoppingListEntry, Recipe, MealPlan, Space
+
+
+class ActionType(Enum):
+ CREATED = 1
+ UPDATED = 2
+ DELETED = 3
+
+
+class ConnectorManager:
+ _connectors: Dict[str, List[Connector]]
+ _listening_to_classes: UnionType = ShoppingListEntry | Recipe | MealPlan | Connector
+ max_concurrent_tasks = 2
+
+ def __init__(self):
+ self._connectors = dict()
+
+ def __call__(self, instance: Any, **kwargs) -> None:
+ if not isinstance(instance, self._listening_to_classes):
+ return
+
+ # If a Connector was changed/updated, refresh connector from the database for said space
+ purge_connector_cache = isinstance(instance, Connector)
+
+ space: Space = instance.space
+ if space.name in self._connectors and not purge_connector_cache:
+ connectors: List[Connector] = self._connectors[space.name]
+ else:
+ with scope(space=space):
+ connectors: List[Connector] = [HomeAssistant(config) for config in space.homeassistantconfig_set.all()]
+ self._connectors[space.name] = connectors
+
+ if len(connectors) == 0 or purge_connector_cache:
+ return
+
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ loop.run_until_complete(self.run_connectors(connectors, space, instance, **kwargs))
+ loop.close()
+
+ @staticmethod
+ async def run_connectors(connectors: List[Connector], space: Space, instance: Any, **kwargs):
+ 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
+
+ tasks: List[asyncio.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)))
+
+ try:
+ await asyncio.gather(*tasks, return_exceptions=False)
+ except BaseException as e:
+ print("received an exception from one of the tasks: ", e)
+ # if isinstance(instance, Recipe):
+ # if "created" in kwargs and kwargs["created"]:
+ # for connector in self._connectors:
+ # connector.on_recipe_created(instance, **kwargs)
+ # return
+ # for connector in self._connectors:
+ # connector.on_recipe_updated(instance, **kwargs)
+ # return
+ #
+ # if isinstance(instance, MealPlan):
+ # if "created" in kwargs and kwargs["created"]:
+ # for connector in self._connectors:
+ # connector.on_meal_plan_created(instance, **kwargs)
+ # return
+ # for connector in self._connectors:
+ # connector.on_meal_plan_updated(instance, **kwargs)
+ # return
diff --git a/cookbook/connectors/homeassistant.py b/cookbook/connectors/homeassistant.py
new file mode 100644
index 00000000..6d504d6c
--- /dev/null
+++ b/cookbook/connectors/homeassistant.py
@@ -0,0 +1,62 @@
+import logging
+
+from homeassistant_api import Client, HomeassistantAPIError
+
+from cookbook.connectors.connector import Connector
+from cookbook.models import ShoppingListEntry, HomeAssistantConfig, Space
+
+
+class HomeAssistant(Connector):
+ _config: HomeAssistantConfig
+
+ def __init__(self, config: HomeAssistantConfig):
+ self._config = config
+ self._logger = logging.getLogger("connector.HomeAssistant")
+
+ 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)
+ async with Client(self._config.url, self._config.token, use_async=True) as client:
+ try:
+ todo_domain = await client.async_get_domain('todo')
+ 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)
+ async with Client(self._config.url, self._config.token, use_async=True) as client:
+ try:
+ todo_domain = await client.async_get_domain('todo')
+ 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)=}")
+
+
+def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
+ item = shopping_list_entry.food.name
+ if shopping_list_entry.amount > 0:
+ 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.amount} {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.amount} {shopping_list_entry.unit.name})"
+ else:
+ item += f" ({shopping_list_entry.amount})"
+
+ description = "Imported by TandoorRecipes"
+ if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0:
+ description += f", created by {shopping_list_entry.created_by.first_name}"
+ else:
+ description += f", created by {shopping_list_entry.created_by.username}"
+
+ return item, description
diff --git a/cookbook/forms.py b/cookbook/forms.py
index dd62aa0b..76d4a17f 100644
--- a/cookbook/forms.py
+++ b/cookbook/forms.py
@@ -10,7 +10,7 @@ from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceFie
from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, Recipe, RecipeBook, RecipeBookEntry,
- SearchPreference, Space, Storage, Sync, User, UserPreference)
+ SearchPreference, Space, Storage, Sync, User, UserPreference, HomeAssistantConfig)
class SelectWidget(widgets.Select):
@@ -188,6 +188,45 @@ class StorageForm(forms.ModelForm):
}
+class HomeAssistantConfigForm(forms.ModelForm):
+ token = forms.CharField(
+ widget=forms.TextInput(
+ attrs={'autocomplete': 'new-password', 'type': 'password'}
+ ),
+ required=True,
+ help_text=_('Long Lived Access Token for your HomeAssistant instance')
+ )
+
+ url = forms.URLField(
+ required=True,
+ help_text=_('Something like http://homeassistant.local:8123/api'),
+ )
+
+ on_shopping_list_entry_created_enabled = forms.BooleanField(
+ help_text="Enable syncing ShoppingListEntry to Homeassistant Todo List -- Warning: Might have negative performance impact",
+ required=False,
+ )
+
+ on_shopping_list_entry_updated_enabled = forms.BooleanField(
+ help_text="PLACEHOLDER",
+ required=False,
+ )
+
+ on_shopping_list_entry_deleted_enabled = forms.BooleanField(
+ help_text="Enable syncing ShoppingListEntry deletion to Homeassistant Todo List -- Warning: Might have negative performance impact",
+ required=False,
+ )
+
+ class Meta:
+ model = HomeAssistantConfig
+ fields = (
+ 'name', 'url', 'token', 'todo_entity', 'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled', 'on_shopping_list_entry_deleted_enabled')
+
+ help_texts = {
+ 'url': _('http://homeassistant.local:8123/api for example'),
+ }
+
+
# TODO: Deprecate
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
diff --git a/cookbook/migrations/0206_alter_storage_path_homeassistantconfig.py b/cookbook/migrations/0206_alter_storage_path_homeassistantconfig.py
new file mode 100644
index 00000000..acbec237
--- /dev/null
+++ b/cookbook/migrations/0206_alter_storage_path_homeassistantconfig.py
@@ -0,0 +1,30 @@
+# Generated by Django 4.2.7 on 2024-01-10 21:28
+
+import cookbook.models
+from django.conf import settings
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('cookbook', '0205_alter_food_fdc_id_alter_propertytype_fdc_id'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HomeAssistantConfig',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)])),
+ ('url', models.URLField(blank=True, help_text='Something like http://homeassistant:8123/api')),
+ ('token', models.CharField(blank=True, max_length=512)),
+ ('todo_entity', models.CharField(default='todo.shopping_list', max_length=128)),
+ ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ ('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
+ ],
+ bases=(models.Model, cookbook.models.PermissionModelMixin),
+ ),
+ ]
diff --git a/cookbook/migrations/0207_homeassistantconfig_on_shopping_list_entry_created_enabled_and_more.py b/cookbook/migrations/0207_homeassistantconfig_on_shopping_list_entry_created_enabled_and_more.py
new file mode 100644
index 00000000..67f93b18
--- /dev/null
+++ b/cookbook/migrations/0207_homeassistantconfig_on_shopping_list_entry_created_enabled_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.2.7 on 2024-01-11 19:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cookbook', '0206_alter_storage_path_homeassistantconfig'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='homeassistantconfig',
+ name='on_shopping_list_entry_created_enabled',
+ field=models.BooleanField(default=False, help_text='Enable syncing ShoppingListEntry to Homeassistant Todo List'),
+ ),
+ migrations.AddField(
+ model_name='homeassistantconfig',
+ name='on_shopping_list_entry_deleted_enabled',
+ field=models.BooleanField(default=False, help_text='Enable syncing ShoppingListEntry deletion to Homeassistant Todo List'),
+ ),
+ migrations.AddField(
+ model_name='homeassistantconfig',
+ name='on_shopping_list_entry_updated_enabled',
+ field=models.BooleanField(default=False, help_text='PLACEHOLDER'),
+ ),
+ migrations.AlterField(
+ model_name='homeassistantconfig',
+ name='url',
+ field=models.URLField(blank=True),
+ ),
+ ]
diff --git a/cookbook/models.py b/cookbook/models.py
index 0614c974..bb9eb716 100644
--- a/cookbook/models.py
+++ b/cookbook/models.py
@@ -49,14 +49,16 @@ def get_active_space(self):
def get_shopping_share(self):
# get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
- return User.objects.raw(' '.join([
- 'SELECT auth_user.id FROM auth_user',
- 'INNER JOIN cookbook_userpreference',
- 'ON (auth_user.id = cookbook_userpreference.user_id)',
- 'INNER JOIN cookbook_userpreference_shopping_share',
- 'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
- 'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
- ]))
+ return User.objects.raw(
+ ' '.join(
+ [
+ 'SELECT auth_user.id FROM auth_user',
+ 'INNER JOIN cookbook_userpreference',
+ 'ON (auth_user.id = cookbook_userpreference.user_id)',
+ 'INNER JOIN cookbook_userpreference_shopping_share',
+ 'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
+ 'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
+ ]))
auth.models.User.add_to_class('get_user_display_name', get_user_display_name)
@@ -339,6 +341,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()
+ HomeAssistantConfig.objects.filter(space=self).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
@@ -363,6 +366,24 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
return self.name
+class HomeAssistantConfig(models.Model, PermissionModelMixin):
+ name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
+
+ url = models.URLField(blank=True)
+ token = models.CharField(max_length=512, blank=True)
+
+ todo_entity = models.CharField(max_length=128, default='todo.shopping_list')
+
+ on_shopping_list_entry_created_enabled = models.BooleanField(default=False, help_text="Enable syncing ShoppingListEntry to Homeassistant Todo List")
+ on_shopping_list_entry_updated_enabled = models.BooleanField(default=False, help_text="PLACEHOLDER")
+ on_shopping_list_entry_deleted_enabled = models.BooleanField(default=False, help_text="Enable syncing ShoppingListEntry deletion to Homeassistant Todo List")
+
+ 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'
@@ -674,10 +695,11 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
if len(inherit) > 0:
# ManyToMany cannot be updated through an UPDATE operation
for i in inherit:
- trough.objects.bulk_create([
- trough(food_id=x, foodinheritfield_id=i['id'])
- for x in Food.objects.filter(tree_filter).values_list('id', flat=True)
- ])
+ trough.objects.bulk_create(
+ [
+ trough(food_id=x, foodinheritfield_id=i['id'])
+ for x in Food.objects.filter(tree_filter).values_list('id', flat=True)
+ ])
inherit = [x['field'] for x in inherit]
for field in ['ignore_shopping', 'substitute_children', 'substitute_siblings']:
@@ -804,8 +826,9 @@ class PropertyType(models.Model, PermissionModelMixin):
unit = models.CharField(max_length=64, blank=True, null=True)
order = models.IntegerField(default=0)
description = models.CharField(max_length=512, blank=True, null=True)
- category = models.CharField(max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')),
- (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
+ category = models.CharField(
+ max_length=64, choices=((NUTRITION, _('Nutrition')), (ALLERGEN, _('Allergen')),
+ (PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
fdc_id = models.IntegerField(null=True, default=None, blank=True)
@@ -1368,19 +1391,20 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
UNIT_REPLACE = 'UNIT_REPLACE'
NAME_REPLACE = 'NAME_REPLACE'
- type = models.CharField(max_length=128,
- choices=(
- (FOOD_ALIAS, _('Food Alias')),
- (UNIT_ALIAS, _('Unit Alias')),
- (KEYWORD_ALIAS, _('Keyword Alias')),
- (DESCRIPTION_REPLACE, _('Description Replace')),
- (INSTRUCTION_REPLACE, _('Instruction Replace')),
- (NEVER_UNIT, _('Never Unit')),
- (TRANSPOSE_WORDS, _('Transpose Words')),
- (FOOD_REPLACE, _('Food Replace')),
- (UNIT_REPLACE, _('Unit Replace')),
- (NAME_REPLACE, _('Name Replace')),
- ))
+ type = models.CharField(
+ max_length=128,
+ choices=(
+ (FOOD_ALIAS, _('Food Alias')),
+ (UNIT_ALIAS, _('Unit Alias')),
+ (KEYWORD_ALIAS, _('Keyword Alias')),
+ (DESCRIPTION_REPLACE, _('Description Replace')),
+ (INSTRUCTION_REPLACE, _('Instruction Replace')),
+ (NEVER_UNIT, _('Never Unit')),
+ (TRANSPOSE_WORDS, _('Transpose Words')),
+ (FOOD_REPLACE, _('Food Replace')),
+ (UNIT_REPLACE, _('Unit Replace')),
+ (NAME_REPLACE, _('Name Replace')),
+ ))
name = models.CharField(max_length=128, default='')
description = models.TextField(blank=True, null=True)
diff --git a/cookbook/serializer.py b/cookbook/serializer.py
index d4f7bd47..8e338c17 100644
--- a/cookbook/serializer.py
+++ b/cookbook/serializer.py
@@ -34,7 +34,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
Step, Storage, Supermarket, SupermarketCategory,
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
- UserFile, UserPreference, UserSpace, ViewLog)
+ UserFile, UserPreference, UserSpace, ViewLog, HomeAssistantConfig)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL
@@ -413,6 +413,27 @@ class StorageSerializer(SpacedModelSerializer):
}
+class HomeAssistantConfigSerializer(SpacedModelSerializer):
+
+ def create(self, validated_data):
+ validated_data['created_by'] = self.context['request'].user
+ return super().create(validated_data)
+
+ class Meta:
+ model = HomeAssistantConfig
+ fields = (
+ 'id', 'name', 'url', 'token', 'todo_entity',
+ '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
@@ -665,8 +686,9 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
properties = validated_data.pop('properties', None)
- obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
- defaults=validated_data)
+ obj, created = Food.objects.get_or_create(
+ name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
+ defaults=validated_data)
if properties and len(properties) > 0:
for p in properties:
@@ -1254,8 +1276,9 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
if obj.email:
try:
- if InviteLink.objects.filter(space=self.context['request'].space,
- created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
+ if InviteLink.objects.filter(
+ space=self.context['request'].space,
+ created_at__gte=datetime.now() - timedelta(hours=4)).count() < 20:
message = _('Hello') + '!\n\n' + _('You have been invited by ') + escape(
self.context['request'].user.get_user_display_name())
message += _(' to join their Tandoor Recipes space ') + escape(
@@ -1410,12 +1433,15 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
- list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False,
- help_text=_("Existing shopping list to update"))
- ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
- "List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
- servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_(
- "Providing a list_recipe ID and servings of 0 will delete that shopping list."))
+ list_recipe = serializers.IntegerField(
+ write_only=True, allow_null=True, required=False,
+ help_text=_("Existing shopping list to update"))
+ ingredients = serializers.IntegerField(
+ write_only=True, allow_null=True, required=False, help_text=_(
+ "List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
+ servings = serializers.IntegerField(
+ default=1, write_only=True, allow_null=True, required=False, help_text=_(
+ "Providing a list_recipe ID and servings of 0 will delete that shopping list."))
class Meta:
model = Recipe
@@ -1423,12 +1449,15 @@ class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
- amount = serializers.IntegerField(write_only=True, allow_null=True, required=False,
- help_text=_("Amount of food to add to the shopping list"))
- unit = serializers.IntegerField(write_only=True, allow_null=True, required=False,
- help_text=_("ID of unit to use for the shopping list"))
- delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True,
- help_text=_("When set to true will delete all food from active shopping lists."))
+ amount = serializers.IntegerField(
+ write_only=True, allow_null=True, required=False,
+ help_text=_("Amount of food to add to the shopping list"))
+ unit = serializers.IntegerField(
+ write_only=True, allow_null=True, required=False,
+ help_text=_("ID of unit to use for the shopping list"))
+ delete = serializers.ChoiceField(
+ choices=['true'], write_only=True, allow_null=True, allow_blank=True,
+ help_text=_("When set to true will delete all food from active shopping lists."))
class Meta:
model = Recipe
diff --git a/cookbook/signals.py b/cookbook/signals.py
index a93ffba1..2d94b1d0 100644
--- a/cookbook/signals.py
+++ b/cookbook/signals.py
@@ -4,11 +4,12 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.postgres.search import SearchVector
from django.core.cache import caches
-from django.db.models.signals import post_save
+from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.utils import translation
from django_scopes import scope, scopes_disabled
+from cookbook.connectors.connector_manager import ConnectorManager
from cookbook.helper.cache_helper import CacheHelper
from cookbook.helper.shopping_helper import RecipeShoppingEditor
from cookbook.managers import DICTIONARY
@@ -161,3 +162,8 @@ def clear_unit_cache(sender, instance=None, created=False, **kwargs):
def clear_property_type_cache(sender, instance=None, created=False, **kwargs):
if instance:
caches['default'].delete(CacheHelper(instance.space).PROPERTY_TYPE_CACHE_KEY)
+
+
+handler = ConnectorManager()
+post_save.connect(handler, dispatch_uid="connector_manager")
+post_delete.connect(handler, dispatch_uid="connector_manager")
diff --git a/cookbook/tables.py b/cookbook/tables.py
index 6392f791..8ffe2a53 100644
--- a/cookbook/tables.py
+++ b/cookbook/tables.py
@@ -3,7 +3,7 @@ from django.utils.html import format_html
from django.utils.translation import gettext as _
from django_tables2.utils import A
-from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog
+from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog, HomeAssistantConfig
class StorageTable(tables.Table):
@@ -15,6 +15,15 @@ class StorageTable(tables.Table):
fields = ('id', 'name', 'method')
+class HomeAssistantConfigTable(tables.Table):
+ id = tables.LinkColumn('edit_home_assistant_config', args=[A('id')])
+
+ class Meta:
+ model = HomeAssistantConfig
+ template_name = 'generic/table_template.html'
+ fields = ('id', 'name', 'url')
+
+
class ImportLogTable(tables.Table):
sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')])
diff --git a/cookbook/urls.py b/cookbook/urls.py
index 9566541a..96735037 100644
--- a/cookbook/urls.py
+++ b/cookbook/urls.py
@@ -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, HomeAssistantConfig)
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'home-assistant-config', api.HomeAssistantConfigViewSet)
router.register(r'supermarket', api.SupermarketViewSet)
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
@@ -114,6 +115,7 @@ urlpatterns = [
path('edit/recipe/convert//', edit.convert_recipe, name='edit_convert_recipe'),
path('edit/storage//', edit.edit_storage, name='edit_storage'),
+ path('edit/home-assistant-config//', edit.edit_home_assistant_config, name='edit_home_assistant_config'),
path('delete/recipe-source//', delete.delete_recipe_source, name='delete_recipe_source'),
@@ -166,7 +168,7 @@ urlpatterns = [
]
generic_models = (
- Recipe, RecipeImport, Storage, RecipeBook, SyncLog, Sync,
+ Recipe, RecipeImport, Storage, HomeAssistantConfig, RecipeBook, SyncLog, Sync,
Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space
)
diff --git a/cookbook/views/api.py b/cookbook/views/api.py
index 98b4d9bc..b5f16568 100644
--- a/cookbook/views/api.py
+++ b/cookbook/views/api.py
@@ -460,6 +460,16 @@ class StorageViewSet(viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space)
+class HomeAssistantConfigViewSet(viewsets.ModelViewSet):
+ # TODO handle delete protect error and adjust test
+ queryset = HomeAssistantConfig.objects
+ serializer_class = HomeAssistantConfigSerializer
+ 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
diff --git a/cookbook/views/delete.py b/cookbook/views/delete.py
index 411eb323..b6cf857b 100644
--- a/cookbook/views/delete.py
+++ b/cookbook/views/delete.py
@@ -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, HomeAssistantConfig)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -122,6 +122,29 @@ class StorageDelete(GroupRequiredMixin, DeleteView):
return HttpResponseRedirect(reverse('list_storage'))
+class HomeAssistantConfigDelete(GroupRequiredMixin, DeleteView):
+ groups_required = ['admin']
+ template_name = "generic/delete_template.html"
+ model = HomeAssistantConfig
+ success_url = reverse_lazy('list_storage')
+
+ def get_context_data(self, **kwargs):
+ context = super(HomeAssistantConfigDelete, self).get_context_data(**kwargs)
+ context['title'] = _("HomeAssistant Config Backend")
+ return context
+
+ def post(self, request, *args, **kwargs):
+ try:
+ return self.delete(request, *args, **kwargs)
+ except ProtectedError:
+ messages.add_message(
+ request,
+ messages.WARNING,
+ _('Could not delete this storage backend as it is used in at least one monitor.') # noqa: E501
+ )
+ return HttpResponseRedirect(reverse('list_storage'))
+
+
class CommentDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Comment
diff --git a/cookbook/views/edit.py b/cookbook/views/edit.py
index 1726b2b5..ce4aac27 100644
--- a/cookbook/views/edit.py
+++ b/cookbook/views/edit.py
@@ -1,3 +1,4 @@
+import copy
import os
from django.contrib import messages
@@ -8,15 +9,17 @@ 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, HomeAssistantConfigForm
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, HomeAssistantConfig
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):
@@ -76,7 +79,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!'))
@@ -87,17 +90,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()
@@ -113,8 +117,8 @@ def edit_storage(request, pk):
)
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(
@@ -124,6 +128,47 @@ def edit_storage(request, pk):
)
+@group_required('admin')
+def edit_home_assistant_config(request, pk):
+ instance: HomeAssistantConfig = get_object_or_404(HomeAssistantConfig, 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 homeassistant config!'))
+ return HttpResponseRedirect(reverse('edit_home_assistant_config'))
+
+ if request.space.demo or settings.HOSTED:
+ messages.add_message(request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
+ return redirect('index')
+
+ if request.method == "POST":
+ form = HomeAssistantConfigForm(request.POST, instance=copy.deepcopy(instance))
+ if form.is_valid():
+ instance.name = form.cleaned_data['name']
+ instance.url = form.cleaned_data['url']
+ instance.todo_entity = form.cleaned_data['todo_entity']
+ instance.on_shopping_list_entry_created_enabled = form.cleaned_data['on_shopping_list_entry_created_enabled']
+ instance.on_shopping_list_entry_updated_enabled = form.cleaned_data['on_shopping_list_entry_updated_enabled']
+ instance.on_shopping_list_entry_deleted_enabled = form.cleaned_data['on_shopping_list_entry_deleted_enabled']
+
+ if form.cleaned_data['token'] != VALUE_NOT_CHANGED:
+ instance.token = form.cleaned_data['token']
+
+ instance.save()
+
+ messages.add_message(request, messages.SUCCESS, _('HomeAssistant config saved!'))
+ else:
+ messages.add_message(request, messages.ERROR, _('There was an error updating this config!'))
+ else:
+ instance.token = VALUE_NOT_CHANGED
+ form = HomeAssistantConfigForm(instance=instance)
+
+ return render(
+ request,
+ 'generic/edit_template.html',
+ {'form': form, 'title': _('HomeAssistantConfig')}
+ )
+
+
class CommentUpdate(OwnerRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = Comment
diff --git a/cookbook/views/lists.py b/cookbook/views/lists.py
index b9ef9cc7..20dbb73c 100644
--- a/cookbook/views/lists.py
+++ b/cookbook/views/lists.py
@@ -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, HomeAssistantConfig
+from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable, HomeAssistantConfigTable
@group_required('admin')
@@ -65,17 +65,31 @@ def storage(request):
)
+@group_required('admin')
+def home_assistant_config(request):
+ table = HomeAssistantConfigTable(HomeAssistantConfig.objects.filter(space=request.space).all())
+ RequestConfig(request, paginate={'per_page': 25}).configure(table)
+
+ return render(
+ request, 'generic/list_template.html', {
+ 'title': _("HomeAssistant Config Backend"),
+ 'table': table,
+ 'create_url': 'new_home_assistant_config'
+ })
+
+
@group_required('admin')
def invite_link(request):
table = InviteLinkTable(
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=request.space).all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)
- return render(request, 'generic/list_template.html', {
- 'title': _("Invite Links"),
- 'table': table,
- 'create_url': 'new_invite_link'
- })
+ return render(
+ request, 'generic/list_template.html', {
+ 'title': _("Invite Links"),
+ 'table': table,
+ 'create_url': 'new_invite_link'
+ })
@group_required('user')
@@ -195,7 +209,7 @@ def custom_filter(request):
def user_file(request):
try:
current_file_size_mb = UserFile.objects.filter(space=request.space).aggregate(Sum('file_size_kb'))[
- 'file_size_kb__sum'] / 1000
+ 'file_size_kb__sum'] / 1000
except TypeError:
current_file_size_mb = 0
diff --git a/cookbook/views/new.py b/cookbook/views/new.py
index d4368f67..263dca09 100644
--- a/cookbook/views/new.py
+++ b/cookbook/views/new.py
@@ -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, HomeAssistantConfigForm
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, HomeAssistantConfig
from recipes import settings
@@ -71,6 +70,30 @@ class StorageCreate(GroupRequiredMixin, CreateView):
return context
+class HomeAssistantConfigCreate(GroupRequiredMixin, CreateView):
+ groups_required = ['admin']
+ template_name = "generic/new_template.html"
+ model = HomeAssistantConfig
+ form_class = HomeAssistantConfigForm
+ success_url = reverse_lazy('list_home_assistant_config')
+
+ def form_valid(self, form):
+ if self.request.space.demo or settings.HOSTED:
+ messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
+ return redirect('index')
+
+ obj = form.save(commit=False)
+ obj.created_by = self.request.user
+ obj.space = self.request.space
+ obj.save()
+ return HttpResponseRedirect(reverse('edit_home_assistant_config', kwargs={'pk': obj.pk}))
+
+ def get_context_data(self, **kwargs):
+ context = super(HomeAssistantConfigCreate, self).get_context_data(**kwargs)
+ context['title'] = _("HomeAssistant Config Backend")
+ return context
+
+
@group_required('user')
def create_new_external_recipe(request, import_id):
if request.method == "POST":