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":