mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-27 12:09:10 -05:00
Compare commits
184 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9a2a89ec | ||
|
|
c50a89c651 | ||
|
|
f21587605a | ||
|
|
2e69a00fce | ||
|
|
bddaa77f71 | ||
|
|
3743a08996 | ||
|
|
f16e457d14 | ||
|
|
64f2787943 | ||
|
|
3ff15b6766 | ||
|
|
b8e0a7cf69 | ||
|
|
e2915dde55 | ||
|
|
05f2fdecb3 | ||
|
|
5d33d82d70 | ||
|
|
e3a3220f00 | ||
|
|
f15f34887a | ||
|
|
e470a70321 | ||
|
|
1a99a2d6f1 | ||
|
|
cf3ddfc610 | ||
|
|
ecbd3edb97 | ||
|
|
7837467c30 | ||
|
|
84759383fa | ||
|
|
aaaae5b1ba | ||
|
|
ea62c10d9a | ||
|
|
3516505dd1 | ||
|
|
4a747f5cd4 | ||
|
|
0623a8ebc7 | ||
|
|
5941022b5e | ||
|
|
2559905a78 | ||
|
|
edde015b71 | ||
|
|
9b7b8beea4 | ||
|
|
2eae8e5eeb | ||
|
|
6d8bc396f8 | ||
|
|
4118c8d9e3 | ||
|
|
78c2eacbd8 | ||
|
|
01510f39e5 | ||
|
|
09cc5aafe9 | ||
|
|
e8b2f57812 | ||
|
|
664e83143f | ||
|
|
f1309cc624 | ||
|
|
6fb7f6bd1f | ||
|
|
158bb1bf03 | ||
|
|
086e802873 | ||
|
|
c94c8d3559 | ||
|
|
f99010aa1d | ||
|
|
32e00999f3 | ||
|
|
e3196a79a8 | ||
|
|
e926b34bec | ||
|
|
a460123184 | ||
|
|
c89c88b981 | ||
|
|
cf6ea04f30 | ||
|
|
15c4609db3 | ||
|
|
053804f8cb | ||
|
|
da748995e7 | ||
|
|
1d80ba3a3b | ||
|
|
29fe6c7363 | ||
|
|
42d4a32ffc | ||
|
|
e8ae844fb0 | ||
|
|
c93f68804a | ||
|
|
b4ea236241 | ||
|
|
2bef5c3b51 | ||
|
|
52f2086616 | ||
|
|
03e1474113 | ||
|
|
9829ab68a6 | ||
|
|
7e07508a31 | ||
|
|
94b0438516 | ||
|
|
b97c90e22f | ||
|
|
f78264620f | ||
|
|
571a618818 | ||
|
|
6c97594591 | ||
|
|
5d353a0839 | ||
|
|
0be1f6a170 | ||
|
|
5cd042fa7c | ||
|
|
e02d2530aa | ||
|
|
b35f5047ab | ||
|
|
f10bec8ab4 | ||
|
|
3bc1daa72e | ||
|
|
5d6574b8cc | ||
|
|
adc65baf9c | ||
|
|
4d2e7eadb6 | ||
|
|
7c985cec23 | ||
|
|
2cd33ee40a | ||
|
|
f61146123e | ||
|
|
4806bd63b6 | ||
|
|
41242c8d09 | ||
|
|
57a967b91d | ||
|
|
fb931f4715 | ||
|
|
e86b476b3a | ||
|
|
7f22e0a275 | ||
|
|
1907223a8a | ||
|
|
9b5fe8f4e7 | ||
|
|
d76fdd090a | ||
|
|
55a0304700 | ||
|
|
5b6dd62f8e | ||
|
|
19f5684d26 | ||
|
|
d6ad1354db | ||
|
|
6faabe3759 | ||
|
|
69acca7de1 | ||
|
|
9d8c08341f | ||
|
|
d488559e42 | ||
|
|
85f7740e9b | ||
|
|
72e831afcf | ||
|
|
cb59f046c0 | ||
|
|
25d505161f | ||
|
|
62aa62b90f | ||
|
|
3fe5340592 | ||
|
|
9233cb9cf9 | ||
|
|
fd9f6f6dca | ||
|
|
ecd4ce603c | ||
|
|
695cab29a1 | ||
|
|
7b6ca94d49 | ||
|
|
35e04f94c6 | ||
|
|
7c4cd02dfa | ||
|
|
4626af3505 | ||
|
|
5ae440d5c9 | ||
|
|
e88010310c | ||
|
|
b6eba9c5e7 | ||
|
|
9d827ac174 | ||
|
|
27679ae8a5 | ||
|
|
6cb9a7068e | ||
|
|
f41c2ee7bb | ||
|
|
af581bb27c | ||
|
|
885c8982c1 | ||
|
|
64a9f67802 | ||
|
|
df45e1d523 | ||
|
|
03d7aa37da | ||
|
|
dd3d28ec75 | ||
|
|
ea377c2f3b | ||
|
|
6f0bf886f6 | ||
|
|
16fbd9fe48 | ||
|
|
79cdb56f9a | ||
|
|
1e6ba924ab | ||
|
|
9ae076e426 | ||
|
|
f346022d8b | ||
|
|
c9cd5325c4 | ||
|
|
ba6c80e04a | ||
|
|
be3f860ba1 | ||
|
|
1ac4020b3d | ||
|
|
5da535b8ac | ||
|
|
b8ed99a59a | ||
|
|
c199536fca | ||
|
|
d60a9f0379 | ||
|
|
c5da006f4a | ||
|
|
b72919dd42 | ||
|
|
020b102c5f | ||
|
|
c02ea744ac | ||
|
|
df76791e1e | ||
|
|
086f8b4d62 | ||
|
|
409594a73a | ||
|
|
5af687364f | ||
|
|
1431be5cad | ||
|
|
769b53a309 | ||
|
|
fd8752b298 | ||
|
|
301f1cede4 | ||
|
|
3f7aed995a | ||
|
|
44d535301d | ||
|
|
15e36cc03f | ||
|
|
f6cb5128f5 | ||
|
|
83567c25aa | ||
|
|
4288d76afd | ||
|
|
195a13f825 | ||
|
|
f0335ebe40 | ||
|
|
7fd2817014 | ||
|
|
0ab21f9941 | ||
|
|
699edb6579 | ||
|
|
372a2e480e | ||
|
|
06e54aed4b | ||
|
|
e426cae091 | ||
|
|
56e44ee3ff | ||
|
|
e18737d254 | ||
|
|
dda2529f6f | ||
|
|
5fdbedc924 | ||
|
|
a2e06a3099 | ||
|
|
6fadad1a5f | ||
|
|
cbc517b5da | ||
|
|
fb018ef9e2 | ||
|
|
1eac80942c | ||
|
|
e611c095bb | ||
|
|
c418b7bbff | ||
|
|
c0417f0b5d | ||
|
|
aef73bc104 | ||
|
|
ca1ce40048 | ||
|
|
b2eef1ee30 | ||
|
|
b4f754e7d3 | ||
|
|
d681e3ced3 |
@@ -7,7 +7,9 @@ SQL_DEBUG=0
|
||||
ALLOWED_HOSTS=*
|
||||
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
SECRET_KEY=
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
@@ -18,7 +20,9 @@ DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangouser
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
POSTGRES_PASSWORD=
|
||||
# ---------------------------------------------------------------
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# database connection string, when used overrides other database settings.
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -9,14 +9,14 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
python-version: ['3.10']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: '3.10'
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
|
||||
3
.github/workflows/docker-publish-dev.yml
vendored
3
.github/workflows/docker-publish-dev.yml
vendored
@@ -24,6 +24,9 @@ jobs:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
- name: Clear Cache
|
||||
working-directory: ./vue
|
||||
run: yarn cache clean --all
|
||||
- name: Install dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
|
||||
2
.github/workflows/docker-publish-release.yml
vendored
2
.github/workflows/docker-publish-release.yml
vendored
@@ -49,4 +49,4 @@ jobs:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
with:
|
||||
args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 \nCheck it out *https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}*'
|
||||
args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}'
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -14,5 +14,5 @@ jobs:
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material
|
||||
- run: pip install mkdocs-material mkdocs-include-markdown-plugin
|
||||
- run: mkdocs gh-deploy --force
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -79,8 +79,8 @@ postgresql/
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
.vscode/
|
||||
vue/yarn.lock
|
||||
vetur.config.js
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
cookbook/templates/sw.js
|
||||
.prettierignore
|
||||
|
||||
@@ -257,7 +257,7 @@ admin.site.register(ViewLog, ViewLogAdmin)
|
||||
|
||||
class InviteLinkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'group', 'valid_until',
|
||||
'group', 'valid_until', 'space',
|
||||
'created_by', 'created_at', 'used_by'
|
||||
)
|
||||
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import traceback
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from recipes.settings import DEBUG
|
||||
|
||||
|
||||
class CookbookConfig(AppConfig):
|
||||
name = 'cookbook'
|
||||
|
||||
def ready(self):
|
||||
# post_save signal is only necessary if using full-text search on postgres
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
import cookbook.signals # noqa
|
||||
|
||||
# when starting up run fix_tree to:
|
||||
# a) make sure that nodes are sorted when switching between sort modes
|
||||
# b) fix problems, if any, with tree consistency
|
||||
with scopes_disabled():
|
||||
try:
|
||||
from cookbook.models import Keyword, Food
|
||||
Keyword.fix_tree(fix_paths=True)
|
||||
Food.fix_tree(fix_paths=True)
|
||||
except OperationalError:
|
||||
pass # if model does not exist there is no need to fix it
|
||||
except ProgrammingError:
|
||||
pass # if migration has not been run database cannot be fixed yet
|
||||
if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||
# when starting up run fix_tree to:
|
||||
# a) make sure that nodes are sorted when switching between sort modes
|
||||
# b) fix problems, if any, with tree consistency
|
||||
with scopes_disabled():
|
||||
try:
|
||||
from cookbook.models import Keyword, Food
|
||||
#Keyword.fix_tree(fix_paths=True) # disabled for now, causes to many unknown issues
|
||||
#Food.fix_tree(fix_paths=True)
|
||||
except OperationalError:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # if model does not exist there is no need to fix it
|
||||
except ProgrammingError:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # if migration has not been run database cannot be fixed yet
|
||||
except Exception:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
|
||||
@@ -36,6 +36,11 @@ class DateWidget(forms.DateInput):
|
||||
class UserPreferenceForm(forms.ModelForm):
|
||||
prefix = 'preference'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
@@ -46,7 +51,19 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
labels = {
|
||||
'use_kj': 'Use KJ'
|
||||
'default_unit': _('Default unit'),
|
||||
'use_fractions': _('Use fractions'),
|
||||
'use_kj': _('Use KJ'),
|
||||
'theme': _('Theme'),
|
||||
'nav_color': _('Navbar color'),
|
||||
'sticky_navbar': _('Sticky navbar'),
|
||||
'default_page': _('Default page'),
|
||||
'show_recent': _('Show recent recipes'),
|
||||
'search_style': _('Search style'),
|
||||
'plan_share': _('Plan sharing'),
|
||||
'ingredient_decimals': _('Ingredient decimal places'),
|
||||
'shopping_auto_sync': _('Shopping list auto sync period'),
|
||||
'comments': _('Comments')
|
||||
}
|
||||
|
||||
help_texts = {
|
||||
@@ -56,7 +73,7 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
'use_fractions': _(
|
||||
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||
# noqa: E501
|
||||
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
|
||||
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
|
||||
'plan_share': _(
|
||||
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
|
||||
# noqa: E501
|
||||
@@ -135,13 +152,14 @@ class ImportExportBase(forms.Form):
|
||||
OPENEATS = 'OPENEATS'
|
||||
PLANTOEAT = 'PLANTOEAT'
|
||||
COOKBOOKAPP = 'COOKBOOKAPP'
|
||||
COPYMETHAT = 'COPYMETHAT'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'),
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'),
|
||||
))
|
||||
|
||||
|
||||
@@ -237,6 +255,12 @@ class SyncForm(forms.ModelForm):
|
||||
'storage': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
labels = {
|
||||
'storage': _('Storage'),
|
||||
'path': _('Path'),
|
||||
'active': _('Active')
|
||||
}
|
||||
|
||||
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
@@ -325,7 +349,8 @@ class InviteLinkForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
space = self.cleaned_data['space']
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(space=space).count()) >= space.max_users:
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(
|
||||
space=space).count()) >= space.max_users:
|
||||
raise ValidationError(_('Maximum number of users for this space reached.'))
|
||||
|
||||
def clean_email(self):
|
||||
@@ -340,7 +365,7 @@ class InviteLinkForm(forms.ModelForm):
|
||||
model = InviteLink
|
||||
fields = ('email', 'group', 'valid_until', 'space')
|
||||
help_texts = {
|
||||
'email': _('An email address is not required but if present the invite link will be send to the user.'),
|
||||
'email': _('An email address is not required but if present the invite link will be sent to the user.'),
|
||||
}
|
||||
field_classes = {
|
||||
'space': SafeModelChoiceField,
|
||||
@@ -395,22 +420,31 @@ class UserCreateForm(forms.Form):
|
||||
|
||||
class SearchPreferenceForm(forms.ModelForm):
|
||||
prefix = 'search'
|
||||
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2, widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
|
||||
help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
|
||||
preset = forms.CharField(widget=forms.HiddenInput(),required=False)
|
||||
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2,
|
||||
widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
|
||||
help_text=_(
|
||||
'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
|
||||
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = SearchPreference
|
||||
fields = ('search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
|
||||
fields = (
|
||||
'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
|
||||
|
||||
help_texts = {
|
||||
'search': _('Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
|
||||
'search': _(
|
||||
'Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
|
||||
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
|
||||
'unaccent': _('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
|
||||
'icontains': _("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
|
||||
'istartswith': _("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
|
||||
'trigram': _("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
|
||||
'fulltext': _("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
|
||||
'unaccent': _(
|
||||
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
|
||||
'icontains': _(
|
||||
"Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
|
||||
'istartswith': _(
|
||||
"Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
|
||||
'trigram': _(
|
||||
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
|
||||
'fulltext': _(
|
||||
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
|
||||
}
|
||||
|
||||
labels = {
|
||||
|
||||
@@ -5,7 +5,7 @@ from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def rescale_image_jpeg(image_object, base_width=720):
|
||||
def rescale_image_jpeg(image_object, base_width=1020):
|
||||
img = Image.open(image_object)
|
||||
icc_profile = img.info.get('icc_profile') # remember color profile to not mess up colors
|
||||
width_percent = (base_width / float(img.size[0]))
|
||||
@@ -13,20 +13,20 @@ def rescale_image_jpeg(image_object, base_width=720):
|
||||
|
||||
img = img.resize((base_width, height), Image.ANTIALIAS)
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, 'JPEG', quality=75, optimize=True, icc_profile=icc_profile)
|
||||
img.save(img_bytes, 'JPEG', quality=90, optimize=True, icc_profile=icc_profile)
|
||||
|
||||
return img_bytes
|
||||
|
||||
|
||||
def rescale_image_png(image_object, base_width=720):
|
||||
basewidth = 720
|
||||
wpercent = (basewidth / float(image_object.size[0]))
|
||||
def rescale_image_png(image_object, base_width=1020):
|
||||
image_object = Image.open(image_object)
|
||||
wpercent = (base_width / float(image_object.size[0]))
|
||||
hsize = int((float(image_object.size[1]) * float(wpercent)))
|
||||
img = image_object.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
img = image_object.resize((base_width, hsize), Image.ANTIALIAS)
|
||||
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
return img
|
||||
img.save(im_io, 'PNG', quality=90)
|
||||
return im_io
|
||||
|
||||
|
||||
def get_filetype(name):
|
||||
@@ -36,9 +36,11 @@ def get_filetype(name):
|
||||
return '.jpeg'
|
||||
|
||||
|
||||
# TODO this whole file needs proper documentation, refactoring, and testing
|
||||
# TODO also add env variable to define which images sizes should be compressed
|
||||
def handle_image(request, image_object, filetype='.jpeg'):
|
||||
if sys.getsizeof(image_object) / 8 > 500:
|
||||
if filetype == '.jpeg':
|
||||
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
return rescale_image_jpeg(image_object), filetype
|
||||
if filetype == '.png':
|
||||
return rescale_image_png(image_object), filetype
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
import re
|
||||
from json import JSONDecodeError
|
||||
from urllib.parse import unquote
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import Tag
|
||||
from recipe_scrapers._utils import get_host_name, normalize_string
|
||||
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from json import JSONDecodeError
|
||||
from recipe_scrapers._utils import get_host_name, normalize_string
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
def get_recipe_from_source(text, url, request):
|
||||
@@ -58,7 +59,7 @@ def get_recipe_from_source(text, url, request):
|
||||
return kid_list
|
||||
|
||||
recipe_json = {
|
||||
'name': '',
|
||||
'name': '',
|
||||
'url': '',
|
||||
'description': '',
|
||||
'image': '',
|
||||
@@ -188,6 +189,6 @@ def remove_graph(el):
|
||||
for x in el['@graph']:
|
||||
if '@type' in x and x['@type'] == 'Recipe':
|
||||
el = x
|
||||
except TypeError:
|
||||
except (TypeError, JSONDecodeError):
|
||||
pass
|
||||
return el
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
|
||||
from recipes import settings
|
||||
from django.contrib.postgres.search import (
|
||||
SearchQuery, SearchRank, TrigramSimilarity
|
||||
)
|
||||
from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity
|
||||
from django.core.cache import caches
|
||||
from django.db.models import Avg, Case, Count, Func, Max, Q, Subquery, Value, When
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone, translation
|
||||
|
||||
from cookbook.filters import RecipeFilter
|
||||
from cookbook.helper.permission_helper import has_group_permission
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Food, Keyword, ViewLog, SearchPreference
|
||||
from cookbook.models import Food, Keyword, Recipe, SearchPreference, ViewLog
|
||||
from recipes import settings
|
||||
|
||||
|
||||
class Round(Func):
|
||||
@@ -38,6 +38,7 @@ def search_recipes(request, queryset, params):
|
||||
search_keywords = params.getlist('keywords', [])
|
||||
search_foods = params.getlist('foods', [])
|
||||
search_books = params.getlist('books', [])
|
||||
search_steps = params.getlist('steps', [])
|
||||
search_units = params.get('units', None)
|
||||
|
||||
# TODO I think default behavior should be 'AND' which is how most sites operate with facet/filters based on results
|
||||
@@ -61,7 +62,7 @@ def search_recipes(request, queryset, params):
|
||||
|
||||
# return queryset.annotate(last_view=Max('viewlog__pk')).annotate(new=Case(When(pk__in=last_viewed_recipes, then=('last_view')), default=Value(0))).filter(new__gt=0).order_by('-new')
|
||||
# queryset that only annotates most recent view (higher pk = lastest view)
|
||||
queryset = queryset.annotate(recent=Coalesce(Max('viewlog__pk'), Value(0)))
|
||||
queryset = queryset.annotate(recent=Coalesce(Max(Case(When(viewlog__created_by=request.user, then='viewlog__pk'))), Value(0)))
|
||||
orderby += ['-recent']
|
||||
|
||||
# TODO create setting for default ordering - most cooked, rating,
|
||||
@@ -142,9 +143,9 @@ def search_recipes(request, queryset, params):
|
||||
|
||||
# TODO add order by user settings - only do search rank and annotation if rank order is configured
|
||||
search_rank = (
|
||||
SearchRank('name_search_vector', search_query, cover_density=True)
|
||||
+ SearchRank('desc_search_vector', search_query, cover_density=True)
|
||||
+ SearchRank('steps__search_vector', search_query, cover_density=True)
|
||||
SearchRank('name_search_vector', search_query, cover_density=True)
|
||||
+ SearchRank('desc_search_vector', search_query, cover_density=True)
|
||||
+ SearchRank('steps__search_vector', search_query, cover_density=True)
|
||||
)
|
||||
queryset = queryset.filter(query_filter).annotate(rank=search_rank)
|
||||
orderby += ['-rank']
|
||||
@@ -191,6 +192,10 @@ def search_recipes(request, queryset, params):
|
||||
if search_units:
|
||||
queryset = queryset.filter(steps__ingredients__unit__id=search_units)
|
||||
|
||||
# probably only useful in Unit list view, so keeping it simple
|
||||
if search_steps:
|
||||
queryset = queryset.filter(steps__id__in=search_steps)
|
||||
|
||||
if search_internal:
|
||||
queryset = queryset.filter(internal=True)
|
||||
|
||||
@@ -395,3 +400,13 @@ def annotated_qs(qs, root=False, fill=False):
|
||||
if start_depth and start_depth > 0:
|
||||
info['close'] = list(range(0, prev_depth - start_depth + 1))
|
||||
return result
|
||||
|
||||
|
||||
def old_search(request):
|
||||
if has_group_permission(request.user, ('guest',)):
|
||||
params = dict(request.GET)
|
||||
params['internal'] = None
|
||||
f = RecipeFilter(params,
|
||||
queryset=Recipe.objects.filter(space=request.user.userpreference.space).all().order_by('name'),
|
||||
space=request.space)
|
||||
return f.qs
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from cookbook.views import views
|
||||
|
||||
@@ -33,6 +36,15 @@ class ScopeMiddleware:
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
else:
|
||||
if request.path.startswith('/api/'):
|
||||
try:
|
||||
if auth := TokenAuthentication().authenticate(request):
|
||||
request.space = auth[0].userpreference.space
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
except AuthenticationFailed:
|
||||
pass
|
||||
|
||||
with scopes_disabled():
|
||||
request.space = None
|
||||
return self.get_response(request)
|
||||
|
||||
@@ -5,7 +5,7 @@ from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from gettext import gettext as _
|
||||
|
||||
from markdown.extensions.tables import TableExtension
|
||||
|
||||
class IngredientObject(object):
|
||||
amount = ""
|
||||
@@ -41,7 +41,7 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
parsed_md = md.markdown(
|
||||
instructions,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code', 'tables',
|
||||
'markdown.extensions.fenced_code', TableExtension(),
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
|
||||
84
cookbook/integration/copymethat.py
Normal file
84
cookbook/integration/copymethat.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
from cookbook.helper.recipe_url_import import iso_duration_to_minutes, parse_servings
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
from recipes.settings import DEBUG
|
||||
|
||||
|
||||
class CopyMeThat(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
if DEBUG:
|
||||
print("testing", zip_info_object.filename, zip_info_object.filename == 'recipes.html')
|
||||
return zip_info_object.filename == 'recipes.html'
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
# 'file' comes is as a beautifulsoup object
|
||||
recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
for category in file.find_all("span", {"class": "recipeCategory"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
try:
|
||||
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
|
||||
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
|
||||
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
|
||||
recipe.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in file.find_all("li", {"class": "recipeIngredient"}):
|
||||
if ingredient.text == "":
|
||||
continue
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(ingredient.text.strip())
|
||||
f = ingredient_parser.get_food(ingredient)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
|
||||
for s in file.find_all("li", {"class": "instruction"}):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text.strip() + ' \n\n'
|
||||
|
||||
for s in file.find_all("li", {"class": "recipeNote"}):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text.strip() + ' \n\n'
|
||||
|
||||
try:
|
||||
if file.find("a", {"id": "original_link"}).text != '':
|
||||
step.instruction += "\n\nImported from: " + file.find("a", {"id": "original_link"}).text
|
||||
step.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
# import the Primary recipe image that is stored in the Zip
|
||||
try:
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipeImage").get("src"))), filetype='.jpeg')
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to import image ', str(e))
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
soup = BeautifulSoup(file, "html.parser")
|
||||
return soup.find_all("div", {"class": "recipe"})
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
|
||||
from bs4 import Tag
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.files import File
|
||||
from django.db import IntegrityError
|
||||
@@ -14,9 +15,9 @@ from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.image_processing import get_filetype, handle_image
|
||||
from cookbook.models import Keyword, Recipe
|
||||
from recipes.settings import DATABASES, DEBUG
|
||||
from recipes.settings import DEBUG
|
||||
|
||||
|
||||
class Integration:
|
||||
@@ -52,7 +53,7 @@ class Integration:
|
||||
icon=icon,
|
||||
space=request.space
|
||||
)
|
||||
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
|
||||
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
|
||||
self.keyword = parent.add_child(
|
||||
name=f'{name} {str(uuid.uuid4())[0:8]}',
|
||||
description=description,
|
||||
@@ -123,8 +124,6 @@ class Integration:
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
with scope(space=self.request.space):
|
||||
self.keyword.name = _('Import') + ' ' + str(il.pk)
|
||||
self.keyword.save()
|
||||
|
||||
try:
|
||||
self.files = files
|
||||
@@ -155,9 +154,17 @@ class Integration:
|
||||
file_list.append(z)
|
||||
il.total_recipes += len(file_list)
|
||||
|
||||
import cookbook
|
||||
if isinstance(self, cookbook.integration.copymethat.CopyMeThat):
|
||||
file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html')))
|
||||
il.total_recipes += len(file_list)
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
if isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
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'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
@@ -231,15 +238,14 @@ class Integration:
|
||||
self.ignored_recipes.append(recipe.name)
|
||||
recipe.delete()
|
||||
|
||||
@staticmethod
|
||||
def import_recipe_image(recipe, image_file, filetype='.jpeg'):
|
||||
def import_recipe_image(self, recipe, image_file, filetype='.jpeg'):
|
||||
"""
|
||||
Adds an image to a recipe naming it correctly
|
||||
:param recipe: Recipe object
|
||||
:param image_file: ByteIO stream containing the image
|
||||
:param filetype: type of file to write bytes to, default to .jpeg if unknown
|
||||
"""
|
||||
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
|
||||
recipe.image = File(handle_image(self.request, File(image_file, name='image'), filetype=filetype)[0], name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
|
||||
recipe.save()
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
@@ -63,7 +63,7 @@ class MealMaster(Integration):
|
||||
current_recipe = ''
|
||||
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("ANSI")
|
||||
line = fl.decode("windows-1250")
|
||||
if (line.startswith('MMMMM') or line.startswith('-----')) and 'meal-master' in line.lower():
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
|
||||
@@ -5,8 +5,9 @@ from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import iso_duration_to_minutes
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
@@ -24,9 +25,24 @@ class NextcloudCookbook(Integration):
|
||||
created_by=self.request.user, internal=True,
|
||||
servings=recipe_json['recipeYield'], space=self.request.space)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
# @vabene check recipe_url_import.iso_duration_to_minutes I think it does what you are looking for
|
||||
# TODO parse keywords
|
||||
try:
|
||||
recipe.working_time = iso_duration_to_minutes(recipe_json['prepTime'])
|
||||
recipe.waiting_time = iso_duration_to_minutes(recipe_json['cookTime'])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 'recipeCategory' in recipe_json:
|
||||
try:
|
||||
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=recipe_json['recipeCategory'])[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 'keywords' in recipe_json:
|
||||
try:
|
||||
for x in recipe_json['keywords'].split(','):
|
||||
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
@@ -41,19 +57,28 @@ class NextcloudCookbook(Integration):
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
|
||||
f = ingredient_parser.get_food(ingredient)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if 'nutrition' in recipe_json:
|
||||
try:
|
||||
recipe.nutrition.calories = recipe_json['nutrition']['calories'].replace(' kcal', '').replace(' ', '')
|
||||
recipe.nutrition.proteins = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
|
||||
recipe.nutrition.fats = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
|
||||
recipe.nutrition.carbohydrates = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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):
|
||||
if re.match(f'^(.)+{recipe.name}/full.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
|
||||
|
||||
return recipe
|
||||
|
||||
@@ -78,7 +78,7 @@ class Plantoeat(Integration):
|
||||
current_recipe = ''
|
||||
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("ANSI")
|
||||
line = fl.decode("windows-1250")
|
||||
if line.startswith('--------------'):
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
|
||||
@@ -62,7 +62,7 @@ class RezKonv(Integration):
|
||||
current_recipe = ''
|
||||
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("ANSI")
|
||||
line = fl.decode("windows-1250")
|
||||
if line.startswith('=====') and 'rezkonv' in line.lower():
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/fi/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/fi/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2510
cookbook/locale/fi/LC_MESSAGES/django.po
Normal file
2510
cookbook/locale/fi/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -11,8 +11,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
|
||||
"PO-Revision-Date: 2021-10-23 09:06+0000\n"
|
||||
"Last-Translator: Tomasz Klimczak <klemensble@gmail.com>\n"
|
||||
"PO-Revision-Date: 2021-11-06 14:06+0000\n"
|
||||
"Last-Translator: retmas gh <tandoor@oppai.ovh>\n"
|
||||
"Language-Team: Polish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/pl/>\n"
|
||||
"Language: pl\n"
|
||||
@@ -385,7 +385,7 @@ msgstr "Zabierz mnie na stronę główną"
|
||||
|
||||
#: .\cookbook\templates\404.html:35
|
||||
msgid "Report a Bug"
|
||||
msgstr "Raprtuj błąd"
|
||||
msgstr "Raportuj błąd"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:7
|
||||
#: .\cookbook\templates\base.html:166
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/ro/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/ro/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2572
cookbook/locale/ro/LC_MESSAGES/django.po
Normal file
2572
cookbook/locale/ro/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/sl/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/sl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2575
cookbook/locale/sl/LC_MESSAGES/django.po
Normal file
2575
cookbook/locale/sl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,30 @@ import operator
|
||||
import pathlib
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models, IntegrityError
|
||||
from django.db.models import Index, ProtectedError
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models import Index, ProtectedError, Q, Subquery
|
||||
from django.db.models.fields.related import ManyToManyField
|
||||
from django.db.models.functions import Substr
|
||||
from django.db.transaction import atomic
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
|
||||
KJ_PREF_DEFAULT, STICKY_NAV_PREF_DEFAULT,
|
||||
SORT_TREE_BY_NAME)
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
||||
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
|
||||
def get_user_name(self):
|
||||
@@ -38,15 +43,26 @@ def get_model_name(model):
|
||||
|
||||
|
||||
class TreeManager(MP_NodeManager):
|
||||
def create(self, *args, **kwargs):
|
||||
return self.get_or_create(*args, **kwargs)[0]
|
||||
|
||||
# model.Manager get_or_create() is not compatible with MP_Tree
|
||||
def get_or_create(self, **kwargs):
|
||||
def get_or_create(self, *args, **kwargs):
|
||||
kwargs['name'] = kwargs['name'].strip()
|
||||
try:
|
||||
return self.get(name__exact=kwargs['name'], space=kwargs['space']), False
|
||||
except self.model.DoesNotExist:
|
||||
with scopes_disabled():
|
||||
try:
|
||||
return self.model.add_root(**kwargs), True
|
||||
# ManyToMany fields can't be set this way, so pop them out to save for later
|
||||
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
||||
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
||||
obj = self.model.add_root(**kwargs)
|
||||
for field in many_to_many:
|
||||
field_model = getattr(obj, field).model
|
||||
for related_obj in many_to_many[field]:
|
||||
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
|
||||
return obj, True
|
||||
except IntegrityError as e:
|
||||
if 'Key (path)' in e.args[0]:
|
||||
self.model.fix_tree(fix_paths=True)
|
||||
|
||||
@@ -2,73 +2,29 @@ from rest_framework.schemas.openapi import AutoSchema
|
||||
from rest_framework.schemas.utils import is_list_view
|
||||
|
||||
|
||||
# TODO move to separate class to cleanup
|
||||
class RecipeSchema(AutoSchema):
|
||||
class QueryParam(object):
|
||||
def __init__(self, name, description=None, qtype='string', required=False):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.qtype = qtype
|
||||
self.required = required
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}, {self.qtype}, {self.description}'
|
||||
|
||||
|
||||
class QueryParamAutoSchema(AutoSchema):
|
||||
def get_path_parameters(self, path, method):
|
||||
if not is_list_view(path, method, self.view):
|
||||
return super(RecipeSchema, self).get_path_parameters(path, method)
|
||||
|
||||
return super().get_path_parameters(path, method)
|
||||
parameters = super().get_path_parameters(path, method)
|
||||
parameters.append({
|
||||
"name": 'query', "in": "query", "required": False,
|
||||
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'keywords', "in": "query", "required": False,
|
||||
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'foods', "in": "query", "required": False,
|
||||
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'units', "in": "query", "required": False,
|
||||
"description": 'Id of unit a recipe should have.',
|
||||
'schema': {'type': 'int', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'rating', "in": "query", "required": False,
|
||||
"description": 'Id of unit a recipe should have.',
|
||||
'schema': {'type': 'int', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'books', "in": "query", "required": False,
|
||||
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'keywords_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'foods_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'books_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'internal', "in": "query", "required": False,
|
||||
"description": 'true or false. If only internal recipes should be returned or not.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'random', "in": "query", "required": False,
|
||||
"description": 'true or false. returns the results in randomized order.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'new', "in": "query", "required": False,
|
||||
"description": 'true or false. returns new results first in search results',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
for q in self.view.query_params:
|
||||
parameters.append({
|
||||
"name": q.name, "in": "query", "required": q.required,
|
||||
"description": q.description,
|
||||
'schema': {'type': q.qtype, },
|
||||
})
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
@@ -86,7 +42,8 @@ class TreeSchema(AutoSchema):
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'root', "in": "query", "required": False,
|
||||
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(obj=api_name),
|
||||
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(
|
||||
obj=api_name),
|
||||
'schema': {'type': 'int', },
|
||||
})
|
||||
parameters.append({
|
||||
@@ -110,3 +67,17 @@ class FilterSchema(AutoSchema):
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
return parameters
|
||||
|
||||
|
||||
# class QueryOnlySchema(AutoSchema):
|
||||
# def get_path_parameters(self, path, method):
|
||||
# if not is_list_view(path, method, self.view):
|
||||
# return super(QueryOnlySchema, self).get_path_parameters(path, method)
|
||||
|
||||
# parameters = super().get_path_parameters(path, method)
|
||||
# parameters.append({
|
||||
# "name": 'query', "in": "query", "required": False,
|
||||
# "description": 'Query string matched (fuzzy) against object name.',
|
||||
# 'schema': {'type': 'string', },
|
||||
# })
|
||||
# return parameters
|
||||
|
||||
@@ -34,7 +34,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
api_serializer = None
|
||||
# extended values are computationally expensive and not needed in normal circumstances
|
||||
try:
|
||||
if bool(int(self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
|
||||
if bool(int(
|
||||
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
|
||||
return fields
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -49,11 +50,13 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
|
||||
def get_image(self, obj):
|
||||
# TODO add caching
|
||||
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude(
|
||||
image__isnull=True).exclude(image__exact='')
|
||||
try:
|
||||
if recipes.count() == 0 and obj.has_children():
|
||||
obj__in = self.recipe_filter + '__in'
|
||||
recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
||||
recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude(
|
||||
image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
||||
except AttributeError:
|
||||
# probably not a tree
|
||||
pass
|
||||
@@ -134,6 +137,7 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
||||
|
||||
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
plan_share = UserNameSerializer(many=True, read_only=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
if validated_data['user'] != self.context['request'].user:
|
||||
@@ -404,7 +408,10 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent', 'numchild', 'numrecipe')
|
||||
fields = (
|
||||
'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent',
|
||||
'numchild',
|
||||
'numrecipe')
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
||||
|
||||
|
||||
@@ -425,12 +432,13 @@ class IngredientSerializer(WritableNestedModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class StepSerializer(WritableNestedModelSerializer):
|
||||
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
ingredients = IngredientSerializer(many=True)
|
||||
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
|
||||
ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue')
|
||||
file = UserFileViewSerializer(allow_null=True, required=False)
|
||||
step_recipe_data = serializers.SerializerMethodField('get_step_recipe_data')
|
||||
recipe_filter = 'steps'
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -442,6 +450,9 @@ class StepSerializer(WritableNestedModelSerializer):
|
||||
def get_ingredients_markdown(self, obj):
|
||||
return obj.get_instruction_render()
|
||||
|
||||
def get_step_recipes(self, obj):
|
||||
return list(obj.recipe_set.values_list('id', flat=True).all())
|
||||
|
||||
def get_step_recipe_data(self, obj):
|
||||
# check if root type is recipe to prevent infinite recursion
|
||||
# can be improved later to allow multi level embedding
|
||||
@@ -452,7 +463,7 @@ class StepSerializer(WritableNestedModelSerializer):
|
||||
model = Step
|
||||
fields = (
|
||||
'id', 'name', 'type', 'instruction', 'ingredients', 'ingredients_markdown',
|
||||
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 'step_recipe_data'
|
||||
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 'step_recipe_data', 'numrecipe'
|
||||
)
|
||||
|
||||
|
||||
@@ -610,6 +621,7 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
|
||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||
servings = CustomDecimalField()
|
||||
shared = UserNameSerializer(many=True)
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -67,7 +67,7 @@
|
||||
</button>
|
||||
|
||||
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
|
||||
<a class="navbar-brand p-0 me-2 justify-content-center" href="/" aria-label="Tandoor">
|
||||
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}" aria-label="Tandoor">
|
||||
<img class="brand-icon" src="{% static 'assets/brand_logo.png' %}" alt="" style="height: 5vh;">
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -336,6 +336,10 @@
|
||||
{% block content_fluid %}
|
||||
{% endblock %}
|
||||
|
||||
{% user_prefs request as prefs%}
|
||||
{{ prefs|json_script:'user_preference' }}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% block script %}
|
||||
@@ -345,6 +349,7 @@
|
||||
localStorage.setItem('SCRIPT_NAME', "{% base_path request 'script' %}")
|
||||
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
|
||||
localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}")
|
||||
localStorage.setItem('DEBUG', "{% is_debug %}")
|
||||
window.addEventListener("load", () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</p>
|
||||
<form method="POST" class="post-form">{% csrf_token %}
|
||||
{{ form|crispy }}
|
||||
<input type="submit" value="Submit" class="btn btn-success">
|
||||
<input type="submit" value="{% trans 'Save' %}" class="btn btn-success">
|
||||
<a href="{% url 'list_storage' %}"><button type="button" class="btn btn-primary">{% trans 'Manage External Storage' %}</button></a>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
{% trans 'Account' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'prefernces' %} active {% endif %}" id="preferences-tab"
|
||||
<a class="nav-link {% if active_tab == 'preferences' %} active {% endif %}" id="preferences-tab"
|
||||
data-toggle="tab" href="#preferences" role="tab"
|
||||
aria-controls="preferences"
|
||||
aria-selected="{% if active_tab == 'prefernces' %} 'true' {% else %} 'false' {% endif %}">
|
||||
aria-selected="{% if active_tab == 'preferences' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Preferences' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@@ -225,4 +225,4 @@
|
||||
window.location.hash = e.target.hash;
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,13 +28,6 @@
|
||||
<span class="col col-md-9">
|
||||
<h2>{% trans 'Shopping List' %}</h2>
|
||||
</span>
|
||||
<span class="col-md-3">
|
||||
<a href="{% url 'view_shopping_new' %}" class="float-right">
|
||||
<button class="btn btn-outline-secondary shadow-none">
|
||||
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
|
||||
</button>
|
||||
</a>
|
||||
</span>
|
||||
<div class="col col-mdd-3 text-right">
|
||||
<b-form-checkbox switch size="lg" v-model="edit_mode"
|
||||
@change="$forceUpdate()">{% trans 'Edit' %}</b-form-checkbox>
|
||||
@@ -977,4 +970,4 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %} TODO: refactor to be Vue app {% endcomment %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load custom_tags %}
|
||||
@@ -29,26 +30,25 @@
|
||||
style="height:50%"
|
||||
href="{% bookmarklet request %}"
|
||||
title="{% trans 'Drag me to your bookmarks to import recipes from anywhere' %}">
|
||||
<img src="{% static 'assets/favicon-16x16.png' %}">{% trans 'Bookmark Me!' %} </a>
|
||||
<img src="{% static 'assets/favicon-16x16.png' %}">{% trans 'Bookmark Me!' %} </a>
|
||||
</div>
|
||||
<nav class="nav nav-pills flex-sm-row" style="margin-bottom:10px">
|
||||
<nav class="nav nav-pills flex-sm-row mb-2">
|
||||
<a class="nav-link active" href="#nav-url" data-toggle="tab" role="tab" aria-controls="nav-url"
|
||||
aria-selected="true" @click="mode='url'">URL</a>
|
||||
aria-selected="true" @click="mode='url'">{% trans 'URL' %}</a>
|
||||
<a class="nav-link" href="#nav-app" data-toggle="tab" role="tab" aria-controls="nav-app"
|
||||
@click="mode='app'">App</a>
|
||||
@click="mode='app'">{% trans 'App' %}</a>
|
||||
<a class="nav-link" href="#nav-source" data-toggle="tab" role="tab" aria-controls="nav-source"
|
||||
@click="mode='source'">Source</a>
|
||||
@click="mode='source'">{% trans 'Source' %}</a>
|
||||
<a class="nav-link disabled" href="#nav-text" data-toggle="tab" role="tab" aria-controls="nav-text"
|
||||
@click="mode='text'">Text</a>
|
||||
@click="mode='text'">{% trans 'Text' %}</a>
|
||||
<a class="nav-link disabled" href="#nav-file" data-toggle="tab" role="tab" aria-controls="nav-file"
|
||||
@click="mode='file'">File</a>
|
||||
@click="mode='file'">{% trans 'File' %}</a>
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
<!-- Import URL -->
|
||||
<div class="tab-pane fade show active" id="nav-url" role="tabpanel">
|
||||
<div class="btn-group btn-group-toggle" data-toggle="buttons">
|
||||
<div class="btn-group btn-group-toggle mt-2" data-toggle="buttons">
|
||||
<label class="btn btn-outline-info btn-sm active" @click="automatic=true">
|
||||
<input type="radio" autocomplete="off" checked> Automatic
|
||||
</label>
|
||||
@@ -57,10 +57,13 @@
|
||||
<input type="radio" autocomplete="off"> Manual
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group my-2">
|
||||
<input class="form-control" v-model="remote_url" placeholder="{% trans 'Enter website URL' %}">
|
||||
<div role="group" class="input-group mt-4">
|
||||
<input type="text" v-model="remote_url"
|
||||
class="form-control form-control-lg form-control-borderless form-control-search form-control"
|
||||
placeholder="{% trans 'Enter website URL' %}">
|
||||
<div class="input-group-append">
|
||||
<button @click="loadRecipe()" class="btn btn-primary shadow-none" type="button"
|
||||
<button @click="loadRecipe()" class="btn btn-primary shadow-none"
|
||||
type="button"
|
||||
id="id_btn_search"><i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -73,6 +76,7 @@
|
||||
<option value="CHEFTAP">Cheftap</option>
|
||||
<option value="CHOWDOWN">Chowdown</option>
|
||||
<option value="COOKBOOKAPP">CookBookApp</option>
|
||||
<option value="COPYMETHAT">CopyMeThat</option>
|
||||
<option value="DOMESTICA">Domestica</option>
|
||||
<option value="MEALIE">Mealie</option>
|
||||
<option value="MEALMASTER">Mealmaster</option>
|
||||
@@ -106,7 +110,7 @@
|
||||
|
||||
<!-- Import JSON or HTML -->
|
||||
<div class=" tab-pane fade show" id="nav-source" role="tabpanel">
|
||||
<div class="btn-group btn-group-toggle" data-toggle="buttons">
|
||||
<div class="btn-group btn-group-toggle mt-2" data-toggle="buttons">
|
||||
<label class="btn btn-outline-info btn-sm active" @click="automatic=true">
|
||||
<input type="radio" autocomplete="off" checked> Automatic
|
||||
</label>
|
||||
@@ -115,7 +119,7 @@
|
||||
<input type="radio" autocomplete="off"> Manual
|
||||
</label>
|
||||
</div>
|
||||
<div class="input-group my-2">
|
||||
<div class="input-group mt-4">
|
||||
<textarea class="form-control input-group-append" v-model="source_data" rows=10
|
||||
placeholder="{% trans 'Paste json or html source here to load recipe.' %}"
|
||||
style="font-size: 12px">
|
||||
@@ -455,7 +459,8 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_servings">{% trans 'Servings' %}</label>
|
||||
<b-form-input id="id_servings" class="form-control" v-model="recipe_data.servings" @change="recipe_data.servings = Math.round($event.replace(',','.'))"></b-form-input>
|
||||
<b-form-input id="id_servings" class="form-control" v-model="recipe_data.servings"
|
||||
@change="recipe_data.servings = Math.round($event.replace(',','.'))"></b-form-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import re
|
||||
from gettext import gettext as _
|
||||
|
||||
import bleach
|
||||
import markdown as md
|
||||
import re
|
||||
from bleach_allowlist import markdown_attrs, markdown_tags
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import Space, get_model_name
|
||||
from django import template
|
||||
from django.db.models import Avg
|
||||
from django.templatetags.static import static
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from recipes import settings
|
||||
from rest_framework.authtoken.models import Token
|
||||
from gettext import gettext as _
|
||||
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import Space, get_model_name
|
||||
from recipes import settings
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -92,10 +94,10 @@ def recipe_last(recipe, user):
|
||||
@register.simple_tag
|
||||
def page_help(page_name):
|
||||
help_pages = {
|
||||
'edit_storage': 'https://vabene1111.github.io/recipes/features/external_recipes/',
|
||||
'view_shopping': 'https://vabene1111.github.io/recipes/features/shopping/',
|
||||
'view_import': 'https://vabene1111.github.io/recipes/features/import_export/',
|
||||
'view_export': 'https://vabene1111.github.io/recipes/features/import_export/',
|
||||
'edit_storage': 'https://docs.tandoor.dev/features/external_recipes/',
|
||||
'view_shopping': 'https://docs.tandoor.dev/features/shopping/',
|
||||
'view_import': 'https://docs.tandoor.dev/features/import_export/',
|
||||
'view_export': 'https://docs.tandoor.dev/features/import_export/',
|
||||
}
|
||||
|
||||
link = help_pages.get(page_name, '')
|
||||
@@ -124,10 +126,10 @@ def markdown_link():
|
||||
@register.simple_tag
|
||||
def bookmarklet(request):
|
||||
if request.is_secure():
|
||||
prefix = "https://"
|
||||
protocol = "https://"
|
||||
else:
|
||||
prefix = "http://"
|
||||
server = prefix + request.get_host()
|
||||
protocol = "http://"
|
||||
server = protocol + request.get_host()
|
||||
prefix = settings.JS_REVERSE_SCRIPT_PREFIX
|
||||
# TODO is it safe to store the token in clear text in a bookmark?
|
||||
if (api_token := Token.objects.filter(user=request.user).first()) is None:
|
||||
@@ -155,3 +157,13 @@ def base_path(request, path_type):
|
||||
return request.META.get('HTTP_X_SCRIPT_NAME', '')
|
||||
elif path_type == 'static_base':
|
||||
return static('vue/manifest.json').replace('vue/manifest.json', '')
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def user_prefs(request):
|
||||
from cookbook.serializer import \
|
||||
UserPreferenceSerializer # putting it with imports caused circular execution
|
||||
try:
|
||||
return UserPreferenceSerializer(request.user.userpreference, context={'request': request}).data
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'recipe': {'id': recipe_1_s1.id, 'name': recipe_1_s1.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test'},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test','shared':[]},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.db.models import Subquery, OuterRef
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import Step, Ingredient
|
||||
from cookbook.models import Ingredient, Step
|
||||
|
||||
LIST_URL = 'api:step-list'
|
||||
DETAIL_URL = 'api:step-detail'
|
||||
@@ -23,8 +23,8 @@ def test_list_permission(arg, request):
|
||||
|
||||
|
||||
def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 2
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 0
|
||||
|
||||
with scopes_disabled():
|
||||
recipe_1_s1.space = space_2
|
||||
@@ -32,9 +32,9 @@ def test_list_space(recipe_1_s1, u1_s1, u1_s2, space_2):
|
||||
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
|
||||
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 0
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 2
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)['results']) == 0
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)['results']) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
|
||||
@@ -18,10 +18,10 @@ def test_add(u1_s1, u2_s1):
|
||||
with scopes_disabled():
|
||||
UserPreference.objects.filter(user=auth.get_user(u1_s1)).delete()
|
||||
|
||||
r = u2_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id}, content_type='application/json')
|
||||
r = u2_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id, 'plan_share': []}, content_type='application/json')
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id}, content_type='application/json')
|
||||
r = u1_s1.post(reverse(LIST_URL), {'user': auth.get_user(u1_s1).id, 'plan_share': []}, content_type='application/json')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@ from pydoc import locate
|
||||
|
||||
from django.urls import include, path
|
||||
from django.views.generic import TemplateView
|
||||
from recipes.version import VERSION_NUMBER
|
||||
from rest_framework import routers, permissions
|
||||
from rest_framework import permissions, routers
|
||||
from rest_framework.schemas import get_schema_view
|
||||
|
||||
from cookbook.helper import dal
|
||||
from recipes.settings import DEBUG
|
||||
from recipes.version import VERSION_NUMBER
|
||||
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShoppingList,
|
||||
Storage, Supermarket, SupermarketCategory, Sync, SyncLog, Unit, get_model_name, Automation, UserFile)
|
||||
from .views import api, data, delete, edit, import_export, lists, new, views, telegram
|
||||
from .models import (Automation, Comment, Food, InviteLink, Keyword, MealPlan, Recipe, RecipeBook,
|
||||
RecipeBookEntry, RecipeImport, ShoppingList, Step, Storage, Supermarket,
|
||||
SupermarketCategory, Sync, SyncLog, Unit, UserFile, get_model_name)
|
||||
from .views import api, data, delete, edit, import_export, lists, new, telegram, views
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'user-name', api.UserNameViewSet, basename='username')
|
||||
@@ -67,8 +68,6 @@ urlpatterns = [
|
||||
path('history/', views.history, name='view_history'),
|
||||
path('supermarket/', views.supermarket, name='view_supermarket'),
|
||||
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
|
||||
path('test/', views.test, name='view_test'),
|
||||
path('test2/', views.test2, name='view_test2'),
|
||||
|
||||
path('import/', import_export.import_recipe, name='view_import'),
|
||||
path('import-response/<int:pk>/', import_export.import_response, name='view_import_response'),
|
||||
@@ -177,7 +176,7 @@ for m in generic_models:
|
||||
)
|
||||
)
|
||||
|
||||
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile]
|
||||
vue_models = [Food, Keyword, Unit, Supermarket, SupermarketCategory, Automation, UserFile, Step]
|
||||
for m in vue_models:
|
||||
py_name = get_model_name(m)
|
||||
url_name = py_name.replace('_', '-')
|
||||
@@ -188,3 +187,7 @@ for m in vue_models:
|
||||
f'list/{url_name}/', c, name=f'list_{py_name}'
|
||||
)
|
||||
)
|
||||
|
||||
if DEBUG:
|
||||
urlpatterns.append(path('test/', views.test, name='view_test'))
|
||||
urlpatterns.append(path('test2/', views.test2, name='view_test2'))
|
||||
|
||||
@@ -2,11 +2,11 @@ import io
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
|
||||
import requests
|
||||
from annoying.decorators import ajax_request
|
||||
from annoying.functions import get_object_or_None
|
||||
from collections import OrderedDict
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
@@ -15,12 +15,12 @@ from django.core.files import File
|
||||
from django.db.models import Case, ProtectedError, Q, Value, When
|
||||
from django.db.models.fields.related import ForeignObjectRel
|
||||
from django.http import FileResponse, HttpResponse, JsonResponse
|
||||
from django_scopes import scopes_disabled
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from icalendar import Calendar, Event
|
||||
from recipe_scrapers import scrape_me, WebsiteNotImplementedError, NoSchemaFoundInWildMode
|
||||
from recipe_scrapers import NoSchemaFoundInWildMode, WebsiteNotImplementedError, scrape_me
|
||||
from rest_framework import decorators, status, viewsets
|
||||
from rest_framework.exceptions import APIException, PermissionDenied
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
@@ -28,41 +28,40 @@ from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.renderers import JSONRenderer, TemplateHTMLRenderer
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ViewSetMixin
|
||||
from treebeard.exceptions import PathOverflow, InvalidMoveToDescendant, InvalidPosition
|
||||
from treebeard.exceptions import InvalidMoveToDescendant, InvalidPosition, PathOverflow
|
||||
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest,
|
||||
CustomIsOwner, CustomIsShare,
|
||||
CustomIsShared, CustomIsUser,
|
||||
from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsGuest, CustomIsOwner,
|
||||
CustomIsShare, CustomIsShared, CustomIsUser,
|
||||
group_required)
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
|
||||
from cookbook.helper.recipe_search import search_recipes, get_facet
|
||||
from cookbook.helper.recipe_search import get_facet, old_search, search_recipes
|
||||
from cookbook.helper.recipe_url_import import get_from_scraper
|
||||
from cookbook.models import (CookLog, Food, Ingredient, Keyword, MealPlan,
|
||||
MealType, Recipe, RecipeBook, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Step,
|
||||
Storage, Sync, SyncLog, Unit, UserPreference,
|
||||
ViewLog, RecipeBookEntry, Supermarket, ImportLog, BookmarkletImport, SupermarketCategory, UserFile, ShareLink, SupermarketCategoryRelation, Automation)
|
||||
from cookbook.models import (Automation, BookmarkletImport, CookLog, Food, ImportLog, Ingredient,
|
||||
Keyword, MealPlan, MealType, Recipe, RecipeBook, RecipeBookEntry,
|
||||
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Step,
|
||||
Storage, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
|
||||
Sync, SyncLog, Unit, UserFile, UserPreference, ViewLog)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
from cookbook.schemas import FilterSchema, RecipeSchema, TreeSchema
|
||||
from cookbook.serializer import (FoodSerializer, IngredientSerializer,
|
||||
KeywordSerializer, MealPlanSerializer,
|
||||
MealTypeSerializer, RecipeBookSerializer,
|
||||
RecipeImageSerializer, RecipeSerializer,
|
||||
ShoppingListAutoSyncSerializer,
|
||||
ShoppingListEntrySerializer,
|
||||
ShoppingListRecipeSerializer,
|
||||
ShoppingListSerializer, StepSerializer,
|
||||
StorageSerializer, SyncLogSerializer,
|
||||
SyncSerializer, UnitSerializer,
|
||||
UserNameSerializer, UserPreferenceSerializer,
|
||||
ViewLogSerializer, CookLogSerializer, RecipeBookEntrySerializer,
|
||||
RecipeOverviewSerializer, SupermarketSerializer, ImportLogSerializer,
|
||||
BookmarkletImportSerializer, SupermarketCategorySerializer, UserFileSerializer, SupermarketCategoryRelationSerializer, AutomationSerializer)
|
||||
from cookbook.schemas import FilterSchema, QueryParam, QueryParamAutoSchema, TreeSchema
|
||||
from cookbook.serializer import (AutomationSerializer, BookmarkletImportSerializer,
|
||||
CookLogSerializer, FoodSerializer, ImportLogSerializer,
|
||||
IngredientSerializer, KeywordSerializer, MealPlanSerializer,
|
||||
MealTypeSerializer, RecipeBookEntrySerializer,
|
||||
RecipeBookSerializer, RecipeImageSerializer,
|
||||
RecipeOverviewSerializer, RecipeSerializer,
|
||||
ShoppingListAutoSyncSerializer, ShoppingListEntrySerializer,
|
||||
ShoppingListRecipeSerializer, ShoppingListSerializer,
|
||||
StepSerializer, StorageSerializer,
|
||||
SupermarketCategoryRelationSerializer,
|
||||
SupermarketCategorySerializer, SupermarketSerializer,
|
||||
SyncLogSerializer, SyncSerializer, UnitSerializer,
|
||||
UserFileSerializer, UserNameSerializer, UserPreferenceSerializer,
|
||||
ViewLogSerializer)
|
||||
from recipes import settings
|
||||
|
||||
|
||||
class StandardFilterMixin(ViewSetMixin):
|
||||
@@ -109,7 +108,8 @@ class FuzzyFilterMixin(ViewSetMixin):
|
||||
if fuzzy:
|
||||
self.queryset = (
|
||||
self.queryset
|
||||
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
|
||||
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
|
||||
default=Value(0))) # put exact matches at the top of the result set
|
||||
.annotate(trigram=TrigramSimilarity('name', query)).filter(trigram__gt=0.2)
|
||||
.order_by('-exact', '-trigram')
|
||||
)
|
||||
@@ -117,7 +117,8 @@ class FuzzyFilterMixin(ViewSetMixin):
|
||||
# TODO have this check unaccent search settings or other search preferences?
|
||||
self.queryset = (
|
||||
self.queryset
|
||||
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))), default=Value(0))) # put exact matches at the top of the result set
|
||||
.annotate(exact=Case(When(name__iexact=query, then=(Value(100))),
|
||||
default=Value(0))) # put exact matches at the top of the result set
|
||||
.filter(name__icontains=query).order_by('-exact', 'name')
|
||||
)
|
||||
|
||||
@@ -201,7 +202,8 @@ class MergeMixin(ViewSetMixin):
|
||||
source.delete()
|
||||
return Response(content, status=status.HTTP_200_OK)
|
||||
except Exception:
|
||||
content = {'error': True, 'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
|
||||
content = {'error': True,
|
||||
'msg': _(f'An error occurred attempting to merge {source.name} with {target.name}')}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -217,7 +219,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
if root.isnumeric():
|
||||
try:
|
||||
root = int(root)
|
||||
except self.model.DoesNotExist:
|
||||
except ValueError:
|
||||
self.queryset = self.model.objects.none()
|
||||
if root == 0:
|
||||
self.queryset = self.model.get_root_nodes()
|
||||
@@ -245,7 +247,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
try:
|
||||
child = self.model.objects.get(pk=pk, space=self.request.space)
|
||||
except (self.model.DoesNotExist):
|
||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {child} exists')}
|
||||
content = {'error': True, 'msg': _(f'No {self.basename} with id {pk} exists')}
|
||||
return Response(content, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
parent = int(parent)
|
||||
@@ -274,7 +276,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
child.move(parent, f'{node_location}-child')
|
||||
content = {'msg': _(f'{child.name} was moved successfully to parent {parent.name}')}
|
||||
return Response(content, status=status.HTTP_200_OK)
|
||||
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition):
|
||||
except (PathOverflow, InvalidMoveToDescendant, InvalidPosition) as e:
|
||||
content = {'error': True, 'msg': _('An error occurred attempting to move ') + child.name}
|
||||
return Response(content, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -409,7 +411,8 @@ class RecipeBookViewSet(viewsets.ModelViewSet, StandardFilterMixin):
|
||||
permission_classes = [CustomIsOwner]
|
||||
|
||||
def get_queryset(self):
|
||||
self.queryset = self.queryset.filter(created_by=self.request.user).filter(space=self.request.space)
|
||||
self.queryset = self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).filter(
|
||||
space=self.request.space).distinct()
|
||||
return super().get_queryset()
|
||||
|
||||
|
||||
@@ -427,7 +430,9 @@ class RecipeBookEntryViewSet(viewsets.ModelViewSet, viewsets.GenericViewSet):
|
||||
permission_classes = [CustomIsOwner]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.filter(Q(book__created_by=self.request.user) | Q(book__shared=self.request.user)).filter(book__space=self.request.space).distinct()
|
||||
queryset = self.queryset.filter(
|
||||
Q(book__created_by=self.request.user) | Q(book__shared=self.request.user)).filter(
|
||||
book__space=self.request.space).distinct()
|
||||
|
||||
recipe_id = self.request.query_params.get('recipe', None)
|
||||
if recipe_id is not None:
|
||||
@@ -497,8 +502,21 @@ class StepViewSet(viewsets.ModelViewSet):
|
||||
queryset = Step.objects
|
||||
serializer_class = StepSerializer
|
||||
permission_classes = [CustomIsUser]
|
||||
pagination_class = DefaultPagination
|
||||
query_params = [
|
||||
QueryParam(name='recipe', description=_('ID of recipe a step is part of. For multiple repeat parameter.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='query', description=_('Query string matched (fuzzy) against object name.'), qtype='string'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
recipes = self.request.query_params.getlist('recipe', [])
|
||||
query = self.request.query_params.get('query', None)
|
||||
if len(recipes) > 0:
|
||||
self.queryset = self.queryset.filter(recipe__in=recipes)
|
||||
if query is not None:
|
||||
self.queryset = self.queryset.filter(Q(name__icontains=query) | Q(recipe__name__icontains=query))
|
||||
return self.queryset.filter(recipe__space=self.request.space)
|
||||
|
||||
|
||||
@@ -527,8 +545,31 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
# TODO split read and write permission for meal plan guest
|
||||
permission_classes = [CustomIsShare | CustomIsGuest]
|
||||
pagination_class = RecipePagination
|
||||
|
||||
schema = RecipeSchema()
|
||||
# TODO the boolean params below (keywords_or through new) should be updated to boolean types with front end refactored accordingly
|
||||
query_params = [
|
||||
QueryParam(name='query', description=_(
|
||||
'Query string matched (fuzzy) against recipe name. In the future also fulltext search.')),
|
||||
QueryParam(name='keywords', description=_('ID of keyword a recipe should have. For multiple repeat parameter.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='foods', description=_('ID of food a recipe should have. For multiple repeat parameter.'),
|
||||
qtype='int'),
|
||||
QueryParam(name='units', description=_('ID of unit a recipe should have.'), qtype='int'),
|
||||
QueryParam(name='rating', description=_('Rating a recipe should have. [0 - 5]'), qtype='int'),
|
||||
QueryParam(name='books', description=_('ID of book a recipe should be in. For multiple repeat parameter.')),
|
||||
QueryParam(name='keywords_or', description=_(
|
||||
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided keywords.')),
|
||||
QueryParam(name='foods_or', description=_(
|
||||
'If recipe should have all (AND=''false'') or any (OR=''<b>true</b>'') of the provided foods.')),
|
||||
QueryParam(name='books_or', description=_(
|
||||
'If recipe should be in all (AND=''false'') or any (OR=''<b>true</b>'') of the provided books.')),
|
||||
QueryParam(name='internal',
|
||||
description=_('If only internal recipes should be returned. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='random',
|
||||
description=_('Returns the results in randomized order. [''true''/''<b>false</b>'']')),
|
||||
QueryParam(name='new',
|
||||
description=_('Returns new results first in search results. [''true''/''<b>false</b>'']')),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
share = self.request.query_params.get('share', None)
|
||||
@@ -539,7 +580,16 @@ class RecipeViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return super().get_queryset()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
if self.request.GET.get('debug', False):
|
||||
return JsonResponse({
|
||||
'new': str(self.get_queryset().query),
|
||||
'old': str(old_search(request).query)
|
||||
})
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
# TODO write extensive tests for permissions
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'list':
|
||||
return RecipeOverviewSerializer
|
||||
@@ -591,6 +641,20 @@ class ShoppingListEntryViewSet(viewsets.ModelViewSet):
|
||||
queryset = ShoppingListEntry.objects
|
||||
serializer_class = ShoppingListEntrySerializer
|
||||
permission_classes = [CustomIsOwner | CustomIsShared]
|
||||
query_params = [
|
||||
QueryParam(name='id',
|
||||
description=_('Returns the shopping list entry with a primary key of id. Multiple values allowed.'),
|
||||
qtype='int'),
|
||||
QueryParam(
|
||||
name='checked',
|
||||
description=_(
|
||||
'Filter shopping list entries on checked. [''true'', ''false'', ''both'', ''<b>recent</b>'']<br> - ''recent'' includes unchecked items and recently completed items.')
|
||||
),
|
||||
QueryParam(name='supermarket',
|
||||
description=_('Returns the shopping list entries sorted by supermarket category order.'),
|
||||
qtype='int'),
|
||||
]
|
||||
schema = QueryParamAutoSchema()
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(
|
||||
@@ -631,7 +695,7 @@ class ViewLogViewSet(viewsets.ModelViewSet):
|
||||
class CookLogViewSet(viewsets.ModelViewSet):
|
||||
queryset = CookLog.objects
|
||||
serializer_class = CookLogSerializer
|
||||
permission_classes = [CustomIsOwner] # CustomIsShared? since ratings are in the cooklog?
|
||||
permission_classes = [CustomIsOwner]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -718,10 +782,9 @@ def get_recipe_file(request, recipe_id):
|
||||
|
||||
@group_required('user')
|
||||
def sync_all(request):
|
||||
if request.space.demo:
|
||||
messages.add_message(
|
||||
request, messages.ERROR, _('This feature is not available in the demo version!')
|
||||
)
|
||||
if request.space.demo or settings.HOSTED:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('This feature is not yet available in the hosted version of tandoor!'))
|
||||
return redirect('index')
|
||||
|
||||
monitors = Sync.objects.filter(active=True).filter(space=request.user.userpreference.space)
|
||||
@@ -758,7 +821,8 @@ def share_link(request, pk):
|
||||
if request.space.allow_sharing:
|
||||
recipe = get_object_or_404(Recipe, pk=pk, space=request.space)
|
||||
link = ShareLink.objects.create(recipe=recipe, created_by=request.user, space=request.space)
|
||||
return JsonResponse({'pk': pk, 'share': link.uuid, 'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
|
||||
return JsonResponse({'pk': pk, 'share': link.uuid,
|
||||
'link': request.build_absolute_uri(reverse('view_recipe', args=[pk, link.uuid]))})
|
||||
else:
|
||||
return JsonResponse({'error': 'sharing_disabled'}, status=403)
|
||||
|
||||
@@ -919,7 +983,7 @@ def ingredient_from_string(request):
|
||||
|
||||
@group_required('user')
|
||||
def get_facets(request):
|
||||
key = request.GET['hash']
|
||||
key = request.GET.get('hash', None)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ from cookbook.helper.recipe_url_import import parse_cooktime
|
||||
from cookbook.models import (Comment, Food, Ingredient, Keyword, Recipe,
|
||||
RecipeImport, Step, Sync, Unit, UserPreference)
|
||||
from cookbook.tables import SyncTable
|
||||
from recipes import settings
|
||||
|
||||
|
||||
@group_required('user')
|
||||
@@ -37,6 +38,10 @@ def sync(request):
|
||||
messages.add_message(request, messages.WARNING, _('You have more users than allowed in your space.'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if request.space.demo or settings.HOSTED:
|
||||
messages.add_message(request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
|
||||
return redirect('index')
|
||||
|
||||
if request.method == "POST":
|
||||
if not has_group_permission(request.user, ['admin']):
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
@@ -186,13 +191,12 @@ def import_url(request):
|
||||
|
||||
ingredient.save()
|
||||
step.ingredients.add(ingredient)
|
||||
print(ingredient)
|
||||
|
||||
if 'image' in data and data['image'] != '' and data['image'] is not None:
|
||||
try:
|
||||
response = requests.get(data['image'])
|
||||
|
||||
img, filetype = handle_image(request, BytesIO(response.content))
|
||||
img, filetype = handle_image(request, File(BytesIO(response.content), name='image'))
|
||||
recipe.image = File(
|
||||
img, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}'
|
||||
)
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import os
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import UpdateView
|
||||
from django.views.generic.edit import FormMixin
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.forms import (CommentForm, ExternalRecipeForm,
|
||||
MealPlanForm,
|
||||
@@ -17,12 +15,13 @@ from cookbook.forms import (CommentForm, ExternalRecipeForm,
|
||||
from cookbook.helper.permission_helper import (GroupRequiredMixin,
|
||||
OwnerRequiredMixin,
|
||||
group_required)
|
||||
from cookbook.models import (Comment, Ingredient, MealPlan,
|
||||
MealType, Recipe, RecipeBook, RecipeImport,
|
||||
from cookbook.models import (Comment, MealPlan,
|
||||
MealType, Recipe, RecipeImport,
|
||||
Storage, Sync, UserPreference)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
from cookbook.provider.nextcloud import Nextcloud
|
||||
from recipes import settings
|
||||
|
||||
|
||||
@group_required('guest')
|
||||
@@ -126,6 +125,10 @@ def edit_storage(request, pk):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot edit this storage!'))
|
||||
return HttpResponseRedirect(reverse('list_storage'))
|
||||
|
||||
if request.space.demo or settings.HOSTED:
|
||||
messages.add_message(request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
|
||||
return redirect('index')
|
||||
|
||||
if request.method == "POST":
|
||||
form = StorageForm(request.POST, instance=instance)
|
||||
if form.is_valid():
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.utils.translation import gettext as _
|
||||
from cookbook.forms import ExportForm, ImportForm, ImportExportBase
|
||||
from cookbook.helper.permission_helper import group_required
|
||||
from cookbook.integration.cookbookapp import CookBookApp
|
||||
from cookbook.integration.copymethat import CopyMeThat
|
||||
from cookbook.integration.pepperplate import Pepperplate
|
||||
from cookbook.integration.cheftap import ChefTap
|
||||
from cookbook.integration.chowdown import Chowdown
|
||||
@@ -65,6 +66,8 @@ def get_integration(request, export_type):
|
||||
return Plantoeat(request, export_type)
|
||||
if export_type == ImportExportBase.COOKBOOKAPP:
|
||||
return CookBookApp(request, export_type)
|
||||
if export_type == ImportExportBase.COPYMETHAT:
|
||||
return CopyMeThat(request, export_type)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
|
||||
@@ -221,6 +221,23 @@ def user_file(request):
|
||||
)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def step(request):
|
||||
# recipe-param is the name of the parameters used when filtering recipes by this attribute
|
||||
# model-name is the models.js name of the model, probably ALL-CAPS
|
||||
return render(
|
||||
request,
|
||||
'generic/model_template.html',
|
||||
{
|
||||
"title": _("Steps"),
|
||||
"config": {
|
||||
'model': "STEP", # *REQUIRED* name of the model in models.js
|
||||
'recipe_param': 'steps',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@group_required('user')
|
||||
def shopping_list_new(request):
|
||||
# recipe-param is the name of the parameters used when filtering recipes by this attribute
|
||||
|
||||
@@ -19,6 +19,7 @@ from cookbook.helper.permission_helper import (GroupRequiredMixin,
|
||||
from cookbook.models import (InviteLink, MealPlan, MealType, Recipe,
|
||||
RecipeBook, RecipeImport, ShareLink, Step, UserPreference)
|
||||
from cookbook.views.edit import SpaceFormMixing
|
||||
from recipes import settings
|
||||
|
||||
|
||||
class RecipeCreate(GroupRequiredMixin, CreateView):
|
||||
@@ -90,6 +91,9 @@ class StorageCreate(GroupRequiredMixin, CreateView):
|
||||
obj.created_by = self.request.user
|
||||
obj.space = self.request.space
|
||||
obj.save()
|
||||
if self.request.space.demo or settings.HOSTED:
|
||||
messages.add_message(self.request, messages.ERROR, _('This feature is not yet available in the hosted version of tandoor!'))
|
||||
return redirect('index')
|
||||
return HttpResponseRedirect(reverse('edit_storage', kwargs={'pk': obj.pk}))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@@ -197,7 +201,10 @@ class InviteLinkCreate(GroupRequiredMixin, CreateView):
|
||||
def form_valid(self, form):
|
||||
obj = form.save(commit=False)
|
||||
obj.created_by = self.request.user
|
||||
obj.space = self.request.space
|
||||
|
||||
# verify given space is actually owned by the user creating the link
|
||||
if obj.space.created_by != self.request.user:
|
||||
obj.space = self.request.space
|
||||
obj.save()
|
||||
if obj.email:
|
||||
try:
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Avg, Q, Sum
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, render, redirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -22,16 +22,15 @@ from django_tables2 import RequestConfig
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from cookbook.filters import RecipeFilter
|
||||
from cookbook.forms import (CommentForm, Recipe, User,
|
||||
UserCreateForm, UserNameForm, UserPreference,
|
||||
UserPreferenceForm, SpaceJoinForm, SpaceCreateForm,
|
||||
SearchPreferenceForm)
|
||||
from cookbook.helper.permission_helper import group_required, share_link_valid, has_group_permission
|
||||
from cookbook.models import (Comment, CookLog, InviteLink, MealPlan,
|
||||
ViewLog, ShoppingList, Space, Keyword, RecipeImport, Unit,
|
||||
Food, UserFile, ShareLink, SearchPreference, SearchFields)
|
||||
from cookbook.tables import (CookLogTable, RecipeTable, RecipeTableSmall,
|
||||
ViewLogTable, InviteLinkTable)
|
||||
from cookbook.forms import (CommentForm, Recipe, SearchPreferenceForm, SpaceCreateForm,
|
||||
SpaceJoinForm, User, UserCreateForm, UserNameForm, UserPreference,
|
||||
UserPreferenceForm)
|
||||
from cookbook.helper.permission_helper import group_required, has_group_permission, share_link_valid
|
||||
from cookbook.models import (Comment, CookLog, Food, InviteLink, Keyword, MealPlan, RecipeImport,
|
||||
SearchFields, SearchPreference, ShareLink, ShoppingList, Space, Unit,
|
||||
UserFile, ViewLog)
|
||||
from cookbook.tables import (CookLogTable, InviteLinkTable, RecipeTable, RecipeTableSmall,
|
||||
ViewLogTable)
|
||||
from cookbook.views.data import Object
|
||||
from recipes.version import BUILD_REF, VERSION_NUMBER
|
||||
|
||||
@@ -287,7 +286,7 @@ def user_settings(request):
|
||||
if request.method == "POST":
|
||||
if 'preference_form' in request.POST:
|
||||
active_tab = 'preferences'
|
||||
form = UserPreferenceForm(request.POST, prefix='preference')
|
||||
form = UserPreferenceForm(request.POST, prefix='preference', space=request.space)
|
||||
if form.is_valid():
|
||||
if not up:
|
||||
up = UserPreference(user=request.user)
|
||||
@@ -331,10 +330,10 @@ def user_settings(request):
|
||||
if not sp:
|
||||
sp = SearchPreferenceForm(user=request.user)
|
||||
fields_searched = (
|
||||
len(search_form.cleaned_data['icontains'])
|
||||
+ len(search_form.cleaned_data['istartswith'])
|
||||
+ len(search_form.cleaned_data['trigram'])
|
||||
+ len(search_form.cleaned_data['fulltext'])
|
||||
len(search_form.cleaned_data['icontains'])
|
||||
+ len(search_form.cleaned_data['istartswith'])
|
||||
+ len(search_form.cleaned_data['trigram'])
|
||||
+ len(search_form.cleaned_data['fulltext'])
|
||||
)
|
||||
if fields_searched == 0:
|
||||
search_form.add_error(None, _('You must select at least one field to search!'))
|
||||
@@ -380,9 +379,9 @@ def user_settings(request):
|
||||
|
||||
sp.save()
|
||||
if up:
|
||||
preference_form = UserPreferenceForm(instance=up)
|
||||
preference_form = UserPreferenceForm(instance=up, space=request.space)
|
||||
else:
|
||||
preference_form = UserPreferenceForm()
|
||||
preference_form = UserPreferenceForm(space=request.space)
|
||||
|
||||
fields_searched = len(sp.icontains.all()) + len(sp.istartswith.all()) + len(sp.trigram.all()) + len(
|
||||
sp.fulltext.all())
|
||||
|
||||
@@ -23,7 +23,7 @@ This application is developed using the Django framework for Python. They have e
|
||||
|
||||
1. Clone this repository wherever you like and install the Python language for your OS (at least version 3.8)
|
||||
2. Open it in your favorite editor/IDE (e.g. PyCharm)
|
||||
1. If you want, create a virutal environment for all your packages.
|
||||
1. If you want, create a virtual environment for all your packages.
|
||||
3. Install all required packages: `pip install -r requirements.txt`
|
||||
4. Run the migrations: `python manage.py migrate`
|
||||
5. Start the development server: `python manage.py runserver`
|
||||
@@ -59,6 +59,8 @@ folder of the GitHub repository.
|
||||
|
||||
In order to contribute to the documentation you can fork the repository and edit the markdown files in the browser.
|
||||
|
||||
Now install mkdocs and dependencies: `pip install mkdocs-material mkdocs-include-markdown-plugin`.
|
||||
|
||||
If you want to test the documentation locally run `mkdocs serve` from the project root.
|
||||
|
||||
## Contribute Translations
|
||||
|
||||
11
docs/faq.md
11
docs/faq.md
@@ -37,4 +37,13 @@ There is only one installation of the Dropbox system, but it handles multiple us
|
||||
For Tandoor that means all people that work together on one recipe collection can be in one space.
|
||||
If you want to host the collection of your friends family or your neighbor you can create a separate space for them (trough the admin interface).
|
||||
|
||||
Sharing between spaces is currently not possible but is planned for future releases.
|
||||
Sharing between spaces is currently not possible but is planned for future releases.
|
||||
|
||||
## Create Admin user / reset passwords
|
||||
To create a superuser or reset a lost password if access to the container is lost you need to
|
||||
|
||||
1. execute into the container using `docker-compose exec web_recipes sh`
|
||||
2. activate the virtual environment `source venv/bin/activate`
|
||||
3. run `python manage.py createsuperuser` and follow the steps shown.
|
||||
|
||||
To change a password enter `python manage.py changepassword <username>` in step 3.
|
||||
@@ -2,13 +2,13 @@ This application features a very versatile import and export feature in order
|
||||
to offer the best experience possible and allow you to freely choose where your data goes.
|
||||
|
||||
!!! warning "WIP"
|
||||
The Module is relatively new. There is a know issue with [Timeouts](https://github.com/vabene1111/recipes/issues/417) on large exports.
|
||||
The Module is relatively new. There is a known issue with [Timeouts](https://github.com/vabene1111/recipes/issues/417) on large exports.
|
||||
A fix is being developed and will likely be released with the next version.
|
||||
|
||||
The Module is build with maximum flexibility and expandability in mind and allows to easily add new
|
||||
The Module is built with maximum flexibility and expandability in mind and allows to easily add new
|
||||
integrations to allow you to both import and export your recipes into whatever format you desire.
|
||||
|
||||
Feel like there is an important integration missing ? Just take a look at the [integration issues](https://github.com/vabene1111/recipes/issues?q=is%3Aissue+is%3Aopen+label%3Aintegration) or open a new one
|
||||
Feel like there is an important integration missing? Just take a look at the [integration issues](https://github.com/vabene1111/recipes/issues?q=is%3Aissue+is%3Aopen+label%3Aintegration) or open a new one
|
||||
if your favorite one is missing.
|
||||
|
||||
!!! info "Export"
|
||||
@@ -37,11 +37,12 @@ Overview of the capabilities of the different integrations.
|
||||
| OpenEats | ✔️ | ❌ | ⌚ |
|
||||
| Plantoeat | ✔️ | ❌ | ✔ |
|
||||
| CookBookApp | ✔️ | ⌚ | ✔️ |
|
||||
| CopyMeThat | ✔️ | ❌ | ✔️ |
|
||||
|
||||
✔ = implemented, ❌ = not implemented and not possible/planned, ⌚ = not yet implemented
|
||||
|
||||
## Default
|
||||
The default integration is the build in (and preferred) way to import and export recipes.
|
||||
The default integration is the built in (and preferred) way to import and export recipes.
|
||||
It is maintained with new fields added and contains all data to transfer your recipes from one installation to another.
|
||||
|
||||
It is also one of the few recipe formats that is actually structured in a way that allows for
|
||||
@@ -90,7 +91,7 @@ Mealie provides structured data similar to nextcloud.
|
||||
|
||||
To migrate your recipes
|
||||
|
||||
1. Go to you Mealie settings and create a new Backup
|
||||
1. Go to your Mealie settings and create a new Backup
|
||||
2. Download the backup by clicking on it and pressing download (this wasn't working for me, so I had to manually pull it from the server)
|
||||
3. Upload the entire `.zip` file to the importer page and import everything
|
||||
|
||||
@@ -118,7 +119,7 @@ Recipes.zip/
|
||||
```
|
||||
|
||||
## Safron
|
||||
Go to you safron settings page and export your recipes.
|
||||
Go to your safron settings page and export your recipes.
|
||||
Then simply upload the entire `.zip` file to the importer.
|
||||
|
||||
!!! warning "Images"
|
||||
@@ -131,8 +132,8 @@ The `.paprikarecipes` file is basically just a zip with gzipped contents. Simply
|
||||
all your recipes.
|
||||
|
||||
## Pepperplate
|
||||
Pepperplate provides a `.zip` files contain all your recipes as `.txt` files. These files are well-structured and allow
|
||||
the import of all data without loosing anything.
|
||||
Pepperplate provides a `.zip` file containing all of your recipes as `.txt` files. These files are well-structured and allow
|
||||
the import of all data without losing anything.
|
||||
|
||||
Simply export the recipes from Pepperplate and upload the zip to Tandoor. Images are not included in the export and
|
||||
thus cannot be imported.
|
||||
@@ -145,7 +146,7 @@ This format is basically completely unstructured and every export looks differen
|
||||
and leads to suboptimal results. Images are also not supported as they are not included in the export (at least
|
||||
the tests I had).
|
||||
|
||||
Usually the import should recognize all ingredients and put everything else into the instructions. If you import fails
|
||||
Usually the import should recognize all ingredients and put everything else into the instructions. If your import fails
|
||||
or is worse than this feel free to provide me with more example data and I can try to improve the importer.
|
||||
|
||||
As ChefTap cannot import these files anyway there won't be an exporter implemented in Tandoor.
|
||||
@@ -154,7 +155,7 @@ As ChefTap cannot import these files anyway there won't be an exporter implement
|
||||
Meal master can be imported by uploading one or more meal master files.
|
||||
The files should either be `.txt`, `.MMF` or `.MM` files.
|
||||
|
||||
The MealMaster spec allow for many variations. Currently, only the on column format for ingredients is supported.
|
||||
The MealMaster spec allow for many variations. Currently, only the one column format for ingredients is supported.
|
||||
Second line notes to ingredients are currently also not imported as a note but simply put into the instructions.
|
||||
If you have MealMaster recipes that cannot be imported feel free to raise an issue.
|
||||
|
||||
@@ -166,7 +167,7 @@ The generated file can simply be imported into Tandoor.
|
||||
As I only had limited sample data feel free to open an issue if your RezKonv export cannot be imported.
|
||||
|
||||
## Recipekeeper
|
||||
Recipe keeper allows to export a zip file containing recipes and images using its apps.
|
||||
Recipe keeper allows you to export a zip file containing recipes and images using its apps.
|
||||
This zip file can simply be imported into Tandoor.
|
||||
|
||||
## OpenEats
|
||||
@@ -213,8 +214,12 @@ Store the outputted json string in a `.json` file and simply import it using the
|
||||
|
||||
## Plantoeat
|
||||
|
||||
Plan to eat allow to export a text file containing all your recipes. Simply upload that text file to Tandoor to import all recipes
|
||||
Plan to eat allows you to export a text file containing all your recipes. Simply upload that text file to Tandoor to import all recipes
|
||||
|
||||
## CookBookApp
|
||||
|
||||
CookBookApp can export .zip files containing .html files. Upload the entire ZIP to Tandoor to import all included recipes.
|
||||
CookBookApp can export .zip files containing .html files. Upload the entire ZIP to Tandoor to import all included recipes.
|
||||
|
||||
## CopyMeThat
|
||||
|
||||
CopyMeThat can export .zip files containing an `.html` file as well as a folder containing all the images. Upload the entire ZIP to Tandoor to import all included recipes.
|
||||
@@ -17,7 +17,7 @@ from recipe scaling.
|
||||
Currently the only available variable in the Templating context is `ingredients`.
|
||||
|
||||
`ingredients` is an array that contains all ingredients of the current recipe step. You can access an ingredient by using
|
||||
`{{ ingredient[<index in list>] }}` where the index refers to the position in the list of ingredients starting with zero.
|
||||
`{{ ingredients[<index in list>] }}` where the index refers to the position in the list of ingredients starting with zero.
|
||||
You can also use the interaction menu of the ingredient to copy its reference.
|
||||
|
||||
!!! warning
|
||||
@@ -28,10 +28,10 @@ You can also use the interaction menu of the ingredient to copy its reference.
|
||||
|
||||
You can also access only the amount, unit, note or food inside your instruction text using
|
||||
```
|
||||
{{ instructions[0].amount }}
|
||||
{{ instructions[0].unit }}
|
||||
{{ instructions[0].food }}
|
||||
{{ instructions[0].note }}
|
||||
{{ ingredients[0].amount }}
|
||||
{{ ingredients[0].unit }}
|
||||
{{ ingredients[0].food }}
|
||||
{{ ingredients[0].note }}
|
||||
```
|
||||
|
||||
## Technical Reasoning
|
||||
@@ -43,4 +43,4 @@ The template could access them by ID, the food name or the position in the list.
|
||||
2. **Name**: very nice to read and easy but does not work when a food occurs twice in a step. Could have workaround but would then be inconsistent.
|
||||
3. **Position**: easy to write and understand but breaks when ordering is changed and not really nice to read when instructions are not rendered.
|
||||
|
||||
I decided to go for the position based system. If you know of any better way feel free to open an issue or PR.
|
||||
I decided to go for the position based system. If you know of any better way feel free to open an issue or PR.
|
||||
|
||||
@@ -51,7 +51,7 @@ There are different versions (tags) released on docker hub.
|
||||
The main, and also recommended, installation option is to install this application using Docker Compose.
|
||||
|
||||
1. Choose your `docker-compose.yml` from the examples below.
|
||||
2. Download the `.env` configuration file with `wget`, then **edit it accordingly**.
|
||||
2. Download the `.env` configuration file with `wget`, then **edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
|
||||
```shell
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
|
||||
```
|
||||
@@ -65,47 +65,9 @@ This configuration exposes the application through an nginx web server on port 8
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/plain/docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
db_recipes:
|
||||
restart: always
|
||||
image: postgres:11-alpine
|
||||
volumes:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- staticfiles:/opt/recipes/staticfiles
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
|
||||
nginx_recipes:
|
||||
image: nginx:mainline-alpine
|
||||
restart: always
|
||||
ports:
|
||||
- 80:80
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- web_recipes
|
||||
volumes:
|
||||
- nginx_config:/etc/nginx/conf.d:ro
|
||||
- staticfiles:/static
|
||||
- ./mediafiles:/media
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:
|
||||
```
|
||||
~~~yaml
|
||||
{% include "./docker/plain/docker-compose.yml" %}
|
||||
~~~
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
@@ -123,62 +85,9 @@ If you use traefik, this configuration is the one for you.
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/traefik-nginx/docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
db_recipes:
|
||||
restart: always
|
||||
image: postgres:11-alpine
|
||||
volumes:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- default
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- staticfiles:/opt/recipes/staticfiles
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
networks:
|
||||
- default
|
||||
|
||||
nginx_recipes:
|
||||
image: nginx:mainline-alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- nginx_config:/etc/nginx/conf.d:ro
|
||||
- staticfiles:/static
|
||||
- ./mediafiles:/media
|
||||
labels: # traefik example labels
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.recipes.rule=Host(`recipes.mydomain.com`, `recipes.myotherdomain.com`)"
|
||||
- "traefik.http.routers.recipes.entrypoints=web_secure" # your https endpoint
|
||||
- "traefik.http.routers.recipes.tls.certresolver=le_resolver" # your cert resolver
|
||||
depends_on:
|
||||
- web_recipes
|
||||
networks:
|
||||
- default
|
||||
- traefik
|
||||
|
||||
networks:
|
||||
default:
|
||||
traefik: # This is you external traefik network
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:
|
||||
```
|
||||
~~~yaml
|
||||
{% include "./docker/traefik-nginx/docker-compose.yml" %}
|
||||
~~~
|
||||
|
||||
#### nginx-proxy
|
||||
|
||||
@@ -198,58 +107,31 @@ LETSENCRYPT_EMAIL=
|
||||
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/docker/nginx-proxy/docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
db_recipes:
|
||||
restart: always
|
||||
image: postgres:11-alpine
|
||||
volumes:
|
||||
- ./postgresql:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
- default
|
||||
~~~yaml
|
||||
{% include "./docker/nginx-proxy/docker-compose.yml" %}
|
||||
~~~
|
||||
|
||||
web_recipes:
|
||||
image: vabene1111/recipes
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- staticfiles:/opt/recipes/staticfiles
|
||||
- nginx_config:/opt/recipes/nginx/conf.d
|
||||
- ./mediafiles:/opt/recipes/mediafiles
|
||||
depends_on:
|
||||
- db_recipes
|
||||
networks:
|
||||
- default
|
||||
#### Nginx Swag by LinuxServer
|
||||
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
|
||||
|
||||
nginx_recipes:
|
||||
image: nginx:mainline-alpine
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
depends_on:
|
||||
- web_recipes
|
||||
volumes:
|
||||
- nginx_config:/etc/nginx/conf.d:ro
|
||||
- staticfiles:/static
|
||||
- ./mediafiles:/media
|
||||
networks:
|
||||
- default
|
||||
- nginx-proxy
|
||||
It contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance.
|
||||
|
||||
networks:
|
||||
default:
|
||||
nginx-proxy:
|
||||
external:
|
||||
name: nginx-proxy
|
||||
If you're running Swag on the default port, you'll just need to change the container name to yours.
|
||||
|
||||
volumes:
|
||||
nginx_config:
|
||||
staticfiles:
|
||||
```
|
||||
If your running Swag on a custom port, some headers must be changed:
|
||||
|
||||
- Create a copy of `proxy.conf`
|
||||
- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to
|
||||
- `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;`
|
||||
- Update `recipes.subdomain.conf` to use the new file
|
||||
- Restart the linuxserver/swag container and Recipes will work correctly
|
||||
|
||||
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
|
||||
|
||||
|
||||
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
|
||||
|
||||
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
|
||||
|
||||
## Additional Information
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ metadata:
|
||||
labels:
|
||||
app: recipes
|
||||
name: recipes-nginx-config
|
||||
namespace: default
|
||||
data:
|
||||
nginx-config: |-
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
http {
|
||||
include mime.types;
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -24,10 +26,5 @@ data:
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
}
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass http://localhost:8080;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
docs/install/k8s/15-secrets.yaml
Normal file
13
docs/install/k8s/15-secrets.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
kind: Secret
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: recipes
|
||||
namespace: default
|
||||
type: Opaque
|
||||
data:
|
||||
# echo -n 'db-password' | base64
|
||||
postgresql-password: ZGItcGFzc3dvcmQ=
|
||||
# echo -n 'postgres-user-password' | base64
|
||||
postgresql-postgres-password: cG9zdGdyZXMtdXNlci1wYXNzd29yZA==
|
||||
# echo -n 'secret-key' | sha256sum | awk '{ printf $1 }' | base64
|
||||
secret-key: ODVkYmUxNWQ3NWVmOTMwOGM3YWUwZjMzYzdhMzI0Y2M2ZjRiZjUxOWEyZWQyZjMwMjdiZDMzYzE0MGE0ZjlhYQ==
|
||||
5
docs/install/k8s/20-service-account.yml
Normal file
5
docs/install/k8s/20-service-account.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: recipes
|
||||
namespace: default
|
||||
@@ -1,50 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: recipes-db
|
||||
labels:
|
||||
app: recipes
|
||||
type: local
|
||||
tier: db
|
||||
spec:
|
||||
storageClassName: manual
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
hostPath:
|
||||
path: "/data/recipes/db"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: recipes-media
|
||||
labels:
|
||||
app: recipes
|
||||
type: local
|
||||
tier: media
|
||||
spec:
|
||||
storageClassName: manual
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
hostPath:
|
||||
path: "/data/recipes/media"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: recipes-static
|
||||
labels:
|
||||
app: recipes
|
||||
type: local
|
||||
tier: static
|
||||
spec:
|
||||
storageClassName: manual
|
||||
capacity:
|
||||
storage: 1Gi
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
hostPath:
|
||||
path: "/data/recipes/static"
|
||||
@@ -1,34 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: recipes-db
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
tier: db
|
||||
storageClassName: manual
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: recipes-media
|
||||
namespace: default
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
tier: media
|
||||
app: recipes
|
||||
storageClassName: manual
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -37,16 +16,12 @@ apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: recipes-static
|
||||
namespace: default
|
||||
labels:
|
||||
app: recipes
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
tier: static
|
||||
app: recipes
|
||||
storageClassName: manual
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
142
docs/install/k8s/40-sts-postgresql.yaml
Normal file
142
docs/install/k8s/40-sts-postgresql.yaml
Normal file
@@ -0,0 +1,142 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
labels:
|
||||
app: recipes
|
||||
tier: database
|
||||
name: recipes-postgresql
|
||||
namespace: default
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: recipes
|
||||
serviceName: recipes-postgresql
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
backup.velero.io/backup-volumes: data
|
||||
labels:
|
||||
app: recipes
|
||||
tier: database
|
||||
name: recipes-postgresql
|
||||
namespace: default
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
securityContext:
|
||||
fsGroup: 999
|
||||
serviceAccount: recipes
|
||||
serviceAccountName: recipes
|
||||
terminationGracePeriodSeconds: 30
|
||||
containers:
|
||||
- name: recipes-db
|
||||
env:
|
||||
- name: BITNAMI_DEBUG
|
||||
value: "false"
|
||||
- name: POSTGRESQL_PORT_NUMBER
|
||||
value: "5432"
|
||||
- name: POSTGRESQL_VOLUME_DIR
|
||||
value: /bitnami/postgresql
|
||||
- name: PGDATA
|
||||
value: /bitnami/postgresql/data
|
||||
- name: POSTGRES_USER
|
||||
value: recipes
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: recipes
|
||||
key: postgresql-password
|
||||
- name: POSTGRESQL_POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: recipes
|
||||
key: postgresql-postgres-password
|
||||
- name: POSTGRES_DB
|
||||
value: recipes
|
||||
image: docker.io/bitnami/postgresql:11.5.0-debian-9-r60
|
||||
imagePullPolicy: IfNotPresent
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- exec pg_isready -U "postgres" -d "wiki" -h 127.0.0.1 -p 5432
|
||||
failureThreshold: 6
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgresql
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- -e
|
||||
- |
|
||||
pg_isready -U "postgres" -d "wiki" -h 127.0.0.1 -p 5432
|
||||
[ -f /opt/bitnami/postgresql/tmp/.initialized ]
|
||||
failureThreshold: 6
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
successThreshold: 1
|
||||
timeoutSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
runAsUser: 1001
|
||||
terminationMessagePath: /dev/termination-log
|
||||
terminationMessagePolicy: File
|
||||
volumeMounts:
|
||||
- mountPath: /bitnami/postgresql
|
||||
name: data
|
||||
dnsPolicy: ClusterFirst
|
||||
initContainers:
|
||||
- command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mkdir -p /bitnami/postgresql/data
|
||||
chmod 700 /bitnami/postgresql/data
|
||||
find /bitnami/postgresql -mindepth 0 -maxdepth 1 -not -name ".snapshot" -not -name "lost+found" | \
|
||||
xargs chown -R 1001:1001
|
||||
image: docker.io/bitnami/minideb:stretch
|
||||
imagePullPolicy: Always
|
||||
name: init-chmod-data
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- mountPath: /bitnami/postgresql
|
||||
name: data
|
||||
restartPolicy: Always
|
||||
securityContext:
|
||||
fsGroup: 1001
|
||||
serviceAccount: recipes
|
||||
serviceAccountName: recipes
|
||||
terminationGracePeriodSeconds: 30
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
volumeClaimTemplates:
|
||||
- apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: data
|
||||
namespace: default
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
volumeMode: Filesystem
|
||||
19
docs/install/k8s/45-service-db.yaml
Normal file
19
docs/install/k8s/45-service-db.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
app: recipes
|
||||
tier: database
|
||||
name: recipes-postgresql
|
||||
namespace: default
|
||||
spec:
|
||||
ports:
|
||||
- name: postgresql
|
||||
port: 5432
|
||||
protocol: TCP
|
||||
targetPort: postgresql
|
||||
selector:
|
||||
app: recipes
|
||||
tier: database
|
||||
sessionAffinity: None
|
||||
type: ClusterIP
|
||||
@@ -2,6 +2,7 @@ apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: recipes
|
||||
namespace: default
|
||||
labels:
|
||||
app: recipes
|
||||
environment: production
|
||||
@@ -9,17 +10,78 @@ metadata:
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: recipes
|
||||
environment: production
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
backup.velero.io/backup-volumes: media,static
|
||||
labels:
|
||||
app: recipes
|
||||
tier: frontend
|
||||
environment: production
|
||||
spec:
|
||||
restartPolicy: Always
|
||||
serviceAccount: recipes
|
||||
serviceAccountName: recipes
|
||||
initContainers:
|
||||
- name: init-chmod-data
|
||||
env:
|
||||
- name: SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: recipes
|
||||
key: secret-key
|
||||
- name: DB_ENGINE
|
||||
value: django.db.backends.postgresql_psycopg2
|
||||
- name: POSTGRES_HOST
|
||||
value: recipes-postgresql
|
||||
- name: POSTGRES_PORT
|
||||
value: "5432"
|
||||
- name: POSTGRES_USER
|
||||
value: postgres
|
||||
- name: POSTGRES_DB
|
||||
value: recipes
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: recipes
|
||||
key: postgresql-postgres-password
|
||||
image: vabene1111/recipes:1.0.1
|
||||
imagePullPolicy: Always
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 64Mi
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
source venv/bin/activate
|
||||
echo "Updating database"
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic_js_reverse
|
||||
python manage.py collectstatic --noinput
|
||||
echo "Setting media file attributes"
|
||||
chown -R 65534:65534 /opt/recipes/mediafiles
|
||||
find /opt/recipes/mediafiles -type d | xargs -r chmod 755
|
||||
find /opt/recipes/mediafiles -type f | xargs -r chmod 644
|
||||
echo "Done"
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
volumeMounts:
|
||||
- mountPath: /opt/recipes/mediafiles
|
||||
name: media
|
||||
# mount as subPath due to lost+found on ext4 pvc
|
||||
subPath: files
|
||||
- mountPath: /opt/recipes/staticfiles
|
||||
name: static
|
||||
# mount as subPath due to lost+found on ext4 pvc
|
||||
subPath: files
|
||||
containers:
|
||||
- name: recipes-nginx
|
||||
image: nginx:latest
|
||||
@@ -28,69 +90,94 @@ spec:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
name: http
|
||||
- containerPort: 8080
|
||||
protocol: TCP
|
||||
name: gunicorn
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
- mountPath: '/media'
|
||||
- mountPath: /media
|
||||
name: media
|
||||
- mountPath: '/static'
|
||||
# mount as subPath due to lost+found on ext4 pvc
|
||||
subPath: files
|
||||
- mountPath: /static
|
||||
name: static
|
||||
# mount as subPath due to lost+found on ext4 pvc
|
||||
subPath: files
|
||||
- name: nginx-config
|
||||
mountPath: /etc/nginx/nginx.conf
|
||||
subPath: nginx-config
|
||||
readOnly: true
|
||||
- name: recipes
|
||||
image: 'vabene1111/recipes:latest'
|
||||
image: vabene1111/recipes:1.0.1
|
||||
imagePullPolicy: IfNotPresent
|
||||
command:
|
||||
- /opt/recipes/venv/bin/gunicorn
|
||||
- -b
|
||||
- :8080
|
||||
- --access-logfile
|
||||
- "-"
|
||||
- --error-logfile
|
||||
- "-"
|
||||
- --log-level
|
||||
- INFO
|
||||
- recipes.wsgi
|
||||
livenessProbe:
|
||||
failureThreshold: 3
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
scheme: HTTP
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 8080
|
||||
scheme: HTTP
|
||||
periodSeconds: 30
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
- mountPath: '/opt/recipes/mediafiles'
|
||||
- mountPath: /opt/recipes/mediafiles
|
||||
name: media
|
||||
- mountPath: '/opt/recipes/staticfiles'
|
||||
# mount as subPath due to lost+found on ext4 pvc
|
||||
subPath: files
|
||||
- mountPath: /opt/recipes/staticfiles
|
||||
name: static
|
||||
# mount as subPath due to lost+found on ext4 pvc
|
||||
subPath: files
|
||||
env:
|
||||
- name: DEBUG
|
||||
value: "0"
|
||||
- name: ALLOWED_HOSTS
|
||||
value: '*'
|
||||
- name: SECRET_KEY
|
||||
value: # CHANGEME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: recipes
|
||||
key: secret-key
|
||||
- name: DB_ENGINE
|
||||
value: django.db.backends.postgresql_psycopg2
|
||||
- name: POSTGRES_HOST
|
||||
value: localhost
|
||||
value: recipes-postgresql
|
||||
- name: POSTGRES_PORT
|
||||
value: "5432"
|
||||
- name: POSTGRES_USER
|
||||
value: recipes
|
||||
value: postgres
|
||||
- name: POSTGRES_DB
|
||||
value: recipes
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: # CHANGEME
|
||||
- name: recipes-db
|
||||
image: 'postgres:latest'
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
volumeMounts:
|
||||
- mountPath: '/var/lib/postgresql/data'
|
||||
name: database
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: recipes
|
||||
- name: POSTGRES_DB
|
||||
value: recipes
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: # CHANGEME
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: recipes
|
||||
key: postgresql-postgres-password
|
||||
securityContext:
|
||||
runAsUser: 65534
|
||||
volumes:
|
||||
- name: database
|
||||
persistentVolumeClaim:
|
||||
claimName: recipes-db
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: recipes-media
|
||||
|
||||
@@ -2,14 +2,21 @@ apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: recipes
|
||||
namespace: default
|
||||
labels:
|
||||
app: recipes
|
||||
tier: frontend
|
||||
spec:
|
||||
selector:
|
||||
app: recipes
|
||||
tier: frontend
|
||||
environment: production
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: http
|
||||
name: http
|
||||
protocol: TCP
|
||||
- port: 8080
|
||||
targetPort: gunicorn
|
||||
name: gunicorn
|
||||
protocol: TCP
|
||||
|
||||
38
docs/install/k8s/70-ingress.yaml
Normal file
38
docs/install/k8s/70-ingress.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
#cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
#kubernetes.io/ingress.class: nginx
|
||||
name: recipes
|
||||
namespace: default
|
||||
spec:
|
||||
rules:
|
||||
- host: recipes.local
|
||||
http:
|
||||
paths:
|
||||
- backend:
|
||||
service:
|
||||
name: recipes
|
||||
port:
|
||||
number: 8080
|
||||
path: /
|
||||
pathType: Prefix
|
||||
- backend:
|
||||
service:
|
||||
name: recipes
|
||||
port:
|
||||
number: 80
|
||||
path: /media
|
||||
pathType: Prefix
|
||||
- backend:
|
||||
service:
|
||||
name: recipes
|
||||
port:
|
||||
number: 80
|
||||
path: /static
|
||||
pathType: Prefix
|
||||
#tls:
|
||||
#- hosts:
|
||||
# - recipes.local
|
||||
# secretName: recipes-local-tls
|
||||
@@ -1,31 +1,98 @@
|
||||
!!! info "Community Contributed"
|
||||
This guide was contributed by the community and is neither officially supported, nor updated or tested.
|
||||
**!!! info "Community Contributed" This guide was contributed by the community and is neither officially supported, nor updated or tested.**
|
||||
|
||||
This is a basic kubernetes setup.
|
||||
Please note that this does not necessarily follow Kubernetes best practices and should only used as a
|
||||
basis to build your own setup from!
|
||||
# K8s Setup
|
||||
|
||||
All files con be found here in the Github Repo:
|
||||
[docs/install/k8s](https://github.com/vabene1111/recipes/tree/develop/docs/install/k8s)
|
||||
This is a setup which should be sufficent for production use. Be sure to replace the default secrets!
|
||||
|
||||
## Important notes
|
||||
# Files
|
||||
|
||||
State (database, static files and media files) is handled via `PersistentVolumes`.
|
||||
## 10-configmap.yaml
|
||||
|
||||
Note that you will most likely have to change the `PersistentVolumes` in `30-pv.yaml`. The current setup is only usable for a single-node cluster because it uses local storage on the kubernetes worker nodes under `/data/recipes/`. It should just serve as an example.
|
||||
The nginx config map. This is loaded as nginx.conf in the nginx sidecar to configure nginx to deliver static content.
|
||||
|
||||
Currently, the deployment in `50-deployment.yaml` just pulls the `latest` tag of all containers. In a production setup, you should set this to a fixed version!
|
||||
## 15-secrets.yaml
|
||||
|
||||
See env variables tagged with `CHANGEME` in `50-deployment.yaml` and make sure to change those! A better setup would use kubernetes secrets but this is not implemented yet.
|
||||
The secrets **replace them!!** This file is only here for a quick start. Be aware that changing secrets after installation will be messy and is not documented here. **You should set new secrets before the installation.** As you are reading this document **before** the installation ;-)
|
||||
|
||||
## Updates
|
||||
Create your own postgresql passwords and the secret key for the django app
|
||||
|
||||
These manifests are not tested against new versions.
|
||||
see also [Managing Secrets using kubectl](https://kubernetes.io/docs/tasks/configmap-secret/managing-secret-using-kubectl/)
|
||||
|
||||
## Apply the manifets
|
||||
**Replace** `db-password`, `postgres-user-password` and `secret-key` **with something - well - secret :-)**
|
||||
|
||||
To apply the manifest with `kubectl`, use the following command:
|
||||
~~~
|
||||
echo -n 'db-password' > ./db-password.txt
|
||||
echo -n 'postgres-user-password' > ./postgres-password.txt
|
||||
echo -n 'secret-key' | sha256sum | awk '{ printf $1 }' > ./secret-key.txt
|
||||
~~~
|
||||
|
||||
```
|
||||
Delete the default secrets file `15-secrets.yaml` and generate the K8s secret from your files.
|
||||
|
||||
~~~
|
||||
kubectl create secret generic recipes \
|
||||
--from-file=postgresql-password=./db-password.txt \
|
||||
--from-file=postgresql-postgres-password=./postgres-password.txt \
|
||||
--from-file=secret-key=./secret-key.txt
|
||||
~~~
|
||||
|
||||
## 20-service-account.yml
|
||||
|
||||
Creating service account `recipes` for deployment and stateful set.
|
||||
|
||||
## 30-pvc.yaml
|
||||
|
||||
The creation of the persistent volume claims for media and static content. May you want to increase the size. This expects to have a storage class installed.
|
||||
|
||||
## 40-sts-postgresql.yaml
|
||||
|
||||
The PostgreSQL stateful set, based on a bitnami image. It runs a init container as root to do the preparations. The postgres container itsef runs as a lower privileged user. The recipes app uses the database super user (postgres) as the recipies app is doing some db migrations on startup, which needs super user privileges.
|
||||
|
||||
## 45-service-db.yaml
|
||||
|
||||
Creating the database service.
|
||||
|
||||
## 50-deployment.yaml
|
||||
|
||||
The deployment first fires up a init container to do the database migrations and file modifications. This init container runs as root. The init conainer runs part of the [boot.sh](https://github.com/TandoorRecipes/recipes/blob/develop/boot.sh) script from the `vabene1111/recipes` image.
|
||||
|
||||
The deployment then runs two containers, the recipes-nginx and the recipes container which runs the gunicorn app. The nginx container gets it's nginx.conf via config map to deliver static content `/static` and `/media`. The guincorn container gets it's secret key and the database password from the secret `recipes`. `gunicorn` runs as user `nobody`.
|
||||
|
||||
## 60-service.yaml
|
||||
|
||||
Creating the app service.
|
||||
|
||||
## 70-ingress.yaml
|
||||
|
||||
Setting up the ingress for the recipes service. Requests for static content `/static` and `/media` are send to the nginx container, everything else to gunicorn. TLS setup via cert-manager is prepared. You have to **change the host** from `recipes.local` to your specific domain.
|
||||
|
||||
# Conclusion
|
||||
|
||||
All in all:
|
||||
|
||||
- The database is set up as a stateful set.
|
||||
- The database container runs as a low privileged user.
|
||||
- Database and application use secrets.
|
||||
- The application also runs as a low privileged user.
|
||||
- nginx runs as root but forks children with a low privileged user.
|
||||
- There's an ingress rule to access the application from outside.
|
||||
|
||||
I tried the setup with [kind](https://kind.sigs.k8s.io/) and it runs well on my local cluster.
|
||||
|
||||
There is a warning, when you check your system as super user:
|
||||
|
||||
**Media Serving Warning**
|
||||
Serving media files directly using gunicorn/python is not recommend! Please follow the steps described here to update your installation.
|
||||
|
||||
I don't know how this check works, but this warning is simply wrong! ;-) Media and static files are routed by ingress to the nginx container - I promise :-)
|
||||
|
||||
# Updates
|
||||
|
||||
These manifests are tested against Release 1.0.1. Newer versions may not work without changes.
|
||||
|
||||
# Apply the manifets
|
||||
|
||||
To apply the manifest with kubectl, use the following command:
|
||||
|
||||
~~~
|
||||
kubectl apply -f ./docs/k8s/
|
||||
```
|
||||
~~~
|
||||
|
||||
21
docs/install/kubesail.md
Normal file
21
docs/install/kubesail.md
Normal file
@@ -0,0 +1,21 @@
|
||||
!!! info "Community Contributed"
|
||||
This guide was contributed by the community and is neither officially supported, nor updated or tested.
|
||||
|
||||
[KubeSail](https://kubesail.com/) lets you install Tandoor by providing a simple web interface for installing and managing apps. You can connect any server running Kubernetes, or get a pre-configured [PiBox](https://pibox.io).
|
||||
|
||||
<!-- A portion of every PiBox sale goes toward supporting Tandoor development. -->
|
||||
|
||||
The KubeSail template is closely based on the [Kubernetes installation]([docs/install/k8s](https://github.com/vabene1111/recipes/tree/develop/docs/install/k8s)) configs
|
||||
|
||||
## Quick Start
|
||||
|
||||
Load the [Tandoor Recipes](https://kubesail.com/template/PastuDan/Tandoor%20Recipes) template, and click **Launch Template**.
|
||||
|
||||
If you have not yet attached your server to KubeSail, see the [Getting a Cluster](https://docs.kubesail.com/guides/bare-metal/) section on the KubeSail docs.
|
||||
|
||||
## Important notes
|
||||
|
||||
In the "Template Variables" section you will see two input fields. These should show `RANDOM(16)`, indicating they will be randomly generated and specific to your install when you launch the template. If you prefer to set these yourself, you can type them in before launching the template.
|
||||
|
||||

|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
These intructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
|
||||
|
||||
!!! warning
|
||||
Be sure to use pyton3.9 and pip related to python 3.9. Depending on your distribution calling `python` or `pip` will use python2 instead of pyton 3.9.
|
||||
Be sure to use python 3.9 and pip related to python 3.9. Depending on your distribution calling `python` or `pip` will use python2 instead of python 3.9.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -60,7 +60,41 @@ Creating recipes_web_recipes_1 ... done
|
||||
- Browse to 192.168.1.1:2000 or whatever your IP and port are
|
||||
- While the containers are starting and doing whatever they need to do, you might still get HTTP errors e.g. 500 or 502. Just be patient and try again in a moment
|
||||
|
||||
5. Additional SSL Setup
|
||||
5. Firewall
|
||||
You need to set up firewall rules in order for the recipes_web container to be able to connect to the recipes_db container.
|
||||
|
||||
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
|
||||
- Ports: All
|
||||
- Source IP: Specific IP -> Select -> Subnet
|
||||
- insert docker network ip (can be found in the docker application, network tab)
|
||||
- Example: IP address: 172.18.0.0 and Subnet mask/Prefix length: 255.255.255.0
|
||||
- Action: Allow
|
||||
- Save and make sure it's above the deny rules
|
||||
|
||||
6. Additional SSL Setup
|
||||
Easiest way is to do it via Reverse Proxy
|
||||
- Control Panel -> Login Portal (renamed Since DSM 7, previously Application Portal) -> Advanced -> Reverse Proxy
|
||||
- Create
|
||||
- insert name
|
||||
- Source:
|
||||
- Protocol: HTTPS
|
||||
- Hostname: URL if you acces from outside, otherwise ip in network
|
||||
- Port: The port you want to access, has to be a different one that the one in the docker-compose file
|
||||
- HSTS can be enabled
|
||||
- Destination:
|
||||
- Protocol: HTTP
|
||||
- Hostname: localhost
|
||||
- Port: port in docker-compose file
|
||||
- Click on Custom Header and press Create -> Websocket
|
||||
- Save
|
||||
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
|
||||
- Ports: Select form a list of build-in applications -> Select -> You find your Reverse Proxy, enable it
|
||||
- Source IP: Depends, All allows access from outside, i use specific to only connect in my network
|
||||
- Action: Allow
|
||||
- Save and make sure it's above the deny rules
|
||||
|
||||
[Deprecated, Note: ssl Path changed for DSM 7]
|
||||
6.1 Additional SSL Setup
|
||||
- create foler `ssl` inside `nginx` folder
|
||||
- download your ssl certificate from `security` tab in dsm `control panel`
|
||||
- or create a task in `task manager` because Synology will update the certificate every few months
|
||||
|
||||
@@ -21,6 +21,9 @@ markdown_extensions:
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
|
||||
plugins:
|
||||
- include-markdown
|
||||
|
||||
nav:
|
||||
- Home: 'index.md'
|
||||
- Installation:
|
||||
@@ -28,6 +31,7 @@ nav:
|
||||
- Unraid: install/unraid.md
|
||||
- Synology: install/synology.md
|
||||
- Kubernetes: install/kubernetes.md
|
||||
- KubeSail or PiBox: install/kubesail.md
|
||||
- Manual: install/manual.md
|
||||
- Other setups: install/other.md
|
||||
- Features:
|
||||
@@ -41,4 +45,4 @@ nav:
|
||||
- Permission System: system/permissions.md
|
||||
- Backup: system/backup.md
|
||||
- Contributing: contribute.md
|
||||
- FAQ: faq.md
|
||||
- FAQ: faq.md
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-11-04 14:56+0100\n"
|
||||
"POT-Creation-Date: 2021-11-08 16:27+0100\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user