diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 202de7b2c..6eb6195d9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -78,6 +78,7 @@ jobs:
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
- name: Django Testing project
+ timeout-minutes: 6
run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml
- name: Publish Test Results
diff --git a/.gitignore b/.gitignore
index 553403a5f..2a977befc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,7 +54,6 @@ docs/_build/
target/
\.idea/dataSources/
-
\.idea/dataSources\.xml
\.idea/dataSources\.local\.xml
@@ -87,3 +86,5 @@ vue/webpack-stats.json
cookbook/templates/sw.js
.prettierignore
vue/.yarn
+vue3/.vite
+vue3/node_modules
diff --git a/cookbook/admin.py b/cookbook/admin.py
index fc148afe5..96ebad971 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, ConnectorConfig)
class CustomUserAdmin(UserAdmin):
@@ -95,6 +95,14 @@ class StorageAdmin(admin.ModelAdmin):
admin.site.register(Storage, StorageAdmin)
+class ConnectorConfigAdmin(admin.ModelAdmin):
+ list_display = ('id', 'name', 'type', 'enabled', 'url')
+ search_fields = ('name', 'url')
+
+
+admin.site.register(ConnectorConfig, ConnectorConfigAdmin)
+
+
class SyncAdmin(admin.ModelAdmin):
list_display = ('storage', 'path', 'active', 'last_checked')
search_fields = ('storage__name', 'path')
diff --git a/cookbook/apps.py b/cookbook/apps.py
index e551319db..63b8296e8 100644
--- a/cookbook/apps.py
+++ b/cookbook/apps.py
@@ -3,6 +3,7 @@ import traceback
from django.apps import AppConfig
from django.conf import settings
from django.db import OperationalError, ProgrammingError
+from django.db.models.signals import post_save, post_delete
from django_scopes import scopes_disabled
from recipes.settings import DEBUG
@@ -14,6 +15,16 @@ class CookbookConfig(AppConfig):
def ready(self):
import cookbook.signals # noqa
+ if not settings.DISABLE_EXTERNAL_CONNECTORS:
+ try:
+ from cookbook.connectors.connector_manager import ConnectorManager # Needs to be here to prevent loading race condition of oauth2 modules in models.py
+ handler = ConnectorManager()
+ post_save.connect(handler, dispatch_uid="connector_manager")
+ post_delete.connect(handler, dispatch_uid="connector_manager")
+ except Exception as e:
+ traceback.print_exc()
+ print('Failed to initialize connectors')
+ pass
# if not settings.DISABLE_TREE_FIX_STARTUP:
# # when starting up run fix_tree to:
# # a) make sure that nodes are sorted when switching between sort modes
diff --git a/cookbook/connectors/__init__.py b/cookbook/connectors/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/cookbook/connectors/connector.py b/cookbook/connectors/connector.py
new file mode 100644
index 000000000..27e9408db
--- /dev/null
+++ b/cookbook/connectors/connector.py
@@ -0,0 +1,29 @@
+from abc import ABC, abstractmethod
+
+from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
+
+
+# A Connector is 'destroyed' & recreated each time 'any' ConnectorConfig in a space changes.
+class Connector(ABC):
+ @abstractmethod
+ def __init__(self, config: ConnectorConfig):
+ pass
+
+ @abstractmethod
+ async def on_shopping_list_entry_created(self, space: Space, instance: ShoppingListEntry) -> None:
+ pass
+
+ # This method might not trigger on 'direct' entry updates: https://stackoverflow.com/a/35238823
+ @abstractmethod
+ async def on_shopping_list_entry_updated(self, space: Space, instance: ShoppingListEntry) -> None:
+ pass
+
+ @abstractmethod
+ async def on_shopping_list_entry_deleted(self, space: Space, instance: ShoppingListEntry) -> None:
+ pass
+
+ @abstractmethod
+ async def close(self) -> None:
+ pass
+
+ # TODO: Add Recipes & possibly Meal Place listeners/hooks (And maybe more?)
diff --git a/cookbook/connectors/connector_manager.py b/cookbook/connectors/connector_manager.py
new file mode 100644
index 000000000..e247db305
--- /dev/null
+++ b/cookbook/connectors/connector_manager.py
@@ -0,0 +1,179 @@
+import asyncio
+import logging
+import queue
+import threading
+from asyncio import Task
+from dataclasses import dataclass
+from enum import Enum
+from types import UnionType
+from typing import List, Any, Dict, Optional, Type
+
+from django.conf import settings
+from django_scopes import scope
+
+from cookbook.connectors.connector import Connector
+from cookbook.connectors.homeassistant import HomeAssistant
+from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
+
+REGISTERED_CLASSES: UnionType | Type = ShoppingListEntry
+
+
+class ActionType(Enum):
+ CREATED = 1
+ UPDATED = 2
+ DELETED = 3
+
+
+@dataclass
+class Work:
+ instance: REGISTERED_CLASSES | ConnectorConfig
+ actionType: ActionType
+
+
+# The way ConnectionManager works is as follows:
+# 1. On init, it starts a worker & creates a queue for 'Work'
+# 2. Then any time its called, it verifies the type of action (create/update/delete) and if the item is of interest, pushes the Work (non-blocking) to the queue.
+# 3. The worker consumes said work from the queue.
+# 3.1 If the work is of type ConnectorConfig, it flushes its cache of known connectors (per space.id)
+# 3.2 If work is of type REGISTERED_CLASSES, it asynchronously fires of all connectors and wait for them to finish (runtime should depend on the 'slowest' connector)
+# 4. Work is marked as consumed, and next entry of the queue is consumed.
+# Each 'Work' is processed in sequential by the worker, so the throughput is about [workers * the slowest connector]
+class ConnectorManager:
+ _queue: queue.Queue
+ _listening_to_classes = REGISTERED_CLASSES | ConnectorConfig
+
+ def __init__(self):
+ self._queue = queue.Queue(maxsize=settings.EXTERNAL_CONNECTORS_QUEUE_SIZE)
+ self._worker = threading.Thread(target=self.worker, args=(0, self._queue,), daemon=True)
+ self._worker.start()
+
+ # Called by post save & post delete signals
+ def __call__(self, instance: Any, **kwargs) -> None:
+ if not isinstance(instance, self._listening_to_classes) or not hasattr(instance, "space"):
+ return
+
+ action_type: ActionType
+ if "created" in kwargs and kwargs["created"]:
+ action_type = ActionType.CREATED
+ elif "created" in kwargs and not kwargs["created"]:
+ action_type = ActionType.UPDATED
+ elif "origin" in kwargs:
+ action_type = ActionType.DELETED
+ else:
+ return
+
+ try:
+ self._queue.put_nowait(Work(instance, action_type))
+ except queue.Full:
+ logging.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
+ return
+
+ def stop(self):
+ self._queue.join()
+ self._worker.join()
+
+ @staticmethod
+ def worker(worker_id: int, worker_queue: queue.Queue):
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ logging.info(f"started ConnectionManager worker {worker_id}")
+
+ # When multiple workers are used, please make sure the cache is shared across all threads, otherwise it might lead to un-expected behavior.
+ _connectors_cache: Dict[int, List[Connector]] = dict()
+
+ while True:
+ try:
+ item: Optional[Work] = worker_queue.get()
+ except KeyboardInterrupt:
+ break
+
+ if item is None:
+ break
+
+ # If a Connector was changed/updated, refresh connector from the database for said space
+ refresh_connector_cache = isinstance(item.instance, ConnectorConfig)
+
+ space: Space = item.instance.space
+ connectors: Optional[List[Connector]] = _connectors_cache.get(space.id)
+
+ if connectors is None or refresh_connector_cache:
+ if connectors is not None:
+ loop.run_until_complete(close_connectors(connectors))
+
+ with scope(space=space):
+ connectors: List[Connector] = list()
+ for config in space.connectorconfig_set.all():
+ config: ConnectorConfig = config
+ if not config.enabled:
+ continue
+
+ try:
+ connector: Optional[Connector] = ConnectorManager.get_connected_for_config(config)
+ except BaseException:
+ logging.exception(f"failed to initialize {config.name}")
+ continue
+
+ if connector is not None:
+ connectors.append(connector)
+
+ _connectors_cache[space.id] = connectors
+
+ if len(connectors) == 0 or refresh_connector_cache:
+ worker_queue.task_done()
+ continue
+
+ loop.run_until_complete(run_connectors(connectors, space, item.instance, item.actionType))
+ worker_queue.task_done()
+
+ logging.info(f"terminating ConnectionManager worker {worker_id}")
+
+ asyncio.set_event_loop(None)
+ loop.close()
+
+ @staticmethod
+ def get_connected_for_config(config: ConnectorConfig) -> Optional[Connector]:
+ match config.type:
+ case ConnectorConfig.HOMEASSISTANT:
+ return HomeAssistant(config)
+ case _:
+ return None
+
+
+async def close_connectors(connectors: List[Connector]):
+ tasks: List[Task] = [asyncio.create_task(connector.close()) for connector in connectors]
+
+ if len(tasks) == 0:
+ return
+
+ try:
+ await asyncio.gather(*tasks, return_exceptions=False)
+ except BaseException:
+ logging.exception("received an exception while closing one of the connectors")
+
+
+async def run_connectors(connectors: List[Connector], space: Space, instance: REGISTERED_CLASSES, action_type: ActionType):
+ tasks: List[Task] = list()
+
+ if isinstance(instance, ShoppingListEntry):
+ shopping_list_entry: ShoppingListEntry = instance
+
+ match action_type:
+ case ActionType.CREATED:
+ for connector in connectors:
+ tasks.append(asyncio.create_task(connector.on_shopping_list_entry_created(space, shopping_list_entry)))
+ case ActionType.UPDATED:
+ for connector in connectors:
+ tasks.append(asyncio.create_task(connector.on_shopping_list_entry_updated(space, shopping_list_entry)))
+ case ActionType.DELETED:
+ for connector in connectors:
+ tasks.append(asyncio.create_task(connector.on_shopping_list_entry_deleted(space, shopping_list_entry)))
+
+ if len(tasks) == 0:
+ return
+
+ try:
+ # Wait for all async tasks to finish, if one fails, the others still continue.
+ await asyncio.gather(*tasks, return_exceptions=False)
+ except BaseException:
+ logging.exception("received an exception from one of the connectors")
diff --git a/cookbook/connectors/homeassistant.py b/cookbook/connectors/homeassistant.py
new file mode 100644
index 000000000..e653c3f3d
--- /dev/null
+++ b/cookbook/connectors/homeassistant.py
@@ -0,0 +1,85 @@
+import logging
+from logging import Logger
+
+from homeassistant_api import Client, HomeassistantAPIError, Domain
+
+from cookbook.connectors.connector import Connector
+from cookbook.models import ShoppingListEntry, ConnectorConfig, Space
+
+
+class HomeAssistant(Connector):
+ _domains_cache: dict[str, Domain]
+ _config: ConnectorConfig
+ _logger: Logger
+ _client: Client
+
+ def __init__(self, config: ConnectorConfig):
+ if not config.token or not config.url or not config.todo_entity:
+ raise ValueError("config for HomeAssistantConnector in incomplete")
+
+ self._domains_cache = dict()
+ self._config = config
+ self._logger = logging.getLogger("connector.HomeAssistant")
+ self._client = Client(self._config.url, self._config.token, async_cache_session=False, use_async=True)
+
+ async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
+ if not self._config.on_shopping_list_entry_created_enabled:
+ return
+
+ item, description = _format_shopping_list_entry(shopping_list_entry)
+
+ todo_domain = self._domains_cache.get('todo')
+ try:
+ if todo_domain is None:
+ todo_domain = await self._client.async_get_domain('todo')
+ self._domains_cache['todo'] = todo_domain
+
+ logging.debug(f"pushing {item} to {self._config.name}")
+ await todo_domain.add_item(entity_id=self._config.todo_entity, item=item)
+ except HomeassistantAPIError as err:
+ self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
+
+ async def on_shopping_list_entry_updated(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
+ if not self._config.on_shopping_list_entry_updated_enabled:
+ return
+ pass
+
+ async def on_shopping_list_entry_deleted(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
+ if not self._config.on_shopping_list_entry_deleted_enabled:
+ return
+
+ item, description = _format_shopping_list_entry(shopping_list_entry)
+
+ todo_domain = self._domains_cache.get('todo')
+ try:
+ if todo_domain is None:
+ todo_domain = await self._client.async_get_domain('todo')
+ self._domains_cache['todo'] = todo_domain
+
+ logging.debug(f"deleting {item} from {self._config.name}")
+ await todo_domain.remove_item(entity_id=self._config.todo_entity, item=item)
+ except HomeassistantAPIError as err:
+ self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
+
+ async def close(self) -> None:
+ await self._client.async_cache_session.close()
+
+
+def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
+ item = shopping_list_entry.food.name
+ if shopping_list_entry.amount > 0:
+ item += f" ({shopping_list_entry.amount:.2f}".rstrip('0').rstrip('.')
+ if shopping_list_entry.unit and shopping_list_entry.unit.base_unit and len(shopping_list_entry.unit.base_unit) > 0:
+ item += f" {shopping_list_entry.unit.base_unit})"
+ elif shopping_list_entry.unit and shopping_list_entry.unit.name and len(shopping_list_entry.unit.name) > 0:
+ item += f" {shopping_list_entry.unit.name})"
+ else:
+ item += ")"
+
+ description = "Imported by TandoorRecipes"
+ if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0:
+ description += f", created by {shopping_list_entry.created_by.first_name}"
+ else:
+ description += f", created by {shopping_list_entry.created_by.username}"
+
+ return item, description
diff --git a/cookbook/forms.py b/cookbook/forms.py
index 40237cae2..872bfa01e 100644
--- a/cookbook/forms.py
+++ b/cookbook/forms.py
@@ -10,7 +10,7 @@ from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from hcaptcha.fields import hCaptchaField
-from .models import Comment, InviteLink, Keyword, Recipe, SearchPreference, Space, Storage, Sync, User, UserPreference
+from .models import Comment, InviteLink, Keyword, Recipe, SearchPreference, Space, Storage, Sync, User, UserPreference, ConnectorConfig
class SelectWidget(widgets.Select):
@@ -160,6 +160,51 @@ class StorageForm(forms.ModelForm):
help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (/remote.php/webdav/ is added automatically)'), }
+class ConnectorConfigForm(forms.ModelForm):
+ enabled = forms.BooleanField(
+ help_text="Is the connector enabled",
+ required=False,
+ )
+
+ on_shopping_list_entry_created_enabled = forms.BooleanField(
+ help_text="Enable action for ShoppingListEntry created events",
+ required=False,
+ )
+
+ on_shopping_list_entry_updated_enabled = forms.BooleanField(
+ help_text="Enable action for ShoppingListEntry updated events",
+ required=False,
+ )
+
+ on_shopping_list_entry_deleted_enabled = forms.BooleanField(
+ help_text="Enable action for ShoppingListEntry deleted events",
+ required=False,
+ )
+
+ update_token = forms.CharField(
+ widget=forms.TextInput(attrs={'autocomplete': 'update-token', 'type': 'password'}),
+ required=False,
+ help_text=_('Long Lived Access Token for your HomeAssistant instance')
+ )
+
+ url = forms.URLField(
+ required=False,
+ help_text=_('Something like http://homeassistant.local:8123/api'),
+ )
+
+ class Meta:
+ model = ConnectorConfig
+
+ fields = (
+ 'name', 'type', 'enabled', 'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
+ 'on_shopping_list_entry_deleted_enabled', 'url', 'todo_entity',
+ )
+
+ help_texts = {
+ 'url': _('http://homeassistant.local:8123/api for example'),
+ }
+
+
# TODO: Deprecate
# class RecipeBookEntryForm(forms.ModelForm):
# prefix = 'bookmark'
diff --git a/cookbook/helper/context_processors.py b/cookbook/helper/context_processors.py
index 30e704c13..9beb8b9bd 100644
--- a/cookbook/helper/context_processors.py
+++ b/cookbook/helper/context_processors.py
@@ -11,4 +11,5 @@ def context_settings(request):
'PRIVACY_URL': settings.PRIVACY_URL,
'IMPRINT_URL': settings.IMPRINT_URL,
'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL,
+ 'DISABLE_EXTERNAL_CONNECTORS': settings.DISABLE_EXTERNAL_CONNECTORS,
}
diff --git a/cookbook/integration/mealmaster.py b/cookbook/integration/mealmaster.py
index 9dce4fdd9..786103b48 100644
--- a/cookbook/integration/mealmaster.py
+++ b/cookbook/integration/mealmaster.py
@@ -22,7 +22,7 @@ class MealMaster(Integration):
if 'Yield:' in line:
servings_text = line.replace('Yield:', '').strip()
else:
- if re.match('\s{2,}([0-9])+', line):
+ if re.match(r'\s{2,}([0-9])+', line):
ingredients.append(line.strip())
else:
directions.append(line.strip())
diff --git a/cookbook/locale/sv/LC_MESSAGES/django.po b/cookbook/locale/sv/LC_MESSAGES/django.po
index 5137fb7eb..6fec1dc0e 100644
--- a/cookbook/locale/sv/LC_MESSAGES/django.po
+++ b/cookbook/locale/sv/LC_MESSAGES/django.po
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-04-11 15:09+0200\n"
-"PO-Revision-Date: 2022-04-17 00:31+0000\n"
-"Last-Translator: Oskar Stenberg <01ste02@gmail.com>\n"
+"PO-Revision-Date: 2024-02-27 12:19+0000\n"
+"Last-Translator: Lukas Åteg \n"
"Language-Team: Swedish \n"
"Language: sv\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
-"X-Generator: Weblate 4.10.1\n"
+"X-Generator: Weblate 4.15\n"
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:91
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
@@ -1812,7 +1812,7 @@ msgstr "Google ld+json info"
#: .\cookbook\templates\url_import.html:268
msgid "GitHub Issues"
-msgstr "GitHub Issues"
+msgstr "GitHub Problem"
#: .\cookbook\templates\url_import.html:270
msgid "Recipe Markup Specification"
@@ -1852,7 +1852,7 @@ msgstr "Kunde inte tolka korrekt..."
msgid "Batch edit done. %(count)d recipe was updated."
msgid_plural "Batch edit done. %(count)d Recipes where updated."
msgstr[0] "Batchredigering klar. %(count)d recept uppdaterades."
-msgstr[1] "Batchredigering klar. %(count)d recept uppdaterades."
+msgstr[1] "Batchredigering klar. %(count)d recepten uppdaterades."
#: .\cookbook\views\delete.py:72
msgid "Monitor"
diff --git a/cookbook/migrations/0214_cooklog_comment_cooklog_updated_at_and_more.py b/cookbook/migrations/0214_cooklog_comment_cooklog_updated_at_and_more.py
new file mode 100644
index 000000000..fe28d646f
--- /dev/null
+++ b/cookbook/migrations/0214_cooklog_comment_cooklog_updated_at_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.2.7 on 2024-02-24 12:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('cookbook', '0213_remove_property_property_unique_import_food_per_space_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cooklog',
+ name='comment',
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='cooklog',
+ name='updated_at',
+ field=models.DateTimeField(auto_now=True),
+ ),
+ migrations.AlterField(
+ model_name='cooklog',
+ name='rating',
+ field=models.IntegerField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name='cooklog',
+ name='servings',
+ field=models.IntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/cookbook/migrations/0215_connectorconfig.py b/cookbook/migrations/0215_connectorconfig.py
new file mode 100644
index 000000000..eaee40b2c
--- /dev/null
+++ b/cookbook/migrations/0215_connectorconfig.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.2.10 on 2024-02-26 14:41
+
+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', '0214_cooklog_comment_cooklog_updated_at_and_more'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ConnectorConfig',
+ 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)])),
+ ('type', models.CharField(choices=[('HomeAssistant', 'HomeAssistant')], default='HomeAssistant', max_length=128)),
+ ('enabled', models.BooleanField(default=True, help_text='Is Connector Enabled')),
+ ('on_shopping_list_entry_created_enabled', models.BooleanField(default=False)),
+ ('on_shopping_list_entry_updated_enabled', models.BooleanField(default=False)),
+ ('on_shopping_list_entry_deleted_enabled', models.BooleanField(default=False)),
+ ('url', models.URLField(blank=True, null=True)),
+ ('token', models.CharField(blank=True, max_length=512, null=True)),
+ ('todo_entity', models.CharField(blank=True, max_length=128, null=True)),
+ ('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/models.py b/cookbook/models.py
index fc1a62ad1..4b8e51726 100644
--- a/cookbook/models.py
+++ b/cookbook/models.py
@@ -367,6 +367,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()
+ ConnectorConfig.objects.filter(space=self).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
@@ -393,6 +394,31 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
return self.name
+class ConnectorConfig(models.Model, PermissionModelMixin):
+ HOMEASSISTANT = 'HomeAssistant'
+ CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),)
+
+ name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
+ type = models.CharField(
+ choices=CONNECTER_TYPE, max_length=128, default=HOMEASSISTANT
+ )
+
+ enabled = models.BooleanField(default=True, help_text="Is Connector Enabled")
+ on_shopping_list_entry_created_enabled = models.BooleanField(default=False)
+ on_shopping_list_entry_updated_enabled = models.BooleanField(default=False)
+ on_shopping_list_entry_deleted_enabled = models.BooleanField(default=False)
+
+ url = models.URLField(blank=True, null=True)
+ token = models.CharField(max_length=512, blank=True, null=True)
+ todo_entity = models.CharField(max_length=128, blank=True, null=True)
+
+ created_by = models.ForeignKey(User, on_delete=models.PROTECT)
+
+ space = models.ForeignKey(Space, on_delete=models.CASCADE)
+ objects = ScopedManager(space='space')
+
+
+
class UserPreference(models.Model, PermissionModelMixin):
# Themes
BOOTSTRAP = 'BOOTSTRAP'
@@ -1293,10 +1319,13 @@ class TelegramBot(models.Model, PermissionModelMixin):
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
+ rating = models.IntegerField(null=True, blank=True)
+ servings = models.IntegerField(null=True, blank=True)
+ comment = models.TextField(null=True, blank=True)
+
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(default=timezone.now)
- rating = models.IntegerField(null=True)
- servings = models.IntegerField(default=0)
+ updated_at = models.DateTimeField(auto_now=True)
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
diff --git a/cookbook/serializer.py b/cookbook/serializer.py
index 8446e3fb5..ec9c0d4f1 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, ConnectorConfig)
from cookbook.templatetags.custom_tags import markdown
from recipes.settings import AWS_ENABLED, MEDIA_URL
@@ -337,7 +337,7 @@ class UserSpaceSerializer(WritableNestedModelSerializer):
class Meta:
model = UserSpace
fields = (
- 'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
+ 'id', 'user', 'space', 'groups', 'active', 'internal_note', 'invite_link', 'created_at', 'updated_at',)
read_only_fields = ('id', 'invite_link', 'created_at', 'updated_at', 'space')
@@ -420,6 +420,27 @@ class StorageSerializer(SpacedModelSerializer):
}
+class ConnectorConfigConfigSerializer(SpacedModelSerializer):
+
+ def create(self, validated_data):
+ validated_data['created_by'] = self.context['request'].user
+ return super().create(validated_data)
+
+ class Meta:
+ model = ConnectorConfig
+ fields = (
+ 'id', 'name', 'url', 'token', 'todo_entity', 'enabled',
+ 'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
+ 'on_shopping_list_entry_deleted_enabled', 'created_by'
+ )
+
+ read_only_fields = ('created_by',)
+
+ extra_kwargs = {
+ 'token': {'write_only': True},
+ }
+
+
class SyncSerializer(SpacedModelSerializer):
class Meta:
model = Sync
@@ -772,8 +793,7 @@ class IngredientSerializer(IngredientSimpleSerializer):
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
ingredients = IngredientSerializer(many=True)
- ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
- ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue')
+ instructions_markdown = serializers.SerializerMethodField('get_instructions_markdown')
file = UserFileViewSerializer(allow_null=True, required=False)
step_recipe_data = serializers.SerializerMethodField('get_step_recipe_data')
recipe_filter = 'steps'
@@ -782,10 +802,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
validated_data['space'] = self.context['request'].space
return super().create(validated_data)
- def get_ingredients_vue(self, obj):
- return obj.get_instruction_render()
-
- def get_ingredients_markdown(self, obj):
+ def get_instructions_markdown(self, obj):
return obj.get_instruction_render()
def get_step_recipes(self, obj):
@@ -800,8 +817,8 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
class Meta:
model = Step
fields = (
- 'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown',
- 'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe',
+ 'id', 'name', 'instruction', 'ingredients', 'instructions_markdown',
+ 'time', 'order', 'show_as_header', 'file', 'step_recipe',
'step_recipe_data', 'numrecipe', 'show_ingredients_table'
)
@@ -846,7 +863,7 @@ class UnitConversionSerializer(WritableNestedModelSerializer, OpenDataModelMixin
class Meta:
model = UnitConversion
fields = (
- 'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
+ 'id', 'name', 'base_amount', 'base_unit', 'converted_amount', 'converted_unit', 'food', 'open_data_slug')
class NutritionInformationSerializer(serializers.ModelSerializer):
@@ -873,6 +890,13 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
return False
+class CommentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Comment
+ fields = '__all__'
+ read_only_fields = ['id', 'created_at', 'created_by', 'updated_at', ]
+
+
class RecipeOverviewSerializer(RecipeBaseSerializer):
keywords = KeywordLabelSerializer(many=True)
new = serializers.SerializerMethodField('is_recipe_new')
@@ -901,7 +925,7 @@ class RecipeSerializer(RecipeBaseSerializer):
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
properties = PropertySerializer(many=True, required=False)
steps = StepSerializer(many=True)
- keywords = KeywordSerializer(many=True)
+ keywords = KeywordSerializer(many=True, required=False)
shared = UserSerializer(many=True, required=False)
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
@@ -950,12 +974,6 @@ class RecipeImportSerializer(SpacedModelSerializer):
fields = '__all__'
-class CommentSerializer(serializers.ModelSerializer):
- class Meta:
- model = Comment
- fields = '__all__'
-
-
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
shared = UserSerializer(many=True, required=False)
@@ -1150,7 +1168,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
'recipe_mealplan',
'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until'
)
- read_only_fields = ('id', 'created_by', 'created_at','updated_at',)
+ read_only_fields = ('id', 'created_by', 'created_at', 'updated_at',)
class ShoppingListEntryBulkSerializer(serializers.Serializer):
@@ -1203,6 +1221,8 @@ class ShareLinkSerializer(SpacedModelSerializer):
class CookLogSerializer(serializers.ModelSerializer):
+ created_by = UserSerializer(read_only=True)
+
def create(self, validated_data):
validated_data['created_by'] = self.context['request'].user
validated_data['space'] = self.context['request'].space
@@ -1210,7 +1230,7 @@ class CookLogSerializer(serializers.ModelSerializer):
class Meta:
model = CookLog
- fields = ('id', 'recipe', 'servings', 'rating', 'created_by', 'created_at')
+ fields = ('id', 'recipe', 'servings', 'rating', 'comment', 'created_by', 'created_at', 'updated_at')
read_only_fields = ('id', 'created_by')
diff --git a/cookbook/tables.py b/cookbook/tables.py
index 6392f791e..3fcec9c32 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, ConnectorConfig
class StorageTable(tables.Table):
@@ -15,6 +15,15 @@ class StorageTable(tables.Table):
fields = ('id', 'name', 'method')
+class ConnectorConfigTable(tables.Table):
+ id = tables.LinkColumn('edit_connector_config', args=[A('id')])
+
+ class Meta:
+ model = ConnectorConfig
+ template_name = 'generic/table_template.html'
+ fields = ('id', 'name', 'type', 'enabled')
+
+
class ImportLogTable(tables.Table):
sync_id = tables.LinkColumn('edit_sync', args=[A('sync_id')])
diff --git a/cookbook/templates/base.html b/cookbook/templates/base.html
index 14b3a18f9..a20696a38 100644
--- a/cookbook/templates/base.html
+++ b/cookbook/templates/base.html
@@ -335,6 +335,10 @@
{% trans 'Space Settings' %}
{% endif %}
+ {% if not DISABLE_EXTERNAL_CONNECTORS and request.user == request.space.created_by or not DISABLE_EXTERNAL_CONNECTORS and user.is_superuser %}
+ {% trans 'External Connectors' %}
+ {% endif %}
{% if user.is_superuser %}
messages.SUCCESS for m in r_messages)
+
+ home_assistant_config_obj.refresh_from_db()
+ assert home_assistant_config_obj.token == new_token
+
+ r = a1_s2.post(
+ reverse(EDIT_VIEW_NAME, args={home_assistant_config_obj.pk}),
+ {
+ 'name': home_assistant_config_obj.name,
+ 'type': home_assistant_config_obj.type,
+ 'url': home_assistant_config_obj.url,
+ 'todo_entity': home_assistant_config_obj.todo_entity,
+ 'update_token': new_token,
+ 'enabled': home_assistant_config_obj.enabled,
+ }
+ )
+ assert r.status_code == 404
+
+
+@pytest.mark.parametrize(
+ "arg", [
+ ['a_u', 302],
+ ['g1_s1', 302],
+ ['u1_s1', 302],
+ ['a1_s1', 200],
+ ['g1_s2', 302],
+ ['u1_s2', 302],
+ ['a1_s2', 404],
+ ])
+def test_view_permission(arg, request, home_assistant_config_obj):
+ c = request.getfixturevalue(arg[0])
+ assert c.get(reverse(EDIT_VIEW_NAME, args={home_assistant_config_obj.pk})).status_code == arg[1]
diff --git a/cookbook/tests/edits/test_edits_storage.py b/cookbook/tests/edits/test_edits_storage.py
index 125445e15..9c4e08e1c 100644
--- a/cookbook/tests/edits/test_edits_storage.py
+++ b/cookbook/tests/edits/test_edits_storage.py
@@ -1,7 +1,10 @@
-from cookbook.models import Storage
-from django.contrib import auth
-from django.urls import reverse
import pytest
+from django.contrib import auth
+from django.contrib import messages
+from django.contrib.messages import get_messages
+from django.urls import reverse
+
+from cookbook.models import Storage
@pytest.fixture
@@ -29,6 +32,9 @@ def test_edit_storage(storage_obj, a1_s1, a1_s2):
)
storage_obj.refresh_from_db()
assert r.status_code == 200
+ r_messages = [m for m in get_messages(r.wsgi_request)]
+ assert not any(m.level > messages.SUCCESS for m in r_messages)
+
assert storage_obj.password == '1234_pw'
assert storage_obj.token == '1234_token'
diff --git a/cookbook/tests/other/test_connector_manager.py b/cookbook/tests/other/test_connector_manager.py
new file mode 100644
index 000000000..e6009f6b4
--- /dev/null
+++ b/cookbook/tests/other/test_connector_manager.py
@@ -0,0 +1,27 @@
+import pytest
+from django.contrib import auth
+from mock.mock import Mock
+
+from cookbook.connectors.connector import Connector
+from cookbook.connectors.connector_manager import run_connectors, ActionType
+from cookbook.models import ShoppingListEntry, ShoppingList, Food
+
+
+@pytest.fixture()
+def obj_1(space_1, u1_s1):
+ e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
+ s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
+ s.entries.add(e)
+ return e
+
+
+@pytest.mark.timeout(10)
+@pytest.mark.asyncio
+async def test_run_connectors(space_1, u1_s1, obj_1) -> None:
+ connector_mock = Mock(spec=Connector)
+
+ await run_connectors([connector_mock], space_1, obj_1, ActionType.DELETED)
+
+ assert not connector_mock.on_shopping_list_entry_updated.called
+ assert not connector_mock.on_shopping_list_entry_created.called
+ connector_mock.on_shopping_list_entry_deleted.assert_called_once_with(space_1, obj_1)
diff --git a/cookbook/tests/other/test_recipe_full_text_search.py b/cookbook/tests/other/test_recipe_full_text_search.py
index eed338875..b6b7a858f 100644
--- a/cookbook/tests/other/test_recipe_full_text_search.py
+++ b/cookbook/tests/other/test_recipe_full_text_search.py
@@ -1,8 +1,9 @@
import itertools
import json
-from datetime import timedelta
+from datetime import timedelta, datetime
import pytest
+import pytz
from django.conf import settings
from django.contrib import auth
from django.urls import reverse
@@ -343,7 +344,7 @@ def test_search_date(found_recipe, recipes, param_type, result, u1_s1, u2_s1, sp
Recipe.objects.filter(id=recipe.id).update(
updated_at=recipe.created_at)
- date = (timezone.now() - timedelta(days=15)).strftime("%Y-%m-%d")
+ date = (datetime.now() - timedelta(days=15)).strftime("%Y-%m-%d")
param1 = f"?{param_type}={date}"
param2 = f"?{param_type}=-{date}"
r = json.loads(u1_s1.get(reverse(LIST_URL) + f'{param1}').content)
diff --git a/cookbook/urls.py b/cookbook/urls.py
index 9566541a2..d75126640 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, ConnectorConfig)
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'connector-config', api.ConnectorConfigConfigViewSet)
router.register(r'supermarket', api.SupermarketViewSet)
router.register(r'supermarket-category', api.SupermarketCategoryViewSet)
router.register(r'supermarket-category-relation', api.SupermarketCategoryRelationViewSet)
@@ -166,7 +167,7 @@ urlpatterns = [
]
generic_models = (
- Recipe, RecipeImport, Storage, RecipeBook, SyncLog, Sync,
+ Recipe, RecipeImport, Storage, ConnectorConfig, RecipeBook, SyncLog, Sync,
Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space
)
diff --git a/cookbook/views/api.py b/cookbook/views/api.py
index 88d492ecc..403245584 100644
--- a/cookbook/views/api.py
+++ b/cookbook/views/api.py
@@ -65,15 +65,27 @@ from cookbook.helper.recipe_search import RecipeSearch
from cookbook.helper.recipe_url_import import clean_dict, get_from_youtube_scraper, get_images_from_soup
from cookbook.helper.scrapers.scrapers import text_scraper
from cookbook.helper.shopping_helper import RecipeShoppingEditor, shopping_helper
+
from cookbook.models import (
Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food, FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink, Keyword, MealPlan, MealType,
Property, PropertyType, Recipe, RecipeBook, RecipeBookEntry, ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket,
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace, ViewLog,
)
+
+from cookbook.models import (Automation, BookmarkletImport, CookLog, CustomFilter, ExportLog, Food,
+ FoodInheritField, FoodProperty, ImportLog, Ingredient, InviteLink,
+ Keyword, MealPlan, MealType, Property, PropertyType, Recipe,
+ RecipeBook, RecipeBookEntry, ShareLink, ShoppingList,
+ ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
+ Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync,
+ SyncLog, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
+ ViewLog, ConnectorConfig)
+
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
+
from cookbook.serializer import (
AccessTokenSerializer, AutomationSerializer, AutoMealPlanSerializer, BookmarkletImportListSerializer, BookmarkletImportSerializer, CookLogSerializer, CustomFilterSerializer,
ExportLogSerializer, FoodInheritFieldSerializer, FoodSerializer, FoodShoppingUpdateSerializer, FoodSimpleSerializer, GroupSerializer, ImportLogSerializer,
@@ -84,6 +96,31 @@ from cookbook.serializer import (
SupermarketSerializer, SyncLogSerializer, SyncSerializer, UnitConversionSerializer, UnitSerializer, UserFileSerializer, UserPreferenceSerializer, UserSerializer,
UserSpaceSerializer, ViewLogSerializer,
)
+
+from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer,
+ AutoMealPlanSerializer, BookmarkletImportListSerializer,
+ BookmarkletImportSerializer, CookLogSerializer,
+ CustomFilterSerializer, ExportLogSerializer,
+ FoodInheritFieldSerializer, FoodSerializer,
+ FoodShoppingUpdateSerializer, FoodSimpleSerializer,
+ GroupSerializer, ImportLogSerializer, IngredientSerializer,
+ IngredientSimpleSerializer, InviteLinkSerializer,
+ KeywordSerializer, MealPlanSerializer, MealTypeSerializer,
+ PropertySerializer, PropertyTypeSerializer,
+ RecipeBookEntrySerializer, RecipeBookSerializer,
+ RecipeExportSerializer, RecipeFromSourceSerializer,
+ RecipeImageSerializer, RecipeOverviewSerializer, RecipeSerializer,
+ RecipeShoppingUpdateSerializer, RecipeSimpleSerializer,
+ ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
+ ShoppingListRecipeSerializer, ShoppingListSerializer,
+ SpaceSerializer, StepSerializer, StorageSerializer,
+ SupermarketCategoryRelationSerializer,
+ SupermarketCategorySerializer, SupermarketSerializer,
+ SyncLogSerializer, SyncSerializer, UnitConversionSerializer,
+ UnitSerializer, UserFileSerializer, UserPreferenceSerializer,
+ UserSerializer, UserSpaceSerializer, ViewLogSerializer,
+ ShoppingListEntryBulkSerializer, ConnectorConfigConfigSerializer)
+
from cookbook.views.import_export import get_integration
from recipes import settings
from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY
@@ -443,6 +480,15 @@ class StorageViewSet(viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space)
+class ConnectorConfigConfigViewSet(viewsets.ModelViewSet):
+ queryset = ConnectorConfig.objects
+ serializer_class = ConnectorConfigConfigSerializer
+ permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
+
+ def get_queryset(self):
+ return self.queryset.filter(space=self.request.space)
+
+
class SyncViewSet(viewsets.ModelViewSet):
queryset = Sync.objects
serializer_class = SyncSerializer
@@ -1257,8 +1303,13 @@ class CookLogViewSet(viewsets.ModelViewSet):
serializer_class = CookLogSerializer
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
+ query_params = [
+ QueryParam(name='recipe', description=_('Filter for entries with the given recipe'), qtype='integer'),
+ ]
def get_queryset(self):
+ if self.request.query_params.get('recipe', None):
+ self.queryset = self.queryset.filter(recipe=self.request.query_params.get('recipe'))
return self.queryset.filter(space=self.request.space)
diff --git a/cookbook/views/delete.py b/cookbook/views/delete.py
index 411eb323c..f37d8a558 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, ConnectorConfig)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -122,6 +122,18 @@ class StorageDelete(GroupRequiredMixin, DeleteView):
return HttpResponseRedirect(reverse('list_storage'))
+class ConnectorConfigDelete(GroupRequiredMixin, DeleteView):
+ groups_required = ['admin']
+ template_name = "generic/delete_template.html"
+ model = ConnectorConfig
+ success_url = reverse_lazy('list_connector_config')
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = _("Connectors Config Backend")
+ return context
+
+
class CommentDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = Comment
diff --git a/cookbook/views/edit.py b/cookbook/views/edit.py
index 84b6c4117..bcd874d83 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,14 +9,16 @@ 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, ConnectorConfigForm
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, above_space_limit, group_required
-from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync
+from cookbook.models import Comment, Recipe, RecipeImport, Storage, Sync, ConnectorConfig
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.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):
@@ -75,7 +78,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!'))
@@ -86,17 +89,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()
@@ -106,13 +110,39 @@ def edit_storage(request, pk):
messages.add_message(request, messages.ERROR, _('There was an error updating this storage backend!'))
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(request, 'generic/edit_template.html', {'form': form, 'title': _('Storage')})
+class ConnectorConfigUpdate(GroupRequiredMixin, UpdateView):
+ groups_required = ['admin']
+ template_name = "generic/edit_template.html"
+ model = ConnectorConfig
+ form_class = ConnectorConfigForm
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs['initial']['update_token'] = VALUE_NOT_CHANGED
+ return kwargs
+
+ def form_valid(self, form):
+ if form.cleaned_data['update_token'] != VALUE_NOT_CHANGED and form.cleaned_data['update_token'] != "":
+ form.instance.token = form.cleaned_data['update_token']
+ messages.add_message(self.request, messages.SUCCESS, _('Config saved!'))
+ return super(ConnectorConfigUpdate, self).form_valid(form)
+
+ def get_success_url(self):
+ return reverse('edit_connector_config', kwargs={'pk': self.object.pk})
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = _("ConnectorConfig")
+ return context
+
+
class CommentUpdate(OwnerRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = Comment
diff --git a/cookbook/views/lists.py b/cookbook/views/lists.py
index b9ef9cc71..32389d198 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, ConnectorConfig
+from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable, ConnectorConfigTable
@group_required('admin')
@@ -65,6 +65,22 @@ def storage(request):
)
+@group_required('admin')
+def connector_config(request):
+ table = ConnectorConfigTable(ConnectorConfig.objects.filter(space=request.space).all())
+ RequestConfig(request, paginate={'per_page': 25}).configure(table)
+
+ return render(
+ request,
+ 'generic/list_template.html',
+ {
+ 'title': _("Connector Config Backend"),
+ 'table': table,
+ 'create_url': 'new_connector_config'
+ }
+ )
+
+
@group_required('admin')
def invite_link(request):
table = InviteLinkTable(
diff --git a/cookbook/views/new.py b/cookbook/views/new.py
index d4368f67b..7e9732511 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, ConnectorConfigForm
from cookbook.helper.permission_helper import GroupRequiredMixin, above_space_limit, group_required
-from cookbook.models import Recipe, RecipeImport, ShareLink, Step
+from cookbook.models import Recipe, RecipeImport, ShareLink, Step, ConnectorConfig
from recipes import settings
@@ -71,6 +70,35 @@ class StorageCreate(GroupRequiredMixin, CreateView):
return context
+class ConnectorConfigCreate(GroupRequiredMixin, CreateView):
+ groups_required = ['admin']
+ template_name = "generic/new_template.html"
+ model = ConnectorConfig
+ form_class = ConnectorConfigForm
+ success_url = reverse_lazy('list_connector_config')
+
+ def form_valid(self, form):
+ if self.request.space.demo:
+ messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
+ return redirect('index')
+
+ if settings.DISABLE_EXTERNAL_CONNECTORS:
+ messages.add_message(self.request, messages.ERROR, _('This feature is not enabled by the server admin!'))
+ return redirect('index')
+
+ obj = form.save(commit=False)
+ obj.token = form.cleaned_data['update_token']
+ obj.created_by = self.request.user
+ obj.space = self.request.space
+ obj.save()
+ return HttpResponseRedirect(reverse('edit_connector_config', kwargs={'pk': obj.pk}))
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['title'] = _("Connector Config Backend")
+ return context
+
+
@group_required('user')
def create_new_external_recipe(request, import_id):
if request.method == "POST":
diff --git a/cookbook/views/views.py b/cookbook/views/views.py
index 55474494d..715563e69 100644
--- a/cookbook/views/views.py
+++ b/cookbook/views/views.py
@@ -50,7 +50,7 @@ def index(request):
# TODO need to deprecate
def search(request):
- if has_group_permission(request.user, ('guest', )):
+ if has_group_permission(request.user, ('guest',)):
return render(request, 'search.html', {})
else:
if request.user.is_authenticated:
@@ -130,7 +130,7 @@ def recipe_view(request, pk, share=None):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
- if not (has_group_permission(request.user, ('guest', )) and recipe.space == request.space) and not share_link_valid(recipe, share):
+ if not (has_group_permission(request.user, ('guest',)) and recipe.space == request.space) and not share_link_valid(recipe, share):
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
return HttpResponseRedirect(reverse('index'))
@@ -157,11 +157,11 @@ def recipe_view(request, pk, share=None):
if not ViewLog.objects.filter(recipe=recipe, created_by=request.user, created_at__gt=(timezone.now() - timezone.timedelta(minutes=5)), space=request.space).exists():
ViewLog.objects.create(recipe=recipe, created_by=request.user, space=request.space)
- if request.method == "GET":
+ servings = recipe.servings
+ if request.method == "GET" and 'servings' in request.GET:
servings = request.GET.get("servings")
return render(request, 'recipe_view.html',
- {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, 'servings': servings })
-
+ {'recipe': recipe, 'comments': comments, 'comment_form': comment_form, 'share': share, 'servings': servings})
@group_required('user')
@@ -289,8 +289,11 @@ def shopping_settings(request):
@group_required('guest')
def history(request):
- view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user, space=request.space).order_by('-created_at').all())
- cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all())
+ view_log = ViewLogTable(ViewLog.objects.filter(created_by=request.user, space=request.space).order_by('-created_at').all(), prefix="viewlog-")
+ view_log.paginate(page=request.GET.get("viewlog-page", 1), per_page=25)
+
+ cook_log = CookLogTable(CookLog.objects.filter(created_by=request.user).order_by('-created_at').all(), prefix="cooklog-")
+ cook_log.paginate(page=request.GET.get("cooklog-page", 1), per_page=25)
return render(request, 'history.html', {'view_log': view_log, 'cook_log': cook_log})
@@ -451,19 +454,19 @@ def web_manifest(request):
manifest_info = {
"name":
- theme_values['app_name'], "short_name":
- theme_values['app_name'], "description":
- _("Manage recipes, shopping list, meal plans and more."), "icons":
- icons, "start_url":
- "./search", "background_color":
- theme_values['nav_bg_color'], "display":
- "standalone", "scope":
- ".", "theme_color":
- theme_values['nav_bg_color'], "shortcuts":
- [{"name": _("Plan"), "short_name": _("Plan"), "description": _("View your meal Plan"), "url":
- "./plan"}, {"name": _("Books"), "short_name": _("Books"), "description": _("View your cookbooks"), "url": "./books"},
- {"name": _("Shopping"), "short_name": _("Shopping"), "description": _("View your shopping lists"), "url":
- "./list/shopping-list/"}], "share_target": {"action": "/data/import/url", "method": "GET", "params": {"title": "title", "url": "url", "text": "text"}}
+ theme_values['app_name'], "short_name":
+ theme_values['app_name'], "description":
+ _("Manage recipes, shopping list, meal plans and more."), "icons":
+ icons, "start_url":
+ "./search", "background_color":
+ theme_values['nav_bg_color'], "display":
+ "standalone", "scope":
+ ".", "theme_color":
+ theme_values['nav_bg_color'], "shortcuts":
+ [{"name": _("Plan"), "short_name": _("Plan"), "description": _("View your meal Plan"), "url":
+ "./plan"}, {"name": _("Books"), "short_name": _("Books"), "description": _("View your cookbooks"), "url": "./books"},
+ {"name": _("Shopping"), "short_name": _("Shopping"), "description": _("View your shopping lists"), "url":
+ "./list/shopping-list/"}], "share_target": {"action": "/data/import/url", "method": "GET", "params": {"title": "title", "url": "url", "text": "text"}}
}
return JsonResponse(manifest_info, json_dumps_params={'indent': 4})
diff --git a/docs/features/authentication.md b/docs/features/authentication.md
index 84df9fca7..34a7970fb 100644
--- a/docs/features/authentication.md
+++ b/docs/features/authentication.md
@@ -3,7 +3,7 @@ methods of central account management and authentication.
## Allauth
[Django Allauth](https://django-allauth.readthedocs.io/en/latest/index.html) is an awesome project that
-allows you to use a [huge number](https://django-allauth.readthedocs.io/en/latest/providers.html) of different
+allows you to use a [huge number](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) of different
authentication providers.
They basically explain everything in their documentation, but the following is a short overview on how to get started.
@@ -17,42 +17,50 @@ They basically explain everything in their documentation, but the following is a
Choose a provider from the [list](https://docs.allauth.org/en/latest/socialaccount/providers/index.html) and install it using the environment variable `SOCIAL_PROVIDERS` as shown
in the example below.
-When at least one social provider is set up, the social login sign in buttons should appear on the login page.
+When at least one social provider is set up, the social login sign in buttons should appear on the login page. The example below enables Nextcloud and the generic OpenID Connect providers.
```ini
-SOCIAL_PROVIDERS=allauth.socialaccount.providers.github,allauth.socialaccount.providers.nextcloud
+SOCIAL_PROVIDERS=allauth.socialaccount.providers.openid_connect,allauth.socialaccount.providers.nextcloud
```
!!! warning "Formatting"
The exact formatting is important so make sure to follow the steps explained here!
+### Configuration, via environment
+
Depending on your authentication provider you **might need** to configure it.
This needs to be done through the settings system. To make the system flexible (allow multiple providers) and to
not require another file to be mounted into the container the configuration ins done through a single
environment variable. The downside of this approach is that the configuration needs to be put into a single line
as environment files loaded by docker compose don't support multiple lines for a single variable.
+The line data needs to either be in json or as Python dictionary syntax.
+
Take the example configuration from the allauth docs, fill in your settings and then inline the whole object
(you can use a service like [www.freeformatter.com](https://www.freeformatter.com/json-formatter.html) for formatting).
Assign it to the additional `SOCIALACCOUNT_PROVIDERS` variable.
+
+The example below is for a generic OIDC provider with PKCE enabled. Most values need to be customized for your specifics!
```ini
-SOCIALACCOUNT_PROVIDERS={"nextcloud":{"SERVER":"https://nextcloud.example.org"}}
+SOCIALACCOUNT_PROVIDERS = "{ 'openid_connect': { 'OAUTH_PKCE_ENABLED': True, 'APPS': [ { 'provider_id': 'oidc', 'name': 'My-IDM', 'client_id': 'my_client_id', 'secret': 'my_client_secret', 'settings': { 'server_url': 'https://idm.example.com/oidc/recipes' } } ] } }"
```
!!! success "Improvements ?"
There are most likely ways to achieve the same goal but with a cleaner or simpler system.
If you know such a way feel free to let me know.
-After that, use your superuser account to configure your authentication backend.
-Open the admin page and do the following
+### Configuration, via Django Admin
+
+Instead of defining `SOCIALACCOUNT_PROVIDERS` in your environment, most configuration options can be done via the Admin interface. PKCE for `openid_connect` cannot currently be enabled this way.
+Use your superuser account to configure your authentication backend by opening the admin page and do the following
1. Select `Sites` and edit the default site with the URL of your installation (or create a new).
2. Create a new `Social Application` with the required information as stated in the provider documentation of allauth.
3. Make sure to add your site to the list of available sites
Now the provider is configured and you should be able to sign up and sign in using the provider.
-Use the superuser account to grant permissions to the newly created users.
+Use the superuser account to grant permissions to the newly created users, or enable default access via `SOCIAL_DEFAULT_ACCESS` & `SOCIAL_DEFAULT_GROUP`.
!!! info "WIP"
I do not have a ton of experience with using various single signon providers and also cannot test all of them.
@@ -70,13 +78,7 @@ SOCIALACCOUNT_PROVIDERS='{"openid_connect":{"APPS":[{"provider_id":"keycloak","n
'
```
-1. Restart the service, login as superuser and open the `Admin` page.
-2. Make sure that the correct `Domain Name` is defined at `Sites`.
-3. Select `Social Application` and chose `Keycloak` from the provider list.
-4. Provide an arbitrary name for your authentication provider, and enter the `Client-ID` and `Secret Key` values obtained from Keycloak earlier.
-5. Make sure to add your `Site` to the list of available sites and save the new `Social Application`.
-
-You are now able to sign in using Keycloak.
+You are now able to sign in using Keycloak after a restart of the service.
### Linking accounts
To link an account to an already existing normal user go to the settings page of the user and link it.
diff --git a/docs/features/connectors.md b/docs/features/connectors.md
new file mode 100644
index 000000000..1f9a1b9c2
--- /dev/null
+++ b/docs/features/connectors.md
@@ -0,0 +1,34 @@
+!!! warning
+ Connectors are currently in a beta stage.
+
+## Connectors
+
+Connectors are a powerful add-on component to TandoorRecipes.
+They allow for certain actions to be translated to api calls to external services.
+
+!!! danger
+ In order for this application to push data to external providers it needs to store authentication information.
+ Please use read only/separate accounts or app passwords wherever possible.
+
+for the configuration please see [Configuration](https://docs.tandoor.dev/system/configuration/#connectors)
+
+## Current Connectors
+
+### HomeAssistant
+
+The current HomeAssistant connector supports the following features:
+1. Push newly created shopping list items.
+2. Pushes all shopping list items if a recipe is added to the shopping list.
+3. Removed todo's from HomeAssistant IF they are unchanged and are removed through TandoorRecipes.
+
+#### How to configure:
+
+Step 1:
+1. Generate a HomeAssistant Long-Lived Access Tokens
+
+2. Get/create a todo list entry you want to sync too.
+
+3. Create a connector
+
+4. ???
+5. Profit
diff --git a/docs/install/archlinux.md b/docs/install/archlinux.md
new file mode 100644
index 000000000..2f17147f6
--- /dev/null
+++ b/docs/install/archlinux.md
@@ -0,0 +1,51 @@
+!!! info "Community Contributed"
+ This guide was contributed by the community and is neither officially supported, nor updated or tested.
+
+These are instructions for pacman based distributions, like ArchLinux. The package is available from the [AUR](https://aur.archlinux.org/packages/tandoor-recipes-git) or from [GitHub](https://github.com/jdecourval/tandoor-recipes-pkgbuild).
+
+## Features
+- systemd integration.
+- Provide configuration for Nginx.
+- Use socket activation.
+- Use a non-root user.
+- Apply migrations automatically.
+
+## Installation
+1. Clone the package, build and install with makepkg:
+```shell
+git clone https://aur.archlinux.org/tandoor-recipes-git.git
+cd tandoor-recipes-git
+makepkg -si
+```
+or use your favourite AUR helper.
+
+2. Setup a PostgreSQL database and user, as explained here: https://docs.tandoor.dev/install/manual/#setup-postgresql
+
+3. Configure the service in `/etc/tandoor/tandoor.conf`.
+
+4. Reinstall the package, or follow [the official instructions](https://docs.tandoor.dev/install/manual/#initialize-the-application) to have tandoor creates its DB tables.
+
+5. Optionally configure a reverse proxy. A configuration for Nginx is provided, but you can Traefik, Apache, etc..
+Edit `/etc/nginx/sites-available/tandoor.conf`. You may want to use another `server_name`, or configure TLS. Then:
+```shell
+cd /etc/nginx/sites-enabled
+ln -s ../sites-available/tandoor.conf
+systemctl restart nginx
+```
+
+6. Enable the service
+```shell
+systemctl enable --now tandoor
+```
+
+## Upgrade
+```shell
+cd tandoor-recipes-git
+git pull
+makepkg -sif
+```
+Or use your favourite AUR helper.
+You shouldn't need to do anything else. This package applies migration automatically. If PostgreSQL has been updated to a new major version, you may need to [run pg_upgrade](https://wiki.archlinux.org/title/PostgreSQL#pg_upgrade).
+
+## Help
+This package is non-official. Issues should be posted to https://github.com/jdecourval/tandoor-recipes-pkgbuild or https://aur.archlinux.org/packages/tandoor-recipes-git.
diff --git a/docs/system/configuration.md b/docs/system/configuration.md
index 8d93f9859..6d5f1ce67 100644
--- a/docs/system/configuration.md
+++ b/docs/system/configuration.md
@@ -437,6 +437,18 @@ key [here](https://fdc.nal.usda.gov/api-key-signup.html).
FDC_API_KEY=DEMO_KEY
```
+#### Connectors
+
+- `DISABLE_EXTERNAL_CONNECTORS` is a global switch to disable External Connectors entirely.
+- `EXTERNAL_CONNECTORS_QUEUE_SIZE` is the amount of changes that are kept in memory if the worker cannot keep up.
+
+(External) Connectors are used to sync the status from Tandoor to other services. More info can be found [here](https://docs.tandoor.dev/features/connectors/).
+
+```env
+DISABLE_EXTERNAL_CONNECTORS=0 # Default 0 (false), set to 1 (true) to disable connectors
+EXTERNAL_CONNECTORS_QUEUE_SIZE=100 # Defaults to 100, set to any number >1
+```
+
### Debugging/Development settings
!!! warning
diff --git a/mkdocs.yml b/mkdocs.yml
index b7f50de22..68d2b70a9 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -35,6 +35,7 @@ nav:
- KubeSail or PiBox: install/kubesail.md
- TrueNAS Portainer: install/truenas_portainer.md
- WSL: install/wsl.md
+ - ArchLinux: install/archlinux.md
- Manual: install/manual.md
- Other setups: install/other.md
- Features:
@@ -42,6 +43,7 @@ nav:
- Shopping: features/shopping.md
- Authentication: features/authentication.md
- Automation: features/automation.md
+ - Connectors: features/connectors.md
- Storages and Sync: features/external_recipes.md
- Import/Export: features/import_export.md
- System:
diff --git a/pytest.ini b/pytest.ini
index d766a8425..79e7a4e2a 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,3 @@
[pytest]
DJANGO_SETTINGS_MODULE = recipes.settings
-python_files = tests.py test_*.py *_tests.py
\ No newline at end of file
+python_files = tests.py test_*.py *_tests.py
diff --git a/recipes/settings.py b/recipes/settings.py
index b8bf35571..ea61ae2f8 100644
--- a/recipes/settings.py
+++ b/recipes/settings.py
@@ -569,4 +569,7 @@ ACCOUNT_RATE_LIMITS = {
"login": "5/m/ip",
}
+DISABLE_EXTERNAL_CONNECTORS = bool(int(os.getenv('DISABLE_EXTERNAL_CONNECTORS', False)))
+EXTERNAL_CONNECTORS_QUEUE_SIZE = int(os.getenv('EXTERNAL_CONNECTORS_QUEUE_SIZE', 100))
+
mimetypes.add_type("text/javascript", ".js", True)
diff --git a/requirements.txt b/requirements.txt
index e799af7ab..c416671b9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
Django==4.2.10
-cryptography===42.0.2
+cryptography===42.0.4
django-annoying==0.10.6
django-autocomplete-light==3.9.7
django-cleanup==8.0.0
@@ -15,7 +15,7 @@ gunicorn==20.1.0
lxml==4.9.3
Markdown==3.5.1
Pillow==10.2.0
-psycopg2-binary==2.9.5
+psycopg2-binary==2.9.9
python-dotenv==1.0.0
requests==2.31.0
six==1.16.0
@@ -26,6 +26,7 @@ pyyaml==6.0.1
uritemplate==4.1.1
beautifulsoup4==4.12.2
microdata==0.8.0
+mock==5.1.0
Jinja2==3.1.3
django-webpack-loader==1.8.1
git+https://github.com/BITSOLVER/django-js-reverse@071e304fd600107bc64bbde6f2491f1fe049ec82
@@ -33,6 +34,7 @@ django-allauth==0.61.1
recipe-scrapers==14.52.0
django-scopes==2.0.0
pytest==8.0.0
+pytest-asyncio==0.23.5
pytest-django==4.8.0
django-treebeard==4.7
django-cors-headers==4.2.0
@@ -46,3 +48,4 @@ pytest-factoryboy==2.6.0
pyppeteer==1.0.2
validators==0.20.0
pytube==15.0.0
+homeassistant-api==4.1.1.post2
diff --git a/vue/src/components/StepComponent.vue b/vue/src/components/StepComponent.vue
index 9585a3a76..3d25335e1 100644
--- a/vue/src/components/StepComponent.vue
+++ b/vue/src/components/StepComponent.vue
@@ -44,7 +44,7 @@
diff --git a/vue/src/locales/sv.json b/vue/src/locales/sv.json
index e352a1e3b..131aad603 100644
--- a/vue/src/locales/sv.json
+++ b/vue/src/locales/sv.json
@@ -200,7 +200,7 @@
"Keyword_Alias": "Nyckelord alias",
"Recipe_Book": "Receptbok",
"Search Settings": "Sökinställningar",
- "warning_feature_beta": "Den här funktionen är för närvarande i ett BETA-läge (testning). Vänligen förvänta dig buggar och eventuellt brytande ändringar i framtiden (möjligen att förlora funktionsrelaterad data) när du använder den här funktionen.",
+ "warning_feature_beta": "Den här funktionen är för närvarande i ett BETA-läge (testning). Förvänta dig buggar och eventuellt större ändringar i framtiden (möjligtvis framtida data kan gå förlorad) när du använder den här funktionen.",
"success_deleting_resource": "En resurs har raderats!",
"file_upload_disabled": "Filuppladdning är inte aktiverat för ditt utrymme.",
"show_only_internal": "Visa endast interna recept",
@@ -246,7 +246,7 @@
"CountMore": "...+{count} fler",
"IgnoreThis": "Lägg aldrig till {mat} automatiskt i inköpslista",
"DelayFor": "Fördröjning på {hours} timmar",
- "ShowDelayed": "Visa fördröjda artiklar",
+ "ShowDelayed": "Visa fördröjda föremål",
"Completed": "Avslutad",
"OfflineAlert": "Du är offline, inköpslistan kanske inte synkroniseras.",
"shopping_share": "Dela inköpslista",
@@ -477,5 +477,64 @@
"Warning_Delete_Supermarket_Category": "Om du tar bort en mataffärskategori raderas också alla relationer till livsmedel. Är du säker?",
"Disabled": "Inaktiverad",
"Social_Authentication": "Social autentisering",
- "Single": "Enstaka"
+ "Single": "Enstaka",
+ "Properties": "Egenskaper",
+ "err_importing_recipe": "Ett fel uppstod vid import av receptet!",
+ "recipe_property_info": "Du kan också lägga till egenskaper till maträtter för att beräkna dessa automatiskt baserat på ditt recept!",
+ "total": "totalt",
+ "CustomLogos": "Anpassade logotyper",
+ "Welcome": "Välkommen",
+ "Input": "Inmatning",
+ "Undo": "Ångra",
+ "NoMoreUndo": "Inga ändringar att ångra.",
+ "Delete_All": "Radera alla",
+ "Property": "Egendom",
+ "Property_Editor": "Egendom redigerare",
+ "Conversion": "Omvandling",
+ "created_by": "Skapad av",
+ "ShowRecentlyCompleted": "Visa nyligen genomförda föremål",
+ "ShoppingBackgroundSyncWarning": "Dålig uppkoppling, inväntar synkronisering...",
+ "show_step_ingredients": "Visa ingredienser för steget",
+ "hide_step_ingredients": "Dölj ingredienser för steget",
+ "Logo": "Logga",
+ "Show_Logo": "Visa logga",
+ "Show_Logo_Help": "Visa Tandoor eller hushålls-logga i navigationen.",
+ "Nav_Text_Mode": "Navigation Textläge",
+ "Nav_Text_Mode_Help": "Beter sig annorlunda för varje tema.",
+ "g": "gram [g] (metriskt, vikt)",
+ "kg": "kilogram [kg] (metriskt, vikt)",
+ "ounce": "ounce [oz] (vikt)",
+ "FDC_Search": "FDC Sök",
+ "property_type_fdc_hint": "Bara egendomstyper med ett FDC ID kan automatiskt hämta data från FDC databasen",
+ "Alignment": "Orientering",
+ "base_amount": "Basmängd",
+ "Datatype": "Datatyp",
+ "Number of Objects": "Antal objekt",
+ "StartDate": "Startdatum",
+ "EndDate": "Slutdatum",
+ "FDC_ID_help": "FDC databas ID",
+ "Data_Import_Info": "Förbättra din samling genom att importera en framtagen lista av livsmedel, enheter och mer för att förbättra din recept-samling.",
+ "Update_Existing_Data": "Uppdatera existerande data",
+ "Use_Metric": "Använd metriska enheter",
+ "Learn_More": "Läs mer",
+ "converted_unit": "Konverterad enhet",
+ "converted_amount": "Konverterad mängd",
+ "base_unit": "Basenhet",
+ "FDC_ID": "FDC ID",
+ "per_serving": "per servering",
+ "Properties_Food_Amount": "Egenskaper Livsmedel Mängd",
+ "Open_Data_Slug": "Öppen Data Slug",
+ "Open_Data_Import": "Öppen Data Import",
+ "Properties_Food_Unit": "Egenskaper Livsmedel Enhet",
+ "OrderInformation": "Objekt är sorterade från små till stora siffror.",
+ "show_step_ingredients_setting": "Visa ingredienser bredvid recept-steg",
+ "show_step_ingredients_setting_help": "Lägg till tabell med ingredienser bredvid recept-steg. Verkställs vid skapande. Kan skrivas över i redigering av receptvyn.",
+ "Space_Cosmetic_Settings": "Vissa kosmetiska inställningar kan ändras av hushålls-administratörer och skriver över klientinställningar för det hushållet.",
+ "show_ingredients_table": "Visa en tabell över ingredienserna bredvid stegets text",
+ "Enable": "Aktivera",
+ "CustomTheme": "Anpassat tema",
+ "CustomThemeHelp": "Skriv över nuvarande tema genom att ladda upp en anpassad CSS-fil.",
+ "CustomNavLogoHelp": "Ladda upp en bild att använda som meny-logga.",
+ "CustomImageHelp": "Ladda upp en bild som visas i överblicken.",
+ "CustomLogoHelp": "Ladda upp kvadratiska bilder i olika storlekar för att ändra logga i webbläsare."
}
diff --git a/vue/src/utils/openapi/api.ts b/vue/src/utils/openapi/api.ts
index a508927da..9f25a3273 100644
--- a/vue/src/utils/openapi/api.ts
+++ b/vue/src/utils/openapi/api.ts
@@ -2262,7 +2262,7 @@ export interface Recipe {
* @type {Array}
* @memberof Recipe
*/
- keywords: Array;
+ keywords?: Array;
/**
*
* @type {Array}
@@ -2940,7 +2940,7 @@ export interface RecipeSteps {
* @type {string}
* @memberof RecipeSteps
*/
- ingredients_markdown?: string;
+ instructions_markdown?: string;
/**
*
* @type {string}
@@ -3793,7 +3793,7 @@ export interface Step {
* @type {string}
* @memberof Step
*/
- ingredients_markdown?: string;
+ instructions_markdown?: string;
/**
*
* @type {string}