Merge branch 'develop' into deprecate_old_code

This commit is contained in:
vabene1111
2024-02-28 17:10:03 +01:00
committed by GitHub
46 changed files with 1481 additions and 4083 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -16,7 +16,7 @@ from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingre
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')

View File

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

View File

View File

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

View File

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

View File

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

View File

@@ -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,52 @@ class StorageForm(forms.ModelForm):
help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> 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=_('<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=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'),
}
class SyncForm(forms.ModelForm):
def __init__(self, *args, **kwargs):

View File

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

View File

@@ -413,15 +413,17 @@ class OpenDataImporter:
if len(create_list) > 0:
model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'open_data_food_slug', 'property_type',))
linked_properties = list(model_type.objects.filter(space=self.request.space).values_list('id', flat=True).all())
linked_properties = list(FoodProperty.objects.filter(food__space=self.request.space).values_list('property_id', flat=True).all())
property_food_relation_list = []
for p in model_type.objects.filter(space=self.request.space, open_data_food_slug__isnull=False).values_list('open_data_food_slug', 'id', ):
if p[1] == 147:
pass
# slug_id_cache should always exist, don't create relations for already linked properties (ignore_conflicts would do that as well but this is more performant)
if p[0] in self.slug_id_cache['food'] and p[1] not in linked_properties:
property_food_relation_list.append(Food.properties.through(food_id=self.slug_id_cache['food'][p[0]], property_id=p[1]))
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
FoodProperty.objects.bulk_create(property_food_relation_list, unique_fields=('food_id', 'property_id',))
return od_response

View File

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

View File

@@ -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 <lukas@ategsolutions.se>\n"
"Language-Team: Swedish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/sv/>\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"

View File

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

View File

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

View File

@@ -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(space=self).delete()
ShoppingListRecipe.objects.filter(recipe__space=self).delete()
@@ -392,6 +393,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'
@@ -1261,10 +1287,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')

View File

@@ -44,12 +44,12 @@ class TreeSchema(AutoSchema):
"name": 'root', "in": "query", "required": False,
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(
obj=api_name),
'schema': {'type': 'int', },
'schema': {'type': 'integer', },
})
parameters.append({
"name": 'tree', "in": "query", "required": False,
"description": 'Return all self and children of {} with ID [int].'.format(api_name),
'schema': {'type': 'int', },
'schema': {'type': 'integer', },
})
return parameters

View File

@@ -33,7 +33,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
ShareLink, 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
@@ -416,6 +416,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
@@ -768,8 +789,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'
@@ -778,10 +798,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):
@@ -796,8 +813,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'
)
@@ -868,6 +885,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')
@@ -896,7 +920,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)
@@ -945,12 +969,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)
@@ -1167,6 +1185,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
@@ -1174,7 +1194,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')

View File

@@ -3,7 +3,7 @@ from django.utils.html import format_html
from django.utils.translation import gettext as _
from django_tables2.utils import A
from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog
from .models import CookLog, InviteLink, RecipeImport, Storage, Sync, SyncLog, ViewLog, 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')])

View File

@@ -335,6 +335,10 @@
<a class="dropdown-item" href="{% url 'view_space_manage' request.space.pk %}"><i
class="fas fa-server fa-fw"></i> {% trans 'Space Settings' %}</a>
{% endif %}
{% if not DISABLE_EXTERNAL_CONNECTORS and request.user == request.space.created_by or not DISABLE_EXTERNAL_CONNECTORS and user.is_superuser %}
<a class="dropdown-item" href="{% url 'list_connector_config' %}"><i
class="fas fa-sync-alt fa-fw"></i> {% trans 'External Connectors' %}</a>
{% endif %}
{% if user.is_superuser %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'view_system' %}"><i

View File

