Add ConnectorManager component which allows for Connectors to listen to triggers and do actions on them. Also add HomeAssistantConfig which stores the configuration for the HomeAssistantConnector

This commit is contained in:
Mikhail Epifanov 2024-01-11 22:05:34 +01:00
parent d493ba72a1
commit e5f0c19cdc
No known key found for this signature in database
18 changed files with 566 additions and 70 deletions

View File

@ -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')

View File

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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=_('<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=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'

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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')])

View File

@ -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/<int:pk>/', edit.convert_recipe, name='edit_convert_recipe'),
path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'),
path('edit/home-assistant-config/<int:pk>/', edit.edit_home_assistant_config, name='edit_home_assistant_config'),
path('delete/recipe-source/<int:pk>/', 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
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

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