Compare commits

..

43 Commits
0.2.1 ... 0.3.0

Author SHA1 Message Date
vabene1111
67b4ec8215 fixed shopping ingredient list 2020-02-17 00:24:33 +01:00
vabene1111
db2e67dd71 incresed instruction font size 2020-02-17 00:22:36 +01:00
vabene1111
de355abd19 fixed tests to reflect new name 2020-02-17 00:17:51 +01:00
vabene1111
41bfa95cb2 nav color theming 2020-02-17 00:11:15 +01:00
vabene1111
ad9944dd01 fixed broken tabulator on default theme 2020-02-16 23:59:16 +01:00
vabene1111
a1160c310c fixed fix of migration 2020-02-16 23:54:45 +01:00
vabene1111
0444286d11 hotkeys for recipe editing 2020-02-16 23:50:58 +01:00
vabene1111
7d4630e3af normalized ingredients 2020-02-16 23:22:44 +01:00
vabene1111
f77aa7c8f0 migrating ingredients 2020-02-16 23:12:16 +01:00
vabene1111
81677a74bb made flatly default theme + fixed preview image 2020-02-16 22:58:24 +01:00
vabene1111
2cc385ceac preview image + readme update 2020-02-16 22:52:42 +01:00
vabene1111
b4cdc92207 catch non existing relation 2020-02-14 00:40:17 +01:00
vabene1111
ffdcbff540 dark theming tabulator + select 2020-02-14 00:35:52 +01:00
vabene1111
cc7422a503 theming refactor
moved server side for a better page loading experience and less javascript mess
2020-02-13 23:47:24 +01:00
vabene1111
c08e30c5a9 case insensitive filter 2020-02-12 23:38:05 +01:00
vabene1111
60477cdb9e settings button 2020-02-04 22:26:40 +01:00
vabene1111
bc066d29f6 dark mode reverted + meal plan button 2020-02-04 22:18:10 +01:00
vabene1111
c96159e15c dark mode 2020-02-04 22:00:47 +01:00
vabene1111
2d70680214 recipe buttons wip 2020-02-03 11:33:44 +01:00
vabene1111
00fdab1678 unit merging 2020-02-03 11:00:11 +01:00
vabene1111
6ccafe3c2f Update README.md 2020-02-02 23:03:46 +01:00
vabene1111
4080301dbc Testing GitHub actions as CI 2020-02-02 23:01:31 +01:00
vabene1111
e7227f84ca some more recipe edit cleanup 2020-02-02 22:52:25 +01:00
vabene1111
6753a2c0b5 basic recipe edit test 2020-02-02 22:46:37 +01:00
vabene1111
56e841879b login template enhancements 2020-02-02 22:12:04 +01:00
vabene1111
19f5b44e50 some basic tests 2020-02-02 22:09:30 +01:00
vabene1111
305a4949fb cleanup recipe edit 2020-02-02 16:06:24 +01:00
vabene1111
07502fecc0 fixed possible markdown xss 2020-02-02 16:06:12 +01:00
vabene1111
4da1293898 fixed it 2020-02-01 21:11:26 +01:00
vabene1111
ab2ce26d9d nearly working 2020-01-30 12:45:55 +01:00
vabene1111
a2348f531b basics of ingredient unit normalization 2020-01-30 12:26:47 +01:00
vabene1111
227d90d49d basic shopping view 2020-01-30 00:28:01 +01:00
vabene1111
6a61c934cd update readme 2020-01-19 14:40:06 +01:00
vabene1111
6f4a40acdd meal plan prev+next week buttons 2020-01-19 14:34:50 +01:00
vabene1111
becdcdc6a4 small meal plan fixes 2020-01-17 18:31:46 +01:00
vabene1111
afa69c647d basic meal plan working 2020-01-17 17:47:23 +01:00
vabene1111
7449380434 meal plan WIP 2020-01-17 16:02:14 +01:00
vabene1111
2afec837a4 fixed missing locale middleware 2020-01-17 14:17:10 +01:00
vabene1111
127eb3181b updated readme 2020-01-17 09:51:05 +01:00
vabene1111
0590826742 Merge pull request #22 from ntindle/patch-3
Update README.md
2020-01-17 09:45:52 +01:00
Nicholas Tindle
ac2c13743c Update README.md 2020-01-16 17:27:17 -06:00
vabene1111
7d4da4c19b Merge pull request #17 from ntindle/patch-2
Update README.md
2020-01-14 06:49:23 +01:00
Nicholas Tindle
763a4a66a2 Update README.md
Minor changes
2020-01-13 21:08:57 -06:00
61 changed files with 2115 additions and 107 deletions

26
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Continous Integration
on: [push]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.7]
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Django Testing project
run: |
python3 manage.py test

2
.gitignore vendored
View File

@@ -63,7 +63,7 @@ venv/
mediafiles/
*.sqlite3
*.sqlite3*
\.idea/workspace\.xml

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
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,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="file://$PROJECT_DIR$" libraries="{pretty-checkbox}" />
<file url="file://$PROJECT_DIR$" libraries="{jquery-3.4.1}" />
</component>
</project>

View File