@@ -112,6 +112,9 @@ def recipe_last(recipe, user):
def page_help(page_name):
help_pages = {
'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/',
'list_connector_config': 'https://docs.tandoor.dev/features/connectors/',
'new_connector_config': 'https://docs.tandoor.dev/features/connectors/',
'edit_connector_config': 'https://docs.tandoor.dev/features/connectors/',
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
'view_import': 'https://docs.tandoor.dev/features/import_export/',
'data_import_url': 'https://docs.tandoor.dev/features/import_export/',

View File

@@ -0,0 +1,126 @@
import json
import pytest
from django.contrib import auth
from django.urls import reverse
from django_scopes import scopes_disabled
from cookbook.models import ConnectorConfig
LIST_URL = 'api:connectorconfig-list'
DETAIL_URL = 'api:connectorconfig-detail'
@pytest.fixture()
def obj_1(space_1, u1_s1):
return ConnectorConfig.objects.create(
name='HomeAssistant 1', token='token', url='url', todo_entity='todo.shopping_list', enabled=True, created_by=auth.get_user(u1_s1), space=space_1, )
@pytest.fixture
def obj_2(space_1, u1_s1):
return ConnectorConfig.objects.create(
name='HomeAssistant 2', token='token', url='url', todo_entity='todo.shopping_list', enabled=True, created_by=auth.get_user(u1_s1), space=space_1, )
@pytest.mark.parametrize(
"arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 403],
['a1_s1', 200],
])
def test_list_permission(arg, request):
c = request.getfixturevalue(arg[0])
r = c.get(reverse(LIST_URL))
assert r.status_code == arg[1]
if r.status_code == 200:
response = json.loads(r.content)
assert 'token' not in response
def test_list_space(obj_1, obj_2, a1_s1, a1_s2, space_2):
assert len(json.loads(a1_s1.get(reverse(LIST_URL)).content)) == 2
assert len(json.loads(a1_s2.get(reverse(LIST_URL)).content)) == 0
obj_1.space = space_2
obj_1.save()
assert len(json.loads(a1_s1.get(reverse(LIST_URL)).content)) == 1
assert len(json.loads(a1_s2.get(reverse(LIST_URL)).content)) == 1
@pytest.mark.parametrize(
"arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 403],
['a1_s1', 200],
['g1_s2', 403],
['u1_s2', 403],
['a1_s2', 404],
])
def test_update(arg, request, obj_1):
test_token = '1234'
c = request.getfixturevalue(arg[0])
r = c.patch(
reverse(
DETAIL_URL,
args={obj_1.id}
),
{'name': 'new', 'token': test_token},
content_type='application/json'
)
response = json.loads(r.content)
assert r.status_code == arg[1]
if r.status_code == 200:
assert response['name'] == 'new'
obj_1.refresh_from_db()
assert obj_1.token == test_token
@pytest.mark.parametrize(
"arg", [
['a_u', 403],
['g1_s1', 403],
['u1_s1', 403],
['a1_s1', 201],
])
def test_add(arg, request, a1_s2, obj_1):
c = request.getfixturevalue(arg[0])
r = c.post(
reverse(LIST_URL),
{'name': 'test', 'url': 'http://localhost:8123/api', 'token': '1234', 'enabled': 'true'},
content_type='application/json'
)
response = json.loads(r.content)
print(r.content)
assert r.status_code == arg[1]
if r.status_code == 201:
assert response['name'] == 'test'
r = c.get(reverse(DETAIL_URL, args={response['id']}))
assert r.status_code == 200
r = a1_s2.get(reverse(DETAIL_URL, args={response['id']}))
assert r.status_code == 404
def test_delete(a1_s1, a1_s2, obj_1):
r = a1_s2.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 404
r = a1_s1.delete(
reverse(
DETAIL_URL,
args={obj_1.id}
)
)
assert r.status_code == 204
with scopes_disabled():
assert ConnectorConfig.objects.count() == 0

View File

@@ -0,0 +1,75 @@
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 pytest_django.asserts import assertTemplateUsed
from cookbook.models import ConnectorConfig
EDIT_VIEW_NAME = 'edit_connector_config'
@pytest.fixture
def home_assistant_config_obj(a1_s1, space_1):
return ConnectorConfig.objects.create(
name='HomeAssistant 1',
type=ConnectorConfig.HOMEASSISTANT,
token='token',
url='http://localhost:8123/api',
todo_entity='todo.shopping_list',
enabled=True,
created_by=auth.get_user(a1_s1),
space=space_1,
)
def test_edit_connector_config_homeassistant(home_assistant_config_obj: ConnectorConfig, a1_s1, a1_s2):
new_token = '1234_token'
r = a1_s1.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,
'update_token': new_token,
'todo_entity': home_assistant_config_obj.todo_entity,
'enabled': home_assistant_config_obj.enabled,
}
)
assert r.status_code == 302
r_messages = [m for m in get_messages(r.wsgi_request)]
assert not any(m.level > 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]

View File

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

View File

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

View File

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

View File

