Merge remote-tracking branch 'upstream/master' into recipe-serving-count

This commit is contained in:
tourn 2020-11-20 14:44:09 +01:00
commit 1e800889e4
82 changed files with 11714 additions and 1398 deletions

View File

@ -16,6 +16,17 @@ POSTGRES_USER=djangodb
POSTGRES_PASSWORD=
POSTGRES_DB=djangodb
# Users can set a amount of time after which the shopping list is refreshed when they are in viewing mode
# This is the minimum interval users can set. Setting this to low will allow users to refresh very frequently which
# might cause high load on the server. (Technically they can obviously refresh as often as they want with their own scripts)
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
# If staticfiles are stored at a different location uncomment and change accordingly
# STATIC_URL=/static/
# If mediafiles are stored at a different location uncomment and change accordingly
# MEDIA_URL=/media/
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
# provided that include an additional nxginx container to handle media file serving.
# If you know what you are doing turn this back on (1) to serve media files using djangos serve() method.

11
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"

View File

@ -11,7 +11,7 @@ jobs:
name: Build image job
steps:
- name: Checkout master
uses: actions/checkout@master#
uses: actions/checkout@master
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}

View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
</state>
</component>

View File

@ -1,6 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="vabene1111-PC">
<words>
<w>autosync</w>
<w>csrftoken</w>
<w>gunicorn</w>
<w>ical</w>

View File

@ -1,16 +1,17 @@
# Recipes ![CI](https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop)
Recipes is a Django application to manage, tag and search recipes using either built in models or external storage providers hosting PDF's, Images or other files.
![Preview](docs/preview.png)
[More Screenshots](https://imgur.com/a/V01151p)
### Features
## Features
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
- :mag: Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- :label: Create and search for **tags**, assign them in batch to all files matching certain filters
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
- :arrow_down: **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
- :iphone: Optimized for use on **mobile** devices like phones and tablets
- :shopping_cart: Generate **shopping** lists from recipes
@ -21,30 +22,29 @@ Recipes is a Django application to manage, tag and search recipes using either b
- :envelope: Export and import recipes from other users
- :heavy_plus_sign: Many more like recipe scaling, image compression, cookbooks, printing views, ...
This application is meant for people with a collection of recipes they want to share with family and friends or simply
This application is meant for people with a collection of recipes they want to share with family and friends or simply
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
Some Documentation can be found [here](https://github.com/vabene1111/recipes/wiki)
# Installation
## Installation
The docker image (`vabene1111/recipes`) simply exposes the application on port `8080`. You may choose any preferred installation method, the following are just examples to make it easier.
### Docker-Compose
2. Choose one of the included configurations [here](docs/docker).
2. Download the environment (config) file template and fill it out `wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env `
1. Choose one of the included configurations [here](docs/docker).
2. Download the environment (config) file template and fill it out `wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env`
3. Start the container `docker-compose up -d`
4. Open the page to create the first user. Alternatively use `docker-compose exec web_recipes createsuperuser`
4. Open the page to create the first user. Alternatively use `docker-compose exec web_recipes createsuperuser`
### Manual
**Python >= 3.8** is required to run this!
Copy `.env.template` to `.env` and fill in the missing values accordingly.
Make sure all variables are available to whatever serves your application.
**Python >= 3.8** is required to run this!
Otherwise simply follow the instructions for any django based deployment
(for example [this one](http://uwsgi-docs.readthedocs.io/en/latest/tutorials/Django_and_nginx.html)).
Refer to [manual install](docs/manual_install) for detailled instructions.
## Updating
While intermediate updates can be skipped when updating please make sure to **read the release notes** in case some special action is required to update.
0. Before updating it is recommended to **create a backup!**
@ -57,25 +57,27 @@ While intermediate updates can be skipped when updating please make sure to **re
You can find a basic kubernetes setup [here](docs/k8s/). Please see the README in the folder for more detail.
## Contributing
Pull Requests and ideas are welcome, feel free to contribute in any way.
For any questions on how to work with django please refer to their excellent [documentation](https://www.djangoproject.com/start/).
### Translating
There is a [transifex project](https://www.transifex.com/django-recipes/django-cookbook/) project to enable community driven translations. If you want to contribute a new language or help maintain an already existing one feel free to create a transifex account (using the link above) and request to join the project.
It is also possible to provide the translations directly by creating a new language using `manage.py makemessages -l <language_code> -i venv`. Once finished simply open a PR with the changed files.
## License
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with an
[common clause](https://commonsclause.com/) selling exception. See [LICENSE.md](https://github.com/vabene1111/recipes/blob/develop/LICENSE.md) for details.
**Reasoning**
**This software and *all* its features are and will always be free for everyone to use and enjoy.**
#### This software and **all** its features are and will always be free for everyone to use and enjoy.
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
A payed hosted version which will be identical in features and code base to the software offered in this repository will
likely be released in the future (including all features needed to sell a hosted version as they might also be useful for personal use).
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
allow me to spend more time developing and improving the software for everyone. Selling exceptions are [approved by Richard Stallman](http://www.gnu.org/philosophy/selling-exceptions.en.html) and the
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).

View File

@ -2,6 +2,13 @@ from django.contrib import admin
from .models import *
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'message')
admin.site.register(Space, SpaceAdmin)
class UserPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style', 'comments')
@ -125,6 +132,13 @@ class ViewLogAdmin(admin.ModelAdmin):
admin.site.register(ViewLog, ViewLogAdmin)
class InviteLinkAdmin(admin.ModelAdmin):
list_display = ('username', 'group', 'valid_until', 'created_by', 'created_at', 'used_by')
admin.site.register(InviteLink, InviteLinkAdmin)
class CookLogAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
@ -132,6 +146,27 @@ class CookLogAdmin(admin.ModelAdmin):
admin.site.register(CookLog, CookLogAdmin)
class ShoppingListRecipeAdmin(admin.ModelAdmin):
list_display = ('id', 'recipe', 'multiplier')
admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
class ShoppingListEntryAdmin(admin.ModelAdmin):
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
class ShoppingListAdmin(admin.ModelAdmin):
list_display = ('id', 'created_by', 'created_at')
admin.site.register(ShoppingList, ShoppingListAdmin)
class ShareLinkAdmin(admin.ModelAdmin):
list_display = ('recipe', 'created_by', 'uuid', 'created_at',)

View File

@ -2,7 +2,7 @@ import django_filters
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Q
from cookbook.forms import MultiSelectWidget
from cookbook.models import Recipe, Keyword, Food
from cookbook.models import Recipe, Keyword, Food, ShoppingList
from django.conf import settings
from django.utils.translation import gettext as _
@ -52,3 +52,16 @@ class IngredientFilter(django_filters.FilterSet):
class Meta:
model = Food
fields = ['name']
class ShoppingListFilter(django_filters.FilterSet):
def __init__(self, data=None, *args, **kwargs):
if data is not None:
data = data.copy()
data.setdefault("finished", False)
super(ShoppingListFilter, self).__init__(data, *args, **kwargs)
class Meta:
model = ShoppingList
fields = ['finished']

View File

@ -31,7 +31,7 @@ class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'comments')
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'shopping_auto_sync', 'comments')
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
@ -39,7 +39,10 @@ class UserPreferenceForm(forms.ModelForm):
'plan_share': _('Default user to share newly created meal plan entries with.'),
'show_recent': _('Show recently viewed recipes on search page.'),
'ingredient_decimals': _('Number of decimals to round ingredients.'),
'comments': _('If you want to be able to create and see comments underneath recipes.')
'comments': _('If you want to be able to create and see comments underneath recipes.'),
'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
'of mobile data. If lower than instance limit it is reset when saving.')
}
widgets = {
@ -262,17 +265,27 @@ class MealPlanForm(forms.ModelForm):
class Meta:
model = MealPlan
fields = ('recipe', 'title', 'meal_type', 'note', 'date', 'shared')
fields = ('recipe', 'title', 'meal_type', 'note', 'recipe_multiplier', 'date', 'shared')
help_texts = {
'shared': _('You can list default users to share recipes with in the settings.'),
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>'),
'recipe_multiplier': _('Scaling factor for recipe.')
}
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget}
class SuperUserForm(forms.Form):
name = forms.CharField()
class InviteLinkForm(forms.ModelForm):
class Meta:
model = InviteLink
fields = ('username', 'group', 'valid_until')
help_texts = {
'username': _('A username is not required, if left blank the new user can choose one.')
}
class UserCreateForm(forms.Form):
name = forms.CharField(label='Username')
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))

View File

@ -67,9 +67,28 @@ def is_object_owner(user, obj):
return owner == user
if owner := getattr(obj, 'user', None):
return owner == user
if getattr(obj, 'get_owner', None):
return obj.get_owner() == user
return False
def is_object_shared(user, obj):
"""
Tests if a given user is shared for a given object
test performed by checking user against the objects shared table
superusers bypass all checks, unauthenticated users cannot own anything
:param user django auth user object
:param obj any object that should be tested
:return: true if user is shared for object, false otherwise
"""
# TODO this could be improved/cleaned up by adding share checks for relevant objects
if not user.is_authenticated:
return False
if user.is_superuser:
return True
return user in obj.shared.all()
def share_link_valid(recipe, share):
"""
Verifies the validity of a share uuid
@ -145,6 +164,20 @@ class CustomIsOwner(permissions.BasePermission):
return is_object_owner(request.user, obj)
class CustomIsShared(permissions.BasePermission): # TODO function duplicate/too similar name
"""
Custom permission class for django rest framework views
verifies user is shared for the object he is trying to access
"""
message = _('You cannot interact with this object as its not owned by you!')
def has_permission(self, request, view):
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
return is_object_shared(request.user, obj)
class CustomIsGuest(permissions.BasePermission):
"""
Custom permission class for django rest framework views

View File