@@ -1,16 +1,21 @@
# Recipes
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.
# Recipes ![CI](https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master)
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](preview.png)
### 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)
- :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
- :iphone: Optimized for use on **mobile** devices like phones and tablets
- :shopping_cart: Generate **shopping** lists from recipes
- :calendar: Create a **Plan** on what to eat when
- :person_with_blond_hair: **Share** recipes with friends and comment on them to suggest or remember changes you made
- :whale: Easy setup with **Docker**
- :art: Customize your interface with **themes**
- :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 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.
@@ -18,13 +23,13 @@ This application is meant for people with a collection of recipes they want to s
Most things should be straight forward but there are some more complicated things.
##### Storage Backends
A `Storage Backend` is a remote storage location where files are stored. To add a new backend klick on `Storage Data` and then on `Storage Backends`. There click the plus button.
A `Storage Backend` is a remote storage location where files are stored. To add a new backend click on `Storage Data` and then on `Storage Backends`. There click the plus button.
Enter a name (just a display name for you to identify it) and an API access Token for the account you want to use.
Dropboxes API tokens can be found on the [Dropboxes API explorer](https://dropbox.github.io/dropbox-api-v2-explorer/#auth_token/from_oauth1)
with the button on the top right. For Nextcloud you can use a App apssword created in the settings.
##### Adding Synced Path's
##### Adding Synced Paths
To add a new path from your Storage backend to the sync list, go to `Storage Data >> Configure Sync` and select the storage backend you want to use.
Then enter the path you want to monitor starting at the storage root (e.g. `/Folder/RecipesFolder`) and save it.
@@ -33,23 +38,23 @@ To sync the recipes app with the storage backends press `Sync now` under `Storag
##### Import Recipes
All files found by the sync can be found under `Manage Data >> Import recipes`. There you can either import all at once without modifying them or import one by one, adding tags while importing.
##### Batch Edit
If you have many untagged recipes you may want to edit them all at once. For this go to
If you have many untagged recipes, you may want to edit them all at once. To do so, go to
`Storage Data >> Batch Edit`. Enter a word which should be contained in the recipe name and select the tags you want to apply.
When clicking submit every recipe containing the word will be updated (tags are added).
When clicking submit, every recipe containing the word will be updated (tags are added).
> Currently the only option is word contains, maybe some more SQL like operators will be added later.
## Installation
### Docker-Compose
When cloning this repository a simple docker-compose file is included. It is made for setups already running an nginx-reverse proxy network with lets encrypt companion but can be changed easily. Copy `.env.template` to `.env` and fill in the missing values accordingly.
Now simply start the containers and run the `update.sh` script which will apply all migrations and collect static files.
When cloning this repository, a simple docker-compose file is included. It is made for setups already running an nginx-reverse proxy network with lets encrypt companion but can be changed easily. Copy `.env.template` to `.env` and fill in the missing values accordingly.
Now simply start the containers and run the `update.sh` script that will apply all migrations and collect static files.
Create a default user by executing into the container with `docker-compose exec web_recipes sh` and run `python3 manage.py createsuperuser`.
### Manual
Copy `.env.template` to `.env` and fill in the missing values accordingly.
You can leave out the docker specific variables (VIRTUAL_HOST, LETSENCRYPT_HOST, LETSENCRYPT_EMAIL).
Make sure all variables are available to whatever servers your application.
Make sure all variables are available to whatever serves your application.
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).
@@ -66,4 +71,4 @@ To start developing:
Pull Requests and ideas are welcome, feel free to contribute in any way.
## License
This project is licensed under the MIT license. Even though it is not required to publish derivatives i highly encourage pushing changes upstream and letting people profit from any work done on this project.
This project is licensed under the MIT license. Even though it is not required to publish derivatives, I highly encourage pushing changes upstream and letting people profit from any work done on this project.

View File

@@ -36,7 +36,7 @@ class RecipeFilter(django_filters.FilterSet):
class QuickRecipeFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='contains')
name = django_filters.CharFilter(lookup_expr='icontains')
keywords = django_filters.ModelMultipleChoiceFilter(queryset=Keyword.objects.all(), widget=MultiSelectWidget,
method='filter_keywords')

View File

@@ -1,3 +1,4 @@
from dal import autocomplete
from django import forms
from django.forms import widgets
from django.utils.translation import gettext as _
@@ -6,11 +7,35 @@ from emoji_picker.widgets import EmojiPickerTextInput
from .models import *
class SelectWidget(widgets.Select):
class Media:
js = ('custom/js/form_select.js',)
class MultiSelectWidget(widgets.SelectMultiple):
class Media:
js = ('custom/js/form_multiselect.js',)
# yes there are some stupid browsers that still dont support this but i dont support people using these browsers
class DateWidget(forms.DateInput):
input_type = 'date'
def __init__(self, **kwargs):
kwargs["format"] = "%Y-%m-%d"
super().__init__(**kwargs)
class UserPreferenceForm(forms.ModelForm):
class Meta:
model = UserPreference
fields = ('theme', 'nav_color')
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!')
}
class ExternalRecipeForm(forms.ModelForm):
file_path = forms.CharField(disabled=True, required=False)
storage = forms.ModelChoiceField(queryset=Storage.objects.all(), disabled=True, required=False)
@@ -32,6 +57,8 @@ class ExternalRecipeForm(forms.ModelForm):
class InternalRecipeForm(forms.ModelForm):
ingredients = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = Recipe
fields = ('name', 'instructions', 'image', 'working_time', 'waiting_time', 'keywords')
@@ -46,6 +73,30 @@ class InternalRecipeForm(forms.ModelForm):
widgets = {'keywords': MultiSelectWidget}
class RecipeForm(forms.Form):
recipe = forms.ModelMultipleChoiceField(
queryset=Recipe.objects.all(),
widget=MultiSelectWidget
)
class UnitMergeForm(forms.Form):
prefix = 'unit'
new_unit = forms.ModelChoiceField(
queryset=Unit.objects.all(),
widget=SelectWidget,
label=_('New Unit'),
help_text=_('New unit that other gets replaced by.'),
)
old_unit = forms.ModelChoiceField(
queryset=Unit.objects.all(),
widget=SelectWidget,
label=_('Old Unit'),
help_text=_('Unit that should be replaced.'),
)
class CommentForm(forms.ModelForm):
prefix = 'comment'
@@ -87,12 +138,6 @@ class StorageForm(forms.ModelForm):
}
class RecipeBookForm(forms.ModelForm):
class Meta:
model = RecipeBook
fields = ('name',)
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
@@ -125,3 +170,17 @@ class ImportRecipeForm(forms.ModelForm):
'file_uid': _('File ID'),
}
widgets = {'keywords': MultiSelectWidget}
class RecipeBookForm(forms.ModelForm):
class Meta:
model = RecipeBook
fields = ('name',)
class MealPlanForm(forms.ModelForm):
class Meta:
model = MealPlan
fields = ('recipe', 'meal', 'note', 'date')
widgets = {'recipe': SelectWidget, 'date': DateWidget}

View File

@@ -1,6 +1,6 @@
from dal import autocomplete
from cookbook.models import Keyword, RecipeIngredients
from cookbook.models import Keyword, RecipeIngredient, Recipe, Unit, Ingredient
class KeywordAutocomplete(autocomplete.Select2QuerySetView):
@@ -19,11 +19,37 @@ class KeywordAutocomplete(autocomplete.Select2QuerySetView):
class IngredientsAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
if not self.request.user.is_authenticated:
return RecipeIngredients.objects.none()
return Ingredient.objects.none()
qs = RecipeIngredients.objects.all()
qs = Ingredient.objects.all()
if self.q:
qs = qs.filter(name__istartswith=self.q)
qs = qs.filter(name__icontains=self.q)
return qs
class RecipeAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
if not self.request.user.is_authenticated:
return Recipe.objects.none()
qs = Recipe.objects.all()
if self.q:
qs = qs.filter(name__icontains=self.q)
return qs
class UnitAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
if not self.request.user.is_authenticated:
return Unit.objects.none()
qs = Unit.objects.all()
if self.q:
qs = qs.filter(name__icontains=self.q)
return qs

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.0.2 on 2020-01-17 14:55
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', '0007_auto_20191226_0852'),
]
operations = [
migrations.CreateModel(
name='MealPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('meal', models.CharField(choices=[('BREAKFAST', 'Breakfast'), ('LUNCH', 'Lunch'), ('DINNER', 'Dinner'), ('OTHER', 'Other')], default='BREAKFAST', max_length=128)),
('note', models.TextField(blank=True)),
('date', models.DateField()),
('recipe', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.0.2 on 2020-01-30 09:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0008_mealplan'),
]
operations = [
migrations.CreateModel(
name='Unit',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, unique=True)),
('description', models.TextField(blank=True, null=True)),
],
),
migrations.AddField(
model_name='recipeingredients',
name='unit_key',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.Unit'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.0.2 on 2020-01-30 09:59
from django.db import migrations
def migrate_ingredient_units(apps, schema_editor):
Unit = apps.get_model('cookbook', 'Unit')
RecipeIngredients = apps.get_model('cookbook', 'RecipeIngredients')
for u in RecipeIngredients.objects.values('unit').distinct():
unit = Unit()
unit.name = u['unit']
unit.save()
for i in RecipeIngredients.objects.all():
i.unit_key = Unit.objects.get(name=i.unit)
i.save()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0009_auto_20200130_1056'),
]
operations = [
migrations.RunPython(migrate_ingredient_units),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.0.2 on 2020-01-30 10:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0010_auto_20200130_1059'),
]
operations = [
migrations.RemoveField(
model_name='recipeingredients',
name='unit',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.2 on 2020-01-30 10:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0011_remove_recipeingredients_unit'),
]
operations = [
migrations.RenameField(
model_name='recipeingredients',
old_name='unit_key',
new_name='unit',
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.0.2 on 2020-02-13 22:15
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', '0012_auto_20200130_1116'),
]
operations = [
migrations.CreateModel(
name='UserPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('theme', models.CharField(choices=[('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly')], default='BOOTSTRAP', max_length=128)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.0.2 on 2020-02-13 22:32
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', '0013_userpreference'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='theme',
field=models.CharField(choices=[('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero')], default='BOOTSTRAP', max_length=128),
),
migrations.AlterField(
model_name='userpreference',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, unique=True),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 3.0.2 on 2020-02-13 22:34
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', '0014_auto_20200213_2332'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.0.2 on 2020-02-13 22:35
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', '0015_auto_20200213_2334'),
]
operations = [
migrations.RemoveField(
model_name='userpreference',
name='id',
),
migrations.AlterField(
model_name='userpreference',
name='user',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.2 on 2020-02-16 21:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0016_auto_20200213_2335'),
]
operations = [
migrations.AlterField(
model_name='userpreference',
name='theme',
field=models.CharField(choices=[('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero')], default='FLATLY', max_length=128),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.0.2 on 2020-02-16 22:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0017_auto_20200216_2257'),
]
operations = [
migrations.RenameModel(
old_name='RecipeIngredients',
new_name='RecipeIngredient',
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 3.0.2 on 2020-02-16 22:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0018_auto_20200216_2303'),
]
operations = [
migrations.CreateModel(
name='Ingredient',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=128, unique=True)),
],
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 3.0.2 on 2020-02-16 22:08
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0019_ingredient'),
]
operations = [
migrations.AddField(
model_name='recipeingredient',
name='ingredient',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.Ingredient'),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 3.0.2 on 2020-02-16 22:09
from django.db import migrations
def migrate_ingredients(apps, schema_editor):
Ingredient = apps.get_model('cookbook', 'Ingredient')
RecipeIngredient = apps.get_model('cookbook', 'RecipeIngredient')
for u in RecipeIngredient.objects.values('name').distinct():
ingredient = Ingredient()
ingredient.name = u['name']
ingredient.save()
for i in RecipeIngredient.objects.all():
i.ingredient = Ingredient.objects.get(name=i.name)
i.save()
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0020_recipeingredient_ingredient'),
]
operations = [
migrations.RunPython(migrate_ingredients),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.0.2 on 2020-02-16 22:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0021_auto_20200216_2309'),
]
operations = [
migrations.RemoveField(
model_name='recipeingredient',
name='name',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.2 on 2020-02-16 22:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0022_remove_recipeingredient_name'),
]
operations = [
migrations.RenameField(
model_name='recipeingredient',
old_name='ingredient',
new_name='name',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.2 on 2020-02-16 22:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0023_auto_20200216_2311'),
]
operations = [
migrations.RenameField(
model_name='recipeingredient',
old_name='name',
new_name='ingredient',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.0.2 on 2020-02-16 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0024_auto_20200216_2313'),
]
operations = [
migrations.AddField(
model_name='userpreference',
name='nav_color',
field=models.CharField(choices=[('PRIMARY', 'Primary'), ('SECONDARY', 'Secondary'), ('SUCCESS', 'Success'), ('INFO', 'Info'), ('WARNING', 'Warning'), ('DANGER', 'Danger'), ('LIGHT', 'Light'), ('DARK', 'Dark')], default='PRIMARY', max_length=128),
),
]

View File

@@ -1,8 +1,34 @@
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from django.db import models
class UserPreference(models.Model):
# Themes
BOOTSTRAP = 'BOOTSTRAP'
DARKLY = 'DARKLY'
FLATLY = 'FLATLY'
SUPERHERO = 'SUPERHERO'
THEMES = ((BOOTSTRAP, 'Bootstrap'), (DARKLY, 'Darkly'), (FLATLY, 'Flatly'), (SUPERHERO, 'Superhero'))
# Nav colors
PRIMARY = 'PRIMARY'
SECONDARY = 'SECONDARY'
SUCCESS = 'SUCCESS'
INFO = 'INFO'
WARNING = 'WARNING'
DANGER = 'DANGER'
LIGHT = 'LIGHT'
DARK = 'DARK'
COLORS = ((PRIMARY, 'Primary'), (SECONDARY, 'Secondary'), (SUCCESS, 'Success'), (INFO, 'Info'), (WARNING, 'Warning'), (DANGER, 'Danger'), (LIGHT, 'Light'), (DARK, 'Dark'))
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
class Storage(models.Model):
DROPBOX = 'DB'
NEXTCLOUD = 'NEXTCLOUD'
@@ -75,12 +101,30 @@ class Recipe(models.Model):
return ' '.join([(x.icon + x.name) for x in self.keywords.all()])
class RecipeIngredients(models.Model):
name = models.CharField(max_length=128)
class Unit(models.Model):
name = models.CharField(unique=True, max_length=128)
description = models.TextField(blank=True, null=True)
def __str__(self):
return self.name
class Ingredient(models.Model):
name = models.CharField(unique=True, max_length=128)
def __str__(self):
return self.name
class RecipeIngredient(models.Model):
ingredient = models.ForeignKey(Ingredient, on_delete=models.PROTECT, null=True)
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
unit = models.CharField(max_length=128)
unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True)
amount = models.DecimalField(default=0, decimal_places=2, max_digits=16)
def __str__(self):
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.ingredient)
class Comment(models.Model):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
@@ -89,6 +133,9 @@ class Comment(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.text
class RecipeImport(models.Model):
name = models.CharField(max_length=128)
@@ -115,3 +162,20 @@ class RecipeBookEntry(models.Model):
def __str__(self):
return self.recipe.name
class MealPlan(models.Model):
BREAKFAST = 'BREAKFAST'
LUNCH = 'LUNCH'
DINNER = 'DINNER'
OTHER = 'OTHER'
MEAL_TYPES = ((BREAKFAST, _('Breakfast')), (LUNCH, _('Lunch')), (DINNER, _('Dinner')), (OTHER, _('Other')),)
user = models.ForeignKey(User, on_delete=models.CASCADE)
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
meal = models.CharField(choices=MEAL_TYPES, max_length=128, default=BREAKFAST)
note = models.TextField(blank=True)
date = models.DateField()
def __str__(self):
return self.meal + ' (' + str(self.date) + ') ' + str(self.recipe)

View File

@@ -0,0 +1,3 @@
$(document).ready(function () {
$('.selectwidget').select2();
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

12
cookbook/static/themes/darkly.min.css vendored Normal file

File diff suppressed because one or more lines are too long

12
cookbook/static/themes/flatly.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,722 @@
/*!
* Select2 Bootstrap Theme v0.1.0-beta.10 (https://select2.github.io/select2-bootstrap-theme)
* Copyright 2015-2017 Florian Kissling and contributors (https://github.com/select2/select2-bootstrap-theme/graphs/contributors)
* Licensed under MIT (https://github.com/select2/select2-bootstrap-theme/blob/master/LICENSE)
*/
.select2-container--bootstrap {
display: block;
/*------------------------------------* #COMMON STYLES
\*------------------------------------*/
/**
* Search field in the Select2 dropdown.
*/
/**
* No outline for all search fields - in the dropdown
* and inline in multi Select2s.
*/
/**
* Adjust Select2's choices hover and selected styles to match
* Bootstrap 3's default dropdown styles.
*
* @see http://getbootstrap.com/components/#dropdowns
*/
/**
* Clear the selection.
*/
/**
* Address disabled Select2 styles.
*
* @see https://select2.github.io/examples.html#disabled
* @see http://getbootstrap.com/css/#forms-control-disabled
*/
/*------------------------------------* #DROPDOWN
\*------------------------------------*/
/**
* Dropdown border color and box-shadow.
*/
/**
* Limit the dropdown height.
*/
/*------------------------------------* #SINGLE SELECT2
\*------------------------------------*/
/*------------------------------------* #MULTIPLE SELECT2
\*------------------------------------*/
/**
* Address Bootstrap control sizing classes
*
* 1. Reset Bootstrap defaults.
* 2. Adjust the dropdown arrow button icon position.
*
* @see http://getbootstrap.com/css/#forms-control-sizes
*/
/* 1 */
/*------------------------------------* #RTL SUPPORT
\*------------------------------------*/
}
.select2-container--bootstrap .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
color: #555555;
font-size: 14px;
outline: 0;
}
.select2-container--bootstrap .select2-selection.form-control {
border-radius: 4px;
}
.select2-container--bootstrap .select2-search--dropdown .select2-search__field {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
background-color: #fff;
border: 1px solid #ccc;
border-radius: 4px;
color: #555555;
font-size: 14px;
}
.select2-container--bootstrap .select2-search__field {
outline: 0;
/* Firefox 18- */
/**
* Firefox 19+
*
* @see http://stackoverflow.com/questions/24236240/color-for-styled-placeholder-text-is-muted-in-firefox
*/
}
.select2-container--bootstrap .select2-search__field::-webkit-input-placeholder {
color: #999;
}
.select2-container--bootstrap .select2-search__field:-moz-placeholder {
color: #999;
}
.select2-container--bootstrap .select2-search__field::-moz-placeholder {
color: #999;
opacity: 1;
}
.select2-container--bootstrap .select2-search__field:-ms-input-placeholder {
color: #999;
}
.select2-container--bootstrap .select2-results__option {
padding: 6px 12px;
color: #555555;
/**
* Disabled results.
*
* @see https://select2.github.io/examples.html#disabled-results
*/
/**
* Hover state.
*/
/**
* Selected state.
*/
}
.select2-container--bootstrap .select2-results__option[role=group] {
padding: 0;
}
.select2-container--bootstrap .select2-results__option[aria-disabled=true] {
color: #777777;
cursor: not-allowed;
}
.select2-container--bootstrap .select2-results__option[aria-selected=true] {
background-color: #f5f5f5;
color: #262626;
}
.select2-container--bootstrap .select2-results__option--highlighted[aria-selected] {
background-color: #337ab7;
color: #fff;
}
.select2-container--bootstrap .select2-results__option .select2-results__option {
padding: 6px 12px;
}
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__group {
padding-left: 0;
}
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option {
margin-left: -12px;
padding-left: 24px;
}
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -24px;
padding-left: 36px;
}
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -36px;
padding-left: 48px;
}
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -48px;
padding-left: 60px;
}
.select2-container--bootstrap .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option {
margin-left: -60px;
padding-left: 72px;
}
.select2-container--bootstrap .select2-results__group {
color: #777777;
display: block;
padding: 6px 12px;
font-size: 12px;
line-height: 1.42857143;
white-space: nowrap;
}
.select2-container--bootstrap.select2-container--focus .select2-selection, .select2-container--bootstrap.select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
-webkit-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-o-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-webkit-transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, -webkit-box-shadow ease-in-out 0.15s;
border-color: #66afe9;
}
.select2-container--bootstrap.select2-container--open {
/**
* Make the dropdown arrow point up while the dropdown is visible.
*/
/**
* Handle border radii of the container when the dropdown is showing.
*/
}
.select2-container--bootstrap.select2-container--open .select2-selection .select2-selection__arrow b {
border-color: transparent transparent #999 transparent;
border-width: 0 4px 4px 4px;
}
.select2-container--bootstrap.select2-container--open.select2-container--below .select2-selection {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-color: transparent;
}
.select2-container--bootstrap.select2-container--open.select2-container--above .select2-selection {
border-top-right-radius: 0;
border-top-left-radius: 0;
border-top-color: transparent;
}
.select2-container--bootstrap .select2-selection__clear {
color: #999;
cursor: pointer;
float: right;
font-weight: bold;
margin-right: 10px;
}
.select2-container--bootstrap .select2-selection__clear:hover {
color: #333;
}
.select2-container--bootstrap.select2-container--disabled .select2-selection {
border-color: #ccc;
-webkit-box-shadow: none;
box-shadow: none;
}
.select2-container--bootstrap.select2-container--disabled .select2-selection,
.select2-container--bootstrap.select2-container--disabled .select2-search__field {
cursor: not-allowed;
}
.select2-container--bootstrap.select2-container--disabled .select2-selection,
.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice {
background-color: #eeeeee;
}
.select2-container--bootstrap.select2-container--disabled .select2-selection__clear,
.select2-container--bootstrap.select2-container--disabled .select2-selection--multiple .select2-selection__choice__remove {
display: none;
}
.select2-container--bootstrap .select2-dropdown {
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
border-color: #66afe9;
overflow-x: hidden;
margin-top: -1px;
}
.select2-container--bootstrap .select2-dropdown--above {
-webkit-box-shadow: 0px -6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0px -6px 12px rgba(0, 0, 0, 0.175);
margin-top: 1px;
}
.select2-container--bootstrap .select2-results > .select2-results__options {
max-height: 200px;
overflow-y: auto;
}
.select2-container--bootstrap .select2-selection--single {
height: 34px;
line-height: 1.42857143;
padding: 6px 24px 6px 12px;
/**
* Adjust the single Select2's dropdown arrow button appearance.
*/
}
.select2-container--bootstrap .select2-selection--single .select2-selection__arrow {
position: absolute;
bottom: 0;
right: 12px;
top: 0;
width: 4px;
}
.select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
border-color: #999 transparent transparent transparent;
border-style: solid;
border-width: 4px 4px 0 4px;
height: 0;
left: 0;
margin-left: -4px;
margin-top: -2px;
position: absolute;
top: 50%;
width: 0;
}
.select2-container--bootstrap .select2-selection--single .select2-selection__rendered {
color: #555555;
padding: 0;
}
.select2-container--bootstrap .select2-selection--single .select2-selection__placeholder {
color: #999;
}
.select2-container--bootstrap .select2-selection--multiple {
min-height: 34px;
padding: 0;
height: auto;
/**
* Make Multi Select2's choices match Bootstrap 3's default button styles.
*/
/**
* Minus 2px borders.
*/
/**
* Clear the selection.
*/
}
.select2-container--bootstrap .select2-selection--multiple .select2-selection__rendered {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
display: block;
line-height: 1.42857143;
list-style: none;
margin: 0;
overflow: hidden;
padding: 0;
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.select2-container--bootstrap .select2-selection--multiple .select2-selection__placeholder {
color: #999;
float: left;
margin-top: 5px;
}
.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
color: #555555;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
cursor: default;
float: left;
margin: 5px 0 0 6px;
padding: 0 6px;
}
.select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
background: transparent;
padding: 0 12px;
height: 32px;
line-height: 1.42857143;
margin-top: 0;
min-width: 5em;
}
.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove {
color: #999;
cursor: pointer;
display: inline-block;
font-weight: bold;
margin-right: 3px;
}
.select2-container--bootstrap .select2-selection--multiple .select2-selection__choice__remove:hover {
color: #333;
}
.select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
margin-top: 6px;
}
.select2-container--bootstrap .select2-selection--single.input-sm,
.input-group-sm .select2-container--bootstrap .select2-selection--single,
.form-group-sm .select2-container--bootstrap .select2-selection--single {
border-radius: 3px;
font-size: 12px;
height: 30px;
line-height: 1.5;
padding: 5px 22px 5px 10px;
/* 2 */
}
.select2-container--bootstrap .select2-selection--single.input-sm .select2-selection__arrow b,
.input-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,
.form-group-sm .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
margin-left: -5px;
}
.select2-container--bootstrap .select2-selection--multiple.input-sm,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple {
min-height: 30px;
border-radius: 3px;
}
.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__choice,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
font-size: 12px;
line-height: 1.5;
margin: 4px 0 0 5px;
padding: 0 5px;
}
.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-search--inline .select2-search__field,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
padding: 0 10px;
font-size: 12px;
height: 28px;
line-height: 1.5;
}
.select2-container--bootstrap .select2-selection--multiple.input-sm .select2-selection__clear,
.input-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,
.form-group-sm .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
margin-top: 5px;
}
.select2-container--bootstrap .select2-selection--single.input-lg,
.input-group-lg .select2-container--bootstrap .select2-selection--single,
.form-group-lg .select2-container--bootstrap .select2-selection--single {
border-radius: 6px;
font-size: 18px;
height: 46px;
line-height: 1.3333333;
padding: 10px 31px 10px 16px;
/* 1 */
}
.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow,
.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow,
.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow {
width: 5px;
}
.select2-container--bootstrap .select2-selection--single.input-lg .select2-selection__arrow b,
.input-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b,
.form-group-lg .select2-container--bootstrap .select2-selection--single .select2-selection__arrow b {
border-width: 5px 5px 0 5px;
margin-left: -5px;
margin-left: -10px;
margin-top: -2.5px;
}
.select2-container--bootstrap .select2-selection--multiple.input-lg,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple {
min-height: 46px;
border-radius: 6px;
}
.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__choice,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__choice {
font-size: 18px;
line-height: 1.3333333;
border-radius: 4px;
margin: 9px 0 0 8px;
padding: 0 10px;
}
.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-search--inline .select2-search__field,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-search--inline .select2-search__field {
padding: 0 16px;
font-size: 18px;
height: 44px;
line-height: 1.3333333;
}
.select2-container--bootstrap .select2-selection--multiple.input-lg .select2-selection__clear,
.input-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear,
.form-group-lg .select2-container--bootstrap .select2-selection--multiple .select2-selection__clear {
margin-top: 10px;
}
.select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single {
/**
* Make the dropdown arrow point up while the dropdown is visible.
*/
}
.select2-container--bootstrap .select2-selection.input-lg.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #999 transparent;
border-width: 0 5px 5px 5px;
}
.input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single {
/**
* Make the dropdown arrow point up while the dropdown is visible.
*/
}
.input-group-lg .select2-container--bootstrap .select2-selection.select2-container--open .select2-selection--single .select2-selection__arrow b {
border-color: transparent transparent #999 transparent;
border-width: 0 5px 5px 5px;
}
.select2-container--bootstrap[dir="rtl"] {
/**
* Single Select2
*
* 1. Makes sure that .select2-selection__placeholder is positioned
* correctly.
*/
/**
* Multiple Select2
*/
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--single {
padding-left: 24px;
padding-right: 12px;
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__rendered {
padding-right: 0;
padding-left: 0;
text-align: right;
/* 1 */
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__clear {
float: left;
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__arrow {
left: 12px;
right: auto;
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--single .select2-selection__arrow b {
margin-left: 0;
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice,
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__placeholder,
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-search--inline {
float: right;
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice {
margin-left: 0;
margin-right: 6px;
}
.select2-container--bootstrap[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove {
margin-left: 2px;
margin-right: auto;
}
/*------------------------------------* #ADDITIONAL GOODIES
\*------------------------------------*/
/**
* Address Bootstrap's validation states
*
* If a Select2 widget parent has one of Bootstrap's validation state modifier
* classes, adjust Select2's border colors and focus states accordingly.
* You may apply said classes to the Select2 dropdown (body > .select2-container)
* via JavaScript match Bootstraps' to make its styles match.
*
* @see http://getbootstrap.com/css/#forms-control-validation
*/
.has-warning .select2-dropdown,
.has-warning .select2-selection {
border-color: #8a6d3b;
}
.has-warning .select2-container--focus .select2-selection,
.has-warning .select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #c0a16b;
border-color: #66512c;
}
.has-warning.select2-drop-active {
border-color: #66512c;
}
.has-warning.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #66512c;
}
.has-error .select2-dropdown,
.has-error .select2-selection {
border-color: #a94442;
}
.has-error .select2-container--focus .select2-selection,
.has-error .select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ce8483;
border-color: #843534;
}
.has-error.select2-drop-active {
border-color: #843534;
}
.has-error.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #843534;
}
.has-success .select2-dropdown,
.has-success .select2-selection {
border-color: #3c763d;
}
.has-success .select2-container--focus .select2-selection,
.has-success .select2-container--open .select2-selection {
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #67b168;
border-color: #2b542c;
}
.has-success.select2-drop-active {
border-color: #2b542c;
}
.has-success.select2-drop-active.select2-drop.select2-drop-above {
border-top-color: #2b542c;
}
/**
* Select2 widgets in Bootstrap Input Groups
*
* @see http://getbootstrap.com/components/#input-groups
* @see https://github.com/twbs/bootstrap/blob/master/less/input-groups.less
*/
/**
* Reset rounded corners
*/
.input-group > .select2-hidden-accessible:first-child + .select2-container--bootstrap > .selection > .select2-selection,
.input-group > .select2-hidden-accessible:first-child + .select2-container--bootstrap > .selection > .select2-selection.form-control {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
.input-group > .select2-hidden-accessible:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection,
.input-group > .select2-hidden-accessible:not(:first-child) + .select2-container--bootstrap:not(:last-child) > .selection > .select2-selection.form-control {
border-radius: 0;
}
.input-group > .select2-hidden-accessible:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection,
.input-group > .select2-hidden-accessible:not(:first-child):not(:last-child) + .select2-container--bootstrap:last-child > .selection > .select2-selection.form-control {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
.input-group > .select2-container--bootstrap {
display: table;
table-layout: fixed;
position: relative;
z-index: 2;
width: 100%;
margin-bottom: 0;
/**
* Adjust z-index like Bootstrap does to show the focus-box-shadow
* above appended buttons in .input-group and .form-group.
*/
/**
* Adjust alignment of Bootstrap buttons in Bootstrap Input Groups to address
* Multi Select2's height which - depending on how many elements have been selected -
* may grow taller than its initial size.
*
* @see http://getbootstrap.com/components/#input-groups
*/
}
.input-group > .select2-container--bootstrap > .selection > .select2-selection.form-control {
float: none;
}
.input-group > .select2-container--bootstrap.select2-container--open, .input-group > .select2-container--bootstrap.select2-container--focus {
z-index: 3;
}
.input-group > .select2-container--bootstrap,
.input-group > .select2-container--bootstrap .input-group-btn,
.input-group > .select2-container--bootstrap .input-group-btn .btn {
vertical-align: top;
}
/**
* Temporary fix for https://github.com/select2/select2-bootstrap-theme/issues/9
*
* Provides `!important` for certain properties of the class applied to the
* original `<select>` element to hide it.
*
* @see https://github.com/select2/select2/pull/3301
* @see https://github.com/fk/select2/commit/31830c7b32cb3d8e1b12d5b434dee40a6e753ada
*/
.form-control.select2-hidden-accessible {
position: absolute !important;
width: 1px !important;
}
/**
* Display override for inline forms
*/
@media (min-width: 768px) {
.form-inline .select2-container--bootstrap {
display: inline-block;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,6 @@
{% load static %}
{% load i18n %}
{% load theming_tags %}
<html>
<head>
@@ -18,11 +19,11 @@
<meta name="msapplication-TileImage" content="/mstile-144x144.png">
<!-- Bootstrap 4 -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
<link id="id_main_css" href="{% theme_url request %}" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.4.1.js"
integrity="sha256-WpOohJOqMqqyKL9FccASB9O0KwACQJpFTUBLTYOVvVU="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
crossorigin="anonymous"></script>
@@ -31,13 +32,18 @@
crossorigin="anonymous"></script>
<!-- Select2 for use with django autocomplete light -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/css/select2.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/select2@4.0.12/dist/js/select2.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/css/select2.min.css" rel="stylesheet"/>
<script src="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/select2.min.js"></script>
<!-- Bootstrap theme for select2 -->
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/select2-bootstrap-theme/0.1.0-beta.10/select2-bootstrap.css"
integrity="sha256-zFnNbsU+u3l0K+MaY92RvJI6AdAVAxK3/QrBApHvlH8=" crossorigin="anonymous"/>
<link rel="stylesheet"
href="{% static 'themes/select2-bootstrap-theme.css' %}"
crossorigin="anonymous"/>
<script type="text/javascript">
$.fn.select2.defaults.set("theme", "bootstrap");
</script>
@@ -60,7 +66,7 @@
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<nav class="navbar navbar-expand-lg navbar-dark bg-{% nav_color request %}" id="id_main_nav">
<!--<a class="navbar-brand" href="{% url 'index' %}">{% trans 'Cookbook' %}</a>-->
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
@@ -73,7 +79,12 @@
class="sr-only">(current)</span></a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'view_books' %}"><i class="fas fa-bookmark"></i> {% trans 'Books' %}</a>
<a class="nav-link" href="{% url 'view_books' %}"><i class="fas fa-bookmark"></i> {% trans 'Books' %}
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'view_plan' %}"><i class="fas fa-calendar"></i> {% trans 'Meal-Plan' %}
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown"
@@ -102,15 +113,24 @@
class="fas fa-history"></i> {% trans 'Import Log' %}</a>
<a class="dropdown-item" href="{% url 'data_stats' %}"><i
class="fas fa-chart-line"></i> {% trans 'Statistics' %}</a>
<a class="dropdown-item" href="{% url 'edit_ingredient' %}"><i
class="fas fa-balance-scale"></i> {% trans 'Units & Ingredients' %}</a>
</div>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield"></i> {% trans 'Admin' %}</a>
<a class="nav-link" href="{% url 'view_settings' %}"><i
class="fas fa-user-cog"></i> {% trans 'Settings' %}</a>
</li>
{% if user.is_superuser %}
<li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}"><i
class="fas fa-user-shield"></i> {% trans 'Admin' %}</a>
</li>
{% endif %}
<li class="nav-item">
{% if user.is_authenticated %}
<a class="nav-link" href="{% url 'logout' %}">{% trans 'Logout' %} {{ user.get_username }} <i
@@ -141,5 +161,8 @@
{% endblock %}
</div>
{% block script %}
{% endblock script %}
</body>
</html>

View File

@@ -2,20 +2,22 @@
{% load crispy_forms_tags %}
{% load i18n %}
{% load custom_tags %}
{% load theming_tags %}
{% load static %}
{% block title %}{% trans 'Edit Recipe' %}{% endblock %}
{% block extra_head %}
<script src="{% static 'tabulator/tabulator.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'tabulator/tabulator_bootstrap4.min.css' %}"/>
<link rel="stylesheet" href="{% tabulator_theme_url request %}"/>
{% endblock %}
{% block content %}
<h3>{% trans 'Edit Recipe' %}</h3>
<form action="." method="post" enctype="multipart/form-data">
<form action="." method="post" enctype="multipart/form-data" id="id_form">
{% csrf_token %}
{% for field in form %}
@@ -26,13 +28,21 @@
<label>{% trans 'Ingredients' %}</label>
<div id="ingredients-table"></div>
<br>
<div class="table-controls">
<button class="btn" id="new_empty" type="button"><i class="fas fa-plus-circle"></i></button>
<div class="table-controls" style="text-align: center">
<button class="btn btn-success" id="new_empty" type="button" style="min-width: 20vw"><i
class="fas fa-plus-circle"></i></button>
<button type="button" class="btn btn-secondary" data-container="body" data-toggle="popover"
data-placement="right" data-html="true" data-trigger="focus"
data-content="{% trans 'Use <b>Ctrl</b>+<b>Space</b> to insert new Ingredient!<br/>You can also save the recipe using <b>Ctrl</b>+<b>Shift</b>+<b>S</b>.' %}">
<i class="fas fa-question"></i>
</button>
<br/>
<br/>
</div>
{% endif %}
{% endfor %}
<input type="hidden" id="ingredients_data_input" name="ingredients">
<hr>
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<a href="{% url 'redirect_delete' form.instance|get_class|lower form.instance.pk %}"
@@ -47,6 +57,57 @@
</form>
<script>
$(function () {
$('[data-toggle="popover"]').popover()
});
$('.popover-dismiss').popover({
trigger: 'focus'
});
let select2UnitEditor = function (cell, onRendered, success, cancel, editorParams) {
return select2Editor(cell, onRendered, success, cancel, editorParams, '{% url 'dal_unit' %}')
};
let select2IngredientEditor = function (cell, onRendered, success, cancel, editorParams) {
return select2Editor(cell, onRendered, success, cancel, editorParams, '{% url 'dal_ingredient' %}')
};
let select2Editor = function (cell, onRendered, success, cancel, editorParams, url) {
let editor = document.createElement("select");
editor.setAttribute("class", "form-control");
editor.setAttribute("style", "height: 100%; color: #00ff00");
onRendered(function () {
let select_2 = $(editor);
select_2.select2({
tags: true,
ajax: {
url: url,
dataType: 'json'
}
});
select_2.select2('open');
select_2.on('select2:select', function (e) {
success(e.params.data.text);
});
select_2.on('select2:close', function (e) {
if (e.target.textContent === "") {
cancel();
}
});
});
//add editor to cell
return editor;
};
function selectText(node) {
if (document.body.createTextRange) {
@@ -69,15 +130,15 @@
$(document).ready(function () {
$('#id_keywords').select2();
var ingredients = {{ ingredients|safe }}
let ingredients = {{ ingredients|safe }}
ingredients.forEach(function (cur, i) {
cur.delete = false
})
});
var data = ingredients
let data = ingredients;
var table = new Tabulator("#ingredients-table", {
let table = new Tabulator("#ingredients-table", {
index: "id",
layout: "fitColumns",
reactiveData: true,
@@ -85,26 +146,44 @@
movableRows: true,
headerSort: false,
columns: [
{ title: "<i class='fas fa-sort'></i>", rowHandle:true, formatter:"handle", headerSort:false, frozen:true, width:36, minWidth:36},
{
title: "<i class='fas fa-sort'></i>",
rowHandle: true,
formatter: "handle",
headerSort: false,
frozen: true,
width: 36,
minWidth: 36
},
{
title: "{% trans 'Ingredient' %}",
field: "name",
field: "ingredient__name",
validator: "required",
editor: "input"
editor: select2IngredientEditor
},
{title: "{% trans 'Amount' %}", field: "amount", validator: "required", editor: "input"},
{title: "{% trans 'Unit' %}", field: "unit", validator: "required", editor: "input"},
{
title: "{% trans 'Delete' %}",
field: "delete",
title: "{% trans 'Unit' %}",
field: "unit__name",
validator: "required",
editor: select2UnitEditor
},
{
formatter: function (cell, formatterParams) {
return "<span style='color:red'><i class=\"fas fa-trash-alt\"></i></span>"
},
align: "center",
editor: true,
formatter: "tickCross"
title: "{% trans 'Delete' %}",
headerSort: false,
cellClick: function (e, cell) {
if (confirm('{% trans 'Are you sure that you want to delete this ingredient?' %}'))
cell.getRow().delete();
}
},
{title: "id", field: "id", visible: false}
],
dataEdited: function (data) {
$('#ingredients_data_input').val(JSON.stringify(data))
$('#id_ingredients').val(JSON.stringify(data))
data.forEach(function (cur, i) {
if (cur.delete) {
@@ -113,24 +192,36 @@
})
},
cellClick: function (e, cell) {
input = cell.getElement().childNodes[0]
input.focus()
input.select()
if (cell._cell.column.definition.editor === "input") {
input = cell.getElement().childNodes[0];
input.focus();
input.select();
}
},
});
// load initial value
$('#ingredients_data_input').val(JSON.stringify(data))
$('#id_ingredients').val(JSON.stringify(data))
document.getElementById("new_empty").addEventListener("click", function () {
function addIngredientRow() {
data.push({
name: "{% trans 'Ingredient' %}",
ingredient__name: "{% trans 'Ingredient' %}",
amount: "100",
unit: "g",
unit__name: "g",
id: Math.floor(Math.random() * 10000000),
delete: false,
});
});
}
document.onkeyup = function (e) {
if (e.shiftKey && e.ctrlKey && (e.which === 83 || e.keyCode === 83)) {
$('#id_form').submit()
} else if (e.ctrlKey && (e.which === 83 || e.keyCode === 32)) {
addIngredientRow();
}
};
document.getElementById("new_empty").addEventListener("click", addIngredientRow);
});
</script>

View File

@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Cookbook" %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<h2><i class="fas fa-shopping-cart"></i> {% trans 'Edit Ingredients' %}</h2>
{% blocktrans %}
The following form can be used if, accidentally, two (or more) units or ingredients where created that should be
the same.
It merges two units or ingredients and updates all recipes using them.
{% endblocktrans %}
<br/>
<br/>
<h4>{% trans 'Units' %}</h4>
<form action="{% url 'edit_ingredient' %}" method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-danger" type="submit"
onclick="confirm('{% trans 'Are you sure that you want to merge these two units ?' %}')"><i
class="fas fa-sync-alt"></i> {% trans 'Merge' %}</button>
</form>
{% endblock %}

View File

@@ -22,7 +22,7 @@
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
<a href="{% url 'redirect_delete' form.instance|get_class|lower form.instance.pk %}"
class="btn btn-danger">{% trans 'Delete' %}</a>
class="btn btn-danger"><i class="fas fa-trash-alt"></i> {% trans 'Delete' %}</a>
{% if view_url %}
<a href="{{ view_url }}" class="btn btn-info"><i class="far fa-eye"></i> {% trans 'View' %}</a>
{% endif %}

View File

@@ -23,4 +23,14 @@
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
<script type="text/javascript">
{% if default_recipe %}
$(document).ready(function () {
$('#id_recipe').val({{ default_recipe.pk }}).trigger('change');
});
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans 'Meal-Plan' %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% endblock %}
{% block content %}
<h3>
{% trans 'Meal-Plan' %} <a href="{% url 'new_plan' %}"><i class="fas fa-plus-circle"></i></a>
</h3>
<div class="row">
<div class="col-md-12" style="text-align: center">
<form action="{% url 'view_plan' %}" method="post">
{% csrf_token %}
<label>{% trans 'Week' %}
<div class="input-group">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary" id="btn_prev"
onclick="$('#id_week').val('{{ surrounding_weeks.prev }}'); document.forms[0].submit()">
<i class="fas fa-arrow-left"></i>
</button>
</div>
<input name="week" id="id_week" class="form-control" type="week"
onchange="document.forms[0].submit()" value="{{ js_week }}">
<div class="input-group-append">
<button class="btn btn-outline-secondary" id="btn_next"
onclick="$('#id_week').val('{{ surrounding_weeks.next }}'); document.forms[0].submit()">
<i class="fas fa-arrow-right"></i>
</button>
</div>
</div>
</label>
</form>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 table-responsive">
<table class="table table-bordered">
<tr style="text-align: center">
{% for d in days %}
<th>{{ d | date:"l" }}<br/>{{ d }}</th>
{% endfor %}
</tr>
{% for plan_key, plan_value in plan.items %}
<tr>
<td colspan="7" style="text-align: center"><h5>{{ plan_value.type_name }}</h5></td>
</tr>
<tr>
{% for day_key, days_value in plan_value.days.items %}
<td>
{% for mp in days_value %}
<a href="{% url 'edit_plan' mp.pk %}"><i class="fas fa-edit"></i></a>
<a href="#" onclick="openRecipe({{ mp.recipe.id }})">{{ mp.recipe.name }}</a><br/>
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</table>
</div>
</div>
{% include 'include/recipe_open_modal.html' %}
{% endblock %}

View File

@@ -20,6 +20,10 @@
<div class="col col-md-3 d-print-none" style="text-align: right">
<button class="btn btn-success" onclick="$('#bookmarkModal').modal({'show':true})"><i
class="fas fa-bookmark"></i></button>
<a class="btn btn-warning" href="{% url 'view_shopping' %}?r={{ recipe.pk }}"><i
class="fas fa-shopping-cart"></i></a>
<a class="btn btn-info" href="{% url 'new_plan' %}?recipe={{ recipe.pk }}"><i
class="fas fa-calendar"></i></a>
</div>
</div>
@@ -93,7 +97,7 @@
</div>
</td>
<td style="font-size: large">{{ i.name }}</td>
<td style="font-size: large">{{ i.ingredient.name }}</td>
</tr>
{% endfor %}
</table>
@@ -113,14 +117,17 @@
</div>
{% endif %}
</div>
{% if recipe.ingredients or recipe.image %}
{% if ingredients or recipe.image %}
<br/>
<br/>
{% endif %}
{% if recipe.instructions %}
{{ recipe.instructions | markdown | safe }}
{% endif %}
<div style="font-size: large">
{% if recipe.instructions %}
{{ recipe.instructions | markdown | safe }}
{% endif %}
</div>
{% if recipe.storage %}
<a href='#' onClick='openRecipe({{ recipe.id }}, true)' class="d-print-none">{% trans 'View external recipe' %}

View File

@@ -41,7 +41,7 @@
{% endif %}
</div>
<input type="submit" class="btn btn-primary" value="login"/>
<input type="submit" class="btn btn-primary" value="{% trans 'Login' %}"/>
<input type="hidden" name="next" value="{{ next }}"/>
</div>
</form>

View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
{% load static %}
{% block title %}{% trans 'Settings' %}{% endblock %}
{% block content %}
<h3>
{% trans 'Settings' %}
</h3>
<br/>
<br/>
<h4><i class="fas fa-language"></i> {% trans 'Language' %}</h4>
<div class="row">
<div class="col-md-12">
<form action="{% url 'set_language' %}" method="post">{% csrf_token %}
<input class="form-control" name="next" type="hidden" value="{{ redirect_to }}">
<select name="language" class="form-control">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
{% for language in languages %}
<option value="{{ language.code }}"{% if language.code == LANGUAGE_CODE %} selected{% endif %}>
{{ language.name_local }} ({{ language.code }})
</option>
{% endfor %}
</select>
<br/>
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
</div>
</div>
<br/>
<br/>
<h4><i class="fas fa-palette"></i>{% trans 'Style' %}</h4>
<form action="." method="post">
{% csrf_token %}
{{ form|crispy }}
<button class="btn btn-success" type="submit"><i class="fas fa-save"></i> {% trans 'Save' %}</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load django_tables2 %}
{% load crispy_forms_tags %}
{% load static %}
{% load i18n %}
{% block title %}{% trans "Cookbook" %}{% endblock %}
{% block extra_head %}
{{ form.media }}
{% 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/>
<button class="btn btn-success" onclick="copy()"><i class="far fa-copy"></i></button>
<div class="row">
<div class="col col-md-12">
<!--// @formatter:off-->
<textarea id="id_list" style="height: 50vh" class="form-control">{% for i in ingredients %}- [ ] {{ i.amount.normalize }} {{ i.unit }} {{ i.ingredient.name }}&#10;{% endfor %}</textarea>
<!--// @formatter:on-->
</div>
</div>
<script type="text/javascript">
function copy() {
let list = $('#id_list');
list.select();
document.execCommand("copy");
}
</script>
{% endblock %}

View File

@@ -1,5 +1,7 @@
from django import template
import markdown as md
import bleach
from bleach_whitelist import markdown_tags, markdown_attrs
register = template.Library()
@@ -11,4 +13,7 @@ def get_class(value):
@register.filter()
def markdown(value):
return md.markdown(value, extensions=['markdown.extensions.fenced_code'])
return bleach.clean(md.markdown(value, extensions=['markdown.extensions.fenced_code']), markdown_tags, markdown_attrs)

View File

@@ -0,0 +1,48 @@
from django import template
from django.templatetags.static import static
from cookbook.models import UserPreference
register = template.Library()
@register.simple_tag
def theme_url(request):
try:
themes = {
UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css',
UserPreference.FLATLY: 'themes/flatly.min.css',
UserPreference.DARKLY: 'themes/darkly.min.css',
UserPreference.SUPERHERO: 'themes/superhero.min.css',
}
if request.user.userpreference.theme in themes:
return static(themes[request.user.userpreference.theme])
else:
raise AttributeError
except AttributeError:
return static('themes/flatly.min.css')
@register.simple_tag
def nav_color(request):
try:
return request.user.userpreference.nav_color
except AttributeError:
return 'primary'
@register.simple_tag
def tabulator_theme_url(request):
try:
themes = {
UserPreference.BOOTSTRAP: 'tabulator/tabulator_bootstrap4.min.css',
UserPreference.FLATLY: 'tabulator/tabulator_bootstrap4.min.css',
UserPreference.DARKLY: 'tabulator/tabulator_site.min.css',
UserPreference.SUPERHERO: 'tabulator/tabulator_site.min.css',
}
if request.user.userpreference.theme in themes:
return static(themes[request.user.userpreference.theme])
else:
raise AttributeError
except AttributeError:
return static('tabulator/tabulator_bootstrap4.min.css')

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

View File

@@ -0,0 +1,73 @@
from django.contrib import auth
from django.contrib.auth.models import User
from django.test import TestCase, Client
from django.urls import reverse
from cookbook.models import Recipe, RecipeIngredient
class TestViews(TestCase):
def setUp(self):
self.client = Client()
self.anonymous_client = Client()
self.client.force_login(User.objects.get_or_create(username='test')[0])
user = auth.get_user(self.client)
self.assertTrue(user.is_authenticated)
def test_index(self):
r = self.client.get(reverse('index'))
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(reverse('index'))
self.assertEqual(r.status_code, 200)
def test_books(self):
url = reverse('view_books')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_plan(self):
url = reverse('view_plan')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_shopping(self):
url = reverse('view_shopping')
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
def test_internal_recipe_update(self):
recipe = Recipe.objects.create(
name='Test',
created_by=auth.get_user(self.client)
)
url = reverse('edit_internal_recipe', args=[recipe.pk])
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
r = self.anonymous_client.get(url)
self.assertEqual(r.status_code, 302)
r = self.client.post(url, {'name': 'Changed', 'working_time': 15, 'waiting_time': 15, 'ingredients': '[]'})
self.assertEqual(r.status_code, 200)
recipe = Recipe.objects.get(pk=recipe.pk)
self.assertEqual('Changed', recipe.name)
r = self.client.post(url,
{'name': 'Changed', 'working_time': 15, 'waiting_time': 15,
'ingredients': '[{"ingredient__name":"Tomato","unit__name":"g","amount":100,"delete":false},{"ingredient__name":"Egg","unit__name":"Piece","amount":2,"delete":false}]'})
self.assertEqual(r.status_code, 200)
self.assertEqual(2, RecipeIngredient.objects.filter(recipe=recipe).count())

View File

@@ -6,7 +6,10 @@ from cookbook.helper import dal
urlpatterns = [
path('', views.index, name='index'),
path('books', views.books, name='view_books'),
path('books/', views.books, name='view_books'),
path('plan/', views.meal_plan, name='view_plan'),
path('shopping/', views.shopping_list, name='view_shopping'),
path('settings/', views.settings, name='view_settings'),
path('view/recipe/<int:pk>', views.recipe_view, name='view_recipe'),
@@ -15,6 +18,7 @@ urlpatterns = [
path('new/keyword/', new.KeywordCreate.as_view(), name='new_keyword'),
path('new/storage/', new.StorageCreate.as_view(), name='new_storage'),
path('new/book/', new.RecipeBookCreate.as_view(), name='new_book'),
path('new/plan/', new.MealPlanCreate.as_view(), name='new_plan'),
path('list/keyword', lists.keyword, name='list_keyword'),
path('list/import_log', lists.sync_log, name='list_import_log'),
@@ -34,6 +38,8 @@ urlpatterns = [
path('edit/storage/<int:pk>/', edit.edit_storage, name='edit_storage'),
path('edit/comment/<int:pk>/', edit.CommentUpdate.as_view(), name='edit_comment'),
path('edit/recipe-book/<int:pk>/', edit.RecipeBookUpdate.as_view(), name='edit_recipe_book'),
path('edit/plan/<int:pk>/', edit.MealPlanUpdate.as_view(), name='edit_plan'),
path('edit/ingredient/', edit.edit_ingredients, name='edit_ingredient'),
path('redirect/delete/<slug:name>/<int:pk>/', edit.delete_redirect, name='redirect_delete'),
@@ -46,6 +52,7 @@ urlpatterns = [
path('delete/comment/<int:pk>/', edit.CommentDelete.as_view(), name='delete_comment'),
path('delete/recipe-book/<int:pk>/', edit.RecipeBookDelete.as_view(), name='delete_recipe_book'),
path('delete/recipe-book-entry/<int:pk>/', edit.RecipeBookEntryDelete.as_view(), name='delete_recipe_book_entry'),
path('delete/plan/<int:pk>/', edit.MealPlanDelete.as_view(), name='delete_plan'),
path('data/sync', data.sync, name='data_sync'), # TODO move to generic "new" view
path('data/batch/edit', data.batch_edit, name='data_batch_edit'),
@@ -56,9 +63,9 @@ urlpatterns = [
path('api/get_file_link/<int:recipe_id>/', api.get_file_link, name='api_get_file_link'),
path('api/get_external_file_link/<int:recipe_id>/', api.get_external_file_link, name='api_get_external_file_link'),
path('api/sync_all/', api.sync_all, name='api_sync'),
path('dal/keyword/', dal.KeywordAutocomplete.as_view(), name='dal_keyword'),
path('dal/ingredient/', dal.IngredientsAutocomplete.as_view(), name='dal_ingredient'),
path('dal/unit/', dal.UnitAutocomplete.as_view(), name='dal_unit'),
]

View File

@@ -7,15 +7,17 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.files import File
from django.db.models import Value, CharField
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, get_object_or_404, render
from django.urls import reverse_lazy, reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext as _, ngettext
from django.views.generic import UpdateView, DeleteView
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, InternalRecipeForm, CommentForm
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeIngredients, RecipeBook, \
RecipeBookEntry
from cookbook.forms import ExternalRecipeForm, KeywordForm, StorageForm, SyncForm, InternalRecipeForm, CommentForm, \
MealPlanForm, UnitMergeForm
from cookbook.models import Recipe, Sync, Keyword, RecipeImport, Storage, Comment, RecipeIngredient, RecipeBook, \
RecipeBookEntry, MealPlan, Unit, Ingredient
from cookbook.provider.dropbox import Dropbox
from cookbook.provider.nextcloud import Nextcloud
@@ -41,9 +43,12 @@ def convert_recipe(request, pk):
@login_required
def internal_recipe_update(request, pk):
recipe_instance = get_object_or_404(Recipe, pk=pk)
status = 200
if request.method == "POST":
form = InternalRecipeForm(request.POST, request.FILES)
form.instance = recipe_instance
if form.is_valid():
recipe = recipe_instance
recipe.name = form.cleaned_data['name']
@@ -69,34 +74,50 @@ def internal_recipe_update(request, pk):
recipe.save()
form_ingredients = json.loads(form.data['ingredients'])
RecipeIngredients.objects.filter(recipe=recipe_instance).delete()
form_ingredients = json.loads(form.cleaned_data['ingredients'])
RecipeIngredient.objects.filter(recipe=recipe_instance).delete()
for i in form_ingredients:
ingredient = RecipeIngredients()
ingredient.recipe = recipe_instance
ingredient.name = i['name']
if isinstance(i['amount'], str):
ingredient.amount = float(i['amount'].replace(',', '.'))
recipe_ingredient = RecipeIngredient()
recipe_ingredient.recipe = recipe_instance
if Ingredient.objects.filter(name=i['ingredient__name']).exists():
recipe_ingredient.ingredient = Ingredient.objects.get(name=i['ingredient__name'])
else:
ingredient.amount = i['amount']
ingredient.unit = i['unit']
ingredient.save()
ingredient = Ingredient()
ingredient.name = i['ingredient__name']
ingredient.save()
recipe_ingredient.ingredient = ingredient
if isinstance(i['amount'], str):
recipe_ingredient.amount = float(i['amount'].replace(',', '.'))
else:
recipe_ingredient.amount = i['amount']
if Unit.objects.filter(name=i['unit__name']).exists():
recipe_ingredient.unit = Unit.objects.get(name=i['unit__name'])
else:
unit = Unit()
unit.name = i['unit__name']
unit.save()
recipe_ingredient.unit = unit
recipe_ingredient.save()
recipe.keywords.set(form.cleaned_data['keywords'])
messages.add_message(request, messages.SUCCESS, _('Recipe saved!'))
return HttpResponseRedirect(reverse('edit_internal_recipe', args=[pk]))
else:
messages.add_message(request, messages.ERROR, _('There was an error importing this recipe!'))
messages.add_message(request, messages.ERROR, _('There was an error saving this recipe!'))
status = 403
else:
form = InternalRecipeForm(instance=recipe_instance)
ingredients = RecipeIngredients.objects.filter(recipe=recipe_instance)
ingredients = RecipeIngredient.objects.select_related('unit__name', 'ingredient__name').filter(recipe=recipe_instance).values('ingredient__name', 'unit__name', 'amount')
return render(request, 'forms/edit_internal_recipe.html',
{'form': form, 'ingredients': json.dumps(list(ingredients.values())),
'view_url': reverse('view_recipe', args=[pk])})
{'form': form, 'ingredients': json.dumps(list(ingredients)),
'view_url': reverse('view_recipe', args=[pk])}, status=status)
class SyncUpdate(LoginRequiredMixin, UpdateView):
@@ -225,6 +246,22 @@ class RecipeBookUpdate(LoginRequiredMixin, UpdateView):
return context
class MealPlanUpdate(LoginRequiredMixin, UpdateView):
template_name = "generic/edit_template.html"
model = MealPlan
form_class = MealPlanForm
# TODO add msg box
def get_success_url(self):
return reverse('view_plan')
def get_context_data(self, **kwargs):
context = super(MealPlanUpdate, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
return context
class RecipeUpdate(LoginRequiredMixin, UpdateView):
model = Recipe
form_class = ExternalRecipeForm
@@ -262,8 +299,29 @@ class RecipeUpdate(LoginRequiredMixin, UpdateView):
return context
# Generic Delete views
@login_required
def edit_ingredients(request):
if request.method == "POST":
form = UnitMergeForm(request.POST, prefix=UnitMergeForm.prefix)
if form.is_valid():
new_unit = form.cleaned_data['new_unit']
old_unit = form.cleaned_data['old_unit']
ingredients = RecipeIngredient.objects.filter(unit=old_unit).all()
for i in ingredients:
i.unit = new_unit
i.save()
old_unit.delete()
messages.add_message(request, messages.SUCCESS, _('Units merged!'))
else:
messages.add_message(request, messages.WARNING, _('There was an error in your form.'))
else:
form = UnitMergeForm()
return render(request, 'forms/ingredients.html', {'form': form})
# Generic Delete views
def delete_redirect(request, name, pk):
return redirect(('delete_' + name), pk)
@@ -374,3 +432,14 @@ class RecipeBookEntryDelete(LoginRequiredMixin, DeleteView):
context = super(RecipeBookEntryDelete, self).get_context_data(**kwargs)
context['title'] = _("Bookmarks")
return context
class MealPlanDelete(LoginRequiredMixin, DeleteView):
template_name = "generic/delete_template.html"
model = MealPlan
success_url = reverse_lazy('view_plan')
def get_context_data(self, **kwargs):
context = super(MealPlanDelete, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
return context

View File

@@ -1,3 +1,5 @@
import re
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -8,8 +10,8 @@ from django.utils.translation import gettext as _
from django.views.generic import CreateView
from cookbook.forms import ImportRecipeForm, RecipeImport, KeywordForm, Storage, StorageForm, InternalRecipeForm, \
RecipeBookForm
from cookbook.models import Keyword, Recipe, RecipeBook
RecipeBookForm, MealPlanForm
from cookbook.models import Keyword, Recipe, RecipeBook, MealPlan
class RecipeCreate(LoginRequiredMixin, CreateView):
@@ -109,3 +111,28 @@ class RecipeBookCreate(LoginRequiredMixin, CreateView):
context = super(RecipeBookCreate, self).get_context_data(**kwargs)
context['title'] = _("Recipe Book")
return context
class MealPlanCreate(LoginRequiredMixin, CreateView):
template_name = "generic/new_template.html"
model = MealPlan
form_class = MealPlanForm
success_url = reverse_lazy('view_plan')
def form_valid(self, form):
obj = form.save(commit=False)
obj.user = self.request.user
obj.save()
return HttpResponseRedirect(reverse('view_plan'))
def get_context_data(self, **kwargs):
context = super(MealPlanCreate, self).get_context_data(**kwargs)
context['title'] = _("Meal-Plan")
recipe = self.request.GET.get('recipe')
if recipe:
if re.match(r'^([0-9])+$', recipe):
if Recipe.objects.filter(pk=int(recipe)).exists():
context['default_recipe'] = Recipe.objects.get(pk=int(recipe))
return context

View File

@@ -1,8 +1,10 @@
import copy
import re
from datetime import datetime, timedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django_tables2 import RequestConfig
from django.utils.translation import gettext as _
@@ -26,7 +28,7 @@ def index(request):
@login_required
def recipe_view(request, pk):
recipe = get_object_or_404(Recipe, pk=pk)
ingredients = RecipeIngredients.objects.filter(recipe=recipe)
ingredients = RecipeIngredient.objects.filter(recipe=recipe)
comments = Comment.objects.filter(recipe=recipe)
if request.method == "POST":
@@ -69,3 +71,97 @@ def books(request):
book_list.append({'book': b, 'recipes': RecipeBookEntry.objects.filter(book=b).all()})
return render(request, 'books.html', {'book_list': book_list})
def get_start_end_from_week(p_year, p_week):
first_day_of_week = datetime.strptime(f'{p_year}-W{int(p_week) - 1}-1', "%Y-W%W-%w").date()
last_day_of_week = first_day_of_week + timedelta(days=6.9)
return first_day_of_week, last_day_of_week
def get_days_from_week(start, end):
delta = end - start
days = []
for i in range(delta.days + 1):
days.append(start + timedelta(days=i))
return days
@login_required()
def meal_plan(request):
js_week = datetime.now().strftime("%Y-W%V")
if request.method == "POST":
js_week = request.POST['week']
year, week = js_week.split('-')
first_day, last_day = get_start_end_from_week(year, week.replace('W', ''))
surrounding_weeks = {'next': (last_day + timedelta(3)).strftime("%Y-W%V"), 'prev': (first_day - timedelta(3)).strftime("%Y-W%V")}
days = get_days_from_week(first_day, last_day)
days_dict = {}
for d in days:
days_dict[d] = []
plan = {}
for t in MealPlan.MEAL_TYPES:
plan[t[0]] = {'type_name': t[1], 'days': copy.deepcopy(days_dict)}
for d in days:
plan_day = MealPlan.objects.filter(date=d).all()
for p in plan_day:
plan[p.meal]['days'][d].append(p)
return render(request, 'meal_plan.html', {'js_week': js_week, 'plan': plan, 'days': days, 'surrounding_weeks': surrounding_weeks})
@login_required
def shopping_list(request):
if request.method == "POST":
form = RecipeForm(request.POST)
if form.is_valid():
recipes = form.cleaned_data['recipe']
else:
recipes = []
else:
raw_list = request.GET.getlist('r')
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))
form = RecipeForm(initial={'recipe': recipes})
ingredients = []
for r in recipes:
for i in RecipeIngredient.objects.filter(recipe=r).all():
ingredients.append(i)
return render(request, 'shopping_list.html', {'ingredients': ingredients, 'recipes': recipes, 'form': form})
@login_required
def settings(request):
try:
up = request.user.userpreference
except UserPreference.DoesNotExist:
up = None
if request.method == "POST":
form = UserPreferenceForm(request.POST)
if form.is_valid():
if not up:
up = UserPreference(user=request.user)
up.theme = form.cleaned_data['theme']
up.nav_color = form.cleaned_data['nav_color']
up.save()
if up:
form = UserPreferenceForm(instance=up)
else:
form = UserPreferenceForm()
return render(request, 'settings.html', {'form': form})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
preview.xcf Normal file

Binary file not shown.

View File

@@ -63,6 +63,7 @@ MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
@@ -71,8 +72,7 @@ ROOT_URLCONF = 'recipes.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')]
,
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -122,7 +122,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'de'
LANGUAGE_CODE = 'en'
TIME_ZONE = 'Europe/Berlin'

View File

@@ -22,6 +22,7 @@ urlpatterns = [
path('', include('cookbook.urls')),
path('admin/', admin.site.urls),
path('accounts/', include('django.contrib.auth.urls')),
path('i18n/', include('django.conf.urls.i18n')),
]
if settings.DEBUG:

View File

@@ -7,12 +7,14 @@ djangorestframework
django-autocomplete-light
django-emoji-picker
django-cleanup
bleach
bleach-whitelist
six
requests
markdown
simplejson
lxml
webdavclient3
python-dotenv==0.10.3
python-dotenv
psycopg2-binary
gunicorn==19.7.1
gunicorn