mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-01 04:10:06 -05:00
Merge branch 'develop' into api_enhancements
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
0
cookbook/connectors/__init__.py
Normal file
0
cookbook/connectors/__init__.py
Normal file
29
cookbook/connectors/connector.py
Normal file
29
cookbook/connectors/connector.py
Normal 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?)
|
||||
179
cookbook/connectors/connector_manager.py
Normal file
179
cookbook/connectors/connector_manager.py
Normal 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")
|
||||
85
cookbook/connectors/homeassistant.py
Normal file
85
cookbook/connectors/homeassistant.py
Normal 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
|
||||
@@ -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 (<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'),
|
||||
}
|
||||
|
||||
|
||||
# TODO: Deprecate
|
||||
# class RecipeBookEntryForm(forms.ModelForm):
|
||||
# prefix = 'bookmark'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
36
cookbook/migrations/0215_connectorconfig.py
Normal file
36
cookbook/migrations/0215_connectorconfig.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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')])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/',
|
||||
|
||||
126
cookbook/tests/api/test_api_connector_config.py
Normal file
126
cookbook/tests/api/test_api_connector_config.py
Normal 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
|
||||
75
cookbook/tests/edits/test_edits_connector_config.py
Normal file
75
cookbook/tests/edits/test_edits_connector_config.py
Normal 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]
|
||||
@@ -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'
|
||||
|
||||
|
||||
27
cookbook/tests/other/test_connector_manager.py
Normal file
27
cookbook/tests/other/test_connector_manager.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user