@ -18,7 +18,7 @@ def get_from_html(html_text, url):
# first try finding ld+json as its most common
for ld in soup.find_all('script', type='application/ld+json'):
try:
ld_json = json.loads(ld.string)
ld_json = json.loads(ld.string.replace('\n', ''))
if type(ld_json) != list:
ld_json = [ld_json]
@ -31,8 +31,8 @@ def get_from_html(html_text, url):
if '@type' in ld_json_item and ld_json_item['@type'] == 'Recipe':
return find_recipe_json(ld_json_item, url)
except JSONDecodeError:
JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400)
except JSONDecodeError as e:
return JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400)
# now try to find microdata
items = microdata.get_items(html_text)

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

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.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,49 @@
# Generated by Django 3.0.7 on 2020-08-11 10:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0074_remove_keyword_created_by'),
]
operations = [
migrations.CreateModel(
name='ShoppingListRecipe',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('multiplier', models.IntegerField(default=1)),
('recipe', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
],
),
migrations.CreateModel(
name='ShoppingListEntry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.IntegerField(default=1)),
('order', models.IntegerField(default=0)),
('checked', models.BooleanField(default=False)),
('food', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Food')),
('list_recipe', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ShoppingListRecipe')),
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Unit')),
],
),
migrations.CreateModel(
name='ShoppingList',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4)),
('note', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('recipes', models.ManyToManyField(blank=True, to='cookbook.ShoppingListRecipe')),
('shared', models.ManyToManyField(blank=True, related_name='list_share', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-08-26 18:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0075_shoppinglist_shoppinglistentry_shoppinglistrecipe'),
]
operations = [
migrations.AddField(
model_name='shoppinglist',
name='entries',
field=models.ManyToManyField(blank=True, to='cookbook.ShoppingListEntry'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.7 on 2020-09-01 11:31
import datetime
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0076_shoppinglist_entries'),
]
operations = [
migrations.CreateModel(
name='InviteLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.UUIDField(default=uuid.uuid4)),
('username', models.CharField(blank=True, max_length=64)),
('valid_until', models.DateField(default=datetime.date(2020, 9, 15))),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-09-01 11:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('cookbook', '0077_invitelink'),
]
operations = [
migrations.AddField(
model_name='invitelink',
name='used_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-09-01 12:54
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('auth', '0011_update_proxy_permissions'),
('cookbook', '0078_invitelink_used_by'),
]
operations = [
migrations.AddField(
model_name='invitelink',
name='group',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.Group'),
preserve_default=False,
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-09-21 21:31
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0079_invitelink_group'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='shopping_auto_sync',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='invitelink',
name='valid_until',
field=models.DateField(default=datetime.date(2020, 10, 5)),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-09-21 21:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0080_auto_20200921_2331'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='shopping_auto_sync',
field=models.IntegerField(default=5),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-09-22 09:43
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0081_auto_20200921_2349'),
]
operations = [
migrations.AlterField(
model_name='invitelink',
name='valid_until',
field=models.DateField(default=datetime.date(2020, 10, 6)),
),
migrations.AlterField(
model_name='shoppinglistentry',
name='amount',
field=models.DecimalField(decimal_places=16, default=0, max_digits=32),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-09-22 10:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0082_auto_20200922_1143'),
]
operations = [
migrations.CreateModel(
name='Space',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default', max_length=128)),
('message', models.CharField(default='', max_length=512)),
],
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-09-22 10:33
from django.db import migrations
def create_default_space(apps, schema_editor):
Space = apps.get_model('cookbook', 'Space')
Space.objects.create(
name='Default',
message=''
)
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0083_space'),
]
operations = [
migrations.RunPython(create_default_space),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-09-22 10:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0084_auto_20200922_1233'),
]
operations = [
migrations.AlterField(
model_name='space',
name='message',
field=models.CharField(blank=True, default='', max_length=512),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-09-29 09:43
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0085_auto_20200922_1235'),
]
operations = [
migrations.AddField(
model_name='mealplan',
name='recipe_multiplier',
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name='invitelink',
name='valid_until',
field=models.DateField(default=datetime.date(2020, 10, 13)),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.7 on 2020-09-29 09:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0086_auto_20200929_1143'),
]
operations = [
migrations.AlterField(
model_name='mealplan',
name='recipe_multiplier',
field=models.DecimalField(decimal_places=4, default=1, max_digits=8),
),
migrations.AlterField(
model_name='shoppinglistrecipe',
name='multiplier',
field=models.DecimalField(decimal_places=4, default=1, max_digits=8),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.1.1 on 2020-09-29 11:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0087_auto_20200929_1152'),
]
operations = [
migrations.AddField(
model_name='shoppinglist',
name='finished',
field=models.BooleanField(default=False),
),
]

View File

@ -1,10 +1,13 @@
import re
import uuid
from datetime import date, timedelta
from annoying.fields import AutoOneToOneField
from django.contrib import auth
from django.contrib.auth.models import User
from django.contrib.auth.models import User, Group
from django.utils.translation import gettext as _
from django.db import models
from django_random_queryset import RandomManager
from recipes.settings import COMMENT_PREF_DEFAULT
@ -23,6 +26,11 @@ def get_model_name(model):
return ('_'.join(re.findall('[A-Z][^A-Z]*', model.__name__))).lower()
class Space(models.Model):
name = models.CharField(max_length=128, default='Default')
message = models.CharField(max_length=512, default='', blank=True)
class UserPreference(models.Model):
# Themes
BOOTSTRAP = 'BOOTSTRAP'
@ -67,6 +75,7 @@ class UserPreference(models.Model):
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
ingredient_decimals = models.IntegerField(default=2)
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
shopping_auto_sync = models.IntegerField(default=5)
def __str__(self):
return str(self.user)
@ -191,6 +200,8 @@ class Recipe(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = RandomManager()
def __str__(self):
return self.name
@ -247,6 +258,7 @@ class MealType(models.Model):
class MealPlan(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
recipe_multiplier = models.DecimalField(default=1, max_digits=8, decimal_places=4)
title = models.CharField(max_length=64, blank=True, default='')
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
@ -266,12 +278,74 @@ class MealPlan(models.Model):
return f'{self.get_label()} - {self.date} - {self.meal_type.name}'
class ShoppingListRecipe(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
multiplier = models.DecimalField(default=1, max_digits=8, decimal_places=4)
def __str__(self):
return f'Shopping list recipe {self.id} - {self.recipe}'
def get_owner(self):
try:
return self.shoppinglist_set.first().created_by
except AttributeError:
return None
class ShoppingListEntry(models.Model):
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
food = models.ForeignKey(Food, on_delete=models.CASCADE)
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
order = models.IntegerField(default=0)
checked = models.BooleanField(default=False)
def __str__(self):
return f'Shopping list entry {self.id}'
def get_owner(self):
try:
return self.shoppinglist_set.first().created_by
except AttributeError:
return None
class ShoppingList(models.Model):
uuid = models.UUIDField(default=uuid.uuid4)
note = models.TextField(blank=True, null=True)
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
finished = models.BooleanField(default=False)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'Shopping list {self.id}'
class ShareLink(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
uuid = models.UUIDField(default=uuid.uuid4)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.recipe} - {self.uuid}'
class InviteLink(models.Model):
uuid = models.UUIDField(default=uuid.uuid4)
username = models.CharField(blank=True, max_length=64)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
valid_until = models.DateField(default=date.today() + timedelta(days=14))
used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by')
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.uuid}'
class CookLog(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)

View File

@ -1,10 +1,12 @@
from decimal import Decimal
from django.contrib.auth.models import User
from drf_writable_nested import WritableNestedModelSerializer, UniqueFieldsMixin
from rest_framework import serializers
from rest_framework.exceptions import APIException, ValidationError
from rest_framework.fields import CurrentUserDefault
from rest_framework.exceptions import ValidationError
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Unit, Ingredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog, Food, Step
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Unit, Ingredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog, Food, Step, ShoppingList, \
ShoppingListEntry, ShoppingListRecipe
from cookbook.templatetags.custom_tags import markdown
@ -14,19 +16,24 @@ class CustomDecimalField(serializers.Field):
"""
def to_representation(self, value):
return value.normalize()
if isinstance(value, Decimal):
return value.normalize()
else:
return Decimal(value).normalize()
def to_internal_value(self, data):
if type(data) == int or type(data) == float:
return data
elif type(data) == str:
if data == '':
return 0
try:
return float(data.replace(',', ''))
except ValueError:
raise ValidationError('A valid number is required')
class UserNameSerializer(serializers.ModelSerializer):
class UserNameSerializer(WritableNestedModelSerializer):
username = serializers.SerializerMethodField('get_user_label')
def get_user_label(self, obj):
@ -184,13 +191,61 @@ class MealPlanSerializer(serializers.ModelSerializer):
recipe_name = serializers.ReadOnlyField(source='recipe.name')
meal_type_name = serializers.ReadOnlyField(source='meal_type.name')
note_markdown = serializers.SerializerMethodField('get_note_markdown')
recipe_multiplier = CustomDecimalField()
def get_note_markdown(self, obj):
return markdown(obj.note)
class Meta:
model = MealPlan
fields = ('id', 'title', 'recipe', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name')
fields = ('id', 'title', 'recipe', 'recipe_multiplier', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name')
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
recipe_name = serializers.ReadOnlyField(source='recipe.name')
multiplier = CustomDecimalField()
class Meta:
model = ShoppingListRecipe
fields = ('id', 'recipe', 'recipe_name', 'multiplier')
read_only_fields = ('id',)
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
food = FoodSerializer(allow_null=True)
unit = UnitSerializer(allow_null=True)
amount = CustomDecimalField()
class Meta:
model = ShoppingListEntry
fields = ('id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked')
read_only_fields = ('id',)
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
class Meta:
model = ShoppingListEntry
fields = ('id', 'checked')
class ShoppingListSerializer(WritableNestedModelSerializer):
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
shared = UserNameSerializer(many=True)
class Meta:
model = ShoppingList
fields = ('id', 'uuid', 'note', 'recipes', 'entries', 'shared', 'finished', 'created_by', 'created_at',)
read_only_fields = ('id',)
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
class Meta:
model = ShoppingList
fields = ('id', 'entries',)
read_only_fields = ('id',)
class ShareLinkSerializer(serializers.ModelSerializer):

View File

@ -108,6 +108,25 @@ class RecipeImportTable(tables.Table):
fields = ('id', 'name', 'file_path')
class ShoppingListTable(tables.Table):
id = tables.LinkColumn('view_shopping', args=[A('id')])
class Meta:
model = ShoppingList
template_name = 'generic/table_template.html'
fields = ('id', 'finished', 'created_by', 'created_at')
class InviteLinkTable(tables.Table):
link = tables.TemplateColumn("<a href='{% url 'view_signup' record.uuid %}' >" + _('Link') + "</a>")
delete = tables.TemplateColumn("<a href='{% url 'delete_invite_link' record.id %}' >" + _('Delete') + "</a>")
class Meta:
model = InviteLink
template_name = 'generic/table_template.html'
fields = ('username', 'group', 'valid_until', 'created_by', 'created_at')
class ViewLogTable(tables.Table):
recipe = tables.LinkColumn('view_recipe', args=[A('recipe_id')])

View File

@ -1,6 +1,7 @@
{% load static %}
{% load i18n %}
{% load theming_tags %}
{% load custom_tags %}
<html>
<head>
@ -72,7 +73,7 @@
<a class="dropdown-item" href="{% url 'view_plan' %}"><i
class="fas fa-calendar fa-fw"></i> {% trans 'Meal-Plan' %}
</a>
<a class="dropdown-item" href="{% url 'view_shopping' %}"><i
<a class="dropdown-item" href="{% url 'list_shopping_list' %}"><i
class="fas fa-shopping-cart fa-fw"></i> {% trans 'Shopping' %}
</a>
<a class="dropdown-item" href="{% url 'list_food' %}"><i
@ -158,6 +159,14 @@
</ul>
</div>
</nav>
{% message_of_the_day as message_of_the_day %}
{% if message_of_the_day %}
<div class="bg-warning" style=" width: 100%; text-align: center!important; color: #ffffff; padding: 8px">
{{ message_of_the_day }}
</div>
{% endif %}
<br/>
<br/>

View File

@ -321,7 +321,7 @@
<div class="row">
<div class="col-md-12">
<label :for="'id_instruction_' + step.id">{% trans 'Instructions' %}</label>
<b-form-textarea class="form-control" rows="2" max-rows="8" v-model="step.instruction"
<b-form-textarea class="form-control" rows="2" max-rows="20" v-model="step.instruction"
:id="'id_instruction_' + step.id"></b-form-textarea>
<small class="text-muted">{% trans 'You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>' %}</small>
</div>
@ -330,7 +330,7 @@
</div>
</draggable>
<div class="row" style="margin-top: 1vh; margin-bottom: 2vh">
<div class="row" style="margin-top: 1vh; margin-bottom: 8vh">
<div class="col-12">
<button type="button" @click="updateRecipe(true)"
class="btn btn-success shadow-none">{% trans 'Save & View' %}</button>
@ -597,7 +597,7 @@
let new_unit = this.recipe.steps[step].ingredients[id]
new_unit.unit = {'name': tag}
this.foods.push(new_unit.unit)
this.units.push(new_unit.unit)
this.recipe.steps[step].ingredients[id] = new_unit
},
searchKeywords: function (query) {

View File

@ -9,7 +9,7 @@
{% block content %}
<div class="table-container">
<h3>{{ title }} {% trans 'List' %}
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
{% if create_url %}
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
</a>

View File

@ -91,7 +91,7 @@
{% render_table recipes %}
{% else %}
<div class="alert alert-danger" role="alert">
{% trans "Log in to view Recipies" %}
{% trans "Log in to view Recipes" %}
</div>
{% endif %}

View File

@ -103,8 +103,15 @@
<div class="card-body">
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
placeholder="{% trans 'Search Recipe' %}" style="margin-bottom: 8px">
<div class="input-group mb-3">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
placeholder="{% trans 'Search Recipe' %}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" @click="getRandomRecipes">
<i class="fas fa-dice"></i>
</button>
</div>
</div>
</div>
</div>
<draggable class="list-group" :list="recipes"
@ -126,6 +133,9 @@
class="text-muted">{% trans 'You can use markdown to format this field. See the <a href="/docs/markdown/" target="_blank" rel="noopener noreferrer">docs here</a>' %}</span></small>
<br/>
<br/>
<input type="number" class="form-control" v-model="new_note_multiplier"
placeholder="{% trans 'Recipe Multiplier' %}" style="margin-bottom: 8px">
<br/>
<draggable :list="pseudo_note_list"
:group="{ name: 'plan', pull: 'clone', put: false }" :clone="cloneNote">
<div class="list-group-item" v-for="(element, index) in pseudo_note_list"
@ -349,6 +359,7 @@
],
new_note_title: '',
new_note_text: '',
new_note_multiplier: '',
default_shared_users: [],
user_id_update: [],
user_names: {},
@ -367,7 +378,7 @@
this.getRecipes();
},
methods: {
makeToast: function(title, message, variant=null) {
makeToast: function (title, message, variant = null) {
//TODO remove duplicate function in favor of central one
this.$bvToast.toast(message, {
title: title,
@ -389,7 +400,7 @@
this.plan_entries = response.data;
}).catch((err) => {
console.log("getPlanEntries error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
getPlanTypes: function () {
@ -401,7 +412,7 @@
}
}).catch((err) => {
console.log("getPlanTypes error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
buildGrid: function () {
@ -441,17 +452,23 @@
this.updateUserNames()
},
getRandomRecipes: function () {
this.$set(this, 'recipe_query', '');
this.getRecipes();
},
getRecipes: function () {
let url = "{% url 'api:recipe-list' %}?limit=5"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
} else {
url += '&random=True'
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
getMdNote: function () {
@ -464,18 +481,18 @@
this.recipes = response.data;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
updateUserNames: function () {
return this.$http.get("{% url 'api:username-list' %}?filter_list=[" + this.user_id_update + ']').then((response) => {
for (let u of response.data) {
this.$set(this.user_names, u.id, u.username);
this.$set(this.user_names, u.id, u.username);
}
}).catch((err) => {
console.log("updateUserNames error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
dragChanged: function (date, meal_type, evt) {
@ -501,7 +518,7 @@
this.$http.put(`{% url 'api:mealplan-list' %}${plan_entry.id}/`, plan_entry).then((response) => {
}).catch((err) => {
console.log("dragChanged update error", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
}
}
@ -512,7 +529,7 @@
this.meal_plan[entry.meal_type_name].days[entry.date].items = this.meal_plan[entry.meal_type_name].days[entry.date].items.filter(item => item !== entry)
}).catch((err) => {
console.log("deleteEntry error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
updatePlanTypes: function () {
@ -526,14 +543,14 @@
promise_list.push(this.$http.post("{% url 'api:mealtype-list' %}", x).then((response) => {
}).catch((err) => {
console.log("updatePlanTypes create error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
}))
} else if (x.delete) {
if (x.id !== undefined) {
promise_list.push(this.$http.delete(`{% url 'api:mealtype-list' %}${x.id}/`, x).then((response) => {
}).catch((err) => {
console.log("updatePlanTypes delete error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
}))
}
} else {
@ -541,7 +558,7 @@
}).catch((err) => {
console.log("updatePlanTypes update error: ", err);
this.makeToast('{% trans 'Error' %}','{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
}))
}
}
@ -556,20 +573,28 @@
}
},
cloneRecipe: function (recipe) {
return {
let r = {
id: Math.round(Math.random() * 1000) + 10000,
recipe: recipe.id,
recipe_name: recipe.name,
recipe_multiplier: (this.new_note_multiplier > 1) ? this.new_note_multiplier : 1,
title: this.new_note_title,
note: this.new_note_text,
is_new: true
}
this.new_note_title = ''
this.new_note_text = ''
this.new_note_multiplier = ''
return r
},
cloneNote: function () {
let new_entry = {
id: Math.round(Math.random() * 1000) + 10000,
title: this.new_note_title,
note: this.new_note_text,
recipe_multiplier: 1,
is_new: true,
}
@ -579,6 +604,7 @@
this.new_note_title = ''
this.new_note_text = ''
this.new_note_multiplier = ''
return new_entry
},
planElementName: function (element) {
@ -618,10 +644,10 @@
let first = true
for (let se of this.shopping_list) {
if (first) {
url += `?r=${se.recipe}`
url += `?r=[${se.recipe},${se.recipe_multiplier}]`
first = false
} else {
url += `&r=${se.recipe}`
url += `&r=[${se.recipe},${se.recipe_multiplier}]`
}
}
return url

View File

@ -36,10 +36,10 @@
class="fas fa-pencil-alt fa-fw"></i> {% trans 'Edit' %}</a>
<button class="dropdown-item" onclick="$('#bookmarkModal').modal({'show':true})">
<i class="fas fa-bookmark fa-fw"></i> {% trans 'Add to Book' %}</button>
{% if ingredients %}
<a class="dropdown-item" href="{% url 'view_shopping' %}?r={{ recipe.pk }}">
<i class="fas fa-shopping-cart fa-fw"></i> {% trans 'Add to Shopping' %}</a>
{% endif %}
<a class="dropdown-item" v-bind:href="getShoppingUrl()" v-if="has_ingredients">
<i class="fas fa-shopping-cart fa-fw"></i> {% trans 'Add to Shopping' %}</a>
<a class="dropdown-item" href="{% url 'new_meal_plan' %}?recipe={{ recipe.pk }}"><i
class="fas fa-calendar fa-fw"></i> {% trans 'Add to Plan' %}</a>
<button class="dropdown-item" onclick="openCookLogModal({{ recipe.pk }})"><i
@ -95,8 +95,9 @@
<br/>
{% endif %}
<div class="row" v-if="recipe && has_ingredients"> <!-- TODO duplicate code remove -->
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2">
<div class="row">
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && has_ingredients">
<!-- TODO duplicate code remove -->
<div class="card border-primary">
<div class="card-body">
<div class="row">
@ -148,9 +149,12 @@
<template v-if="i.no_amount">
<span>&#x2063;</span>
</template>
<template v-if="!i.no_amount && i.unit">
<template v-if="!i.no_amount">
<span>[[roundDecimals(i.amount * ingredient_factor)]]</span>
[[i.unit.name]]
{# Allow for amounts without units, such as "2 eggs" #}
<template v-if="i.unit">
[[i.unit.name]]
</template>
</template>
</label>
</div>
@ -168,7 +172,9 @@
</td>
<td style="vertical-align: middle!important;">
<template v-if="i.note">
<b-button v-b-popover.hover="i.note" class="btn btn-sm d-print-none"><i class="fas fa-info"></i></b-button>
<b-button v-b-popover.hover="i.note"
class="btn btn-sm d-print-none"><i
class="fas fa-info"></i></b-button>
<div class="d-none d-print-block">
<i class="far fa-comment-alt"></i> [[i.note]]
@ -240,7 +246,8 @@
style="margin-top: 1vh">
<div class="col-md-6">
<table class="table table-sm">
<template v-for="i in recipe.steps[{{ forloop.counter0 }}].ingredients"> <!-- TODO duplicate code remove -->
<template v-for="i in recipe.steps[{{ forloop.counter0 }}].ingredients">
<!-- TODO duplicate code remove -->
<template v-if="i.is_header">
<tr>
@ -263,9 +270,12 @@
<template v-if="i.no_amount">
<span>&#x2063;</span>
</template>
<template v-if="!i.no_amount && i.unit">
<template v-if="!i.no_amount">
<span>[[roundDecimals(i.amount * ingredient_factor)]]</span>
[[i.unit.name]]
{# Allow for amounts without units, such as "2 eggs" #}
<template v-if="i.unit">
[[i.unit.name]]
</template>
</template>
</label>
</div>
@ -283,7 +293,9 @@
</td>
<td style="vertical-align: middle!important;">
<template v-if="i.note">
<b-button v-b-popover.hover="i.note" class="btn btn-sm d-print-none"><i class="fas fa-info"></i></b-button>
<b-button v-b-popover.hover="i.note"
class="btn btn-sm d-print-none"><i
class="fas fa-info"></i></b-button>
<div class="d-none d-print-block">
<i class="far fa-comment-alt"></i> [[i.note]]
</div>
@ -514,6 +526,9 @@
time_diff += s.time
}
},
getShoppingUrl: function () {
return `{% url 'view_shopping' %}?r=[${this.recipe.id},${this.ingredient_factor}]`
}
}
});

View File

@ -1,6 +1,8 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans 'Login' %}{% endblock %}
{% block content %}
{% if form.errors %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block title %}{% trans 'Register' %}{% endblock %}
{% block content %}
<h3>{% trans 'Create your Account' %}</h3>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Create User' %}
</button>
</form>
{% endblock %}

View File

@ -4,61 +4,700 @@
{% load static %}
{% load i18n %}
{% block title %}{% trans "Cookbook" %}{% endblock %}
{% block title %}{% trans "Shopping List" %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% include 'include/vue_base.html' %}
<link rel="stylesheet" href="{% static 'css/vue-multiselect-bs4.min.css' %}">
<script src="{% static 'js/vue-multiselect.min.js' %}"></script>
<script src="{% static 'js/Sortable.min.js' %}"></script>
<script src="{% static 'js/vuedraggable.umd.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/pretty-checkbox.min.css' %}">
{% endblock %}
{% block content %}
<h2><i class="fas fa-shopping-cart"></i> {% trans 'Shopping List' %}</h2>
<form action="{% url 'view_shopping' %}" method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-sync-alt"></i> {% trans 'Load' %}</button>
</form>
<br/>
<br/>
<div class="row">
<div class="col col-md-12">
<!--// @formatter:off-->
<textarea id="id_list" class="form-control" rows="{{ ingredients|length|add:1 }}">{% for i in ingredients %}{% if markdown_format %}- [ ] {% endif %}{{ i.amount.normalize }} {{ i.unit }} {{ i.food.name }}&#13;&#10;{% endfor %}</textarea>
<!--// @formatter:on-->
<div class="col col-md-9">
<h2>{% trans 'Shopping List' %}</h2>
</div>
</div>
<br/>
<div class="row">
<div class="col col-md-12 text-center">
<button class="btn btn-success" onclick="copy()" style="width: 15vw" data-toggle="tooltip"
data-placement="right" title="{% trans 'Copy list to clipboard' %}" id="id_btn_copy" onmouseout="resetTooltip()"><i
class="far fa-copy"></i></button>
<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>
</div>
</div>
<script type="text/javascript">
function copy() {
let list = $('#id_list');
<template v-if="shopping_list !== undefined">
list.select();
<div class="text-center" v-if="loading">
<i class="fas fa-spinner fa-spin fa-8x"></i>
</div>
<div v-else-if="edit_mode">
<div class="row">
<div class="col col-md-6">
$('#id_btn_copy').attr('data-original-title','{% trans 'Copied!' %}').tooltip('show');
<div class="card">
<div class="card-header">
<i class="fa fa-search"></i> {% trans 'Search' %}
</div>
<div class="card-body">
<input type="text" class="form-control" v-model="recipe_query" @keyup="getRecipes"
placeholder="{% trans 'Search Recipe' %}">
<ul class="list-group" style="margin-top: 8px">
<li class="list-group-item" v-for="x in recipes">
<div class="row flex-row" style="padding-left: 0.5vw; padding-right: 0.5vw">
<div class="flex-column flex-fill my-auto"><a v-bind:href="getRecipeUrl(x.id)"
target="_blank"
rel="nofollow norefferer">[[x.name]]</a>
</div>
<div class="flex-column align-self-end">
<button class="btn btn-outline-primary shadow-none"
@click="addRecipeToList(x)"><i
class="fa fa-plus"></i></button>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
document.execCommand("copy");
}
<div class="col col-md-6">
<div class="card">
<div class="card-header">
<i class="fa fa-shopping-cart"></i> {% trans 'Shopping Recipes' %}
</div>
<div class="card-body">
<template v-if="shopping_list.recipes.length < 1">
{% trans 'No recipes selected' %}
</template>
<template v-else>
<div class="row flex-row my-auto" v-for="x in shopping_list.recipes"
style="margin-top: 1vh!important;">
<div class="flex-column align-self-start " style="margin-right: 0.4vw">
<button class="btn btn-outline-danger" @click="removeRecipeFromList(x)"><i
class="fa fa-trash"></i></button>
</div>
<div class="flex-grow-1 flex-column my-auto"><a v-bind:href="getRecipeUrl(x.recipe)"
target="_blank"
rel="nofollow norefferer">[[x.recipe_name]]</a>
</div>
<div class="flex-column align-self-end ">
<div class="input-group input-group-sm my-auto">
<div class="input-group-prepend">
<button class="text-muted btn btn-outline-primary shadow-none"
@click="((x.multiplier - 1) > 0) ? x.multiplier -= 1 : 1">-
</button>
</div>
<input class="form-control" type="number" v-model="x.multiplier">
<div class="input-group-append">
<button class="text-muted btn btn-outline-primary shadow-none"
@click="x.multiplier += 1">
+
</button>
</div>
</div>
</div>
function resetTooltip() {
setTimeout(function () {
$('#id_btn_copy').attr('data-original-title','{% trans 'Copy list to clipboard' %}');
}, 300);
}
</div>
</template>
</div>
</div>
</div>
</div>
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
<table class="table table-sm table-striped" style="margin-top: 1vh">
<tbody is="draggable" group="people" :list="display_entries" tag="tbody" :empty-insert-threshold="10"
handle=".handle" @sort="sortEntries()">
<tr v-for="(element, index) in display_entries" :key="element.id">
<!--<td class="handle"><i class="fas fa-sort"></i></td>-->
<td>[[element.amount]]</td>
<td>[[element.unit.name]]</td>
<td>[[element.food.name]]</td>
<td>
<button class="btn btn-sm btn-outline-danger" v-if="element.list_recipe === null"
@click="shopping_list.entries = shopping_list.entries.filter(item => item.id !== element.id)">
<i class="fa fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
<div class="row">
<div class="col col-md-3">
<input class="form-control" type="number" placeholder="{% trans 'Amount' %}"
v-model="new_entry.amount" ref="new_entry_amount">
</div>
<div class="col col-md-4">
<multiselect
v-tabindex
ref="unit"
v-model="new_entry.unit"
:options="units"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select Unit' %}"
tag-placeholder="{% trans 'Create' %}"
select-label="{% trans 'Select' %}"
:taggable="true"
@tag="addUnitType"
label="name"
track-by="name"
:multiple="false"
:loading="units_loading"
@search-change="searchUnits">
</multiselect>
</div>
<div class="col col-md-4">
<multiselect
v-tabindex
ref="food"
v-model="new_entry.food"
:options="foods"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select Food' %}"
tag-placeholder="{% trans 'Create' %}"
select-label="{% trans 'Select' %}"
:taggable="true"
@tag="addFoodType"
label="name"
track-by="name"
:multiple="false"
:loading="foods_loading"
@search-change="searchFoods">
</multiselect>
</div>
<div class="col col-md-1 my-auto text-right">
<button class="btn btn-success btn-lg" @click="addEntry()"><i class="fa fa-plus"></i>
</button>
</div>
</div>
<div class="row">
<div class="col" style="text-align: right; margin-top: 1vh">
<div class="form-group form-check form-group-lg">
<input class="form-check-input" style="zoom:1.3;" type="checkbox"
v-model="shopping_list.finished" id="id_finished">
<label class="form-check-label" style="zoom:1.3;"
for="id_finished"> {% trans 'Finished' %}</label>
</div>
</div>
</div>
<div class="row">
<div class="col" style="margin-top: 1vh">
<multiselect
v-tabindex
v-model="shopping_list.shared"
:options="users"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
placeholder="{% trans 'Select User' %}"
select-label="{% trans 'Select' %}"
label="username"
track-by="id"
:multiple="true"
:loading="users_loading"
@search-change="searchUsers">
</multiselect>
</div>
</div>
</div>
<div v-else>
{% if request.user.userpreference.shopping_auto_sync > 0 %}
<div class="row" v-if="!onLine">
<div class="col col-md-12">
<div class="alert alert-warning" role="alert">
{% trans 'You are offline, shopping list might not sync.' %}
</div>
</div>
</div>
{% endif %}
<div class="row" style="margin-top: 8px">
<div class="col col-md-12">
<table class="table">
<tr v-for="x in display_entries">
<template v-if="!x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
</template>
</tr>
<tr v-for="x in display_entries" class="text-muted">
<template v-if="x.checked">
<td><input type="checkbox" style="zoom:1.4;" v-model="x.checked"
@change="entryChecked(x)">
</td>
<td>[[x.amount]]</td>
<td>[[x.unit.name]]</td>
<td>[[x.food.name]]</td>
</template>
</tr>
</table>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh">
<div class="col" style="text-align: right">
<b-button class="btn btn-info" v-b-modal.id_modal_export><i
class="fas fa-file-export"></i> {% trans 'Export' %}</b-button>
<button class="btn btn-success" @click="updateShoppingList()" v-if="edit_mode"><i
class="fas fa-save"></i> {% trans 'Save' %}
</button>
</div>
</div>
<b-modal id="id_modal_export" title="{% trans 'Copy/Export' %}">
<div class="row">
<div class="col col-12">
<label>
{% trans 'List Prefix' %}
<input class="form-control" v-model="export_text_prefix">
</label>
</div>
</div>
<div class="row">
<div class="col col-12">
<b-form-textarea class="form-control" max-rows="8" v-model="export_text">
</b-form-textarea>
</div>
</div>
</b-modal>
</template>
{% endblock %}
{% block script %}
<script type="application/javascript">
let csrftoken = Cookies.get('csrftoken');
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
Vue.component('vue-multiselect', window.VueMultiselect.default)
let app = new Vue({
components: {
Multiselect: window.VueMultiselect.default
},
delimiters: ['[[', ']]'],
el: '#id_base_container',
data: {
shopping_list_id: {% if shopping_list_id %}{{ shopping_list_id }}{% else %}null{% endif %},
loading: true,
edit_mode: false,
export_text_prefix: '', //TODO add userpreference
recipe_query: '',
recipes: [],
shopping_list: undefined,
new_entry: {
unit: undefined,
amount: undefined,
food: undefined,
},
foods: [],
foods_loading: false,
units: [],
units_loading: false,
users: [],
users_loading: false,
onLine: navigator.onLine,
},
directives: {
tabindex: {
inserted(el) {
el.setAttribute('tabindex', 0);
}
}
},
computed: {
multiplier_cache() {
let cache = {}
this.shopping_list.recipes.forEach((r) => {
cache[r.id] = !(Number.isNaN(r.multiplier)) ? parseFloat(r.multiplier) : 1
})
return cache
},
display_entries() {
let entries = []
//TODO merge multiple ingredients of same unit
this.shopping_list.entries.forEach(element => {
let item = {}
Object.assign(item, element);
if (item.list_recipe !== null) {
item.amount = item.amount * this.multiplier_cache[item.list_recipe]
}
item.unit = ((element.unit !== undefined && element.unit !== null) ? element.unit : {'name': ''})
entries.push(item)
});
return entries
},
export_text() {
let text = ''
for (let e of this.display_entries.filter(item => item.checked === false)) {
text += `${this.export_text_prefix}${e.amount} ${e.unit.name} ${e.food.name} \n`
}
return text
}
},
/*
watch: {
recipe: {
deep: true,
handler() {
this.recipe_changed = this.recipe_changed !== undefined;
}
}
},
created() {
window.addEventListener('beforeunload', this.warnPageLeave)
},
*/
mounted: function () {
this.loadShoppingList()
{% if recipes %}
this.loading = true
this.edit_mode = true
let loadingRecipes = []
{% for r in recipes %}
loadingRecipes.push(this.loadInitialRecipe({{ r.recipe }}, {{ r.multiplier }}))
{% endfor %}
Promise.allSettled(loadingRecipes).then(() => {
this.loading = false
})
{% endif %}
{% if request.user.userpreference.shopping_auto_sync > 0 %}
setInterval(() => {
if ((this.shopping_list_id !== null) && !this.edit_mode && window.navigator.onLine) {
this.loadShoppingList(true)
}
}, {% widthratio request.user.userpreference.shopping_auto_sync 1 1000 %})
window.addEventListener('online', this.updateOnlineStatus);
window.addEventListener('offline', this.updateOnlineStatus);
{% endif %}
this.searchUsers('')
},
methods: {
updateOnlineStatus(e) {
const {
type
} = e;
this.onLine = type === 'online';
},
/*
warnPageLeave: function (event) {
if (this.recipe_changed) {
event.returnValue = ''
return ''
}
},
*/
makeToast: function (title, message, variant = null) {
//TODO remove duplicate function in favor of central one
this.$bvToast.toast(message, {
title: title,
variant: variant,
toaster: 'b-toaster-top-center',
solid: true
})
},
loadInitialRecipe: function (recipe, multiplier) {
return this.$http.get('{% url 'api:recipe-detail' 123456 %}'.replace('123456', recipe)).then((response) => {
this.addRecipeToList(response.data, multiplier)
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
loadShoppingList: function (autosync = false) {
if (this.shopping_list_id) {
this.$http.get("{% url 'api:shoppinglist-detail' 123456 %}".replace('123456', this.shopping_list_id) + ((autosync) ? '?autosync=true' : '')).then((response) => {
if (!autosync) {
this.shopping_list = response.body
this.loading = false
} else {
let check_map = {}
for (let e of response.body.entries) {
check_map[e.id] = {checked: e.checked}
}
for (let se of this.shopping_list.entries) {
if (check_map[se.id] !== undefined) {
se.checked = check_map[se.id].checked
}
}
}
if (this.shopping_list.entries.length === 0) {
this.edit_mode = true
}
}).catch((err) => {
console.log(err)
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
} else {
this.shopping_list = {
"recipes": [],
"entries": [],
"entries_display": [],
"shared": [{% for u in request.user.userpreference.plan_share.all %}
{'id': {{ u.pk }}, 'username': '{{ u.get_user_name }}'},
{% endfor %}],
"created_by": 1
}
this.loading = false
if (this.shopping_list.entries.length === 0) {
this.edit_mode = true
}
}
},
updateShoppingList: function () {
this.loading = true
let recipe_promises = []
for (let i in this.shopping_list.recipes) {
if (this.shopping_list.recipes[i].created) {
console.log('updating recipe', this.shopping_list.recipes[i])
recipe_promises.push(this.$http.post("{% url 'api:shoppinglistrecipe-list' %}", this.shopping_list.recipes[i], {}).then((response) => {
let old_id = this.shopping_list.recipes[i].id
console.log("list recipe create respose ", response.body)
this.$set(this.shopping_list.recipes, i, response.body)
for (let e of this.shopping_list.entries.filter(item => item.list_recipe === old_id)) {
console.log("found recipe updating ID")
e.list_recipe = this.shopping_list.recipes[i].id
}
}).catch((err) => {
console.log(err)
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error updating a resource!' %}' + err.bodyText, 'danger')
}))
}
}
Promise.allSettled(recipe_promises).then(() => {
console.log("proceeding to update shopping list", this.shopping_list)
if (this.shopping_list_id === null) {
return this.$http.post("{% url 'api:shoppinglist-list' %}", this.shopping_list, {}).then((response) => {
console.log(response)
this.makeToast('{% trans 'Updated' %}', '{% trans 'Object created successfully!' %}', 'success')
this.loading = false
this.shopping_list = response.body
this.shopping_list_id = this.shopping_list.id
window.history.pushState('shopping_list', '{% trans 'Shopping List' %}', "{% url 'view_shopping' 123456 %}".replace('123456', this.shopping_list_id));
}).catch((err) => {
console.log(err)
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error creating a resource!' %}' + err.bodyText, 'danger')
this.loading = false
})
} else {
return this.$http.put("{% url 'api:shoppinglist-detail' shopping_list_id %}", this.shopping_list, {}).then((response) => {
console.log(response)
this.shopping_list = response.body
this.makeToast('{% trans 'Updated' %}', '{% trans 'Changes saved successfully!' %}', 'success')
this.loading = false
}).catch((err) => {
console.log(err)
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error updating a resource!' %}' + err.bodyText, 'danger')
this.loading = false
})
}
})
},
sortEntries: function () {
this.display_entries.forEach((item, index) => {
})
console.log("IMPLEMENT ME", this.display_entries)
},
entryChecked: function (entry) {
console.log("checked entry: ", entry)
this.shopping_list.entries.forEach((item) => {
if (item.id === entry.id) { //TODO unwrap once same entries are merged
item.checked = entry.checked
this.$http.put("{% url 'api:shoppinglistentry-detail' 123456 %}".replace('123456', item.id), item, {}).then((response) => {
}).catch((err) => {
console.log(err)
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error updating a resource!' %}' + err.bodyText, 'danger')
this.loading = false
})
}
})
},
addEntry: function () {
if (this.new_entry.food !== undefined) {
this.shopping_list.entries.push({
'list_recipe': null,
'food': this.new_entry.food,
'unit': this.new_entry.unit,
'amount': parseFloat(this.new_entry.amount),
'order': 0,
'checked': false
})
this.new_entry = {
unit: undefined,
amount: undefined,
food: undefined,
}
this.$refs.new_entry_amount.focus();
} else {
this.makeToast('{% trans 'Error' %}', '{% trans 'Please enter a valid food' %}', 'danger')
}
},
getRecipes: function () {
let url = "{% url 'api:recipe-list' %}?limit=5&internal=true"
if (this.recipe_query !== '') {
url += '&query=' + this.recipe_query;
} else {
this.recipes = []
return
}
this.$http.get(url).then((response) => {
this.recipes = response.data;
}).catch((err) => {
console.log("getRecipes error: ", err);
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
getRecipeUrl: function (id) { //TODO generic function that can be reused else were
return '{% url 'view_recipe' 123456 %}'.replace('123456', id)
},
addRecipeToList: function (recipe, multiplier = 1) {
let slr = {
"created": true,
"id": Math.random() * 1000,
"recipe": recipe.id,
"recipe_name": recipe.name,
"multiplier": multiplier
}
this.shopping_list.recipes.push(slr)
for (let s of recipe.steps) {
for (let i of s.ingredients) {
if (!i.is_header && i.food !== null) {
this.shopping_list.entries.push({
'list_recipe': slr.id,
'food': i.food,
'unit': i.unit,
'amount': i.amount,
'order': 0
})
}
}
}
},
removeRecipeFromList: function (slr) {
this.shopping_list.entries = this.shopping_list.entries.filter(item => item.list_recipe !== slr.id)
this.shopping_list.recipes = this.shopping_list.recipes.filter(item => item !== slr)
},
searchKeywords: function (query) {
this.keywords_loading = true
this.$http.get("{% url 'api:keyword-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.keywords = response.data;
this.keywords_loading = false
}).catch((err) => {
console.log(err)
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
searchUnits: function (query) { //TODO move to central component
this.units_loading = true
this.$http.get("{% url 'api:unit-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.units = response.data;
this.units_loading = false
}).catch((err) => {
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
searchFoods: function (query) { //TODO move to central component
this.foods_loading = true
this.$http.get("{% url 'api:food-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.foods = response.data
this.foods_loading = false
}).catch((err) => {
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
addFoodType: function (tag, index) { //TODO move to central component
let new_food = {'name': tag}
this.foods.push(new_food)
this.new_entry.food = new_food
},
addUnitType: function (tag, index) { //TODO move to central component
let new_unit = {'name': tag}
this.units.push(new_unit)
this.new_entry.unit = new_unit
},
searchUsers: function (query) { //TODO move to central component
this.users_loading = true
this.$http.get("{% url 'api:username-list' %}" + '?query=' + query + '&limit=10').then((response) => {
this.users = response.data
this.users_loading = false
}).catch((err) => {
this.makeToast('{% trans 'Error' %}', '{% trans 'There was an error loading a resource!' %}' + err.bodyText, 'danger')
})
},
},
beforeDestroy() {
window.removeEventListener('online', this.updateOnlineStatus);
window.removeEventListener('offline', this.updateOnlineStatus);
}
});
</script>
{% endblock %}

View File

@ -16,8 +16,18 @@
<br/>
<br/>
<h3>{% trans 'Backup & Restore' %}</h3>
<a href="{% url 'api_backup' %}" class="btn btn-success">{% trans 'Download Backup' %}</a>
<div class="row">
<div class="col-md-6">
<h3>{% trans 'Invite Links' %}</h3>
<a href="{% url 'list_invite_link' %}" class="btn btn-success">{% trans 'Show Links' %}</a>
</div>
<div class="col-md-6">
<h3>{% trans 'Backup & Restore' %}</h3>
<a href="{% url 'api_backup' %}" class="btn btn-success">{% trans 'Download Backup' %}</a>
</div>
</div>
<br/>
<br/>
@ -59,7 +69,8 @@
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
{% if secret_key %}
{% blocktrans %}
You do not have a <code>SECRET_KEY</code> configured in your <code>.env</code> file. Django defaulted to the standard key
You do not have a <code>SECRET_KEY</code> configured in your <code>.env</code> file. Django defaulted to the
standard key
provided with the installation which is publicly know and insecure! Please set
<code>SECRET_KEY</code> int the <code>.env</code> configuration file.
{% endblocktrans %}

View File

@ -80,7 +80,7 @@
<div class="col-md-1">
<input class="form-control" v-model="i.amount">
</div>
<div class="col-md-5">
<div class="col-md-4">
<table class="table-layout:fixed">
<col width="95%"/>
@ -119,7 +119,7 @@
</div>
<div class="col-md-5">
<div class="col-md-4">
<multiselect v-tabindex
ref="ingredient"
@ -143,6 +143,9 @@
</multiselect>
</div>
<div class="col-md-2">
<input type="text" placeholder="{% trans 'Note' %}" class="form-control" v-model="i.note">
</div>
<div class="col-md-1">
<button class="btn btn-outline-danger btn-lg" type="button"
@click="deleteIngredient(i)" tabindex="-1"><i
@ -345,7 +348,9 @@
},
openUnitSelect: function (id) {
let index = id.replace('unit_', '')
this.$set(app.$refs.unit[index].$data, 'search', this.recipe_data.recipeIngredient[index].unit.text)
if (this.recipe_data.recipeIngredient[index].unit !== null) {
this.$set(app.$refs.unit[index].$data, 'search', this.recipe_data.recipeIngredient[index].unit.text)
}
},
openIngredientSelect: function (id) {
let index = id.replace('ingredient_', '')
@ -367,7 +372,7 @@
this.units = response.data.results;
if (this.recipe_data !== undefined) {
for (let x of Array.from(this.recipe_data.recipeIngredient)) {
if (x.unit.text !== '') {
if (x.unit !== null && x.unit.text !== '') {
this.units = this.units.filter(item => item.text !== x.unit.text)
this.units.push(x.unit)
}

View File

@ -7,7 +7,7 @@ from django.urls import reverse, NoReverseMatch
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension
from cookbook.models import get_model_name
from cookbook.models import get_model_name, Space
from recipes import settings
register = template.Library()
@ -69,6 +69,11 @@ def recipe_last(recipe, user):
return ''
@register.simple_tag
def message_of_the_day():
return Space.objects.first().message
@register.simple_tag
def is_debug():
return settings.DEBUG

View File

@ -0,0 +1,27 @@
import json
from django.contrib import auth
from django.db.models import ProtectedError
from django.urls import reverse
from cookbook.models import Storage, Sync, Keyword, ShoppingList
from cookbook.tests.views.test_views import TestViews
class TestApiShopping(TestViews):
def setUp(self):
super(TestApiShopping, self).setUp()
self.list_1 = ShoppingList.objects.create(created_by=auth.get_user(self.user_client_1))
self.list_2 = ShoppingList.objects.create(created_by=auth.get_user(self.user_client_2))
def test_shopping_view_permissions(self):
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 404), (self.admin_client_1, 404), (self.superuser_client, 200)],
reverse('api:shoppinglist-detail', args={self.list_1.id}))
self.list_1.shared.add(auth.get_user(self.user_client_2))
self.batch_requests([(self.anonymous_client, 403), (self.guest_client_1, 404), (self.user_client_1, 200), (self.user_client_2, 200), (self.admin_client_1, 404), (self.superuser_client, 200)],
reverse('api:shoppinglist-detail', args={self.list_1.id}))
# TODO add tests for editing

View File

@ -12,7 +12,6 @@ class TestEditsRecipe(TestBase):
{'file': 'cookbook/tests/resources/websites/ld_json_2.html', 'result_length': 1450},
{'file': 'cookbook/tests/resources/websites/ld_json_3.html', 'result_length': 1545},
{'file': 'cookbook/tests/resources/websites/ld_json_4.html', 'result_length': 1657},
{'file': 'cookbook/tests/resources/websites/ld_json_invalid.html', 'result_length': 115},
{'file': 'cookbook/tests/resources/websites/ld_json_itemList.html', 'result_length': 3131},
{'file': 'cookbook/tests/resources/websites/ld_json_multiple.html', 'result_length': 1546},
{'file': 'cookbook/tests/resources/websites/micro_data_1.html', 'result_length': 1022},
@ -23,6 +22,7 @@ class TestEditsRecipe(TestBase):
for test in test_list:
with open(test['file'], 'rb') as file:
print(f'Testing {test["file"]} expecting length {test["result_length"]}')
parsed_content = json.loads(get_from_html(file.read(), 'test_url').content)
self.assertEqual(len(str(parsed_content)), test['result_length'])
file.close()

View File

@ -1,16 +0,0 @@
<script type="application/ld+json"> {
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Selbstgemachter Schokopudding",
"author": "AP",
"image": "https://www.lidl-kochen.de/images/recipe/64590/selbstgemachter-schokopudding-144479.jpg",
"description": "Rezept für Selbstgemachter Schokopudding » Über 245x nachgekocht » 20min Zubereitung » 7 Zutaten » 473 kcal/Portion
",
"keywords": "Schokolade, Milch, Schlagsahne, Kuvertüre, schnell, einfach, günstig, lecker, leicht, Snack, glutenfrei, vegetarisch, unter 500 kcal, Sommer, Winter, Herbst, Frühling, Deutschland, Kinder, Familie, für Singles, für Studenten, kochen, ohne Backofen, für Paare, fürs Büro, für die Arbeit, beliebte Rezepte, Dessert", "recipeCuisine": "Deutschland", "cookTime": "PT20M",
"nutrition": {
"@type": "NutritionInformation",
"calories": "473 Kalorie"
},
"recipeInstructions": [{"@type":"HowToStep","text":"Schokolade fein hacken. In einem Topf 400 ml Milch, 150 g Sahne, Zucker, Salz und Schokolade langsam bei kleiner bis mittlerer Stufe unter Rühren erhitzen, bis die Schokolade geschmolzen ist. Anschließend aufkochen, dabei weiter rühren. \r\n\r\n"},{"@type":"HowToStep","text":"Übrige kalte Milch und Stärke in einer Schüssel mit einem Schneebesen gründlich verrühren. Kochende Milch vom Herd nehmen, angerührte Stärke einrühren und Topf zurück auf den Herd setzen. Unter Rühren ca. 1 Min. aufkochen, bis der Pudding dickflüssig ist. Vom Herd nehmen, in eine mit kaltem Wasser ausgespülte Schüssel füllen und erkalten lassen. \r\n\r\n"},{"@type":"HowToStep","text":"In einem hohen Gefäß übrige Sahne mit einem Handrührgerät mit Schneebesen steif schlagen. Pudding mit Sahne und Schokostreuseln garnieren und servieren.\r\n\r\nGuten Appetit!"}],
"recipeIngredient": ["Kuvertüre, zartbitter 150 g","Milch 450 ml","Schlagsahne 200 g","Zucker 75 g","Salz Prise","Speisestärke 30 g","Schokoladenstreusel, Zartbitter 2 EL"],
"recipeCategory": "Snack, Dessert, Andere" }</script>

View File

@ -23,17 +23,22 @@ router.register(r'recipe', api.RecipeViewSet)
router.register(r'ingredient', api.IngredientViewSet)
router.register(r'meal-plan', api.MealPlanViewSet)
router.register(r'meal-type', api.MealTypeViewSet)
router.register(r'shopping-list', api.ShoppingListViewSet)
router.register(r'shopping-list-entry', api.ShoppingListEntryViewSet)
router.register(r'shopping-list-recipe', api.ShoppingListRecipeViewSet)
router.register(r'view-log', api.ViewLogViewSet)
urlpatterns = [
path('', views.index, name='index'),
path('setup/', views.setup, name='view_setup'),
path('signup/<slug:token>', views.signup, name='view_signup'),
path('system/', views.system, name='view_system'),
path('search/', views.search, name='view_search'),
path('books/', views.books, name='view_books'),
path('plan/', views.meal_plan, name='view_plan'),
path('plan/entry/<int:pk>', views.meal_plan_entry, name='view_plan_entry'),
path('shopping/', views.shopping_list, name='view_shopping'),
path('shopping/<int:pk>', views.shopping_list, name='view_shopping'),
path('settings/', views.user_settings, name='view_settings'),
path('history/', views.history, name='view_history'),
path('test/', views.test, name='view_test'),
@ -89,7 +94,7 @@ urlpatterns = [
]
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Food)
generic_models = (Recipe, RecipeImport, Storage, RecipeBook, MealPlan, SyncLog, Sync, Comment, RecipeBookEntry, Keyword, Food, ShoppingList, InviteLink)
for m in generic_models:
py_name = get_model_name(m)

View File

@ -26,13 +26,14 @@ from rest_framework.parsers import JSONParser, FileUploadParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ViewSetMixin
from cookbook.helper.permission_helper import group_required, CustomIsOwner, CustomIsAdmin, CustomIsUser, CustomIsGuest, CustomIsShare
from cookbook.helper.permission_helper import group_required, CustomIsOwner, CustomIsAdmin, CustomIsUser, CustomIsGuest, CustomIsShare, CustomIsShared
from cookbook.helper.recipe_url_import import get_from_html
from cookbook.models import Recipe, Sync, Storage, CookLog, MealPlan, MealType, ViewLog, UserPreference, RecipeBook, Ingredient, Food, Step, Keyword, Unit, SyncLog
from cookbook.models import Recipe, Sync, Storage, CookLog, MealPlan, MealType, ViewLog, UserPreference, RecipeBook, Ingredient, Food, Step, Keyword, Unit, SyncLog, ShoppingListRecipe, ShoppingList, ShoppingListEntry
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
from cookbook.serializer import MealPlanSerializer, MealTypeSerializer, RecipeSerializer, ViewLogSerializer, UserNameSerializer, UserPreferenceSerializer, RecipeBookSerializer, IngredientSerializer, FoodSerializer, StepSerializer, \
KeywordSerializer, RecipeImageSerializer, StorageSerializer, SyncSerializer, SyncLogSerializer, UnitSerializer
KeywordSerializer, RecipeImageSerializer, StorageSerializer, SyncSerializer, SyncLogSerializer, UnitSerializer, ShoppingListSerializer, ShoppingListRecipeSerializer, ShoppingListEntrySerializer, ShoppingListEntryCheckedSerializer, \
ShoppingListAutoSyncSerializer
class UserNameViewSet(viewsets.ReadOnlyModelViewSet):
@ -154,7 +155,7 @@ class MealPlanViewSet(viewsets.ModelViewSet):
"""
queryset = MealPlan.objects.all()
serializer_class = MealPlanSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [permissions.IsAuthenticated] # TODO fix permissions
def get_queryset(self):
queryset = MealPlan.objects.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).distinct().all()
@ -203,6 +204,18 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
serializer_class = RecipeSerializer
permission_classes = [CustomIsShare | CustomIsGuest] # TODO split read and write permission for meal plan guest
def get_queryset(self):
queryset = super().get_queryset()
internal = self.request.query_params.get('internal', None)
if internal:
queryset = queryset.filter(internal=True)
random = self.request.query_params.get('random', False)
if random:
queryset = queryset.random(5)
return queryset
# TODO write extensive tests for permissions
@decorators.action(
@ -233,6 +246,39 @@ class RecipeViewSet(viewsets.ModelViewSet, StandardFilterMixin):
return Response(serializer.errors, 400)
class ShoppingListRecipeViewSet(viewsets.ModelViewSet):
queryset = ShoppingListRecipe.objects.all()
serializer_class = ShoppingListRecipeSerializer
permission_classes = [CustomIsUser, ] # TODO add custom validation
# TODO custom get qs
class ShoppingListEntryViewSet(viewsets.ModelViewSet):
queryset = ShoppingListEntry.objects.all()
serializer_class = ShoppingListEntrySerializer
permission_classes = [CustomIsOwner, ] # TODO add custom validation
# TODO custom get qs
class ShoppingListViewSet(viewsets.ModelViewSet):
queryset = ShoppingList.objects.all()
serializer_class = ShoppingListSerializer
permission_classes = [CustomIsOwner | CustomIsShared]
def get_queryset(self):
if self.request.user.is_superuser:
return self.queryset
return self.queryset.filter(Q(created_by=self.request.user) | Q(shared=self.request.user)).all()
def get_serializer_class(self):
autosync = self.request.query_params.get('autosync', None)
if autosync:
return ShoppingListAutoSyncSerializer
return self.serializer_class
class ViewLogViewSet(viewsets.ModelViewSet):
queryset = ViewLog.objects.all()
serializer_class = ViewLogSerializer

View File

@ -6,6 +6,7 @@ import requests
from PIL import Image
from django.contrib import messages
from django.core.files import File
from django.db.transaction import atomic
from django.utils.translation import gettext as _
from django.http import HttpResponseRedirect, HttpResponse
from django.shortcuts import redirect, render
@ -94,6 +95,7 @@ def batch_edit(request):
@group_required('user')
@atomic
def import_url(request):
if request.method == 'POST':
data = json.loads(request.body)
@ -120,20 +122,26 @@ def import_url(request):
recipe.keywords.add(k)
for ing in data['recipeIngredient']:
f, f_created = Food.objects.get_or_create(name=ing['ingredient']['text'])
if ing['unit']:
u, u_created = Unit.objects.get_or_create(name=ing['unit']['text'])
else:
u = Unit.objects.get(name=request.user.userpreference.default_unit)
ingredient = Ingredient()
ingredient.food, f_created = Food.objects.get_or_create(name=ing['ingredient']['text'])
if ing['unit']:
ingredient.unit, u_created = Unit.objects.get_or_create(name=ing['unit']['text'])
# TODO properly handle no_amount recipes
if isinstance(ing['amount'], str):
try:
ing['amount'] = float(ing['amount'].replace(',', '.'))
ingredient.amount = float(ing['amount'].replace(',', '.'))
except ValueError:
# TODO return proper error
ingredient.no_amount = True
pass
elif isinstance(ing['amount'], float) or isinstance(ing['amount'], int):
ingredient.amount = ing['amount']
ingredient.note = ing['note'] if 'note' in ing else ''
step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=ing['amount']))
ingredient.save()
step.ingredients.add(ingredient)
print(ingredient)
if data['image'] != '':
response = requests.get(data['image'])

View File

@ -9,7 +9,7 @@ from django.views.generic import DeleteView
from cookbook.helper.permission_helper import group_required, GroupRequiredMixin, OwnerRequiredMixin
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeBook, \
RecipeBookEntry, MealPlan, Food
RecipeBookEntry, MealPlan, Food, InviteLink
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
@ -148,3 +148,14 @@ class MealPlanDelete(OwnerRequiredMixin, DeleteView):
context = super(MealPlanDelete, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
return context
class InviteLinkDelete(OwnerRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = InviteLink
success_url = reverse_lazy('list_invite_link')
def get_context_data(self, **kwargs):
context = super(InviteLinkDelete, self).get_context_data(**kwargs)
context['title'] = _("Invite Link")
return context

View File

@ -1,13 +1,16 @@
from datetime import datetime
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.db.models.functions import Lower
from django.shortcuts import render
from django.utils.translation import gettext as _
from django_tables2 import RequestConfig
from cookbook.filters import IngredientFilter
from cookbook.filters import IngredientFilter, ShoppingListFilter
from cookbook.helper.permission_helper import group_required
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Food
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable
from cookbook.models import Keyword, SyncLog, RecipeImport, Storage, Food, ShoppingList, InviteLink
from cookbook.tables import KeywordTable, ImportLogTable, RecipeImportTable, StorageTable, IngredientTable, ShoppingListTable, InviteLinkTable
@group_required('user')
@ -45,9 +48,27 @@ def food(request):
return render(request, 'generic/list_template.html', {'title': _("Ingredients"), 'table': table, 'filter': f})
@group_required('user')
def shopping_list(request):
f = ShoppingListFilter(request.GET, queryset=ShoppingList.objects.filter(Q(created_by=request.user) | Q(shared=request.user)).all().order_by('finished', 'created_at'))
table = ShoppingListTable(f.qs)
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Shopping Lists"), 'table': table, 'filter': f, 'create_url': 'view_shopping'})
@group_required('admin')
def storage(request):
table = StorageTable(Storage.objects.all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Storage Backend"), 'table': table, 'create_url': 'new_storage'})
@group_required('admin')
def invite_link(request):
table = InviteLinkTable(InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None).all())
RequestConfig(request, paginate={'per_page': 25}).configure(table)
return render(request, 'generic/list_template.html', {'title': _("Invite Links"), 'table': table, 'create_url': 'new_invite_link'})

View File

@ -9,9 +9,9 @@ from django.utils.translation import gettext as _
from django.views.generic import CreateView
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \
RecipeBookForm, MealPlanForm
RecipeBookForm, MealPlanForm, InviteLinkForm
from cookbook.helper.permission_helper import GroupRequiredMixin, group_required
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan, ShareLink, MealType, Step
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan, ShareLink, MealType, Step, InviteLink
class RecipeCreate(GroupRequiredMixin, CreateView):
@ -162,3 +162,21 @@ class MealPlanCreate(GroupRequiredMixin, CreateView):
context['default_recipe'] = Recipe.objects.get(pk=int(recipe))
return context
class InviteLinkCreate(GroupRequiredMixin, CreateView):
groups_required = ['admin']
template_name = "generic/new_template.html"
model = InviteLink
form_class = InviteLinkForm
def form_valid(self, form):
obj = form.save(commit=False)
obj.created_by = self.request.user
obj.save()
return HttpResponseRedirect(reverse('list_invite_link'))
def get_context_data(self, **kwargs):
context = super(InviteLinkCreate, self).get_context_data(**kwargs)
context['title'] = _("Invite Link")
return context

View File

@ -1,7 +1,7 @@
import copy
import os
from datetime import datetime, timedelta
from uuid import UUID
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash, authenticate
from django.contrib.auth.forms import PasswordChangeForm
@ -164,44 +164,18 @@ def meal_plan_entry(request, pk):
@group_required('user')
def shopping_list(request):
markdown_format = True
def shopping_list(request, pk=None):
raw_list = request.GET.getlist('r')
if request.method == "POST":
form = ShoppingForm(request.POST)
if form.is_valid():
recipes = form.cleaned_data['recipe']
markdown_format = form.cleaned_data['markdown_format']
else:
recipes = []
else:
raw_list = request.GET.getlist('r')
recipes = []
for r in raw_list:
r = r.replace('[', '').replace(']', '')
if re.match(r'^([0-9])+,([0-9])+[.]*([0-9])*$', r):
rid, multiplier = r.split(',')
if recipe := Recipe.objects.filter(pk=int(rid)).first():
recipes.append({'recipe': recipe.id, 'multiplier': multiplier})
recipes = []
for r in raw_list:
if re.match(r'^([1-9])+$', r):
if Recipe.objects.filter(pk=int(r)).exists():
recipes.append(int(r))
markdown_format = False
form = ShoppingForm(initial={'recipe': recipes, 'markdown_format': False})
ingredients = []
for r in recipes:
for s in r.steps.all():
for ri in s.ingredients.exclude(unit__name__contains='Special:').all():
index = None
for x, ig in enumerate(ingredients):
if ri.food == ig.food and ri.unit == ig.unit:
index = x
if index:
ingredients[index].amount = ingredients[index].amount + ri.amount
else:
ingredients.append(ri)
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form, 'markdown_format': markdown_format})
return render(request, 'shopping_list.html', {'shopping_list_id': pk, 'recipes': recipes})
@group_required('guest')
@ -228,6 +202,11 @@ def user_settings(request):
up.plan_share.set(form.cleaned_data['plan_share'])
up.ingredient_decimals = form.cleaned_data['ingredient_decimals']
up.comments = form.cleaned_data['comments']
up.shopping_auto_sync = form.cleaned_data['shopping_auto_sync']
if up.shopping_auto_sync < settings.SHOPPING_MIN_AUTOSYNC_INTERVAL:
up.shopping_auto_sync = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
up.save()
if 'user_name_form' in request.POST:
@ -276,7 +255,7 @@ def setup(request):
return HttpResponseRedirect(reverse('login'))
if request.method == 'POST':
form = SuperUserForm(request.POST)
form = UserCreateForm(request.POST)
if form.is_valid():
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']:
form.add_error('password', _('Passwords dont match!'))
@ -296,11 +275,59 @@ def setup(request):
for m in e:
form.add_error('password', m)
else:
form = SuperUserForm()
form = UserCreateForm()
return render(request, 'setup.html', {'form': form})
def signup(request, token):
try:
token = UUID(token, version=4)
except ValueError:
messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!'))
return HttpResponseRedirect(reverse('index'))
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
if request.method == 'POST':
form = UserCreateForm(request.POST)
if link.username != '':
data = dict(form.data)
data['name'] = link.username
form.data = data
if form.is_valid():
if form.cleaned_data['password'] != form.cleaned_data['password_confirm']:
form.add_error('password', _('Passwords dont match!'))
else:
user = User(
username=form.cleaned_data['name'],
)
try:
validate_password(form.cleaned_data['password'], user=user)
user.set_password(form.cleaned_data['password'])
user.save()
messages.add_message(request, messages.SUCCESS, _('User has been created, please login!'))
link.used_by = user
link.save()
user.groups.add(link.group)
return HttpResponseRedirect(reverse('login'))
except ValidationError as e:
for m in e:
form.add_error('password', m)
else:
form = UserCreateForm()
if link.username != '':
form.fields['name'].initial = link.username
form.fields['name'].disabled = True
return render(request, 'registration/signup.html', {'form': form, 'link': link})
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
return HttpResponseRedirect(reverse('index'))
def markdown_info(request):
return render(request, 'markdown_info.html', {})

View File

@ -0,0 +1,147 @@
# Manual installation instructions
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))
**Important note:** Be sure to use pyton3.8 and pip related to python 3.8. Depending on your distribution calling `python` or `pip` will use python2 instead of pyton 3.8.
## Prerequisites
*Optional*: create a virtual env and activate it
Get the last version from the repository: `git clone https://github.com/vabene1111/recipes.git -b master`
Install postgresql requirements: `sudo apt install libpq-dev postgresql`
Install project requirements: `pip3.8 install -r requirements.txt`
## Setup postgresql
Run `sudo -u postgres psql`
In the psql console:
```sql
CREATE DATABASE djangodb;
CREATE USER djangouser WITH PASSWORD 'password';
GRANT ALL PRIVILEGES ON DATABASE djangodb TO djangouser;
ALTER DATABASE djangodb OWNER TO djangouser;
--Maybe not necessary, but should be faster:
ALTER ROLE djangouser SET client_encoding TO 'utf8';
ALTER ROLE djangouser SET default_transaction_isolation TO 'read committed';
ALTER ROLE djangouser SET timezone TO 'UTC';
--Grant superuser right to your new user, it will be removed later
ALTER USER djangouser WITH SUPERUSER;
```
Move or copy `.env.template` to `.env` and update it with relevent values. For example:
```env
# only set this to true when testing/debugging
# when unset: 1 (true) - dont unset this, just for development
DEBUG=0
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
#ALLOWED_HOSTS=*
# random secret key, use for example base64 /dev/urandom | head -c50 to generate one
SECRET_KEY=TOGENERATE
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
DB_ENGINE=django.db.backends.postgresql_psycopg2
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=djangouser
POSTGRES_PASSWORD=password
POSTGRES_DB=djangodb
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
# provided that include an additional nxginx container to handle media file serving.
# If you know what you are doing turn this back on (1) to serve media files using djangos serve() method.
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
GUNICORN_MEDIA=0
# allow authentication via reverse proxy (e.g. authelia), leave of if you dont know what you are doing
# docs: https://github.com/vabene1111/recipes/tree/develop/docs/docker/nginx-proxy%20with%20proxy%20authentication
# when unset: 0 (false)
REVERSE_PROXY_AUTH=0
# the default value for the user preference 'comments' (enable/disable commenting system)
# when unset: 1 (true)
COMMENT_PREF_DEFAULT=1
```
## Initialize the application
Execute `export $(cat .env |grep "^[^#]" | xargs)` to load variables from `.env`
Execute `/python3.8 manage.py migrate`
And revert superuser from postgres: `sudo -u postgres psql` and `ALTER USER djangouser WITH NOSUPERUSER;`
Generate static files: `python3.8 manage.py collectstatic` and remember the folder where files have been copied.
## Setup web services
### gunicorn
Create a service that will start gunicorn at boot: `sudo nano /etc/systemd/system/gunicorn_recipes.service`
And enter these lines:
```service
[Unit]
Description=gunicorn daemon for recipes
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=3
Group=www-data
WorkingDirectory=/media/data/recipes
EnvironmentFile=/media/data/recipes/.env
ExecStart=/opt/.pyenv/versions/3.8.5/bin/gunicorn --error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output --bind unix:/media/data/recipes/recipes.sock recipes.wsgi:application
[Install]
WantedBy=multi-user.target
```
*Note*: `-error-logfile /tmp/gunicorn_err.log --log-level debug --capture-output` are usefull for debugging and can be removed later
*Note2*: Fix the path in the `ExecStart` line to where you gunicorn and recipes are
Finally, run `sudo systemctl enable gunicorn_recipes.service` and `sudo systemctl start gunicorn_recipes.service`. You can check that the service is correctly started with `systemctl status gunicorn_recipes.service`
### nginx
Now we tell nginx to listen to a new port and forward that to gunicorn. `sudo nano /etc/nginx/sites-available/recipes.conf`
And enter these lines:
```nginx
server {
listen 8002;
#access_log /var/log/nginx/access.log;
#error_log /var/log/nginx/error.log;
# serve media files
location /static {
alias /media/data/recipes/staticfiles;
}
location /media {
alias /media/data/recipes/mediafiles;
}
location / {
proxy_pass http://unix:/media/data/recipes/recipes.sock;
}
}
```
*Note*: Enter the correct path in static and proxy_pass lines.
Enable the website `sudo ln -s /etc/nginx/sites-available/recipes.conf /etc/nginx/sites-enabled` and restart nginx : `sudo systemctl restart nginx.service`

View File

@ -1,2 +1,2 @@
CALL venv\Scripts\activate.bat
python manage.py makemessages -i venv -l de -l nl -l rn -l fr
python manage.py makemessages -i venv -l de -l nl -l rn -l fr -l tr -l pt -l en

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-07-15 18:43+0200\n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
@ -18,14 +18,18 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:169
#: .\recipes\settings.py:174
msgid "English"
msgstr "Englisch"
#: .\recipes\settings.py:170
#: .\recipes\settings.py:175
msgid "German"
msgstr "Deutsch"
#: .\recipes\settings.py:171
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-06-02 21:20+0200\n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
@ -17,10 +17,19 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:138
#: .\recipes\settings.py:174
msgid "English"
msgstr ""
#: .\recipes\settings.py:175
msgid "German"
msgstr ""
#: .\recipes\settings.py:139
msgid "English"
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-07-15 18:43+0200\n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
@ -18,14 +18,18 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: .\recipes\settings.py:169
#: .\recipes\settings.py:174
msgid "English"
msgstr ""
#: .\recipes\settings.py:170
#: .\recipes\settings.py:175
msgid "German"
msgstr ""
#: .\recipes\settings.py:171
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-07-15 18:43+0200\n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
@ -18,14 +18,18 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:169
#: .\recipes\settings.py:174
msgid "English"
msgstr ""
#: .\recipes\settings.py:170
#: .\recipes\settings.py:175
msgid "German"
msgstr ""
#: .\recipes\settings.py:171
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

Binary file not shown.

View File

@ -0,0 +1,35 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: .\recipes\settings.py:174
msgid "English"
msgstr ""
#: .\recipes\settings.py:175
msgid "German"
msgstr ""
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

Binary file not shown.

View File

@ -0,0 +1,34 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: .\recipes\settings.py:174
msgid "English"
msgstr ""
#: .\recipes\settings.py:175
msgid "German"
msgstr ""
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

Binary file not shown.

View File

@ -0,0 +1,35 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-09-29 19:44+0200\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"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: .\recipes\settings.py:174
msgid "English"
msgstr ""
#: .\recipes\settings.py:175
msgid "German"
msgstr ""
#: .\recipes\settings.py:176
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:177
msgid "French"
msgstr ""

View File

@ -24,12 +24,17 @@ SECRET_KEY = os.getenv('SECRET_KEY') if os.getenv('SECRET_KEY') else 'INSECURE_S
DEBUG = bool(int(os.getenv('DEBUG', True)))
# allow djangos wsgi server to server mediafiles
GUNICORN_MEDIA = bool(int(os.getenv('GUNICORN_MEDIA', True)))
REVERSE_PROXY_AUTH = bool(int(os.getenv('REVERSE_PROXY_AUTH', False)))
# default value for user preference 'comment'
COMMENT_PREF_DEFAULT = bool(int(os.getenv('COMMENT_PREF_DEFAULT', True)))
# minimum interval that users can set for automatic sync of shopping lists
SHOPPING_MIN_AUTOSYNC_INTERVAL = int(os.getenv('SHOPPING_MIN_AUTOSYNC_INTERVAL', 5))
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS').split(',') if os.getenv('ALLOWED_HOSTS') else ['*']
CORS_ORIGIN_ALLOW_ALL = True
@ -175,10 +180,10 @@ LANGUAGES = [
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_URL = os.getenv('STATIC_URL', '/static/')
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
MEDIA_URL = '/media/'
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
MEDIA_ROOT = os.path.join(BASE_DIR, "mediafiles")
# Serve static files with gzip

View File

@ -1,28 +1,29 @@
bleach==3.1.5
bleach-whitelist==0.0.10
Django==3.0.7
bleach==3.2.1
bleach-whitelist==0.0.11
Django==3.1.1
django-annoying==0.10.6
django-autocomplete-light==3.5.1
django-cleanup==4.0.0
django-crispy-forms==1.9.1
django-emoji-picker==0.0.6
django-filter==2.2.0
django-filter==2.4.0
django-tables2==2.3.1
djangorestframework==3.11.0
drf-writable-nested==0.6.0
drf-writable-nested==0.6.1
gunicorn==20.0.4
lxml==4.5.1
Markdown==3.2.2
Pillow==7.1.2
psycopg2-binary==2.8.5
python-dotenv==0.13.0
psycopg2-binary==2.8.6
python-dotenv==0.15.0
requests==2.23.0
simplejson==3.17.0
simplejson==3.17.2
six==1.15.0
webdavclient3==3.14.4
whitenoise==5.1.0
whitenoise==5.2.0
icalendar==4.0.6
pyyaml==5.3.1
uritemplate==3.0.1
beautifulsoup4==4.9.1
microdata==0.7.1
beautifulsoup4==4.9.2
microdata==0.7.1
django-random-queryset==0.1.3