@@ -8,9 +8,12 @@ from rest_framework.schemas import get_schema_view
from cookbook.version_info import TANDOOR_VERSION
from recipes.settings import DEBUG, PLUGINS
from .models import (Automation, Comment, CustomFilter, Food, InviteLink, Keyword, PropertyType, Recipe, RecipeImport, Space, Step, Storage, Supermarket, SupermarketCategory,
Sync, SyncLog, Unit, UnitConversion, UserFile, UserSpace, get_model_name,
)
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, ConnectorConfig)
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
from .views.api import CustomAuthToken, ImportOpenData
@@ -49,6 +52,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)
@@ -142,7 +146,12 @@ urlpatterns = [
path('manifest.json', views.web_manifest, name='web_manifest'),
]
generic_models = (Recipe, RecipeImport, Storage, SyncLog, Sync, Comment, InviteLink, UserSpace, Space)
generic_models = (
Recipe, RecipeImport, Storage, ConnectorConfig, RecipeBook, SyncLog, Sync,
Comment, RecipeBookEntry, ShoppingList, InviteLink, UserSpace, Space
)
for m in generic_models:
py_name = get_model_name(m)

View File

@@ -14,6 +14,7 @@ from zipfile import ZipFile
import requests
import validators
from PIL import UnidentifiedImageError
from annoying.decorators import ajax_request
from annoying.functions import get_object_or_None
from django.contrib import messages
@@ -34,7 +35,6 @@ from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from icalendar import Calendar, Event
from oauth2_provider.models import AccessToken
from PIL import UnidentifiedImageError
from recipe_scrapers import scrape_me
from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
from requests.exceptions import MissingSchema
@@ -57,23 +57,42 @@ from cookbook.helper.HelperFunctions import str2bool
from cookbook.helper.image_processing import handle_image
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.open_data_importer import OpenDataImporter
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly, CustomIsShared, CustomIsSpaceOwner, CustomIsUser, CustomRecipePermission,
CustomTokenHasReadWriteScope, CustomTokenHasScope, CustomUserPermission, IsReadOnlyDRF, above_space_limit, group_required,
has_group_permission, is_space_owner, switch_user_active_space,
)
from cookbook.helper.permission_helper import (
CustomIsAdmin, CustomIsOwner, CustomIsOwnerReadOnly, CustomIsShared, CustomIsSpaceOwner, CustomIsUser, CustomRecipePermission, CustomTokenHasReadWriteScope,
CustomTokenHasScope, CustomUserPermission, IsReadOnlyDRF, above_space_limit, group_required, has_group_permission, is_space_owner, switch_user_active_space,
)
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, 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, IngredientSerializer, IngredientSimpleSerializer, InviteLinkSerializer,
@@ -84,6 +103,31 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, Au
SupermarketCategorySerializer, 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 +487,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
@@ -607,6 +660,7 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
'base_unit': 'g',
'space': self.request.space
})[0]
food.save()
try:
@@ -625,28 +679,25 @@ class FoodViewSet(viewsets.ModelViewSet, TreeMixin):
for fn in data['foodNutrients']:
if fn['nutrient']['id'] == pt.fdc_id:
property_found = True
food_property_list.append(
Property(property_type_id=pt.id,
food_property_list.append(Property(
property_type_id=pt.id,
property_amount=max(0, round(fn['amount'], 2)), # sometimes FDC might return negative values which make no sense, set to 0
import_food_id=food.id,
space=self.request.space,
))
if not property_found:
food_property_list.append(
Property(property_type_id=pt.id,
food_property_list.append(Property(
property_type_id=pt.id,
property_amount=0, # if field not in FDC data the food does not have that property
import_food_id=food.id,
space=self.request.space,
))
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type', ))
properties = Property.objects.bulk_create(food_property_list, unique_fields=('space', 'property_type',))
property_food_relation_list = []
for p in Property.objects.filter(space=self.request.space, import_food_id=food.id).values_list('import_food_id', 'id', ):
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
for p in properties:
property_food_relation_list.append(Food.properties.through(food_id=food.id, property_id=p.pk))
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
Property.objects.filter(space=self.request.space, import_food_id=food.id).update(import_food_id=None)
return self.retrieve(request, pk)
except Exception:
@@ -721,7 +772,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
query_params = [
QueryParam(name='from_date', description=_('Filter meal plans from date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
QueryParam(name='to_date', description=_('Filter meal plans to date (inclusive) in the format of YYYY-MM-DD.'), qtype='string'),
QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='meal_type', description=_('Filter meal plans with MealType ID. For multiple repeat parameter.'), qtype='integer'),
]
schema = QueryParamAutoSchema()
@@ -849,7 +900,7 @@ class StepViewSet(viewsets.ModelViewSet):
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
query_params = [
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'), qtype='integer'),
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
]
schema = QueryParamAutoSchema()
@@ -887,32 +938,32 @@ class RecipeViewSet(viewsets.ModelViewSet):
query_params = [
QueryParam(name='query', description=_('Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='int'),
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='int'),
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='int'),
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='int'),
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='int'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='int'),
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='int'),
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='int'),
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='int'),
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='int'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='int'),
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter. Equivalent to keywords_or'), qtype='integer'),
QueryParam(name='keywords_or', description=_('Keyword IDs, repeat for multiple. Return recipes with any of the keywords'), qtype='integer'),
QueryParam(name='keywords_and', description=_('Keyword IDs, repeat for multiple. Return recipes with all of the keywords.'), qtype='integer'),
QueryParam(name='keywords_or_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords.'), qtype='integer'),
QueryParam(name='keywords_and_not', description=_('Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords.'), qtype='integer'),
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'), qtype='integer'),
QueryParam(name='foods_or', description=_('Food IDs, repeat for multiple. Return recipes with any of the foods'), qtype='integer'),
QueryParam(name='foods_and', description=_('Food IDs, repeat for multiple. Return recipes with all of the foods.'), qtype='integer'),
QueryParam(name='foods_or_not', description=_('Food IDs, repeat for multiple. Exclude recipes with any of the foods.'), qtype='integer'),
QueryParam(name='foods_and_not', description=_('Food IDs, repeat for multiple. Exclude recipes with all of the foods.'), qtype='integer'),
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='integer'),
QueryParam(name='rating', description=_('Rating a recipe should have or greater. [0 - 5] Negative value filters rating less than.'), qtype='integer'),
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='int'),
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='int'),
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='int'),
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='int'),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [true/<b>false</b>]')),
QueryParam(name='random', description=_('Returns the results in randomized order. [true/<b>false</b>]')),
QueryParam(name='new', description=_('Returns new results first in search results. [true/<b>false</b>]')),
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='int'),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending - filters on or before date.')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [true/<b>false</b>]')),
QueryParam(name='books_or', description=_('Book IDs, repeat for multiple. Return recipes with any of the books'), qtype='integer'),
QueryParam(name='books_and', description=_('Book IDs, repeat for multiple. Return recipes with all of the books.'), qtype='integer'),
QueryParam(name='books_or_not', description=_('Book IDs, repeat for multiple. Exclude recipes with any of the books.'), qtype='integer'),
QueryParam(name='books_and_not', description=_('Book IDs, repeat for multiple. Exclude recipes with all of the books.'), qtype='integer'),
QueryParam(name='internal', description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
QueryParam(name='random', description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
QueryParam(name='new', description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
QueryParam(name='timescooked', description=_('Filter recipes cooked X times or more. Negative values returns cooked less than X times'), qtype='integer'),
QueryParam(name='cookedon', description=_('Filter recipes last cooked on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='createdon', description=_('Filter recipes created on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='updatedon', description=_('Filter recipes updated on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='viewedon', description=_('Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending ''-'' filters on or before date.')),
QueryParam(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']')),
]
schema = QueryParamAutoSchema()
@@ -1049,7 +1100,9 @@ class UnitConversionViewSet(viewsets.ModelViewSet):
queryset = UnitConversion.objects
serializer_class = UnitConversionSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
query_params = [QueryParam(name='food_id', description='ID of food to filter for', qtype='int'), ]
query_params = [
QueryParam(name='food_id', description='ID of food to filter for', qtype='integer'),
]
schema = QueryParamAutoSchema()
def get_queryset(self):
@@ -1097,12 +1150,13 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
serializer_class = ShoppingListEntrySerializer
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
query_params = [
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='int'),
QueryParam(name='checked',
description=_(
'Filter shopping list entries on checked. [true, false, both, <b>recent</b>]<br> - recent includeks unchecked items and recently completed items.'
)),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='int'),
QueryParam(name='id', description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'), qtype='integer'),
QueryParam(
name='checked',
description=_('Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> \
- ''recent'' includes unchecked items and recently completed items.')
),
QueryParam(name='supermarket', description=_('Returns the shopping list entries sorted by supermarket category order.'), qtype='integer'),
]
schema = QueryParamAutoSchema()
@@ -1171,8 +1225,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)
@@ -1224,11 +1283,52 @@ class UserFileViewSet(viewsets.ModelViewSet, StandardFilterMixin):
class AutomationViewSet(viewsets.ModelViewSet, StandardFilterMixin):
"""
list:
optional parameters
- **automation_type**: Return the Automations matching the automation type. Multiple values allowed.
*Automation Types:*
- FS: Food Alias
- UA: Unit Alias
- KA: Keyword Alias
- DR: Description Replace
- IR: Instruction Replace
- NU: Never Unit
- TW: Transpose Words
- FR: Food Replace
- UR: Unit Replace
- NR: Name Replace
"""
queryset = Automation.objects
serializer_class = AutomationSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
query_params = [
QueryParam(name='automation_type', description=_('Return the Automations matching the automation type. Multiple values allowed.'), qtype='string'),
]
schema = QueryParamAutoSchema()
auto_type = {
'FS': 'FOOD_ALIAS',
'UA': 'UNIT_ALIAS',
'KA': 'KEYWORD_ALIAS',
'DR': 'DESCRIPTION_REPLACE',
'IR': 'INSTRUCTION_REPLACE',
'NU': 'NEVER_UNIT',
'TW': 'TRANSPOSE_WORDS',
'FR': 'FOOD_REPLACE',
'UR': 'UNIT_REPLACE',
'NR': 'NAME_REPLACE'
}
def get_queryset(self):
automation_type = self.request.query_params.getlist('automation_type', [])
if automation_type:
self.queryset = self.queryset.filter(type__in=[self.auto_type[x.upper()] for x in automation_type])
self.queryset = self.queryset.filter(space=self.request.space).all()
return super().get_queryset()
@@ -1255,6 +1355,7 @@ class CustomFilterViewSet(viewsets.ModelViewSet, StandardFilterMixin):
queryset = CustomFilter.objects
serializer_class = CustomFilterSerializer
permission_classes = [CustomIsOwner & CustomTokenHasReadWriteScope]
pagination_class = DefaultPagination
def get_queryset(self):
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(space=self.request.space).distinct()

View File

@@ -8,7 +8,12 @@ from django.utils.translation import gettext as _
from django.views.generic import DeleteView
from cookbook.helper.permission_helper import GroupRequiredMixin, OwnerRequiredMixin, group_required
from cookbook.models import Comment, InviteLink, Recipe, RecipeImport, Space, Storage, Sync, UserSpace
from cookbook.models import (Comment, InviteLink, MealPlan, Recipe, RecipeBook, RecipeBookEntry,
RecipeImport, Space, Storage, Sync, UserSpace, ConnectorConfig)
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.local import Local
from cookbook.provider.nextcloud import Nextcloud
@@ -119,6 +124,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

View File

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

View File

@@ -6,8 +6,8 @@ from django.utils.translation import gettext as _
from django_tables2 import RequestConfig
from cookbook.helper.permission_helper import group_required
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile
from cookbook.tables import ImportLogTable, InviteLinkTable, RecipeImportTable, StorageTable
from cookbook.models import InviteLink, RecipeImport, Storage, SyncLog, UserFile, 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(

View File

@@ -5,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
@@ -70,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":

View File

@@ -154,11 +154,13 @@ 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})
@group_required('user')
def books(request):
return render(request, 'books.html', {})
@@ -274,8 +276,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})

