Merge branch 'develop' into develop
@ -5,26 +5,27 @@ DEBUG=0
|
||||
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
|
||||
ALLOWED_HOSTS=*
|
||||
|
||||
# random secret key, use for example base64 /dev/urandom | head -c50 to generate one
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
SECRET_KEY=
|
||||
|
||||
# your default timezone
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
# DB_OPTIONS= {} # e.g. {"sslmode":"require"} to enable ssl
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangodb
|
||||
POSTGRES_USER=djangouser
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# the default value for the user preference 'fractions' (enable/disable fraction support)
|
||||
# when unset: 0 (disabled)
|
||||
# default: disabled=0
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# when unset: 1 (true)
|
||||
# default comments enabled=1
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
|
||||
# Users can set a amount of time after which the shopping list is refreshed when they are in viewing mode
|
||||
@ -47,13 +48,13 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave of if you dont know what you are doing
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
|
||||
# allows you to setup o auth providers
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount.providers.nextcloud,
|
||||
|
||||
|
18
.github/ISSUE_TEMPLATE/help-request.md
vendored
@ -6,14 +6,16 @@ labels: setup issue
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Version
|
||||
Please provide your current version (can be found on the system page since v0.8.4)
|
||||
Version:
|
||||
|
||||
### Issue
|
||||
## Issue
|
||||
Please describe your problem here
|
||||
|
||||
|
||||
## Setup Info
|
||||
Version: (can be found on the system page since v0.8.4)
|
||||
OS: e.g. Ubuntu 20.02
|
||||
|
||||
Other relevant information regarding your problem (proxies, firewalls, etc.)
|
||||
|
||||
### `.env`
|
||||
Please include your `.env` config file (**make sure to remove/replace all secrets**)
|
||||
```
|
||||
@ -25,3 +27,7 @@ When running with docker compose please provide your `docker-compose.yml`
|
||||
```
|
||||
docker-compose.yml content
|
||||
```
|
||||
|
||||
### Logs
|
||||
If you feel like there is anything interesting please post the output of `docker-compose logs` at
|
||||
container startup and when the issue happens.
|
22
.github/ISSUE_TEMPLATE/url_import.md
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Website Import
|
||||
about: Anything related to website imports
|
||||
title: ''
|
||||
labels: enhancement, url_import
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Version
|
||||
Please provide your current version (can be found on the system page since v0.8.4)
|
||||
Version:
|
||||
|
||||
### Information
|
||||
Exact URL you are trying to import from:
|
||||
|
||||
When did the issue happen: When pressing the search button with the url / when importing after the page has loaded
|
||||
|
||||
Response/Message shown
|
||||
```
|
||||
Message
|
||||
```
|
2
.github/workflows/ci.yml
vendored
@ -25,4 +25,4 @@ jobs:
|
||||
python3 manage.py collectstatic_js_reverse
|
||||
- name: Django Testing project
|
||||
run: |
|
||||
python3 manage.py test
|
||||
pytest
|
||||
|
1
.gitignore
vendored
@ -78,3 +78,4 @@ postgresql/
|
||||
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
.vscode/
|
||||
|
@ -2,9 +2,12 @@
|
||||
<dictionary name="vabene1111-PC">
|
||||
<words>
|
||||
<w>autosync</w>
|
||||
<w>chowdown</w>
|
||||
<w>csrftoken</w>
|
||||
<w>gunicorn</w>
|
||||
<w>ical</w>
|
||||
<w>mealie</w>
|
||||
<w>safron</w>
|
||||
<w>traefik</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
@ -29,4 +29,7 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="TestRunnerService">
|
||||
<option name="PROJECT_TEST_RUNNER" value="pytest" />
|
||||
</component>
|
||||
</module>
|
@ -14,8 +14,10 @@ RUN mkdir /opt/recipes
|
||||
WORKDIR /opt/recipes
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libressl-dev libffi-dev && \
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libressl-dev libffi-dev cargo && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
|
||||
|
36
README.md
@ -1,13 +1,27 @@
|
||||
# Recipes
|
||||

|
||||

|
||||

|
||||

|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://app.tandoor.dev"><img src="https://github.com/vabene1111/recipes/raw/develop/docs/logo_color.svg" height="256px" width="256px"></a>
|
||||
<br>
|
||||
Tandoor Recipes
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
Recipes is a Django application to manage, tag and search recipes using either built-in models or
|
||||
external storage providers hosting PDF's, images or other files.
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
[Installation Instructions](https://vabene1111.github.io/recipes/install/docker/) - [Documentation](https://vabene1111.github.io/recipes/) - [More (slightly outdated) Screenshots](https://imgur.com/a/V01151p)
|
||||
<p align="center">
|
||||
|
||||
<img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop" >
|
||||
<img src="https://img.shields.io/github/stars/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/github/forks/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/docker/pulls/vabene1111/recipes" >
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Documentation</a> •
|
||||
<a href="https://app.tandoor.dev/" target="_blank" rel="noopener noreferrer">Demo</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
@ -32,14 +46,10 @@ external storage providers hosting PDF's, images or other files.
|
||||
This application is meant for people with a collection of recipes they want to share with family and friends or simply
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
|
||||
a public page.
|
||||
Documentation can be found [here](https://github.com/vabene1111/recipes/wiki).
|
||||
Documentation can be found [here](https://docs.tandoor.dev/).
|
||||
|
||||
While this application has been around for a while and is actively used by many (including myself), it is still considered
|
||||
**beta** software that has a lot of rough edges and unpolished parts.
|
||||
|
||||
## Documentation
|
||||
Please refer to the [documentation](https://vabene1111.github.io/recipes/) for everything you need to know.
|
||||
|
||||
## License
|
||||
|
||||
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with an
|
||||
|
@ -1,25 +1,35 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
|
||||
MealPlan, MealType, NutritionInformation, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
|
||||
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
|
||||
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation)
|
||||
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation, ImportLog)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, CustomUserAdmin)
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'message')
|
||||
list_display = ('name', 'created_by', 'message')
|
||||
|
||||
|
||||
admin.site.register(Space, SpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'name', 'theme', 'nav_color',
|
||||
'default_page', 'search_style', 'comments'
|
||||
)
|
||||
list_display = ('name', 'space', 'theme', 'nav_color', 'default_page', 'search_style',)
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@ -203,3 +213,10 @@ class NutritionInformationAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
admin.site.register(NutritionInformation, NutritionInformationAdmin)
|
||||
|
||||
|
||||
class ImportLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'type', 'running', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
admin.site.register(ImportLog, ImportLogAdmin)
|
||||
|
@ -3,77 +3,79 @@ from django.conf import settings
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.forms import MultiSelectWidget
|
||||
from cookbook.models import Food, Keyword, Recipe, ShoppingList
|
||||
|
||||
with scopes_disabled():
|
||||
class RecipeFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(method='filter_name')
|
||||
keywords = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Keyword.objects.none(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_keywords'
|
||||
)
|
||||
foods = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Food.objects.none(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_foods',
|
||||
label=_('Ingredients')
|
||||
)
|
||||
|
||||
class RecipeFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(method='filter_name')
|
||||
keywords = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Keyword.objects.all(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_keywords'
|
||||
)
|
||||
foods = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Food.objects.all(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_foods',
|
||||
label=_('Ingredients')
|
||||
)
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(data, *args, **kwargs)
|
||||
self.filters['foods'].queryset = Food.objects.filter(space=space).all()
|
||||
self.filters['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
@staticmethod
|
||||
def filter_keywords(queryset, name, value):
|
||||
if not name == 'keywords':
|
||||
@staticmethod
|
||||
def filter_keywords(queryset, name, value):
|
||||
if not name == 'keywords':
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(keywords=x)
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(keywords=x)
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_foods(queryset, name, value):
|
||||
if not name == 'foods':
|
||||
@staticmethod
|
||||
def filter_foods(queryset, name, value):
|
||||
if not name == 'foods':
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(steps__ingredients__food__name=x).distinct()
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(
|
||||
steps__ingredients__food__name=x
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_name(queryset, name, value):
|
||||
if not name == 'name':
|
||||
@staticmethod
|
||||
def filter_name(queryset, name, value):
|
||||
if not name == 'name':
|
||||
return queryset
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter(Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)).order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=value)
|
||||
return queryset
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2': # noqa: E501
|
||||
queryset = queryset \
|
||||
.annotate(similarity=TrigramSimilarity('name', value), ) \
|
||||
.filter(Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)) \
|
||||
.order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=value)
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['name', 'keywords', 'foods', 'internal']
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['name', 'keywords', 'foods', 'internal']
|
||||
|
||||
|
||||
class IngredientFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr='icontains')
|
||||
class FoodFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr='icontains')
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ['name']
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ShoppingListFilter(django_filters.FilterSet):
|
||||
class ShoppingListFilter(django_filters.FilterSet):
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
if data is not None:
|
||||
data = data.copy()
|
||||
data.setdefault("finished", False)
|
||||
super(ShoppingListFilter, self).__init__(data, *args, **kwargs)
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
if data is not None:
|
||||
data = data.copy()
|
||||
data.setdefault("finished", False)
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ['finished']
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ['finished']
|
||||
|
@ -1,11 +1,12 @@
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from emoji_picker.widgets import EmojiPickerTextInput
|
||||
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
|
||||
UserPreference)
|
||||
UserPreference, SupermarketCategory, MealType, Space)
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
@ -74,18 +75,18 @@ class UserNameForm(forms.ModelForm):
|
||||
|
||||
class ExternalRecipeForm(forms.ModelForm):
|
||||
file_path = forms.CharField(disabled=True, required=False)
|
||||
storage = forms.ModelChoiceField(
|
||||
queryset=Storage.objects.all(),
|
||||
disabled=True,
|
||||
required=False
|
||||
)
|
||||
file_uid = forms.CharField(disabled=True, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'name', 'keywords', 'description', 'servings', 'working_time', 'waiting_time',
|
||||
'file_path', 'storage', 'file_uid'
|
||||
'name', 'description', 'servings', 'working_time', 'waiting_time',
|
||||
'file_path', 'file_uid', 'keywords'
|
||||
)
|
||||
|
||||
labels = {
|
||||
@ -97,95 +98,83 @@ class ExternalRecipeForm(forms.ModelForm):
|
||||
'file_uid': _('Storage UID'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
|
||||
|
||||
class InternalRecipeForm(forms.ModelForm):
|
||||
ingredients = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'name', 'image', 'working_time',
|
||||
'waiting_time', 'servings', 'keywords'
|
||||
)
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
'keywords': _('Keywords'),
|
||||
'working_time': _('Preparation time in minutes'),
|
||||
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
'servings': _('Number of servings'),
|
||||
field_classes = {
|
||||
'keywords': SafeModelMultipleChoiceField,
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
|
||||
|
||||
class ShoppingForm(forms.Form):
|
||||
recipe = forms.ModelMultipleChoiceField(
|
||||
queryset=Recipe.objects.filter(internal=True).all(),
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
markdown_format = forms.BooleanField(
|
||||
help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'), # noqa: E501
|
||||
required=False,
|
||||
initial=False
|
||||
)
|
||||
class ImportExportBase(forms.Form):
|
||||
DEFAULT = 'DEFAULT'
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFRON = 'SAFRON'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'),
|
||||
))
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
recipe = forms.ModelChoiceField(
|
||||
queryset=Recipe.objects.filter(internal=True).all(),
|
||||
widget=SelectWidget
|
||||
)
|
||||
image = forms.BooleanField(
|
||||
help_text=_('Export Base64 encoded image?'),
|
||||
required=False
|
||||
)
|
||||
download = forms.BooleanField(
|
||||
help_text=_('Download export directly or show on page?'),
|
||||
required=False
|
||||
)
|
||||
class ImportForm(ImportExportBase):
|
||||
files = forms.FileField(required=True, widget=forms.ClearableFileInput(attrs={'multiple': True}))
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
recipe = forms.CharField(
|
||||
widget=forms.Textarea,
|
||||
help_text=_('Simply paste a JSON export into this textarea and click import.') # noqa: E501
|
||||
)
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none())
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipes'].queryset = Recipe.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class UnitMergeForm(forms.Form):
|
||||
prefix = 'unit'
|
||||
|
||||
new_unit = forms.ModelChoiceField(
|
||||
queryset=Unit.objects.all(),
|
||||
new_unit = SafeModelChoiceField(
|
||||
queryset=Unit.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('New Unit'),
|
||||
help_text=_('New unit that other gets replaced by.'),
|
||||
)
|
||||
old_unit = forms.ModelChoiceField(
|
||||
queryset=Unit.objects.all(),
|
||||
old_unit = SafeModelChoiceField(
|
||||
queryset=Unit.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('Old Unit'),
|
||||
help_text=_('Unit that should be replaced.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['new_unit'].queryset = Unit.objects.filter(space=space).all()
|
||||
self.fields['old_unit'].queryset = Unit.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class FoodMergeForm(forms.Form):
|
||||
prefix = 'food'
|
||||
|
||||
new_food = forms.ModelChoiceField(
|
||||
queryset=Food.objects.all(),
|
||||
new_food = SafeModelChoiceField(
|
||||
queryset=Food.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('New Food'),
|
||||
help_text=_('New food that other gets replaced by.'),
|
||||
)
|
||||
old_food = forms.ModelChoiceField(
|
||||
queryset=Food.objects.all(),
|
||||
old_food = SafeModelChoiceField(
|
||||
queryset=Food.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('Old Food'),
|
||||
help_text=_('Food that should be replaced.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['new_food'].queryset = Food.objects.filter(space=space).all()
|
||||
self.fields['old_food'].queryset = Food.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
prefix = 'comment'
|
||||
@ -210,11 +199,23 @@ class KeywordForm(forms.ModelForm):
|
||||
|
||||
|
||||
class FoodForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipe'].queryset = Recipe.objects.filter(space=space).all()
|
||||
self.fields['supermarket_category'].queryset = SupermarketCategory.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'description', 'ignore_shopping', 'recipe', 'supermarket_category')
|
||||
widgets = {'recipe': SelectWidget}
|
||||
|
||||
field_classes = {
|
||||
'recipe': SafeModelChoiceField,
|
||||
'supermarket_category': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class StorageForm(forms.ModelForm):
|
||||
username = forms.CharField(
|
||||
@ -222,18 +223,16 @@ class StorageForm(forms.ModelForm):
|
||||
required=False
|
||||
)
|
||||
password = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
),
|
||||
widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('Leave empty for dropbox and enter app password for nextcloud.') # noqa: E501
|
||||
help_text=_('Leave empty for dropbox and enter app password for nextcloud.')
|
||||
)
|
||||
token = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
),
|
||||
required=False,
|
||||
help_text=_('Leave empty for nextcloud and enter api token for dropbox.') # noqa: E501
|
||||
help_text=_('Leave empty for nextcloud and enter api token for dropbox.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -241,34 +240,63 @@ class StorageForm(forms.ModelForm):
|
||||
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
|
||||
|
||||
help_texts = {
|
||||
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), # noqa: E501
|
||||
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
}
|
||||
|
||||
|
||||
class RecipeBookEntryForm(forms.ModelForm):
|
||||
prefix = 'bookmark'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = RecipeBookEntry
|
||||
fields = ('book',)
|
||||
|
||||
field_classes = {
|
||||
'book': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class SyncForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['storage'].queryset = Storage.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Sync
|
||||
fields = ('storage', 'path', 'active')
|
||||
|
||||
field_classes = {
|
||||
'storage': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(
|
||||
queryset=Keyword.objects.all().order_by('id'),
|
||||
queryset=Keyword.objects.none(),
|
||||
required=False,
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all().order_by('id')
|
||||
|
||||
|
||||
class ImportRecipeForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'keywords', 'file_path', 'file_uid')
|
||||
@ -280,16 +308,33 @@ class ImportRecipeForm(forms.ModelForm):
|
||||
'file_uid': _('File ID'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
field_classes = {
|
||||
'keywords': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class RecipeBookForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['shared'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = RecipeBook
|
||||
fields = ('name', 'icon', 'description', 'shared')
|
||||
widgets = {'icon': EmojiPickerTextInput, 'shared': MultiSelectWidget}
|
||||
field_classes = {
|
||||
'shared': SafeModelMultipleChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class MealPlanForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipe'].queryset = Recipe.objects.filter(space=space).all()
|
||||
self.fields['meal_type'].queryset = MealType.objects.filter(space=space).all()
|
||||
self.fields['shared'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(MealPlanForm, self).clean()
|
||||
@ -318,15 +363,28 @@ class MealPlanForm(forms.ModelForm):
|
||||
'date': DateWidget,
|
||||
'shared': MultiSelectWidget
|
||||
}
|
||||
field_classes = {
|
||||
'recipe': SafeModelChoiceField,
|
||||
'meal_type': SafeModelChoiceField,
|
||||
'shared': SafeModelMultipleChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['space'].queryset = Space.objects.filter(created_by=user).all()
|
||||
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
fields = ('username', 'group', 'valid_until')
|
||||
fields = ('username', 'group', 'valid_until', 'space')
|
||||
help_texts = {
|
||||
'username': _('A username is not required, if left blank the new user can choose one.') # noqa: E501
|
||||
}
|
||||
field_classes = {
|
||||
'space': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class UserCreateForm(forms.Form):
|
||||
|
8
cookbook/helper/CustomTestRunner.py
Normal file
@ -0,0 +1,8 @@
|
||||
from django.test.runner import DiscoverRunner
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
class CustomTestRunner(DiscoverRunner):
|
||||
def run_tests(self, *args, **kwargs):
|
||||
with scopes_disabled():
|
||||
return super().run_tests(*args, **kwargs)
|
@ -10,10 +10,10 @@ class BaseAutocomplete(autocomplete.Select2QuerySetView):
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.model.objects.none()
|
||||
|
||||
qs = self.model.objects.all()
|
||||
qs = self.model.objects.filter(space=self.request.space).all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__istartswith=self.q)
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
|
||||
return qs
|
||||
|
||||
|
@ -28,7 +28,7 @@ def parse_amount(x):
|
||||
and (
|
||||
x[end] in string.digits
|
||||
or (
|
||||
(x[end] == '.' or x[end] == ',')
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
)
|
||||
@ -36,7 +36,10 @@ def parse_amount(x):
|
||||
):
|
||||
end += 1
|
||||
if end > 0:
|
||||
amount = float(x[:end].replace(',', '.'))
|
||||
if "/" in x[:end]:
|
||||
amount = parse_fraction(x[:end])
|
||||
else:
|
||||
amount = float(x[:end].replace(',', '.'))
|
||||
else:
|
||||
amount = parse_fraction(x[0])
|
||||
end += 1
|
||||
|
@ -1,6 +1,9 @@
|
||||
"""
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.views.generic.detail import SingleObjectTemplateResponseMixin
|
||||
from django.views.generic.edit import ModelFormMixin
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
@ -40,8 +43,7 @@ def has_group_permission(user, groups):
|
||||
return False
|
||||
groups_allowed = get_allowed_groups(groups)
|
||||
if user.is_authenticated:
|
||||
if (user.is_superuser
|
||||
| bool(user.groups.filter(name__in=groups_allowed))):
|
||||
if bool(user.groups.filter(name__in=groups_allowed)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -56,19 +58,12 @@ def is_object_owner(user, obj):
|
||||
:param obj any object that should be tested
|
||||
:return: true if user is owner of object, false otherwise
|
||||
"""
|
||||
# TODO this could be improved/cleaned up by adding
|
||||
# get_owner methods to all models that allow owner checks
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if owner := getattr(obj, 'created_by', None):
|
||||
return owner == user
|
||||
if owner := getattr(obj, 'user', None):
|
||||
return owner == user
|
||||
if getattr(obj, 'get_owner', None):
|
||||
try:
|
||||
return obj.get_owner() == user
|
||||
return False
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def is_object_shared(user, obj):
|
||||
@ -84,9 +79,7 @@ def is_object_shared(user, obj):
|
||||
# share checks for relevant objects
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
return user in obj.shared.all()
|
||||
return user in obj.get_shared()
|
||||
|
||||
|
||||
def share_link_valid(recipe, share):
|
||||
@ -97,11 +90,7 @@ def share_link_valid(recipe, share):
|
||||
:return: true if a share link with the given recipe and uuid exists
|
||||
"""
|
||||
try:
|
||||
return (
|
||||
True
|
||||
if ShareLink.objects.filter(recipe=recipe, uuid=share).exists()
|
||||
else False
|
||||
)
|
||||
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
@ -119,7 +108,7 @@ def group_required(*groups_required):
|
||||
def in_groups(u):
|
||||
return has_group_permission(u, groups_required)
|
||||
|
||||
return user_passes_test(in_groups, login_url='view_no_group')
|
||||
return user_passes_test(in_groups, login_url='view_no_perm')
|
||||
|
||||
|
||||
class GroupRequiredMixin(object):
|
||||
@ -131,13 +120,17 @@ class GroupRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not has_group_permission(request.user, self.groups_required):
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
_('You do not have the required permissions to view this page!') # noqa: E501
|
||||
)
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
|
||||
try:
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
@ -145,25 +138,22 @@ class OwnerRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
_('You are not logged in and therefore cannot view this page!')
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy('account_login') + '?next=' + request.path
|
||||
)
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path)
|
||||
else:
|
||||
if not is_object_owner(request.user, self.get_object()):
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
_('You cannot interact with this object as it is not owned by you!') # noqa: E501
|
||||
)
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
return super(OwnerRequiredMixin, self) \
|
||||
.dispatch(request, *args, **kwargs)
|
||||
try:
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
# Django Rest Framework Permission classes
|
||||
|
@ -10,9 +10,10 @@ from cookbook.models import Keyword
|
||||
from django.http import JsonResponse
|
||||
from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
from recipe_scrapers import _utils
|
||||
|
||||
|
||||
def get_from_html(html_text, url):
|
||||
def get_from_html(html_text, url, space):
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
|
||||
# first try finding ld+json as its most common
|
||||
@ -31,7 +32,7 @@ def get_from_html(html_text, url):
|
||||
|
||||
if ('@type' in ld_json_item
|
||||
and ld_json_item['@type'] == 'Recipe'):
|
||||
return find_recipe_json(ld_json_item, url)
|
||||
return JsonResponse(find_recipe_json(ld_json_item, url, space))
|
||||
except JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{
|
||||
@ -45,7 +46,7 @@ def get_from_html(html_text, url):
|
||||
for i in items:
|
||||
md_json = json.loads(i.json())
|
||||
if 'schema.org/Recipe' in str(md_json['type']):
|
||||
return find_recipe_json(md_json['properties'], url)
|
||||
return JsonResponse(find_recipe_json(md_json['properties'], url, space))
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
@ -55,7 +56,7 @@ def get_from_html(html_text, url):
|
||||
status=400)
|
||||
|
||||
|
||||
def find_recipe_json(ld_json, url):
|
||||
def find_recipe_json(ld_json, url, space):
|
||||
if type(ld_json['name']) == list:
|
||||
try:
|
||||
ld_json['name'] = ld_json['name'][0]
|
||||
@ -69,8 +70,10 @@ def find_recipe_json(ld_json, url):
|
||||
if 'recipeIngredient' in ld_json:
|
||||
# some pages have comma separated ingredients in a single array entry
|
||||
if (len(ld_json['recipeIngredient']) == 1
|
||||
and len(ld_json['recipeIngredient'][0]) > 30):
|
||||
and type(ld_json['recipeIngredient']) == list):
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',') # noqa: E501
|
||||
elif type(ld_json['recipeIngredient']) == str:
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'].split(',')
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
if '\n' in x:
|
||||
@ -82,6 +85,7 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
if x.replace(' ', '') != '':
|
||||
x = x.replace('½', "0.5").replace('¼', "0.25").replace('¾', "0.75")
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
@ -122,28 +126,7 @@ def find_recipe_json(ld_json, url):
|
||||
ld_json['recipeIngredient'] = []
|
||||
|
||||
if 'keywords' in ld_json:
|
||||
keywords = []
|
||||
|
||||
# keywords as string
|
||||
if type(ld_json['keywords']) == str:
|
||||
ld_json['keywords'] = ld_json['keywords'].split(',')
|
||||
|
||||
# keywords as string in list
|
||||
if (type(ld_json['keywords']) == list
|
||||
and len(ld_json['keywords']) == 1
|
||||
and ',' in ld_json['keywords'][0]):
|
||||
ld_json['keywords'] = ld_json['keywords'][0].split(',')
|
||||
|
||||
# keywords as list
|
||||
for kw in ld_json['keywords']:
|
||||
if k := Keyword.objects.filter(name=kw).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k).strip()})
|
||||
else:
|
||||
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw.strip()})
|
||||
|
||||
ld_json['keywords'] = keywords
|
||||
else:
|
||||
ld_json['keywords'] = []
|
||||
ld_json['keywords'] = parse_keywords(listify_keywords(ld_json['keywords']), space)
|
||||
|
||||
if 'recipeInstructions' in ld_json:
|
||||
instructions = ''
|
||||
@ -173,7 +156,8 @@ def find_recipe_json(ld_json, url):
|
||||
else:
|
||||
ld_json['recipeInstructions'] = ''
|
||||
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from') + ' ' + url
|
||||
if url != '':
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from') + ' ' + url
|
||||
|
||||
if 'image' in ld_json:
|
||||
# check if list of images is returned, take first if so
|
||||
@ -217,13 +201,15 @@ def find_recipe_json(ld_json, url):
|
||||
else:
|
||||
ld_json['prepTime'] = 0
|
||||
|
||||
ld_json['servings'] = 1
|
||||
try:
|
||||
if 'recipeYield' in ld_json:
|
||||
if type(ld_json['recipeYield']) == str:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'])[0])
|
||||
elif type(ld_json['recipeYield']) == list:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'][0])[0])
|
||||
except Exception as e:
|
||||
print(e)
|
||||
ld_json['servings'] = 0
|
||||
|
||||
for key in list(ld_json):
|
||||
if key not in [
|
||||
@ -232,4 +218,126 @@ def find_recipe_json(ld_json, url):
|
||||
]:
|
||||
ld_json.pop(key, None)
|
||||
|
||||
return JsonResponse(ld_json)
|
||||
return ld_json
|
||||
|
||||
|
||||
def get_from_scraper(scrape, space):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
|
||||
recipe_json = {}
|
||||
recipe_json['name'] = scrape.title()
|
||||
|
||||
try:
|
||||
description = scrape.schema.data.get("description") or ''
|
||||
recipe_json['prepTime'] = _utils.get_minutes(scrape.schema.data.get("prepTime")) or 0
|
||||
recipe_json['cookTime'] = _utils.get_minutes(scrape.schema.data.get("cookTime")) or 0
|
||||
except AttributeError:
|
||||
description = ''
|
||||
recipe_json['prepTime'] = 0
|
||||
recipe_json['cookTime'] = 0
|
||||
|
||||
description += "\n\nImported from " + scrape.url
|
||||
recipe_json['description'] = description
|
||||
|
||||
try:
|
||||
servings = scrape.yields()
|
||||
servings = int(re.findall(r'\b\d+\b', servings)[0])
|
||||
except (AttributeError, ValueError, IndexError):
|
||||
servings = 1
|
||||
recipe_json['servings'] = servings
|
||||
|
||||
if recipe_json['cookTime'] + recipe_json['prepTime'] == 0:
|
||||
try:
|
||||
recipe_json['prepTime'] = scrape.total_time()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
recipe_json['image'] = scrape.image()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
keywords = []
|
||||
try:
|
||||
if scrape.schema.data.get("keywords"):
|
||||
keywords += listify_keywords(scrape.schema.data.get("keywords"))
|
||||
if scrape.schema.data.get('recipeCategory'):
|
||||
keywords += listify_keywords(scrape.schema.data.get("recipeCategory"))
|
||||
if scrape.schema.data.get('recipeCuisine'):
|
||||
keywords += listify_keywords(scrape.schema.data.get("recipeCuisine"))
|
||||
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), space)
|
||||
except AttributeError:
|
||||
recipe_json['keywords'] = keywords
|
||||
|
||||
try:
|
||||
ingredients = []
|
||||
for x in scrape.ingredients():
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': {
|
||||
'text': '',
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': x,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': '',
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
recipe_json['recipeIngredient'] = ingredients
|
||||
except AttributeError:
|
||||
recipe_json['recipeIngredient'] = ingredients
|
||||
|
||||
try:
|
||||
recipe_json['recipeInstructions'] = scrape.instructions()
|
||||
except AttributeError:
|
||||
recipe_json['recipeInstructions'] = ""
|
||||
|
||||
return recipe_json
|
||||
|
||||
|
||||
def parse_keywords(keyword_json, space):
|
||||
keywords = []
|
||||
# keywords as list
|
||||
for kw in keyword_json:
|
||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k)})
|
||||
else:
|
||||
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw})
|
||||
|
||||
return keywords
|
||||
|
||||
|
||||
def listify_keywords(keyword_list):
|
||||
# keywords as string
|
||||
if type(keyword_list) == str:
|
||||
keyword_list = keyword_list.split(',')
|
||||
|
||||
# keywords as string in list
|
||||
if (type(keyword_list) == list
|
||||
and len(keyword_list) == 1
|
||||
and ',' in keyword_list[0]):
|
||||
keyword_list = keyword_list[0].split(',')
|
||||
return [x.strip() for x in keyword_list]
|
||||
|
33
cookbook/helper/scope_middleware.py
Normal file
@ -0,0 +1,33 @@
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from cookbook.views import views
|
||||
|
||||
|
||||
class ScopeMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.user.is_authenticated:
|
||||
|
||||
if request.path.startswith('/admin/'):
|
||||
with scopes_disabled():
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
if request.user.userpreference.space is None and not reverse('account_logout') in request.path:
|
||||
return views.no_space(request)
|
||||
|
||||
if request.user.groups.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.no_groups(request)
|
||||
|
||||
request.space = request.user.userpreference.space
|
||||
# with scopes_disabled():
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
else:
|
||||
with scopes_disabled():
|
||||
request.space = None
|
||||
return self.get_response(request)
|
@ -1,6 +1,6 @@
|
||||
import bleach
|
||||
import markdown as md
|
||||
from bleach_whitelist import markdown_attrs, markdown_tags
|
||||
from bleach_allowlist import markdown_attrs, markdown_tags
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from jinja2 import Template, TemplateSyntaxError
|
||||
|
79
cookbook/integration/chowdown.py
Normal file
@ -0,0 +1,79 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class Chowdown(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^(_)*recipes/([A-Za-z\d\s-])+.md$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
description_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
descriptions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'title:' in line:
|
||||
title = line.replace('title:', '').replace('"', '').strip()
|
||||
if 'image:' in line:
|
||||
image = line.replace('image:', '').strip()
|
||||
if 'tags:' in line:
|
||||
tags = line.replace('tags:', '').strip()
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'directions:' not in line:
|
||||
ingredients.append(line[2:])
|
||||
if '---' in line and direction_mode:
|
||||
direction_mode = False
|
||||
description_mode = True
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line[2:])
|
||||
if 'ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'directions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
if description_mode and len(line) > 3 and '---' not in line:
|
||||
descriptions.append(line)
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
for k in tags.split(','):
|
||||
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions)
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient, space=self.request.space)
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{image}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
34
cookbook/integration/default.py
Normal file
@ -0,0 +1,34 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
|
||||
class Default(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_zip = ZipFile(file)
|
||||
|
||||
recipe_string = recipe_zip.read('recipe.json').decode("utf-8")
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
if 'image.png' in recipe_zip.namelist():
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read('image.png')))
|
||||
return recipe
|
||||
|
||||
def decode_recipe(self, string):
|
||||
data = json.loads(string)
|
||||
serialized_recipe = RecipeExportSerializer(data=data, context={'request': self.request})
|
||||
if serialized_recipe.is_valid():
|
||||
recipe = serialized_recipe.save()
|
||||
return recipe
|
||||
|
||||
return None
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
export = RecipeExportSerializer(recipe).data
|
||||
|
||||
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
|
@ -1,8 +1,157 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.files import File
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.models import Keyword, Recipe
|
||||
|
||||
|
||||
class Integration:
|
||||
@staticmethod
|
||||
def get_recipe(string):
|
||||
raise Exception('Method not implemented in storage integration')
|
||||
request = None
|
||||
keyword = None
|
||||
files = None
|
||||
|
||||
def __init__(self, request, export_type):
|
||||
"""
|
||||
Integration for importing and exporting recipes
|
||||
:param request: request context of import session (used to link user to created objects)
|
||||
"""
|
||||
self.request = request
|
||||
self.keyword = Keyword.objects.create(
|
||||
name=f'Import {export_type} {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}.{datetime.datetime.now().strftime("%S")}',
|
||||
description=f'Imported by {request.user.get_user_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}',
|
||||
icon='📥',
|
||||
space=request.space
|
||||
)
|
||||
|
||||
def do_export(self, recipes):
|
||||
"""
|
||||
Perform the export based on a list of recipes
|
||||
:param recipes: list of recipe objects
|
||||
:return: HttpResponse with a ZIP file that is directly downloaded
|
||||
"""
|
||||
export_zip_stream = BytesIO()
|
||||
export_zip_obj = ZipFile(export_zip_stream, 'w')
|
||||
|
||||
for r in recipes:
|
||||
if r.internal and r.space == self.request.space:
|
||||
recipe_zip_stream = BytesIO()
|
||||
recipe_zip_obj = ZipFile(recipe_zip_stream, 'w')
|
||||
|
||||
recipe_stream = StringIO()
|
||||
filename, data = self.get_file_from_recipe(r)
|
||||
recipe_stream.write(data)
|
||||
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
|
||||
recipe_stream.close()
|
||||
|
||||
try:
|
||||
recipe_zip_obj.write(r.image.path, 'image.png')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
response = HttpResponse(export_zip_stream.getvalue(), content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="export.zip"'
|
||||
return response
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
"""
|
||||
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
|
||||
If false is returned the file will be ignored
|
||||
By default all files are included
|
||||
:param zip_info_object: ZipInfo object
|
||||
:return: Boolean if object should be included
|
||||
"""
|
||||
return True
|
||||
|
||||
def do_import(self, files, il):
|
||||
"""
|
||||
Imports given files
|
||||
:param files: List of in memory files
|
||||
:param il: Import Log object to refresh while running
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
with scope(space=self.request.space):
|
||||
ignored_recipes = []
|
||||
try:
|
||||
self.files = files
|
||||
for f in files:
|
||||
if '.zip' in f['name'] or '.paprikarecipes' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if self.import_file_name_filter(z):
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
if duplicate := self.is_duplicate(recipe):
|
||||
ignored_recipes.append(duplicate)
|
||||
import_zip.close()
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(f['file'])
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
if duplicate := self.is_duplicate(recipe):
|
||||
ignored_recipes.append(duplicate)
|
||||
except BadZipFile:
|
||||
il.msg += 'ERROR ' + _('Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n'
|
||||
|
||||
if len(ignored_recipes) > 0:
|
||||
il.msg += '\n' + _('The following recipes were ignored because they already existed:') + ' ' + ', '.join(ignored_recipes) + '\n\n'
|
||||
|
||||
il.keyword = self.keyword
|
||||
il.msg += (_('Imported %s recipes.') % Recipe.objects.filter(keywords=self.keyword).count()) + '\n'
|
||||
il.running = False
|
||||
il.save()
|
||||
|
||||
def is_duplicate(self, recipe):
|
||||
"""
|
||||
Checks if a recipe is already present, if so deletes it
|
||||
:param recipe: Recipe object
|
||||
"""
|
||||
if Recipe.objects.filter(space=self.request.space, name=recipe.name).count() > 1:
|
||||
recipe.delete()
|
||||
return recipe.name
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_export(recipe):
|
||||
raise Exception('Method not implemented in storage integration')
|
||||
def import_recipe_image(recipe, image_file):
|
||||
"""
|
||||
Adds an image to a recipe naming it correctly
|
||||
:param recipe: Recipe object
|
||||
:param image_file: ByteIO stream containing the image
|
||||
"""
|
||||
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
recipe.save()
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
"""
|
||||
Takes any file like object and converts it into a recipe
|
||||
:param file: ByteIO or any file like object, depends on provider
|
||||
:return: Recipe object
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
"""
|
||||
Takes a recipe object and converts it to a string (depending on the format)
|
||||
returns both the filename of the exported file and the file contents
|
||||
:param recipe: Recipe object that should be converted
|
||||
:returns:
|
||||
- name - file name in export
|
||||
- data - string content for file to get created in export zip
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
52
cookbook/integration/mealie.py
Normal file
@ -0,0 +1,52 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class Mealie(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^recipes/([A-Za-z\d-])+.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text']
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient, space=self.request.space)
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{recipe_json["slug"]}.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
54
cookbook/integration/nextcloud_cookbook.py
Normal file
@ -0,0 +1,54 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^Recipes/([A-Za-z\d\s])+/recipe.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True,
|
||||
servings=recipe_json['recipeYield'], space=self.request.space)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
# TODO parse keywords
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient, space=self.request.space)
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^Recipes/{recipe.name}/full.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
45
cookbook/integration/paprika.py
Normal file
@ -0,0 +1,45 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import microdata
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.helper.recipe_url_import import find_recipe_json
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Ingredient, Unit
|
||||
import gzip
|
||||
|
||||
|
||||
class Paprika(Integration):
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
with gzip.open(file, 'r') as recipe_zip:
|
||||
recipe_json = json.loads(recipe_zip.read().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=recipe_json['directions'] + '\n\n' + recipe_json['nutritional_info']
|
||||
)
|
||||
|
||||
for ingredient in recipe_json['ingredients'].split('\n'):
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient, space=self.request.space)
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])))
|
||||
return recipe
|
60
cookbook/integration/safron.py
Normal file
@ -0,0 +1,60 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class Safron(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').strip()
|
||||
if 'Description:' in line:
|
||||
description = line.replace('Description:', '').strip()
|
||||
if 'Yield:' in line:
|
||||
directions.append(_('Servings') + ' ' + line.replace('Yield:', '').strip() + '\n')
|
||||
if 'Cook:' in line:
|
||||
directions.append(_('Waiting time') + ' ' + line.replace('Cook:', '').strip() + '\n')
|
||||
if 'Prep:' in line:
|
||||
directions.append(_('Preparation Time') + ' ' + line.replace('Prep:', '').strip() + '\n')
|
||||
if 'Cookbook:' in line:
|
||||
directions.append(_('Cookbook') + ' ' + line.replace('Cookbook:', '').strip() + '\n')
|
||||
if 'Section:' in line:
|
||||
directions.append(_('Section') + ' ' + line.replace('Section:', '').strip() + '\n')
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Instructions:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip())
|
||||
if 'Ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Instructions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
step = Step.objects.create(instruction='\n'.join(directions))
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient, space=self.request.space)
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
BIN
cookbook/locale/cs/LC_MESSAGES/django.mo
Normal file
1871
cookbook/locale/cs/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/hy/LC_MESSAGES/django.mo
Normal file
1880
cookbook/locale/hy/LC_MESSAGES/django.po
Normal file
@ -1,20 +1,22 @@
|
||||
# Generated by Django 3.0.2 on 2020-01-30 09:59
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_ingredient_units(apps, schema_editor):
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
RecipeIngredients = apps.get_model('cookbook', 'RecipeIngredients')
|
||||
with scopes_disabled():
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
RecipeIngredients = apps.get_model('cookbook', 'RecipeIngredients')
|
||||
|
||||
for u in RecipeIngredients.objects.values('unit').distinct():
|
||||
unit = Unit()
|
||||
unit.name = u['unit']
|
||||
unit.save()
|
||||
for u in RecipeIngredients.objects.values('unit').distinct():
|
||||
unit = Unit()
|
||||
unit.name = u['unit']
|
||||
unit.save()
|
||||
|
||||
for i in RecipeIngredients.objects.all():
|
||||
i.unit_key = Unit.objects.get(name=i.unit)
|
||||
i.save()
|
||||
for i in RecipeIngredients.objects.all():
|
||||
i.unit_key = Unit.objects.get(name=i.unit)
|
||||
i.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,19 +1,21 @@
|
||||
# Generated by Django 3.0.2 on 2020-02-16 22:09
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_ingredients(apps, schema_editor):
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
RecipeIngredient = apps.get_model('cookbook', 'RecipeIngredient')
|
||||
with scopes_disabled():
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
RecipeIngredient = apps.get_model('cookbook', 'RecipeIngredient')
|
||||
|
||||
for u in RecipeIngredient.objects.values('name').distinct():
|
||||
ingredient = Ingredient()
|
||||
ingredient.name = u['name']
|
||||
ingredient.save()
|
||||
for u in RecipeIngredient.objects.values('name').distinct():
|
||||
ingredient = Ingredient()
|
||||
ingredient.name = u['name']
|
||||
ingredient.save()
|
||||
|
||||
for i in RecipeIngredient.objects.all():
|
||||
i.ingredient = Ingredient.objects.get(name=i.name)
|
||||
i.save()
|
||||
for i in RecipeIngredient.objects.all():
|
||||
i.ingredient = Ingredient.objects.get(name=i.name)
|
||||
i.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,15 +1,17 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-26 14:14
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.bulk_create([
|
||||
Group(name=u'guest'),
|
||||
Group(name=u'user'),
|
||||
Group(name=u'admin'),
|
||||
])
|
||||
with scopes_disabled():
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.bulk_create([
|
||||
Group(name=u'guest'),
|
||||
Group(name=u'user'),
|
||||
Group(name=u'admin'),
|
||||
])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,15 +1,17 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-27 16:00
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
User = apps.get_model('auth', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.groups.count() < 1:
|
||||
u.groups.add(Group.objects.get(name='admin'))
|
||||
u.save()
|
||||
with scopes_disabled():
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
User = apps.get_model('auth', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.groups.count() < 1:
|
||||
u.groups.add(Group.objects.get(name='admin'))
|
||||
u.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,43 +2,45 @@
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_meal_types(apps, schema_editor):
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
with scopes_disabled():
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
|
||||
breakfast = MealType.objects.create(
|
||||
name=_('Breakfast'),
|
||||
order=0,
|
||||
)
|
||||
breakfast = MealType.objects.create(
|
||||
name=_('Breakfast'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
lunch = MealType.objects.create(
|
||||
name=_('Lunch'),
|
||||
order=0,
|
||||
)
|
||||
lunch = MealType.objects.create(
|
||||
name=_('Lunch'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
dinner = MealType.objects.create(
|
||||
name=_('Dinner'),
|
||||
order=0,
|
||||
)
|
||||
dinner = MealType.objects.create(
|
||||
name=_('Dinner'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
other = MealType.objects.create(
|
||||
name=_('Other'),
|
||||
order=0,
|
||||
)
|
||||
other = MealType.objects.create(
|
||||
name=_('Other'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
for m in MealPlan.objects.all():
|
||||
if m.meal == 'BREAKFAST':
|
||||
m.meal_type = breakfast
|
||||
if m.meal == 'LUNCH':
|
||||
m.meal_type = lunch
|
||||
if m.meal == 'DINNER':
|
||||
m.meal_type = dinner
|
||||
if m.meal == 'OTHER':
|
||||
m.meal_type = other
|
||||
for m in MealPlan.objects.all():
|
||||
if m.meal == 'BREAKFAST':
|
||||
m.meal_type = breakfast
|
||||
if m.meal == 'LUNCH':
|
||||
m.meal_type = lunch
|
||||
if m.meal == 'DINNER':
|
||||
m.meal_type = dinner
|
||||
if m.meal == 'OTHER':
|
||||
m.meal_type = other
|
||||
|
||||
m.save()
|
||||
m.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,22 +2,24 @@
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_meal_types(apps, schema_editor):
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
User = apps.get_model('auth', 'User')
|
||||
with scopes_disabled():
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
User = apps.get_model('auth', 'User')
|
||||
|
||||
for u in User.objects.all():
|
||||
for t in MealType.objects.filter(created_by=None).all():
|
||||
user_type = MealType.objects.create(
|
||||
name=t.name,
|
||||
created_by=u,
|
||||
)
|
||||
MealPlan.objects.filter(Q(created_by=u) and Q(meal_type=t)).update(meal_type=user_type)
|
||||
for u in User.objects.all():
|
||||
for t in MealType.objects.filter(created_by=None).all():
|
||||
user_type = MealType.objects.create(
|
||||
name=t.name,
|
||||
created_by=u,
|
||||
)
|
||||
MealPlan.objects.filter(Q(created_by=u) and Q(meal_type=t)).update(meal_type=user_type)
|
||||
|
||||
MealType.objects.filter(created_by=None).delete()
|
||||
MealType.objects.filter(created_by=None).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -3,11 +3,14 @@
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def invalidate_shares(apps, schema_editor):
|
||||
ShareLink = apps.get_model('cookbook', 'ShareLink')
|
||||
with scopes_disabled():
|
||||
ShareLink = apps.get_model('cookbook', 'ShareLink')
|
||||
|
||||
ShareLink.objects.all().delete()
|
||||
ShareLink.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,16 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-06-25 19:37
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_ingredients(apps, schema_editor):
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
with scopes_disabled():
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
|
||||
for r in Recipe.objects.all():
|
||||
for i in Ingredient.objects.filter(recipe=r).all():
|
||||
r.ingredients.add(i)
|
||||
r.save()
|
||||
for r in Recipe.objects.all():
|
||||
for i in Ingredient.objects.filter(recipe=r).all():
|
||||
r.ingredients.add(i)
|
||||
r.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -1,21 +1,23 @@
|
||||
# Generated by Django 3.0.7 on 2020-06-25 20:19
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def create_default_step(apps, schema_editor):
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Step = apps.get_model('cookbook', 'Step')
|
||||
with scopes_disabled():
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Step = apps.get_model('cookbook', 'Step')
|
||||
|
||||
for r in Recipe.objects.filter(internal=True).all():
|
||||
s = Step.objects.create(
|
||||
instruction=r.instructions
|
||||
)
|
||||
for i in r.ingredients.all():
|
||||
s.ingredients.add(i)
|
||||
s.save()
|
||||
r.steps.add(s)
|
||||
r.save()
|
||||
for r in Recipe.objects.filter(internal=True).all():
|
||||
s = Step.objects.create(
|
||||
instruction=r.instructions
|
||||
)
|
||||
for i in r.ingredients.all():
|
||||
s.ingredients.add(i)
|
||||
s.save()
|
||||
r.steps.add(s)
|
||||
r.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -2,27 +2,29 @@
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def convert_old_specials(apps, schema_editor):
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
with scopes_disabled():
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
|
||||
for i in Ingredient.objects.all():
|
||||
if i.amount == 0:
|
||||
i.no_amount = True
|
||||
if i.unit.name == 'Special:Header':
|
||||
i.header = True
|
||||
i.unit = None
|
||||
i.food = None
|
||||
i.save()
|
||||
for i in Ingredient.objects.all():
|
||||
if i.amount == 0:
|
||||
i.no_amount = True
|
||||
if i.unit.name == 'Special:Header':
|
||||
i.header = True
|
||||
i.unit = None
|
||||
i.food = None
|
||||
i.save()
|
||||
|
||||
try:
|
||||
Unit.objects.filter(name='Special:Header').delete()
|
||||
Food.objects.filter(name='Header').delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
Unit.objects.filter(name='Special:Header').delete()
|
||||
Food.objects.filter(name='Header').delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
146
cookbook/migrations/0108_auto_20210219_1410.py
Normal file
@ -0,0 +1,146 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-19 13:10
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0107_auto_20210128_1535'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cooklog',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='invitelink',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='keyword',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mealplan',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mealtype',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipebook',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipebookentry',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipeimport',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sharelink',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglist',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='storage',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supermarket',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supermarketcategory',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sync',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='synclog',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unit',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='viewlog',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
63
cookbook/migrations/0109_auto_20210221_1204.py
Normal file
@ -0,0 +1,63 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-21 11:04
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0108_auto_20210219_1410'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='recipebookentry',
|
||||
name='space',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='food',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='keyword',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supermarket',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supermarketcategory',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='unit',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='food',
|
||||
unique_together={('space', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='keyword',
|
||||
unique_together={('space', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='supermarket',
|
||||
unique_together={('space', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='supermarketcategory',
|
||||
unique_together={('space', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='unit',
|
||||
unique_together={('space', 'name')},
|
||||
),
|
||||
]
|
19
cookbook/migrations/0110_auto_20210221_1406.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-21 13:06
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0109_auto_20210221_1204'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='space',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
]
|
32
cookbook/migrations/0111_space_created_by.py
Normal file
@ -0,0 +1,32 @@
|
||||
# Generated by Django 3.1.6 on 2021-02-21 13:19
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def set_default_owner(apps, schema_editor):
|
||||
Space = apps.get_model('cookbook', 'Space')
|
||||
User = apps.get_model('auth', 'user')
|
||||
|
||||
with scopes_disabled():
|
||||
for x in Space.objects.all():
|
||||
x.created_by = User.objects.filter(is_superuser=True).first()
|
||||
x.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0110_auto_20210221_1406'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.RunPython(set_default_owner),
|
||||
]
|
17
cookbook/migrations/0112_remove_synclog_space.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1.7 on 2021-03-16 23:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0111_space_created_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='synclog',
|
||||
name='space',
|
||||
),
|
||||
]
|
21
cookbook/migrations/0113_auto_20210317_2017.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.1.7 on 2021-03-17 19:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0112_remove_synclog_space'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='shoppinglistentry',
|
||||
name='space',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='space',
|
||||
),
|
||||
]
|
31
cookbook/migrations/0114_importlog.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.1.7 on 2021-03-18 17:23
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0113_auto_20210317_2017'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ImportLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('type', models.CharField(max_length=32)),
|
||||
('running', models.BooleanField(default=True)),
|
||||
('msg', models.TextField(default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('keyword', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.keyword')),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
@ -9,7 +9,7 @@ from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_random_queryset import RandomManager
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
|
||||
STICKY_NAV_PREF_DEFAULT)
|
||||
@ -29,12 +29,44 @@ def get_model_name(model):
|
||||
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
|
||||
|
||||
|
||||
class PermissionModelMixin:
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return ('space',)
|
||||
|
||||
def get_space_kwarg(self):
|
||||
return '__'.join(self.get_space_key())
|
||||
|
||||
def get_owner(self):
|
||||
if getattr(self, 'created_by', None):
|
||||
return self.created_by
|
||||
if getattr(self, 'user', None):
|
||||
return self.user
|
||||
return None
|
||||
|
||||
def get_shared(self):
|
||||
if getattr(self, 'shared', None):
|
||||
return self.shared.all()
|
||||
return []
|
||||
|
||||
def get_space(self):
|
||||
p = '.'.join(self.get_space_key())
|
||||
if getattr(self, p, None):
|
||||
return getattr(self, p)
|
||||
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
||||
|
||||
|
||||
class Space(models.Model):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
||||
message = models.CharField(max_length=512, default='', blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class UserPreference(models.Model):
|
||||
|
||||
class UserPreference(models.Model, PermissionModelMixin):
|
||||
# Themes
|
||||
BOOTSTRAP = 'BOOTSTRAP'
|
||||
DARKLY = 'DARKLY'
|
||||
@ -107,11 +139,14 @@ class UserPreference(models.Model):
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
|
||||
|
||||
class Storage(models.Model):
|
||||
class Storage(models.Model, PermissionModelMixin):
|
||||
DROPBOX = 'DB'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
LOCAL = 'LOCAL'
|
||||
@ -128,11 +163,14 @@ class Storage(models.Model):
|
||||
path = models.CharField(blank=True, default='', max_length=256)
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Sync(models.Model):
|
||||
class Sync(models.Model, PermissionModelMixin):
|
||||
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
|
||||
path = models.CharField(max_length=512, default="")
|
||||
active = models.BooleanField(default=True)
|
||||
@ -140,92 +178,138 @@ class Sync(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.path
|
||||
|
||||
|
||||
class SupermarketCategory(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
class Supermarket(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
|
||||
class Supermarket(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
class SupermarketCategoryRelation(models.Model):
|
||||
|
||||
class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
|
||||
supermarket = models.ForeignKey(Supermarket, on_delete=models.CASCADE, related_name='category_to_supermarket')
|
||||
category = models.ForeignKey(SupermarketCategory, on_delete=models.CASCADE, related_name='category_to_supermarket')
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
objects = ScopedManager(space='supermarket__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'supermarket', 'space'
|
||||
|
||||
class Meta:
|
||||
ordering = ('order',)
|
||||
|
||||
|
||||
class SyncLog(models.Model):
|
||||
class SyncLog(models.Model, PermissionModelMixin):
|
||||
sync = models.ForeignKey(Sync, on_delete=models.CASCADE)
|
||||
status = models.CharField(max_length=32)
|
||||
msg = models.TextField(default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = ScopedManager(space='sync__space')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.sync} - {self.status}"
|
||||
|
||||
|
||||
class Keyword(models.Model):
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
class Keyword(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=64)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
if self.icon:
|
||||
return f"{self.icon} {self.name}"
|
||||
else:
|
||||
return f"{self.name}"
|
||||
|
||||
class Meta:
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
class Unit(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
|
||||
class Unit(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
class Food(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
|
||||
class Food(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
ignore_shopping = models.BooleanField(default=False)
|
||||
description = models.TextField(default='', blank=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
class Ingredient(models.Model):
|
||||
food = models.ForeignKey(
|
||||
Food, on_delete=models.PROTECT, null=True, blank=True
|
||||
)
|
||||
unit = models.ForeignKey(
|
||||
Unit, on_delete=models.PROTECT, null=True, blank=True
|
||||
)
|
||||
|
||||
class Ingredient(models.Model, PermissionModelMixin):
|
||||
food = models.ForeignKey(Food, on_delete=models.PROTECT, null=True, blank=True)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True)
|
||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
note = models.CharField(max_length=256, null=True, blank=True)
|
||||
is_header = models.BooleanField(default=False)
|
||||
no_amount = models.BooleanField(default=False)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
objects = ScopedManager(space='step__recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'step', 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.step_set.first().recipe_set.first().space
|
||||
|
||||
def __str__(self):
|
||||
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.food)
|
||||
|
||||
@ -233,7 +317,7 @@ class Ingredient(models.Model):
|
||||
ordering = ['order', 'pk']
|
||||
|
||||
|
||||
class Step(models.Model):
|
||||
class Step(models.Model, PermissionModelMixin):
|
||||
TEXT = 'TEXT'
|
||||
TIME = 'TIME'
|
||||
|
||||
@ -249,6 +333,15 @@ class Step(models.Model):
|
||||
order = models.IntegerField(default=0)
|
||||
show_as_header = models.BooleanField(default=True)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.recipe_set.first().space
|
||||
|
||||
def get_instruction_render(self):
|
||||
from cookbook.helper.template_helper import render_instructions
|
||||
return render_instructions(self)
|
||||
@ -257,7 +350,7 @@ class Step(models.Model):
|
||||
ordering = ['order', 'pk']
|
||||
|
||||
|
||||
class NutritionInformation(models.Model):
|
||||
class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
carbohydrates = models.DecimalField(
|
||||
default=0, decimal_places=16, max_digits=32
|
||||
@ -268,11 +361,20 @@ class NutritionInformation(models.Model):
|
||||
max_length=512, default="", null=True, blank=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.recipe_set.first().space
|
||||
|
||||
def __str__(self):
|
||||
return 'Nutrition'
|
||||
|
||||
|
||||
class Recipe(models.Model):
|
||||
class Recipe(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.CharField(max_length=512, blank=True, null=True)
|
||||
servings = models.IntegerField(default=1)
|
||||
@ -297,51 +399,68 @@ class Recipe(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = RandomManager()
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
class Comment(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
|
||||
class RecipeImport(models.Model):
|
||||
class RecipeImport(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
|
||||
file_uid = models.CharField(max_length=256, default="")
|
||||
file_path = models.CharField(max_length=512, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class RecipeBook(models.Model):
|
||||
class RecipeBook(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
shared = models.ManyToManyField(
|
||||
User, blank=True, related_name='shared_with'
|
||||
)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class RecipeBookEntry(models.Model):
|
||||
class RecipeBookEntry(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
book = models.ForeignKey(RecipeBook, on_delete=models.CASCADE)
|
||||
|
||||
objects = ScopedManager(space='book__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'book', 'space'
|
||||
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
@ -355,29 +474,31 @@ class RecipeBookEntry(models.Model):
|
||||
unique_together = (('recipe', 'book'),)
|
||||
|
||||
|
||||
class MealType(models.Model):
|
||||
class MealType(models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
order = models.IntegerField(default=0)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class MealPlan(models.Model):
|
||||
recipe = models.ForeignKey(
|
||||
Recipe, on_delete=models.CASCADE, blank=True, null=True
|
||||
)
|
||||
class MealPlan(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
title = models.CharField(max_length=64, blank=True, default='')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
shared = models.ManyToManyField(
|
||||
User, blank=True, related_name='plan_share'
|
||||
)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
|
||||
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
|
||||
note = models.TextField(blank=True)
|
||||
date = models.DateField()
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def get_label(self):
|
||||
if self.title:
|
||||
return self.title
|
||||
@ -390,12 +511,19 @@ class MealPlan(models.Model):
|
||||
return f'{self.get_label()} - {self.date} - {self.meal_type.name}'
|
||||
|
||||
|
||||
class ShoppingListRecipe(models.Model):
|
||||
recipe = models.ForeignKey(
|
||||
Recipe, on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
class ShoppingListRecipe(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.recipe.space
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list recipe {self.id} - {self.recipe}'
|
||||
|
||||
@ -406,7 +534,7 @@ class ShoppingListRecipe(models.Model):
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListEntry(models.Model):
|
||||
class ShoppingListEntry(models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
|
||||
@ -414,9 +542,21 @@ class ShoppingListEntry(models.Model):
|
||||
order = models.IntegerField(default=0)
|
||||
checked = models.BooleanField(default=False)
|
||||
|
||||
objects = ScopedManager(space='shoppinglist__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'shoppinglist', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.shoppinglist_set.first().space
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
def get_shared(self):
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
@ -424,7 +564,7 @@ class ShoppingListEntry(models.Model):
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingList(models.Model):
|
||||
class ShoppingList(models.Model, PermissionModelMixin):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
|
||||
@ -435,16 +575,22 @@ class ShoppingList(models.Model):
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list {self.id}'
|
||||
|
||||
|
||||
class ShareLink(models.Model):
|
||||
class ShareLink(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.recipe} - {self.uuid}'
|
||||
|
||||
@ -453,7 +599,7 @@ def default_valid_until():
|
||||
return date.today() + timedelta(days=14)
|
||||
|
||||
|
||||
class InviteLink(models.Model):
|
||||
class InviteLink(models.Model, PermissionModelMixin):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
username = models.CharField(blank=True, max_length=64)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
@ -464,25 +610,49 @@ class InviteLink(models.Model):
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.uuid}'
|
||||
|
||||
|
||||
class CookLog(models.Model):
|
||||
class CookLog(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
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)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
|
||||
class ViewLog(models.Model):
|
||||
class ViewLog(models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
|
||||
class ImportLog(models.Model, PermissionModelMixin):
|
||||
type = models.CharField(max_length=32)
|
||||
running = models.BooleanField(default=True)
|
||||
msg = models.TextField(default="")
|
||||
keyword = models.ForeignKey(Keyword, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
@ -35,14 +35,14 @@ class Dropbox(Provider):
|
||||
# TODO check if has_more is set and import that as well
|
||||
for recipe in recipes['entries']:
|
||||
path = recipe['path_lower']
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() \
|
||||
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
|
||||
if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists():
|
||||
name = os.path.splitext(recipe['name'])[0]
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage,
|
||||
file_uid=recipe['id']
|
||||
file_uid=recipe['id'],
|
||||
space=monitor.space,
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
@ -50,7 +50,7 @@ class Dropbox(Provider):
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
msg='Imported ' + str(import_count) + ' recipes',
|
||||
sync=monitor
|
||||
sync=monitor,
|
||||
)
|
||||
log_entry.save()
|
||||
|
||||
@ -104,9 +104,7 @@ class Dropbox(Provider):
|
||||
recipe.link = Dropbox.get_share_link(recipe)
|
||||
recipe.save()
|
||||
|
||||
response = requests.get(
|
||||
recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')
|
||||
)
|
||||
response = requests.get(recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.'))
|
||||
|
||||
return io.BytesIO(response.content)
|
||||
|
||||
|
@ -18,13 +18,13 @@ class Local(Provider):
|
||||
import_count = 0
|
||||
for file in files:
|
||||
path = monitor.path + '/' + file
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() \
|
||||
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
|
||||
if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists():
|
||||
name = os.path.splitext(file)[0]
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage
|
||||
storage=monitor.storage,
|
||||
space=monitor.space,
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
@ -32,7 +32,7 @@ class Local(Provider):
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
msg='Imported ' + str(import_count) + ' recipes',
|
||||
sync=monitor
|
||||
sync=monitor,
|
||||
)
|
||||
log_entry.save()
|
||||
|
||||
|
@ -34,13 +34,13 @@ class Nextcloud(Provider):
|
||||
import_count = 0
|
||||
for file in files:
|
||||
path = monitor.path + '/' + file
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() \
|
||||
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
|
||||
if not Recipe.objects.filter(file_path__iexact=path, space=monitor.space).exists() and not RecipeImport.objects.filter(file_path=path, space=monitor.space).exists():
|
||||
name = os.path.splitext(file)[0]
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage
|
||||
storage=monitor.storage,
|
||||
space=monitor.space,
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
@ -48,7 +48,7 @@ class Nextcloud(Provider):
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
msg='Imported ' + str(import_count) + ' recipes',
|
||||
sync=monitor
|
||||
sync=monitor,
|
||||
)
|
||||
log_entry.save()
|
||||
|
||||
@ -68,14 +68,7 @@ class Nextcloud(Provider):
|
||||
|
||||
data = {'path': recipe.file_path, 'shareType': 3}
|
||||
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(
|
||||
recipe.storage.username, recipe.storage.password
|
||||
),
|
||||
data=data
|
||||
)
|
||||
r = requests.post(url, headers=headers, auth=HTTPBasicAuth(recipe.storage.username, recipe.storage.password), data=data)
|
||||
|
||||
response_json = r.json()
|
||||
|
||||
|
@ -1,17 +1,20 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import QuerySet
|
||||
from drf_writable_nested import (UniqueFieldsMixin,
|
||||
WritableNestedModelSerializer)
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.exceptions import ValidationError, NotAuthenticated, NotFound, ParseError
|
||||
from rest_framework.fields import ModelField
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
|
||||
from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
|
||||
MealPlan, MealType, NutritionInformation, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport,
|
||||
ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Sync, SyncLog,
|
||||
Unit, UserPreference, ViewLog, SupermarketCategory, Supermarket, SupermarketCategoryRelation)
|
||||
Unit, UserPreference, ViewLog, SupermarketCategory, Supermarket, SupermarketCategoryRelation, ImportLog)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
|
||||
|
||||
@ -39,6 +42,38 @@ class CustomDecimalField(serializers.Field):
|
||||
raise ValidationError('A valid number is required')
|
||||
|
||||
|
||||
class SpaceFilterSerializer(serializers.ListSerializer):
|
||||
|
||||
def to_representation(self, data):
|
||||
if type(data) == QuerySet and data.query.is_sliced:
|
||||
# if query is sliced it came from api request not nested serializer
|
||||
return super().to_representation(data)
|
||||
if self.child.Meta.model == User:
|
||||
data = data.filter(userpreference__space=self.context['request'].space)
|
||||
else:
|
||||
data = data.filter(**{'__'.join(data.model.get_space_key()): self.context['request'].space})
|
||||
return super().to_representation(data)
|
||||
|
||||
|
||||
class SpacedModelSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class MealTypeSerializer(SpacedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = SpaceFilterSerializer
|
||||
model = MealType
|
||||
fields = ('id', 'name', 'order', 'created_by')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
class UserNameSerializer(WritableNestedModelSerializer):
|
||||
username = serializers.SerializerMethodField('get_user_label')
|
||||
|
||||
@ -46,11 +81,18 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
||||
return obj.get_user_name()
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = SpaceFilterSerializer
|
||||
model = User
|
||||
fields = ('id', 'username')
|
||||
|
||||
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
if validated_data['user'] != self.context['request'].user:
|
||||
raise NotFound()
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
@ -58,10 +100,14 @@ class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
||||
'comments'
|
||||
)
|
||||
read_only_fields = ['user']
|
||||
|
||||
|
||||
class StorageSerializer(serializers.ModelSerializer):
|
||||
class StorageSerializer(SpacedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Storage
|
||||
fields = (
|
||||
@ -69,13 +115,15 @@ class StorageSerializer(serializers.ModelSerializer):
|
||||
'token', 'created_by'
|
||||
)
|
||||
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True},
|
||||
'token': {'write_only': True},
|
||||
}
|
||||
|
||||
|
||||
class SyncSerializer(serializers.ModelSerializer):
|
||||
class SyncSerializer(SpacedModelSerializer):
|
||||
class Meta:
|
||||
model = Sync
|
||||
fields = (
|
||||
@ -84,7 +132,7 @@ class SyncSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class SyncLogSerializer(serializers.ModelSerializer):
|
||||
class SyncLogSerializer(SpacedModelSerializer):
|
||||
class Meta:
|
||||
model = SyncLog
|
||||
fields = ('id', 'sync', 'status', 'msg', 'created_at')
|
||||
@ -97,6 +145,7 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
|
||||
return str(obj)
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = SpaceFilterSerializer
|
||||
model = Keyword
|
||||
fields = (
|
||||
'id', 'label',
|
||||
@ -111,17 +160,13 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
return str(obj)
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = Keyword.objects.get_or_create(**validated_data)
|
||||
obj, created = Keyword.objects.get_or_create(name=validated_data['name'], space=self.context['request'].space)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = SpaceFilterSerializer
|
||||
model = Keyword
|
||||
fields = (
|
||||
'id', 'name', 'icon', 'label', 'description',
|
||||
'created_at', 'updated_at'
|
||||
)
|
||||
fields = ('id', 'name', 'icon', 'label', 'description', 'created_at', 'updated_at')
|
||||
|
||||
read_only_fields = ('id',)
|
||||
|
||||
@ -129,9 +174,7 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = Unit.objects.get_or_create(**validated_data)
|
||||
obj, created = Unit.objects.get_or_create(name=validated_data['name'], space=self.context['request'].space)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
@ -143,9 +186,7 @@ class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = SupermarketCategory.objects.get_or_create(**validated_data)
|
||||
obj, created = SupermarketCategory.objects.get_or_create(name=validated_data['name'], space=self.context['request'].space)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@ -156,7 +197,7 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial
|
||||
fields = ('id', 'name')
|
||||
|
||||
|
||||
class SupermarketCategoryRelationSerializer(serializers.ModelSerializer):
|
||||
class SupermarketCategoryRelationSerializer(SpacedModelSerializer):
|
||||
category = SupermarketCategorySerializer()
|
||||
|
||||
class Meta:
|
||||
@ -164,7 +205,7 @@ class SupermarketCategoryRelationSerializer(serializers.ModelSerializer):
|
||||
fields = ('id', 'category', 'supermarket', 'order')
|
||||
|
||||
|
||||
class SupermarketSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer):
|
||||
category_to_supermarket = SupermarketCategoryRelationSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
@ -176,9 +217,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = Food.objects.get_or_create(name=validated_data['name'])
|
||||
obj, created = Food.objects.get_or_create(name=validated_data['name'], space=self.context['request'].space)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@ -221,17 +260,6 @@ class StepSerializer(WritableNestedModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
# used for the import export. temporary workaround until that module is finally fixed
|
||||
class StepExportSerializer(WritableNestedModelSerializer):
|
||||
ingredients = IngredientSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = (
|
||||
'id', 'name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header'
|
||||
)
|
||||
|
||||
|
||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NutritionInformation
|
||||
@ -267,21 +295,17 @@ class RecipeSerializer(WritableNestedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# used for the import export. temporary workaround until that module is finally fixed
|
||||
class RecipeExportSerializer(RecipeSerializer):
|
||||
steps = StepExportSerializer(many=True)
|
||||
|
||||
|
||||
class RecipeImageSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['image', ]
|
||||
|
||||
|
||||
class RecipeImportSerializer(serializers.ModelSerializer):
|
||||
class RecipeImportSerializer(SpacedModelSerializer):
|
||||
class Meta:
|
||||
model = RecipeImport
|
||||
fields = '__all__'
|
||||
@ -293,26 +317,32 @@ class CommentSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class RecipeBookSerializer(serializers.ModelSerializer):
|
||||
class RecipeBookSerializer(SpacedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = RecipeBook
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'created_by']
|
||||
fields = ('id', 'name', 'description', 'icon', 'shared', 'created_by')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
class RecipeBookEntrySerializer(serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
book = validated_data['book']
|
||||
if not book.get_owner() == self.context['request'].user:
|
||||
raise NotFound(detail=None, code=None)
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = RecipeBookEntry
|
||||
fields = '__all__'
|
||||
fields = ('id', 'book', 'recipe',)
|
||||
|
||||
|
||||
class MealTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MealType
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class MealPlanSerializer(serializers.ModelSerializer):
|
||||
class MealPlanSerializer(SpacedModelSerializer):
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
meal_type_name = serializers.ReadOnlyField(source='meal_type.name')
|
||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||
@ -321,6 +351,10 @@ class MealPlanSerializer(serializers.ModelSerializer):
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = (
|
||||
@ -328,6 +362,7 @@ class MealPlanSerializer(serializers.ModelSerializer):
|
||||
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
||||
'meal_type_name'
|
||||
)
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
@ -364,13 +399,18 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
shared = UserNameSerializer(many=True)
|
||||
supermarket = SupermarketSerializer(allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = (
|
||||
'id', 'uuid', 'note', 'recipes', 'entries',
|
||||
'shared', 'finished', 'supermarket', 'created_by', 'created_at'
|
||||
)
|
||||
read_only_fields = ('id',)
|
||||
read_only_fields = ('id', 'created_by',)
|
||||
|
||||
|
||||
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||
@ -382,24 +422,115 @@ class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShareLinkSerializer(serializers.ModelSerializer):
|
||||
class ShareLinkSerializer(SpacedModelSerializer):
|
||||
class Meta:
|
||||
model = ShareLink
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class CookLogSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data): # TODO make mixin
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = CookLog
|
||||
fields = '__all__'
|
||||
fields = ('id', 'recipe', 'servings', 'rating', 'created_by', 'created_at')
|
||||
read_only_fields = ('id', 'created_by')
|
||||
|
||||
|
||||
class ViewLogSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ViewLog
|
||||
fields = '__all__'
|
||||
fields = ('id', 'recipe', 'created_by', 'created_at')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
class ImportLogSerializer(serializers.ModelSerializer):
|
||||
keyword = KeywordSerializer(read_only=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ImportLog
|
||||
fields = ('id', 'type', 'msg', 'running', 'keyword', 'created_by', 'created_at')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
# Export/Import Serializers
|
||||
|
||||
class KeywordExportSerializer(KeywordSerializer):
|
||||
class Meta:
|
||||
model = Keyword
|
||||
fields = ('name', 'icon', 'description', 'created_at', 'updated_at')
|
||||
|
||||
|
||||
class NutritionInformationExportSerializer(NutritionInformationSerializer):
|
||||
class Meta:
|
||||
model = NutritionInformation
|
||||
fields = ('carbohydrates', 'fats', 'proteins', 'calories', 'source')
|
||||
|
||||
|
||||
class SupermarketCategoryExportSerializer(SupermarketCategorySerializer):
|
||||
class Meta:
|
||||
model = SupermarketCategory
|
||||
fields = ('name',)
|
||||
|
||||
|
||||
class UnitExportSerializer(UnitSerializer):
|
||||
class Meta:
|
||||
model = Unit
|
||||
fields = ('name', 'description')
|
||||
|
||||
|
||||
class FoodExportSerializer(FoodSerializer):
|
||||
supermarket_category = SupermarketCategoryExportSerializer(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'ignore_shopping', 'supermarket_category')
|
||||
|
||||
|
||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
food = FoodExportSerializer(allow_null=True)
|
||||
unit = UnitExportSerializer(allow_null=True)
|
||||
amount = CustomDecimalField()
|
||||
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount')
|
||||
|
||||
|
||||
class StepExportSerializer(WritableNestedModelSerializer):
|
||||
ingredients = IngredientExportSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = ('name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
|
||||
|
||||
|
||||
class RecipeExportSerializer(WritableNestedModelSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
steps = StepExportSerializer(many=True)
|
||||
keywords = KeywordExportSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'name', 'description', 'keywords', 'steps', 'working_time',
|
||||
'waiting_time', 'internal', 'nutrition', 'servings', 'servings_text',
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
43
cookbook/static/assets/favicon.svg
Normal file
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Logo" transform="matrix(0.637323,0,0,0.637323,-243.095,-716.725)">
|
||||
<g id="Kreis" transform="matrix(1.44936,0,0,1.50279,387.258,1039.34)">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584" style="fill:url(#_Linear1);"/>
|
||||
<clipPath id="_clip2">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g id="Shadow" transform="matrix(1.10322,0,0,1.064,-5.58287,50.5786)">
|
||||
<path d="M156.285,427.208L389.554,660.477L668.803,495.551L374.012,200.761L156.285,427.208Z" style="fill:rgb(22,22,22);"/>
|
||||
<g transform="matrix(1,0,0,1,-4.22105,0.775864)">
|
||||
<path d="M208.628,178.613L485.935,455.919L590.027,364.63L296.923,71.526L294.175,138.989L208.628,178.613Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-85.3876,27.8512)">
|
||||
<path d="M310.385,145.641L587.692,422.948L590.392,361.357L297.288,68.253L294.175,138.989L310.385,145.641Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.471,0,0,1.471,406.537,1149.69)">
|
||||
<path d="M256.049,220C286.222,219.994 312.656,207.31 329.388,194.134C346.35,180.754 370.899,183.406 384.611,200.1C407.129,227.376 420.598,261.944 420.598,299.53C420.598,361.08 382.604,437.101 329.764,463.706C307.035,475.15 283.466,480.586 256.098,480.599L256.098,480.599L256.049,480.599L256,480.599L256,480.599C228.632,480.586 205.063,475.15 182.334,463.706C129.494,437.101 91.5,361.08 91.5,299.53C91.5,261.944 104.969,227.376 127.487,200.1C141.199,183.406 165.748,180.754 182.71,194.134C199.442,207.31 225.876,219.994 256.049,220Z" style="fill:rgb(255,203,118);"/>
|
||||
</g>
|
||||
<g id="Flame-2" serif:id="Flame 2" transform="matrix(0.965725,0,0,0.89175,164.497,436.391)">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z" style="fill:rgb(255,111,0);"/>
|
||||
<clipPath id="_clip3">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip3)">
|
||||
<g transform="matrix(1.28784,-0.270602,0.285942,1.59598,247.349,825.209)">
|
||||
<path d="M255.004,46.957C279.547,58.545 306,85.447 313.307,120.161C325.437,177.791 291.571,193.789 262.496,192.403C215.889,190.181 200.194,153.246 231.326,108.9C250.631,81.401 232.663,36.408 255.004,46.957Z" style="fill:rgb(255,209,0);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Hut" transform="matrix(1.521,0,0,1.521,393.566,1149.06)">
|
||||
<path d="M228.197,408.524C222.698,408.524 217.813,406.688 214.024,403.619C211.776,401.794 210.92,398.752 211.888,396.024C212.856,393.295 215.437,391.472 218.332,391.472C232.214,391.4 256.112,391.396 256.112,391.396C256.112,391.396 280.009,391.4 293.891,391.472C296.786,391.472 299.367,393.295 300.335,396.024C301.303,398.752 300.447,401.794 298.199,403.619C294.41,406.688 289.526,408.524 284.027,408.524L228.197,408.524ZM217.24,378.877C214.208,378.877 211.3,377.671 209.158,375.525C207.015,373.379 205.814,370.469 205.82,367.436C205.831,361.119 205.842,354.539 205.842,354.539C205.842,350.423 203.097,346.814 199.131,345.714C185.313,341.841 175.2,329.468 175.2,314.823C175.2,297.07 190.059,282.657 208.362,282.657C208.362,282.657 208.362,282.657 208.362,282.657C215.401,282.657 221.675,278.218 224.017,271.581C227.243,262.39 236.411,252.015 256,251.998L256,251.998L256.223,251.998L256.223,251.998C275.812,252.015 284.98,262.39 288.206,271.581C290.549,278.218 296.822,282.657 303.861,282.657C303.861,282.657 303.861,282.657 303.861,282.657C322.164,282.657 337.023,297.07 337.023,314.823C337.023,329.468 326.911,341.841 313.093,345.714C309.127,346.814 306.382,350.423 306.381,354.539C306.381,354.539 306.386,361.127 306.391,367.447C306.394,370.478 305.191,373.385 303.049,375.529C300.907,377.672 298.001,378.877 294.971,378.877C275.615,378.877 236.604,378.877 217.24,378.877Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2e-06,0,0,2e-06,3755.77,81.7179)"><stop offset="0" style="stop-color:rgb(39,39,39);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 6.1 KiB |
43
cookbook/static/assets/logo_color.svg
Normal file
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Logo" transform="matrix(0.637323,0,0,0.637323,-243.095,-716.725)">
|
||||
<g id="Kreis" transform="matrix(1.44936,0,0,1.50279,387.258,1039.34)">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584" style="fill:url(#_Linear1);"/>
|
||||
<clipPath id="_clip2">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g id="Shadow" transform="matrix(1.10322,0,0,1.064,-5.58287,50.5786)">
|
||||
<path d="M156.285,427.208L389.554,660.477L668.803,495.551L374.012,200.761L156.285,427.208Z" style="fill:rgb(22,22,22);"/>
|
||||
<g transform="matrix(1,0,0,1,-4.22105,0.775864)">
|
||||
<path d="M208.628,178.613L485.935,455.919L590.027,364.63L296.923,71.526L294.175,138.989L208.628,178.613Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-85.3876,27.8512)">
|
||||
<path d="M310.385,145.641L587.692,422.948L590.392,361.357L297.288,68.253L294.175,138.989L310.385,145.641Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.471,0,0,1.471,406.537,1149.69)">
|
||||
<path d="M256.049,220C286.222,219.994 312.656,207.31 329.388,194.134C346.35,180.754 370.899,183.406 384.611,200.1C407.129,227.376 420.598,261.944 420.598,299.53C420.598,361.08 382.604,437.101 329.764,463.706C307.035,475.15 283.466,480.586 256.098,480.599L256.098,480.599L256.049,480.599L256,480.599L256,480.599C228.632,480.586 205.063,475.15 182.334,463.706C129.494,437.101 91.5,361.08 91.5,299.53C91.5,261.944 104.969,227.376 127.487,200.1C141.199,183.406 165.748,180.754 182.71,194.134C199.442,207.31 225.876,219.994 256.049,220Z" style="fill:rgb(255,203,118);"/>
|
||||
</g>
|
||||
<g id="Flame-2" serif:id="Flame 2" transform="matrix(0.965725,0,0,0.89175,164.497,436.391)">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z" style="fill:rgb(255,111,0);"/>
|
||||
<clipPath id="_clip3">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip3)">
|
||||
<g transform="matrix(1.28784,-0.270602,0.285942,1.59598,247.349,825.209)">
|
||||
<path d="M255.004,46.957C279.547,58.545 306,85.447 313.307,120.161C325.437,177.791 291.571,193.789 262.496,192.403C215.889,190.181 200.194,153.246 231.326,108.9C250.631,81.401 232.663,36.408 255.004,46.957Z" style="fill:rgb(255,209,0);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Hut" transform="matrix(1.521,0,0,1.521,393.566,1149.06)">
|
||||
<path d="M228.197,408.524C222.698,408.524 217.813,406.688 214.024,403.619C211.776,401.794 210.92,398.752 211.888,396.024C212.856,393.295 215.437,391.472 218.332,391.472C232.214,391.4 256.112,391.396 256.112,391.396C256.112,391.396 280.009,391.4 293.891,391.472C296.786,391.472 299.367,393.295 300.335,396.024C301.303,398.752 300.447,401.794 298.199,403.619C294.41,406.688 289.526,408.524 284.027,408.524L228.197,408.524ZM217.24,378.877C214.208,378.877 211.3,377.671 209.158,375.525C207.015,373.379 205.814,370.469 205.82,367.436C205.831,361.119 205.842,354.539 205.842,354.539C205.842,350.423 203.097,346.814 199.131,345.714C185.313,341.841 175.2,329.468 175.2,314.823C175.2,297.07 190.059,282.657 208.362,282.657C208.362,282.657 208.362,282.657 208.362,282.657C215.401,282.657 221.675,278.218 224.017,271.581C227.243,262.39 236.411,252.015 256,251.998L256,251.998L256.223,251.998L256.223,251.998C275.812,252.015 284.98,262.39 288.206,271.581C290.549,278.218 296.822,282.657 303.861,282.657C303.861,282.657 303.861,282.657 303.861,282.657C322.164,282.657 337.023,297.07 337.023,314.823C337.023,329.468 326.911,341.841 313.093,345.714C309.127,346.814 306.382,350.423 306.381,354.539C306.381,354.539 306.386,361.127 306.391,367.447C306.394,370.478 305.191,373.385 303.049,375.529C300.907,377.672 298.001,378.877 294.971,378.877C275.615,378.877 236.604,378.877 217.24,378.877Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2e-06,0,0,2e-06,3755.77,81.7179)"><stop offset="0" style="stop-color:rgb(39,39,39);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 6.1 KiB |
BIN
cookbook/static/assets/logo_color144.png
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
cookbook/static/assets/logo_color512.png
Normal file
After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
Before Width: | Height: | Size: 5.9 KiB |
@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="book" class="svg-inline--fa fa-book fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M128 152v-32c0-4.4 3.6-8 8-8h208c4.4 0 8 3.6 8 8v32c0 4.4-3.6 8-8 8H136c-4.4 0-8-3.6-8-8zm8 88h208c4.4 0 8-3.6 8-8v-32c0-4.4-3.6-8-8-8H136c-4.4 0-8 3.6-8 8v32c0 4.4 3.6 8 8 8zm299.1 159.7c-4.2 13-4.2 51.6 0 64.6 7.3 1.4 12.9 7.9 12.9 15.7v16c0 8.8-7.2 16-16 16H80c-44.2 0-80-35.8-80-80V80C0 35.8 35.8 0 80 0h352c8.8 0 16 7.2 16 16v368c0 7.8-5.5 14.2-12.9 15.7zm-41.1.3H80c-17.6 0-32 14.4-32 32 0 17.7 14.3 32 32 32h314c-2.7-17.3-2.7-46.7 0-64zm6-352H80c-17.7 0-32 14.3-32 32v278.7c9.8-4.3 20.6-6.7 32-6.7h320V48z"></path></svg>
|
Before Width: | Height: | Size: 740 B |
Before Width: | Height: | Size: 2.0 KiB |
Before Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 1.8 KiB |
1
cookbook/static/vue/import_response_view.html
Normal file
@ -0,0 +1 @@
|
||||
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue App</title><link href="css/chunk-vendors.css" rel="preload" as="style"><link href="js/chunk-vendors.js" rel="preload" as="script"><link href="js/import_response_view.js" rel="preload" as="script"><link href="css/chunk-vendors.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="img/icons/favicon-16x16.png"><link rel="manifest" href="manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="apple-mobile-web-app-title" content="Recipes"><link rel="apple-touch-icon" href="img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><div id="app"></div><script src="js/chunk-vendors.js"></script></body></html>
|
1
cookbook/static/vue/js/import_response_view.js
Normal file
@ -11,17 +11,17 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% static 'favicon.png' %}">
|
||||
<link rel="shortcut icon" href="{% static 'favicon.png' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="{% static 'favicon.png' %}" sizes="96x96">
|
||||
<link rel="apple-touch-icon" href="{% static 'favicon.png' %}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'favicon.png' %}">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="{% static 'favicon.png' %}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'favicon.png' %}">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="{% static 'favicon.png' %}">
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="shortcut icon" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="icon" type="image/png" href="{% static 'assets/favicon.svg' %}" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="{% static 'assets/favicon.svg' %}" sizes="96x96">
|
||||
<link rel="apple-touch-icon" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'assets/favicon.svg' %}">
|
||||
<link rel="apple-touch-icon" sizes="167x167" href="{% static 'assets/favicon.svg' %}">
|
||||
|
||||
<link rel="manifest" href="{% static 'manifest/webmanifest' %}">
|
||||
<link rel="manifest" href="{% url 'web_manifest' %}">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/mstile-144x144.png">
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% trans 'Export Recipes' %}{% endblock %}
|
||||
@ -9,8 +9,9 @@
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans 'Export' %}</h2>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<form action="." method="post">
|
||||
@ -21,50 +22,4 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if export %}
|
||||
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<label for="id_export">
|
||||
{% trans 'Exported Recipe' %}</label>
|
||||
<textarea id="id_export" class="form-control" rows="12">
|
||||
{{ export }}
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12 text-center">
|
||||
<button class="btn btn-success" onclick="copy()" style="width: 15vw" data-toggle="tooltip"
|
||||
data-placement="right" title="{% trans 'Copy to clipboard' %}" id="id_btn_copy"
|
||||
onmouseout="resetTooltip()"><i
|
||||
class="far fa-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function copy() {
|
||||
let json = $('#id_export');
|
||||
|
||||
json.select();
|
||||
|
||||
$('#id_btn_copy').attr('data-original-title', '{% trans 'Copied!' %}').tooltip('show');
|
||||
|
||||
document.execCommand("copy");
|
||||
}
|
||||
|
||||
function resetTooltip() {
|
||||
setTimeout(function () {
|
||||
$('#id_btn_copy').attr('data-original-title', '{% trans 'Copy list to clipboard' %}');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip()
|
||||
})
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -77,6 +77,9 @@
|
||||
:hide-selected="true"
|
||||
:preserve-search="true"
|
||||
placeholder="{% trans 'Select Keywords' %}"
|
||||
tag-placeholder="{% trans 'Add Keyword' %}"
|
||||
:taggable="true"
|
||||
@tag="addKeyword"
|
||||
label="label"
|
||||
track-by="id"
|
||||
id="id_keywords"
|
||||
@ -667,6 +670,10 @@
|
||||
this.units.push(new_unit.unit)
|
||||
this.recipe.steps[step].ingredients[id] = new_unit
|
||||
},
|
||||
addKeyword: function (tag) {
|
||||
let new_keyword = {'label':tag,'name':tag}
|
||||
this.recipe.keywords.push(new_keyword)
|
||||
},
|
||||
searchKeywords: function (query) {
|
||||
this.keywords_loading = true
|
||||
this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => {
|
||||
|
@ -1,14 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{% trans 'Import Recipes' %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans 'Import' %}</h2>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<form action="." method="post">
|
||||
<form action="." method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<button class="btn btn-success" type="submit"><i class="fas fa-file-import"></i> {% trans 'Import' %}
|
||||
@ -16,4 +22,5 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
34
cookbook/templates/import_response.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{% trans 'Import' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div id="app">
|
||||
<import-response-view></import-response-view>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMPORT_ID = {{pk}};
|
||||
</script>
|
||||
|
||||
{% render_bundle 'chunk-vendors' %}
|
||||
{% render_bundle 'import_response_view' %}
|
||||
{% endblock %}
|
@ -1,51 +1,48 @@
|
||||
{
|
||||
"name": "Recipes",
|
||||
"name": "Tandoor Recipes",
|
||||
"short_name" : "Tandoor",
|
||||
"description": "Application to manage, tag and search recipes.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/manifest/icon-192.png",
|
||||
"src": "/static/assets/logo_color144.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
"sizes": "144x144"
|
||||
},
|
||||
{
|
||||
"src": "/static/manifest/icon-512.png",
|
||||
"src": "/static/assets/logo_color512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/search",
|
||||
"background_color": "#18BC9C",
|
||||
"background_color": "#ffcb76",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"theme_color": "#18BC9C",
|
||||
"theme_color": "#ffcb76",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Plan",
|
||||
"short_name": "Plan",
|
||||
"description": "View your meal Plan",
|
||||
"url": "/plan",
|
||||
"icons": [{ "src": "/static/manifest/icon-192.png", "sizes": "192x192" }]
|
||||
"url": "/plan"
|
||||
},
|
||||
{
|
||||
"name": "Books",
|
||||
"short_name": "Cookbooks",
|
||||
"description": "View your cookbooks",
|
||||
"url": "/books",
|
||||
"icons": [{ "src": "/static/manifest/icon-192.png", "sizes": "192x192" }]
|
||||
"url": "/books"
|
||||
},
|
||||
{
|
||||
"name": "Shopping",
|
||||
"short_name": "Shopping",
|
||||
"description": "View your shopping lists",
|
||||
"url": "/list/shopping-list/",
|
||||
"icons": [{ "src": "/static/manifest/shopping-cart-192.png", "sizes": "192x192" }]
|
||||
"url": "/list/shopping-list/"
|
||||
},
|
||||
{
|
||||
"name": "Latest Shopping List",
|
||||
"short_name": "Shopping List",
|
||||
"description": "View the latest shopping list",
|
||||
"url": "/shopping/latest/",
|
||||
"icons": [{ "src": "/static/manifest/shopping-cart-192.png", "sizes": "192x192" }]
|
||||
"url": "/shopping/latest/"
|
||||
}
|
||||
]
|
||||
}
|