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:
parent
d493ba72a1
commit
e5f0c19cdc
@ -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')
|
||||
|
0
cookbook/connectors/__init__.py
Normal file
0
cookbook/connectors/__init__.py
Normal file
41
cookbook/connectors/connector.py
Normal file
41
cookbook/connectors/connector.py
Normal 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
|
98
cookbook/connectors/connector_manager.py
Normal file
98
cookbook/connectors/connector_manager.py
Normal 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
|
62
cookbook/connectors/homeassistant.py
Normal file
62
cookbook/connectors/homeassistant.py
Normal 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
|
@ -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'
|
||||
|
@ -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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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')])
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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":
|
||||
|
Loading…
Reference in New Issue
Block a user