View File

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

View File

@@ -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
![Profile Page](https://github.com/TandoorRecipes/recipes/assets/7824786/15ebeec5-5be3-48db-97d1-c698405db533)
2. Get/create a todo list entry you want to sync too.
![Todos Viewer](https://github.com/TandoorRecipes/recipes/assets/7824786/506c4c34-3d40-48ae-a80c-e50374c5c618)
3. Create a connector
![New Connector Config](https://github.com/TandoorRecipes/recipes/assets/7824786/7f14f181-1341-4cab-959b-a6bef79e2159)
4. ???
5. Profit

51
docs/install/archlinux.md Normal file
View File

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

View File

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

View File

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

View File

@@ -471,4 +471,7 @@ ACCOUNT_FORMS = {'signup': 'cookbook.forms.AllAuthSignupForm', 'reset_password':
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_RATE_LIMITS = {"change_password": "1/m/user", "reset_password": "1/m/ip,1/m/key", "reset_password_from_key": "1/m/ip", "signup": "5/m/ip", "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)

View File

@@ -1,5 +1,5 @@
Django==4.2.10
cryptography===42.0.2
cryptography===42.0.4
django-annoying==0.10.6
django-cleanup==8.0.0
django-crispy-forms==2.0
@@ -14,7 +14,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
@@ -25,6 +25,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
@@ -32,6 +33,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
@@ -45,3 +47,4 @@ pytest-factoryboy==2.6.0
pyppeteer==1.0.2
validators==0.20.0
pytube==15.0.0
homeassistant-api==4.1.1.post2

View File

@@ -69,10 +69,17 @@
<td v-for="p in f.properties" v-bind:key="`${f.id}_${p.property_type.id}`">
<b-input-group>
<template v-if="p.property_amount == null">
<b-btn class="btn-sm btn-block btn-success" @click="p.property_amount = 0; updateFood(f)">Add</b-btn>
<b-btn class="btn-sm btn-block btn-success" @click="enableProperty(p,f)">Add</b-btn>
</template>
<template v-else>
<b-form-input v-model="p.property_amount" type="number" :disabled="f.loading" v-b-tooltip.focus :title="p.property_type.name" @change="updateFood(f)"></b-form-input>
<b-input-group>
<b-form-input v-model="p.property_amount" type="number" :ref="`id_input_${f.id}_${p.property_type.id}`" :disabled="f.loading" v-b-tooltip.focus :title="p.property_type.name"
@change="updateFood(f)"></b-form-input>
<b-input-group-append>
<b-btn @click="p.property_amount = null; updateFood(f)"><i class="fas fa-trash-alt"></i></b-btn>
</b-input-group-append>
</b-input-group>
</template>
</b-input-group>
</td>
@@ -103,7 +110,6 @@
</b-input-group>
<i class="fas fa-divide fa-fw mr-1 ml-1"></i>
<b-input type="number" v-model="calculator_to_per"></b-input>
</b-form>
@@ -150,6 +156,7 @@ import GenericMultiselect from "@/components/GenericMultiselect.vue";
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
import KeywordsComponent from "@/components/KeywordsComponent.vue";
import VueClipboard from 'vue-clipboard2'
Vue.use(VueClipboard)
Vue.use(BootstrapVue)
@@ -197,7 +204,7 @@ export default {
this.recipe.steps.forEach(s => {
s.ingredients.forEach(i => {
if (this.foods.filter(x => (x.id === i.food.id)).length === 0) {
if (i.food != null && this.foods.filter(x => (x.id === i.food.id)).length === 0) {
this.foods.push(this.buildFood(i.food))
}
})
@@ -248,7 +255,8 @@ export default {
updateFood: function (food) {
let apiClient = new ApiApiFactory()
apiClient.partialUpdateFood(food.id, food).then(result => {
this.spliceInFood(this.buildFood(result.data))
// don't use result to prevent flickering
//this.spliceInFood(this.buildFood(result.data))
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
@@ -268,7 +276,14 @@ export default {
},
copyCalculatedResult: function () {
this.$copyText(this.calculator_to_amount)
}
},
enableProperty: async function (property, food) {
property.property_amount = 0;
this.updateFood(food)
await this.$nextTick();
this.$refs[`id_input_${food.id}_${property.property_type.id}`][0].focus()
this.$refs[`id_input_${food.id}_${property.property_type.id}`][0].select()
},
},
}
</script>

View File

@@ -44,7 +44,7 @@
<!-- step text -->
<div class="row">
<div class="col col-md-12">
<compile-component :code="step.ingredients_markdown"
<compile-component :code="step.instructions_markdown"
:ingredient_factor="ingredient_factor"></compile-component>
</div>
</div>

View File

@@ -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."
}

View File

@@ -518,6 +518,7 @@ export class Models {
header_component: {
name: "BetaWarning",
},
params: ["automation_type", "page", "pageSize", "options"],
},
create: {
params: [["name", "description", "type", "param_1", "param_2", "param_3", "order", "disabled"]],
@@ -620,7 +621,7 @@ export class Models {
},
form_function: "AutomationOrderDefault",
},
},
}
}
static UNIT_CONVERSION = {
@@ -1032,7 +1033,7 @@ export class Models {
static CUSTOM_FILTER = {
name: "Custom Filter",
apiName: "CustomFilter",
paginated: true,
create: {
params: [["name", "search", "shared"]],
form: {
@@ -1055,6 +1056,9 @@ export class Models {
},
},
},
list: {
params: ["page", "pageSize", "options"],
},
}
static USER_NAME = {
name: "User",
@@ -1228,6 +1232,7 @@ export class Models {
static STEP = {
name: "Step",
apiName: "Step",
paginated: true,
list: {
params: ["recipe", "query", "page", "pageSize", "options"],
},

File diff suppressed because it is too large Load Diff